Compare commits

..
2009 changed files with 13278 additions and 223866 deletions

No files matched your search

-1
View File
@@ -1 +0,0 @@
github: mvschwarz
+2 -40
View File
@@ -3,12 +3,9 @@ name: Tests
# Builds, type-checks and runs every test suite on a clean GitHub runner, and records the file
# list each published package would contain. Each suite is its own job so one failure does not
# hide the others. No provider credentials or operator configuration are supplied.
# Package suites and the Linux seat-environment check deny external networking;
# scenario runtime has no external route.
# Package suites deny external networking; scenario runtime has no external route.
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
@@ -17,7 +14,7 @@ permissions:
contents: read
concurrency:
group: tests-${{ github.event_name }}-${{ github.ref }}
group: tests-${{ github.ref }}
cancel-in-progress: true
jobs:
@@ -77,42 +74,7 @@ jobs:
- run: npm ci
- run: npm run test:repo
linux-seat-environment:
if: ${{ github.event_name != 'push' }}
# This integration test reads /proc/<pid>/environ and is skipped on macOS.
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install the real terminal and staging tools
run: |
sudo apt-get update
sudo apt-get install --yes tmux rsync
- run: npm ci
- run: npm run build
- name: Check seat environments without ambient credentials or external network
run: |
set -euo pipefail
suite_root=$(mktemp -d "$RUNNER_TEMP/opr-seat-env.XXXXXX")
mkdir -p "$suite_root/home"
# Only loopback exists in this namespace. Drop back to the runner user
# before the CLI, private daemon and private tmux server are started.
sudo unshare --net -- /bin/bash -s -- "$(id -un)" "$suite_root" "$PATH" <<'SUITE'
set -euo pipefail
ip link set lo up
exec runuser -u "$1" -- env -i HOME="$2/home" PATH="$3" TMPDIR="$2" TMUX_TMPDIR="$2" \
CI=true TERM=xterm-256color \
npm run test -w packages/daemon -- test/seat-env-parent-session.integration.test.ts --maxWorkers=1 --reporter=verbose
SUITE
package-tests:
if: ${{ github.event_name != 'push' }}
# macOS, the platform OpenRig runs on: several daemon and cli tests read macOS ps columns,
# file-inode behaviour and release tags.
runs-on: macos-latest
+14 -14
View File
@@ -211,16 +211,17 @@ Skills ship from three copies in this repository (the "edges"), listed in
Each skill in the layout file lists which edges carry it. At this commit, 33 skills are in `spec`
and `canonical`, 17 only in `plugin`, and 2 in all three.
- Each skill has one editable home: its `spec` copy when it has a `spec` edge, otherwise its
`plugin` copy. Its other edges are generated from that home.
- `npm run mirror-skills:check` (part of `npm test`) hashes every file in each edge and compares the
hashes with `scripts/skill-edge-digests.generated.json`. It also fails on a `SKILL.md` the layout
does not list, on a generated copy that differs from its home, and on a home holding text the
public copies would drop.
- The full apply, `npm run mirror-skills`, regenerates every generated copy from its home and
refreshes the digests. It reads nothing outside the repository and never writes to a home.
- **To fix a skill:** edit its home, run `npm run mirror-skills`, then run
`npm run mirror-skills:check`.
does not list.
- The full apply, `npm run mirror-skills`, regenerates all three edges from the maintainers' skill
source, which lives outside this repository. It needs `OPENRIG_SKILL_CANON_ROOT` and three
authority-file environment variables, so an outside contributor cannot run it.
- **To fix a skill:** edit the file in every edge that carries it (same bytes), run
`node scripts/regen-edge-digests.mjs` to refresh the hashes from disk, then run
`npm run mirror-skills:check`. Say in the pull request that you edited mirrored skill files: the
next full apply regenerates these copies from the maintainers' source, so the change has to land
there too.
- **To add a skill:** open an issue first. Its layout entry is generated from files that are not
in this repository.
- Every shipped skill also becomes a context pack at package time (next recipe).
@@ -290,10 +291,9 @@ packages.
package, and the daemon copies its Markdown files to `$OPENRIG_HOME/reference/` when it
starts. Files in `docs/as-built/` carry a `last-verified-against-source` commit in their
frontmatter.
- **Internal-leak guard** (`scripts/internal-leak-scanner.mjs`). Its term lists come from an
optional local file that `OPENRIG_LEAK_GUARD_RULES` names (`scripts/leak-guard-rules.mjs`);
without one the term checks are skipped and every other check runs, and the release-time
substance gate requires it. It runs in `generate-context-packs.mjs` (static pack sources, on every
- **Internal-leak guard** (`scripts/internal-leak-scanner.mjs`, with rules in
`scripts/internal-tokens.generated.json`). It refuses maintainer-internal names and paths in
content that ships. It runs in `generate-context-packs.mjs` (static pack sources, on every
`npm test`), in the full skill mirror apply (each public skill), in `scripts/build-package.sh`
(the daemon `specs/` tree before it is copied into the package), and in the release-time
substance gate (`scripts/check-substance-gate.mjs`, `npm run gate:substance`) over the packed
@@ -303,8 +303,8 @@ packages.
machine-specific values: credentials, home and temporary paths, network addresses, email
addresses. It never fails the job; you read it and decide.
- **Generated files.** Do not hand-edit `scripts/*.generated.json` or anything under
`packages/daemon/context-packs/`. The skill-edge digests are refreshed by
`npm run mirror-skills` (see "A shipped skill").
`packages/daemon/context-packs/`. The skill-edge digests are the one generated file a contributor
refreshes, with `node scripts/regen-edge-digests.mjs` (see "A shipped skill").
The internal-token rules support optional `allowed_context_lines` for reviewed generic
examples: the complete line must match exactly, including indentation and case. Unlike
`allowed_context_substrings`, this does not exempt a line with added or substituted text;
-3343
View File
File diff suppressed because it is too large. Load diff
+5 -10
View File
@@ -40,11 +40,7 @@ repository, and we're happy to link to it.
## Setting up
Node `^22 || ^24`, a working `tmux` and `rsync` are required. macOS includes `rsync`; many Linux
installs don't (`sudo apt-get install rsync` on Debian or Ubuntu). `npm test` and `npm run mirror-skills`
use it. Use the npm that comes with your Node, which is npm 10 or newer (`npm --version` shows which you
have). A separately packaged npm 9, such as Debian or Ubuntu's `npm` package, fails a fresh `npm ci` with
`ENOENT` on `chmod` of `node_modules/@openrig/tui/dist/main.js`. Then:
Node `^22 || ^24` and a working `tmux` are required. Then:
```bash
git clone https://github.com/mvschwarz/openrig.git
@@ -60,11 +56,10 @@ runs too. Use `npm install <package>` only when you're adding or changing a depe
the lockfile change with it.
`npm test` builds the daemon and runs repository checks before the package suites. Read the
specific failure. Each shipped skill has one editable home in this repository (see
["A shipped skill" in ARCHITECTURE.md](ARCHITECTURE.md#a-shipped-skill)). Edit its home, run
`npm run mirror-skills` to regenerate its other copies and refresh the digests, then run
`npm run mirror-skills:check` and commit the result. The apply reads nothing outside the repository.
The repository checks include a docs guard: a
specific failure. A shipped skill may exist in more than one copy (see ARCHITECTURE.md): edit every copy, then run
`node scripts/regen-edge-digests.mjs` and commit the digest file it changes (the full
`npm run mirror-skills` apply needs maintainer-only inputs; see "A shipped skill" in
[ARCHITECTURE.md](ARCHITECTURE.md#a-shipped-skill)). The repository checks include a docs guard: a
tracked file under `docs/` must be in `docs/as-built/`, `docs/reference/` or `docs/releases/`
(`docs/DESIGN.md` is the one exception). They check that shipped first-run text names only teams, skills and
commands that exist, and they validate the context-pack sources;
+21 -81
View File
@@ -8,71 +8,25 @@ OpenRig is open-source software for building and running your own network of age
It's the open-source system behind my AI civilization experiments.
**Guide:** [Getting started](docs/reference/quickstart.md) · **Stuck?** [Help](docs/reference/help.md) · **Questions:** [Q&A](https://github.com/mvschwarz/openrig/discussions/92) · **Updates and demos:** [@_feralmachine on X](https://x.com/_feralmachine)
**Guide:** [Getting started](docs/reference/getting-started.md) · **Stuck?** [Help](docs/reference/help.md) · **Questions:** [Q&A](https://github.com/mvschwarz/openrig/discussions/92) · **Updates and demos:** [@_feralmachine on X](https://x.com/_feralmachine)
## See it running
![The OpenRig TUI: the build rig as a graph, then as a table of seats with runtime, model, context and state, then one seat in detail (real recording, 10 seconds)](assets/readme/openrig-agents-working.gif)
**Start here:** [the guided first-use path](docs/reference/quickstart.md): install, talk to the kernel operator, then choose a team for your first useful change.
**Start here:** [the guided first-use path](docs/reference/getting-started.md): install, talk to the kernel operator, then choose a team for your first useful change.
Not setting this up today? Get the next walkthrough and occasional OpenRig updates → https://openrig.dev/follow
## Install and first run
**Let your coding agent set it up.** First check that `node --version` shows 22 or 24: the command below needs
Node.js and the `npx` that comes with it. Then, in your project folder, add OpenRig's `rigs` skill to the agent you
use:
Requires Node.js 22 or 24 and tmux, on macOS or Linux. On Linux, the distribution's own Node.js can be older (Ubuntu 24.04's is 18); install a supported version with [nvm](https://github.com/nvm-sh/nvm) (`nvm install 22`) or NodeSource. On a Mac with Apple silicon, use Node.js 22 ([compatibility history](docs/releases/v0.5.15.md#known-compatibility-limitation)). On Windows, use WSL2, the Windows route OpenRig supports; native Windows isn't supported. OpenRig's automated tests don't run on WSL2 yet; see [one user's working setup](docs/reference/getting-started.md#wsl2-a-reported-working-setup). Launching a rig writes provider hooks and workspace trust settings. Before running the commands below, read [what OpenRig changes on your machine](#what-openrig-changes-on-your-machine) and back up the relevant files.
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/skills/rigs --skill rigs --agent codex
```
Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what you want built. The skill has your agent tell
you what it will run and change before it does, install OpenRig, open the OpenRig view and pass your goal to
OpenRig's operator.
You need Node.js 22 or 24 (22 on Apple silicon), tmux, macOS or Linux (WSL2 on Windows), and Claude Code or Codex
signed in. One provider is enough to start.
Already have OpenRig? Use the tag that matches `rig --version`, or upgrade first.
Prefer to type the commands yourself? [Install it yourself ↓](#install-it-yourself)
Requires Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude Code or Codex account: one is enough, since the kernel operator adapts the shipped `starter` team to whichever you have. On Linux, the distribution's own Node.js can be older (Ubuntu 24.04's is 18); install a supported version with [nvm](https://github.com/nvm-sh/nvm) (`nvm install 22`) or NodeSource. On a Mac with Apple silicon, use Node.js 22 ([compatibility history](docs/releases/v0.5.15.md#known-compatibility-limitation)). On a Mac where you aren't an administrator, or where Homebrew belongs to another account (its `brew install` is refused), install Node.js in your own home folder with [nvm](https://github.com/nvm-sh/nvm): run `touch ~/.zshrc` (a new Mac account has none, and nvm's installer only adds itself to a profile file that already exists), then nvm's install command, for nvm v0.40.8 `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.8/install.sh | bash` ([nvm's page](https://github.com/nvm-sh/nvm#install--update-script) has the newest), open a new terminal, and run `nvm install 22`. nvm itself needs no administrator password, and `npm install -g` then installs into your nvm folder. If nvm's installer says you need Apple's command line tools, run `xcode-select --install`, then its install command again. On Windows, use WSL2, the Windows route OpenRig supports; native Windows isn't supported. OpenRig's automated tests don't run on WSL2 yet; see [one user's working setup](docs/reference/getting-started.md#wsl2-a-reported-working-setup).
> **What OpenRig changes on your machine**
> - **Install:** `@openrig/cli` goes in globally with npm.
> - **Setup:** `rig setup` adds an OpenRig block to `~/.tmux.conf`. It can install tmux (with Homebrew on macOS), and
> Herdr unless you pass `--no-herdr`. Plain `rig setup`, which is what the one-command install runs, checks both
> Claude Code and Codex and may install a missing one; `rig setup --providers claude` (or `codex`) limits it to
> yours.
> - **Starting OpenRig, for the coding agent you use** (paths are the usual defaults):
> - Codex: hooks and trust in `~/.codex/config.toml`.
> - Claude Code: workspace trust in `~/.claude.json`, and activity hooks in the project's
> `.claude/settings.local.json`, plus a status line unless you already have your own.
> - Discovery skills go in `~/.claude/skills` and `~/.agents/skills`.
> - **Starting a team in your project:** adds managed guidance to `AGENTS.md` or `CLAUDE.md`, plus the team's skills
> and plugins.
> - **Permissions:** by default, OpenRig's own kernel gets broader permissions than a project team gets by default.
> On Codex that's full access with no approval prompts (`-s danger-full-access -a never`). On Claude Code, its file
> tools, web fetches and operational commands, including reads under your home folder, run without asking. A
> permission policy, per-agent choice or Codex profile changes that. What your team can do depends on the team you
> choose and your settings; `workshop`, for example, ships with broad access.
> - **Backups:** OpenRig doesn't guarantee it can preserve or roll back these files, so back them up first.
>
> [Everything OpenRig writes, and why →](https://github.com/mvschwarz/openrig#what-openrig-changes-on-your-machine)
### Install it yourself
This runs OpenRig's install script from the `v0.7.4` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
**One command.** This runs OpenRig's install script from the `v0.6.7` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
```bash
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.6.7/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.6.7/scripts/install.sh | sh
```
If a provider isn't signed in yet, the last step reports `FAILED [4/4]`, and `rig setup` lists the sign-in under "Some steps need attention". If the only remaining failures are provider sign-ins, the install steps finished; sign in to each selected provider as below and continue.
@@ -88,7 +42,7 @@ To install with Bun instead, run `bun add -g @openrig/cli`. OpenRig still runs o
npm 11 and later can skip that postinstall script and print `npm warn install-scripts` naming `@openrig/cli`. That's expected: the CLI still works, and only the Node.js and SQLite check was skipped. To run it yourself, use `node "$(npm root -g)/@openrig/cli/scripts/check-abi.mjs"`; `rig doctor` checks the Node.js version only.
Choose the working account you already have: **Claude Code, Codex, or both**. Reuse an explicit choice; no second subscription is required. `rig setup --dry-run` previews the broader setup; applying `rig setup` checks both harnesses and installs a missing one, plus Herdr unless declined with `--no-herdr`. Pass the choice with `--providers claude` or `--providers codex` so the other reads not selected instead of failed. The one-command install above doesn't know the choice, so it installs and checks both; for one provider, go step by step and run `rig setup --providers <choice>`. On macOS, the installing agent offers Ghostty once. Setup leaves an existing cmux installation alone. It is optional for the [selected-provider path](docs/reference/getting-started.md#choose-your-providers).
Choose the working account you already have: **Claude Code, Codex, or both**. Reuse an explicit choice; no second subscription is required. `rig setup --dry-run` previews the broader setup; applying `rig setup` checks both harnesses and installs a missing one, plus Herdr unless declined with `--no-herdr`. On macOS, the installing agent offers Ghostty once. Setup leaves an existing cmux installation alone. It is optional for the [selected-provider path](docs/reference/getting-started.md#choose-your-providers).
A team seat with no permission policy, per-seat choice or (for Codex) named profile launches with OpenRig's [team default](docs/reference/rig-spec.md#team-launch-defaults): Claude runs ordinary `rig` commands, project reads and common tests without prompting, while lifecycle commands such as `rig up` and `rig down` still ask; Codex also gets the OpenRig workspace and its pod's state directory as writable directories. Before launching, your agent recommends keeping that default, and offers to remember selected OpenRig commands in your native settings only if you want that, at personal project scope unless you explicitly choose user-wide sessions. It is not global YOLO or permission to invent work. On Yes, the agent [adds and verifies native rules](docs/reference/getting-started.md#have-your-agent-configure-permissions); No or no answer keeps the team default and leaves settings unchanged. An existing explicit choice is reused. Say “Undo the OpenRig command allowances added by this setup” to remove only its additions.
@@ -106,11 +60,10 @@ to an unused provider.
### Your agent opens OpenRig for you
After installing, your agent opens the OpenRig TUI and the operator for you. To get back to them later, ask your
agent for OpenRig or your agents in any words. After checking the selected login, it starts the daemon if it's stopped
and opens the view:
agent for OpenRig or your agents in any words. After checking the selected login, it runs:
```sh
rig daemon start
rig daemon start # if stopped
rig terminal open saved:kernel --window
```
@@ -191,7 +144,6 @@ Not setting this up today? Get the next walkthrough and occasional OpenRig updat
- **Questions:** [Discussions › Q&A](https://github.com/mvschwarz/openrig/discussions/categories/q-a)
- **Bugs and feature requests:** [open an issue](https://github.com/mvschwarz/openrig/issues/new/choose)
- **Contributing:** [CONTRIBUTING.md](CONTRIBUTING.md) · [Code of Conduct](CODE_OF_CONDUCT.md) · [Security policy](SECURITY.md) · [Getting help](.github/SUPPORT.md)
- **Working on OpenRig with a coding agent?** Start with the `developing-openrig` skill in `.agents/skills/` (Claude Code: `.claude/skills/`) and [CONTRIBUTING.md](CONTRIBUTING.md).
- **Videos:** [youtube.com/@openrig](https://www.youtube.com/@openrig)
- **Releases:** [GitHub Releases](https://github.com/mvschwarz/openrig/releases) and npm `@openrig/cli`
@@ -208,7 +160,7 @@ using a published package, since repository guidance can be ahead of npm.
| --- | --- |
| **npm installation** | Installs the CLI (`rig` and `openrig-tui`), bundled components and dependencies under your npm prefix (with Bun, under Bun's global directory). OpenRig's postinstall checks the Node.js version and that the SQLite module loads; Bun may block this script. It does not run daemon or provider setup. |
| **`rig setup`** | Attempts missing tools and writes an OpenRig block in `~/.tmux.conf` for mouse support and scrollback. Installs herdr by default on macOS and Linux unless declined with `--no-herdr`; an unavailable herdr install is a warning. On macOS the installing agent offers Ghostty, and `--ghostty` attempts it after acceptance. Existing cmux settings are left unchanged. `--full` adds workstation tools. `--dry-run` shows setup's plan without applying it. |
| **Daemon startup** | Creates/updates instance state under `OPENRIG_HOME` (normally `~/.openrig`), including its database and managed plugin resources. Seeds the `openrig-skills` discovery skill, the `refocusing` skill and the person-facing `rigs` skill in `~/.claude/skills` and `~/.agents/skills`, subject to existing version ownership. When a Codex seat is already recorded and `runtime.codex.hooks_enabled` is enabled (the default), ensures its hook configuration and trust records as described below. With no recorded Codex seat, enabled hooks wait for its first launch; disabled hooks still remove a stale OpenRig-managed hook block, if present. |
| **Daemon startup** | Creates/updates instance state under `OPENRIG_HOME` (normally `~/.openrig`), including its database and managed plugin resources. Seeds the `openrig-skills` discovery skill, the `refocusing` skill and the person-facing `rigs` skill in `~/.claude/skills` and `~/.agents/skills`, subject to existing version ownership. With `runtime.codex.hooks_enabled` enabled (the default), writes Codex hook configuration and trust records as described below—even before a rig launches. |
| **Rig/seat launch and attachment** | Creates tmux sessions, supplies seat identity and daemon connection environment, and projects selected guidance, skills, plugins and runtime resources into the workspace. Managed startup pre-trusts the workspace. Claude context collection can also be provisioned for attached sessions and refreshed during monitoring. In a Git repository, newly created files under `.codex/plugins/shared:openrig-core/` (or the unqualified `.codex/plugins/openrig-core/`) are added to the repository's Git `info/exclude` inside an `# BEGIN OpenRig generated files` block; new `AGENTS.md`, `CLAUDE.md` and `CLAUDE.local.md` files stay visible with a warning. |
| **Bundle install** (`rig bundle install`, or `rig up` with a `.rigbundle` or GitHub link) | Writes the bundle's files into the install target: `--target`, or the current directory for `rig up` and for a GitHub link. `rig bundle install` with a local archive needs `--target`. Routes its declared skills, plugins and context packs into your libraries. A GitHub link's archive is kept under `OPENRIG_HOME/bundle-imports/`, and installs are recorded in `OPENRIG_HOME/bundle-audit.jsonl`. A bundle that carries a project creates it under `workspace.projects_root` and records it, with the rig's association, in the workspace catalog. |
| **Explicit permission configuration** | The built-in bootstrap does **not** add `rig` command allow rules to your settings files; a team seat with no permission policy, per-seat choice or (for Codex) named profile gets the per-launch [team default](docs/reference/rig-spec.md#team-launch-defaults) instead. Agent-guided setup recommends keeping it and requires your actual answer before the agent [adds remembered rules at your chosen scope](docs/reference/getting-started.md#have-your-agent-configure-permissions). No/no answer keeps the team default and preserves settings; existing choices and stricter rules remain relevant. Broader access is separate. |
@@ -222,23 +174,16 @@ user's home; changing `OPENRIG_HOME` alone does not isolate provider configurati
writes `HOME/.claude.json`, and also `<CLAUDE_CONFIG_DIR>/.claude.json` when the
daemon has that variable set. In the workspace, `.claude/settings.local.json` receives
the context collector's `statusLine` command and selected activity hooks;
a user's own status-line command is preserved, so it can prevent collector
installation. Selected settings resources can still replace overlapping keys.
The collector script lives under `.openrig/`, and the activity-hook relay under
the collector script lives under `.openrig/`, and the activity-hook relay under
the instance's `state/claude-activity-hooks/`. Selected settings/MCP resources can also
change that settings file and `.mcp.json`. The shared settings resource enables
Exa/Context7 MCP entries and leaves existing `permissions.defaultMode` and permission
rules unchanged. Managed launch flags select the session mode. Selected MCP resources
configure those external services. Built-in bootstrap
change that settings file and `.mcp.json`. The shared settings resource sets
`permissions.defaultMode` to `acceptEdits` and enables Exa/Context7 MCP entries;
selected MCP resources configure those external services. Built-in bootstrap
no longer writes a command allowlist to `~/.claude/settings.json` or removes
older allowances. `CLAUDE_CONFIG_DIR` does not relocate the project-local writes.
To keep your own status line alongside the collector, see
[Custom Claude status line](docs/reference/claude-status-line.md).
- **Codex:** writes the daemon's `CODEX_HOME/config.toml` (normally
`~/.codex/config.toml`). With hooks enabled, daemon startup ensures the OpenRig
activity relay commands and their trust hashes when a Codex seat is already
recorded; otherwise the first Codex launch does so. With hooks disabled, startup
can remove a stale OpenRig-managed hook block even without a Codex seat. Seat startup adds
`~/.codex/config.toml`). Startup enables hooks, adds the OpenRig activity relay
commands and pre-writes trust hashes for those commands. Seat startup adds
`trust_level = "trusted"` for the workspace; selected config resources can
add MCP settings. Managed launches pass `-c check_for_update_on_startup=false`,
which writes no config; a recognized update notice that still appears can be
@@ -304,12 +249,9 @@ separates the desired selection from the last launch arguments; neither proves
native enforcement. See the [permission guide](docs/reference/getting-started.md#per-seat-permission-mode).
Managed hook blocks target OpenRig's entries and retain unrelated hooks, but
trust entries and selected resource keys can be replaced. Claude's collector,
activity-hook and JSON resource writers leave unreadable or malformed settings
untouched; this is not a product-wide preservation guarantee. Codex's workspace
trust writer can treat an unreadable `config.toml` as empty and write a new trust
configuration. Read-modify-write operations can also race other writers; there
is no complete preservation or rollback guarantee. Back up relevant
trust entries, selected resource keys and Claude's existing status-line command
can be replaced. Some writers recover unreadable settings as empty objects;
this is not a complete preservation or rollback guarantee. Back up relevant
files before first use. Daemon/bootstrap writes are automatic and do not each
have an interactive preview; `rig setup --dry-run` does not preview every later
startup effect.
@@ -355,7 +297,7 @@ rig specs ls
## How It Works
OpenRig is a local daemon + CLI + terminal UI + MCP server, built on tmux. OpenRig itself is under active development; only the older React web UI is in maintenance mode, with best-effort support.
OpenRig is a local daemon + CLI + terminal UI + MCP server, built on tmux. The older React web UI remains in maintenance mode with best-effort support.
```
CLI / TUI / MCP
@@ -390,8 +332,6 @@ For cmux, use `--provider cmux`. In the TUI, a rig's detail view has an `Open te
## Key Concepts
The first words you meet, in plain language: [What these words mean](docs/reference/quickstart.md#what-these-words-mean).
- **RigSpec**: Declarative multi-agent harness definition in YAML. Pods, members, edges, continuity policies, culture file.
- **AgentSpec**: Reusable agent blueprint with skills, guidance, hooks, profiles, and startup contracts.
- **Seat**: A stable role and address in a rig, such as `dev-build@starter`. The conversation occupying it can change while its identity and authored context remain.
@@ -507,7 +447,7 @@ Before setup or managed launch, review [what OpenRig changes on your machine](#w
Already-running adopted sessions may need restart before they pick up newly written runtime config.
**For agents:** First ask which available provider(s) the user wants, reusing an explicit choice, and pass it to setup (`rig setup --providers claude`, `codex` or `both`). Recommend the working account they already have and follow the selected-provider guide above. Offer core setup (`rig setup`) or the fuller workstation path (`rig setup --full`) only when they want those additional tools. Inspect the result with `--json` and use `rig doctor` to finish any remaining machine-specific issues. Starting OpenRig and opening its view are the last steps of installing, not extras, so do them without stopping to ask whether to go on: start the daemon if it is stopped, open the OpenRig TUI and operator for them (say so, then run `rig terminal open saved:kernel --window --json`; no commands for the person to copy), and hand the person's goal and project folder to the kernel operator ([installing-agent handoff](docs/reference/getting-started.md#installing-agent-handoff)). Installation is finished when the person is talking to the operator; don't implement the project yourself. When OpenRig itself misbehaves, read [docs/reference/help.md](docs/reference/help.md) (installed agents can run `rig context get help`; the same text is at [openrig.dev/help/agents](https://www.openrig.dev/help/agents)): it covers the next step, known problems, and how to reach the team if you're still stuck.
**For agents:** First ask which available provider(s) the user wants, reusing an explicit choice. Recommend the working account they already have and follow the selected-provider guide above. Offer core setup (`rig setup`) or the fuller workstation path (`rig setup --full`) only when they want those additional tools. Inspect the result with `--json` and use `rig doctor` to finish any remaining machine-specific issues. Then start the daemon if it is stopped, open the OpenRig TUI and operator for them (say so, then run `rig terminal open saved:kernel --window --json`; no commands for the person to copy), and hand the person's goal and project folder to the kernel operator ([installing-agent handoff](docs/reference/getting-started.md#installing-agent-handoff)). Installation is finished when the person is talking to the operator; don't implement the project yourself. When OpenRig itself misbehaves, read [docs/reference/help.md](docs/reference/help.md) (installed agents can run `rig context get help`; the same text is at [openrig.dev/help/agents](https://www.openrig.dev/help/agents)): it covers the next step, known problems, and how to reach the team if you're still stuck.
## Comparison with Claude Managed Agents
+11 -13
View File
@@ -3,19 +3,9 @@
These are things we'd welcome help with. Most came from the community, and most are small enough for one pull request.
This isn't a schedule or a promise: we release often, and an item ships when a good change for it lands.
To see what's being worked on, by release and theme, open the [OpenRig roadmap board](https://github.com/users/mvschwarz/projects/1).
Issues labelled `good first issue` are also listed on the [contribute page](https://github.com/mvschwarz/openrig/contribute).
## Released in 0.7.4
[0.7.4 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.4): a first, experimental step toward every
assistant you use working on one team. See its release notes for what shipped, the experimental and security notes for
hosted members, upgrade instructions, contributor credits and the remaining testing gaps.
## How to help
1. Pick an item and comment on its issue to say you're working on it and coordinate with others. A maintainer can assign
you to make that work visible. If it has no issue yet, open one first.
1. Pick an item and comment on its issue to say you're working on it. If it has no issue yet, open one first.
2. Read `CONTRIBUTING.md`, and ask your agent to use the `developing-openrig` skill. It's a map of how OpenRig is
built, tested and changed.
3. Open a pull request that says what a user gets and how you verified it.
@@ -31,20 +21,28 @@ tie. Feel free to coordinate on the issue first.
## Agents and runtimes
- **Codex seats that can reach the local daemon by default** (#275), with an easy opt-out.
- **Custom Codex providers such as Bedrock** (#194): accept providers that authenticate with an environment key, and
forward their token.
- **Operator recovery for Pi and Oh My Pi seats** (#41).
- **More harnesses:** we're working out one way for any coding-agent CLI to plug into OpenRig. More on this soon
(design discussion: #43).
- **New agent types:** OpenCode (#87, #227, #517), the Cursor CLI (#352), the Grok CLI (#522) and Devin (#44) are
planned on a shared adapter.
Design discussion: #43. Adapter pull requests are welcome once that adapter lands.
## Slack
- **A DM or private channel for single-user setups** (#313).
- **A channel map** (#192): one channel per seat, keyed by channel ID.
## Reliability and everyday use
- **A configurable readiness window for fresh launches** (#182), so seats under load don't time out at a fixed 30
seconds.
- **Draft-aware delivery** (#48): automated sends wait while someone is typing in a seat, or a seat can be set to
inbox-only.
- **A host-aware resource budget for seats** (#80).
- **Predictable `rig up` over a stopped rig with the same name.**
- **A "merged" stage in the project view** for teams that integrate on a branch other than main (#402).
- **Codex seats that leave a repository's tracked `AGENTS.md` alone** (related: #64).
- **Install and run on Node 26.**
+5 -9
View File
@@ -24,7 +24,7 @@ RUN set -eux; \
rm -rf /var/lib/apt/lists/*
# --- layer 2: pinned node (an in-range engines version — see scripts/check-engines) ---
ARG NODE_VERSION=22.23.4
ARG NODE_VERSION=22.22.1
ARG TARGETARCH
RUN set -eux; \
case "${TARGETARCH}" in \
@@ -33,14 +33,10 @@ RUN set -eux; \
"") echo "TARGETARCH is empty — the legacy (non-BuildKit) builder does not populate it. Pass --build-arg TARGETARCH explicitly (build-testbed-image.sh does) or enable BuildKit. REFUSING to default to amd64, which would install wrong-arch Node." >&2; exit 1 ;; \
*) echo "unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \
esac; \
NODE_TARBALL="node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz"; \
curl -fsSLo "/tmp/${NODE_TARBALL}" \
"https://nodejs.org/dist/v${NODE_VERSION}/${NODE_TARBALL}"; \
curl -fsSLo /tmp/SHASUMS256.txt \
"https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt"; \
(cd /tmp && awk -v tarball="${NODE_TARBALL}" '$2 == tarball' SHASUMS256.txt | sha256sum -c -); \
tar -xJf "/tmp/${NODE_TARBALL}" -C /usr/local --strip-components=1 --no-same-owner; \
rm -f "/tmp/${NODE_TARBALL}" /tmp/SHASUMS256.txt; \
curl -fsSLo /tmp/node.tar.xz \
"https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz"; \
tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 --no-same-owner; \
rm -f /tmp/node.tar.xz; \
node --version; npm --version
# --- layer 3: the OpenRig package from the LOCAL pack tarball, never the npm registry ---
+4 -4
View File
@@ -10,14 +10,14 @@ applies-when: |
its own source-verification stamp.
siblings: [codemap.md, arteries.md, test-layers.md, cli-reference.md, frontmatter-schema.md]
prerequisite-reads: []
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# OpenRig as-built docs
These pages describe the code and point to its owning files. This index was checked against
main commit `e8f0ab340db773392ec8be75b072d1c0f3068a50`. A linked module's
main commit `2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7`. A linked module's
`last-verified-against-source` is its own verification boundary; updating this index does not
revalidate that module or identify a running daemon or published package.
@@ -93,7 +93,7 @@ At the named commit there are **26 Markdown files**: **14** under `architecture/
these counts from the repository root:
```bash
source_commit=e8f0ab340db773392ec8be75b072d1c0f3068a50
source_commit=2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
git ls-tree -r --name-only "$source_commit" -- docs/as-built | grep -E '\.md$' | wc -l
git ls-tree -r --name-only "$source_commit" -- docs/as-built/architecture | grep -E '\.md$' | wc -l
git ls-tree -r --name-only "$source_commit" -- docs/as-built/ui | grep -E '\.md$' | wc -l
+3 -3
View File
@@ -9,14 +9,14 @@ applies-when: |
overview or the current as-built module index linked here.
siblings: [README.md, codemap.md, arteries.md, test-layers.md, ui.md]
prerequisite-reads: []
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# Architecture module navigation
This compatibility page points to the modular documentation. Its links were checked against main
`e8f0ab340db773392ec8be75b072d1c0f3068a50`; the linked pages retain their own source stamps.
`2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7`; the linked pages retain their own source stamps.
- [Repository architecture](../../ARCHITECTURE.md): package boundaries, request flow, derived
source counts and contributor recipes.
@@ -12,8 +12,8 @@ applies-when: |
layer).
siblings: [daemon-core.md, agent-spec-and-startup.md, lifecycle-snapshot-restore.md]
prerequisite-reads: [../README.md, daemon-core.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# Adapters and Runtimes
@@ -27,7 +27,7 @@ adapters (`ClaudeResumeAdapter`, `CodexResumeAdapter`, `PiResumeAdapter` and
this harness *actually* resume, or did it silently fresh-launch?" truthfully
rather than optimistically.
> Verified against source at main `e8f0ab340db773392ec8be75b072d1c0f3068a50`. Each count below sits beside the
> Verified against source at main `2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7`. Each count below sits beside the
> command that produces it; run the command from the repository root to refresh
> it.
@@ -88,39 +88,6 @@ Readiness has its own attention codes (`runtime-adapter.ts:74–87`):
When `checkReady` reports one, the readiness wait returns at once and startup
ends `attention_required`.
### Evidence for a failed Claude resume
Previously, a failed Claude launch recorded its error without the probe code or
the captured input's identity, and a refused strict full-restore attention clear
had no corresponding refusal event. Probe-backed Claude launch failures now
carry an optional `probe` in `HarnessLaunchResult` and `node.startup_failed`.
Strict full-restore refusals emit `seat.attention_clear_refused`; a
`pane_not_usable` refusal includes that probe and the restore attempt ID.
A missing strict reconciler emits `strict_reconciler_unavailable` without
inventing an attempt ID or probe evidence, and still refuses the clear.
`native-resume-probe-evidence.ts` records the status, code, a closed-set login
refusal and row kind when matched, and the exact capture's line count, byte
count and SHA-256. Its new fields contain no captured pane text or free-form
probe detail. Existing attention evidence and recovery decisions keep their
previous behavior. MCP approval detection still takes precedence over the
login-refusal check.
Pane dimensions are optional, observed after the capture. Their read has a
250 ms deadline so a hung `listPanes` cannot indefinitely delay the failure
result or its event. On timeout, error, ambiguous pane identity or invalid
dimensions, the unavailable dimensions are `null`; late results are ignored.
The deadline bounds this diagnostic wait, not the underlying tmux command.
`native-resume-probe-evidence.test.ts` covers unavailable and late geometry;
`startup-orchestrator.test.ts` and `restore-orchestrator.test.ts` exercise the
real adapter/orchestrator and SQLite event paths with available, failing and
never-settling geometry reads. `seat-attention-reconciler.test.ts` checks that
a missing strict reconciler records its refusal without changing restore
history or sending input. These use synthetic terminal captures, not a
live Claude session. The stub scenario runtime cannot draw Claude's native
login-refusal UI, and its scenario runner does not bind the restore action.
## 2. The runtime adapters
**5** classes implement `RuntimeAdapter`
@@ -208,17 +175,10 @@ other rig gets `teamPermissionDefault` instead (`native-permission-store.ts:38`,
`:56`), on the same launch paths. A Claude Code seat at the floor with no native
permission mode keeps `--permission-mode acceptEdits` and gets
`--settings` (`kernel-authority.ts:64–66`):
`TEAM_CLAUDE_ALLOW` (`:33`) is `Skill`, `Read(./**)`, `Glob`, `Grep`,
`TEAM_CLAUDE_ALLOW` (`:25`) is `Skill`, `Read(./**)`, `Glob`, `Grep`,
`Bash(rig:*)`, eight read-only shell commands and 17 test-runner commands, and
`TEAM_CLAUDE_ASK` (`:40`) asks before 29 `rig` commands: the 23 that start, stop or
remove teams or agents (such as `up`, `down`, `seat stop`, `seat restart` and `bundle install`),
and six kept for a person: `import` (its `--instantiate` mode launches nodes), `seat
set-permissions` and `policy apply` under both spellings (an explicit permission choice),
`setup` (kept for its `--policy` path, which records one; a prefix rule can't tell that path
from ordinary setup, so ordinary setup prompts too), and `compact` (it replaces a peer's
working context). Other `rig` commands fall under the `Bash(rig:*)` allowance. The list was
42 before: 15 commands were removed and 2 added (`seat restart` and `policy permissions
apply`), 13 fewer prompts. The settings carry the allow list and a
`TEAM_CLAUDE_ASK` (`:32`) asks before 41 `rig` lifecycle commands such as `up`,
`down`, `seat stop` and `bundle install`. The settings carry the allow list and a
`PreToolUse` Bash hook, `packages/daemon/assets/claude-team-permissions.cjs`, that applies
both lists (`kernel-authority.ts:43–53`): it asks before a lifecycle command unless the
command only asks for `--help` or `-h`, allows a simple literal command that matches an
@@ -238,28 +198,22 @@ resume launches (`codex-runtime-adapter.ts:378`, `codex-resume.ts:103`).
`<cwd>/CLAUDE.md` by default (`:194`, `:501`; default at
`domain/managed-blocks.ts:17`, and the rig may select `CLAUDE.local.md`
instead with `managed_blocks: { claude-code: <file> }`,
`managed-blocks.ts:15`); `skill_install` and skill resources → the seat's
skill root, `binding.seatSkillsRoot`, which is the `skills/` folder of the
seat's generated plugin in its own storage
(`domain/startup-orchestrator.ts:353–369`), or
`<cwd>/.claude/skills/<name>/` for a seat without one (`:215–216`,
`:849`); subagents → `.claude/agents`, plugins →
`managed-blocks.ts:15`); `skill_install` → `<cwd>/.claude/skills/<name>/`
(`:200`, `:582`); subagents → `.claude/agents`, plugins →
`.claude/plugins/<id>` (only plugins that apply to Claude), runtime
resources → `.claude/extensions/<id>` (`:584–586`); settings fragments
merged into `.claude/settings.local.json` (`:594`) and MCP fragments into
`<cwd>/.mcp.json` (`:597`).
- **Launches**: fresh = `claude <posture> --session-id <generatedId> --name
<name>` (`:329`); resume = `claude <posture> --resume <token> --name <name>`
(`:328`); fork = `claude <posture> --resume <parentId> --fork-session
--session-id <generatedId> --name <seat>`, which needs Claude Code 2.0.73 or
later. `<posture>` is the flag above plus any non-interruptive,
(`:328`); fork = `claude <posture> --resume <parentId> --fork-session --name
<seat>` (`:298`). `<posture>` is the flag above plus any non-interruptive,
kernel operational or team default argument. Each command may carry a `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1`
env prefix (`yolo-mode.ts:101–103`; on by default, off with
`OPENRIG_CLAUDE_DISABLE_ALTERNATE_SCREEN=0`), a `--model <model>` argument
(`:275`) and `--effort <effort>` (`:277`). A fresh launch and a fork both
return the generated `--session-id` as their `claude_id` token, so a forked
seat's process names its own conversation and strict proof can verify it.
Neither reads Claude's session files back.
(`:275`) and `--effort <effort>` (`:277`). A fresh launch returns the
generated `--session-id` as its `claude_id` token; a fork polls Claude's
session files for the new id.
- **Managed launch.** When the binding selects a native permission mode, the
command comes from `ClaudeManagedLaunch.prepare()`
(`domain/claude-managed-launch.ts:97`), which resolves `claude` on the
@@ -281,45 +235,14 @@ resume launches (`codex-runtime-adapter.ts:378`, `codex-resume.ts:103`).
`no_conversation_found` (`:429–434`) and with `recovery:
"attention_required"` plus pane evidence on an `attention_required` probe
(`:444–450`) — the adapter never relaunches on its own.
- **Fresh in-place handover readiness.** `assessFreshInPlaceProbe` qualifies an
otherwise ready history probe only for the matching fresh launch, binding and
launch generation. The current pane must show a prompt, and `assessManagedProbe`
must confirm the new launch's exact session token in the same foreground process
across two observations, with the pane and launch still current. It reuses the
existing managed identity proof, including its process fingerprint. A process
label alone, old scrollback, an unavailable screen, an unreadable process list
or an unidentified Claude process cannot admit the handover. Those cases stay
`awaiting_runtime`; a positive identity conflict and visible approval or login
gates keep their specific results. The handover waits within its existing
readiness budget before sending the context packet.
The fresh path requires the last `❯` prompt to sit inside Claude's complete
input frame: its top rule directly above the prompt and a bottom rule below.
A shell's echoed launch command cannot borrow an earlier header or input
frame, even when the matching Claude process is already running. A header
and prompt alone, or a partially drawn frame, remain pending. This may delay
readiness until the full frame is visible; other launch paths keep their
existing screen predicates.
Previously, unavailable observations and ambiguous headerless prompts could
retain the old ready result. `claude-fresh-in-place-scope.test.ts` now checks
missing observations, unknown or changing process identity, retained UI above
direct and script launch commands, partial frames, recovery and normal prompt
controls. `claude-handover-readiness.test.ts` exercises the real handover
service with migrated SQLite, modeled pane/process observations and one synthetic
clock. Readiness and the later packet-submission receipt remain separate checks;
these tests do not run a live Claude account.
### CodexRuntimeAdapter (`codex-runtime-adapter.ts:65`)
- `readonly runtime = "codex"` (`:66`).
- **Projects** to `.agents/` targets: `guidance_merge` → `<cwd>/AGENTS.md`
(`:309`, `:576`); `skill_install` and skill resources → the seat's skill
root, `binding.seatSkillsRoot`, which is the `skills/` folder beside the
seat's catalog in its own storage
(`domain/startup-orchestrator.ts:360–369`), or
`<cwd>/.agents/skills/<name>/` for a seat without one (`:563–564`,
`:1060`); plugins go to `.codex/plugins/<id>` (`:1063`).
(`:309`, `:576`); `skill_install` → `<cwd>/.agents/skills/<name>/` (`:315`);
skills resolve under `.agents/skills/<id>` (`:664`); plugins go to
`.codex/plugins/<id>` (`:667`).
- **Launches/resumes**: fresh = `codex[ --no-daemon]<posture>[<network>] -C
<cwd> …` (`:442`), then capture a fresh thread id (`:460`); resume is built
by `buildCodexResumeCore` (`:441`) as `codex<daemon><posture> resume
@@ -376,28 +299,11 @@ maps (`startup.ts:989`, `:1259`); see `daemon-core.md` §4 "Startup sequence".
Opening a seat in a terminal app goes through `TerminalProvider`s under
`packages/daemon/src/domain/terminal/`, served by `/api/terminal`:
`HerdrAdapter` (`herdr-adapter.ts:351`), the default
(`terminal-service.ts:55–58`), and `CmuxProviderAdapter`
(`terminal-service.ts:55–56`), and `CmuxProviderAdapter`
(`cmux-provider-adapter.ts:52`), which is best effort and refuses with
`cmux_unavailable` when cmux is not connected. A request that names no
provider takes the first of Herdr, then cmux, that is available and alive,
and stays on Herdr when neither is (`providerNameFor`,
`terminal-service.ts:175`); a named provider is used as named. These place existing tmux
`cmux_unavailable` when cmux is not connected. These place existing tmux
sessions in views; they do not launch harnesses.
`RexAdapter` (`rex-adapter.ts`) opens a view as a new Rex session through
the `rex` CLI (`rex --autostart=false api call <method> -`, `rex-transport.ts`):
it creates an empty session (`session.create`), adds one window per grid page
(`session.new_window`), and checks the tiles once after a short wait. It is used only when named;
the CLI and TUI name it when they run inside Rex (`openRexView`,
`packages/cli/src/terminal-window-rex.ts`), and an omitted provider never picks
it. It refuses with `rex_unavailable` when the Rex server does not answer.
Discovery (`server.version` and `server.status`) has a 1 s budget, and
`status()` and `liveness()` share one probe. A request whose reply is lost (a
timeout, a killed client, an unreadable reply) is `unknown`, not failed: the
adapter never closes a session on it and reports `rex_outcome_unknown`.
Herdr and Rex build their layout trees from one grid helper
(`buildTileGrid`, `tile-layout.ts`).
## 3. Resume honesty
The daemon does not assume a harness resumed just because the launch command
@@ -439,7 +345,7 @@ never relaunches itself. Acting on that hint is the caller's choice:
`StartupOrchestrator` retries once fresh on `retry_fresh` unless the caller
passes `allowFreshFallback: false` (`startup-orchestrator.ts:319–330`), and the
restore orchestrator passes `false` when a pod-aware node requested resume
(`restore-orchestrator.ts:1224`).
(`restore-orchestrator.ts:1217`).
### `resume-metadata-refresher.ts`
@@ -480,8 +386,8 @@ module.
- **Rule 1** — §2: **0** files in `adapters/` import Hono.
- **Rule 5** — restore picks a node's adapter by that node's own runtime: the
saved startup context's runtime, else the node's (`restore-orchestrator.ts:1115–1116`),
used to choose the replay adapter (`:1138`).
saved startup context's runtime, else the node's (`restore-orchestrator.ts:1108–1109`),
used to choose the replay adapter (`:1131`).
- **Rule 13** — the readiness loop is `StartupOrchestrator.waitForReady`
(`startup-orchestrator.ts:562`; 1 s doubling to a 16 s cap), which calls each
adapter's `checkReady` (§2). The timeout comes from the
@@ -494,9 +400,9 @@ module.
separate field that also allows `forked` (`startup-orchestrator.ts:101`).
- **Rule 15** — adapters return `ok:false` and never relaunch (§2, §3);
restore passes `allowFreshFallback: false` for pod-aware resume
(`restore-orchestrator.ts:1224`), and a resume that concludes failed rolls
back to zero sessions as `awaiting-decision` (`restore-orchestrator.ts:1055`,
`:1243`).
(`restore-orchestrator.ts:1217`), and a resume that concludes failed rolls
back to zero sessions as `awaiting-decision` (`restore-orchestrator.ts:1048`,
`:1236`).
## See also
@@ -11,8 +11,8 @@ applies-when: |
resolve and preserve identity.
siblings: [daemon-core.md, adapters-and-runtimes.md, lifecycle-snapshot-restore.md, packaging-bootstrap-bundles.md]
prerequisite-reads: [../README.md, daemon-core.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# Agent/Rig Spec, Resolution, Startup, Identity
@@ -22,7 +22,7 @@ and identity-addressable topology. The spec-and-startup contract: parse →
resolve → project → deliver pre-launch files → persist replay context → launch
→ wait → deliver post-launch files.
> Verified against source at main `e8f0ab340db773392ec8be75b072d1c0f3068a50`. `domain/…` and `routes/…` paths
> Verified against source at main `2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7`. `domain/…` and `routes/…` paths
> are under `packages/daemon/src/`; a bare file name such as
> `rigspec-schema.ts:119` is in `packages/daemon/src/domain/`. Each count sits
> beside the command that produces it; run the command from the repository
@@ -119,37 +119,6 @@ them imports Hono: **0**
- `startup-resolver.ts` — additive startup layering.
- `projection-planner.ts` — runtime resource projection planning.
### Profile resolution during launch
Preflight already resolves profiles in `profile-resolution-worker.ts`. Previously,
the pod-aware apply path, materialized-member launch and first-start retry called
the synchronous resolver again on the daemon's request thread. A fallback skill
scan or managed-catalog inspection could therefore delay every other request even
after preflight had finished ([#1653](https://github.com/mvschwarz/openrig/issues/1653)).
Those three launch paths now await the same resolver in a worker before projecting
resources or starting the member. Configuration precedence and launch order still
come from the existing resolver and instantiator.
An apply operation reuses one lazy worker across its members and terminates it on
success or failure. Unlike preflight, launch supplies no `scanScope`: each member
reads current files, including changes made by an earlier member's startup.
Standalone member launches and first-start retries use a short-lived worker for
their resolution. This adds worker startup and context-copy costs; it does not
reuse the preflight result or cache configuration between requests. The helper's
existing synchronous fallback for a deleted process working directory remains.
Worker errors become configuration-failure results. Apply can then follow the
existing all-members-failed cleanup, including service rollback and restoration
of replaced generations. Materialized launches report a failed member, and
first-start retries refuse before writing retained state.
`profile-resolution-worker.test.ts` holds a real fallback scan open with a FIFO,
requires an HTTP health response while that FIFO is still blocked, then checks
that the selected skill reaches projection. It covers all three launch paths and
a skill edit between members. These fixtures use SQLite and the real instantiator
with a simulated runtime; they do not establish native provider behavior or
responsiveness during other synchronous parts of startup.
## 4. Startup orchestration (the spec-startup contract)
`StartupOrchestrator.startNode` (`startup-orchestrator.ts:172`, a thin wrapper
@@ -223,22 +192,16 @@ proof selected, or a terminal runtime) record `node.startup_proof_skipped`
(`startup-orchestrator.ts:374`); resume, fork and rebuild keep the existing proof
history.
**Pending startup context and `rig seat continue`.** When a fresh launch stops at
a native prompt before its context is delivered, `fail()` (`:806`) marks the
failure `freshContextPending` and adds "run: rig seat continue <session>". A
Codex fork whose own id isn't verified at launch, or whose readiness times out,
ends `attention_required` with `forkContextPending` (its parent token) instead:
the role waits, and nothing is saved for an unverified child.
`canContinueStartup` (`:764`, using `readPendingStartupContext`, `:46`) allows
**Fresh context pending and `rig seat continue`.** When a fresh launch stops at
a native prompt before its context is delivered, `fail()` (`:592`) marks the
failure `freshContextPending` and adds "run: rig seat continue <session>".
`canContinueFresh` (`:554`, using `hasPendingFreshStartup`, `:35`) allows
continuation only while that failure is the occupant's latest startup event.
`continueStartup` (`seat-lifecycle-service.ts:723`) re-checks the binding,
`continueFreshStartup` (`seat-lifecycle-service.ts:395`) re-checks the binding,
pane and runtime, then runs startup again on the same conversation without
relaunching the harness. A fork continuation first re-verifies the child with
`captureForkIdentity` (`codex-runtime-adapter.ts:668`; `startup-orchestrator.ts:559`)
and needs the exact pending event it started from; a fresh continuation keeps
the plain "still pending" check. It is reached by `rig seat continue`,
relaunching the harness. It is reached by `rig seat continue`,
`POST /api/seat/continue/:seatRef` and the per-seat startup route's `continue`
action (`routes/startup.ts:159`).
action (`routes/startup.ts:148`).
**Startup layering is additive and ordered** (`resolveStartup`,
`startup-resolver.ts:15`–`22`): (1) agent base, (2) profile, (3) rig culture
@@ -11,8 +11,8 @@ applies-when: |
compatibility limits that still describe the shipped system.
siblings: [daemon-core.md, coordination-primitive.md]
prerequisite-reads: [../README.md, daemon-core.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# Architecture Invariants, Event System, Compatibility Notes
@@ -21,7 +21,7 @@ This module collects the cross-cutting invariants that do not belong to any
single subsystem: the architecture rules the codebase holds itself to, the
event-system shape, and the intentional compatibility limits.
> Verified against source at main `e8f0ab340db773392ec8be75b072d1c0f3068a50`. Each count below sits beside the
> Verified against source at main `2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7`. Each count below sits beside the
> command that produces it; run the command from the repository root to refresh
> it.
@@ -61,20 +61,19 @@ system-level invariants.
`monotonicFactory()`. Restore does not pick the newest session by max
ULID; it resolves the active occupant recorded in the snapshot
(`resolveActiveSnapshotSession`, `active-occupant.ts:74`, called at
`restore-orchestrator.ts:774` and `:971`).
`restore-orchestrator.ts:767` and `:964`).
13. Readiness checking is a retry loop with exponential backoff and
configurable timeout, using adapter-specific probes (Claude TUI
indicator, Codex ready message, terminal immediate).
14. Restore outcome states are a fixed set (`RestoreNodeResult.status`,
`types.ts:475`): `resumed`, `rebuilt`, `fresh-primed`,
`awaiting-decision`, `attention_required`, `failed`, plus
`operator_recovered` (set only by later reconciliation), `fresh`
(retained only for the legacy continuity-restoring skip path) and
`left_attached` (a seat joined with `rig attach` as an external CLI, which
restore leaves as it is). `rebuilt` = new process assembled from artifacts.
`operator_recovered` (set only by later reconciliation) and `fresh`
(retained only for the legacy continuity-restoring skip path). `rebuilt` =
new process assembled from artifacts.
15. Restore honesty: a failed resume stops loudly as `awaiting-decision`, with
the blank session rolled back and no session running
(`restore-orchestrator.ts:1055`). No automatic fresh fallback. Fresh launch
(`restore-orchestrator.ts:1048`). No automatic fresh fallback. Fresh launch
is explicit follow-up only (`rig up --fresh <seats...>`,
`packages/cli/src/commands/up.ts:99`).
16. Post-command handoff required on `up`, `down`, `restore`,
@@ -137,7 +136,7 @@ comments:
caller's opt-in to wait, and a failed wait returns without sending. Otherwise
only positive evidence of an open
picker or approval prompt refuses a send; a busy or unknown seat gets the
message with an advisory warning (`session-transport.ts:1427`–`1433`). The
message with an advisory warning (`session-transport.ts:1402`–`1408`). The
audited `--dangerously-interact` override is the only way past an open prompt.
- **Non-interruptive mode is per-launch flags only.** It never changes
permissions or native settings files, applies only to full-bypass Claude Code
@@ -222,18 +221,7 @@ SSE delivery surfaces include the following. The daemon has **11**
`git grep -l 'streamSSE(' -- packages/daemon/src/routes | wc -l`).
- `GET /api/events` — global stream of all events (`server.ts:730`
`app.route("/api/events", eventsRoute)`); `?rigId=` keeps one rig's. Each
event's SSE `id` is its `seq`. Before going live, a connection replays
stored events, read and written 500 at a time (`routes/events.ts`):
- with a `Last-Event-ID` header (an `EventSource` sends it when it
reconnects), every event after that `seq`, whatever `replay` says;
- otherwise `?replay=<n>`: the newest `n` events (of the rig, with
`rigId`); `replay=0` starts from now; `replay=all`, no `replay`, a
count past the log's size, or any other value, replays the whole log.
Either way the replay ends at the newest event stored as the connection
subscribes; the events stored meanwhile were buffered and follow it, each
once and in order, before the stream goes live.
`app.route("/api/events", eventsRoute)`).
- `GET /api/stream/watch` — new stream items (`routes/stream.ts:194`).
- `GET /api/queue/watch` — queue/inbox coordination events
(`routes/queue.ts:1054`, with a `/sse` alias at `:1055`).
@@ -278,15 +266,9 @@ Intentional limits that still describe the shipped system:
5. `rig ask` transcript search prefers `rg`, falls back to `grep -E`
(`history-query.ts:252`, `:267`); quality/perf varies by backend.
6. Chat is rig-scoped only — no cross-rig channels or DMs.
7. `--verify` on `rig send` reports what the pane shows, not agent
acknowledgement. By default it compares the message (whole for a
header-carrying send, the first 40 characters for a raw one) in captures
before and after the send, leaving out the current input box; with
`transport.verify_input_box` on, Claude Code and Codex sends are verified
only for a seat proven idle before the paste and on input-box evidence; a
busy or unknown seat reads unconfirmed (`transport-and-transcripts.md` §3
step 5).
When it cannot confirm, it reports `rendered-unconfirmed`, not a failure.
7. `--verify` on `rig send` checks pane content for message visibility, not
agent acknowledgement: it compares occurrences of the message's first 40
characters before and after the send (`session-transport.ts:1580`–`1583`).
8. Terminal node readiness is shell-ready only — no service health probes.
9. Managed-app service surfaces are descriptive only — OpenRig does not
auto-inject service URLs/tokens into agent prompts beyond authored
@@ -7,13 +7,13 @@ domains: [engineering-advisor, operating-advisor]
applies-when: |
Tracing allowlisted file access, conflict-checked writes, progress indexing,
steering composition, or the daemon health-summary endpoints.
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# Content Surfaces — Files, Progress, Steering, and Health
Source snapshot: `e8f0ab340db773392ec8be75b072d1c0f3068a50`. This describes the source at that commit;
Source snapshot: `2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7`. This describes the source at that commit;
it does not establish the version or behavior of a running daemon.
The daemon exposes filesystem content through the files, progress, and
@@ -10,8 +10,8 @@ applies-when: |
transactional handoff guarantee, or where queue closure is enforced.
siblings: [workflow-runtime.md, mission-control.md, daemon-core.md]
prerequisite-reads: [../README.md, daemon-core.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# Coordination Primitive — Stream/Queue/Inbox/Outbox
@@ -24,7 +24,7 @@ filesystem path remains untouched, and the daemon-backed `rig queue` /
only to SQLite (`packages/cli/src/commands/queue.ts:19`,
`packages/cli/src/commands/stream.ts:11`).
> Verified against source at main `e8f0ab340db773392ec8be75b072d1c0f3068a50`. Each count below sits beside the
> Verified against source at main `2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7`. Each count below sits beside the
> command that produces it; run the command from the repository root to refresh
> it.
@@ -44,7 +44,7 @@ Five host-scoped tables back the primitive, one per migration `023`–`027` in
`archived_at` may be set, `stream-store.ts:217`).
- **`queue_items`** (`024_queue_items.ts`) — L3 owned-work queue.
Unless `--id` is supplied, the CLI generates the create request's `qitem_id` (TEXT PK) before sending as `qitem-<UTC YYYYMMDDHHMMSS>-<16 hex>` (64 random bits) and prints it to stderr as a request identity, not proof of persistence; reuse that ID with `--id` and the unchanged payload after an unknown outcome, while daemon callers without an ID still use `newQitemId()` and its `qitem-<UTC YYYYMMDDHHMMSS>-<8 hex>` form.
State enum (**8** values, `QUEUE_STATES` at `queue-repository.ts:33`;
State enum (**8** values, `QUEUE_STATES` at `queue-repository.ts:32`;
`sed -n '/^export const QUEUE_STATES/,/] as const/p' packages/daemon/src/domain/queue-repository.ts | grep -c '^ "'`):
`pending | in-progress | done | blocked | failed | denied | canceled |
handed-off`. Carries `closure_reason`, `closure_target`,
@@ -97,15 +97,15 @@ Routes import these; services are Hono-free:
(`findOverdue`, `:3378`), nudge-result tracking (`recordNudgeAttempt`,
`:3663`). Nothing in the daemon writes the `last_heartbeat` column
(`queue-pickup.ts:16`). Cross-rig validation hook exposed as `validateRig`
constructor option (`queue-repository.ts:717`).
constructor option (`queue-repository.ts:711`).
- **`queue-transition-log.ts`** — append-only state-transition log; used by
`queue-repository.ts`, exposed as the read-only property
`QueueRepository.transitionLog` (`queue-repository.ts:666`).
`QueueRepository.transitionLog` (`queue-repository.ts:660`).
- **`hot-potato-enforcer.ts`** — pure validator for the load-bearing API
contract (see §3).
- **`inbox-handler.ts`** — mailbox handler: drop (idempotent on `inbox_id`,
`:100`), absorb (promotes a pending entry to a `queue_item`, idempotent,
`:155`), deny (records reason, `:215`). The handler has no auth hook
`:99`), absorb (promotes a pending entry to a `queue_item`, idempotent,
`:154`), deny (records reason, `:214`). The handler has no auth hook
(`inbox-handler.ts:71`); identity comes from the route. `POST /inbox/drop`
takes the sender only from the `X-OpenRig-Session` header through
`requireSenderIdentity` (`routes/queue.ts:1094`) and answers 400
@@ -122,148 +122,26 @@ Routes import these; services are Hono-free:
Create, handoff and handoff-and-complete return the committed row without
waiting for terminal delivery. Each stages a deterministic
`wake-intent-<qitem>` outbox row in its transaction and delivers it after
commit (`queue-repository.ts:1412`–`1424`, `:1475`–`1483`). The returned
commit (`queue-repository.ts:1404`–`1416`, `:1467`–`1475`). The returned
`lastNudgeResult` is therefore normally null; `rig queue show <id>` reads the
wake result later. If create cannot retain the intent, the task is still saved
and the row records `failed:wake not retained: <reason>`. Startup reconciles
and drains intents left pending by a crash once (`startup.ts:2361`–`2362`).
`maybeNudge` remains only as the path for a repository with no outbox.
**Which field answers "did it reach them".** `deliveryOutcome` is the gateway
(human-delivery) ledger, derived at read time
(`QueueRepository.deliveryOutcomeFor`): `posted` or `transport-failed` from the
row's Slack receipt notes, or `never-posted` when a gateway-routed row still
has no receipt once the post window has passed. A task delivered only to an
agent's pane has no such receipt, so it reads `null`. A task that also has a
gateway or human-notification side can carry a value, and the CLI then reports
that gateway side too. For the pane side, read:
- `lastNudgeWireResult`: the wake send's result as stored (for example
`verified`, `delivered-ack-pending`, `indeterminate:…`, `retained:…`,
`failed:…`). A claim never rewrites it; a later wake replaces it.
- `lastNudgeResult`: the result as presented. It is the stored result, except
that a claimed row whose stored result is `delivered-ack-pending` or
`indeterminate:…` reads `claimed` (`presentedNudgeResult`). Unclaiming shows
the stored result again.
- `claimedAt`: when the destination claimed the row. Closing doesn't clear it.
- `pickup`: the derived pickup receipt (`unclaimed`, `working`, `needs-input`,
`stalled-after-claim`, `parked`, `terminal`), computed at read time.
A claimed item whose owner's activity reports outstanding input reads
`needs-input`, with the reason in `pickup.evidence`. That covers an item in
progress and a `pending` item that kept its claim, after an update back to
`pending` or a reopen. A Codex permission request the daemon couldn't confirm on
screen reads `owner may need input (not verified: permission_request_unverified)`.
The input signal can outlast the prompt it reported, so it never pauses
recovery. When the claim would otherwise read `stalled-after-claim`, the receipt
keeps that evidence in `pickup.stall`, and the stuck sweep's finding, its
backstop and manual dispositions behave exactly as for `stalled-after-claim`.
Queue reads, the pickup view and the execution view share this receipt
(`queue-pickup.ts`). Unclaimed, blocked and terminal items keep their receipts.
A claim isn't proof that the body was read or that the terminal render was
verified, and closing a task isn't a new delivery receipt: a claimed task that
is later closed still reads `claimed` when its stored result was unconfirmed
(`delivered-ack-pending` or `indeterminate:…`); a `verified` or `failed:…`
result reads as stored. For such an unconfirmed, claimed item,
`rig queue create --verify` says "Terminal receipt unconfirmed, but the seat
has claimed the item (claimed)."; for any claimed item it adds "A claim shows
the seat took the item; it does not prove the body was read."
(`terminalDeliveryResult`, `packages/cli/src/commands/queue.ts`).
The standing detector in `queue-stuck-sweep.ts` creates findings through the
queue repository, with `evidenceRef: rig queue show <source-qitem-id>` pointing
to the underlying durable work row. Unclaimed and undelivered findings go to
the destination's orchestrator when one is derivable, otherwise to the
destination itself, and that seat may be dead. Overdue claims go to their
destination. Repeated detections reuse the existing finding without changing
its route; when the source condition resolves, the sweep closes its own finding.
Previously, falling out of a reporting query could itself close a delivery or
unclaimed finding. An unconfirmed retry, a fresh human-notification post window,
an unreadable human registry or a larger reporting-age setting could therefore
look like successful recovery. These findings now require source evidence:
closure, pickup or parking of the work, a verified/accepted wake, or a posted
notification for the current human recipient and episode, as appropriate to the
finding. `delivered-ack-pending`, `indeterminate:…`, missing results and unrelated
legacy human receipts do not prove resolution. Uncertainty keeps an existing
finding open without itself sending another wake or creating another finding.
A verified wake resolves a wake failure, not an unclaimed obligation: delivery
does not prove pickup.
A legacy human notice with no owner-notification history can still resolve from
the existing item-scoped posted ledger when its recipient is confirmed human.
This covers older pending asks and parks without inventing an episode that was
never recorded. A registered alias needs a readable registry to establish its
human recipient; reserved human references remain known during an outage.
Legacy receipts carry no recipient key, so a destination reroute requires a new
matching notification episode. A legacy park is accepted only when no earlier
notice or reroute predates it, and later history contains only receipts or
asking-agent reroutes within that park. A re-park, an exit from the park or an
ambiguous append cannot establish the same recipient without owner history;
the finding waits for a new matching episode or other source resolution.
Rerouting only the asking agent within an otherwise unchanged park preserves
its human recipient (`blockedOn`).
Modern notifications still require their current episode, and an unrelated
legacy human receipt cannot resolve an agent wake.
A known hold still leaves recovery with the existing mechanism: an ask on
Steering without an alert, a wake held by the seat typing guard, or a live
quiet-release job. Its closure note names that disposition rather than claiming
delivery or pickup. An unreadable quiet hold is not positive closure evidence.
Claimed work in either `pending` or `in-progress` uses the same pickup derivation;
returning a row to `pending` while retaining its claim no longer hides a later
stall or closes its existing stalled-claim finding. A retained failed wake cannot
override that pickup evidence. The agent's claim still does not establish delivery
of a separate notification to a person.
That automatic closure records `stuck-sweep@system` as the actor in both the
transition and the `queue.updated` audit event, with the source evidence in the
delivery and unclaimed-obligation closure notes. Previously it credited the finding's source agent
with the detector's action. The finding's source,
destination and claim remain its existing ownership records; the underlying work
is not reassigned, and existing transition history is not rewritten.
`queue-stuck-sweep-resolution.test.ts` checks unconfirmed retries, retained
claims, current human-notification episodes and registry outages with migrated
SQLite, including positive recovery controls and no repeated send on an
unresolved retry. `quiet-hold-custody.test.ts` checks existing findings with
readable and unreadable quiet custody. `queue-stuck-sweep.test.ts` checks that
pending and claimed findings close once, name the sweep in their transition and
event, and keep their ownership. The posted-human-notice case also checks the actor while leaving
the person's original ask open. These checks exercise the detector and queue
repository, not a live agent or connector.
The sweep reports six finding kinds. Its custody check covers closures that
name a successor qitem or a seat (`closure_reason = handed_off_to`) in the last
24 hours, plus older closures with open findings. A seat target is satisfied by
a local successor carrying this row's lineage, and any target by a
`custody-verified: <target> …` note on the closed row. A new custody finding for
a human destination goes to the agent that closed the item, using the closing
transition from active or archived history. Later notes and notification
receipts do not change that owner. If the closing actor is missing, human, system
machinery or not admitted by this host's queue, the asking agent receives the
finding if its address is admitted. Human identity includes reserved references
and registered aliases, independent of terminal bindings. Admission uses the
queue's existing address check and does not require a live seat. An agent
destination keeps its existing route; if neither closer nor source is an admitted
agent, the human destination remains the durable fallback. This routing does not
change custody verification or suppress unresolved findings.
If the human registry is unavailable when a custody candidate's owner needs
classification, that candidate keeps its original destination, and one log line
per candidate names it and the registry error. This doesn't infer an agent
identity from an unreadable registry, which may not exist before the first human
is registered. In a pass with no finding to save for a `<name>@external` person,
which includes an install with no registry yet, work bound for agents keeps
routing and repaired findings still close. Such a finding still needs the
registry to admit it when it's saved, so during an outage a pass that must save
one fails at that finding, as it did before this routing. Findings not yet
created in that pass, and the closing of repaired findings, wait for the next
pass that completes. Once the registry can be read again, the next pass creates
the finding and routes it to the closer. Registry recovery restores owner refinement
for new findings; existing findings keep their established route. Reserved human
references and legacy agent names keep their known classifications without a
registry read.
queue repository, with `evidenceRef: rig queue show <source-qitem-id>`
(`queue-stuck-sweep.ts:605`) pointing to the underlying durable work row. This
satisfies the existing human-route evidence contract without changing
destination resolution: a finding goes to the destination's orchestrator when
one is derivable, otherwise to the destination itself, and that seat may be
dead. Repeated detections refresh the existing finding; when the source
condition resolves, the sweep closes its own finding (`:612`–`637`). The sweep
reports six finding kinds (`:50`). Its custody check covers closures that name
a successor qitem or a seat (`closure_reason = handed_off_to`) in the last 24
hours; a seat target is satisfied by a local successor carrying this row's
lineage, and any target by a `custody-verified: <target> …` note on the closed
row (`:168`–`244`, `:519`–`554`).
## 3. The hot-potato closure contract (where queue closure is enforced)
@@ -272,17 +150,15 @@ contract. `validateClosure` (`hot-potato-enforcer.ts:59`) checks a transition
against `CLOSURE_REASONS` (`:18`; **7** values:
`sed -n '/^export const CLOSURE_REASONS/,/] as const/p' packages/daemon/src/domain/hot-potato-enforcer.ts | grep -c '^ "'`):
`state=done` requires `closure_reason ∈ {handed_off_to, denied,
`state=done` requires `closure_reason ∈ {handed_off_to, blocked_on, denied,
canceled, no-follow-on, escalation, superseded}`. The reasons
`handed_off_to | escalation` additionally require
`handed_off_to | blocked_on | escalation` additionally require
`closure_target` (`:86`):
- `handed_off_to` — work continues with a different seat (`closure_target` =
new owner).
- `blocked_on` — an active `state=blocked` park record, rejected with `state=done`.
To wait for a qitem, use `rig queue block <id> --on <child>` or
`rig queue update <id> --state blocked --blocked-on <child>`; the item returns
to pending when its blocker completes.
- `blocked_on` — work is parked pending another qitem (`closure_target` =
blocker `qitem_id`).
- `denied` — receiver rejected the work (`closure_target` = reason text).
- `canceled` — sender or receiver withdrew (`closure_target` = note).
- `no-follow-on` — terminal completion, nothing else needed.
@@ -291,19 +167,19 @@ canceled, no-follow-on, escalation, superseded}`. The reasons
- `superseded` — the row was replaced by cancel-and-replace
(`closure_target` = the successor qitem). The update path records it on
`state=canceled` and refuses it there without a `closure_target`
(`queue-repository.ts:2629`).
(`queue-repository.ts:2619`).
Tier→SLA mapping for `closure_required_at` also lives in
`hot-potato-enforcer.ts` (`TIER_SLA_SECONDS`, `:115`). This validator is
`hot-potato-enforcer.ts` (`TIER_SLA_SECONDS`, `:114`). This validator is
invoked by `QueueRepository.update()` (and `updateWithinTransaction()`), both
through the call at `queue-repository.ts:2531`, so closure is enforced at the
through the call at `queue-repository.ts:2521`, so closure is enforced at the
daemon transaction boundary — the workflow runtime *projects* on closure but
does not otherwise gate it (see `workflow-runtime.md`). The one
workflow-aware check is in the queue: `update()` refuses a terminal close of a
live workflow frontier packet from a non-workflow verb
(`workflow_frontier_packet`, `queue-repository.ts:2557`), through a predicate
(`workflow_frontier_packet`, `queue-repository.ts:2547`), through a predicate
startup injects. "Terminal" there is the queue's terminal set, `done` and
`handed-off` (`queue-repository.ts:52`).
`handed-off` (`queue-repository.ts:51`).
Closure fields are stored only on `state=done`, the park record (`blocked`
with `blocked_on`), the handoff close (`handed-off` with `handed_off_to`) and
@@ -314,11 +190,11 @@ note without a state change as an append-only transition; moving it to
another state requires an explicit reopen with a note. A `qitem-` blocker must
exist and be live on this daemon; when a blocker leaves the active states,
each row parked on it follows the blocker's handoff successor or returns to
`pending` with a durable wake (`queue-repository.ts:2407`–`2529`,
`:2612`–`2656`, `:2931`–`3008`). Claiming a parked row clears its
`pending` with a durable wake (`queue-repository.ts:2397`–`2519`,
`:2602`–`2646`, `:2921`–`2998`). Claiming a parked row clears its
`blocked_on`; the claim transition keeps the former gate as a `closure_target`
audit pointer with no closure reason, which a re-park without `--blocked-on`
reuses (`queue-repository.ts:2233`–`2254`, `:2571`–`2577`).
reuses (`queue-repository.ts:2223`–`2244`, `:2561`–`2567`).
`human-route-enforcer.ts` is a second pure validator at the same boundary: a
`human-gate` row, a row addressed to a human seat, or a park on one must carry
`summary` and `evidence_ref`.
@@ -356,7 +232,7 @@ message-passing closure (never 2PC).**
commit (§2; the forwarded body carries the `nudge` flag) — the sending
daemon never reaches across a host boundary. Before forwarding, the
forwarding daemon stamps its own host id, when it has one, onto the source
session (`stampSelfHostSuffix`, `queue-repository.ts:548`; called at
session (`stampSelfHostSuffix`, `queue-repository.ts:542`; called at
`routes/queue.ts:378` and `:509`), so the target row records the sender as
`member@rig@<forwarding host>`.
- **Idempotency.** On create, the forwarding daemon mints the `qitemId` before
@@ -367,21 +243,21 @@ message-passing closure (never 2PC).**
conflict the origin returns the stored row when destination and source
match (idempotent absorb) and a structured `qitem_id_reuse` error (409) when
they differ (`QueueRepository.create()` catch path,
`queue-repository.ts:1431`–`1472`, with `isQitemPrimaryKeyConflict`,
`:461`). When destination and source match but the body differs, the stored
`queue-repository.ts:1423`–`1464`, with `isQitemPrimaryKeyConflict`,
`:455`). When destination and source match but the body differs, the stored
row is returned unchanged with `createWarning: qitem_body_not_saved`; the
supplied body is not saved (`:1450`–`1454`).
supplied body is not saved (`:1442`–`1446`).
- **Cross-host handoff choreography.** The local atomic close+create cannot
span two DBs, so the route-layer choreography (`crossHostHandoff`,
`routes/queue.ts:316`) runs: successor-create on the target host FIRST (via
the one forward helper, `:397`), local source-close SECOND
(`QueueRepository.closeCrossHostHandoffSource`, `queue-repository.ts:2031`)
(`QueueRepository.closeCrossHostHandoffSource`, `queue-repository.ts:2021`)
— never the reverse. A crash between the two leaves a live duplicate that
the idempotent re-drive converges; the reverse order would leave a closed
source pointing at a successor that does not exist (a dropped potato — the
one forbidden outcome). The successor id is DERIVED, not minted:
`deriveCrossHostSuccessorId(source, destination, host)`
(`queue-repository.ts:489`) → `qitem-xh-<sha256[:16]>` — a pure stateless
(`queue-repository.ts:483`) → `qitem-xh-<sha256[:16]>` — a pure stateless
function, so a re-drive re-derives the same id across daemon restarts and
absorbs on the target PK. *(Residual, inherent to at-least-once delivery
without 2PC: a re-drive naming a DIFFERENT destination derives a different
@@ -398,7 +274,7 @@ message-passing closure (never 2PC).**
host-qualified key appears only in the `closure_target` column on
`queue_items` and its verbatim mirror on `queue_transitions`: the minted
cross-host close note names the 2-part `toSession` only
(`queue-repository.ts:2087`), and `handed_off_to` and the
(`queue-repository.ts:2077`), and `handed_off_to` and the
`queue.handed_off` event stay 2-part.
Re-drive semantics: already-terminal + MATCHING `closure_target` = absorb;
MISMATCH = structured `cross_host_close_conflict` (409), checked in the route
@@ -451,16 +327,6 @@ a daemon route or reconnect policy.
## 5. Route surface
The TUI FEED reads `/api/attention` for human requests and current explicit
human-addressed updates. Its independent update query filters open states and
strict human destinations before the 1001-row sentinel, serves at most 1000
updates with critical then urgent rows first, and requires no transport receipt. These local
FYIs create no approval obligation. `/api/queue/human-updates` remains confirmed
delivery history; the TUI suppresses a history card when the same queue item is
in the current update window. Current-update coverage is independent of the
receipt window, so an unavailable current reader cannot retain closed delivery
history beyond a successful history read. Both reads leave queue state intact.
- `/api/stream` (`server.ts:783`) — `POST /emit` (`routes/stream.ts:56`),
`GET /list` (`:91`, including `sourceSession`, `hintDestination`, `hintTag`,
`since`, `until` and `direction` filters), `GET /watch` + `/sse` SSE
@@ -483,9 +349,7 @@ over a body actor. On a direct request from a known origin it is recorded as
`transport:v1`; `resolveRecordedProvenance` (`:199`) instead records
`origin-unknown:v1` when the origin-unknown marker is set or a relayed request
carried `origin-unknown:v1` (`:207`), `relay:v1` for a relayed request that
carried `transport:v1`, and `claimed:v1` for other relayed requests. `transport:v1`
says the name came from that header, which the CLI sets from the seat's
`OPENRIG_SESSION_NAME`; it isn't an authentication. Without the
carried `transport:v1`, and `claimed:v1` for other relayed requests. Without the
header, the
body actor is recorded as `claimed:v1`; with neither (or, for inbox drop,
without the header) the route answers 400 `actor_required`. The stamp lands in
+12 -12
View File
@@ -9,8 +9,8 @@ applies-when: |
graph, the SQLite schema/migration set, or the route-mount surface.
siblings: [coordination-primitive.md, agent-spec-and-startup.md, lifecycle-snapshot-restore.md]
prerequisite-reads: [../README.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# Daemon Core — Wiring, DB, Migrations, Startup
@@ -20,7 +20,7 @@ OpenRig is a local control plane for multi-agent coding topologies. The daemon
(`@openrig/cli`), the terminal UI (`@openrig/tui`), the web UI (`@openrig/ui`)
and the MCP server all sit on top of.
> Verified against source at main `e8f0ab340db773392ec8be75b072d1c0f3068a50`. Each count below sits beside the
> Verified against source at main `2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7`. Each count below sits beside the
> command that produces it; run the command from the repository root to refresh
> it.
@@ -29,7 +29,7 @@ and the MCP server all sit on top of.
For what OpenRig is and how its packages fit together, read `ARCHITECTURE.md` at
the repository root. This module covers the daemon's own wiring.
### Source footprint at `e8f0ab340db773392ec8be75b072d1c0f3068a50`
### Source footprint at `2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7`
The footprint counts use non-test TypeScript files under each package's `src/`
(tests live in separate `packages/*/test/` directories):
@@ -37,9 +37,9 @@ The footprint counts use non-test TypeScript files under each package's `src/`
| Metric | Count | Directory or command |
|---|---|---|
| All packages | **1205** | `packages/*/src` |
| Daemon | **660** (**431** under `domain/`, **29** under `adapters/`) | `packages/daemon/src`, `…/src/domain`, `…/src/adapters` |
| CLI | **174** | `packages/cli/src` |
| All packages | **1203** | `packages/*/src` |
| Daemon | **659** (**430** under `domain/`, **29** under `adapters/`) | `packages/daemon/src`, `…/src/domain`, `…/src/adapters` |
| CLI | **173** | `packages/cli/src` |
| Web UI | **304** | `packages/ui/src` |
| TUI | **67** | `packages/tui/src` |
| Database migrations | **96** (`001_core_schema.ts` … `097_thread_part_map.ts`; no `093`) | `git ls-files packages/daemon/src/db/migrations \| wc -l` |
@@ -56,7 +56,7 @@ six runtime keys — `claude-code`, `codex`, `pi`, `omp`, `stub`, `terminal`
### The stack
```text
CLI (87 top-level commands) / TUI / web UI / MCP (24 tools)
CLI (87 top-level commands) / TUI / web UI / MCP (18 tools)
|
v
Hono daemon routes (69 app.route() mounts + direct handlers for /healthz,
@@ -73,7 +73,7 @@ Hono daemon routes (69 app.route() mounts + direct handlers for /healthz,
+-- coordination routes (stream / queue / workflow / mission control)
|
v
Framework-free domain services (431 files under packages/daemon/src/domain)
Framework-free domain services (430 files under packages/daemon/src/domain)
|
+-- SQLite state (96 migrations)
+-- tmux / cmux / resume adapters
@@ -226,9 +226,9 @@ The daemon entrypoint `packages/daemon/src/index.ts:306` calls
## 5. Test files
A static count of tracked test files at `e8f0ab340db773392ec8be75b072d1c0f3068a50` (no pass counts are claimed
here; CI runs the suites in `.github/workflows/tests.yml`): daemon **927**,
CLI **261**, web UI **198**, TUI **105**
A static count of tracked test files at `2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7` (no pass counts are claimed
here; CI runs the suites in `.github/workflows/tests.yml`): daemon **923**,
CLI **260**, web UI **198**, TUI **103**
(`git ls-files 'packages/<package>/**/*.test.ts' 'packages/<package>/**/*.test.tsx' | wc -l`).
## See also
@@ -11,8 +11,8 @@ applies-when: |
and the CLI restore-packet command work.
siblings: [daemon-core.md, agent-spec-and-startup.md, transport-and-transcripts.md]
prerequisite-reads: [../README.md, agent-spec-and-startup.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# Lifecycle — Snapshot, Restore, Continuity
@@ -22,7 +22,7 @@ The durable-state half of the core product loop:
captures serialized rig state; restore replays it honestly (no silent
fresh-fallback); restore-check is a separate read-only readiness probe.
> Verified against source at main `e8f0ab340db773392ec8be75b072d1c0f3068a50`. Each count below sits beside the
> Verified against source at main `2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7`. Each count below sits beside the
> command that produces it; run the command from the repository root to refresh
> it.
@@ -37,9 +37,8 @@ All in `packages/daemon/src/domain/types.ts`. The spec/projection types live in
`resumed`, `rebuilt`, `fresh`, `fresh-primed`, `awaiting-decision`,
`failed`, `attention_required`, `operator_recovered`, `n-a`. The restore
result itself, `RestoreNodeResult.status` (`types.ts:475`), has the same
values without `n-a`, plus `left_attached`: **9**
values without `n-a`: **8**
(`grep -E '^ status: "resumed"' packages/daemon/src/domain/types.ts | grep -o '"[^"]*"' | wc -l`).
`left_attached` isn't a `NodeRestoreOutcome`; the inventory projects it as `n-a`.
Where `restore-orchestrator.ts` sets each one:
- `resumed` — `baseStatus = "resumed"` after a legacy resume (`:1030`); the
status is returned only by `finishJoinedResume` (defined `:1320`, called
@@ -62,10 +61,6 @@ All in `packages/daemon/src/domain/types.ts`. The spec/projection types live in
`:1292`, `:1300`).
- `operator_recovered` — only from later reconciliation,
`reconcileNodeRuntimeTruth` (`:1590`, event at `:1727`).
- `left_attached` — a seat joined with `rig attach` as an external CLI,
returned first in `restoreNodeWithCompensation`: nothing is launched and
its binding and claim stay as they were. It doesn't count against the
rollup, and its forecast in `restore-plan-preview.ts` is `leave-attached`.
- **RestoreRigResult** (`types.ts:444`) — the rig-level rollup, **4** values
(`grep '^export type RestoreRigResult' packages/daemon/src/domain/types.ts | grep -o '"[^"]*"' | wc -l`):
`fully_restored`, `partially_restored`, `failed`, `not_attempted`.
@@ -73,26 +68,10 @@ All in `packages/daemon/src/domain/types.ts`. The spec/projection types live in
first three; `not_attempted` is set when pre-restore validation fails
(`:281`).
- **SnapshotData** (`types.ts:351`) — the serialized snapshot payload. It has
**8** optional fields, kept optional so older snapshots still load
**7** optional fields, kept optional so older snapshots still load
(`sed -n '/^export interface SnapshotData/,/^}/p' packages/daemon/src/domain/types.ts | grep -c '?:'`):
`activeSessionIdByNode?`, `activeOccupantsByNode?`, `periodicResumeSourcesByNode?`,
`topologyRoster?`, `pods?`, `continuityStates?`, `nodeStartupContext?`, `envReceipt?`.
Periodic snapshots retain a down agent's whole authoritative resume-source
session in `periodicResumeSourcesByNode`, without marking it running. A named
single-agent launch using automatic periodic selection can resume that exact
carried source; an explicit snapshot or whole-rig restore still asks about an
absent agent with resumable history. Tokenless and non-resume-policy agents
keep their existing fresh path. Only the node's newest session with a token
and `resume_if_possible` policy is carried; a current fresh-successor relation
must agree. Named launch rechecks the live newest session, resume policy and
fresh-successor relation under its lifecycle lease, before changing any seat
state; a source retired after capture stops and asks without launching.
Idle-shell carry advice uses automatic selection again, not an
explicit snapshot that disables carry. The selected source is shared by preflight and post-launch
restore; explicit reviewed-history selection keeps its existing checks.
Ranking, periodic cadence and `--fresh` are unchanged. Retention is best-effort
if the earlier-source lookup fails: capture still records current crash
insurance and logs that the resume sources were unavailable.
`activeSessionIdByNode?`, `activeOccupantsByNode?`, `topologyRoster?`,
`pods?`, `continuityStates?`, `nodeStartupContext?`, `envReceipt?`.
- **NodeStartupSnapshot** (`types.ts:344`) — persisted restore replay input:
classification-free projection entries, resolved startup files, startup
actions, runtime.
@@ -100,7 +79,7 @@ All in `packages/daemon/src/domain/types.ts`. The spec/projection types live in
restore replay seam: persists only entry identity + source metadata, NOT
stale `classification`, `conflicts`, or `noOps` (Architecture Rule 10).
Restore rebuilds each entry as `safe_projection` with empty `conflicts` and
`noOps` (`restore-orchestrator.ts:1177`, `:1182`, `:1183`).
`noOps` (`restore-orchestrator.ts:1170`, `:1175`, `:1176`).
## 2. Snapshot, restore, continuity domain services
@@ -118,8 +97,8 @@ All under `packages/daemon/src/domain/`:
accept only snapshots that pass `isRestoreUsableSnapshotData` (`:250`).
- `restore-orchestrator.ts` — resume, checkpoint delivery, startup replay,
live continuity consultation, topology ordering (`computeRestorePlan`,
`:676`, ordering only `delegates_to` and `spawned_by` edges, `:71`, among
the roster's intended nodes, `:677`), and service boot gating before agent
`:669`, ordering only `delegates_to` and `spawned_by` edges, `:71`, among
the roster's intended nodes, `:670`), and service boot gating before agent
restore (`serviceOrchestrator.boot`, `:331`).
- `restore-preconditions.ts` — `validatePreRestore` (`:7`), the one
pre-restore validation shared by restore and restore-check (§3, §5.1).
@@ -153,7 +132,7 @@ Restore behavior, each point checked in `restore-orchestrator.ts`:
- Resolves each node's occupant from the snapshot's recorded active-occupant
maps, not by picking the newest session row
(`resolveActiveSnapshotSession`, `active-occupant.ts:74`, called at
`restore-orchestrator.ts:774` and `:971`). An ambiguous occupant fails the
`restore-orchestrator.ts:767` and `:964`). An ambiguous occupant fails the
node loudly (`activeOccupantAmbiguityError`, `active-occupant.ts:97`).
- Consults live `continuity_state`; preserves state when a node is already
`restoring` (`:777–791` — `SELECT status FROM continuity_state …` at
@@ -166,38 +145,35 @@ Restore behavior, each point checked in `restore-orchestrator.ts`:
prefilters missing optional artifacts into warnings; **hard-fails a node if
a required startup file is missing** (`:1161` — status `"failed"`, error
"Missing required startup files: …"). Before any mutation,
`validatePreRestore` (`restore-preconditions.ts:7`, called from
`RestoreOrchestrator.restore`) finds a node that will consume replay and is
missing a required startup file (`required_startup_file_missing`, or
`startup_owner_root_missing`). Restore holds only that node: it is not
launched and is reported `failed` ("Not launched: …" with the blocker's
message and remediation), and the rest of the rig restores. When every node
the restore would launch is held, or for any other blocker, the whole restore
stops before any mutation (outcome `pre_restore_validation_failed`). Its other
`validatePreRestore` (`restore-preconditions.ts:7`, called at
`restore-orchestrator.ts:271`) already blocks the whole restore when a node
that will consume replay is missing a required startup file
(`required_startup_file_missing`, `restore-preconditions.ts:151`; outcome
`pre_restore_validation_failed`, `restore-orchestrator.ts:291`). Its other
blockers are `invalid_snapshot_data` (`restore-preconditions.ts:27`),
`invalid_topology_roster` (`:78`), `checkpoint_missing_node_cwd` (`:91`),
`service_rig_root_missing` (`:187`) and `service_compose_file_missing`
(`:197`). The `/api/up` route returns
`startup_owner_root_missing` (`:139`), `service_rig_root_missing` (`:187`)
and `service_compose_file_missing` (`:197`). The `/api/up` route returns
that outcome as HTTP 409 with `status: "not_attempted"`
(`routes/up.ts:162`). A node that resumes its exact native session replays
no startup files, actions, guidance or skills (`replayContained`,
`restore-orchestrator.ts:1114`); its saved Claude activity-hook selection
`restore-orchestrator.ts:1107`); its saved Claude activity-hook selection
is reapplied before native resume. A skipped hook reapply adds a warning
without changing the restore outcome.
- Writes a **transcript boundary marker before re-launch**
(`writeBoundaryMarker`, `:900`, called before `launchNode` at `:911`) when
(`writeBoundaryMarker`, `:893`, called before `launchNode` at `:904`) when
the transcript store is enabled.
- Refuses to restore over live sessions (`:259–262` — `rig_not_stopped`: "Rig …
has live sessions. Stop the rig with 'rig down' before restoring, or use the
latest auto-pre-down snapshot."). A tmux probe that fails also blocks it
(`classifyRunningSessions`, `:612`).
(`classifyRunningSessions`, `:605`).
- Checks that the harness actually resumed. The resume adapters judge the
pane with `assessNativeResumeProbe` (`domain/native-resume-probe.ts:84`). A
pod-aware resume counts as resumed only when startup reports `resumed`
continuity or the launched session row carries the snapshot's resume type
and token (`launchedSessionMatchesSnapshotResume`,
`restore-orchestrator.ts:1390`); a reported `fresh` continuity without that
proof rolls back to `awaiting-decision` (`:1228–1245`).
`restore-orchestrator.ts:1383`); a reported `fresh` continuity without that
proof rolls back to `awaiting-decision` (`:1221–1238`).
- Saves a `rig up --non-interruptive` or `--no-non-interruptive` choice on
the rig only after the live-session check and pre-restore validation pass
(`:301`); see [adapters-and-runtimes.md](adapters-and-runtimes.md) for the
@@ -205,24 +181,24 @@ Restore behavior, each point checked in `restore-orchestrator.ts`:
`launchNodeSubset` (`:392`) and `launchSingleNode` restore chosen seats rather
than the whole rig; unlike a full restore, a failed tmux probe there proceeds
with a `liveness_probe_unknown` warning (`:517`).
with a `liveness_probe_unknown` warning (`:510`).
**Restore-honesty rules** (texts in `architecture-rules-and-event-system.md`;
cited here by number with the code that carries them):
- Rule 7 — `RESTORE_POLICY_LEVEL`
(`packages/daemon/src/domain/profile-resolver.ts:106`) orders the three
policies, and `resolveRestorePolicy` (`profile-resolver.ts:589`) rejects a
policies, and `resolveRestorePolicy` (`profile-resolver.ts:550`) rejects a
profile or member value that broadens it.
- Rule 14 — the outcome set is `RestoreNodeResult.status` (`types.ts:475`,
§1); `rebuilt` is set only when a checkpoint is written
(`restore-orchestrator.ts:1097`).
(`restore-orchestrator.ts:1090`).
- Rule 15 — in `restore-orchestrator.ts`, a `resume_if_possible` seat with a
session but no token stops as `awaiting-decision` before launch unless
`--fresh` names it (`:812–831`); a failed resume kills the blank session
(`rollbackToZeroSession`, `:940`) and returns `awaiting-decision`
(`:1055`); pod-aware resume passes
`allowFreshFallback: !(isPodAware && resumeRequested)` (`:1224`). Fresh
`--fresh` names it (`:805–824`); a failed resume kills the blank session
(`rollbackToZeroSession`, `:933`) and returns `awaiting-decision`
(`:1048`); pod-aware resume passes
`allowFreshFallback: !(isPodAware && resumeRequested)` (`:1217`). Fresh
launch is explicit: `rig up --existing <rig> --fresh <seats...>`
(`packages/cli/src/commands/up.ts:99`). On a terminal, `rig up` asks
yes or no for each `awaiting-decision` seat and re-posts with the accepted
@@ -280,13 +256,13 @@ cited here by number with the code that carries them):
- **`rig seat continue <seat>`** — when a fresh launch stops at a native
prompt before its startup context is delivered, the startup orchestrator
fails it as `attention_required` and adds "run: rig seat continue
<session>" (`startup-orchestrator.ts:822`). The command
(`packages/cli/src/commands/seat.ts:816`) calls
`POST /api/seat/continue/:seatRef` (`routes/seat.ts:367`), which runs
`continueStartup` (`seat-lifecycle-service.ts:723`): it re-checks the
<session>" (`startup-orchestrator.ts:600`). The command
(`packages/cli/src/commands/seat.ts:633`) calls
`POST /api/seat/continue/:seatRef` (`routes/seat.ts:293`), which runs
`continueFreshStartup` (`seat-lifecycle-service.ts:395`): it re-checks the
binding, pane and runtime, then delivers the pending context to the same
conversation without relaunching. In a restore, a pod-aware seat in this
state reports `attention_required` (`restore-orchestrator.ts:1265`). See
state reports `attention_required` (`restore-orchestrator.ts:1258`). See
[agent-spec-and-startup.md](agent-spec-and-startup.md) for the startup side.
- **Per-seat startup decision** — `POST /api/startup/:rigId/:logicalId`
(`routes/startup.ts:142`) takes `resume`, `start`, `fresh` or `continue`
@@ -405,20 +381,6 @@ daemon's `/api/transcripts/<session>/full`, `:156`), `read <packet-dir> [--json]
cross-runtime v0 standard — Claude Code and Codex transcripts both supported
via runtime parsers + redaction (`packages/cli/src/restore-packet/`).
### Shared history inspection
`GET /api/startup/:rigId/nodes/:nodeId/history` lists captured histories for one durable node in the current, policy-ranked restore snapshot. `POST` to the same path plus `/validate` with exactly `{ "snapshotId": "…", "historySessionId": "…" }` rechecks one listed choice. Both routes use startup's terminal bearer authentication. The CLI exposes the same server decisions with `rig seat history --rig <rig-id> --node-id <node-id> --json`; pass both `--snapshot-id` and `--history-session-id` to validate. These calls perform no snapshot capture, token refresh, launch, or database write.
The list includes captured Claude and Codex rows that the current restore policy and native resume adapter can structurally attempt. Codex requires an explicit `codex_id` and token; implicit `codex_last` cannot name a particular history. Claude accepts `claude_id` and `claude_name` with tokens. The server checks exact rig and node membership, the snapshot's intended roster and runtime, and the node's current occupant compatibility. A change to a sibling node does not retarget or invalidate this node's list. A changed or pruned snapshot, removed history row, same-name replacement node, ambiguous current occupant, or incompatible source is refused; validation never substitutes another history.
`tokenState` (`present`, `stale`, or `unverified` for listed rows) describes the saved verification record and its age. Eligibility means a future launch may **attempt** native resume. A stored row can share a native token with another row, and neither listing nor validation proves that a native conversation still exists.
### Selected history startup
`GET /api/startup/:rigId/nodes/:nodeId?snapshotId=<listed-id>&historySessionId=<listed-id>` returns a read-only review for one listed choice: the selected row's forecast, current terminal observation, consumed launch setting sources (including a member permission envelope or Claude rules), and an opaque revision. The permission view uses restore's pure policy resolution: a readable custom policy's current launch posture can supersede its persisted cache, and a changed resolved decision requires re-review. It refuses a captured row whose native type/token, provenance, restore policy, or harness no longer agrees with the live registry row. The response contains no native token. `POST /api/startup/:rigId/nodes/:nodeId` accepts exactly `{ "action": "resume", "snapshotId": "…", "historySessionId": "…", "revision": "…" }`. Both routes require startup's terminal bearer token. The selected route accepts no fresh, continue, arbitrary token, or alternate target. Review does not capture a snapshot or prepare a provider. `rig seat history` also uses a strictly read-only daemon status check, so inspection does not remove stale daemon state.
Apply revalidates the same current compatible source, durable node and saved launch settings after prerequisite and presence checks. The existing narrow restore engine carries that exact historical row through legacy and pod-aware native resume, with a second identity check at native dispatch after preparation. Before the new registry row is committed, selected startup also checks that no other attachment or running occupant claimed the node during terminal creation or pane observation; a collision leaves that claimant intact and reports the terminal outcome for inspection. An occupied or uncertain terminal blocks the attempt. No alternate history or fresh conversation is selected. The response projects exact typed references and outcome fields; only diagnostic text is redacted, so even a short native name cannot corrupt an ID. It names the selected source row and the newly registered result row when one exists; only the engine's joined native continuity proof produces success. If a change occurs after shell creation, the result reports a partial or uncertain attempt for inspection. The existing engine handles native prerequisites, compensation and proof. This surface adds no separate per-agent prelaunch probe; native checks remain at execution. Physical tmux cleanup still addresses sessions by name, so it cannot prove ownership against an external same-name replacement outside the selected registry guard.
## See also
- `agent-spec-and-startup.md` — StartupOrchestrator persists the replay
@@ -7,13 +7,13 @@ domains: [engineering-advisor, operating-advisor]
applies-when: |
Tracing slice, mission, rig, or fleet review data back to scope documents,
proof artifacts, judgments, queue state, or frozen exports.
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# Living Notes — Composed Review and Frozen Exports
Source snapshot: `e8f0ab340db773392ec8be75b072d1c0f3068a50`. This describes the source at that commit;
Source snapshot: `2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7`. This describes the source at that commit;
it does not establish the version or behavior of a running daemon.
Living Notes builds review payloads from existing documents and recorded
+14 -15
View File
@@ -6,13 +6,13 @@ topics: [coordination, observability]
domains: [engineering-advisor, operating-advisor, orchestrator, human-operator]
applies-when: |
Need to know how the daemon-backed Mission Control surface works — the
seven views, the nine write verbs, the action audit table, the
seven views, the eight write verbs, the action audit table, the
bearer-token middleware, or how queue observability maps to the
coordination primitive's sources.
siblings: [coordination-primitive.md, workflow-runtime.md, ../ui/project-and-for-you.md]
prerequisite-reads: [../README.md, coordination-primitive.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# Mission Control — Queue Observability + Verb Contract
@@ -20,12 +20,12 @@ last-updated: 2026-10-08
Mission Control is the daemon's queue-observability and operator-action
surface, mounted at `/api/mission-control` over the coordination primitive's
`queue_items` and `stream_items` (see `coordination-primitive.md`). It has
seven views, nine verbs, a recent-ships cap of 10
seven views, eight verbs, a recent-ships cap of 10
(`mission-control-read-layer.ts:107`), and a daemon-backed action audit
table. In the web UI the old `/mission-control` page now redirects to
`/for-you`, and the For You feed carries the verb actions (§6).
> Verified against source at main `e8f0ab340db773392ec8be75b072d1c0f3068a50`. A bare file name such as
> Verified against source at main `2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7`. A bare file name such as
> `mission-control-read-layer.ts:31` is in
> `packages/daemon/src/domain/mission-control/`; `domain/…`, `routes/…`,
> `middleware/…`, `db/…`, `index.ts`, `server.ts` and `startup.ts` are under
@@ -85,14 +85,14 @@ There is no filesystem fallback. When no `StreamStore` is wired,
> `rig` or `rig tui` is called the "mission-control TUI" in source
> (`packages/tui/README.md:1`), but it does not call these routes.
## 2. The nine verbs (the write contract)
## 2. The eight verbs (the write contract)
`MISSION_CONTROL_VERBS` (`mission-control-action-log.ts:14–29`) is the verb
`MISSION_CONTROL_VERBS` (`mission-control-action-log.ts:14–26`) is the verb
list. The verbs execute through `MissionControlWriteContract.act`
(`mission-control-write-contract.ts:131`); the closure mapping is
`verbToClosure` (`:509`).
Verbs: **9** — `sed -n '/^export const MISSION_CONTROL_VERBS = \[/,/\] as const;/p' packages/daemon/src/domain/mission-control/mission-control-action-log.ts | grep -c '^ "'`
Verbs: **8** — `sed -n '/^export const MISSION_CONTROL_VERBS = \[/,/\] as const;/p' packages/daemon/src/domain/mission-control/mission-control-action-log.ts | grep -c '^ "'`
| Verb | Effect |
|---|---|
@@ -103,8 +103,7 @@ Verbs: **9** — `sed -n '/^export const MISSION_CONTROL_VERBS = \[/,/\] as cons
| `hold` | `state="blocked"`, `closure_reason="blocked_on"` |
| `drop` | `state="done"`, `closure_reason="canceled"` |
| `handoff` | same closure and new destination item as `route`; the 4-step shape (see below) |
| `resolve` | for a `blocked` item parked on a human seat or a well-formed typed gate (`external:`, `fold:`, `auth:`): moves it to `in-progress` with required decision/satisfaction evidence in the transition note; typed waits are recorded as blocker resolutions, not human decisions. Qitem dependencies, malformed gates and non-blocked items are refused; no closure and no new item (`:369`, `:386`, `:410–411`). After commit it sends a best-effort nudge with the decision text to the item's owner unless `notify: false` (`:450–467`); the CLI wrapper is `rig queue resolve <qitemId> --decision <text>` (`packages/cli/src/commands/queue.ts:828`) |
| `answer` | only for a `pending` ask addressed to the acting person: a choice (`questionId` + `optionId`) through `QueueRepository.recordHumanAnswer`, or their words (`decision`) through `resolveDirectHumanReply`, each with its existing meaning (a partial choice set stays pending; words fill the first unanswered question). Once answered, one reply row goes to the asking seat (`human-answer` tag, `replyTo` the ask, an id derived from the ask) and then the ask closes once with `human-decision-resolved`, the order Slack's answer continuation uses. With neither a choice nor words, a complete question set whose return didn't finish is sent again with exactly its recorded answers. A closed ask answers 409 `qitem_already_terminal` and records nothing; a refused answer is 400 `answer_not_recorded`; 409 `answer_not_closed` means the ask stayed open: its return failed, it changed before its close, or a retry came in other words than the reply already returned (that retry isn't sent). The TUI's Feed sends it |
| `resolve` | only for a `blocked` item parked on a human seat: moves it to `in-progress` with the required decision text as the transition note; no closure and no new item (`:369`, `:386`, `:410–411`). After commit it sends a best-effort nudge with the decision text to the item's owner unless `notify: false` (`:450–467`); the CLI wrapper is `rig queue resolve <qitemId> --decision <text>` (`packages/cli/src/commands/queue.ts:828`) |
`annotate` requires `annotation`, `hold` and `drop` require `reason`, `route`
and `handoff` require `destinationSession`, and `resolve` requires a non-empty
@@ -114,12 +113,12 @@ the audit record instead; the wrapped `reason_required` error has no status
mapping, so it returns 500 (`mission-control-action-log.ts:128–133`,
`mission-control-write-contract.ts:245–248`). Items already `done` or
`handed-off` are refused with 409 `qitem_already_terminal`, except under
`resolve`, which answers 409 `qitem_not_leg1_parked` for any item that is not
blocked on a human seat or a well-formed typed gate (`external:`, `fold:`, `auth:`). An unknown item returns 404 `qitem_not_found`
`resolve`, which answers 409 `qitem_not_leg1_parked` for any item not parked on
a human seat. An unknown item returns 404 `qitem_not_found`
(`routes/mission-control.ts:240–251`, `:330–341`).
Each verb except `answer` is one atomic daemon transaction (`answer`'s queue writes are the repository's own, in the order above, then its audit row and event): the queue mutation via
`QueueRepository.updateWithinTransaction()` (`domain/queue-repository.ts:2371`,
Each verb is one atomic daemon transaction: the queue mutation via
`QueueRepository.updateWithinTransaction()` (`domain/queue-repository.ts:2361`,
which keeps the hot-potato closure validation — see
`coordination-primitive.md` §3), an audit row in `mission_control_actions`
(`mission-control-write-contract.ts:207`), and a persisted
@@ -135,7 +134,7 @@ created in the same transaction (`createWithinTransaction`, `:173`), along
with the successor's wake intent (`stageWakeIntent`, `:195`, checked by
`assertTerminalClosureHasIntent`, `:242`). The wake itself runs after commit
(`deliverWakeForSuccessor`, `:264`). It is on unless the caller passes
`notify: false` (`domain/queue-repository.ts:1182`), and a notify failure
`notify: false` (`domain/queue-repository.ts:1174`), and a notify failure
does NOT roll back durable mutations (`mission-control-write-contract.ts:272–275`).
A failed destination create rolls back the source closure, the audit row and
the new item together
@@ -9,14 +9,14 @@ applies-when: |
source routing, bootstrap plan/apply, or the retained package install engine.
siblings: [agent-spec-and-startup.md, plugin-agent-image-context-pack.md]
prerequisite-reads: [../README.md, agent-spec-and-startup.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# Packaging, bootstrap and bundles
This module describes source at main commit
`e8f0ab340db773392ec8be75b072d1c0f3068a50`. Source paths below are repository-relative.
`2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7`. Source paths below are repository-relative.
An npm CLI artifact and a `.rigbundle` have different builders and consumers; neither
source verification nor archive integrity establishes that a daemon has adopted an artifact.
@@ -9,14 +9,14 @@ applies-when: |
agent-image capture/fork/protection, or Claude guided-compaction behavior.
siblings: [packaging-bootstrap-bundles.md, agent-spec-and-startup.md]
prerequisite-reads: [../README.md, agent-spec-and-startup.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# Content libraries and compaction
This module describes source at main commit
`e8f0ab340db773392ec8be75b072d1c0f3068a50`. Source paths below are repository-relative.
`2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7`. Source paths below are repository-relative.
Context packs, plugins and agent images have filesystem-backed content and daemon-side
discovery. Their consumers can read database identity, mutate files, deliver messages or
launch sessions; the whole layer is not a read-only catalog.
@@ -123,24 +123,20 @@ is refused; the other content checks are advisory.
`packages/daemon/src/domain/skill-catalog.ts` resolves a seat's skill loadout from the
catalog at `skills.root` (`catalog.yaml`, schema `openrig.skill-catalog/v1`) and the system,
topology and project selections. For a working directory it projects skills into
`<cwd>/.claude/skills` or `<cwd>/.agents/skills` and tracks what it owns in
`<cwd>/.openrig/skill-loadouts/<runtime>.json`. For a Claude or Codex team seat it takes a
`location` instead: the seat's own storage outside the project, with its `skill-loadout.json`
there, and for Claude the `openrig:<id>` names its generated plugin gives. Each projection
reports `current`, `missing`, `shadowed`, `stale` or `conflicting`.
`rig skill loadout --runtime <runtime>` inspects the composed projection for a working
directory, `rig skill loadout --seat <session|self>` does the same for one seat's own skills,
and both reconcile only with `--apply`; `rig skill audit` is a separate
topology and project selections. It projects skills into `<cwd>/.claude/skills` or
`<cwd>/.agents/skills` and tracks what it owns in
`<cwd>/.openrig/skill-loadouts/<runtime>.json`. Each projection reports `current`, `missing`,
`shadowed`, `stale` or `conflicting`. `rig skill loadout --runtime <runtime>` inspects the
composed projection and reconciles it only with `--apply`; `rig skill audit` is a separate
read-only provenance and freshness audit. Launch applies the selected loadout per seat.
For a Claude Code or Codex seat, the profile resolver also adds each selected plugin's skills to
that loadout (`packages/daemon/src/domain/profile-resolver.ts:309–330`, with
`resolvePluginSkills()` at `skill-catalog.ts:460`). Neither runtime reads skills from the plugin
that loadout (`packages/daemon/src/domain/profile-resolver.ts:287–308`, with
`resolvePluginSkills()` at `skill-catalog.ts:368`). Neither runtime reads skills from the plugin
folder projected into the working directory. The skills are projected under their plain names,
and a skill the profile already selects keeps its source. A kept plugin copy (an edited or
user-owned one, or OpenRig's own copy whose plugin source changed or moved after the loadout
was resolved, `skill-catalog.ts:1060–1068`) gives a `plugin_skill_kept` warning, and an unreadable one gives
was resolved, `skill-catalog.ts:937–945`) gives a `plugin_skill_kept` warning, and an unreadable one gives
`plugin_skill_skipped`. If plugin skills can't be projected at all, the seat starts without them
and gets a `plugin_skills_not_projected` warning (`rigspec-instantiator.ts:2045`).
@@ -192,8 +188,7 @@ The routes mount at `/api/agent-images` and expose list/sync, snapshot/fork,
entry/preview, pin/unpin, delete and prune.
`packages/cli/src/commands/agent-image.ts` owns the image library commands. The top-level
`rig fork` verb (`packages/cli/src/commands/fork.ts`) drives `POST /fork`; `--keep-image`
also captures a pinned image. Forking a Claude Code seat needs Claude Code 2.0.73 or later,
because the fork is launched with its own `--session-id`.
also captures a pinned image.
## Plugins
@@ -268,14 +263,6 @@ Code. Its implementation requires Claude Code, observed usage and a valid intege
post-restore cooldown (10 minutes), the deduplication window (60 seconds) and the
already-triggered latch; an active manual attempt also blocks it.
While the policy is enabled with a valid threshold (`managedCompactionActive` in
`packages/daemon/src/adapters/kernel-authority.ts`, using the enforcer's exported `isValidThresholdPercent`),
`operationalLaunchArgs` merges `idleCompaction: false` into the Claude launch's one `--settings` value, adding one
when there is none. Fresh launch, fork, resume (`claude-code-adapter.ts`) and restore (`claude-resume.ts`) all build
their Claude arguments there. The policy is read at each launch; a running session keeps the settings it was launched
with. Claude Code before 2.1.290 ignores the key, Claude's managed settings take precedence over `--settings`, and
`autoCompactEnabled` is not set.
`/compact` is held behind a preparation attempt:
1. The first above-threshold tick starts an attempt with its own ID, the seat's occupant
@@ -284,22 +271,13 @@ with. Claude Code before 2.1.290 ignores the key, Claude's managed settings take
2. The agent writes `RESTORE-MAP.md.tmp` with its file-edit tool, completion marker last.
Later ticks return `preparation_pending` until that exact map is complete. The map path is under the seat's working directory
(`.openrig/compaction/preparation/<session>/<attempt>/`, with a `.gitignore` of `*`) when
that is writable, otherwise under `compaction/preparation/` in the OpenRig home. An explicit
`policies.claude_compaction.preparation_root` instead places maps at `<root>/<session>/<attempt>/`
for both manual and automatic attempts; failure to prepare that root stops the attempt without fallback.
that is writable, otherwise under `compaction/preparation/` in the OpenRig home.
3. The daemon atomically renames the completed temp file to `RESTORE-MAP.md` in its existing
final send checks, after rechecking the attempt and occupant. An already-complete final map
remains compatible. Only then is `/compact` sent, with the final map path appended to the
compact instruction. Publication failure cannot authorize `/compact`.
4. The continuation then runs below the threshold: turn boundary, restore prompt, then a
read-depth audit prompt (each item marked `FULL`, `PARTIAL` or `NOT_READ`), one stage per
poll tick. A seat refreshes its usage sample only when it takes a turn, so when the sample is
stale, unknown or has no percentage and a stage is pending, the monitor drains that stage
without one (`context-monitor.ts:175`, `claude-compaction-enforcer.ts:436`). Only a fresh,
known sample can start a compaction. The sample-less drain reads readiness from the pane alone
and counts a timer-less live status row, such as `✻ Compacting conversation…`, as work
(`session-transport.ts:153-161`); a recent hook showing work or a waiting person still
refuses. It records no width receipt, because the only figure would be the pre-compact one.
read-depth audit prompt (each item marked `FULL`, `PARTIAL` or `NOT_READ`).
An automatic attempt has a 25-minute ceiling that starts once the prep is delivered. A stale
occupant generation, the policy becoming disabled, or the deadline passing stops it. A prep
@@ -315,7 +293,7 @@ over the global extra file, and an extra file that declares a different seat is
manually through `/api/compaction/trigger`, `cancel` and `state`, which use the terminal
bearer-token middleware; it passes every request through when no token is configured.
`--skip-map` skips the map requirement once, `--cancel` ends the preparation, and `--state`
shows the attempt, its expected map and its deadline. Ordinary approval prompts during preparation can be answered within that same deadline; OpenRig waits without answering them. A manual attempt has 180 seconds for
shows the attempt, its expected map and its deadline. A manual attempt has 120 seconds for
preparation and the idle wait.
The below-threshold continuation checks policy enablement too, except for an explicitly
@@ -11,8 +11,8 @@ applies-when: |
vs tmux-metadata-key naming distinction.
siblings: [daemon-core.md, lifecycle-snapshot-restore.md]
prerequisite-reads: [../README.md, daemon-core.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# Transport, Transcripts, Chat, Ask
@@ -22,7 +22,7 @@ capture/broadcast wrap tmux with honest errors; transcripts are bounded
`tmux capture-pane` snapshots written to files; chat is daemon-backed SQLite;
the daemon's `rig ask` service gathers evidence and never calls an LLM.
> Verified against source at main `e8f0ab340db773392ec8be75b072d1c0f3068a50`. Each count below sits beside the
> Verified against source at main `2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7`. Each count below sits beside the
> command that produces it; run the command from the repository root to refresh
> it.
@@ -33,12 +33,11 @@ blanket-replaced: `rigged` → `rig` is a per-occurrence question, not a global
substitution.
**Axis 1 — MCP tool names are `rig_*`.** `packages/cli/src/mcp-server.ts`
registers **24** tools, all named `rig_*`
registers **18** tools, all named `rig_*`
(`grep -c 'server.tool(' packages/cli/src/mcp-server.ts`), including
`rig_rig_nodes` (`mcp-server.ts:290`), `rig_send` (`:307`),
`rig_capture` (`:341`), `rig_chatroom_send` (`:366`) and `rig_chatroom_watch`
(`:402`), the five queue tools `rig_queue_create`, `_show`, `_list`, `_claim`
and `_update`, and `rig_telemetry_events`. No `rigged_*` name other than the tmux keys below remains in product
`rig_rig_nodes` (`mcp-server.ts:288`), `rig_send` (`:305`),
`rig_capture` (`:339`), `rig_chatroom_send` (`:364`) and `rig_chatroom_watch`
(`:400`). No `rigged_*` name other than the tmux keys below remains in product
source: **0** hits
(`git grep -n 'rigged_' -- ':(glob)packages/*/src/**' | grep -v -c '@rigged_'`).
@@ -86,250 +85,52 @@ Routes: `packages/daemon/src/routes/{transport,transcripts,ask,chat,whoami}.ts`
`SessionTransport.send()`:
1. Resolve the session name (`resolveBySessionName`,
`session-transport.ts:963`): not found → 404; the same name in more than
`session-transport.ts:939`): not found → 404; the same name in more than
one rig → 409. A tmux probe of the session can also refuse it as missing or
tmux as unavailable (`:1317`–`1334`). Pod, rig, global and `--to` list
tmux as unavailable (`:1293`–`1310`). Pod, rig, global and `--to` list
targets go through `POST /api/transport/broadcast`, which resolves them
with `resolveSessions` and sends to each recipient in turn.
2. Take the seat's delivery lease. Every write runs under a per-seat,
serialized lease from the seat delivery guard, installed at daemon start
(`send`, `:1155`). With the seat's typing guard on, the message is held
(`send`, `:1131`). With the seat's typing guard on, the message is held
instead of typed: the result is `outcome: "retained"` with HTTP 200
(`routes/transport.ts:115`); operators use `rig seat set-typing-guard` and
`rig seat held-messages`. This same typing guard has `off` (default),
`draft-aware`, and `hold` modes. The legacy enabled boolean selects hold/off.
`DraftAwareDelivery` makes one attempt for opted-in seats and immediately returns
the exact refusal reason and original held IDs when input is unavailable.
Migration `100_typing_guard_modes` extends existing guard/audit rows and adds
non-executable receipt metadata to the original outbox; it creates no tables
or retry scheduler. Combined wakes preserve every original ID/body.
Legacy storage bits fail safe to hold on rollback; newer legacy audit writes
supersede older saved modes on re-upgrade, including same-value writes.
`rig seat held-messages`.
3. Classify send readiness. Only a positive interactive-prompt reading
(`needs_input`) refuses, with `target_needs_input`
(`session-transport.ts:1482`), unless the caller passes
(`session-transport.ts:1457`), unless the caller passes
`--dangerously-interact --reason`; that override persists an audit record
before sending and refuses the send if it can't
(`prompt_override_audit_unavailable`, `:1471`). A pane that reads `unknown` while the seat's
latest runtime hook says it waits on a person reads as `needs_input` (`session-transport.ts:1878–1889`). A mid-work reading (`running`, from a
(`prompt_override_audit_unavailable`, `:1446`). A pane that reads `unknown` while the seat's
latest runtime hook says it waits on a person reads as `needs_input` (`:1842`). A mid-work reading (`running`, from a
fresh runtime hook or the pane's mid-work patterns,
`findPatternEvidence(recentLines, [...MID_WORK_PATTERNS, CLAUDE_LIVE_STATUS_PATTERN])`,
`:311–315`) or an `unknown` one proceeds with an advisory `warning` (`:1495`,
`:1503`). `--force` has no effect on this path, and combining it with
`:298`) or an `unknown` one proceeds with an advisory `warning` (`:1470`,
`:1478`). `--force` has no effect on this path, and combining it with
`--wait-for-idle` is refused with 400 (`routes/transport.ts:66`–`73`).
4. Two-step tmux send: a unique file/buffer pasted with `paste-buffer -d -r -p`
(`packages/daemon/src/adapters/tmux.ts:625`) → ~200ms delay
(`session-transport.ts:1558`) → separate named `Enter` (`session-transport.ts:1573–1578`). The paste
(`session-transport.ts:1533`) → separate named `Enter` (`:1551`). The paste
targets the seat's registered pane ID, not the session name, and is refused
with no input written if the session no longer holds exactly that pane.
Bracketed paste preserves multiline input in supporting TUIs; the payload
never enters a shell argument. A `--dangerously-interact` answer is pasted
without `-p` (`session-transport.ts:1541`) and Enter is pressed only if the
whole answer is still staged (`:1562`–`1570`). A successful paste proves
without `-p` (`session-transport.ts:1516`) and Enter is pressed only if the
whole answer is still staged (`:1537`–`1545`). A successful paste proves
transport execution, not runtime consumption.
The transport's prompt reader uses the shared `composer-prompts.ts` matchers
from Mr-Neutr0n's #635. Its cursor-aware extension preserves capture styling
and excludes a wholly dim autocomplete suffix after the cursor. The daemon
directly depends on `string-width` to compare Unicode terminal cell widths
with cursor positions and exact staged text; a dependency hoisted from the
TUI would not declare this daemon runtime requirement. Default seats retain
their existing advisory recognition and staged-text verification behavior.
For draft-aware seats, `TmuxAdapter` checks the current composer after buffer
preparation and again immediately before keys. The delivery lease records
the pasted text and any observed collapsed-paste label; Enter requires that
owned input. Late human input, changed recipients or stopped queue wakes refuse
further input or hold the message; ambiguous writes remain indeterminate. Nothing
is retried automatically. Unicode cell widths use the daemon's direct
`string-width` dependency. See [per-seat terminal delivery](../../reference/seat-delivery.md)
for activation, persistence, inspection and lifecycle tradeoffs.
5. Optional `--verify`: capture the last 30 pane lines before the paste and,
500 ms after Enter, again, leaving out the current input box (the last
prompt line and its continuation, `verificationTranscript`). A send
carrying OpenRig's From/To header is matched as the whole message with
whitespace normalized (`countEnvelopeOccurrences`); a raw send is matched
by its first 40 characters (`verificationSnippet`). More occurrences after
the send report `outcome: "delivered"`; otherwise the outcome is
`rendered-unconfirmed`: text and Enter landed but the capture could not
re-confirm the render, which is not a failure. The outcome values are
`delivered`, `rendered-unconfirmed`, `failed` and `retained`.
With `transport.verify_input_box` on (off by default), a Claude Code or
Codex send is verified only when the seat was proven idle before the paste
and the input box shows the message, and is unconfirmed otherwise:
- **Seat idle before the paste** (`readSeatBeforePaste`). The `--verify`
capture taken before the paste (the whole screen) must match an idle
allow-list (`SEAT_IDLE_SHAPES`) built from real Claude Code 2.1.296 and
Codex 0.160.0 frames and, where no frame was captured, from the Claude
Code 2.1.296 bundle, the Codex 0.160.0 source and OpenRig's own launch
code. Claude Code: the empty box or its `Try "…"` suggestion between two
rules; the opening rule plain, or carrying this seat's own session name
the way Claude Code draws `--name` (`─…─ <session> ─`, or `──` as in
earlier versions, as wide as the closing rule; OpenRig launches every
Claude seat with `--name`); the footer row of the seat's permission
posture, one for each mode OpenRig can launch: bypass
(`⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents`), accept
edits (OpenRig's default), auto, don't ask, plan, and default
(`⏸ manual mode on · ← for agents`); no statusLine row between the
closing rule and the footer (OpenRig's statusLine collector prints
nothing); a blank, effort or tmux-notice row above the box; and above
that, in the status slot, the completion row of the seat's last turn
(`✻ <verb> for <duration> · done <time>` at column 0, matched by the
Claude Code 2.1.296 bundle's grammar: one of its eight past-tense verbs,
its duration format, and the time of a turn finished today on a 12- or
24-hour en-US clock). A blank slot counts only under that completion
row, with only blank rows between them (as in the fullscreen renderer),
or, before the first turn, under the last row of the Claude Code v2.1.296
welcome banner. OpenRig launches Claude in the classic renderer, where the
box follows the transcript, so an idle seat that has finished a turn
shows that turn's completion row in the slot; real frames of idle seats
OpenRig launched show it. A configured statusLine, which OpenRig installs
on every Claude seat, makes Claude Code drop the footer's hints, so a
running turn draws the same footer row as an idle one, without
`esc to interrupt`: the status slot is what tells them apart. The 2.1.296
bundle draws the live status row (the spinner) there only while no
streamed text with a complete line is on screen
(`isLoading && (!hasDisplayedStreamingText || tailBlank || …)`); while
streamed text is shown it draws nothing in its place, and the last
streamed row moves into the slot. That row never matches the completion
row, because Claude Code draws a message behind its `●` column. The
bundle does not show that the last streamed row is never blank, so a
blank slot under transcript text reads `unknown`: that case is left
unconfirmed rather than read as idle. Codex: the empty box over its
status line and a `? for shortcuts` hint row, with the completion row or
the fresh session's greeting as the last row above the box. The
completion row is matched by the grammar Codex 0.160.0 prints it with
(`Worked for <elapsed> • <time>`, a 12- or 24-hour clock, a date before
the time on another day, runtime metrics after it). No row anywhere may
be a running, streaming, queued, pending-input or paste-hint row (a live
status row, `esc to interrupt`, `paste again to expand`, a queued label,
Codex's pending-input headers or `↳` rows, a spinner). Any other frame,
including every running, streaming, steer-preview, paste-hint or unknown
layout, another seat's name, a statusLine that prints a row, a background
shell in the footer and a fullscreen renderer with a statusLine (which
reserves a row for it), reads `seat: not-idle` or
`unknown`, and the send reads unconfirmed. This holds when a running turn
hides its signal (Codex hides its `• Working` row while it streams;
Claude Code shows only `paste again to expand` in its footer for 8 s
after a paste, and no `esc to interrupt` under a statusLine). So a send to
a busy or unknown seat always reads unconfirmed.
**Versions.** The layouts were recorded from Claude Code 2.1.296 and
Codex 0.160.0 (the versioned frames under
`packages/daemon/test/fixtures/input-box/`, with their limits in that
folder's README). A frame that visibly names another client version reads
`unknown` before the paste and `unrecognized` in the box: Claude Code's
welcome banner (`Claude Code v2.1.297`), Codex's session header
(`>_ OpenAI Codex (v0.161.0)`) or its update notice, whose first version
is the running one (`Update available! 0.159.0 -> …`). Most frames
show no version (the banner and header scroll away), and there a matching
layout is evidence about the layout only, not proof of the client
version: another version that draws the same rows reads the same. There is
no version discovery and no extra read for it.
Which Claude seats OpenRig launches can verify: an idle Claude Code
seat in any posture OpenRig launches, after any number of turns, whose
status slot holds its last turn's completion row as above, or a fresh
seat whose slot is blank under the welcome banner. It reads `unknown`
when that row is anything else: a blank row under transcript text, a
completion row with more after the time (a still-running note, hidden
messages, a budget), the weekday or date of a turn that finished on an
earlier day, another locale's or a custom time format, an interrupted
turn, or a row printed after the turn. And only a text Claude Code shows
inline in its box can verify (see the box reading below): a multi-line
text, which includes every `rig send` envelope and every queue wake,
collapses to a paste label and stays unconfirmed.
- **The box held exactly this message before Enter.** One extra capture
between the paste and Enter must show exactly the sent text in the input
box with its word boundaries kept (`readInputBox`, `boxHoldsText`): runs
of whitespace, including Unicode spaces, compare as one space, so a box
holding `nowhere` is not the text `now here`. A row break counts as a
space only where it must be whitespace of the box's text. Claude Code
2.1.296 wraps its input at spaces only (wrap-ansi). Codex 0.160.0 wraps
it with textwrap (FirstFit, UAX #14 break opportunities, the hyphen
splitter), which also breaks with no space drawn after a hyphen or a
slash and between ideographs. So a break counts where the next row's
first word would have fit on the row before it (both wrappers fill a row
first, so the text has a line break there), or where it falls between
two ASCII letters or digits, or before a hyphen, a slash, a closing
bracket or `.,:;!?`, and the two words it separates, joined, fit in the
box's widest row, but only after a row whose text starts at its first
cell with a printable ASCII character and whose last word is ASCII
letters, digits, `'` and `.,:;` only. Codex keeps a word that fits in a
row whole only where the word starts on a row that already holds text:
a row holding only indentation absorbs the next word and splits it
where the row fills, and a word after a hyphen or a slash can be split
the same way in a row that took the next textwrap line. So on Codex a
split between two letters is not always the text's space; after an
indented row, or after a last word with a break opportunity inside it,
only the first rule reads the break. The first rule has one known
residual on Codex, found in a model of the rust-v0.160.0 source, not in
a live capture: once an indented row has absorbed and split a word, the
re-pass can end a later row early at a hyphen, so a break with no
whitespace can pass the first rule. Every such box in that model also
holds the earlier split after the indented row, which reads `ambiguous`,
so the box never reads `exact`. The widest row is counted in
printable ASCII cells only, so combining marks, joiners and zero-width
characters never widen it. Any other break, and a break inside a word of
the text, reads `ambiguous` and the send stays unconfirmed. A collapsed paste label, Claude's
queued-messages label or `Try "…"` suggestion, or another draft is never
the message. The default `--verify` matcher above is unchanged.
- **The box is empty after Enter on the same pane process.** The capture
after Enter must show the box empty, with the pane process
(`#{pane_pid}`) and the seat's binding unchanged.
These readings stay internal to the send; the result carries only the
verdict (`verified`, `outcome`). An empty box reads `queued` instead of
`empty` where the frames say the message may wait behind a turn: Codex's
pending-input rows or a row under its running-turn status, or a running
turn in the frame read before Enter (Codex's `• Working` row; for Claude
Code, `esc to interrupt` or a live status row above the box, including
above a box of several rows). The verdict uses these readings: verified
needs `empty`, so for a seat that was idle before the paste they are the
only check that rejects a message queued behind a turn that started after
the pre-paste capture. An audited prompt answer (step 4) reads no box: with
the setting on it stays unconfirmed, whatever the default matcher would
say.
The readings decide the verification verdict, not whether Enter is sent:
the seat is read from the capture `--verify` already takes, and none of
the extra reads decides whether Enter is sent. Each has
a 500 ms deadline, which is a budget, not a guarantee: the pre-Enter read
comes after the existing 200 ms wait and can add about 500 ms before
Enter, and the post-Enter process read can add about another 500 ms
before the reply. A read that misses its deadline reads `unavailable`, the
send stays unconfirmed, and Enter is still sent once. With a deliberately
slowed screen read, review measured about 718 to 721 ms from paste to
Enter, against about 216 to 219 ms on main under the same slowdown.
Limits: the seat is read once, before the paste, and Enter
follows about 200 ms later. A turn can start in that window, from a person
typing in the pane or from the agent itself (Claude Code and Codex start
turns on their own when a background task finishes, a scheduled wake-up
fires or a teammate's message arrives). Such a turn is caught only when
the frame read before Enter shows it; a Codex turn already streaming
there hides its `• Working` row and is not seen. An empty box after an
exact one means the box was cleared, normally by our Enter; a clear by
something else at that moment (a person's C-c) looks the same, and so
does a text the TUI runs as a command (a Claude Code `/command`, a Codex
`!command`), which clears the box without the model reading a message.
`#{pane_pid}` is the pane's root process (the shell for most launches), so
an agent restarted under the same shell is not caught by the pane process
check, and the same pane and binding are not proof of the same native
conversation.
An empty box means the TUI took the text, not that the model read it.
Consumers see only the report. With the setting on, a queue wake that
reads unconfirmed records the existing `delivered-ack-pending` state, as
an unconfirmed wake does today. Every queue wake to Claude Code is
unconfirmed under the setting, because its multi-line envelope collapses
to a paste label, and so is a wake to a busy or not-proven-idle Claude
Code or Codex seat. A pending, unclaimed handoff with no pickup evidence
can then enter the wake ladder's existing escalation path after
`queue.wake_unconfirmed_window_minutes` (default 30). It is not blindly
re-sent: the ladder's unconfirmed path skips its retry rung
(`queue-wake-ladder.ts`), and nothing in the send path retries or
re-sends.
5. Optional `--verify`: capture the last 30 pane lines before and after the
send (after a 500 ms wait, `:1572`) and count the message's first 40
characters in each. A higher count after the send reports
`outcome: "delivered"`; otherwise the outcome is `rendered-unconfirmed` —
text and Enter landed but the capture could not re-confirm the render,
which is not a failure at the daemon (`session-transport.ts:681`
`verify?`; `:1571` `if (opts?.verify)`; `:1583`
`verified = postCount > preCount`). The outcome values are `delivered`,
`rendered-unconfirmed`, `failed` and `retained` (`:748`).
6. CLI `--verify` goes further (`packages/cli/src/commands/send.ts:596`): it
looks for the text still unsubmitted at the prompt, makes one guarded
Enter-only retry through the daemon's `submitOnly` path
(`session-transport.ts:1344`, which refuses with `staged_mismatch` unless the
(`session-transport.ts:1320`, which refuses with `staged_mismatch` unless the
pane shows the expected staged text), and ends `staged-not-consumed` with
exit code 1 if it is still there.
7. Honest result with reason on failure. Reasons missing from the send
@@ -344,15 +145,6 @@ Architecture rule 18 (`architecture-rules-and-event-system.md`): tmux is
transport, not truth — `send/capture/broadcast` wrap tmux reliably with honest
errors.
The daemon runs tmux without a shell: argv straight to the tmux binary found on
`PATH`. `OPENRIG_TMUX_SHELL=1` at daemon start runs every tmux call through
`/bin/sh` instead (Linux and macOS; Windows always uses argv). A binary that
can't be started at all (a script without `#!`, a missing interpreter) runs
through `/bin/sh -c 'exec "$0" "$@"' tmux …`, which finds tmux as the shell
path does; nothing ran before, so no command runs twice. The resolved path is
cached, so a different tmux installed earlier on `PATH` takes effect after a
daemon restart.
### 3b. Cross-host coordination verbs
`rig send/capture/transcript/broadcast --host <id>` (and the `agent@rig@host`
@@ -418,37 +210,32 @@ remote daemon's ordinary local routes; the cross-host logic is in the CLI.
1. `NodeLauncher` starts transcript rotation right after tmux session
creation, before the harness boots
(`NodeLauncher`, `startTranscriptRotation`).
2. Each rotation tick captures the trailing lines and atomically overwrites
(temp file + rename in `startTranscriptRotation`)
(`packages/daemon/src/domain/node-launcher.ts:181`).
2. Each rotation tick captures the trailing lines with
`capturePaneContent` (`transcript-rotation.ts:213` →
`adapters/tmux.ts:1082`) and atomically overwrites (temp file + rename,
`transcript-rotation.ts:283`–`285`)
`transcripts/{rig-name}/{session-name}.log` under the OpenRig home (or
the `transcripts.path` setting). It skips the write when the captured bytes
are unchanged. Defaults: 1000 trailing lines every 2 s
(`DEFAULT_TRANSCRIPT_LINES`, `DEFAULT_TRANSCRIPT_POLL_INTERVAL_MS`); an idle seat is captured less often,
(`transcript-rotation.ts:27`–`28`); an idle seat is captured less often,
within the 10-second freshness window. Both are tunable with
`OPENRIG_TRANSCRIPTS_LINES` and `OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS`
or the matching `transcripts.*` settings. This bounded capture replaced the
earlier `tmux pipe-pane` stream (see the header comment of `transcript-rotation.ts`), so the file
holds a trailing window, not the whole session. Sessions on one adapter
tick on one shared timer, and the captures due in a tick share
`capturePanesContent`: one chained call per chunk of 24, with a
`list-sessions` started beside it on every batch, whose result is consulted
only when the chain fails, each chunk on its own (`captureBatch` in
`transcript-rotation.ts` → `capturePanesContent` in `adapters/tmux.ts`).
A session the batch leaves out, a lone due session, or a pane or window
target reads per target with `capturePaneContent` (`adapters/tmux.ts`).
earlier `tmux pipe-pane` stream (`transcript-rotation.ts:3`), so the file
holds a trailing window, not the whole session.
3. `TranscriptStore` owns path convention, ANSI stripping for `readTail` and
`grep`, boundary
markers, `readTail`, `readFull`, `grep`. `readFull` returns the raw bytes
(`TranscriptStore.readFull`); the `/full` route redacts them. The transcript
(`transcript-store.ts:378`); the `/full` route redacts them. The transcript
routes check capture health first and can start capture lazily for a seat
that has none.
4. `rig transcript <session> --tail N / --grep "pattern"` provides
agent-facing access.
5. On restore: a `--- SESSION BOUNDARY: … ---` marker is written before
re-launch (`RestoreOrchestrator`); each rotation tick keeps each
re-launch (`restore-orchestrator.ts:893`); each rotation tick keeps each
distinct boundary line once, as a header above the fresh capture
(`startTranscriptRotation`, boundary header). (Restore-side detail in
(`transcript-rotation.ts:254`–`257`). (Restore-side detail in
`lifecycle-snapshot-restore.md`.)
6. `rig ask` gathers rig summary plus transcript excerpts, chat excerpts,
insufficiency state, and guidance.
@@ -462,40 +249,6 @@ agent IS the LLM. The one exception is the explicit CLI flag `rig ask --wake`
Claude or Codex session (`claude -p --resume` / `codex exec resume`,
`packages/cli/src/ask-wake.ts:58`–`60`) to answer one question.
### Current-generation records for `rig walk`
`rig walk` checks a delivered piece against the current conversation through
`GET /api/sessions/:sessionName/generation-record` (`routes/sessions.ts`).
Without `sinceBytes`, the route returns record identity and size; with it, the
route also reads a bounded suffix from the same open file. This terminal-class
endpoint uses the terminal bearer gate.
For Codex, the current occupant must have a verified identity for its bound
pane. The route compares tmux's current root PID with the identity's
`observedPanePid`, falling back to `observedPid` for older verdicts that only
recorded one PID. Previously it always compared the root with the native Codex
PID, refusing a verified Codex running beneath a shell or launcher. Recording
both PIDs lets that wrapper pass without accepting a changed pane root or
changing the identity schema.
For dual-PID verdicts, the selected thread must also be the thread resolved for
`observedPid`. All candidate threads still participate in the ambiguity check;
another process's resolvable thread cannot stand in for a missing or
unresolvable verified native process. This reuses the same process observation
and thread lookups, without an additional census.
The existing thread lookup still requires a Codex descendant with one
resolvable thread, a thread-table record path and a matching rollout header.
The route rechecks occupant generation, pane binding and root PID after the
thread lookup. These are bounded observations, not an atomic process snapshot.
An unresolved identity still returns 409; `rig walk` reports consumption as
unverified instead of claiming it read the record. Route regressions in
`packages/daemon/test/walk-consumption-primitives.test.ts` cover shell and
launcher wrappers, legacy verdicts, root changes, replaced or unresolved native
processes, and changes during thread resolution with synthetic process
observations and actual SQLite/JSONL
fixtures; they do not exercise a live Codex conversation.
## 5. Chat flow
1. `rig chatroom send <rig> "message"` → `POST
@@ -511,8 +264,8 @@ fixtures; they do not exercise a live Codex conversation.
(`packages/ui/src/components/RigChatPanel.tsx`), but no UI view mounts it:
**1** file under `packages/ui/src` names it, its own
(`git grep -l 'RigChatPanel' -- packages/ui/src | wc -l`).
5. MCP: **`rig_chatroom_send` + `rig_chatroom_watch`** (`mcp-server.ts:366`,
`:402`).
5. MCP: **`rig_chatroom_send` + `rig_chatroom_watch`** (`mcp-server.ts:364`,
`:400`).
6. Source of truth: daemon-backed SQLite (`chat_messages` table), NOT tmux
scrollback.
@@ -530,14 +283,10 @@ The intentional limits in this layer (numbered as in
- Note 5 — transcript search prefers `rg` but falls back to `grep -E`;
search quality/performance varies by backend.
- Note 6 — chat is rig-scoped only: no cross-rig channels or DMs.
- Note 7 — `--verify` on `rig send` reports what the pane shows, not agent
acknowledgement. By default it compares the message (whole for a
header-carrying send, the first 40 characters for a raw one) in captures
before and after the send, leaving out the current input box; with
`transport.verify_input_box` on, Claude Code and Codex sends are verified
only for a seat proven idle before the paste and on input-box evidence; a
busy or unknown seat reads unconfirmed (§3 step 5). When it cannot confirm,
it reports `rendered-unconfirmed`, not a failure.
- Note 7 — `--verify` on `rig send` checks the pane for the message by
comparing snippet counts before and after the send (§3 step 4); when the
capture cannot re-confirm the render it reports `rendered-unconfirmed`,
not a failure.
(The full compatibility-notes list lives in
`architecture-rules-and-event-system.md`.)
+3 -28
View File
@@ -7,13 +7,13 @@ domains: [engineering-advisor, operating-advisor]
applies-when: |
Following workflow validation, instantiation, projection, routing, failure
recovery, lifecycle graph revisions, or the corresponding CLI/API.
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# Workflow Runtime — Specs, Packets, and Lifecycle Graphs
Source snapshot: `e8f0ab340db773392ec8be75b072d1c0f3068a50`. This describes the source at that commit;
Source snapshot: `2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7`. This describes the source at that commit;
it does not establish the version or behavior of a running daemon.
A workflow binds a specification to durable queue packets. The daemon
@@ -56,12 +56,6 @@ harness grammar. Nonlocal host pins can validate as registered names but
This is a workflow-step restriction, not a claim that all queue traffic is
local-only.
A step whose `allowed_exits` can neither finish it nor route it on is refused as
`step_cannot_finish` (`workflow-validator.ts:223-236`): its exits leave out `done`, none is
mapped in `next_hop.on`, and it allows no `handoff` that routes on. In a dependency graph,
allowing `handoff` is enough, because handoff completes a sink. `instantiate` refuses any spec
with a validation error.
`allowed_exits` and `max_hops` affect validation/projection. In contrast,
`continuation_required`, `preserve_lineage`, `closure_required`, closure
messages, `skill_refs`, and `spawn_budget` produce
@@ -129,25 +123,6 @@ parked `blocked_on` that human seat in the same transaction (summary and
for a declared role, an ordinary packet goes to that handler role's seat
(`workflow-projector.ts:1659`–`1778`).
A `preferred_targets` entry may end in `@{rig}`, for example `watcher@{rig}`.
When the instance is bound to a rig (`--rig`, or the spec's `target.rig`
default), the trailing `{rig}` becomes that rig, so one reusable spec addresses
that rig's member, including a `builtin:terminal` member. Only a trailing
`@{rig}` is the form: braces in the member part or a partial rig name
(`ops-{rig}@rig-a`, `ops-watcher@prefix-{rig}`) stay literal. Literal
`member@rig` entries are unchanged. With no bound rig the entry stays literal
and is handled like any other destination, at the step that selects it: a
selected `member@{rig}` is refused as an unknown destination rig
(`unknown_destination_rig`), and `rig workflow validate` reports such an
exception target as `unregistered`. A `{rig}` in a role that is never selected,
or in a preferred target that is not selected, does not stop an instantiate.
The cached spec keeps the literal `{rig}`; the expansion is computed from the
instance's stored `bound_rig` each time its spec is read, so later hand-offs,
resume and exception routing use the same target
(`workflow-rig-placeholder.ts`, `WorkflowSpecCache.getForInstance`). A
terminal member cannot act on a nudge, so it must poll its own queue for the
packet.
Instantiation also emits advisories for missing members in otherwise
registered target rigs. That member check is advisory and does not imply
all preferred targets received a live-readiness test.
@@ -12,8 +12,8 @@ applies-when: |
Project UI.
siblings: [content-surfaces.md, daemon-core.md, ../ui/project-and-for-you.md]
prerequisite-reads: [../README.md, daemon-core.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# Workspace Primitive — RigSpec.workspace, Migrations 038/039, Missions/Projects/Slices
@@ -26,7 +26,7 @@ through `whoami` / node-inventory and gate per-item repo scope. Alongside it, a
the Project UI's mission / slice surfaces.
> Paths are relative to `packages/daemon/src/` unless prefixed `packages/` or
> `docs/`. Verified against source at main `e8f0ab340db773392ec8be75b072d1c0f3068a50`. Each count sits beside
> `docs/`. Verified against source at main `2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7`. Each count sits beside
> the command that produces it; run the commands from the repository root.
> The files / markdown / progress / steering surfaces are in the sibling
> `content-surfaces.md`.
@@ -208,9 +208,9 @@ match; the filter is skipped when the column is absent.
> `:114–120`); `createInputRefusal` `:128–141` (call `:136`), used by
> `/create` `:490` and the self-forward create `:436`; handoff calls `:679`,
> `:758` in handlers `:650`, `:732` (`/create` handler `:450`); ordering note
> `:482–489`; inherited value `:676–677`, `domain/queue-repository.ts:1691`,
> `:1868`; list filter `routes/queue.ts:906`,
> `domain/queue-repository.ts:3236–3238`, CLI
> `:482–489`; inherited value `:676–677`, `domain/queue-repository.ts:1681`,
> `:1858`; list filter `routes/queue.ts:906`,
> `domain/queue-repository.ts:3226–3228`, CLI
> `packages/cli/src/commands/queue.ts:1143`; mounted `server.ts:784`.
## 2. Workspace HTTP route — frontmatter validator and doctor
@@ -576,7 +576,7 @@ through the `/project*` destinations; it does not call `/api/projects`
- **Defensive column access:** `RigRepository` probes for `workspace_json`
(`rig-repository.ts:185`, `:193`), node-inventory's own reads fall back to
null on error (`node-inventory.ts:724`, `:796–798`), and the queue
repository detects `target_repo` (`queue-repository.ts:758`), so partial
repository detects `target_repo` (`queue-repository.ts:752`), so partial
test fixtures don't crash. Migrations 038/039 ship separately so a fixture
can apply only the half it needs (`038_workspace_primitive.ts:12–14`).
- **Read-only projection, no new state for the tree:** SliceIndexer /
+6 -13
View File
@@ -9,8 +9,8 @@ applies-when: |
what broke there before, then go and read the code.
siblings: [README.md, test-layers.md]
prerequisite-reads: [../../ARCHITECTURE.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# Arteries: where a small change has a large effect
@@ -22,7 +22,7 @@ last-updated: 2026-10-08
behaviour and risk that are not listed here exist, and some of it is riskier than anything below.
- **It is incomplete by construction.** A pull request is not safe because its files are missing
from this list.
- **It may be stale.** It was checked against commit `e8f0ab34`. Files and dependencies move. If
- **It may be stale.** It was checked against commit `2caac7dd`. Files and dependencies move. If
this page and the code disagree, the code is right; fix the page.
- **Use it to change altitude, then go to the ground:** read the code, run the commands, check the
behaviour. When you are lost, go and look rather than leaning on this page.
@@ -35,12 +35,12 @@ area.
| Artery | Key files | What depends on it | Past regressions and lessons |
|---|---|---|---|
| **Message delivery to seats** | `daemon/src/domain/session-transport.ts`, `daemon/src/domain/seat-delivery-guard.ts`, `daemon/src/adapters/tmux.ts` | Every `rig send`, queue wake, nudge, watchdog wake and handoff | #150 (the fix for #142) stopped typing into a bare shell. It then refused managed Codex seats (fixed by #171 before release) and every Claude seat launched with an explicit permission mode, which shipped in 0.6.2 as #197 (fixed by #220). The delivery guard's rule that a name must resolve to exactly one target, added in #109 (0.6.0), was involved in #141 (fixed by #151), the delivery half of #174 (fixed by #181) and #188 (fixed by #189). Since #310/#327, a Claude seat whose runtime can't be observed still receives ordinary messages with a warning; only a plain idle shell or the wrong recipient is refused. |
| **Launch, relaunch, resume** | `daemon/src/adapters/claude-code-adapter.ts`, `daemon/src/domain/claude-managed-launch.ts`, `daemon/src/adapters/codex-runtime-adapter.ts`, `daemon/src/domain/codex-daemon-support.ts`, `daemon/src/adapters/pi-runtime-adapter.ts`, `daemon/src/domain/seat-launch-environment.ts`, `daemon/src/domain/native-resume-probe.ts`, `daemon/src/domain/startup-orchestrator.ts` | Every seat start, restore and permission mode, plus the non-interruptive, `permission_policy`, effort, kernel operational-authority and team permission-default settings that each launch and resume command carries | Always setting `CLAUDE_CONFIG_DIR` gave explicit-mode Claude seats a blank config (#154). #225 fixed which config home is selected, and #565 writes the onboarding and trust bootstrap into that home. Codex seats launch with `--no-daemon` only when `codex --help` shows support (#69, #77); when support is unknown, OpenRig refuses to launch, so a failing or slow `codex --help` blocks the seat. The readiness window defaults to 30 seconds and can time out under load (#182); since #643 it is configurable as `runtime.readiness_timeout_seconds` (env `OPENRIG_RUNTIME_READINESS_TIMEOUT_SECONDS`, 1–600). A git worktree's `.git` file broke the Codex sandbox (#121; see #126). #618 moved non-managed Claude, Codex and Pi launches behind the seat launch environment, and #628, #638 and #666 followed within days. Pi launch and resume carry `--thinking` from the seat's resolved effort (OMP does not). |
| **Launch, relaunch, resume** | `daemon/src/adapters/claude-code-adapter.ts`, `daemon/src/domain/claude-managed-launch.ts`, `daemon/src/adapters/codex-runtime-adapter.ts`, `daemon/src/domain/codex-daemon-support.ts`, `daemon/src/domain/seat-launch-environment.ts`, `daemon/src/domain/native-resume-probe.ts`, `daemon/src/domain/startup-orchestrator.ts` | Every seat start, restore and permission mode, plus the non-interruptive, `permission_policy`, effort, kernel operational-authority and team permission-default settings that each launch and resume command carries | Always setting `CLAUDE_CONFIG_DIR` gave explicit-mode Claude seats a blank config (#154). #225 fixed which config home is selected, and #565 writes the onboarding and trust bootstrap into that home. Codex seats launch with `--no-daemon` only when `codex --help` shows support (#69, #77); when support is unknown, OpenRig refuses to launch, so a failing or slow `codex --help` blocks the seat. The readiness window defaults to 30 seconds and can time out under load (#182); since #643 it is configurable as `runtime.readiness_timeout_seconds` (env `OPENRIG_RUNTIME_READINESS_TIMEOUT_SECONDS`, 1–600). A git worktree's `.git` file broke the Codex sandbox (#121; see #126). #618 moved non-managed Claude, Codex and Pi launches behind the seat launch environment, and #628, #638 and #666 followed within days. |
| **Queue claim, pickup, wakes** | `daemon/src/domain/queue-repository.ts`, `daemon/src/domain/queue-pickup.ts`, `daemon/src/domain/queue-wake-ladder.ts`, `daemon/src/routes/require-sender-identity.ts`, `cli/src/client.ts` | All durable work between seats | After a slow `/healthz`, the CLI host-qualified a local sender and claims failed (#131, fixed by #135). Done-and-closed items read as stalled forever (#164, fixed by #176). |
| **Rig identity: same names, archived and deleted rigs** | `daemon/src/domain/rig-repository.ts`, `daemon/src/domain/rig-lifecycle-service.ts`, `daemon/src/domain/rig-teardown.ts`, `daemon/src/domain/seat-handover-service.ts`, `daemon/src/domain/running-name-guard.ts` | Seat resolution, `rig remove`, restore, importing a rig from YAML | Archived rigs still matched seat references, and `rig remove` killed another rig's live seat with the same name (#174, fixed by #181). Replacing stopped same-name rig generations on YAML import followed in #196. |
| **Projection of skills and files** | `daemon/src/domain/rigspec-instantiator.ts`, `daemon/src/domain/projection-planner.ts`, and each runtime adapter's `project()` (Claude Code, Codex, Pi, OMP, stub) | What every agent is given at start | Codex seats skipped skills that a Claude sibling had already projected (#159, fixed by #162). File-shaped entries failed with ENOTDIR (fixed by #185). |
| **Native process observation** | `daemon/src/domain/native-process-lineage.ts` (parses `ps -Ao pid,ppid,pgid,tpgid,ucomm,lstart,command`) | Liveness, identity verdicts, delivery checks | Processes whose command name contains a space were dropped (#134, fixed by #82). Non-English locales broke the date parsing (fixed by #239, which runs `ps` with `LC_ALL=C`). #220 also changed this file to recognise managed Claude seats behind launch wrappers. Claude identity proof (wrappers, launcher shims, conversation rotation, effort, version-named binaries, launch `--settings`) changed ten more times after 0.6.3. |
| **Schema and migrations** | `daemon/src/db/migrations/`, `daemon/src/db/all-migrations.ts`, `daemon/src/db/migrate.ts` | Every install, and every upgrade of a long-lived install | Parallel pull requests pick the same next number: #112 adds `086_rig_kernel_variant` while `main` already has `086_classification_fields_and_attempts`, and #155 and #195 landed as 090 and 091. `migrate.ts` applies by migration name in name order, so a duplicate number is not skipped, but its order relative to the other migration changes; 093 is unused at `e8f0ab34`. A long-lived install upgrading across several releases applies many migrations in one start, against real data. |
| **Schema and migrations** | `daemon/src/db/migrations/`, `daemon/src/db/all-migrations.ts`, `daemon/src/db/migrate.ts` | Every install, and every upgrade of a long-lived install | Parallel pull requests pick the same next number: #112 adds `086_rig_kernel_variant` while `main` already has `086_classification_fields_and_attempts`, and #155 and #195 landed as 090 and 091. `migrate.ts` applies by migration name in name order, so a duplicate number is not skipped, but its order relative to the other migration changes; 093 is unused at `2caac7dd`. A long-lived install upgrading across several releases applies many migrations in one start, against real data. |
| **Restore and recovery** | `daemon/src/domain/restore-orchestrator.ts`, `daemon/src/adapters/{claude,codex,pi,omp}-resume.ts`, `daemon/src/domain/reconciler.ts`, `daemon/src/domain/restore-check-service.ts`, `daemon/src/domain/startup-orchestrator.ts`, `daemon/src/adapters/tmux.ts` | Reboots, power loss, daemon restarts | Detached seats kept stale tmux pane ids after a reboot (#141, fixed by #151 in `tmux.ts`). `restore-check` read queue files and Claude hooks from a fixed shared-docs path (#130); #139 made it use the configured root, and #328 replaced the queue-file check with a probe of the daemon's queue store and checks the selected Claude hooks. After a fresh relaunch, a stale Codex restore attention flag never clears (#116). Full-restore recovery of OMP seats is open (#41). |
## How reviews use this page
@@ -65,7 +65,7 @@ change to an artery meets a real end-to-end check before it merges, not only a r
### Scenario coverage today
At commit `e8f0ab34`. For why the stub cannot reach some of these yet, see "What a stub can and
At commit `2caac7dd`. For why the stub cannot reach some of these yet, see "What a stub can and
cannot prove" in [test-layers.md](test-layers.md).
- **Queue durability across a daemon restart: covered.** CI runs
@@ -109,10 +109,3 @@ When a regression reaches a release, add one line: date, issue, artery, and whic
explicit-mode Claude seat; the project's own day-to-day seats use the default launch path.
- 2026-09-29/30, #141, #174, #188 (after 0.6.0), delivery and rig identity: reviews of #109 did not
see the downstream effect of the exactly-one-target rule.
- 2026-10-19, #1483, managed compaction: a helper-only command-composition check did not bind the submitted command to the precompact effect. `managed-compaction-effect.test.ts` now drives the real enforcer command into a version-bound stub input model and checks the shipped precompact marker; its 851-character pasted-command control lacks that effect despite a successful submit receipt. `rig compact` rejects stub runtimes, so this is an adapter/helper check, not an installed CLI or native compaction proof. The model uses the recorded Claude Code 2.1.282 distinction; other versions and actual context compaction remain unverified here.
- 2026-10-19, #484, refocus orientation: script tests supplied basis strings without composing parked typed queue state through `queue whoami` and the hook. `refocus-parked-baton.yaml` plus the active-baton contrast now assert the shipped hook's own emitted context; installed-scenario runs healthy/seeded/healthy; its scratch trace-child fault moves the parked row from held work into the work candidates as current work, and the hook's emitted context must then fail with that row's slice shown as `WORK FROM QUEUE EVIDENCE`. This proves the composed diagnostic effect, not native hook scheduling or an agent reading and acting on it. With the work packet's bounds and per-runtime default, the parked scenario runs with the packet on and asserts the row as held work. `refocus-parked-baton-packet-off.yaml` keeps the labelled fallback as the packet-off control.
- 2026-10-23, F1 (0.7.0 candidate `2fec7382`), launch (a fresh Codex kernel): a fresh Codex kernel stopped at Codex's hook review. Caught at the cut, before publication, by native first-user and daily runs. Missed by the stub layer, which has no real Codex binary. Fixed in #1824.
- 2026-10-24, B6 (0.7.1 candidate `7badb557`), launch and resume (Codex hook trust): after an upgrade from 0.7.0, a Codex seat stopped at "Hooks need review: 6", because Codex 0.164 asks to approve a plugin's whole content again. Caught at the cut, before publication, by a native upgrade run with a real Codex. Missed by source tests and stub scenarios (a stub can't see the real Codex binary) and by the installed-upgrade check (no real Codex). Fixed in #1948. Scenario owed: a cheap real-agent Codex upgrade with approval renewal.
- 2026-10-24, pane-only attention (#1876 on the 0.7.1 candidate), native observation: a Codex seat waiting at a prompt that only its pane showed read as working, with no attention signal. Caught at the cut, before publication, by real work on a test machine. Missed by fixtures that all carried hook signals. Fixed in #1946. Scenario owed: a stub pane showing a selector with no hook event must read as needing attention.
- 2026-10-25, #1955, delivery (send readiness): approval-prompt text that a working Claude seat quoted in its own output read as a waiting prompt, so sends and queue wakes to that seat were refused. This one was present since 0.7.0, so it did reach a release; it was found during the 0.7.1 cut by real work on a test machine. Missed by classifier fixtures that had no working pane quoting prompt text. Fixed in #1955 for 0.7.2.
+86 -220
View File
@@ -9,23 +9,23 @@ applies-when: |
the source that implements a command. Use command help for invocation details.
siblings: [README.md, codemap.md]
prerequisite-reads: [../reference/help.md]
last-verified-against-source: 34fdcb80543903b054a982cc48bb302ae6871682
last-updated: 2026-10-26
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# CLI Reference — Registered Commands and Options
Verified against source commit `34fdcb80543903b054a982cc48bb302ae6871682`.
Verified against source commit `2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7`.
The inventory below comes from the actual Commander tree returned by
[`createProgram()`](../../packages/cli/src/index.ts), not a grep of command
strings or an installed CLI from a different commit.
There are **90 top-level registrations**, **410 registered command objects**
There are **87 top-level registrations**, **356 registered command objects**
below `rig` (including groups and the hidden `restore apply` command), and
**1,304 explicitly registered option objects**, including the root version
**1,082 explicitly registered option objects**, including the root version
option. Aliases do not add command objects; short/long spellings of one
option do not add option objects. Commander-generated help is additional.
These are counts for that source snapshot, not a claim about a deployed release.
These are source counts, not a claim about a deployed release.
## Reading this reference
@@ -143,7 +143,7 @@ Root: `rig`; declared option: `-V, --version`.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig daemon` | — | — |
| `rig daemon start` | — | `--port <port>`<br>`--host <host>`<br>`--db <path>`<br>`--allow-db-conflict`<br>`--no-kernel`<br>`--wait-for-kernel`<br>`--wait-for-kernel-ms <ms>` |
| `rig daemon start` | — | `--port <port>`<br>`--host <host>`<br>`--db <path>`<br>`--no-kernel`<br>`--wait-for-kernel`<br>`--wait-for-kernel-ms <ms>` |
| `rig daemon stop` | — | — |
| `rig daemon status` | — | — |
| `rig daemon logs` | — | `--follow` |
@@ -152,29 +152,22 @@ Root: `rig`; declared option: `-V, --version`.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig status` | — | `--json` |
`rig status --json` prints one object: `daemon`, `kernel`, `workspaceRoot`, `rigs` and `cmux`.
`daemon.reachable` has three values. `true`: the daemon answered its health probe (it may still
report `healthy: false`, and may carry a `reason` and `eventLoop` evidence). `false`: positively down
(`state` is `stopped` or `stale`). `null`: not confirmed (`state` is `unverified`, or `running`
with `reason: "unresponsive"` because `/healthz` did not answer). When `reachable` is not `true`,
the sections that were not read are `null`.
| `rig status` | — | — |
### snapshot
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig snapshot <rig>` | — | `--intended-seats <ids>` |
| `rig snapshot list <rig>` | — | — |
| `rig snapshot <rigId>` | — | `--intended-seats <ids>` |
| `rig snapshot list <rigId>` | — | — |
### restore
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig restore` | — | — |
| `rig restore apply <snapshotId>` (hidden) | — | `--rig <rig>` **required** |
| `rig restore status <attemptId>` | — | `--rig <rig>` **required**<br>`--json` |
| `rig restore apply <snapshotId>` (hidden) | — | `--rig <rigId>` **required** |
| `rig restore status <attemptId>` | — | `--rig <rigId>` **required**<br>`--json` |
### crash-cart
@@ -188,44 +181,11 @@ the sections that were not read are `null`.
|---|---|---|
| `rig gateway` | — | — |
| `rig gateway human` | — | — |
| `rig gateway human add <entityId>` | — | `--display-name <name>` **required**<br>`--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>` **required** (repeatable)<br>`--delivery-class <A\|B\|C\|D>` **required**<br>`--away`<br>`--replace`<br>`--reason <reason>`<br>`--actor <actor>`<br>`--organization <id>`<br>`--group <id>` (repeatable)<br>`--policy <text>`<br>`--policy-file <path>`<br>`--default` |
| `rig gateway human add <entityId>` | — | `--display-name <name>` **required**<br>`--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>` **required** (repeatable)<br>`--delivery-class <A\|B\|C\|D>` **required**<br>`--away`<br>`--replace`<br>`--reason <reason>`<br>`--actor <actor>` |
| `rig gateway human list` | — | `--json` |
| `rig gateway human quiet` | — | — |
| `rig gateway human quiet set <entityId>` | — | `--daily <window>`<br>`--until <time>`<br>`--tz <zone>`<br>`--actor <actor>`<br>`--json` |
| `rig gateway human quiet end <entityId>` | — | `--actor <actor>`<br>`--json` |
| `rig gateway human quiet clear <entityId>` | — | `--actor <actor>`<br>`--json` |
| `rig gateway human quiet show <entityId>` | — | `--json` |
| `rig gateway human mode` | — | — |
| `rig gateway human mode save <entityId> <name>` | — | `--availability <value>`<br>`--expected-back <when>`<br>`--decision-room <words>`<br>`--exclude <item...>`<br>`--quiet <rule>`<br>`--tz <zone>`<br>`--account <where>`<br>`--actor <actor>`<br>`--json` |
| `rig gateway human mode saved <entityId> [name]` | — | `--json` |
| `rig gateway human mode apply <entityId> <name>` | — | `--until <time>`<br>`--tz <zone>`<br>`--actor <actor>`<br>`--json` |
| `rig gateway human mode current <entityId>` | — | `--json` |
| `rig gateway human mode end <entityId>` | — | `--scheduled`<br>`--actor <actor>`<br>`--json` |
| `rig gateway human mode account <entityId> <applicationId>` | — | `--json` |
| `rig gateway human mode room <entityId>` | — | `--json` |
| `rig gateway human mode decide <qitemId>` | — | `--application <id>` **required**<br>`--choose <question=option...>`<br>`--reason <why>` **required**<br>`--reversible`<br>`--irreversible`<br>`--actor <actor>`<br>`--json` |
| `rig gateway human mode decisions <entityId> <applicationId>` | — | `--json` |
| `rig gateway human show <entityId>` | — | `--json` |
| `rig gateway human set <entityId> <field> <value>` | — | `--reason <reason>`<br>`--actor <actor>` |
| `rig gateway human remove <entityId>` | — | `--force` |
| `rig gateway channel` | — | — |
| `rig gateway channel add <id>` | — | `--display-name <name>` **required**<br>`--binding <slack:connectorRef:secretsRef:channelId>` **required**<br>`--organization <id>`<br>`--group <id>` (repeatable)<br>`--policy <text>`<br>`--policy-file <path>`<br>`--replace` |
| `rig gateway channel remove <id>` | — | `--force` |
| `rig gateway org` | — | — |
| `rig gateway org set <id>` | — | `--name <name>`<br>`--policy <text>`<br>`--policy-file <path>` |
| `rig gateway group` | — | — |
| `rig gateway group set <id>` | — | `--organization <id>`<br>`--name <name>`<br>`--policy <text>`<br>`--policy-file <path>` |
| `rig gateway participants` | — | `--organization <id>`<br>`--json` |
| `rig gateway default [entityId]` | — | `--clear` |
- Several people may be registered. Organizations, groups, readable policy, shared channels and the default person are
kept in `$OPENRIG_HOME/gateway/participants.yaml`, never in a person's fragment, so an older daemon still loads every
person after a rollback. `human set <entityId> organization|groups|policy <value>` writes there (`unset` clears;
`@<path>` reads a policy file). `participants --json` carries each participant's `kind`, `address`, `organization`,
`groups`, `default` and `policy` (a list of `{ source, text }`, organization first), plus `default`, `organizations`,
`groups`, `warnings` and `participantFile`. `default` with no argument prints the current default and its source
(`chosen` or `only-person`), or `null` with the reason. See `docs/reference/gateway-participants.md`.
- `human set <entityId> slack-subscription <value>` — sets the person's Slack subscription: `every-ask` (the default) posts every ask to them to Slack; `urgent-only` posts only asks created with `--alert`, and other asks wait on Steering and the TUI; `unset` removes the authored subscription so the default applies. `human show` prints the value with its origin, `authored` or `default` (`slack-subscription: every-ask (default)`); `--json` carries it as `record.slackSubscription` `{ value, source }`.
### parked
@@ -237,7 +197,7 @@ the sections that were not read are `null`.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig export <rig>` | — | `-o, --output <path>` |
| `rig export <rigId>` | — | `-o, --output <path>` |
### import
@@ -311,7 +271,7 @@ the sections that were not read are `null`.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig bundle` | — | — |
| `rig bundle create <spec>` | — | `-o, --output <path>` **required**<br>`--name <name>`<br>`--bundle-version <ver>`<br>`--include-login-files`<br>`--include-packages <refs...>`<br>`--rig-root <root>`<br>`--context-pack <dir>` (repeatable)<br>`--project-dir <dir>`<br>`--preset <name>`<br>`--seat <member=runtime>` (repeatable)<br>`--notes <text>`<br>`--min-daemon-version <ver>`<br>`--min-cli-version <ver>`<br>`--allow-drift`<br>`--json` |
| `rig bundle create <spec>` | — | `-o, --output <path>` **required**<br>`--name <name>`<br>`--bundle-version <ver>`<br>`--include-packages <refs...>`<br>`--rig-root <root>`<br>`--context-pack <dir>` (repeatable)<br>`--project-dir <dir>`<br>`--preset <name>`<br>`--seat <member=runtime>` (repeatable)<br>`--notes <text>`<br>`--min-daemon-version <ver>`<br>`--min-cli-version <ver>`<br>`--allow-drift`<br>`--json` |
| `rig bundle configurations <spec>` | — | `--json` |
| `rig bundle inspect <path>` | — | `--preset <name>`<br>`--seat <member=runtime>` (repeatable)<br>`--json` |
| `rig bundle install <path>` | — | `--preset <name>`<br>`--seat <member=runtime>` (repeatable)<br>`--non-interruptive`<br>`--no-non-interruptive`<br>`--plan`<br>`--yes`<br>`--target <root>`<br>`--cwd <path>`<br>`--skip-version-check`<br>`--force`<br>`--json` |
@@ -322,25 +282,25 @@ the sections that were not read are `null`.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig up <source>` | — | `--non-interruptive`<br>`--no-non-interruptive`<br>`--plan`<br>`--yes`<br>`--cwd <path>`<br>`--env <KEY=VALUE>` (repeatable)<br>`--name <rig>`<br>`--target <root>`<br>`--preset <name>`<br>`--seat <member=runtime>` (repeatable)<br>`--existing`<br>`--fresh <seats...>`<br>`--json`<br>`--host <id>` |
| `rig up <source>` | — | `--non-interruptive`<br>`--no-non-interruptive`<br>`--plan`<br>`--yes`<br>`--cwd <path>`<br>`--target <root>`<br>`--preset <name>`<br>`--seat <member=runtime>` (repeatable)<br>`--existing`<br>`--fresh <seats...>`<br>`--json`<br>`--host <id>` |
### down
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig down <rig>` | — | `--delete`<br>`--force`<br>`--snapshot`<br>`--yes`<br>`--json`<br>`--host <id>` |
| `rig down <rig>` | — | `--delete`<br>`--force`<br>`--snapshot`<br>`--json`<br>`--host <id>` |
### archive
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig archive <rig>` | — | `--force`<br>`--json` |
| `rig archive <rigId>` | — | `--force`<br>`--json` |
### unarchive
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig unarchive <rig>` | — | `--json` |
| `rig unarchive <rigId>` | — | `--json` |
### host
@@ -354,31 +314,12 @@ the sections that were not read are `null`.
| `rig host list` | `ls` | `--json` |
| `rig host doctor <id>` | — | `--posture <profile>`<br>`--public-addr <ip>`<br>`--json` |
Registration makes a host visible in topology; remote FEED attention remains opt-in.
The human-readable success output of `host add` and `host pair` now includes the FEED
next step alongside the existing `host doctor` suggestion. For an HTTP entry named
`laptop`, run this on the local instance to include its attention items:
```sh
rig config set feed.subscriptions.laptop.enabled true
```
The key uses the registry id, including any dots, and defaults to `false`. Registration
does not change that setting. SSH entries instead explain that FEED needs an HTTP entry;
ids reserved by feed settings (such as `progress`) get a different-id suggestion rather
than an invalid command. `--json` keeps its single JSON result without these hints.
The host CLI tests execute the advertised command against an isolated config and cover
registration, reserved names and JSON output; pairing responses are simulated, so they
do not establish connectivity to a live peer.
### ps
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig ps` | — | `--json`<br>`--no-cleanup`<br>`--resources`<br>`--nodes`<br>`--full`<br>`--verbose`<br>`--limit <n>`<br>`--fields <list>`<br>`--summary`<br>`--filter <key=value>`<br>`--active`<br>`--running`<br>`-A, --all-rigs`<br>`--rig <name>`<br>`--session <name>`<br>`--include-archived`<br>`--host <id>`<br>`--all-hosts`<br>`--hosts <ids>` |
- `--nodes --json` — each seat carries the daemon's served `needsAttention` flag; the CLI doesn't re-derive it, and it is absent when an older daemon doesn't serve it. `arbitratedNeedsInput` and `needsInputEvidence` (`{ rung, observedAt }` or null) come with `--full`, or by name through `--fields`.
### roster
| Invocation | Aliases | Declared options |
@@ -393,20 +334,7 @@ do not establish connectivity to a live peer.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig mcp` | — | — |
| `rig mcp serve` | — | `--port <port>`<br>`--http`<br>`--bind <host:port>`<br>`--grant-service <url>`<br>`--grant-service-header-file <path>`<br>`--oauth-resource <url>`<br>`--oauth-issuer <url>` |
| `rig mcp token` | — | — |
| `rig mcp token add <name>` | — | `--seat <session>` **required**<br>`--rig <name...>`<br>`--root <name...>`<br>`--workspace`<br>`--owner` |
| `rig mcp token list` | — | `--json` |
| `rig mcp token revoke <name>` | — | — |
### acp
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig acp` | — | — |
| `rig acp run <qitemId> <agent...>` | — | `--cwd <dir>`<br>`--permissions-to <session>`<br>`--load-session <id>`<br>`--poll-seconds <n>` |
| `rig acp cancel <runId>` | — | — |
| `rig acp stop <runId> [stopCommand...]` | — | — |
| `rig mcp serve` | — | `--port <port>` |
### agent
@@ -435,7 +363,7 @@ do not establish connectivity to a live peer.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig send [session] [text]` | — | `--to <sessions>` (repeatable)<br>`--pod <name>`<br>`--rig <name>`<br>`--verify`<br>`--force`<br>`--wait-for-idle <seconds>`<br>`--raw`<br>`--dangerously-interact`<br>`--type-without-checking`<br>`--reason <text>`<br>`--urgent`<br>`--host <id>`<br>`--from <session>`<br>`--context <ref>`<br>`--json` |
| `rig send [session] [text]` | — | `--to <sessions>` (repeatable)<br>`--pod <name>`<br>`--rig <name>`<br>`--verify`<br>`--force`<br>`--wait-for-idle <seconds>`<br>`--raw`<br>`--dangerously-interact`<br>`--reason <text>`<br>`--host <id>`<br>`--from <session>`<br>`--context <ref>`<br>`--json` |
### stream
@@ -447,29 +375,24 @@ do not establish connectivity to a live peer.
| `rig stream watch` | — | `--json` |
| `rig stream show <streamItemId>` | — | `--json` |
| `rig stream archive <streamItemId>` | — | `--json` |
| `rig stream subscribe <readerSession>` | — | `--tags <tags>`<br>`--type <hintType>`<br>`--actor <session>`<br>`--json` |
| `rig stream unsubscribe <readerSession>` | — | `--json` |
| `rig stream subscriptions` | — | `--json` |
| `rig stream read` | — | `--limit <n>`<br>`--reader <session>`<br>`--json` |
| `rig stream act <streamItemId>` | — | `--work <qitemId>`<br>`--decline <reason>`<br>`--duplicate <id>`<br>`--reader <session>`<br>`--json` |
### queue
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig queue` | — | — |
| `rig queue create` | — | `--source <session>`<br>`--destination <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--body-context <ref>`<br>`--mission <id>`<br>`--slice <id>`<br>`--gate <role>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--expires-at <iso>`<br>`--id <qitemId>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--human-intent <intent>`<br>`--alert`<br>`--human-detail-file <path>`<br>`--reply-to <qitemId>`<br>`--human-questions-file <path>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--verify`<br>`--full`<br>`--json` |
| `rig queue claim <qitemId>` | — | `--destination <session>`<br>`--full`<br>`--json` |
| `rig queue unclaim <qitemId>` | — | `--destination <session>`<br>`--reason <text>`<br>`--full`<br>`--json` |
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--full`<br>`--json` |
| `rig queue block <qitemId>` | — | `--on <blocker>` **required**<br>`--actor <session>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--continuation <text>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--full`<br>`--json` |
| `rig queue create` | — | `--source <session>`<br>`--destination <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--body-context <ref>`<br>`--mission <id>`<br>`--slice <id>`<br>`--gate <role>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--expires-at <iso>`<br>`--id <qitemId>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--human-intent <intent>`<br>`--human-detail-file <path>`<br>`--reply-to <qitemId>`<br>`--human-questions-file <path>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--verify`<br>`--json` |
| `rig queue claim <qitemId>` | — | `--destination <session>`<br>`--json` |
| `rig queue unclaim <qitemId>` | — | `--destination <session>`<br>`--reason <text>`<br>`--json` |
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--json` |
| `rig queue block <qitemId>` | — | `--on <blocker>` **required**<br>`--actor <session>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--continuation <text>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--json` |
| `rig queue resolve <qitemId>` | — | `--decision <text>` **required**<br>`--actor <session>`<br>`--bearer <token>`<br>`--no-notify`<br>`--json` |
| `rig queue handoff <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--alert`<br>`--host <id>`<br>`--no-nudge`<br>`--full`<br>`--json` |
| `rig queue handoff-and-complete <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--alert`<br>`--host <id>`<br>`--no-nudge`<br>`--full`<br>`--json` |
| `rig queue whoami` | — | `--session <session>`<br>`--recent-limit <n>`<br>`--work-candidates`<br>`--json` |
| `rig queue handoff <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--json` |
| `rig queue handoff-and-complete <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--json` |
| `rig queue whoami` | — | `--session <session>`<br>`--recent-limit <n>`<br>`--json` |
| `rig queue fallback <qitemId>` | — | `--destination <session>` **required**<br>`--reason <text>`<br>`--json` |
| `rig queue show <qitemId>` | — | `--full`<br>`--json` |
| `rig queue transitions <qitemId>` | — | `--json`<br>`--full` |
| `rig queue transitions <qitemId>` | — | `--json` |
| `rig queue list` | — | `-a, --all`<br>`-A, --all-rigs`<br>`--full`<br>`--owned`<br>`--mine`<br>`-o <format>`<br>`--destination <session>`<br>`--source <session>`<br>`--state <state>`<br>`--target-repo <name>`<br>`--limit <n>`<br>`--json` |
| `rig queue overdue` | — | `--rig <name>`<br>`-A, --all-rigs`<br>`--full`<br>`--limit <n>`<br>`--json` |
| `rig queue undelivered` | — | `--rig <name>`<br>`-A, --all-rigs`<br>`--full`<br>`--limit <n>`<br>`--json` |
@@ -488,14 +411,13 @@ do not establish connectivity to a live peer.
| `rig slack setup` | — | `--channel <id>`<br>`--inbound-destination <session>`<br>`--minimum-level-that-posts <level>`<br>`--minimum-level-that-interrupts <level>`<br>`--source-label <label>`<br>`--secrets-env-file <path>`<br>`--required-scopes <csv>`<br>`--reason <reason>`<br>`--actor <actor>` |
| `rig slack status` | — | `--json` |
| `rig slack manifest` | — | `--url`<br>`--json` |
| `rig slack verify` | — | `--json` |
| `rig slack verify` | — | `--json`<br>`--reason <reason>`<br>`--actor <actor>` |
| `rig slack channel-map` | — | — |
| `rig slack channel-map list` | — | `--json` |
| `rig slack channel-map set <match> <channel>` | — | `--inbound <seat>`<br>`--reason <reason>`<br>`--actor <actor>` |
| `rig slack channel-map set <match> <channel>` | — | `--reason <reason>`<br>`--actor <actor>` |
| `rig slack channel-map remove <match>` | — | `--reason <reason>`<br>`--actor <actor>` |
| `rig slack enable` | — | `--reason <reason>`<br>`--actor <actor>` |
| `rig slack disable` | — | `--reason <reason>` **required**<br>`--actor <actor>` |
| `rig slack post-open-asks` | — | `--reason <reason>`<br>`--wait <seconds>`<br>`--actor <actor>`<br>`--json` |
| `rig slack outbound` | — | `--json` |
| `rig slack inbound` | — | — |
@@ -509,10 +431,8 @@ do not establish connectivity to a live peer.
| `rig project experimental disable` | — | `--config <file>` **required**<br>`--max-requests <n>`<br>`--timeout-ms <n>`<br>`--json` |
| `rig project experimental status` | — | `--config <file>` **required**<br>`--max-requests <n>`<br>`--timeout-ms <n>`<br>`--json` |
| `rig project experimental capture` | — | `--config <file>` **required**<br>`--input <file>` **required**<br>`--output <file>` **required**<br>`--json` |
| `rig project decisions` | — | — |
| `rig project decisions status` | — | `--json` |
| `rig project candidates` | — | `--project <id>` **required**<br>`--taxonomy <file>` **required**<br>`--classifier-version <version>` **required**<br>`--evidence-epoch <epoch>` **required**<br>`--decisions <file>`<br>`--experiment <file>`<br>`--decide`<br>`--limit <count>`<br>`--json` |
| `rig project wake` | — | `--project <id>` **required**<br>`--taxonomy <file>` **required**<br>`--classifier-version <version>` **required**<br>`--evidence-epoch <epoch>` **required**<br>`--decisions <file>`<br>`--experiment <file>`<br>`--decide`<br>`--limit <count>`<br>`--json` |
| `rig project candidates` | — | `--project <id>` **required**<br>`--taxonomy <file>` **required**<br>`--classifier-version <version>` **required**<br>`--evidence-epoch <epoch>` **required**<br>`--decisions <file>`<br>`--experiment <file>`<br>`--limit <count>`<br>`--json` |
| `rig project wake` | — | `--project <id>` **required**<br>`--taxonomy <file>` **required**<br>`--classifier-version <version>` **required**<br>`--evidence-epoch <epoch>` **required**<br>`--decisions <file>`<br>`--experiment <file>`<br>`--limit <count>`<br>`--json` |
| `rig project shadow-status` | — | — |
| `rig project shadow-drain` | — | `--actor <name>` |
| `rig project shadow-stop` | — | `--actor <name>` |
@@ -538,24 +458,20 @@ do not establish connectivity to a live peer.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig terminal` | — | — |
| `rig terminal open <view>` | — | `--provider <name>`<br>`--session <name>`<br>`--window`<br>`--expected-plan <id>`<br>`--json` |
| `rig terminal open <view>` | — | `--provider <name>`<br>`--window`<br>`--expected-plan <id>`<br>`--json` |
| `rig terminal views` | — | `--json` |
| `rig terminal status` | — | `--provider <name>`<br>`--session <name>`<br>`--json` |
See [named terminal sessions](../reference/terminal-sessions.md) to select a
Herdr session per request, including session-bound previews and window reuse.
| `rig terminal status` | — | `--provider <name>`<br>`--json` |
### watchdog
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig watchdog` | — | — |
| `rig watchdog register` | — | `--spec <path>`<br>`--policy <policy>` **required**<br>`--target-session <session>` **required**<br>`--interval-seconds <n>` **required**<br>`--registered-by <session>` **required**<br>`--threshold-bytes <n>`<br>`--threshold-mb <n>`<br>`--threshold-tokens <n>`<br>`--threshold-percent <n>`<br>`--watched-file <path>`<br>`--requires-job <jobId>`<br>`--message <text>`<br>`--active-wake-interval-seconds <n>`<br>`--scan-interval-seconds <n>`<br>`--json` |
| `rig watchdog register` | — | `--spec <path>`<br>`--policy <policy>` **required**<br>`--target-session <session>` **required**<br>`--interval-seconds <n>` **required**<br>`--registered-by <session>` **required**<br>`--threshold-bytes <n>`<br>`--threshold-mb <n>`<br>`--watched-file <path>`<br>`--requires-job <jobId>`<br>`--message <text>`<br>`--active-wake-interval-seconds <n>`<br>`--scan-interval-seconds <n>`<br>`--json` |
| `rig watchdog list` | — | `-a, --all`<br>`--full`<br>`--limit <n>`<br>`--json` |
| `rig watchdog show <jobId>` | — | `--json` |
| `rig watchdog status <jobId>` | — | `--json` |
| `rig watchdog stop <jobId>` | — | `--reason <text>`<br>`--json` |
| `rig watchdog update <jobId>` | — | `--interval-seconds <n>`<br>`--message <text>`<br>`--spec <path>`<br>`--json` |
### workflow
@@ -567,26 +483,26 @@ Herdr session per request, including session-bound previews and window reuse.
| `rig workflow revise <instanceId>` | — | `--apply`<br>`--expected-version <number>`<br>`--expected-digest <sha256>`<br>`--operation-key <key>`<br>`--actor-session <session>`<br>`--reason <text>`<br>`--json` |
| `rig workflow operation <key>` | — | `--json` |
| `rig workflow instantiate-lifecycle <missionPath>` | — | `--operation-key <key>` **required**<br>`--root-objective <text>` **required**<br>`--created-by <session>` **required**<br>`--entry-owner <session>`<br>`--rig <name>`<br>`--json` |
| `rig workflow instantiate <specPath>` | — | `--root-objective <text>` **required**<br>`--created-by <session>` **required**<br>`--entry-owner <session>`<br>`--rig <name>`<br>`--project <id>`<br>`--mission <id>`<br>`--slice <id>`<br>`--tags <tags>`<br>`--json` |
| `rig workflow project` | — | `--instance <id>` **required**<br>`--current-packet <qitem-id>` **required**<br>`--exit <kind>` **required**<br>`--actor-session <session>` (only when the caller's session can't be derived)<br>`--result-note <text>`<br>`--evidence-ref <ref>`<br>`--wait-for-proof <scope>`<br>`--blocked-on <ref>`<br>`--next-owner <session>`<br>`--acceptance-candidate <identity>`<br>`--acceptance-verdict <verdict>`<br>`--acceptance-evidence-ref <path>`<br>`--json` |
| `rig workflow instantiate <specPath>` | — | `--root-objective <text>` **required**<br>`--created-by <session>` **required**<br>`--entry-owner <session>`<br>`--rig <name>`<br>`--json` |
| `rig workflow project` | — | `--instance <id>` **required**<br>`--current-packet <qitem-id>` **required**<br>`--exit <kind>` **required**<br>`--actor-session <session>` **required**<br>`--result-note <text>`<br>`--evidence-ref <ref>`<br>`--wait-for-proof <scope>`<br>`--blocked-on <ref>`<br>`--next-owner <session>`<br>`--acceptance-candidate <identity>`<br>`--acceptance-verdict <verdict>`<br>`--acceptance-evidence-ref <path>`<br>`--json` |
| `rig workflow list` | — | `--status <s>`<br>`--json` |
| `rig workflow specs` | — | `--json` |
| `rig workflow guidance <instanceId>` | — | `--packet <id>`<br>`--component <id>`<br>`--full`<br>`--json` |
| `rig workflow show <instanceId>` | — | `--json` |
| `rig workflow trace <instanceId>` | — | `--json` |
| `rig workflow continue <instanceId>` | — | `--json` |
| `rig workflow run <specPath>` | — | `--root-objective <text>` **required**<br>`--created-by <session>` **required**<br>`--entry-owner <session>`<br>`--rig <name>`<br>`--project <id>`<br>`--mission <id>`<br>`--slice <id>`<br>`--tags <tags>`<br>`--json` |
| `rig workflow run <specPath>` | — | `--root-objective <text>` **required**<br>`--created-by <session>` **required**<br>`--entry-owner <session>`<br>`--rig <name>`<br>`--json` |
| `rig workflow watch <instanceId>` | — | `--json` |
| `rig workflow route <instanceId>` | — | `--to <session>` **required**<br>`--actor-session <session>` (only when the caller's session can't be derived)<br>`--packet <qitem-id>`<br>`--reason <text>`<br>`--json` |
| `rig workflow resume <instanceId>` | — | `--actor-session <session>` (only when the caller's session can't be derived)<br>`--occurrence <failed-qitem-id>`<br>`--decision <text>`<br>`--json` |
| `rig workflow abort <instanceId>` | — | `--reason <text>` **required**<br>`--actor-session <session>` (only when the caller's session can't be derived)<br>`--json` |
| `rig workflow route <instanceId>` | — | `--to <session>` **required**<br>`--actor-session <session>` **required**<br>`--packet <qitem-id>`<br>`--reason <text>`<br>`--json` |
| `rig workflow resume <instanceId>` | — | `--actor-session <session>` **required**<br>`--occurrence <failed-qitem-id>`<br>`--decision <text>`<br>`--json` |
| `rig workflow abort <instanceId>` | — | `--reason <text>` **required**<br>`--actor-session <session>` **required**<br>`--json` |
| `rig workflow status` | — | `--json` |
### capture
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig capture [session]` | — | `--rig <name>`<br>`--pod <name>`<br>`--lines <n>`<br>`--history-plus-pane`<br>`--readable`<br>`--size <WxH>`<br>`--host <id>`<br>`--json` |
| `rig capture [session]` | — | `--rig <name>`<br>`--pod <name>`<br>`--lines <n>`<br>`--host <id>`<br>`--json` |
### broadcast
@@ -604,12 +520,7 @@ Herdr session per request, including session-bound previews and window reuse.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig ask <rig> <question>` | — | `--json`<br>`--seat <session-name>`<br>`--session <token>`<br>`--wake <target>`<br>`--runtime <runtime>`<br>`--wake-timeout <seconds>` |
Wake seat targets must include the rig: `member@rig` or `member@rig@N` (1 = newest tenure).
Values without `@` go unchanged to the runtime as a session ID or title; a bare member
name is not resolved within `<rig>`.
Only Claude and Codex tenures can be woken.
| `rig ask <rig> <question>` | — | `--json`<br>`--seat <session-name>`<br>`--session <token>`<br>`--wake <seat[@gen]\|token>`<br>`--runtime <runtime>`<br>`--wake-timeout <seconds>` |
### chatroom
@@ -672,7 +583,7 @@ Only Claude and Codex tenures can be woken.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig skill` | — | — |
| `rig skill loadout` | — | `--runtime <runtime>` (folder mode; **required** there)<br>`--seat <session>` (seat mode; `self` is this seat)<br>`--wait-for-idle <ms>`<br>`--resend`<br>`--cwd <path>`<br>`--project-root <path>`<br>`--topology <ids>`<br>`--apply`<br>`--json` |
| `rig skill loadout` | — | `--runtime <runtime>` **required**<br>`--cwd <path>`<br>`--project-root <path>`<br>`--topology <ids>`<br>`--apply`<br>`--json` |
| `rig skill audit` | — | `--json` |
### agent-image
@@ -690,18 +601,11 @@ Only Claude and Codex tenures can be woken.
| `rig agent-image prune` | — | `--dry-run`<br>`--force`<br>`--json` |
| `rig agent-image sync` | — | `--json` |
### community-harness
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig community-harness` | — | — |
| `rig community-harness list` | — | `--json` |
### fork
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig fork <source-session>` | — | `--rig <rig>` **required**<br>`--pod <pod-namespace>` **required**<br>`--member <member-id>` **required**<br>`--keep-image`<br>`--image-name <name>`<br>`--image-version <version>`<br>`--rig-root <path>`<br>`--json` |
| `rig fork <source-session>` | — | `--rig <rig-id>` **required**<br>`--pod <pod-namespace>` **required**<br>`--member <member-id>` **required**<br>`--keep-image`<br>`--image-name <name>`<br>`--image-version <version>`<br>`--rig-root <path>`<br>`--json` |
### workspace
@@ -720,7 +624,7 @@ Only Claude and Codex tenures can be woken.
| `rig mode show` | — | `--json` |
| `rig mode effective` | — | `--rig <id>`<br>`--project <id>`<br>`--mission <id>`<br>`--workstream <id>`<br>`--qitem <id>`<br>`--json` |
| `rig mode cite` | — | `--rig <id>`<br>`--project <id>`<br>`--mission <id>`<br>`--workstream <id>`<br>`--qitem <id>` |
| `rig mode unset <scope> [qualifier]` | — | `--bearer <token>`<br>`--category <category>`<br>`--json` |
| `rig mode unset <scope> [qualifier]` | — | `--bearer <token>`<br>`--json` |
| `rig mode defaults` | — | `--json` |
### policy
@@ -732,17 +636,17 @@ Only Claude and Codex tenures can be woken.
| `rig policy permissions list` | — | `--spec <path>`<br>`--json` |
| `rig policy permissions show <nameOrRef>` | — | `--spec <path>`<br>`--json` |
| `rig policy permissions current` | — | `--spec <path>` **required**<br>`--json` |
| `rig policy permissions apply <name>` | — | `--spec <path>`<br>`--seat <seat>`<br>`--reason <text>`<br>`--operator <address>`<br>`--json` |
| `rig policy permissions apply <name>` | — | `--spec <path>` **required**<br>`--json` |
| `rig policy list` | — | `--spec <path>`<br>`--json` |
| `rig policy show <nameOrRef>` | — | `--spec <path>`<br>`--json` |
| `rig policy current` | — | `--spec <path>` **required**<br>`--json` |
| `rig policy apply <name>` | — | `--spec <path>`<br>`--seat <seat>`<br>`--reason <text>`<br>`--operator <address>`<br>`--json` |
| `rig policy apply <name>` | — | `--spec <path>` **required**<br>`--json` |
### whoami
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig whoami` | — | `--node-id <id>`<br>`--session <name>`<br>`--host <id>`<br>`--all-hosts`<br>`--hosts <ids>`<br>`--json`<br>`--full`<br>`--verbose`<br>`--all-peers` |
| `rig whoami` | — | `--node-id <id>`<br>`--session <name>`<br>`--host <id>`<br>`--all-hosts`<br>`--hosts <ids>`<br>`--json`<br>`--full`<br>`--verbose` |
### config
@@ -774,10 +678,10 @@ Only Claude and Codex tenures can be woken.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig auth` | — | — |
| `rig auth status` | — | `--runtime <runtime>`<br>`--codex-home <dir>`<br>`--host <id>` |
| `rig auth list` | — | `--runtime <runtime>`<br>`--codex-home <dir>`<br>`--host <id>` |
| `rig auth status` | — | `--runtime <runtime>` |
| `rig auth list` | — | `--runtime <runtime>` |
| `rig auth save <profile>` | — | `--runtime <runtime>` |
| `rig auth switch <profile>` | — | `--runtime <runtime>`<br>`--codex-home <dir>`<br>`--host <id>` |
| `rig auth switch <profile>` | — | `--runtime <runtime>` |
| `rig auth validate <profile>` | — | `--runtime <runtime>` |
| `rig auth seats` | — | — |
| `rig auth seats list` | — | `--runtime <runtime>` |
@@ -790,14 +694,12 @@ Only Claude and Codex tenures can be woken.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig provider` | — | — |
| `rig provider status` | — | `--json`<br>`--refresh`<br>`--provider <p>`<br>`--claude-token-file <path>`<br>`--credential-label <label>`<br>`--all-hosts`<br>`--hosts <ids>` |
| `rig provider status` | — | `--json` |
| `rig provider accounts` | — | `--json`<br>`--provider <p>`<br>`--account <a>` |
| `rig provider bindings` | — | `--json`<br>`--provider <p>`<br>`--account <a>` |
| `rig provider signals` | — | `--json`<br>`--provider <p>`<br>`--account <a>` |
| `rig provider precheck` | — | `--seat <s>` **required**<br>`--to-account <a>`<br>`--token-file <path>`<br>`--stored-login`<br>`--json` |
| `rig provider switch` | — | `--seat <s>` **required**<br>`--to-account <a>`<br>`--token-file <path>`<br>`--stored-login`<br>`--force-unsafe`<br>`--json` |
| `rig provider moves` | — | `--seat <seat>`<br>`--limit <n>`<br>`--json` |
| `rig provider moves report` | — | `--file <path>` **required**<br>`--to <seat>` **required**<br>`--follows <qitemId>`<br>`--evidence-ref <ref>`<br>`--json` |
| `rig provider precheck` | — | `--seat <s>` **required**<br>`--to-account <a>` **required**<br>`--json` |
| `rig provider switch` | — | `--seat <s>` **required**<br>`--to-account <a>` **required**<br>`--force-unsafe`<br>`--json` |
### usage
@@ -805,7 +707,6 @@ Only Claude and Codex tenures can be woken.
|---|---|---|
| `rig usage` | — | — |
| `rig usage top` | — | `--window <duration>`<br>`--top <n>`<br>`--json` |
| `rig usage cost` | — | `--seat <session>`<br>`--json` |
| `rig usage series` | — | `--seat <session>`<br>`--lane <lane>`<br>`--since <iso>`<br>`--until <iso>`<br>`--limit <n>`<br>`--json` |
### telemetry
@@ -842,13 +743,13 @@ Only Claude and Codex tenures can be woken.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig expand <rig> <pod-fragment-path>` | — | `--json`<br>`--rig-root <path>` |
| `rig expand <rig-id> <pod-fragment-path>` | — | `--json`<br>`--rig-root <path>` |
### add
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig add <rig> <pod-namespace> <member-fragment-path>` | — | `--json`<br>`--rig-root <path>` |
| `rig add <rig-id> <pod-namespace> <member-fragment-path>` | — | `--json`<br>`--rig-root <path>` |
### create
@@ -862,25 +763,11 @@ Only Claude and Codex tenures can be woken.
|---|---|---|
| `rig grow <rig-id> <members...>` | — | `--pod <pod>`<br>`--new-pod <pod>`<br>`--runtime <runtime>`<br>`--cwd <path>`<br>`--json` |
### edge
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig edge` | — | — |
| `rig edge add <rig> [from] [to]` | — | `--kind <kind>`<br>`--plan`<br>`--json` |
| `rig edge remove <rig> <edge-id>` | — | `--plan`<br>`--json` |
`add` requires `from`, `to` and `--kind`, and is idempotent: a repeat reports the
existing edge and writes nothing. `remove` deletes the edge with that ID; a repeat changes nothing and
returns `edge_not_found` (exit 1). See
[changing edges on an existing rig](../reference/rig-spec.md#changing-edges-on-an-existing-rig)
for input and receipt examples, rollback, and snapshot behavior.
### reconcile-session
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig reconcile-session <session>` | — | `--rig <rigId>`<br>`--node <logicalId>`<br>`--no-launch`<br>`--repair-missing-pane`<br>`--json` |
| `rig reconcile-session <session>` | — | `--rig <rigId>`<br>`--node <logicalId>`<br>`--no-launch`<br>`--json` |
### env
@@ -901,13 +788,30 @@ for input and receipt examples, rollback, and snapshot behavior.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig release <rig>` | — | `--delete`<br>`--json` |
| `rig release <rigId>` | — | `--delete`<br>`--json` |
### launch
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig launch <rig> [nodeRef]` | — | `--seats <ids>`<br>`--hold-reason <reason>`<br>`--snapshot-id <id>`<br>`--retry-startup-from <member-file>`<br>`--rig-root <path>`<br>`--plan`<br>`--json`<br>`--host <id>` |
| `rig launch <rigId> [nodeRef]` | — | `--seats <ids>`<br>`--hold-reason <reason>`<br>`--snapshot-id <id>`<br>`--retry-startup-from <member-file>`<br>`--first-start-from <rig-file>`<br>`--rig-root <path>`<br>`--plan`<br>`--json`<br>`--host <id>` |
For an existing pod member that has never started, use
`rig launch <rigId> <nodeRef> --first-start-from <rig.yaml> --rig-root <absolute-root> --plan`.
The complete RigSpec supplies the selected member's inherited guidance; only that
existing node is eligible to launch. Its identity, source references, cwd and policy
must agree with the retained member. A mismatch requires separate source convergence.
The root and referenced files must already exist on the selected daemon host.
Omit `--plan` to print the resolved model, effort and permission plan, then execute
it once. `--json` emits a plan JSON line followed by a result JSON line; with
`--plan` it emits only the plan. Execution revalidates the source/state fingerprint.
An unset model means the runtime default is requested; the concrete native model
is unobserved. Persisted `rig seat set-model` and native permission choices apply.
Prior sessions, startup context or native history require the ordinary restore or
retry paths. Live or unknown occupancy refuses first start. No other seat, edge or
held state is changed. After a lost response, inspect the seat before recovery;
the command does not retry automatically.
### remove
@@ -919,7 +823,7 @@ for input and receipt examples, rollback, and snapshot behavior.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig shrink <rig> <podRef>` | — | `--fallback <live-seat>`<br>`--json` |
| `rig shrink <rigId> <podRef>` | — | `--fallback <live-seat>`<br>`--json` |
### destroy
@@ -931,7 +835,7 @@ for input and receipt examples, rollback, and snapshot behavior.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig setup` | — | `--dry-run`<br>`--json`<br>`--full`<br>`--no-herdr`<br>`--ghostty`<br>`--no-ghostty`<br>`--providers <list>`<br>`--policy <name>`<br>`--spec <path>`<br>`--router-line <answer>` |
| `rig setup` | — | `--dry-run`<br>`--json`<br>`--full`<br>`--no-herdr`<br>`--ghostty`<br>`--no-ghostty`<br>`--policy <name>`<br>`--spec <path>` |
### restore-check
@@ -954,8 +858,6 @@ for input and receipt examples, rollback, and snapshot behavior.
|---|---|---|
| `rig compact-plan` | — | `--json`<br>`--rig <name>`<br>`--refresh`<br>`--threshold-tokens <n>`<br>`--threshold-percent <0-100>` |
- `--threshold-tokens` and `--threshold-percent` set the plan's own candidate thresholds (the percentage applies when a seat's context window size is missing). They are not the automatic compaction setting: the output labels them as the plan's own and shows `policies.claude_compaction` (`enabled`, `threshold_percent`) separately, for comparison only, or says it couldn't be read.
### compact
| Invocation | Aliases | Declared options |
@@ -973,57 +875,21 @@ for input and receipt examples, rollback, and snapshot behavior.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig seat` | — | — |
| `rig seat history` | — | `--rig <id>` **required**<br>`--node-id <id>` **required**<br>`--snapshot-id <id>`<br>`--history-session-id <id>`<br>`--json` |
| `rig seat set-typing-guard <seat>` | — | `--enabled <boolean>`<br>`--mode <mode>`<br>`--reason <text>` **required**<br>`--json` |
| `rig seat set-typing-guard <seat>` | — | `--enabled <boolean>` **required**<br>`--reason <text>` **required**<br>`--json` |
| `rig seat held-messages <seat>` | — | `--limit <n>`<br>`--offset <n>`<br>`--id <id>`<br>`--json` |
| `rig seat retire-held-message <seat> <id>` | — | `--reason <text>` **required**<br>`--json` |
| `rig seat status <seat>` | — | `--json` |
| `rig seat handover <seat>` | — | `--source <source>`<br>`--reason <reason>`<br>`--operator <address>`<br>`--dry-run`<br>`--json` |
| `rig seat switch-client <seat>` | — | `--to-window <n>`<br>`--client <id>`<br>`--json` |
| `rig seat clear-attention <session>` | — | `--reason <text>`<br>`--json` |
| `rig seat set-terminal-policy <seat>` | — | `--preset <preset>` **required**<br>`--helper <addresses...>`<br>`--rig-default`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json`<br>`--peer-compaction <decision>`<br>`--peer-model <decision>`<br>`--self-compaction <decision>`<br>`--self-model <decision>`<br>`--openrig-compaction <decision>`<br>`--openrig-model <decision>` |
| `rig seat set-permissions <seat>` | — | `--mode <mode>`<br>`--rules <file>`<br>`--codex-permissions <file>`<br>`--codex-profile <name>`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat set-effort <seat>` | — | `--effort <level>`<br>`--clear`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat effort <seat>` | — | `--json` |
| `rig seat set-permissions <seat>` | — | `--mode <mode>` **required**<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat set-model <seat>` | — | `--model <id>` **required**<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat set-runtime <seat>` | — | `--runtime <runtime>`<br>`--reason <text>`<br>`--model <id>`<br>`--effort <level>`<br>`--codex-config-profile <name>`<br>`--permission-mode <mode>`<br>`--confirm-timeout <seconds>`<br>`--dry-run`<br>`--confirm <switch-id>`<br>`--note <text>`<br>`--recover`<br>`--json` |
| `rig seat set-cwd <seat>` | — | `--cwd <directory>` **required**<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat launch <seat>` | — | `--fresh` **required**<br>`--reason <text>`<br>`--plan`<br>`--stop`<br>`--operator <address>`<br>`--json` |
| `rig seat restart <seat>` | — | `--keep-conversation` **required**<br>`--reason <text>`<br>`--plan`<br>`--verify`<br>`--expect-account <text>`<br>`--operator <address>`<br>`--host <id>`<br>`--json` |
| `rig seat launch <seat>` | — | `--fresh` **required**<br>`--reason <text>` **required**<br>`--stop`<br>`--operator <address>`<br>`--json` |
| `rig seat continue <seat>` | — | `--json` |
| `rig seat stop <seat>` | — | `--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat clean <seat>` | — | `--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
| `rig seat set-resume-token <session>` | — | `--token-stdin`<br>`--reason <text>` **required**<br>`--json` |
A `rig seat stop` that successfully kills the current session records that it
was intentionally stopped. With other healthy members running, the rig's `lifecycleState` in
`rig ps` and the TUI stays `running`; the stopped member no longer makes it
`degraded`. Stopping every member
leaves the rig `stopped`. Node JSON exposes `intentionallyStopped` in compact,
`--full`, and `--fields` output.
Process counts still reflect reality: stopping one of two members leaves
`nodeCount: 2`, `runningCount: 1`, and process `status: partial`. Node lifecycle,
resume information, and attention diagnostics remain available. A crash or
attention condition on another member still affects the rig lifecycle.
Stop intent survives daemon restarts and audit retention. It applies only to the
latest exited session while the seat has no binding; a subsequent session status
change clears it, and a new session starts without it. Existing exits are not
backfilled as intentional stops. If the session disappears between the live
probe and the stop, its unexplained exit still affects rig health. The internal
stop in `rig seat launch --fresh --stop` does not record final stop intent, so a
refused replacement still degrades a rig with other healthy members running.
`rig start --last` and `rig start --rigs <rig>` use the full per-seat lifecycle
for restore selection. A partly running rig with an intentionally stopped member
still appears as a `degraded` candidate and reports `rig_not_stopped` (exit 1)
when restore is attempted. Launch scheduling and restore eligibility remain
unchanged.
See [per-seat terminal delivery](../reference/seat-delivery.md) for
off, draft-aware and hold modes, immediate refusal results and held-message inspection.
### handover
| Invocation | Aliases | Declared options |
@@ -1072,6 +938,6 @@ off, draft-aware and hold modes, immediate refusal results and held-message insp
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig proof` | — | `--workspace <path>` |
| `rig proof show [scope]` | — | `--project <id>`<br>`--json` |
| `rig proof judge <scope-item>` | — | `--project <id>`<br>`--verdict <verdict>` **required**<br>`--reason <text>` **required**<br>`--evidence <ref>` (repeatable)<br>`--subject <kind:ref>`<br>`--comparison <ref>`<br>`--revision <revision>`<br>`--operation-id <id>`<br>`--replace`<br>`--json` |
| `rig proof show [scope]` | — | `--json` |
| `rig proof judge <scope-item>` | — | `--verdict <verdict>` **required**<br>`--reason <text>` **required**<br>`--evidence <ref>` (repeatable)<br>`--subject <kind:ref>`<br>`--comparison <ref>`<br>`--revision <revision>`<br>`--operation-id <id>`<br>`--replace`<br>`--json` |
| `rig proof add <slice-path>` | — | `--mission <name>`<br>`--artifact-type <type>` **required**<br>`--verdict <verdict>` **required**<br>`--candidate-sha <sha>` **required**<br>`--money-evidence <line>` **required**<br>`--slice-id <dot-id>`<br>`--file <path>`<br>`--body <text>`<br>`--name <filename>`<br>`--replace`<br>`--evidences <refs>`<br>`--self-check <text>`<br>`--media <refs>`<br>`--json` |
+4 -4
View File
@@ -9,14 +9,14 @@ applies-when: |
Follow the module and source entry points below, then read the implementation.
siblings: [README.md, arteries.md, test-layers.md, cli-reference.md]
prerequisite-reads: [README.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# OpenRig codemap
The paths and named entry points below were checked at main
`e8f0ab340db773392ec8be75b072d1c0f3068a50`. This is a navigation map, not a replacement for
`2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7`. This is a navigation map, not a replacement for
reading the implementation. Linked documents retain their individual verification stamps.
[README.md](README.md) lists the complete document tree and commands to derive its inventory.
@@ -79,7 +79,7 @@ stamp says whether it already covers them; until then, start from the source nam
| What does a non-interruptive launch add? | `nonInterruptiveArgs()` in `packages/daemon/src/adapters/non-interruptive.ts` (per-launch arguments only; it never changes permissions or native settings files). The separate instance-wide YOLO setting, off by default, is in `packages/daemon/src/adapters/yolo-mode.ts`. | [Adapters and runtimes](architecture/adapters-and-runtimes.md) |
| What launch arguments does a kernel seat get? | `operationalLaunchArgs()` and `KERNEL_CLAUDE_ALLOW` in `packages/daemon/src/adapters/kernel-authority.ts`; `NativePermissionStore.launchOverride()` in `packages/daemon/src/domain/native-permission-store.ts` decides it per launch (only the rig named `kernel`, with no explicit seat choice, permission policy or Codex profile). | [Adapters and runtimes](architecture/adapters-and-runtimes.md) |
| What launch default does a seat in any other rig get? | Under the same conditions `launchOverride()` sets `teamPermissionDefault`: a Claude seat at the floor posture gets `TEAM_CLAUDE_ALLOW` and `TEAM_CLAUDE_ASK` from `operationalLaunchArgs()` in `packages/daemon/src/adapters/kernel-authority.ts`, whose `claudeCommandSettings()` hands the kernel or team lists to the session-only Bash hook `packages/daemon/assets/claude-team-permissions.cjs`, which returns the allow or ask decision; a workspace-write Codex seat gets `--add-dir` roots from `prepareCodexTeamWorkspace()` in `packages/daemon/src/domain/codex-team-workspace.ts`. | [Adapters and runtimes](architecture/adapters-and-runtimes.md) |
| How does `rig seat continue` finish a fresh or Codex-fork startup that stopped before its context was delivered? | `continueStartup()` in `packages/daemon/src/domain/seat-lifecycle-service.ts`, `canContinueStartup()` and `readPendingStartupContext()` in `packages/daemon/src/domain/startup-orchestrator.ts`, `captureForkIdentity()` in `packages/daemon/src/adapters/codex-runtime-adapter.ts`, the route in `packages/daemon/src/routes/seat.ts`, and the command in `packages/cli/src/commands/seat.ts`. | [Agent spec and startup](architecture/agent-spec-and-startup.md) |
| How does `rig seat continue` finish a fresh startup that stopped before its context was delivered? | `continueFreshStartup()` in `packages/daemon/src/domain/seat-lifecycle-service.ts`, `canContinueFresh()` in `packages/daemon/src/domain/startup-orchestrator.ts`, the route in `packages/daemon/src/routes/seat.ts`, and the command in `packages/cli/src/commands/seat.ts`. | [Agent spec and startup](architecture/agent-spec-and-startup.md) |
| How does `rig context work-install` choose a project and position? | `resolveWorkPosition()` in `packages/cli/src/lib/work-install.ts`, `packages/cli/src/commands/context.ts`, and `packages/daemon/src/domain/workspace/project-catalog.ts`. | [Workspace](architecture/workspace-primitive.md) |
| Where do rosters come from? | `packages/cli/src/commands/roster.ts`: `rig roster list`, `show` and `find` read JSON roster files from `<workspace.root>/rosters` (or `--folder`) and are read-only. | [Agent spec and startup](architecture/agent-spec-and-startup.md), "Specialist rosters" |
| What telemetry can be read? | `packages/daemon/src/domain/finite-telemetry.ts` (the `events`, `queue-transitions` and `tenures` streams), `/api/telemetry` in `packages/daemon/src/routes/telemetry.ts`, and `packages/cli/src/commands/telemetry.ts`. | [Architecture rules and events](architecture/architecture-rules-and-event-system.md), "Telemetry read surface" |
+5 -5
View File
@@ -6,13 +6,13 @@ topics: [knowledge-and-context, frontmatter]
domains: [engineering-advisor, operating-advisor]
applies-when: |
Writing or refreshing a source-grounded document under docs/as-built/.
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# As-Built Frontmatter
Source snapshot: `e8f0ab340db773392ec8be75b072d1c0f3068a50`. This describes the source at that commit;
Source snapshot: `2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7`. This describes the source at that commit;
it does not establish the version or behavior of a running daemon.
Frontmatter makes an as-built page discoverable and records the source it
@@ -44,8 +44,8 @@ that an installed package or a running system uses that source. Keep runtime
observations separately qualified.
```yaml
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
```
Check that the commit resolves with `git cat-file -t <commit>` in a checkout
+44 -103
View File
@@ -7,13 +7,13 @@ applies-when: |
request, what each check actually proves, and what CI will run for you. Also
read it before claiming a stub-agent scenario covers a behaviour.
siblings: [arteries.md, README.md, codemap.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# Test layers: what to run before you push, and what each layer proves
> **This page is a map, not the territory.** It was checked against source at `e8f0ab34`
> **This page is a map, not the territory.** It was checked against source at `2caac7dd`
> and it will drift. It is also incomplete on purpose. Before you rely on a command or a
> claim here, read the script it names or run it. If a behaviour is missing from this page
> (or from [arteries.md](arteries.md)), that tells you nothing about whether a change to it
@@ -39,10 +39,10 @@ to `main`. In the PR description, say which layers you ran and which you could n
|---|---|---|---|---|
| Typecheck | `npm run lint` | Builds the daemon, then `tsc --noEmit` passes for daemon, ui, cli and tui | Any runtime behaviour. Formatting. | Every change |
| Build | `npm run build` | All four workspaces compile (the UI also runs `vite build`) | That the *published* package is complete (see Packaging) | Every change |
| Repo checks | `npm run test:repo` | Builds the daemon, then runs `node --test --test-concurrency=1 scripts/*.test.mjs`, `scripts/check-first-run-references.mjs`, `scripts/check-docs-guard.mjs`, `mirror-skills --check` (skill homes, generated copies, layout and digests) and `generate-context-packs --check` | Package behaviour | Every change. It matters most when you touch `scripts/`, `docs/` or skills. |
| Repo checks | `npm run test:repo` | Builds the daemon, then runs `node --test --test-concurrency=1 scripts/*.test.mjs`, `scripts/check-first-run-references.mjs`, `scripts/check-docs-guard.mjs`, `mirror-skills --check` and `generate-context-packs --check` | Package behaviour | Every change. It matters most when you touch `scripts/`, `docs/` or skills. |
| Package suites | `npm run test:workspaces`. One package: `npm run test -w packages/cli`. One file, from the repo root: `npx vitest run packages/cli/test/<file>.test.ts` | Vitest unit and integration tests for daemon, cli and tui. Some of them start real scenario daemons, tmux sessions and stub runners. | The installed package. Real Claude Code or Codex. CI's sandbox conditions (see CI). | Every change (`npm test` covers it) |
| Web UI suite | `npm run test:ui` | jsdom unit tests for `packages/ui` | Real browser rendering | When you touch `packages/ui`, or a daemon API the UI reads. The web UI is in maintenance mode, so this suite is advisory locally and is not part of `npm test`. |
| Generated files | `npm run mirror-skills:check`, `npm run generate-context-packs:check` (both part of `npm test`) | Shipped skill homes are publishable, generated copies match their homes, all edges match their recorded hashes, and every static context pack passes the daemon's own manifest parser and the leak scan | Whether the skill content is any good | Edit a shipped skill's one in-repo home, run `npm run mirror-skills` to regenerate its other copies and refresh digests, then run the checks and commit the result. The apply reads nothing outside the repository. See "A shipped skill" in [ARCHITECTURE.md](../../ARCHITECTURE.md#a-shipped-skill). |
| Generated files | `npm run mirror-skills:check`, `npm run generate-context-packs:check` (both part of `npm test`) | Every copy of each shipped skill matches its recorded hash, and every static context pack passes the daemon's own manifest parser and the leak scan | Whether the skill content is any good | After you edit a shipped skill: edit the same file in every copy that carries it, run `node scripts/regen-edge-digests.mjs`, then the check. The full `npm run mirror-skills` apply needs maintainer-only inputs. See "A shipped skill" in [ARCHITECTURE.md](../../ARCHITECTURE.md#a-shipped-skill). |
| Local gate | `npm run gate` | Lint plus `npm test` on a clean tree, tied to `HEAD`, one run per machine at a time, with a JSON receipt | Anything that lint and `npm test` don't already prove | Optional. Useful when several agents share one machine, or when you want a receipt tied to a commit. |
| Packaging | `npm run build:package`, `npm run test:tui-package`, `bash scripts/smoke-fresh-install.sh` | The publishable `@openrig/cli` assembles, the packed TUI launches, and a fresh install starts a daemon | Behaviour beyond startup | When you touch dependencies, bin entries, build scripts, or files a package ships |
| Stub scenarios, host mode | `node --import tsx packages/daemon/scripts/run-scenarios.mjs [scenario.yaml …]` | A scenario's steps and assertions pass against a scratch daemon, real tmux and `runtime: stub` seats | The installed package. Seeded-regression pairing. Everything under [What a stub can and cannot prove](#what-a-stub-can-and-cannot-prove). | While you write or debug a scenario |
@@ -71,13 +71,8 @@ are not yet runnable checks.
database, workspace and topology roots, seat identity, bearer tokens), forces a fixture
`OPENRIG_HOME` and fails if the home is not fixture-scoped. It also wraps `fetch`, so a real
request to anything other than a loopback ephemeral-port fixture or a registered target fails.
It points `HOME` and the XDG directories at a fixture user home and clears `CODEX_HOME` and
`CLAUDE_CONFIG_DIR`. `OPENRIG_E2E_REAL_CODEX=1` keeps native homes and binaries only for
`seat-handover-model-fidelity-e2e.test.ts` and `seat-lifecycle-set-model-resume-e2e.test.ts`;
all other files remain isolated. The scenario scaffold always uses fixture homes and runtime stubs.
The two native files rely on Vitest's default per-file isolation; runs without it are unsupported.
Scenario scaffolds scrub inherited provider roots but preserve those passed in an explicit `baseEnv`.
A test can still set its own
Unless `OPENRIG_E2E_REAL_CODEX=1`, it points `HOME` and the XDG directories at a fixture user
home and clears `CODEX_HOME`; that opt-in keeps the real homes. A test can still set its own
overrides after setup. The UI suite (jsdom) uses its own `packages/ui/test/setup.ts`.
- **Stale vendored daemon.** `npm run build:package` leaves an assembled copy of the daemon at
@@ -88,11 +83,6 @@ are not yet runnable checks.
credentials, and outbound network blocked except to localhost (see below). A test that reads
your real home directory, a running daemon, the network or a credential can pass on your
machine and fail in CI.
- **Mixed fixture clocks.** `compaction-command-submission.test.ts` uses one clock for the
enforcer, the transport's injected `now` and sleeps, and its `Date.now` idle deadline. Previously,
real-time transport waits could exhaust the fixture's 20 ms budget during a scheduler pause
even though the modeled seat was idle. Restore the `Date.now` spy after each test; keep real
deadline behavior covered by the transport tests. This changes the fixture, not production timeouts.
- **Docs location.** `check-docs-guard` fails if a tracked file under `docs/` sits outside
`docs/as-built/`, `docs/reference/` or `docs/releases/`. `docs/DESIGN.md` is the one allowed
exception.
@@ -109,17 +99,7 @@ credentials are supplied.
| `typecheck` | ubuntu | `npm run lint` |
| `repo-checks` | ubuntu | `npm run test:repo` |
| `package-tests` | macOS, matrix `daemon`, `cli`, `tui`, `ui` (fail-fast off) | Installs tmux, runs `npm ci` and `npm run build`. Then runs `npm run test -w packages/<pkg>` under `env -i` with a scratch `HOME`/`TMPDIR`/`TMUX_TMPDIR` and a `sandbox-exec` profile that blocks outbound network except localhost and the job's own socket directory. `scripts/ci-macos-process-probe.mjs` runs first. |
| `linux-seat-environment` | ubuntu | Builds all packages, then runs `seat-env-parent-session.integration.test.ts` with real CLI, daemon, tmux and stub seats. A network namespace has loopback only; tests run as the unprivileged runner user under `env -i` with scratch `HOME`/`TMPDIR`/`TMUX_TMPDIR`. Runs on pull requests and manual dispatch, like `package-tests`. |
| `installed-scenario` | ubuntu | `bash scripts/run-pr-scenarios.sh` (fifteen container runs, described below). Uploads `dist/pr-scenarios/` as `installed-scenario-evidence`, including on failure. |
The Linux seat-environment job covers a gap in the macOS matrix: the integration test
reads `/proc/<pid>/environ`, so macOS skips it. It checks both a new tmux server and an
existing server carrying a parent's session identity. It verifies the scoped environment,
the runtime directory and disabled session-bus default, and tmux's terminal identity in
the launched panes; the existing owner session stays unchanged. This adds one focused
Linux job to the existing suite. Stub seats prove the environment passed to the pane;
native Claude image paste and Shift+Enter handling still need separate verification
(see [#2041](https://github.com/mvschwarz/openrig/issues/2041)).
| `installed-scenario` | ubuntu | `bash scripts/run-pr-scenarios.sh` (eight container runs, described below). Uploads `dist/pr-scenarios/` as `installed-scenario-evidence`, including on failure. |
A second workflow, `.github/workflows/portability-report.yml` (job `portability-report`),
lists lines your PR adds that contain machine-specific values: credentials, home or temp
@@ -158,8 +138,8 @@ Exit codes: 0 pass, 1 a leg failed, 2 lane busy, 3 the runner itself errored.
| Piece | Where |
|---|---|
| `runtime: stub` adapter | `packages/daemon/src/adapters/stub-runtime-adapter.ts`, with `stub-runner.ts` (the process that runs in the pane), `stub-script.ts` (the launch-script format) and `stub-compaction.ts` / `stub-restore.ts` |
| Scenario format and runner | `packages/daemon/test/helpers/scenario-*.ts`. Step verbs: `up`, `down`, `send`, `restart`, `daemon`, `seed_regression` are bound. `restore`, `mutate`, `policy` are rejected at load time (`UNBOUND_STEP_VERB`) because the runner has no shipped binding for them; `emit` parses but only `refocus` is bound. Assertion surfaces: `ps`, `queue`, `stream`, `scope`, `pane`, `transcript`, `tui_socket`, `policy_provenance`. |
| Scenario library | `packages/test-system/scenarios/`: 20 scenarios, 7 stub topologies, `agents/`, `culture.md`, per-seat stub scripts in `scripts/` |
| Scenario format and runner | `packages/daemon/test/helpers/scenario-*.ts`. Step verbs: `up`, `down`, `send`, `restart`, `daemon`, `seed_regression` are bound. `restore`, `emit`, `mutate`, `policy` parse but are unbound. Assertion surfaces: `ps`, `queue`, `stream`, `scope`, `pane`, `transcript`, `tui_socket`, `policy_provenance`. |
| Scenario library | `packages/test-system/scenarios/`: 15 scenarios, 7 stub topologies, `agents/`, `culture.md`, per-seat stub scripts in `scripts/` |
| Runner fixtures | `packages/daemon/test/fixtures/scenarios/` (`scenario-01-per-seat-scripts`, `scenario-02-baton`, `scenario-10-one-view-state`) |
| Host runner | `packages/daemon/scripts/run-scenarios.mjs` |
| In-container entry and result check | `packages/test-system/ci/run.mjs`, `packages/test-system/ci/result.mjs` |
@@ -190,12 +170,10 @@ node --import tsx packages/daemon/scripts/run-scenarios.mjs packages/daemon/test
In CI the `installed-scenario` job runs `scripts/run-pr-scenarios.sh` with no arguments:
both baton cases (`fixture` = `scenario-02-baton.yaml`, `library` = `queue-baton-survives-restart.yaml`),
each run three times as healthy, then lost-baton, then healthy (six containers); the
`refocus-parked` effect case runs healthy, seeded, then healthy (three containers); and the
six passing-only cases (`transcript` = `transcript-reads-addressed-seat.yaml`, `capture` =
`capture-returns-addressed-seat.yaml`, `daemon-restart`, `refocus-active`,
`refocus-parked-off`, `prompt-state`) run once each, healthy only (six containers). That is
fifteen fresh containers.
each run three times as healthy, then lost-baton, then healthy. Then the two stub-script
cases (`transcript` = `transcript-reads-addressed-seat.yaml`, `capture` =
`capture-returns-addressed-seat.yaml`) run once each, healthy only. That is eight fresh
containers.
To run a case before you push, point `DOCKER_HOST` at a Docker engine you control over SSH:
@@ -205,13 +183,10 @@ DOCKER_HOST=ssh://<your-host> bash scripts/run-pr-scenarios.sh \
--remote --case library --mode healthy --out dist/scenario-check
```
- Options: `--case fixture|library|transcript|capture|daemon-restart|refocus-parked|
refocus-active|refocus-parked-off|prompt-state` (default: all nine), `--mode
healthy|lost-baton|seeded` (default: healthy, lost-baton, healthy) and `--out <dir>` (default
`dist/pr-scenarios`). `--mode` applies to the baton cases (`fixture` and `library`) and to
the `refocus-parked` effect case; the six passing-only cases (`transcript`, `capture`,
`daemon-restart`, `refocus-active`, `refocus-parked-off`, `prompt-state`) always run once
each, healthy. `--mode lost-baton` leaves them out of a full run, and
- Options: `--case fixture|library|transcript|capture` (default: all four), `--mode
healthy|lost-baton` (default: healthy, lost-baton, healthy) and `--out <dir>` (default
`dist/pr-scenarios`). `--mode` applies to `fixture` and `library`; `transcript` and `capture`
always run once, healthy. `--mode lost-baton` leaves them out of a full run, and
`--case transcript|capture --mode lost-baton` is refused with exit 2. Use a fresh `--out`
directory for each run you want to keep.
- `--remote` accepts only `ssh://` values for `DOCKER_HOST`, and it refuses if
@@ -270,16 +245,16 @@ The stub does not fabricate product outputs. It runs the real daemon, tmux and C
not Claude Code or Codex, and it differs from them in ways that decide what a stub green
means:
| Area | What the stub does at `e8f0ab34` | What that means for your test |
| Area | What the stub does at `2caac7dd` | What that means for your test |
|---|---|---|
| Consuming a message | `stub-runner.ts` runs its launch script once and then idles. It has no stdin reader, socket or other input channel. The default script prints `[stub] scripted reply: acknowledged` at boot, before anything has been sent. | A pane showing a reply, or your echoed text, does not prove the message was consumed. No stub scenario can currently prove "delivered and answered". Separately, `rig send --verify` means "appeared in the pane" (with transport.verify_input_box on, a Claude Code or Codex send means "the seat was idle before the paste and its input box took the exact text"), not acknowledgement (see `rig send --help`), and the scenario `send` step doesn't pass `--verify` at all. |
| Consuming a message | `stub-runner.ts` runs its launch script once and then idles. It has no stdin reader, socket or other input channel. The default script prints `[stub] scripted reply: acknowledged` at boot, before anything has been sent. | A pane showing a reply, or your echoed text, does not prove the message was consumed. No stub scenario can currently prove "delivered and answered". Separately, `rig send --verify` means "appeared in the pane", not acknowledgement (see `rig send --help`), and the scenario `send` step doesn't pass `--verify` at all. |
| Launch path | `StubRuntimeAdapter` types `node <stub-runner> …` into the pane (`tmux.sendText`, then Enter). Claude Code with an explicit permission mode launches through a managed launch (`ClaudeManagedLaunch.prepare`, `tmux.sendShellCommand`). Otherwise Claude Code, Codex and Pi launch through `SeatLaunchEnvironment.command` and `tmux.sendShellCommand`; the stub adapter does not get that environment. The shell-foreground check in `session-transport.ts` (`unverifiedShellForeground`) runs for every runtime except `terminal` and `claude-code`, and only `codex` has a native-process proof; Claude Code ordinary delivery applies its own uncertainty policy at the input boundary. | A stub seat doesn't exercise the seat launch environment, managed launch, wrappers or native-process identity (the class behind #197). Wrapping the stub in a shell wouldn't change that. |
| Permissions | `validateNativePermissionSelection` accepts only `codex` and `claude-code`. The stub carries the resolved `floor` / `full_bypass` posture, including one set by a declarative `permission_policy`, into its runner's `--posture` argument and READY line. It ignores non-interruptive launches, and it doesn't exercise Claude or Codex native permission behaviour. | Testing Claude permission modes or per-seat permission selection needs a real runtime. |
| Queue pickup and wake | No stub worker reacts to a nudge by claiming an item, working on it and handing it back. | The baton scenarios prove the claim *survives a restart*. They don't prove delivery, pickup or wake. |
| Reboot / tmux reset | `daemon: {op: restart}` restarts only the scenario daemon, and the tmux server keeps running. No step resets tmux. | Recycled pane IDs and stale bindings after a real reboot (the class behind #141) are not covered by a daemon restart. |
| Multi-rig and same-name identities | The `up` step always uses the scenario's top-level `topology` and ignores a per-step override. No step archives or removes a rig. | Cross-rig scope, same-name generations and archive/remove routing (the class behind #174) need runner work before they can be tested. |
| Step-time restore, compaction, mutation, policy | `restore`, `mutate` and `policy` steps are refused at load time (`UNBOUND_STEP_VERB`); `emit` parses but a non-`refocus` behavior throws `UnboundActionError` when the runner reaches it. Behaviours run only from a per-seat launch script (`env.stub_scripts`). A seat `restart` step maps to `rig launch`, which is not native resume or fork. | Step-time compaction and restore, and native resume and fork, are not covered. |
| Skill projection | The stub projects skills into `<cwd>/.openrig/stub/skills/`. Claude Code projects a team seat's skills into the seat's generated plugin in OpenRig's state, and into `<cwd>/.claude/skills/` only for a seat without one. | A stub green says nothing about provider-specific projection (the class behind #159). |
| Step-time restore, compaction, mutation, policy | `restore`, `emit`, `mutate` and `policy` parse, but the runner throws `UnboundActionError` when it reaches them. Behaviours run only from a per-seat launch script (`env.stub_scripts`). A seat `restart` step maps to `rig launch`, which is not native resume or fork. | Step-time compaction and restore, and native resume and fork, are not covered. |
| Skill projection | The stub projects skills into `<cwd>/.openrig/stub/skills/`. Claude Code projects into `<cwd>/.claude/skills/`. | A stub green says nothing about provider-specific projection (the class behind #159). |
| Model and provider | No model, no tokens. Stub scripts refuse `usage_limit` because it only exists on real runtimes. | Model behaviour, provider usage limits and real context usage need live evals or a real runtime. |
| Platform | CI's scenario job runs in a Linux container. | macOS process behaviour is covered by the macOS `package-tests` jobs, not by scenarios. |
@@ -289,27 +264,19 @@ The most useful next pieces of harness work, roughly in dependency order:
2. A model-free test entry point shaped like a provider, that goes through the real native
adapter and managed launch. Package tests such as `claude-interactive-launch.test.ts` and
the `*-native.test.ts` files already drive the real adapters through tmux against an
offline recorder; they prove command resolution and arguments.
`claude-executable-foreground.test.ts` holds that recorder open in private Bash, zsh, dash and ksh panes
to check parent PID, process-group ownership, tmux's pane command and parent-shell
restoration for unmanaged fresh, fork and resume launches. These fixtures do not prove
native readiness, history or answers.
offline recorder; they prove command resolution and arguments, not readiness, history or
answers.
3. Lifecycle and identity steps (tmux reset, archive/remove).
4. Then pickup, wake and restore scenarios.
### Library status
Seven library scenarios run in CI: `queue-baton-survives-restart` (since #243) and
`refocus-parked-baton` with their seeded pairs, and `transcript-reads-addressed-seat`,
`capture-returns-addressed-seat`, `daemon-restart-preserves-team-identity`,
`refocus-active-baton` and `refocus-parked-baton-packet-off` in passing form only. The
`fixture` case runs the `scenario-02-baton` test fixture through the same seeded pair.
Three more, `down-stops-every-seat`, `send-renders-in-addressed-pane` and
`up-relaunch-restores-a-two-seat-team`, run with the local runner but not in CI, and
`compaction-restore-resumes-role` runs the compaction round-trip's success path the same
way. Those ten later scenarios are not in this table; "Status today" under
[Help wanted](#help-wanted-command-families-without-a-behavioural-scenario) covers seven
of them. The other nine scenarios are authored but not yet runnable as meaningful checks:
Three scenarios run in CI: `queue-baton-survives-restart` (since #243) with its seeded pair,
and `transcript-reads-addressed-seat` and `capture-returns-addressed-seat` in passing form
only. Two more, `down-stops-every-seat` and `send-renders-in-addressed-pane`, run with the
local runner but not in CI. Those four later scenarios are not in this table; see "Status
today" under [Help wanted](#help-wanted-command-families-without-a-behavioural-scenario). The
other ten scenarios are authored but not yet runnable as meaningful checks:
| Scenario | Why it doesn't run as a check yet | Smallest useful next step |
|---|---|---|
@@ -318,10 +285,11 @@ of them. The other nine scenarios are authored but not yet runnable as meaningfu
| `ps-scope-honesty` | The second `up` reuses the first topology, and the expected match tolerates the other rig | Make `up` honour the per-step topology, then assert the exclusion |
| `send-verify-means-rendered` | The `send` step drops `verify`, and the stub has no input loop | Build the input-consuming stub and assert a response derived from the nonce |
| `home-divergence-preseed-visible` | The pipeline ignores `env.pre_existing_tmux`, and the default script never prints the expected `trust reached` | Recreate the real HOME split and whatever consumes it |
| `compaction-restore-resumes-role` | Step-time `emit` is unbound | Input-triggered hooks plus evidence specific to one seat |
| `stream-emit-durable-replay-live` | Step-time `emit` is unbound | Assert the actual stream contract |
| `policy-posture-survives-structural-edit` | The validator refuses its `policy` and `mutate` steps at load time (`UNBOUND_STEP_VERB` — no shipped runner binding) | Rewrite around current per-seat permission selection and an observed launch |
| `locked-restore-never-permissive` | The validator refuses its `policy` and `restore` steps at load time (`UNBOUND_STEP_VERB` — no shipped runner binding) | Keep the invariant (the selection survives a restore) and use current modes |
| `one-view-state-after-mutation-storm` | The validator rejects its bare-list `equals` (`EQUALS_NOT_DECLARATIVE`) and refuses its `mutate` steps at load time (`UNBOUND_STEP_VERB`) | Declare the comparable fields and real mutations |
| `policy-posture-survives-structural-edit` | `policy` and `mutate` are unbound | Rewrite around current per-seat permission selection and an observed launch |
| `locked-restore-never-permissive` | `policy` and `restore` are unbound | Keep the invariant (the selection survives a restore) and use current modes |
| `one-view-state-after-mutation-storm` | The validator rejects its bare-list `equals` (`EQUALS_NOT_DECLARATIVE`), and `mutate` is unbound | Declare the comparable fields and real mutations |
| `kill-daemon-mid-handoff` | A pane `send` doesn't create the queue item the next assertion expects, and later assertions use a shape the queue reader doesn't return | Rebuild the setup before treating a red result as meaningful |
In most of these files, `seed_regression` steps that come *after* the assertions are
@@ -347,19 +315,6 @@ above remain pending: pane rendering does not prove input consumption, durable
stream replay does not prove live subscription, and this test does not exercise
native providers, restore or policy changes.
`packages/daemon/test/rig-edge-cli.integration.test.ts` uses the same private
scenario daemon and tmux helpers for edge repair. It removes a stub seat, re-adds
its logical ID with the terminal runtime, then restores its cross-pod edges with one
`rig edge add` per edge. It checks repeat IDs and edge counts, refusal without a
write, unchanged sibling node/binding identities and pane PIDs, export,
snapshot capture and exact rollback. It models the cross-runtime replacement
workaround without invoking Claude Code or Codex. The YAML runner has no bound
edge/remove/add verbs, so this journey drives the real CLI directly:
```sh
npm test -w @openrig/daemon -- test/rig-edge-cli.integration.test.ts
```
## If your change touches an artery, add or extend a scenario (or say why not)
[arteries.md](arteries.md) lists the behaviours that everything else depends on. If your
@@ -427,7 +382,7 @@ not as current status.
## Help wanted: command families without a behavioural scenario
> This list may be stale. It was taken from the `rig --help` tree of 0.6.3: 85 visible
> top-level families. At `e8f0ab34`, `packages/cli/src/index.ts` registers 87 top-level
> top-level families. At `2caac7dd`, `packages/cli/src/index.ts` registers 87 top-level
> commands; `roster` and `telemetry` were added since and have no row yet. Before picking one up, check `packages/test-system/scenarios/`, the cases in
> `scripts/run-pr-scenarios.sh`, and the current `rig --help`.
> One scenario doesn't cover a family's every option, sequence or platform. A `--help`
@@ -447,29 +402,12 @@ that seat's `rig transcript --tail 200 --json`. `capture` has partial coverage i
pane. The scenario catches a capture that resolves the seat by a prefix match on its member
name (so `dev-impl` also matches `dev-impl2`) and takes the first hit, or any resolver that
returns the same pane for both seats; it does not exercise a prefix match on the full seat
name. All six run with the local runner (`run-scenarios.mjs`). The transcript, capture and daemon
scenarios also run in the CI job, in passing form only; the down, send and up scenarios don't.
name. All four run with the local runner (`run-scenarios.mjs`). The transcript and capture
scenarios also run in the CI job, in passing form only; the down and send scenarios don't.
They do not prove that panes are gone or that a seat consumed a message. The transcript
case does not establish ordering, uniqueness, sibling-token absence, tail limits or restart
persistence. `up` has partial coverage in `up-relaunch-restores-a-two-seat-team.yaml`: a
two-seat stub team comes up, goes down, and the same spec starts a new team in place of the
stopped one with the same name, exactly two seats, both running, not archived, so a
relaunch that re-records the old seats into the team's row fails on the final `ps` counts.
It does not prove that conversations survive the round trip (that is the `--existing`
restore path), that a seat consumed a message, or that the panes are gone after `down`.
`daemon` has partial coverage in `daemon-restart-preserves-team-identity.yaml`: a two-seat
stub team is up, the runner restarts the scratch daemon, and `rig ps --json` must report the
same name still running with exactly two seats, both running, not archived, so a restart
that loses the team's row, or changes its seat counts or status, fails on the post-restart `ps`;
`rig down` then records it stopped, so the restart left a governable team. It does not prove
that queue items survive the restart (that is `queue-baton-survives-restart.yaml`), that a
kill inside a handoff window is reconciled (that is `kill-daemon-mid-handoff.yaml`), the
port-and-process half of the row, or that the panes are gone after `down`.
`compaction-restore-resumes-role` covers the compaction round-trip's success path: the
seat's launch script (delivered through `env.stub_scripts`) fires the real precompact and
restore seams in order, and impl's own pane carries the injected directive. It runs with
the local runner, not in CI (container mode refuses `env.stub_scripts` loud and named), and
its seeded wrong-seat-restore negative case is not armed yet.
persistence. `up` and `daemon` are used by the existing scenarios but have no scenario of their
own. Everything else has none.
Before you start, two honest constraints:
@@ -491,9 +429,9 @@ Before you start, two honest constraints:
| Group | Family | Proposed first check (observable result) |
|---|---|---|
| lifecycle | `start` | Restore two stopped stub seats. Both answer distinct fresh nonces, and no second daemon starts. |
| lifecycle | `daemon` (partial) | Restart-preserving-the-persisted-team-row is covered by `daemon-restart-preserves-team-identity.yaml`. Next: port and process agreement through public commands, and `stop`, which needs a runner binding. |
| lifecycle | `daemon` | start/status/stop/restart. Port, process and persisted queue identity agree through public commands. |
| lifecycle | `bootstrap` | Bootstrap a minimal stub spec. Seats come up ready and the selected spec is the one used. |
| lifecycle | `up` (partial) | Relaunch-not-duplicating-identities is covered by `up-relaunch-restores-a-two-seat-team.yaml`. Next: each seat consumes its own send, which needs the input-consuming stub. |
| lifecycle | `up` | Launch two stub seats. Each consumes its own send. Relaunching doesn't duplicate identities. |
| lifecycle | `down` | (partial) Next: its panes are gone, a sibling rig still answers, retained data follows the docs. |
| lifecycle | `create` | Create a one-seat rig through the public path. It appears and answers without a hand-written topology. |
| lifecycle | `launch` | Fresh, relaunch, resume and fork on provider-shaped fixtures. Correct restored token, one live process. |
@@ -592,6 +530,9 @@ How to make a case useful:
Read these with care. This page notes them and proposes no changes.
- `packages/test-system/ci/README.md` describes the CI scenario job, except its
"Run one case" section, which still says both cases and six runs; the script runs four cases
and eight containers by default.
- The headers of `transcript-reads-addressed-seat.yaml` and `capture-returns-addressed-seat.yaml`
say "host mode only". Both run in the CI container job, which executes the host-mode pipeline
inside the image.
+2 -2
View File
@@ -11,8 +11,8 @@ applies-when: |
named ui/ module.
siblings: [README.md, codemap.md, architecture.md]
prerequisite-reads: []
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# ui.md was reorganized into the modular as-built tree
@@ -11,8 +11,8 @@ applies-when: |
lives.
siblings: [shell-and-routing.md]
prerequisite-reads: [../README.md, shell-and-routing.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# UI Library/Specs Surfaces + Design-System Pointer
@@ -21,7 +21,7 @@ The Library destination keeps `/specs` as the route and presents the product
label "Library": specs, applications, context packs, agent specs, agent
images, plugins, and skills.
> Verified against source at `e8f0ab34`; package version 0.6.7. Component and
> Verified against source at `2caac7dd`; package version 0.6.7. Component and
> primitive names below are reconciled against `packages/ui/src/components/`,
> not taken on trust from `DESIGN.md`'s lists. The web UI is in maintenance
> mode (`docs/reference/developing.md`).
+4 -12
View File
@@ -13,8 +13,8 @@ applies-when: |
stamped commit.
siblings: [shell-and-routing.md, ../architecture/mission-control.md]
prerequisite-reads: [../README.md, shell-and-routing.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# UI Project Observability, For You, Dashboard
@@ -29,7 +29,7 @@ and the maintenance-notice dismissal (see
[`shell-and-routing.md`](shell-and-routing.md)).
> Paths are relative to `packages/ui/src/` unless prefixed `docs/` or
> `packages/`. Verified at `e8f0ab34` (package version 0.6.7). The web UI
> `packages/`. Verified at `2caac7dd` (package version 0.6.7). The web UI
> is in maintenance mode (`docs/reference/developing.md`); this page
> records what ships, not a roadmap.
@@ -342,21 +342,13 @@ six destinations (Topology, Project, For You, Library, Search & Audit,
Settings); and `DashboardFooter`. Its glyphs come from
`dashboard/vellum/fidelity-glyphs.js`.
The kernel card renders `kernel_state: "skipped"` as a neutral **boot not
tracked** state. Built-in boot can be skipped because a kernel rig is already
managed or because automatic boot was disabled; the served reason remains
visible. Ready-agent counts do not promote this state to healthy. An unavailable
tracker or a malformed status envelope remains **unknown**. Recovery still uses
the existing launch-plan and daemon restore guards.
The vellum barrel `dashboard/vellum/index.ts` still exports
`BackLayerContent` and `BackVellumSheet`; `/lab/vellum-lab`
(`lab/VellumLab.tsx`) imports from it.
> Source: `components/dashboard/Dashboard.tsx` (header comment `:1-24`,
> glyph import, `DESTINATIONS`, data wiring, render order);
> `hooks/useDaemonVersion.ts`; `components/dashboard/vellum/index.ts:5-6`;
> `components/KernelStatusCard.tsx`; `components/RigStatusCard.tsx`.
> `hooks/useDaemonVersion.ts`; `components/dashboard/vellum/index.ts:5-6`.
## 5. Cross-cutting properties
+4 -4
View File
@@ -10,8 +10,8 @@ applies-when: |
mounts, or how the shared detail drawer and event consumption work.
siblings: [topology.md, project-and-for-you.md, library-specs-and-design-system.md]
prerequisite-reads: [../README.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# UI Shell, Routing, Drawer System
@@ -21,7 +21,7 @@ operator surface. Brand/visual rules live in `docs/DESIGN.md` (pointer from
`library-specs-and-design-system.md`); this module is how the shell is
assembled and what routes it mounts.
> Verified against source at `e8f0ab34`; package version 0.6.7. UI
> Verified against source at `2caac7dd`; package version 0.6.7. UI
> footprint is **304** source files (`packages/ui/src`, `.ts`+`.tsx`,
> non-test; 235 at the previous stamp `7eaf524c`). The web UI is in
> maintenance mode (`docs/reference/developing.md`).
@@ -80,7 +80,7 @@ rather than acting as a generic file browser.
## 3. Route model
> The route table below is derived from `packages/ui/src/routes.tsx` at
> `e8f0ab34` (605 lines). Each line number is the route's
> `2caac7dd` (605 lines). Each line number is the route's
> `createRoute(` line. The exported route tree is assembled at
> `routes.tsx:539` (`export const routeTree = rootRoute.addChildren([`);
> it holds 54 routes.
+3 -3
View File
@@ -11,8 +11,8 @@ applies-when: |
the topology navigation/overlay contracts.
siblings: [shell-and-routing.md, project-and-for-you.md]
prerequisite-reads: [../README.md, shell-and-routing.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
last-verified-against-source: 2caac7dd1bb16585138a6f7c1dd8b1f0752c49f7
last-updated: 2026-10-05
---
# UI Topology — Graph/Table/Terminal, HotPotato, ActivityRing
@@ -21,7 +21,7 @@ Topology is a scoped workspace with graph, table, and terminal views at the
`/topology` route family (`shell-and-routing.md` §3). It is the operator's
live picture of host → rig → pod → seat.
> Verified against source at `e8f0ab34` (package version 0.6.7). The web UI
> Verified against source at `2caac7dd` (package version 0.6.7). The web UI
> is in maintenance mode (`docs/reference/developing.md`). The source file
> that defines the hybrid nodes is `HybridTopologyNodes.tsx` (DESIGN.md's
> implementation reference).
-355
View File
@@ -1,355 +0,0 @@
# Handing work between OpenRig and A2A agents
An OpenRig seat can give a queue row's task to an outside A2A agent. In the other direction, an A2A server can
front a seat's queue and return its answer as an artifact. These are compositions of the
[official Python SDK](https://github.com/a2aproject/a2a-python) and existing `rig queue` commands, not a
`rig a2a` command or a daemon endpoint. The scripts below are examples you run and own.
OpenRig does not configure the outside agent's permissions, model or credentials. Choose a peer and task content
you are authorized to share. The peer's result is its report, not acceptance of the work by your team.
## Before either recipe
Use a working OpenRig installation and an existing seat. `rig whoami --json` identifies your seat;
`rig ps --nodes --rig <rig>` shows the receiving seat's canonical session name. If you need a team first, use
[getting started](getting-started.md). Keep the daemon and provider environment you already use.
In a scratch folder, make a Python environment (Python 3.10 or newer, with `venv` and `pip` available):
```sh
python3 -m venv .venv
.venv/bin/python -m pip install 'a2a-sdk[http-server]==1.1.0' httpx uvicorn
```
The examples pin the SDK version used in the pipe trials; they are not a recommendation to downgrade another
application. The official SDK 1.1.0 paths were exercised in both directions. Other 1.x versions were not tested.
Recheck the API and cancellation behavior when changing versions.
Pydantic AI 2.60.0 with FastA2A 2.1.1 did not interoperate with these SDK 1.1.0 recipes in the tested setup:
FastA2A's inbound typed client returned `result={}` even though the SDK front, with 0.3 compatibility enabled,
sent task, artifact and completed records in its raw SSE stream. Outbound, the official SDK raised a protobuf
parse error at the FastA2A task's `status.timestamp`; the full wire timestamp was not retained. When mixing
frameworks, compare the raw stream's task IDs, artifact and final status with the typed client's output.
An HTTP 200 or an empty typed result is not proof that the task succeeded. Retain the evidence before retrying.
Save this helper as `queue_io.py`:
```python
import asyncio
import json
async def rig(*args):
child = await asyncio.create_subprocess_exec(
'rig', *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
out, err = await child.communicate()
if child.returncode:
raise RuntimeError(f'rig {args[:2]} exited {child.returncode}: {err.decode()} {out.decode()}')
return json.loads(out)
```
It inherits the caller's OpenRig environment. Run queue operations in the intended seat's environment; set
`OPENRIG_URL` only when deliberately selecting another daemon. A timed-out write is uncertain: read its row and
transitions before trying again. Do not silently send the task a second time.
## Outbound: a queue task goes to an A2A peer
You need the peer's base URL and a row assigned to your seat. For a no-model peer, the official
[Hello World sample](https://github.com/a2aproject/a2a-samples/tree/6603ba3f2c31a7ef33e70b9d8b5b5f8be42ac9a3/samples/python/agents/helloworld)
returns an echo artifact. Fetch that revision:
```sh
git clone https://github.com/a2aproject/a2a-samples.git
git -C a2a-samples checkout --detach 6603ba3f2c31a7ef33e70b9d8b5b5f8be42ac9a3
```
Start the peer yourself in a separate terminal, following `a2a-samples/samples/python/agents/helloworld/README.md`.
Its default URL is `http://127.0.0.1:9999`, used below; it is available only while you run the sample.
The sample does not implement the input-required and cancellation controls described later. Use a peer that
supports those operations when testing them.
Save as `a2a_client.py`. This client also works without an OpenRig row, for the inbound recipe below.
Streaming reads have no deadline so quiet worker waits keep their stream; connection, write and pool waits are bounded to 10 seconds.
Stop the local wait with Ctrl-C or a caller-owned overall deadline; this does not confirm cancellation, so keep the task/context IDs and cancel through the peer when it is reachable.
```python
import asyncio
import json
import os
import uuid
from pathlib import Path
import httpx
from a2a.client import A2ACardResolver, ClientConfig, create_client
from a2a.helpers import new_text_message
from a2a.types import Role, SendMessageRequest
from google.protobuf.json_format import MessageToDict
from queue_io import rig
async def main():
row_id = os.environ.get('A2A_ROW')
text = os.environ.get('A2A_TEXT')
if row_id:
row = await rig('queue', 'show', row_id, '--full', '--json')
identity = await rig('whoami', '--json')
if row['destinationSession'] != identity['identity']['sessionName']:
raise RuntimeError('Choose a row assigned to this seat.')
if row['state'] == 'pending':
await rig('queue', 'claim', row_id, '--json')
row = await rig('queue', 'show', row_id, '--full', '--json')
if row['state'] != 'in-progress':
raise RuntimeError('The row is not in progress; inspect it before sending.')
if text is None:
text = row['body']
if text is None:
raise RuntimeError('Set A2A_TEXT or A2A_ROW.')
message = new_text_message(text, role=Role.ROLE_USER)
if os.environ.get('A2A_CONTEXT'):
message.context_id = os.environ['A2A_CONTEXT']
if os.environ.get('A2A_TASK'):
message.task_id = os.environ['A2A_TASK']
log_path = Path(f'a2a-{uuid.uuid4()}.jsonl').resolve()
state, bound = 'UNKNOWN', False
with log_path.open('x') as log:
async with httpx.AsyncClient() as http:
card = await A2ACardResolver(httpx_client=http, base_url=os.environ['A2A_URL']).get_agent_card()
log.write(json.dumps({'card': MessageToDict(card), 'message': MessageToDict(message)}) + '\n')
log.flush()
client = await create_client(agent=card, client_config=ClientConfig(
streaming=True, httpx_client=httpx.AsyncClient(timeout=httpx.Timeout(10.0, read=None))))
try:
async for chunk in client.send_message(SendMessageRequest(message=message)):
event = MessageToDict(chunk) # SDK 1.1.0 yields StreamResponse, not a tuple.
log.write(json.dumps(event) + '\n')
log.flush()
print(json.dumps(event), flush=True)
task = event.get('task')
if task and row_id and not bound:
await rig('queue', 'update', row_id, '--note',
f"A2A peer={card.name} task={task['id']} context={task['contextId']} log={log_path}", '--json')
bound = True
status = (task or event.get('statusUpdate') or {}).get('status', {})
state = status.get('state', state)
finally:
await client.close()
if row_id:
await rig('queue', 'update', row_id, '--note', f'A2A reported {state}; full result/events: {log_path}', '--json')
print(f'Reported state: {state}; saved events: {log_path}')
asyncio.run(main())
```
When `A2A_TEXT` is unset, the row body becomes the peer's message verbatim. Write the body as the peer's input,
or set `A2A_TEXT` to the exact text you want to send. Run it from your seat, replacing the row and peer URL:
```sh
export A2A_URL=http://127.0.0.1:9999
export A2A_ROW=<your-row-id>
.venv/bin/python a2a_client.py
```
Read the saved `artifactUpdate` parts and final status. Put the useful result on the **same** row, using a note
while work continues or a resolution when you deliberately close it. The script leaves the row owned and open:
```sh
rig queue update "$A2A_ROW" --note 'A2A result: <answer, artifact reference, and any limits>' --json
# Only when your work on the row is actually finished:
rig queue update "$A2A_ROW" --state done --closure-reason no-follow-on --resolution '<your assessed outcome>' --json
```
If the stream fails, retain its exception and log. The peer may still be working; inspect the known task before
resending. When the task asks for input, set `A2A_TASK` and `A2A_CONTEXT` to its recorded IDs and `A2A_TEXT` to the
answer, then run the client again. For a new follow-up, unset `A2A_TASK` and keep `A2A_CONTEXT`: a completed task
is not reopened. Unset `A2A_TEXT` when the next outbound task should again use its row body.
## Inbound: an A2A client reaches an OpenRig seat
Run the following server in a custodian seat's environment, with `A2A_SEAT` set to the existing worker seat's
canonical session name. The worker is a separate participant: it watches its queue, claims the row, does the work
and writes a resolution. A deterministic shell worker is enough to test this path; a model is optional.
Save as `a2a_front.py` beside `queue_io.py`:
```python
import asyncio
import json
import os
import uvicorn
from starlette.applications import Starlette
from a2a.helpers import get_message_text, new_task_from_user_message, new_text_message, new_text_part
from a2a.server.agent_execution import AgentExecutor
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from a2a.server.tasks import InMemoryTaskStore, TaskUpdater
from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill
from queue_io import rig
seat = os.environ['A2A_SEAT']
rows, canceling, published = {}, set(), {}
class Front(AgentExecutor):
async def execute(self, context, events):
task = context.current_task or new_task_from_user_message(context.message)
if not context.current_task:
await events.enqueue_event(task)
update = TaskUpdater(events, task.id, task.context_id)
published[task.id] = asyncio.Event()
try:
text = get_message_text(context.message)
if task.id not in rows:
row_id = 'qitem-a2a-' + task.id
body = json.dumps({'taskId': task.id, 'contextId': task.context_id, 'request': text})
await rig('queue', 'create', '--id', row_id, '--destination', seat, '--body', body,
'--summary', 'Inbound A2A task', '--no-nudge', '--full', '--json')
rows[task.id] = row_id
else:
row_id = rows[task.id]
await rig('queue', 'update', row_id, '--note', 'A2A answer: ' + text, '--json')
working = False
while True:
row = await rig('queue', 'show', row_id, '--full', '--json')
if task.id in canceling:
await asyncio.Future() # cancel() owns the terminal event.
if row['state'] == 'done':
if not row.get('resolution'):
await update.failed(new_text_message('Row closed without a result resolution.'))
else:
await update.add_artifact([new_text_part(row['resolution'])], name='seat-result')
await update.complete(new_text_message('Seat closed row ' + row_id))
return
if row['state'] in ('failed', 'denied', 'handed-off'):
await update.failed(new_text_message(row.get('resolution') or ('Row ' + row['state'])))
return
if row['state'] == 'canceled':
await update.cancel(new_text_message('Queue row canceled.'))
return
if row['state'] == 'in-progress' and not working:
await update.start_work(new_text_message('Seat claimed ' + row_id))
working = True
history = await rig('queue', 'transitions', row_id, '--full', '--json')
notes = [t.get('transitionNote') or '' for t in history]
conversation = [n for n in notes if n.startswith(('A2A question: ', 'A2A answer: '))]
if conversation and conversation[-1].startswith('A2A question: '):
await update.requires_input(new_text_message(conversation[-1][14:]))
return
await asyncio.sleep(1)
except asyncio.CancelledError:
if task.id in canceling:
await published[task.id].wait() # See SDK 1.1.0 cancellation note below.
raise
except Exception as error:
await update.failed(new_text_message(str(error)))
async def cancel(self, context, events):
canceling.add(context.task_id)
try:
row_id = rows[context.task_id]
await rig('queue', 'update', row_id, '--state', 'canceled', '--resolution',
'A2A client requested cancellation; worker stop not yet confirmed.', '--json')
await TaskUpdater(events, context.task_id, context.context_id).cancel(
new_text_message('Queue cancellation committed; worker stop is a separate observation.'))
finally:
published[context.task_id].set()
card = AgentCard(name='OpenRig queue: ' + seat, version='example-1',
description='A worker seat returns its queue resolution.',
default_input_modes=['text/plain'], default_output_modes=['text/plain'],
capabilities=AgentCapabilities(streaming=True),
supported_interfaces=[AgentInterface(protocol_binding='JSONRPC', url='http://127.0.0.1:19092', protocol_version='1.0')],
skills=[AgentSkill(id='queue-work', name='Queue work', description='Work assigned to ' + seat, tags=['queue'])])
handler = DefaultRequestHandler(agent_executor=Front(), task_store=InMemoryTaskStore(), agent_card=card)
app = Starlette(routes=[*create_agent_card_routes(card), *create_jsonrpc_routes(handler, '/')])
uvicorn.run(app, host='127.0.0.1', port=19092)
```
Start the front, then use a second terminal for the outside client:
```sh
# Custodian terminal:
export A2A_SEAT=<worker-session-from-rig-ps>
.venv/bin/python a2a_front.py
# Client terminal, in the folder with the example scripts and venv:
unset A2A_ROW A2A_TASK A2A_CONTEXT
export A2A_URL=http://127.0.0.1:19092
export A2A_TEXT='Return exactly these words: "hello from the receiving seat".'
.venv/bin/python a2a_client.py
```
The client waits while the worker does its part. In the **worker's own environment**, inspect and claim the row:
```sh
rig queue list --owned --full --json
export ROW=<the-new-qitem-a2a-id>
rig queue show "$ROW" --full --json
rig queue claim "$ROW" --json
rig queue update "$ROW" --state done --closure-reason no-follow-on --resolution 'hello from the receiving seat' --json
```
The client should receive that exact resolution as an artifact, followed by completed. Compare the artifact with
`rig queue show "$ROW" --full --json`, including the task/context IDs in the row body. The sample uses `--no-nudge`:
the worker must be watching its queue; merely starting the front does not start or wake an agent.
To wake a model worker after the row appears, run `rig send <worker-session> 'Check your queue and process the new A2A task.'`
from the custodian's environment, or remove `--no-nudge` from the front's queue-create call to request the normal queue wake.
For input-required, the worker instead records `--note 'A2A question: <question>'` and leaves the row in progress.
The client reruns with the same `A2A_TASK`, `A2A_CONTEXT` and its answer as `A2A_TEXT`. The worker reads the
`A2A answer: ` transition with `rig queue transitions "$ROW" --full --json`, then finishes that row. These prefixes
are this example's small convention, not built-in OpenRig fields. A new follow-up omits the completed task ID and
keeps the context ID; it produces a new row.
## Cancel and read the outcome honestly
For a protocol-1.0 JSON-RPC peer, a direct control request includes `A2A-Version: 1.0`. For example, after the task
is bound and working (substitute its ID; use `GetTask` with the same parameters to inspect it):
```sh
curl -sS "$A2A_URL" -H 'Content-Type: application/json' -H 'A2A-Version: 1.0' \
--data '{"jsonrpc":"2.0","id":"cancel-1","method":"CancelTask","params":{"id":"<task-id>"}}'
```
Read the JSON body and task state, not just HTTP status. A pipe trial received HTTP 200 carrying JSON-RPC
`-32009 VERSION_NOT_SUPPORTED` when the version header was omitted. That was an error, not a completed task.
The SDK handles versioned calls; raw requests must follow the peer's advertised interface and
[protocol version rules](https://a2a-protocol.org/v1.0.0/specification/).
SDK 1.1.0's default V2 handler cancels the running producer **before** awaiting the executor's `cancel` callback.
If that callback awaits a queue write, the producer can close the event channel first. The inbound trial then
returned stale working state even though the row was canceled. The event in the example keeps that producer
alive until the callback publishes its outcome. This is specific to the exercised ordering; see the
[pinned SDK implementation](https://github.com/a2aproject/a2a-python/blob/v1.1.0/src/a2a/server/agent_execution/active_task.py).
Test it again when changing SDK versions, including an awaited external write rather than an immediate callback.
Keep these facts separate:
| A2A observation | What it means for the queue or worker |
|---|---|
| Submitted | The A2A front accepted a task; a row may not yet be created. |
| Working | This front observed the receiving seat's claim. |
| Input-required | A question needs answering; this example leaves the row in-progress. |
| Completed + artifact | The worker's stored result was returned; your assessment still belongs to you. |
| Canceled | This inbound front committed queue cancellation; an outside peer's cancellation says nothing about your outbound row. Verify separately that the worker noticed and stopped. |
| Failed / JSON-RPC error | Retain the actual error and any row effect; do not report success. |
| Disconnect / timeout | The last known state remains all you know. Inspect before retrying. |
There is no one-to-one A2A/queue state mapping. The example does not follow handoffs to another row, so it reports
that limit instead of pretending the successor's work completed. Stopping the front only detaches clients; it does
not cancel its queue rows or stop their workers. Cancel or hand off outstanding work deliberately before stopping
your server. Acknowledged cancellation does not establish that a model provider stopped spending.
## What was exercised, and what remains open
The initial trials used an OpenRig 0.7.0 candidate and SDK 1.1.0 on Linux. Outbound discovery, streamed output and
artifacts used the official Hello World sample. Deterministic peers covered input-required, cancellation, failure
and concurrency. Inbound used a private daemon and a separate no-model queue worker: five artifacts matched the
stored resolutions, two tasks overlapped without crossing IDs, and completed-task reuse left the old result
unchanged. The successful cancellation check also observed the worker stop, beyond receiving the protocol reply.
A later inbound check used an official SDK 1.1.0 client and front, an OpenRig queue row, and Pi 1.4.0 running
`openai/gpt-4o-mini`. One marker answer reached the row's resolution and the client's completed artifact. A
custodian performed the queue writes; model tools were disabled. That run used a 120-second streaming read
timeout and prompt RPC event draining, changed together. It did not separately test the unbounded read used above.
These results do not establish model quality or provider billing. Other framework versions, model tool use,
public deployment and authentication, stream reconnect, durable bridge restart, and cancellation before row
binding were not exercised. The inbound example holds A2A tasks and their row mapping in memory: keep it running for the conversation;
after a crash, inspect retained OpenRig rows before any replacement run. It binds loopback and supplies no public
authentication layer. Use it on your own machine or a trusted private path, not as an unauthenticated public gateway.
-77
View File
@@ -1,77 +0,0 @@
# Handing work to an ACP agent
`rig acp run` gives one queue row's task to an agent that speaks the
[Agent Client Protocol](https://agentclientprotocol.com) (ACP), such as OpenClaw's `openclaw acp`, and records what
happened on that same row. The seat that runs it is the row's custodian; the agent is recorded as the executor.
```sh
rig acp run <qitemId> --cwd <work folder> -- <the agent's ACP command> [args...]
```
The row's notes get, in order:
- **the binding:** the agent's name and version, the ACP protocol version, whether it can load sessions, and the session;
- **progress,** about once a minute (tool calls so far, output so far); the full transcript is in the run's log file, named on the row;
- **each permission request and its answer** (below);
- **the outcome:** `completed`, `stopped` (for example `max_tokens`), `cancelled`, `failed` or `unknown`, with the
agent's output;
- **what happened to the agent's processes** after the run ended.
The outcome is the agent's report, not a judgment of the work. The row stays open and yours: read the result, then
close it or hand it on with your own resolution, as you would any other.
## When the agent asks permission
OpenRig holds no permission policy of its own. The agent's own settings decide what it asks, and an owner decides
the answer. Each request becomes a queue row to `--permissions-to` (this seat by default), listing the action, its
target, the options the agent offered and the run, session and request it belongs to. Answer on that row with the
option's id:
```sh
rig queue update <permission row> --state done --closure-reason no-follow-on --resolution allow-once
```
Answer on the permission row itself; don't hand it off, because the run waits on that row. The answer goes back to
that one request, once. The turn waits for it; there's no deadline. An answer that isn't one
of the offered options is noted and the question is asked again. If the turn is cancelled first, the permission row
is closed as stale and nothing is sent. Whoever answers can follow their own written policy; that choice is theirs.
## Cancel, detach and stop
These are different things, and the row keeps them apart:
- **Cancel** (Ctrl-C, or `rig acp cancel <runId>` from another terminal) asks the agent to stop its turn. The row
records when it was requested and when the agent acknowledged it. Whether the model provider stopped work, and what
it cost, stays **unknown** unless you check the provider's own usage.
- **Detach** (a second Ctrl-C, or the run ending normally) closes the agent's connection. That doesn't stop
the agent's runtime.
- **Stop:** when the run ends, it reports whether any process carrying the run's environment marker remains:
`settled` (none), `incomplete` (with their process ids) or `unknown` (the process table couldn't be read). Only
processes that inherited the marker are seen, so one the agent started with a cleared environment, or through a
service manager, isn't. It never touches a process it didn't launch. To stop what's left, use the runtime's
own stop command, through `rig acp stop <runId> -- <that command>` so the result lands on the row.
A gateway the agent connects to but didn't start, such as OpenClaw's Gateway, isn't a process of the run. Leave a
shared one running; stop your own with its own command.
## Reading the outcome
- `completed`: the agent ended its turn with output.
- `unknown`: the turn ended, but nothing shows the work happened. That covers an `end_turn` with no output and no or
zero usage (an agent can end a turn this way after its provider refused the request), an `end_turn` whose text
reports an interruption (OpenClaw's `[OpenClaw interruption]`), and a connection that closed before the turn ended.
Check the agent's own log before you treat it as done.
- `failed`: the agent returned an error. The row keeps its original code, message and data.
- `cancelled`, `stopped`: as above.
## Notes for specific agents
- **OpenClaw:** `openclaw acp` is a bridge to a running OpenClaw Gateway, so start the Gateway first, then run, for
example, `rig acp run <row> -- openclaw acp --url ws://127.0.0.1:18789 --token-file <file>`. Its permission requests
come from the Gateway's exec approvals.
- **Continuing a session:** `--load-session <id>` continues an earlier session when the agent advertises loading. If it
doesn't, or the load fails, the run reports that and starts nothing new.
## Limits
- One run is one turn for one row.
- The client offers the agent no file-system or terminal methods; the agent works in its own environment.
- Spend is whatever the agent's provider charges; OpenRig doesn't measure it.
+8 -12
View File
@@ -176,8 +176,7 @@ defaults:
|-------|------|----------|---------|-------------|
| `runtime` | string | no | — | Default runtime for this agent. Precedence: the rig spec member's `runtime`, then the profile's `preferences.runtime`, then this, then `claude-code`. |
| `model` | string | no | — | Default model, with the same precedence. |
| `effort` | string | no | — | Default reasoning effort, with the same precedence. Claude gets `--effort`, Codex `-c model_reasoning_effort=…`, Pi `--thinking <level>` (Pi's levels: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`; passed as written). A blank or non-string value is ignored with an advisory. |
| `advisor` | string | no | — | Default Claude Advisor model, with the same precedence. Passed as the session-scoped `--advisor` flag, independently of the primary model; never written to shared Claude settings. Blank or non-string values are ignored with an advisory. Other runtimes ignore it. |
| `effort` | string | no | — | Default reasoning effort, with the same precedence. Claude gets `--effort`, Codex `-c model_reasoning_effort=…`. A blank or non-string value is ignored with an advisory. |
| `lifecycle` | Lifecycle | no | see below | Lifecycle behavior defaults. |
### Lifecycle Defaults
@@ -185,7 +184,7 @@ defaults:
| Field | Type | Default | Allowed Values |
|-------|------|---------|----------------|
| `execution_mode` | string | `interactive_resident` | `interactive_resident` (only value in v1; `wake_on_demand` is explicitly rejected) |
| `compaction_strategy` | string | `default-compaction` | `default-compaction`, `managed-compaction`, `handover`, `apprentice-handover`; deprecated aliases accepted with a validation advisory: `harness_native` → `default-compaction`, `pod_continuity` → `handover` (`custom_prompt` is explicitly rejected in v1). `managed-compaction` and `apprentice-handover` create their jobs only for `claude-code` seats; see [Codex compaction and sizing](agent-startup-guide.md#codex-compaction-and-sizing) |
| `compaction_strategy` | string | `default-compaction` | `default-compaction`, `managed-compaction`, `handover`, `apprentice-handover`; deprecated aliases accepted with a validation advisory: `harness_native` → `default-compaction`, `pod_continuity` → `handover` (`custom_prompt` is explicitly rejected in v1) |
| `restore_policy` | string | `resume_if_possible` | `resume_if_possible`, `relaunch_fresh`, `checkpoint_only` |
| `mechanic` | string | — | A canonical `seat@rig` session address; needed by `apprentice-handover` |
@@ -229,9 +228,7 @@ This gives access to the shared pool: skills such as `systematic-debugging`, `ve
`development-team` and `test-driven-development`; the `openrig-core` plugin, which carries `openrig-user` and OpenRig's
other skills; and runtime resources such as `claude-default-settings`, `claude-default-mcp`, `codex-default-config` and
`claude-activity-hooks`. Agents select the ones they need via profile `uses`, for example
`plugins: [shared:openrig-core]`. That selection projects the plugin's skills into the seat. For a Claude seat it also loads
the plugin's hooks, through a `CLAUDE_CODE_PLUGIN_DIRS` the daemon sets for a seat that selected `openrig-core` (see
`refocus-channel.md`); a seat that didn't select it gets neither.
`plugins: [shared:openrig-core]`.
---
@@ -260,11 +257,10 @@ profiles:
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `summary` | string | no | — | Profile description. Spec review surfaces read a profile `description` instead. |
| `preferences` | object | no | — | Runtime/model/effort/Advisor preferences for this profile. |
| `preferences` | object | no | — | Runtime/model/effort preferences for this profile. |
| `preferences.runtime` | string | no | — | Preferred runtime. |
| `preferences.model` | string | no | — | Preferred model. |
| `preferences.effort` | string | no | — | Preferred reasoning effort. |
| `preferences.advisor` | string | no | — | Preferred Claude Advisor model; overrides `defaults.advisor` and can be overridden by the member. |
| `uses` | Uses | no | all empty | Selects which declared resources are active for this profile. |
| `startup` | StartupBlock | no | — | Profile-level startup files and actions. Merged with agent-level startup via layering. |
| `lifecycle` | Lifecycle | no | — | Profile-level lifecycle overrides. `restore_policy` can only narrow (`resume_if_possible` → `relaunch_fresh` → `checkpoint_only`); `compaction_strategy` and `mechanic` take the most specific value (defaults, then profile, then member). |
@@ -290,7 +286,7 @@ The `uses` categories are: `skills`, `guidance`, `subagents`, `plugins`, `runtim
empty list, is refused: hooks ship inside plugins.
**Skills resolve more widely than other resources.** For Claude Code and Codex seats, a skill ID that no declared
resource provides is looked up at launch in the project's runtime skill folder in the seat's cwd (`<cwd>/.claude/skills` or
resource provides is looked up at launch in the seat's runtime skill folder (`<cwd>/.claude/skills` or
`<cwd>/.agents/skills`), then the `skills/` folder beside the RigSpec, then the home skill folder, then the managed
skills root. Pi looks only in the `skills/` folder beside the RigSpec. OMP does no filesystem discovery, so its
`uses.skills` references need declared resources. A discovered skill's ID is its `SKILL.md` frontmatter `name`, and a declared resource wins over a discovered skill with the
@@ -370,10 +366,10 @@ version.
| Category | Fields | Description |
|----------|--------|-------------|
| `skills` | `id`, `path` | Skill directories containing a SKILL.md. Projected by the runtime adapter. Claude and Codex seats get them in the seat's own storage, `<OpenRig home>/state/seats/<rig id>/<logical seat>/<runtime>/`, outside the project: Claude to `plugin/skills/<id>` in the seat's generated plugin, where they show as `openrig:<id>`, and Codex to `skills/<id>` beside the seat's skill catalog. A Claude or Codex seat without that storage gets `<cwd>/.claude/skills/<id>` or `<cwd>/.agents/skills/<id>`. Pi and OMP project to the seat's own agent directory under OpenRig's state. |
| `skills` | `id`, `path` | Skill directories containing a SKILL.md. Projected by the runtime adapter: Claude to `<cwd>/.claude/skills/<id>`, Codex to `<cwd>/.agents/skills/<id>`, Pi and OMP to the seat's own agent directory under OpenRig's state. |
| `guidance` | `id`, `path`, `target`*, `merge`* | Guidance files, merged as a managed block (see "Guidance Resources"). |
| `subagents` | `id`, `path` | Subagent definitions. Claude copies them to `<cwd>/.claude/agents/`, Codex to `<cwd>/.agents/`; Pi and OMP don't project them. |
| `plugins` | `id`, `source`, `plugin_type`* | Local plugin directories; selected with `uses.plugins`. `source.kind` must be `local`. `plugin_type` is `claude`, `codex` or `auto` (default): Claude projects a plugin to `<cwd>/.claude/plugins/<id>` when it is `claude`, or `auto` with a `.claude-plugin/plugin.json`; Codex projects to `<cwd>/.codex/plugins/<id>` when it is `codex`, or `auto` with a `.codex-plugin/plugin.json`. Pi and OMP don't project plugins. Neither Claude Code nor Codex reads skills from that plugin folder, so when a seat is instantiated a selected plugin's skills are also projected into the seat's skill folder under their plain names: the seat's own storage (see `skills`), or `.claude/skills/<name>` or `.agents/skills/<name>` for a seat without it. A folder already there with the same name, ignoring letter case, that OpenRig doesn't own is left alone; unless it already holds the same bytes, launch warns `plugin_skill_kept`. A skill the seat already selects by that name (from its profile, the System World or the project) keeps that source, an edited OpenRig-owned copy is kept with `plugin_skill_kept`, a retained plugin copy whose remembered source can no longer be projected is also kept with `plugin_skill_kept` (a seat selecting that plugin refreshes it at its next launch), and if the plugin skills can't be projected the seat launches without them and warns `plugin_skills_not_projected`. Hooks ship inside plugins. |
| `plugins` | `id`, `source`, `plugin_type`* | Local plugin directories; selected with `uses.plugins`. `source.kind` must be `local`. `plugin_type` is `claude`, `codex` or `auto` (default): Claude projects a plugin to `<cwd>/.claude/plugins/<id>` when it is `claude`, or `auto` with a `.claude-plugin/plugin.json`; Codex projects to `<cwd>/.codex/plugins/<id>` when it is `codex`, or `auto` with a `.codex-plugin/plugin.json`. Pi and OMP don't project plugins. Neither Claude Code nor Codex reads skills from that plugin folder, so when a seat is instantiated a selected plugin's skills are also projected into the seat's skill folder (`.claude/skills/<name>` or `.agents/skills/<name>`) under their plain names. A folder already there with the same name, ignoring letter case, that OpenRig doesn't own is left alone; unless it already holds the same bytes, launch warns `plugin_skill_kept`. A skill the seat already selects by that name (from its profile, the System World or the project) keeps that source, an edited OpenRig-owned copy is kept with `plugin_skill_kept`, a retained plugin copy whose remembered source can no longer be projected is also kept with `plugin_skill_kept` (a seat selecting that plugin refreshes it at its next launch), and if the plugin skills can't be projected the seat launches without them and warns `plugin_skills_not_projected`. Hooks ship inside plugins. |
| `runtime_resources` | `id`, `path`, `runtime`, `type` | Runtime-specific resources. `runtime` and `type` are required. An entry whose `runtime` isn't the seat's runtime is skipped. Pi and OMP project none. |
*Fields marked with `*` are optional.
@@ -461,7 +457,7 @@ restore behavior, and the distinction between readiness and verified proof.
| Hint | When Delivered | Mechanism |
|------|---------------|-----------|
| `auto` | Depends on the file | A seat's own role (a file marked `orientation: role`, or at a `guidance/role.md` path) is `send_text`, so pod-mates never overwrite each other's role in the shared file. A seat launched before this rule keeps the hint saved at its launch, which restore (`rig up <rig>`), `rig seat launch --fresh` and handover replay, until the rig is instantiated from its spec again: `rig down <rig>`, then `rig up <spec file>`, which starts a new generation with fresh conversations. Otherwise a path ending in `SKILL.md`, or content beginning with `# SKILL`, is `skill_install`; a `.md` file is `guidance_merge`; and any other file is `send_text` |
| `auto` | Depends on the file | A path ending in `SKILL.md`, or content beginning with `# SKILL`, is `skill_install`; otherwise a `.md` file is `guidance_merge`, and any other file is `send_text` |
| `guidance_merge` | Before harness boot | Merged into CLAUDE.md/AGENTS.md as managed block |
| `skill_install` | Before harness boot | Installed to runtime skill directory |
| `send_text` | After harness is ready | Sent as text to agent terminal via tmux |
+9 -288
View File
@@ -1,7 +1,7 @@
# Agent Startup Guide
Last validated: 2026-10-05, against main `9b88b118`
Applies to: OpenRig 0.6.8
Applies to: OpenRig 0.6.7
This guide teaches you how to think about what goes into an agent's startup experience — what files to write, where to put them, and how the layering model delivers them. It is an authoring guide, not a schema reference. For field-level details, see `rig-spec.md` and `agent-spec.md`.
@@ -38,31 +38,6 @@ The attention codes that stop startup this way are `trust_gate`,
`bypass_consent_gate`, `codex_auth_refusal` and `codex_client_incompatible`.
They are recognized from each harness's English prompt text.
## Continue a Codex fork whose child ID arrived late
For a member with `session_source.mode: fork`, OpenRig waits for Codex's new
child thread ID before sending its role and other startup prompts. It saves the
ID and sends the role only for a verified child: the bound pane's foreground
process forks the requested parent, and its own native record, matched to its
start time, supplies the ID. This capture uses `runtime.readiness_timeout_seconds`
(30 seconds by default, configurable from 1 to 600 seconds), as does the later
readiness check. The two waits have separate budgets. A seat handover with
`--source fork:<seat>` is different: its fork carries the conversation itself,
and a missed ID capture leaves the ID pending rather than stopping the handover.
If the ID is still unavailable, the seat reports `attention_required` and keeps
its startup context pending. Wait for the child to finish starting, resolve any
native prompt, then run `rig seat continue <seat>`. The continuation verifies
that the bound pane is running a fork of the requested parent and reads the
child's own native identity before delivering the saved context. It does not
launch another fork or resume the parent. The parent ID is never saved as the
child's resume token.
An unavailable or conflicting identity leaves the context pending. Once prompt
delivery has been attempted, it cannot be replayed by `continue`, including
after a daemon restart: inspect `rig seat status <seat>` and the native pane
before deciding how to recover an uncertain delivery.
## Two Categories of Startup
Everything an agent receives at boot time falls into one of two categories:
@@ -240,18 +215,7 @@ seconds), for up to 30 seconds by default. Change the window with
`rig config set runtime.readiness_timeout_seconds <1-600>` (or `OPENRIG_RUNTIME_READINESS_TIMEOUT_SECONDS`); it
applies to the next launch. A recognized prompt (the attention codes above) ends the wait at once as
`attention_required`. A timeout ends startup as `failed` ("Readiness timeout after Ns — harness did not become
interactive"), and the post-launch files aren't sent. A Codex fork that the rig launches (a member with
`session_source.mode: fork`, or an agent image's fork) is the exception: its timeout ends as `attention_required`,
with its startup context kept for `rig seat continue` (see below). A seat handover's fork has its own readiness check.
**Fresh Claude handovers.** `rig handover --source fresh` keeps the previous agent's scrollback. Before sending the
handover context, OpenRig waits for the latest prompt inside Claude's complete input frame, and confirms that the
process in the pane belongs to the new launch. A shell's `❯` prompt or echoed launch command is not enough, even
with an old Claude header or input frame above it and Claude already running. A header alone or a partially drawn
input frame also keeps the check pending. If the current screen or process cannot be read, or the process identity is unconfirmed, the handover keeps
waiting within the readiness window and fails if that window expires. Old screen text or the `claude` process label
alone cannot complete this check. A healthy agent can therefore remain unconfirmed while those observations are
unavailable; inspect its pane before deciding how to recover.
interactive"), and the post-launch files aren't sent.
**Claude submission check.** For each startup message to a Claude seat, OpenRig makes a bounded check that the prompt
was submitted. When the capture can't tell, it records the submission as unverified and carries on. When it sees the
@@ -265,136 +229,22 @@ one wins, and it needs `idempotent: true`). With `authenticated`, a fresh launch
and the seat answers it with `rig startup-proof submit --challenge-id <id> --answer <answer>`. `rig ps --nodes --full`
shows the result in the ORIENTED column: `verified`, `missing`, `rejected` or `n-a`. A missing proof doesn't block startup.
**After a failure.** Delivery, launch and action failures, and readiness timeouts other than a Codex fork's, give
`failed`. Every 30 seconds the context monitor marks a `failed` or `attention_required` seat `ready` once its pane reads
ready, unless its fresh or Codex fork context is still pending for `rig seat continue`. A seat that timed out can therefore read `ready` without having received its
**After a failure.** Delivery, launch and action failures, and readiness timeouts, give `failed`. Every 30 seconds the
context monitor marks a `failed` or `attention_required` seat `ready` once its pane reads ready, unless fresh context
is still pending for `rig seat continue`. A seat that timed out can therefore read `ready` without having received its
post-launch files. If it needs them, first look at what that occupant has done; then
`rig seat launch <seat> --fresh --reason <text>` starts a blank conversation for the seat. If the occupant is still
live, the command refuses with `session_live` unless you also pass `--stop`, which replaces that occupant deliberately.
**Preview before replacing a seat.** `rig seat launch <seat> --fresh --plan` inspects the selected
seat without starting tmux, stopping or launching a process, projecting startup files, or changing
lifecycle/queue records. Add `--json` for structured output. `--fresh` is still required;
`--reason` is optional for the preview and required when executing. Pass `--stop` to preview the
intended replacement of a live managed occupant. Adopted and unmanaged occupants remain blocked.
The plan identifies the target, current occupant, stop requirements, blockers, startup counts,
and each sibling seat's unchanged effect. Siblings stay attached and untouched. Launch composes
a new command through the runtime adapter using current settings; it does not replay a stored
wrapper command. The seat's launch environment and managed executable selection still apply.
The plan does not construct or display the launch command.
A preview is a snapshot, not a reservation or a native-readiness check. Unreadable tmux produces
`complete: false` and `launchable: null` (unknown), unless a known blocker already proves
`launchable: false`. A successful preview exits 0, including blocked or incomplete plans; scripts
must inspect `launchable === true` before deciding to execute. Request failures still exit nonzero.
Execution always resolves and checks the current state again. An older daemon without the preview
endpoint refuses the request; the CLI never falls back to executing a launch.
### Change an existing seat's working directory
Editing `cwd` in an already applied RigSpec doesn't update an existing seat's saved launch directory. Use `set-cwd` to
save a new one for the seat's next launch:
```sh
rig seat set-cwd planning-lead@project \
--cwd /work/project/seats/planning-lead --reason "isolate planning"
rig seat status planning-lead@project
```
**The path:** it must be absolute **on the daemon's host**. OpenRig creates a missing directory, resolves symlinks to
its real path, and refuses a filesystem root.
**What it saves:** the directory and a `node.cwd_changed` audit event (from, to, reason, operator), together, in one
transaction. The seat's node, canonical address, queue, session history and skill selection stay as they are.
`rig seat status` shows **Working directory for future launches** (`launch_cwd` in JSON). That's saved configuration,
not where the running process is.
**What it writes:** it creates the folder if it's missing, and writes nothing else there. The seat's next fresh launch or
rebuild handover writes its startup files there, as launch does today.
**`set-cwd`'s preflight policy:** before saving, it reads, without writing, the enumerated resource copies (including
nested plugin files) and startup skills that launch would place in the new directory, at their actual destinations,
as they are at that moment. It's a point-in-time check, not a global overwrite guarantee or protection against later
changes; at adoption, launch applies its own policy. (The commit's re-check covers the saved startup configuration,
not the destination's file contents.)
- **Refused, with the protected paths named (`cwd_destination_conflict`):** a file there that an operator edited, an
unrecorded file with different content, or two sources for one destination. That's stricter than launch, which
overwrites an unrecorded differing file with a warning; the refusal is deliberate, while the occupant still runs.
- **What to do:** use a clean destination, or move or merge those files, then retry.
- **Not checked:** managed guidance blocks and native configuration merges keep the adapter's policy at launch, and
launch-owned files such as `.openrig/context-collector.cjs` are written by launch as always.
- **A community harness** takes no startup files, so none are checked for it.
**The running occupant keeps its current directory.** Adopt the new one with:
- `rig seat launch <seat> --fresh --stop --reason <text>`, which starts a blank conversation; or
- `rig handover <seat> --source rebuild --reason <text>`, which carries durable context.
For Codex rebuilds, OpenRig also carries a complete supported native permission policy read from the live
predecessor's own conversation and process, without adding fresh-launch grants. A later recorded seat permission
or policy choice, or a differing declared successor profile, takes precedence. Missing, stale or unsupported observations keep the existing launch
behaviour and are reported as unverified in the handover result; requested modes and profiles alone don't prove
preservation. The result reports what was emitted, not native successor equivalence.
**Adopt it promptly.** The saved directory doesn't move the running process, but background work can use it before
adoption. Saved configuration isn't the live process's directory, and these examples aren't the only consumers. For a
running Claude seat:
- **Collector provisioning** writes `.openrig/context-collector.cjs` and merges `.claude/settings.local.json` in the
saved directory, under its existing preservation rules.
- **Managed compaction,** manual (`POST /api/compaction/trigger`) or automatic, asks the seat to write its restore map
under it. That may be outside the seat's current workspace, so a seat without bypass permissions may meet a write
prompt.
Adopting promptly is guidance. It doesn't protect against a poll or a compaction that runs before your next command.
**Before adopting,** don't `rig down`/`rig up` or hand over by fork. Those resume the existing conversation in the
saved directory, and Claude keeps conversations per project directory, so a resume there may not find it.
Cross-directory resume hasn't been run natively; this is the order to follow. `rig seat restart --keep-conversation`
(Codex) isn't adoption: it respawns in the directory it observes from the running process.
**Failures:** a refusal, missing or malformed saved startup context, or an audit-write failure leaves the prior
directory saved, and changes no seat, custody or startup file (a refusal may leave the new folder created). A concurrent configuration change is reported, not overwritten. A CLI timeout means the outcome is
unknown; check `rig seat status` before retrying. Choosing the same directory again records no second event.
**The old directory:** files there are left as they are, since other seats may still use them. The source RigSpec
declaration isn't changed either; update it separately if a future recreation should use the new directory.
### The `applies_on` Field
Each startup file and action specifies when it applies:
- `fresh_start` — delivered when a new conversation starts: the first launch, `rig seat launch --fresh`,
`rig seat continue`, and seats restored with `--fresh`
- `restore` — delivered when a seat is restored without its earlier conversation: a restore whose resume falls back to a fresh conversation, or a retried start
- `restore` — delivered when a seat is restored, including a restore whose resume falls back to a fresh conversation
- Default: `[fresh_start, restore]` (both)
**An exact resume delivers neither.** When a restore reopens the seat's saved conversation (`rig up <rig> --existing` with a resume token), OpenRig types no startup actions and delivers no startup files, `restore` ones included: the conversation already has them, and typing them again would add turns to it. The one exception: a managed guidance block missing from the guidance file is put back before the harness starts.
Use this to avoid re-sending context that a fresh conversation doesn't need twice. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both, so a seat that comes back fresh gets it again.
---
## What a seat is set to, and what it's running
A seat's settings can disagree with its running agent: what's saved for the next launch, what the running process
actually uses, and what its conversation has already read are three separate things. OpenRig reports each one
separately. Without live evidence it says unknown (or not verified), never the saved value. A saved change reaches the
running seat only through the action in the last column.
| What | Saved for the next launch | What the running seat has | How the running seat gets it |
|---|---|---|---|
| Model | `rig seat set-model` | `rig ps --nodes` shows the configured model beside the last observed one, or `UNKNOWN (<reason>)` | The next managed resume or successor launch |
| Reasoning effort | `rig seat set-effort` | `rig seat effort <seat>`: requested, saved, sent at launch, and what the session recorded, which is not verified until it takes a turn | Its next managed continuation; nothing restarts |
| Permissions | `rig seat set-permissions` (`--mode`, `--rules`, or for Codex `--codex-permissions` or `--codex-profile`), shown by `rig seat status` | What it launched with; a status read doesn't prove the running process enforces the selection | Its next managed launch or resume ([per-seat permission mode](getting-started.md#per-seat-permission-mode)) |
| Working directory | `rig seat set-cwd`, shown by `rig seat status` as **Working directory for future launches** | The directory it started in | `rig seat launch --fresh --stop` (a blank conversation) or `rig handover --source rebuild` (durable context); [details](#change-an-existing-seats-working-directory) |
| Startup files and skills | The spec's startup files, skills and actions | What its conversation read when it started; editing a file afterwards doesn't reach it | A fresh launch or a handover delivers them again; for the running seat, send or walk the change (`rig walk <seat> --through <files>`) |
| Tools (MCP servers, plugins) | Selected fragments, merged into the harness's own config (`.mcp.json` for Claude, `config.toml` for Codex) | Unknown to OpenRig: registered isn't active, Claude asks before it uses a new server, and no OpenRig command reports which tools a running harness loaded | Check in the harness itself (Claude Code's `/mcp`); a running session may need a restart to pick up newly written config ([runtime config disclosure](#runtime-config-disclosure)) |
Per harness: Claude Code's and Codex's permission choices are in the setup reference
([Claude Code](getting-started.md#claude-code-a-different-launch-flag),
[Codex](getting-started.md#codex-select-sandbox-and-approvals-together)), Codex homes are
[below](#separate-codex-homes-for-separate-installations), community harnesses' limits are in
[their presets](community-harness-presets.md), and moving a live seat to the other runtime is in the
[setup reference](getting-started.md#move-a-seat-to-the-other-runtime).
Use this to avoid re-sending context that the agent already has from its resumed conversation. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both.
---
@@ -405,9 +255,7 @@ The AgentSpec and RigSpec allow declaring deterministic environment configuratio
### What the Spec Supports
**Hooks** ship inside plugins: declare the plugin under `resources.plugins[]`. AgentSpec validation refuses
`resources.hooks`. OpenRig loads one plugin's hooks into a seat itself: the shipped `openrig-core` plugin, for a Claude seat
that selects it (see [the refocus channel](refocus-channel.md#the-mechanism)). The folder a declared plugin is projected into
is not one Claude Code or Codex loads, so a plugin of your own does not get its hooks run by that projection.
`resources.hooks`.
**Runtime resources** (in `resources.runtime_resources`):
```yaml
@@ -434,11 +282,6 @@ Types are `send_text`, `slash_command` and `startup_proof` (see "Startup proof"
`idempotent` is required, and an action that isn't idempotent must not list `restore` in `applies_on`. Both phases
run after the harness is ready: `after_files` (the default) after the post-launch files, then `after_ready`.
**Don't rename a managed Claude seat.** OpenRig starts every Claude seat with `--name <session>`, on a fresh start and on
every resume, and its own reads of the seat use that name: context usage and managed compaction read the status-line
record Claude saves under it, and the input box is recognized by the name on its border. A startup `/rename` to another
name breaks those reads until the next resume puts the session name back.
### Current Support Matrix
| Capability | Status | Notes |
@@ -446,7 +289,7 @@ name breaks those reads until the next resume puts the session name back.
| Guidance file projection (`guidance_merge`) | **Supported** | Reliable. Primary delivery mechanism. |
| Skill projection (`skill_install`) | **Supported** | Reliable. Skills are copied to workspace. |
| `send_text` delivery after ready | **Supported** | Reliable. Requires harness to be ready. |
| Hooks | **Through plugins, limited** | `resources.hooks` is refused. OpenRig loads the shipped `openrig-core` plugin's hooks into a Claude seat that selects it (Claude Code v2.1.280 or later); a plugin you declare under `resources.plugins[]` is projected, but its hooks are not loaded. |
| Hooks | **Through plugins** | Declare a plugin under `resources.plugins[]`; `resources.hooks` is refused. |
| Runtime resource projection | **Supported for recognized fragments** | `claude_settings_fragment`, `claude_mcp_fragment`, and `codex_config_fragment` are applied to provider config. Unknown types are copied to runtime extension directories. |
| Permission configuration | **Native settings plus managed launch flags** | OpenRig launches Claude with `acceptEdits` and Codex with `workspace-write` unless an explicit supported selection changes them; a seat with no policy, selection or named Codex profile also gets the per-launch [team default](rig-spec.md#team-launch-defaults), and the `kernel` rig's seats get a wider operational default ([rig spec, "At launch"](rig-spec.md)). Neither is written to a settings file. Use [the first-user permission guide](getting-started.md#opt-in-permissive-operation) for opt-in and custom choices. |
| MCP installation | **Supported for Claude fragments** | A selected `claude_mcp_fragment` is merged into the project's `.mcp.json`. Claude asks to approve new servers found there; that prompt stops startup as `mcp_gate` until someone answers it and runs `rig seat continue`. Otherwise use `/mcp` or `claude mcp add`, or describe the servers in startup files for the agent to configure. |
@@ -564,106 +407,6 @@ remain shared.
**Terminal seats** (`builtin:terminal`) get no agent or profile layers and no startup proof.
### Claude compaction preparation directory
Managed Claude compaction asks the seat to write a restore map before `/compact`. By default,
OpenRig uses `<cwd>/.openrig/compaction/preparation/`, falling back to its instance home when
that directory cannot be prepared. A filesystem write check cannot establish whether Claude's
file-edit permissions allow the seat to write there.
If the launch workspace is denied by Claude's permissions, select an absolute preparation root:
```sh
rig config set policies.claude_compaction.preparation_root /absolute/path/to/restore-maps
```
Grant the seat file-edit access to that root in its Claude configuration. OpenRig does not change
Claude permissions; choose a directory outside any matching deny rule. The corresponding environment
variable is `OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT`.
New manual and automatic attempts use `<root>/<session>/<attempt-id>/RESTORE-MAP.md`; an in-flight
attempt keeps its original path through the post-compaction restore request. The root gets an
exclusive `.gitignore` containing `*`, and the daemon publishes the marked `.tmp` file with an
atomic rename. Use a dedicated root: a conflicting
ignore file or an unwritable configured directory stops preparation with
`preparation_directory_unavailable`, visible in `rig compact <session> --state`, without choosing
another location. Fix the directory and explicitly retry. `--skip-map` still skips the map once.
Reset with `rig config reset policies.claude_compaction.preparation_root` to restore the default.
The setting applies to new attempts without restarting the daemon.
The managed `/compact` command keeps its 600-character budget. When the selected map path or
the attempt's compaction instruction does not fit, the existing restore request carries it;
it never substitutes a newer preparation root or instruction. An explicitly selected root's
published map is also named in that request when the path fit in `/compact`. The default root
keeps the existing command/restore handoff. Under `--skip-map`, restore still recognizes an
optional map completed at that attempt's path, or says that no map was required.
The compaction preparation and command-submission tests cover manual and automatic attempts,
long paths and instructions, settings changed during preparation, directory failures and
late maps after cancellation or expiry. They use synthetic delivery and filesystem fixtures;
they do not prove a live Claude session's permission choices or compaction behavior.
### Codex compaction and sizing
OpenRig's managed compaction is for Claude Code seats only, by design. That covers `rig compact`, the
`policies.claude_compaction.*` automatic policy, and the watchdog jobs that the `managed-compaction` and
`apprentice-handover` strategies create. A Codex seat compacts with Codex's own automatic compaction, and OpenRig
leaves that to Codex.
- `rig compact <session>` refuses a Codex seat: "not a Claude (claude-code) seat".
- `rig compact-plan` lists a Codex seat as skipped (`codex_not_managed_by_claude_compact_in_place`).
- The automatic policy never acts on a Codex seat.
- A Codex member with `compaction_strategy: managed-compaction` or `apprentice-handover` gets no watchdog jobs.
**What Codex compacts at.** Codex describes `model_auto_compact_token_limit` as the "token threshold that triggers
automatic history compaction (unset uses model defaults)". Its configuration reference does not say what those
defaults are, and the model catalog's `auto_compact_token_limit` is null for the models we checked in Codex 0.162.1.
Measure a seat's own compactions before relying on an unset limit.
**What window the model has.** `codex debug models` prints the catalog. Each model carries `context_window`,
`max_context_window` and `effective_context_window_percent`. In Codex 0.162.1, `gpt-5.6-sol` has a `context_window`
of 272000 at 95%, which is 258400. That is the `model_context_window` its rollout `token_count` events report.
A limit only matters when it is below that effective window. Leave `model_context_window` alone: don't raise it toward
`max_context_window` to avoid compaction. Set the limit instead.
**Setting the limit for seats only.** Put `model_auto_compact_token_limit` in a Codex profile file,
`$CODEX_HOME/<name>.config.toml` (`~/.codex/<name>.config.toml` when `CODEX_HOME` is unset), and name it on each
Codex member that should use it:
```toml
# ~/.codex/seat-compaction.config.toml
model_auto_compact_token_limit = 150000 # choose your own value
```
```yaml
codex_config_profile: seat-compaction
```
OpenRig passes `-p seat-compaction` on that seat's fresh, fork and resume launches. Codex layers the profile file on
top of the base user config, so only seats that name the profile see the lower limit. A Codex session started without
that profile keeps the base `config.toml` in the same Codex home (`$CODEX_HOME/config.toml`, or `~/.codex/config.toml`
when `CODEX_HOME` is unset). The `150000` is an example, not a recommendation.
With the normal launch mode, `codex_config_profile` replaces OpenRig's explicit workspace-write sandbox flag, so the
profile must set the sandbox and approvals you want. See
[permission precedence and limits](getting-started.md#custom-settings-and-precedence), which also gives Codex's order
of precedence between a profile, project settings and CLI overrides.
**The trade-off.** Every model call carries the whole context so far, so a smaller context makes each call cheaper. The
cost is that Codex compacts sooner and more often, so more of the conversation is carried as a summary instead of
verbatim. A higher limit keeps more detail and makes each call larger.
**Related keys.** These also shape compaction. This guide doesn't recommend values for them. See Codex's
[configuration reference](https://learn.chatgpt.com/docs/config-file/config-reference):
| Key | What Codex says it does |
|---|---|
| `model_auto_compact_token_limit_scope` | `total` (the default) counts the full active context; `body_after_prefix` counts only growth after the carried compaction-window prefix. |
| `compact_prompt` | Inline override for the history compaction prompt. |
| `experimental_compact_prompt_file` | Loads the compaction prompt override from a file (experimental). |
| `tool_output_token_limit` | Token budget for storing individual tool or function outputs in history. |
When authoring startup content, note which instructions are runtime-specific. For example, an orchestrator that needs a monitoring loop should include instructions like: "If running Claude Code, use `/loop 3m` to periodically check rig health. If running Codex, check rig health at the start of each task cycle instead."
---
@@ -715,28 +458,6 @@ Existing user files and legacy role blocks are left intact; this is not a cleanu
of already-installed shared blocks. General user-authored guidance projection is
unchanged.
The other shipped agent specs that start a seat with `guidance/role.md` mark that
file `orientation: role` too, so `rig queue whoami --json` reports their role and
refocus names it. They never projected a role into shared guidance; their startup
delivery is unchanged.
**Removing a leftover shared role block by hand.** A kernel installed before 0.6.5
may still hold one seat's old role in its working directory's `CLAUDE.md` (or the
rig's configured `managed_blocks` file) or `AGENTS.md`. Upgrading doesn't remove it.
`rig down` removes OpenRig's managed blocks from that file when no other running
rig uses it, so it stays until then. To remove it sooner, by hand:
1. Find the exact block: the lines from `<!-- BEGIN OpenRig MANAGED BLOCK: role -->`
through `<!-- END OpenRig MANAGED BLOCK: role -->` (in older files, `RIGGED`
instead of `OpenRig`). Only the block whose id is exactly `role`; leave
`rig-role`, other ids and everything outside the markers alone.
2. Confirm each seat that shares that directory has its per-seat replacement:
`rig queue whoami --json` run in the seat reports `role.state` as `present`.
A seat started before it was upgraded still has the old content in its
conversation; restart it fresh if you want it to start from the per-seat role.
3. Keep a copy of the file, remove only those lines, and compare the result with
the copy: nothing else in the file should differ.
**Separate project context from role**
Don't put project documentation inside the role guidance. The role is about the agent's function; project context is about what the agent is working on. Use rig-level or pod-level startup files for project context.
+4 -71
View File
@@ -41,31 +41,6 @@ state. Attention/alarm values likewise stay in their own machinery (`attentionCo
Both herdr and omnigent converged on this exclusion, with warning comments on the
collision in both codebases.
The legacy `agentActivity` field distinguishes a typed provider interruption from
a request for a person: fresh Codex `at_limit` and Claude `StopFailure/rate_limit`
events carry `state: unknown` and `reason: usage_limit`, never a `needs_input`
hook. A permission prompt can independently supply human-input evidence. See
[Claude usage-limit detection](claude-status-line.md#claude-usage-limit-detection)
for the separate provider signal and queue timer.
### Per-seat attention in the node inventory
`rig ps --nodes` carries the daemon's `needsAttention` result, using the same predicate
as the rig's `attentionCount`. It also serves `arbitratedNeedsInput`: `true` for observed
input, `false` for an observed clear, and `null` when no authoritative needs-input source
answered. `needsInputEvidence` retains that source's rung and observation time, or `null`.
A zero prompt count alone is not an observed clear. Absent arbitration evidence leaves
the existing live-hook fallback in effect; lifecycle, startup, held and error signals
can independently raise attention. A false attention flag means no known trigger, not
proof that an unobserved input box is clear.
The compact human table's ATTN column renders `yes`, `no`, or `?` when the flag is
unavailable. Compact JSON retains `needsAttention`; use `--full` or
`--fields needsAttention,arbitratedNeedsInput,needsInputEvidence` with `--json` for the
provenance. The TUI uses the served flag for attention glyphs and totals, and shows the
needs-input evidence in seat detail, including a hook-only prompt. With an older daemon,
attention stays unknown while its existing prompt, startup and error hints remain visible.
## Derived diagnoses — computed at read time, never stored
- **PARKED** — the disease: `(activity = idle-at-prompt OR needs-input pending) ×
@@ -102,7 +77,7 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
- **r3 — Claude `sessions/<pid>.json` self-report** (busy/shell/idle/waiting, since
v2.1.139): standing, above hooks for working/idle. Undocumented internal — unreadable
means fall down the ladder, never error.
- **r2 — lifecycle hooks** (Claude SessionStart/UserPromptSubmit/PostToolBatch/Stop/StopFailure/Notification;
- **r2 — lifecycle hooks** (Claude SessionStart/UserPromptSubmit/Stop/Notification;
Codex SessionStart/UserPromptSubmit/Stop/PermissionRequest): standing for Claude; Codex's
hook rung starts at trial and is promoted after 50 agreeing observations over at least an
hour. Exactly-once turn boundaries; SubagentStop filtered (it
@@ -117,51 +92,10 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
window in production — never by fixture pass alone (symmetric admission: a fixture
pass admits a rung to TRIAL, where it is measured but not consulted; production agreement
promotes it). Undeclared and partial-coverage rungs are identity-only, which is also where
a degraded rung goes, with one declared exception: for a pi or omp seat or a community
harness, sampling is positive-only: a changing pane reads working, and a quiet one never reads idle on its
own, since the pane can be quiet mid-turn.
- **pi and omp runner hooks**: OpenRig's runner reads the child's RPC event stream and posts
each turn boundary (`agent_start`, `turn_start`, tool calls, `agent_end`; attention). These
decide working, idle and needs-input. Unlike Claude and Codex hooks, an idle or attention
boundary holds until the next one instead of expiring after 15 s; a working boundary still
expires, so a turn whose end was lost reads unknown, not working forever. Posts are taken
in the runner's own order (time, then its sequence), so an older boundary arriving late
doesn't win. The runner's report that its child
exited reads unknown, not idle. A boundary the runner didn't see (if a runtime ended a turn
without `agent_end`) leaves the seat working up to the 15 s window, then unknown, never idle. After a
daemon restart, a runner seat takes its last recorded boundary from the hook store, for its
current session and occupant generation only, so an idle seat still reads idle before it
posts again, provided that report is fresh: a pi runner's idle boundary states an interval
(below) and lapses to unknown once it is older than two of them.
A pi runner also posts an idle `Stop` (`idle_heartbeat`) every three minutes while it waits, and states that
interval as `livenessSeconds` on every `Stop`: a newly ready runner reads idle at once, and an idle boundary that
carries an interval holds for two of them, then lapses to unknown, so a runner killed without saying so stops reading
idle. Silence never clears a pending attention prompt, and a runner that states no interval (omp, an older pi
runner) keeps the hold-until-the-next-boundary behaviour.
Claude permission notifications raise needs-input immediately. A visible permission can clear
after it has been observed and then disappears. A notification whose dialog was never observed
can also clear when Claude reports `PostToolBatch`: every tool in that actor's batch has resolved,
so its earlier permission requests are no longer pending. The relay carries the native actor
identity; a subagent or in-process teammate finishing its batch cannot clear the main session's
request, and overlapping actors remain separate. An individual `PostToolUse` is insufficient
because another tool in the batch may still be waiting for permission.
Batch completion must come from the current occupant and have a later source timestamp than
the matching notification. Missing actor attribution from an older relay, equal or older
timestamps, and another actor's completion retain attention. An unrecognized or missing screen
does not expire attention by age. `Stop` and `UserPromptSubmit` remain the turn-boundary fallback.
The oracle remembers each actor's latest completed batch for the current occupant, so a strictly
earlier notification arriving late cannot restore the mark, even if the completion arrived first.
A notification with an equal or later timestamp remains eligible; swapping or retiring the
occupant discards its remembered completions.
Completing a batch changes the needs-input signal without inferring that the seat is idle or
overwriting a later elicitation. The hook contract is documented in
[Claude's hooks reference](https://code.claude.com/docs/en/hooks#posttoolbatch).
a degraded rung goes.
Partial-coverage honesty: a source with partial lifecycle coverage gets identity-only
trust, unless its inventory declares it positive-only. A positive-only source can report
working and nothing else; its silence reads unknown, never idle. The ladder is also the migration path — new oracles slot in above existing
trust. The ladder is also the migration path — new oracles slot in above existing
heuristics, lower rungs retire a release at a time, consumers never see a flag day.
Seat-keyed: state attaches to the durable seat nodeId, never the occupant. A handover or
@@ -183,8 +117,7 @@ LIFECYCLE); see the
- **Attention codes:** `trust_gate`, `hook_trust_gate`, `update_gate`, `login_required`,
`mcp_gate`, `bypass_consent_gate`, `codex_auth_refusal`, `codex_client_incompatible`.
- **Restore outcome:** `resumed`, `fresh-primed`, `awaiting-decision`, `attention_required`,
`failed` (plus `rebuilt`, `operator_recovered` and the legacy `fresh`). A seat joined with
`rig attach` as an external CLI reads `left_attached`: restore leaves it as it is.
`failed` (plus `rebuilt`, `operator_recovered` and the legacy `fresh`).
## Rejected oracles — dated receipts (2026-08-26), so nobody re-derives them expensively
-11
View File
@@ -1,11 +0,0 @@
# Checked seat stop (HTTP)
Read `GET /api/seat/stop-precondition/:seatRef` immediately before an operator-confirmed stop. A successful response has this shape:
```json
{"contract":"seat-stop.v1","expected":{"nodeId":"<node ID>","sessionId":"<latest session ID or null>"}}
```
Pass `expected` unchanged in the body of the existing `POST /api/seat/stop/:seatRef`, alongside `reason` and optional `operator`. The contract marker lets clients detect support. `expected` is optional; a request without it retains the existing stop behavior. A malformed explicit value returns HTTP 400. A null session ID can describe an empty seat, but stopping it returns `no_session`.
Checked stop requires the delivery guard. It refuses a changed node, latest session, binding or occupant before a terminal effect with HTTP 409 `selection_changed`. If the selection changes after client detach, the response reports `partialEffect: true` and no kill is sent. A kill failure or a failure to save stop state after a kill reports `stop_uncertain`; inspect the seat before retrying. Success retains the usual `seat`, `sessionName` and `sessionId` fields.
-111
View File
@@ -1,111 +0,0 @@
# Custom Claude status line
OpenRig installs a silent context collector as `statusLine.command` in the seat
working directory's `.claude/settings.local.json`. Claude's project-local setting
overrides your user-level status line, so a renderer configured only in
`~/.claude/settings.json` can disappear in that directory. Other Claude sessions
using the same directory also read that setting.
Since 0.6.5, OpenRig preserves a custom, nonempty project-local status line command,
including a wrapper or a command composed with shell operators. It still refreshes
its own collector script. If your command does not run the collector, context and
provider usage stop updating, and Claude resume-token capture at adoption or
handover loses its current session evidence. An earlier sidecar can remain and
contain an older session ID; without one, context and capture report
`missing_sidecar`, and provider usage reports `no_statusline_cache_yet` unless an
earlier cache remains.
## Feed both commands the same input
On macOS, Linux or WSL2, save a wrapper such as this, replacing the three
placeholder paths and `my-statusline` with your actual commands:
```sh
#!/bin/sh
input=$(cat)
printf '%s' "$input" |
node '/seat/cwd/.openrig/context-collector.cjs' \
'/instance/home/state/context-usage' \
'/instance/home/state/provider-usage' >/dev/null 2>&1
printf '%s' "$input" | my-statusline
```
Take the collector path and both directory arguments from the existing generated
`statusLine.command` in that seat's `.claude/settings.local.json`; the instance
home is the selected `OPENRIG_HOME`. Keep paths quoted, including paths with
spaces. Each command needs the same status line JSON on stdin: simply running
the collector and renderer in sequence against a single stdin lets the first
consume the input.
In that same settings file, merge this field while preserving your other settings:
```json
{
"statusLine": {
"type": "command",
"command": "sh '/absolute/path/to/openrig-status-line.sh'"
}
}
```
Use the renderer command from your user-level configuration in the wrapper. The
collector stays silent and the renderer supplies the visible status line. This
POSIX-shell example applies to WSL2's Linux installation; it does not establish
native Windows support.
## Check after an upgrade or instance change
The collector command's paths and arguments are implementation details. OpenRig
preserves your wrapper, so it cannot refresh those arguments for you. Recheck
them after an upgrade, a seat-directory move or an `OPENRIG_HOME` change.
After Claude next updates its status line, inspect context usage in `rig ps` or
`rig whoami --full` from the seat. Unknown or stale context is a reason to check
the wrapper; it does not prove the collector is running. For a direct check,
inspect the current session's JSON under the configured `state/context-usage`
directory: its timestamp should advance and its `session_id` should match the
current Claude session. A visible custom status line alone does not verify
collection or end-to-end adoption/handover recovery. The wrapper above suppresses
collector errors; remove its stderr redirection temporarily when diagnosing it.
## Claude usage-limit detection
The status line supplies subscription usage windows and their reset timestamps.
OpenRig also registers Claude's [`StopFailure` hook](https://code.claude.com/docs/en/hooks#stopfailure),
available since Claude Code 2.1.78. Its exact `error: "rate_limit"` value reports a
provider interruption even when no status line window is available. This path
requires the updated OpenRig hook configuration and the current seat's runtime
and occupant generation. Unknown identity or an expired event cannot trigger a
usage-limit pause; a hook with a newer source timestamp supersedes the event,
regardless of arrival order. The event expires after five minutes in any case.
A helper Claude started from a seat's shell inherits that seat's environment,
so its rate-limit hook is attributed to that seat too. This is appropriate when
the helper uses the same login, since the quota belongs to the account. A
helper on a different login is attributed the same way, so its rate limit can
hold pending handoffs to every seat in that seat's Claude pool for one retry
interval, even when the seat's own login still has room. This attribution does
not bind the hook to Claude's own native session.
The event carries no usage percentage, account identity or reset timestamp.
It marks the Claude provider pool as limited and preserves a current
`agentActivity.reason` of `usage_limit` over a cached idle reading. It does not
count as a request for human input; a newer permission prompt still takes
precedence in the seat's activity display. Conflicting seat windows retain the
existing `explicit_unknown` host diagnosis.
Pending work handled by the wake ladder uses the existing local Claude pool
(the one-account-per-host assumption), with separate pools for seats that
declare different Claude config directories. If reset windows conflict within
a proposed shared pool, only the seats reporting exhaustion are held; another
seat's pending handoffs can still retry. An exhausted status line window with
a known reset supplies the timer. Otherwise a typed failure uses
`queue.wake_retry_interval_seconds` (default 300) from the event time, plus the
existing wake jitter, and records `config-fallback`. That is a retry time, not a
claim that the provider's quota will reset then. The timer releases the shared
blocker once and retains the existing durable wake delivery behavior.
Authentication, billing and overloaded errors are not classified as usage
limits. Claude limit banners are not scraped, and older runtimes or failures
that do not emit this hook still depend on status line data. Without either
source, provider usage remains unknown.
@@ -1,13 +0,0 @@
# Install as $OPENRIG_HOME/community-harnesses/cursor.yaml, with openrig-cursor-launch on the seat's PATH.
format: 1
name: cursor
command: openrig-cursor-launch
maintainer: Your name or team
limits:
- resume reads a chat as missing unless its store is in the seat's Cursor config folder, under the seat's working directory
- no guidance, skills or startup files are placed
- needs-input and approval prompts are read from the screen only
launch_result: true
resume: true
startup_assisted: false
first_input: true
@@ -1,68 +0,0 @@
#!/bin/sh
# A reference launcher for Cursor's CLI as an OpenRig community harness. Copy it, own it, and change it: OpenRig
# doesn't ship or run it. It follows docs/reference/community-harness-presets.md:
# - a new launch: `cursor-agent create-chat` prints a new chat id; the launcher reports it, then reopens that chat;
# - a resume (OPENRIG_RESUME_REF set): it reopens the chat it was asked for and reports that id, or, when Cursor holds
# no such chat, reports it missing and starts nothing.
# Cursor's chat-id facts come from the probe in OpenRig issue #352, and the chat store's location from Cursor's own CLI
# (2026.10.22).
set -eu
producer="openrig-cursor-launch 2"
cursor="${OPENRIG_CURSOR_AGENT:-cursor-agent}"
# Each seat gets its own Cursor config folder unless CURSOR_CONFIG_DIR is already set. Cursor keeps cli-config.json
# (`--model`, approval settings) and the chats there, so a seat doesn't rewrite the person's own ~/.cursor. Cursor's
# login isn't in it: Cursor keeps that in ~/.config/cursor/auth.json on Linux and in the Keychain on macOS.
CURSOR_CONFIG_DIR="${CURSOR_CONFIG_DIR:-${OPENRIG_HOME:-$HOME/.openrig}/state/cursor/${OPENRIG_NODE_ID:-${OPENRIG_SESSION_NAME:-default}}}"
export CURSOR_CONFIG_DIR
mkdir -p "$CURSOR_CONFIG_DIR"
# Write this attempt's record, all at once, where OpenRig asked for it (nothing when it didn't ask).
report() {
[ -n "${OPENRIG_LAUNCH_RESULT:-}" ] || return 0
tmp="$OPENRIG_LAUNCH_RESULT.$$.tmp"
printf '%s\n' "$1" > "$tmp"
mv "$tmp" "$OPENRIG_LAUNCH_RESULT"
}
plain_id() { case "$1" in ''|*[!A-Za-z0-9._:-]*) return 1 ;; *) return 0 ;; esac; }
# Where Cursor keeps a chat: <config folder>/chats/<md5 of the working directory>/<chat id>/store.db. Cursor hashes
# its process's working directory, which is the physical path, so this hashes `pwd -P`.
chat_store() {
if command -v md5sum >/dev/null 2>&1; then sum=$(pwd -P | tr -d '\n' | md5sum | cut -d' ' -f1)
else sum=$(pwd -P | tr -d '\n' | md5 -q); fi
printf '%s/chats/%s/%s/store.db' "$CURSOR_CONFIG_DIR" "$sum" "$1"
}
# Preserve the file's exact bytes, including trailing newlines. A read error stops before the harness starts.
if [ -n "${OPENRIG_FIRST_INPUT:-}" ]; then
first_input=$(cat "$OPENRIG_FIRST_INPUT" && printf '.')
first_input=${first_input%.}
set -- "$@" "$first_input"
fi
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
id="$OPENRIG_RESUME_REF"
plain_id "$id" || { echo "openrig-cursor-launch: refusing an unusual chat id" >&2; exit 2; }
# Cursor's `--resume` with an id it doesn't hold exits 0 on a new, empty chat, so check first. Gone: say so, start nothing.
if [ ! -f "$(chat_store "$id")" ]; then
report "{\"format\":1,\"attempt\":\"${OPENRIG_LAUNCH_ATTEMPT:-}\",\"harness\":\"cursor\",\"error\":\"missing\",\"producer\":\"$producer\"}"
echo "openrig-cursor-launch: Cursor has no chat $id in $CURSOR_CONFIG_DIR for $(pwd -P); starting nothing" >&2
exit 1
fi
else
# create-chat prints the id but doesn't exit (see #352): read the first line, then stop it.
out=$(mktemp)
"$cursor" create-chat > "$out" 2>/dev/null &
pid=$!
i=0
while [ "$i" -lt 100 ] && [ ! -s "$out" ]; do sleep 0.1; i=$((i + 1)); done
kill "$pid" 2>/dev/null || true
id=$(head -n 1 "$out" | tr -d '\r\n\t ')
rm -f "$out"
# No usable id: report nothing, so OpenRig shows the identity as unavailable, and start Cursor on its own chat.
plain_id "$id" || exec "$cursor" --trust "$@"
fi
report "{\"format\":1,\"attempt\":\"${OPENRIG_LAUNCH_ATTEMPT:-}\",\"harness\":\"cursor\",\"nativeRef\":\"$id\",\"producer\":\"$producer\"}"
exec "$cursor" --resume "$id" --trust "$@"
@@ -1,19 +0,0 @@
# Merge this into each Hermes seat's config.yaml, in that seat's Hermes home ($OPENRIG_HOME/state/hermes/<node id> with
# the example launcher), before the seat's first start.
#
# Hermes runs every tool command in its gateway (one per Hermes home, so one per seat with the example launcher), with
# the gateway's environment. For each session it swaps
# in the client's own values only for its toolchain names and the names listed here. Without this list, `rig` inside
# Hermes runs as whichever seat started the gateway, or as no seat. With it, each seat runs `rig` as itself.
# These are the seat's own OpenRig variables; none is a secret, and Hermes refuses credential-like names anyway.
terminal:
client_env_passthrough:
- OPENRIG_SESSION_NAME
- OPENRIG_NODE_ID
- OPENRIG_HOME
- OPENRIG_URL
- OPENRIG_HOST
- OPENRIG_PORT
- OPENRIG_DB
# Your own settings go beside it (the guide says which): the model and provider, and agent.max_turns if you want a cap.
@@ -1,12 +0,0 @@
# Install as $OPENRIG_HOME/community-harnesses/hermes.yaml, with openrig-hermes-launch on the seat's PATH.
format: 1
name: hermes
command: openrig-hermes-launch # no model, provider or --max-turns here: they go in the seat's Hermes config.yaml
maintainer: Your name or team
limits:
- resume not declared until reopening a named session, and a missing one, have been checked with a real Hermes
- closing a seat's pane detaches it; Hermes finishes any accepted turn; whether its gateway exits when idle is unchecked
- creation settings (model, provider, max turns) come from the seat's Hermes config.yaml, not from flags
- no guidance, skills or startup files are placed
- needs-input and approval prompts are read from the screen only
names_conversation: true
@@ -1,34 +0,0 @@
#!/bin/sh
# A reference launcher for Hermes Agent's CLI as an OpenRig community harness. Copy it, own it, and change it: OpenRig
# doesn't ship or run it. It follows docs/reference/community-harness-presets.md (`names_conversation`):
# - a new launch (OPENRIG_CONVERSATION set): Hermes creates the session titled with that name;
# - a resume (OPENRIG_RESUME_REF set): Hermes reopens that session, and never creates one. A name that matches nothing
# ends with Hermes's own "No session found" error, so a gone conversation isn't replaced by a new one.
# Put the model, provider and max turns in the seat's Hermes config.yaml, not on the command line. Hermes v0.22.4's CLI
# creates a `-c <name> --create-if-missing` session with no launch flags, then reopens it and refuses any creation flag
# that differs, so such flags fail even on a new name. Other arguments go only to a new session, never to a resume.
set -eu
hermes="${OPENRIG_HERMES:-hermes}"
# Each seat gets its own Hermes home, so seats don't share sessions or a gateway, and a seat's stop concerns only its own.
# Its settings (model, provider, agent.max_turns) come from that home's config.yaml, and its key from the environment
# (an OpenRouter key, say), not from the person's ~/.hermes.
HERMES_HOME="${OPENRIG_HOME:-$HOME/.openrig}/state/hermes/${OPENRIG_NODE_ID:-${OPENRIG_SESSION_NAME:-default}}"
export HERMES_HOME
mkdir -p "$HERMES_HOME"
# Hermes's tools see its shared gateway's environment except for the names a profile passes through, so a seat whose
# config.yaml doesn't pass OPENRIG_SESSION_NAME would run `rig` as another seat, or as none (see config.yaml.example).
if ! grep -qs OPENRIG_SESSION_NAME "$HERMES_HOME/config.yaml"; then
echo "openrig-hermes-launch: $HERMES_HOME/config.yaml doesn't pass OPENRIG_SESSION_NAME to Hermes's tools, so rig run from Hermes won't act as this seat; see config.yaml.example" >&2
fi
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
exec "$hermes" chat -c "$OPENRIG_RESUME_REF"
fi
if [ -n "${OPENRIG_CONVERSATION:-}" ]; then
exec "$hermes" chat -c "$OPENRIG_CONVERSATION" --create-if-missing "$@"
fi
echo "openrig-hermes-launch: OpenRig passed no conversation name; is names_conversation set in the preset?" >&2
exit 2
@@ -1,15 +0,0 @@
# The Gateway must already run. Configure its route and authentication in this seat's OPENCLAW_CONFIG_PATH.
format: 1
name: openclaw-gateway
command: openrig-openclaw-launch --gateway
maintainer: Your name or team
limits:
- connects to an existing Gateway; this launcher never starts or stops that service
- stopping the TUI closes its client; an accepted turn may keep running on the Gateway
- uses only agent main; automatic session resets must stay off
- typing /new changes the native key while the seat keeps the name OpenRig gave it
- no guidance, skills or startup files are placed
- needs-input and approval prompts are read from the screen only
names_conversation: true
resume: true
first_input: true
@@ -1,16 +0,0 @@
# Install as $OPENRIG_HOME/community-harnesses/openclaw.yaml, with openrig-openclaw-launch on the seat's PATH.
format: 1
name: openclaw
command: openrig-openclaw-launch
maintainer: Your name or team
limits:
- runs locally in the pane (openclaw tui --local), using only agent main, with no Gateway; Gateway-attached use isn't covered
- Finish the current local turn before stopping; an interrupted local turn may retain native recovery work and refuse the next request. This launcher does not repair or retry it.
- local resume may run a native database integrity check; check that the TUI has finished startup before sending work
- automatic session resets (session.reset) must stay off, or a key can start a new conversation
- typing /new in the pane moves the chat to a fresh session key; the seat still holds the name OpenRig gave it
- no guidance, skills or startup files are placed
- needs-input and approval prompts are read from the screen only
names_conversation: true
resume: true
first_input: true
@@ -1,70 +0,0 @@
#!/bin/sh
# Reference launcher for OpenClaw 2026.10.6 (fe06181), not installed by OpenRig.
# Each seat owns its client state and uses the main agent. OpenRig's opaque name maps to its full native key.
# --gateway selects an existing Gateway configured for this client; the default is an embedded local agent.
# Resume checks that exact key at its owner; an unavailable lookup is not a missing conversation.
set -eu
openclaw="${OPENRIG_OPENCLAW:-openclaw}"
mode=local
if [ "${1:-}" = --gateway ]; then mode=gateway; shift; fi
OPENCLAW_STATE_DIR="${OPENRIG_HOME:-$HOME/.openrig}/state/openclaw/${OPENRIG_NODE_ID:-${OPENRIG_SESSION_NAME:-default}}"
export OPENCLAW_STATE_DIR
mkdir -p "$OPENCLAW_STATE_DIR"
name="${OPENRIG_RESUME_REF:-${OPENRIG_CONVERSATION:-}}"
if [ -z "$name" ]; then
echo "openrig-openclaw-launch: OpenRig passed no conversation name; is names_conversation set in the preset?" >&2
exit 2
fi
case "$name" in
agent:main:*) key="$name" ;;
agent:*) echo "openrig-openclaw-launch: this example supports only the main OpenClaw agent" >&2; exit 2 ;;
*) key="agent:main:$name" ;;
esac
# Preserve the file's exact bytes, including trailing newlines. A read error stops before the harness starts.
if [ -n "${OPENRIG_FIRST_INPUT:-}" ]; then
first_input=$(cat "$OPENRIG_FIRST_INPUT" && printf '.')
first_input=${first_input%.}
set -- "$@" --message "$first_input"
fi
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
if [ "$mode" = gateway ]; then
params=$(KEY="$key" node -e 'process.stdout.write(JSON.stringify({ key: process.env.KEY, agentId: "main" }))')
if listing=$("$openclaw" gateway call sessions.describe --params "$params" --json); then listed=true; else listed=false; fi
else
if listing=$("$openclaw" sessions --agent main --json --limit all); then listed=true; else listed=false; fi
fi
if [ "$listed" = false ]; then
echo "openrig-openclaw-launch: this seat's OpenClaw sessions couldn't be listed; not starting" >&2
exit 4
fi
if printf '%s' "$listing" | KEY="$key" MODE="$mode" node -e '
let raw = ""; process.stdin.on("data", (chunk) => { raw += chunk; });
process.stdin.on("end", () => {
try {
const data = JSON.parse(raw);
if (process.env.MODE === "gateway") {
if (data.session === null) process.exit(3);
process.exit(data.session?.key === process.env.KEY ? 0 : 4);
}
const { sessions } = data;
if (!Array.isArray(sessions)) process.exit(4);
process.exit(sessions.some((s) => s?.agentId === "main" && s.key === process.env.KEY) ? 0 : 3);
} catch { process.exit(4); }
});'; then
:
else
result=$?
if [ "$result" -eq 3 ]; then
echo "openrig-openclaw-launch: this seat's OpenClaw sessions don't list \"$key\"; not starting a new one under that key" >&2
else
echo "openrig-openclaw-launch: this seat's OpenClaw sessions couldn't be listed; not starting" >&2
fi
exit "$result"
fi
fi
if [ "$mode" = gateway ]; then exec "$openclaw" tui --session "$key" "$@"; fi
exec "$openclaw" tui --local --session "$key" "$@"
@@ -1,100 +0,0 @@
#!/bin/sh
# A reference launcher for Pi's own terminal UI (interactive `pi`) as an OpenRig community harness. Copy it, own it, and
# change it: OpenRig doesn't ship or run it. It's written for Pi 1.4.0 from its source and not yet checked with a real Pi.
# It follows docs/reference/community-harness-presets.md (`names_conversation`, `first_input`):
# - a new launch (OPENRIG_CONVERSATION set): Pi creates the session whose id is that name, in the seat's own session
# folder, and takes the first input as its first prompt;
# - a resume (OPENRIG_RESUME_REF set): the launcher opens the one session file whose id is that name, and starts nothing
# when there isn't exactly one. Pi's --session-id creates a new session for an id it doesn't find, and --session with
# an id can open another session whose id starts the same way, so neither is used to resume.
set -eu
pi="${OPENRIG_PI:-pi}"
state="${OPENRIG_HOME:-$HOME/.openrig}/state/pi-tui/${OPENRIG_NODE_ID:-${OPENRIG_SESSION_NAME:-default}}"
sessions="$state/sessions"
# Each seat gets its own Pi agent folder (settings, logins, skills) unless PI_CODING_AGENT_DIR is already set, so seats
# don't share settings and a person's ~/.pi/agent isn't touched. Give the seat its provider key in its environment, or its
# login and settings in this folder, before its first start.
PI_CODING_AGENT_DIR="${PI_CODING_AGENT_DIR:-$state/agent}"
# Pi expands a leading ~ in this folder; do the same, so the skills land where Pi looks.
case "$PI_CODING_AGENT_DIR" in
"~") PI_CODING_AGENT_DIR="$HOME" ;;
"~/"*) PI_CODING_AGENT_DIR="$HOME/${PI_CODING_AGENT_DIR#"~/"}" ;;
esac
export PI_CODING_AGENT_DIR
mkdir -p "$PI_CODING_AGENT_DIR/skills" "$sessions"
# Copy the installed OpenRig's coordination skills in at each launch, so an upgrade reaches the seat. Pi lists the skills
# in <agent folder>/skills/ by itself; the seat's first input should still name queue-handoff (see the recipe).
skills_from="${OPENRIG_PI_SKILLS_FROM:-${OPENRIG_HOME:-$HOME/.openrig}/plugins/openrig-core/skills}"
set -f
for skill in ${OPENRIG_PI_SKILLS:-queue-handoff}; do
case "$skill" in
*[!a-z0-9-]*|-*) echo "openrig-pi-tui-launch: skipping '$skill': not a skill name" >&2; continue ;;
esac
if [ -d "$skills_from/$skill" ]; then
rm -rf "$PI_CODING_AGENT_DIR/skills/$skill"
cp -Rp "$skills_from/$skill" "$PI_CODING_AGENT_DIR/skills/"
else
echo "openrig-pi-tui-launch: no $skill skill in $skills_from; starting without it" >&2
fi
done
set +f
# Pi's session ids: letters, digits, '.', '_' and '-', starting and ending with a letter or digit.
valid_id() {
case "$1" in
"" | *[!A-Za-z0-9._-]* | [!A-Za-z0-9]* | *[!A-Za-z0-9]) return 1 ;;
esac
}
# The first input goes over as Pi's first prompt, byte for byte, after `--`. OpenRig's starts with its identity-context
# line; Pi would read one starting with @ as a file even after `--`, and may run one starting with / as a command.
set --
if [ -n "${OPENRIG_FIRST_INPUT:-}" ]; then
first_input=$(cat "$OPENRIG_FIRST_INPUT" && printf '.')
first_input="${first_input%.}"
if [ -n "$first_input" ]; then set -- -- "$first_input"; fi
fi
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
id="$OPENRIG_RESUME_REF"
if ! valid_id "$id"; then
echo "openrig-pi-tui-launch: '$id' isn't a Pi session id; starting nothing" >&2
exit 2
fi
# Pi names a session file <time>_<id>.jsonl, but the file's first line, its header, is what says which session it is:
# its top-level type and id, read as JSON as Pi reads it. Pi writes the file only once the conversation has a message, so
# a session that never got one can't be reopened. Pi runs on Node, so node reads the header.
node="${OPENRIG_PI_NODE:-node}"
if ! command -v "$node" >/dev/null 2>&1; then
echo "openrig-pi-tui-launch: $node isn't on PATH to read Pi's session headers; starting nothing" >&2
exit 1
fi
found="" count=0
for file in "$sessions"/*_"$id".jsonl; do
[ -s "$file" ] || continue
if head -n 1 "$file" | "$node" -e '
let text = ""; process.stdin.on("data", c => { text += c; }).on("end", () => {
try { const h = JSON.parse(text); process.exit(h && h.type === "session" && h.id === process.argv[1] ? 0 : 1); }
catch { process.exit(1); }
});' "$id"; then
found="$file" count=$((count + 1))
fi
done
if [ "$count" -ne 1 ]; then
echo "openrig-pi-tui-launch: expected one Pi session $id in $sessions, found $count; starting nothing" >&2
exit 1
fi
exec "$pi" --session-dir "$sessions" --session "$found" "$@"
fi
if [ -n "${OPENRIG_CONVERSATION:-}" ]; then
if ! valid_id "$OPENRIG_CONVERSATION"; then
echo "openrig-pi-tui-launch: '$OPENRIG_CONVERSATION' isn't a Pi session id; starting nothing" >&2
exit 2
fi
exec "$pi" --session-dir "$sessions" --session-id "$OPENRIG_CONVERSATION" "$@"
fi
echo "openrig-pi-tui-launch: OpenRig passed no conversation name; is names_conversation set in the preset?" >&2
exit 2
@@ -1,15 +0,0 @@
# Install as $OPENRIG_HOME/community-harnesses/pi-tui.yaml, with openrig-pi-tui-launch on the seat's PATH.
# "pi" itself is the built-in managed Pi runtime, so this preset for Pi's own terminal UI takes another name.
format: 1
name: pi-tui
command: openrig-pi-tui-launch # no model or provider here: they go in the seat's Pi settings or environment
maintainer: Your name or team
limits:
- checked once with Pi 1.4.0 and OpenRig 0.7.4 (one run, one model); other Pi versions aren't checked
- resume not declared until reopening a named session, and a missing one, have been checked with a real Pi
- with the default typing guard, a wake submits a person's half-typed draft in Pi's editor together with the wake; run the seat unattended, not where a person also types
- a send while a Pi dialog OpenRig doesn't recognize is open, such as /settings, types into the dialog
- how Pi takes a wake that arrives while it's busy isn't shown
- only the listed openrig-core skills are copied into the seat's Pi agent folder; no guidance or startup files are placed
names_conversation: true
first_input: true
-351
View File
@@ -1,351 +0,0 @@
# Community harness presets
A community harness is a coding harness an outside contributor maintains. You install it as one small data file, and a
rig member runs it by naming it as its `runtime`. OpenRig doesn't test these harnesses. It shows them as community
harnesses, and it reads their activity only from what the screen shows.
## What a community harness gets
What OpenRig does for a community-harness seat, capability by capability. **Partly** and **no** mean OpenRig shows that it doesn't know;
it never reads an unsupported capability as idle, working, ready or verified.
| Capability | Supported | How it works, or what you see instead |
|---|---|---|
| Launch | yes | OpenRig types the preset's `command` into the seat's pane, with its variables passed through `/usr/bin/env`. The seat reads as ready once something other than a shell runs in the pane; that's a process check, not proof of which conversation it runs. With `startup_assisted`, the seat reports attention required until a person checks it. |
| Delivery | yes | `rig send` and queue wakes paste the text into the pane and press Enter, as for any seat. A pane that's back at a shell is refused (`target_runtime_unverified`) and nothing is typed. OpenRig doesn't confirm the harness took the message; `rig send --verify` checks the pane afterwards. |
| Identity | when declared | With `launch_result`, the conversation is shown as reported by its launcher; with `names_conversation`, as named by OpenRig. Neither is observed by OpenRig. Without either, the identity is unavailable. |
| Resume | when declared | `resume: true` with one of the two above (see [Reopening a conversation](#reopening-a-conversation-resume-true)). Without it, a resume says `Resume is unsupported: community harness "<name>" is launch-only`, and a restore stops and asks. |
| Activity | partly | Read from the screen only; there are no activity hooks. Work and attention the screen shows are kept, but a screen that looks idle reads `unknown`, never idle, because a quiet prompt can be an approval or a pause. |
| Attention | partly | Read from the screen: a permission question or numbered choice that OpenRig recognizes reads as needing attention, and `rig send` won't type into it. A prompt it doesn't recognize reads `unknown`. |
| Approval | no | Approvals stay in the harness's own pane. OpenRig relays none, so a person answers them there. |
| Cancel | no | OpenRig has no verb that cancels a running turn. `rig seat stop` and `rig down` end the seat's tmux session; anything the harness runs outside its pane, such as a gateway, is its own, and the preset's `limits` should say what keeps running. Restarting in place and switching harness are refused. |
| Transport | yes | The seat is a tmux pane like any other, so `rig capture`, `rig send` and `--host` work as they do for any seat. |
**Not placed:** guidance, skills and startup files (a launch warns that they weren't delivered), and the model: set it in the harness's own
configuration.
**Check that the harness answers on its own first.** Before writing a preset, start the harness in a terminal outside OpenRig, with the
same configuration and credentials your launcher will give it, and send it one prompt. Use the folder your launcher gives the seat, not
the harness's default one: the example launchers keep one per seat under `$OPENRIG_HOME/state/<harness>/` (`HERMES_HOME`,
`OPENCLAW_STATE_DIR` or `CURSOR_CONFIG_DIR`), so a check with your own defaults can pass while the seat's setup fails. If no answer comes
back, the problem is in the harness's own setup, such as its model, provider or login, and relaunching an OpenRig seat won't fix it. Fix
it in the harness, then connect it.
## The preset
One YAML file per harness: `$OPENRIG_HOME/community-harnesses/<name>.yaml`. `rig community-harness list` shows what's
installed.
```yaml
format: 1
name: example-harness # must match the file name: lowercase letters, digits and hyphens
command: example-launch # one program and its arguments, typed into the seat's pane
maintainer: Example Maintainer
limits: # what the maintainer says doesn't work
- fork unsupported
launch_result: true # optional: the launcher reports the conversation it created (below)
# names_conversation: true # optional, instead of launch_result: OpenRig names the conversation (below)
resume: true # optional, needs one of those two; declare it only once your harness's resume is checked
startup_assisted: true # optional: startup submission needs a person's check (below)
first_input: true # optional: launcher takes the first turn from OPENRIG_FIRST_INPUT (below)
```
- **`command`** is a single program and its arguments. Shell syntax such as `|`, `&&`, `;`, redirections or `$(…)`
is refused: put it in a script and name the script.
- **Built-in runtime names are reserved.** A preset can't replace `claude-code`, `codex` or the other built-ins.
- **Unknown fields are refused,** so a preset written for a newer OpenRig says so instead of half-working.
- **Fork isn't supported, and resume only as described below.** Neither is placing skills, guidance or startup files. Except
for assisted startup below, the seat is called ready while something other than a shell runs in its pane, which says
nothing about which conversation it is.
### Assisted startup
Set `startup_assisted: true` when startup submission needs a person's check. The seat may start its first turn
automatically, including from input passed at launch with `first_input: true`. This declaration requires a check after
the submission attempt; it does not hold the agent back. Without `first_input`, OpenRig pastes the startup text and
presses Enter once.
After attempting startup input, OpenRig reports **attention required**: check the pane and submit any text still in the
composer. OpenRig does not resend the identity prompt. Once the seat responds, use the reported
`rig seat clear-attention <session> --reason "Startup submission checked; seat responded"` command to acknowledge the
check. A launch with no startup text needs no such check. Other presets keep their existing behavior.
## Reporting the conversation a launch created (`launch_result: true`)
Some harnesses create their own conversation id before they start. Cursor's `cursor-agent create-chat` is one. With
`launch_result: true`, the harness's launcher tells OpenRig that id for each launch.
1. **OpenRig names one file per launch attempt.** It starts `command` with two variables:
- `OPENRIG_LAUNCH_RESULT`, a new file path inside `$OPENRIG_HOME/state/launch-results/`;
- `OPENRIG_LAUNCH_ATTEMPT`, this attempt's id.
2. **The launcher writes one JSON record to that path,** then starts the harness:
```json
{ "format": 1, "attempt": "<OPENRIG_LAUNCH_ATTEMPT>", "harness": "cursor", "nativeRef": "<the conversation id>", "producer": "openrig-cursor-launch 1.0" }
```
3. **OpenRig reads that attempt's record once,** waiting up to 20 seconds for it. It records `nativeRef` on the session,
and the seat shows its identity as **reported by its launcher, not observed**. That's what the launcher says, not
proof that the harness used it.
The seat still starts, with its identity **unavailable** and the reason named, when the record:
- wasn't written;
- belongs to another attempt or another harness;
- isn't JSON, isn't format 1, or is larger than 64 KiB;
- has an unusable `nativeRef` (empty, too long, or containing spaces or control characters);
- names no producer;
- or is reached through a link.
None of these means the conversation is gone.
A reported id doesn't make the harness resumable on its own. Without `resume: true`, nothing resumes it: a restore
stops and asks, as it did before (re-run it with `--fresh <seat>` only if you want a new conversation), and elsewhere a
resume says it's unsupported, and a new conversation starts only where the caller allows a fresh start.
**Good to know:**
- **The variables are passed with `/usr/bin/env`,** so `launch_result` needs a POSIX system.
- **A launcher that never writes its record** adds up to 20 seconds to each launch, while OpenRig waits for it.
- **Records are kept,** one small file per attempt, under `$OPENRIG_HOME/state/launch-results/<session>/`. A record
larger than 64 KiB isn't read.
## Letting OpenRig name the conversation (`names_conversation: true`)
Some harnesses take a conversation name from the caller and create that conversation, or reopen it. Hermes's
`hermes chat -c <name> --create-if-missing` is one. With `names_conversation: true`, OpenRig names each new conversation
and the harness uses that name, so no record is needed.
- **A new launch:** OpenRig starts `command` with `OPENRIG_CONVERSATION` set to a new name, `openrig-<seat>-<8 hex digits>`
(at most 100 characters). Each launch gets its own name, so two seats never share a conversation.
- **What the seat shows:** OpenRig records that name on the session, and the seat shows its identity as **named by
OpenRig, not observed**. That's the name OpenRig passed, not proof that the harness used it.
- **Not with `launch_result`:** a preset declares one or the other. Either the launcher reports the conversation, or
OpenRig names it.
## Reopening a conversation (`resume: true`)
**A named conversation:** with `names_conversation`, a resume starts `command` with `OPENRIG_RESUME_REF` set to the saved
name. The harness reopens it and must never create one under that name. Nothing reports back, so:
- **readiness is process-only:** the seat reads as ready while something other than a shell runs in its pane. That
doesn't show the named conversation exists: a harness (or a launcher's own check) that hasn't yet found it gone reads
as ready, and the saved name stays unverified until something observes it;
- **never creating one is the launcher's job:** a resume must not create a conversation under the saved name. When it's
gone, the harness or launcher ends with its error and the pane is back at a shell; the seat keeps its saved name, and
OpenRig starts nothing new, though the seat may already have read as ready;
- **a former runtime's id** isn't passed to the harness, as below.
**A reported conversation:** with `launch_result`, a resume (a restore included) starts the launcher with `OPENRIG_RESUME_REF` set to the
conversation to reopen. The launcher reports what happened in the same attempt's file:
- **Reopened:** the usual record, with that conversation as `nativeRef`.
- **Gone:** `{ "format": 1, "attempt": "…", "harness": "cursor", "error": "missing", "producer": "…" }`. The launcher
starts nothing. OpenRig reports the conversation missing and starts nothing either: it never replaces it with a new one.
For a reported conversation, OpenRig also says so plainly when:
- **no usable record is written:** the resume isn't confirmed. The seat needs attention and keeps the conversation it
asked for; nothing is restarted;
- **the record names a different conversation:** the seat needs attention and keeps the conversation it asked for.
OpenRig doesn't switch it to the reported one;
- **the saved conversation isn't this harness's own** (a former runtime's id, say): it isn't passed to the launcher. A restore stops and asks, and elsewhere the resume says it isn't possible;
- **the record's folder can't be made:** the launcher couldn't be told what to reopen, so nothing starts.
## An example: Hermes
[`community-harness-examples/hermes/`](community-harness-examples/hermes/) has a preset and a small launcher for Hermes
Agent's CLI.
- **The launcher:** it creates the session OpenRig named (`-c <name> --create-if-missing`), and on a resume reopens it
without `--create-if-missing`, with its own Hermes home per seat. A name that matches nothing ends with Hermes's own
"No session found" error.
- **Settings go in the seat's Hermes `config.yaml`, not in flags:** Hermes's CLI creates a named session with no launch
flags and then reopens it, refusing any creation flag (model, provider, `--max-turns`) that differs. So the preset's
command carries none, and each seat's `config.yaml`, in its Hermes home, sets them before the seat's first start:
the model and provider, and `agent.max_turns` if you want a cap (nothing else sets it once the flag is gone).
- **Let Hermes run `rig` as the seat:** Hermes runs every tool command in its gateway, one per Hermes home, so with
this launcher one per seat (a two-seat run showed separate gateways, homes and sockets, and stopping one seat left
the other's running). It runs them with that gateway's
environment, and swaps in a session's own values only for its toolchain names and the names its profile lists in
`terminal.client_env_passthrough`. So put the seat's OpenRig variables in that list, in each seat's `config.yaml`
([`config.yaml.example`](community-harness-examples/hermes/config.yaml.example)). Without it, `rig` inside Hermes
runs as whichever seat started the gateway, or as no seat; with it, each seat runs `rig` as itself. To check, ask the
seat's Hermes to run `rig whoami --json`. The launcher warns when a seat's `config.yaml` doesn't pass
`OPENRIG_SESSION_NAME`. This covers Hermes's CLI and TUI, whose clients pass their environment; its ACP clients pass
none.
- **The preset:** it declares `names_conversation` but not `resume`, until reopening a named session, and a missing one,
have been checked with a real Hermes.
- **Stopping:** Hermes's CLI is a client of a gateway, so closing a seat's pane detaches it. Hermes finishes any turn it
accepted. Whether the seat's gateway then exits once idle hasn't been checked: check with `ps` that a stopped seat's
gateway has gone before relying on it. The preset says so in its limits.
Copy them and maintain them as your own.
## An example: OpenClaw
[`community-harness-examples/openclaw/`](community-harness-examples/openclaw/) has a preset and a small launcher for
OpenClaw's CLI.
- **Choose the mode:** `openclaw.yaml` runs an embedded agent with `tui --local`. `openclaw-gateway.yaml` runs a TUI client of an existing Gateway. Configure that client's Gateway route and authentication through `OPENCLAW_CONFIG_PATH`; the launcher never starts or stops the Gateway.
- **The conversation:** both examples use only agent `main`, mapping OpenRig's name to the full native key `agent:main:<OpenRig name>`. Each seat has its own client state folder.
- **Its resume check:** local mode checks `sessions --agent main --json --limit all`; Gateway mode calls the selected Gateway's `sessions.describe`. Both require the exact canonical key. An unavailable lookup stops the launch; a confirmed missing key is never recreated. A client's local cache cannot authorize Gateway resume.
- **Stopping:** a Gateway client's pane closing detaches it. The Gateway and an accepted turn can keep running; stopping that service is separate. Local mode runs its embedded agent in the pane.
- **Unattended seats:** prefer Gateway mode within the capabilities qualified for your installed version. Local interrupted-turn recovery is not supported by these launchers.
- **Local interruption:** Finish the current local turn before stopping; an interrupted local turn may retain native recovery work and refuse the next request. This launcher does not repair or retry it. OpenRig recognizes the recorded local “changed while starting work” error frame as needing attention: “OpenClaw turn not recovered; inspect the pane before retrying”. Use `rig capture <session>` to inspect the native conversation. An ordinary send still submits a new turn, with an advisory that the last local turn was not recovered; it is not an automatic replay of that turn. It does not clear native recovery records, repeat the request or start a fresh conversation; a successful process launch is not proof that the turn recovered.
- **Local startup after resume:** OpenClaw may run its database integrity check during startup. A reported resumed process does not confirm readiness for the next request; check that the TUI has finished starting before sending work.
- **Resets must stay off:** automatic session resets can create a new conversation under the same key. Typing `/new` also moves to a fresh key while the seat keeps the name OpenRig gave it.
- **The presets:** both declare `names_conversation`, `resume` and `first_input`. The launcher passes the generated first startup text through OpenClaw's `--message` option. OpenRig labels the saved name as passed, not observed by its adapter.
Copy the launcher and the preset for your mode, and maintain them as your own.
**Stopping a community seat stops its pane.** `rig down` names those seats ("Pane stopped: …"), since a harness's own
processes, a gateway say, may keep running after it. OpenRig doesn't stop them, and whether they survive isn't exercised
by OpenRig's tests, so check the harness's own processes after `rig down`. Each preset's limits say what to expect.
**Not supported for community harnesses:** restarting a seat in place (`rig seat restart` is for Codex seats) and
switching harness on a running seat. Each says so before anything changes.
## An example: Cursor
[`community-harness-examples/cursor/`](community-harness-examples/cursor/) has a preset and a small launcher for Cursor's
CLI.
- **The launcher:** it reports the chat `cursor-agent create-chat` makes, then reopens it. Each seat gets its own Cursor
config folder unless `CURSOR_CONFIG_DIR` is already set. Cursor keeps its chats and `cli-config.json` there, and its
login elsewhere.
- **The preset:** it declares `launch_result` and `resume`. Cursor's `--resume` with an id it doesn't hold starts a new,
empty chat without saying so, so on a resume the launcher first checks that the chat's store exists. When it doesn't, the
launcher reports `error: "missing"` and starts nothing.
- **First input at launch:** the preset declares `first_input: true` and `startup_assisted: false`. The launcher passes
the generated startup text as Cursor's positional first prompt. This route and same-chat resume were checked with
Cursor Agent 2026.10.22-27dcf5f. Later tool approvals use Cursor's native prompts; startup submission does not answer them.
- **Sender names come from the sending session.** A command run from an unattached operator shell can arrive as
`<unknown sender>`; that doesn't mean the receiving Cursor seat lost its identity.
Copy both and maintain them as your own.
## An example: Pi's terminal UI
[`community-harness-examples/pi-tui/`](community-harness-examples/pi-tui/) has a preset and a small launcher that run Pi's
own terminal UI as a community harness. `pi` is the built-in managed Pi runtime, so the preset is named `pi-tui`. OpenRig
delivers to this seat as to any community seat (the Delivery row above), not as to a plain `runtime: terminal` seat, which
queue wakes don't reach. Checked once, on Pi 1.4.0 with OpenRig 0.7.4 and one model: a parked row's wake reached Pi in
the same session (the same session file) and Pi resumed the work, and a handoff reached a second `pi-tui` seat, which
picked it up unattended. The first seat's task named the commands to use, so this shows a wake reaching Pi and Pi acting
on it, not Pi choosing by itself to park or hand work on.
- **The launcher:** each seat gets its own Pi agent folder (`$OPENRIG_HOME/state/pi-tui/<node>/agent`, unless
`PI_CODING_AGENT_DIR` is set) and its own session folder. At each launch it copies the installed `openrig-core` skills
it's given (`queue-handoff` by default) into that agent folder. A new launch starts `pi --session-id <the name OpenRig
gave>` and passes the first input as Pi's first prompt, byte for byte.
- **Resume isn't declared.** The launcher can reopen a named session: it opens the one session file whose header has that
id, and starts nothing when there isn't exactly one, because Pi's own `--session-id` creates a session for an id it
doesn't find. Declare `resume: true` only after a real Pi has reopened a named session and refused a missing one.
- **Its limits:** run a `pi-tui` seat unattended, not where a person also types into it.
- With the seat's [typing guard](seat-delivery.md) at its default, a wake submits a person's half-typed draft in Pi's
editor together with the wake. The guard's draft-aware mode isn't a known fix for this.
- A send while a Pi dialog OpenRig doesn't recognize is open, such as `/settings`, types into the dialog.
- Not shown: how Pi takes a wake that arrives while it's busy (a message sent during a busy turn was answered after
it), and other Pi versions.
- **Give the seat a model** before its first start: its provider key in its environment, or its login and settings in its
agent folder.
Copy both and maintain them as your own.
The launcher is the contributor's code, in the contributor's package. OpenRig runs no code from a preset and loads no
module into its daemon. A preset that sets `launch_result`, `names_conversation`, `startup_assisted` or `first_input` is refused by an older
OpenRig that doesn't know the field.
## Pass the first startup input at launch
A format-1 preset may set `first_input: true` (a boolean, default false). OpenRig composes the same initial identity turn it would otherwise paste, including the first merged text file, durable-obligation guidance and any selected orientation challenge. Before launch it writes that text to a per-attempt 0600 file under its own `state/first-input/` folder and passes the path as `OPENRIG_FIRST_INPUT`. The typed launch command carries the path, not the text; an argument-based launcher still puts the text in the native harness’s argv.
The contributor-owned launcher reads the file and uses its harness’s supported initial-prompt route: the Cursor example passes one positional prompt; OpenClaw uses `--message`. Core knows neither flag. If no initial identity turn is due (an ordinary resume, for example), the variable is absent and the launcher starts without a first message. Later startup files and actions use their existing delivery paths; they are not folded into the launch file.
OpenRig records `node.startup_first_input_passed` with the path and `submission: unconfirmed`. This says the input was passed at launch, not that the native harness submitted or read it. An uncertain launch is not permission to replay it. Readiness remains process-only. `startup_assisted` is independent: when declared, startup still requires a person’s acknowledgement, including when the first input was passed at launch.
Presets without `first_input` and built-in runtimes keep their existing behavior. Older OpenRig versions reject this new field as unknown. The private input file remains for diagnostics; it can contain private startup context. A launcher that turns its contents into one argument is subject to the platform’s argument-size limit; the launcher must report a launch error rather than silently truncate the turn. The examples’ argument transport is covered with stand-ins; native unattended startup still requires qualification with the installed harness.
## Interactive Pi in a terminal seat: the coordination skills
This is for a seat that runs interactive `pi` from a `runtime: terminal` member's startup, or from a community preset's
launcher. It isn't for a managed `runtime: pi` seat, which already copies its selected skill directories into the seat's own
Pi agent directory (`$OPENRIG_HOME/state/pi/<session>/agent/skills/`), or for Workshop, whose Pi profiles carry pinned skill
copies (openrig-world's `PI-SKILLS.md`). The [`pi-tui` example](#an-example-pis-terminal-ui) above does step 1 for its
seats in its launcher.
OpenRig installs no skills for a terminal or community seat: it skips their resources and startup files, and `rig skill
loadout` covers Claude Code and Codex only. Without the coordination skills, the agent there doesn't know the queue's habits,
such as parking a row that waits on CI instead of leaving it in progress, or handing work on so the next row names its
source. The seat's setup does two things.
1. **Make the released skills readable.** The installed OpenRig's own plugin carries them, matching the installed release:
`rig plugin list` shows `openrig-core` and its path (usually `$OPENRIG_HOME/plugins/openrig-core`), and the skills are in
its `skills/` folder. Copy whole skill directories, keeping their helper files and modes, into the `skills/` folder of the
Pi agent directory that seat uses (`PI_CODING_AGENT_DIR`, or `~/.pi/agent` by default). At least `queue-handoff`:
```bash
CORE="$(rig plugin list | awk '$1 == "openrig-core" {print $NF}')"
PI_AGENT_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
mkdir -p "$PI_AGENT_DIR/skills"
cp -Rp "$CORE/skills/queue-handoff" "$PI_AGENT_DIR/skills/"
```
Copy again after each OpenRig upgrade: a copy doesn't follow the plugin.
2. **Point the agent at it when it starts.** Don't count on Pi finding the skill by itself; name it in the seat's first turn.
For example:
> Before you work your queue, read `<Pi agent directory>/skills/queue-handoff/SKILL.md` in full: it's how this team hands
> work on, parks it and waits. When you wait on CI or a long job, park the row with `rig queue block <id> --on
> external:<what> --wake-after <duration> --continuation "<what resumes>"`.
- A community preset with `first_input: true` carries it in the seat's first input when the file is declared with
`delivery_hint: send_text` (see "Pass the first startup input at launch" above). A `.md` file left at the `auto` hint
counts as guidance, which a community seat doesn't take.
- A plain terminal seat can pass it as Pi's initial message in the command its startup runs. Check `pi --help` for that
argument in your Pi version.
- `first_input` carries text, not files: it doesn't install the skills.
**In a plain terminal seat, a queue wake doesn't reach Pi.** OpenRig doesn't treat a `runtime: terminal` seat as an agent
seat. When a parked row's wake fires, OpenRig doesn't type it into Pi, and a handoff to that seat records a failed wake
(`failed:terminal-seat`). Parking still records the wait, but `--wake-after` won't bring Pi back. Check the seat's parked
rows yourself (`rig queue whoami --session <seat>`, or `rig queue show <id>`), or tell Pi to continue the named row.
**What this shows, and what it doesn't.** A copied folder and a startup line put the text in front of the agent. They don't
show that Pi discovered the skill by itself, or that the agent follows it. Check those separately: that the agent read the
whole file (its tool trace), and what its rows record, such as a CI wait parked on `external:` with a wake, and a handoff
made with `handoff-and-complete`, whose next row names it as its source.
## Write your own
Start from the example closest to your harness, and change it. First check that the harness answers a prompt on its own
([above](#what-a-community-harness-gets)).
1. **Choose how the conversation is named.**
- **Your harness creates an id you can read before it starts,** as Cursor's `create-chat` does: declare `launch_result: true`, and the
launcher writes the record.
- **Your harness takes a name from its caller and creates or reopens it,** as Hermes's `-c <name>` and OpenClaw's session key do: declare
`names_conversation: true`, and the launcher passes `OPENRIG_CONVERSATION` on.
- **Neither:** leave both out. The seat starts and works, its identity shows as unavailable, and it can't be resumed.
2. **Write the launcher:** a small script your package installs on the seat's `PATH`. It reads OpenRig's variables, starts the harness with
`exec`, and does nothing else.
- `OPENRIG_CONVERSATION` with `names_conversation`, or `OPENRIG_LAUNCH_RESULT` and `OPENRIG_LAUNCH_ATTEMPT` with `launch_result`.
- **On a resume,** `OPENRIG_RESUME_REF`: reopen exactly that conversation. Check that it exists first, and when it doesn't, end with an
error (or report `error: "missing"`) instead of creating one.
- **With `first_input: true`,** `OPENRIG_FIRST_INPUT`: pass the file's exact bytes through your harness's own first-prompt route.
- **Keep each seat's harness state apart** (Cursor's config folder, Hermes's home, OpenClaw's state folder), so two seats never share a
conversation.
3. **Write the preset** at `$OPENRIG_HOME/community-harnesses/<name>.yaml`, and check it with `rig community-harness list`.
- Declare `resume` only after checking, with the real harness, that a reopen works and that a missing conversation is refused.
- List what doesn't work under `limits`.
4. **Try it in a rig:** give a member `runtime: <name>`, then go through the checks in the promotion test below.
5. **Publish the preset and the launcher in your own package,** and maintain them. OpenRig runs no code from a preset, so the launcher is
yours.
## The promotion test
A community harness stays a community harness: you maintain it, and OpenRig shows it as one. **Promotion** means OpenRig takes a harness on
as a built-in runtime that it maintains and tests itself. **Promotion is decided by the OpenRig maintainers, on evidence from OpenRig's own
testing.** Passing these checks isn't automatic promotion, and it isn't a promise of upstream support.
What the maintainers look at:
1. **Everything the preset declares works on a real install,** end to end through OpenRig:
- a seat starts and takes its first turn (`rig up`);
- a message arrives (`rig send`), and the reply can be read (`rig capture`);
- the seat stops (`rig down`) without leaving work running that the person can't see, or the preset's `limits` say what keeps running;
- with `resume`, `rig up <rig> --existing` reopens the same conversation, and a missing one is refused, never replaced.
2. **What it doesn't support reads as not known,** never as idle, working, ready or verified.
3. **Its `limits` are true,** and name anything a person would otherwise be surprised by.
4. **The built-in runtimes are unchanged.**
5. **It's maintained:** a named maintainer, and a launcher that keeps working with the harness's current release.
There's no score or threshold. The maintainers weigh the evidence.
+11 -40
View File
@@ -1,8 +1,7 @@
# Edge Types Reference
Historical whole-document validation: 2026-10-07, at the 0.6.6 cut `2620dea8` (whole document at `fcaf1f8e`).
The fallback escalation section and deferred-target exceptions below describe #605.
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/rigspec-instantiator.ts`, `packages/daemon/src/domain/queue-owner.ts`, `packages/daemon/src/domain/escalation-owner.ts`
Last validated against code: 2026-10-07, at the 0.6.6 cut `2620dea8` (whole document at `fcaf1f8e`; its sources unchanged in edge behaviour since then)
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/rigspec-instantiator.ts`, `packages/daemon/src/domain/queue-owner.ts`
---
@@ -17,7 +16,8 @@ See `docs/reference/rig-spec.md` for the YAML syntax.
## Edge Kinds
Five edge kinds are accepted by both the pod-aware and legacy flat-node validators:
Five edge kinds are accepted by the pod-aware validator (the legacy flat-node format accepts only `delegates_to`,
`spawned_by` and `can_observe`):
| Kind | Accepted | Has Runtime Behavior | Description |
|------|----------|---------------------|-------------|
@@ -25,13 +25,13 @@ Five edge kinds are accepted by both the pod-aware and legacy flat-node validato
| `spawned_by` | yes | **yes — affects launch order** | Source was spawned by target |
| `can_observe` | yes | no | Source can observe target's output |
| `collaborates_with` | yes | no | Peer collaboration |
| `escalates_to` | yes | **yes — fallback queue escalation** | Source escalates to target |
| `escalates_to` | yes | no | Source escalates to target |
## What Edges Actually Do Today
### Launch ordering
Only `delegates_to` and `spawned_by` constrain the order in which nodes are launched:
Only `delegates_to` and `spawned_by` affect runtime behavior. They constrain the order in which nodes are launched:
- **`delegates_to`**: source launches BEFORE target. The delegator must be up before the delegate.
- **`spawned_by`**: target (parent) launches BEFORE source (child). The parent must be up before the child it spawned.
@@ -46,42 +46,13 @@ All other edge kinds (`can_observe`, `collaborates_with`, `escalates_to`) do NOT
wake ladder escalates an unacknowledged row to that orchestrator, and stuck-row findings and recovery go to it. A seat
with no incoming `delegates_to` edge has no default orchestrator. `spawned_by` plays no part here.
### Fallback escalation across rigs
A seat with **no declared `delegates_to` parent** may declare one `escalates_to` destination. The wake ladder and
stuck sweep use it for new recovery rows; they do not move the original obligation or reassign an already-open recovery. A stopped or unbound declared
parent still takes precedence, preserving the existing parent route and its fallback.
```yaml
edges:
- kind: escalates_to
from: work.lead
to: review-advisor@coord
```
The target is a canonical same-daemon seat address (`pod-member@rig`), or an existing local member using the usual
edge syntax. Named targets are stored without requiring the other rig to exist and resolved when needed. A matching
physical session name takes precedence over a derived canonical address. Deferred declarations survive export and
snapshots; they don't constrain launch order or make the rigs depend on each other's lifecycle.
Delivery is one hop. Recovery rows carry `escalation-route:one-hop`, so a destination's own `escalates_to` declaration
does not forward the recovery again. A missing or stopped destination, multiple matches or declarations, self route,
or declared cycle preserves the existing fallback: the wake ladder proceeds to its operator rung; the sweep retains
its existing owner fallback. The recovery body/transition explains the result. Cycle inspection includes stopped
seats and delegation parents; an unresolved downstream endpoint is reported as unknown, never as proof of no cycle.
Queue address admission is unchanged. Cross-host and synthetic destinations are not supported.
On an existing rig, use `rig edge add <rig> work.lead review-advisor@coord --kind escalates_to`; `--plan` writes nothing.
The receipt's target node ID is null for a deferred address. Remove it with the exact edge ID returned by add.
A rig-local graph omits deferred targets, since those seats are outside that graph; the raw topology and export retain them.
### Graph visualization
Local-node edges are rendered in the UI topology graph; deferred cross-rig targets are omitted. They create visual connections between nodes, helping the operator understand the team structure. Pod-local edges appear within the pod group; cross-pod edges connect across groups.
All edges are rendered in the UI topology graph. They create visual connections between nodes, helping the operator understand the team structure. Pod-local edges appear within the pod group; cross-pod edges connect across groups.
### Identity projection
Local-node edges appear in `rig whoami --json` output under `edges.outgoing` and `edges.incoming`, with their kind and the connected peer's identity. This lets an agent know its relationship to other members — but the agent interprets this information, it's not enforced by the runtime.
All edges appear in `rig whoami --json` output under `edges.outgoing` and `edges.incoming`, with their kind and the connected peer's identity. This lets an agent know its relationship to other members — but the agent interprets this information, it's not enforced by the runtime.
### Attach hint heuristic
@@ -93,7 +64,7 @@ node, in creation order, whose session is running, or else the first node with a
Edges do NOT currently:
- **Route messages** — `rig send` can target any session, regardless of edges (queue escalation, above, is the one
daemon path that follows `delegates_to` or fallback `escalates_to`)
daemon path that follows `delegates_to`)
- **Enforce delegation** — an agent can communicate with any peer, not just its edge targets
- **Control permissions** — there's no edge-based access control
- **Affect transport** — `rig capture`, `rig broadcast`, etc. work based on session identity, not edge topology
@@ -157,7 +128,7 @@ If none fit, `can_observe` is the safest default — it documents the relationsh
## Validation Rules
1. Edge kinds must be one of: `delegates_to`, `spawned_by`, `can_observe`, `collaborates_with`, `escalates_to`
2. Pod-local edges use unqualified member IDs — both endpoints must exist in the same pod, except a named `escalates_to` target as above.
3. Cross-pod edges use `pod.member` format — both endpoints must resolve to actual members, except a named `escalates_to` target as above.
2. Pod-local edges use unqualified member IDs — both `from` and `to` must exist in the same pod
3. Cross-pod edges use `pod.member` format — both endpoints must resolve to actual members
4. Cross-pod edges must reference different pods (same-pod edges should use the pod-local syntax)
5. Self-edges are not validated at the schema level but are not meaningful
-165
View File
@@ -1,165 +0,0 @@
# Gateway participants: several people, shared channels, organizations and policy
OpenRig's gateway can register **several people** and **shared channels**, group them into **organizations** and
**groups**, give each readable **policy** text, and name a **default person**. Agents read the policy and apply it;
OpenRig doesn't enforce it. This page covers the commands, what the default is for, and what upgrading and rolling back
do.
## Participants
A participant is either:
- **a person**, registered with `rig gateway human add` as before, with their own Slack binding; or
- **a shared channel**: one vetted Slack channel registered as one participant, `<id>@external`, not a person per
sender.
People and channels share one address space: a channel can't take a person's id.
```bash
# An organization and a group inside it (one command each, create or update)
rig gateway org set acme --name "Acme" --policy "Answer Acme's customers within a working day. Never promise a date."
rig gateway group set support --organization acme --name "Support" --policy-file ./support-policy.md
# People: the second and later ones are allowed now
rig gateway human add sarah --display-name "Sarah" \
--binding "slack:main:file:/etc/openrig/slack.env:primary:handle=U0SARAH" --delivery-class B \
--organization acme --group support --policy "Sarah approves refunds up to 500." --default
# A shared channel
rig gateway channel add acme-customers --display-name "Acme customers" \
--binding "slack:main:file:/etc/openrig/slack.env:C0ACME" --organization acme
# See everyone, with organization, groups, the default and each participant's policy
rig gateway participants
rig gateway participants --organization acme --json
rig gateway human show sarah
```
Change a person's placement with `rig gateway human set <id> organization|groups|policy <value>` (`unset` clears it;
for policy, `@<path>` reads a file). Remove a channel with `rig gateway channel remove <id>`, which refuses while
non-terminal rows are addressed to it unless you pass `--force`.
## Policy is context, not enforcement
A participant's **effective policy** is its organization's text, then each of its groups', then its own, each labelled
with where it came from. Agents read it (`rig gateway participants --json`, `rig gateway human show --json`) and apply
it with judgment. It doesn't grant or block anything by itself, and a participant's name never implies a policy.
## The default person
Some things need a person without naming one: an escalation of stuck work, the digest of notices, Mission Control's
my-queue, a workflow's human step when no person is configured for it, and a pairing approval without `--human`. They
go to:
1. the default person, when one is chosen (`rig gateway default <id>`, or `--default` on `human add`);
2. otherwise the only registered person, so a one-person install needs no choice;
3. otherwise nobody, and each says so: an escalation reports `undeliverable:no-default-person`, the notice digest
waits, and the others name the command to run.
`rig gateway default` shows the current default; `rig gateway default --clear` clears the choice. Choosing a default
never changes `workspace.operator_seat_name`, which keeps its own meaning.
## In the TUI: whose queue, and who acts
The person at the terminal is the **primary user**: the default person, or, with several people and none chosen, the
person at the terminal unnamed. Whatever the TUI shows, its acts are theirs.
- **Opening:** with a chosen default, the TUI opens on that person's Feed. With none chosen, including a one-person
install that never chose one, it opens on the topology, as before.
- **Whose queue:** `whose <person>` shows one person's asks, `whose all` everyone's, and `whose default` the default
person's. The Feed's first line says whose queue it shows and who acts.
- **Acting on someone else's ask** (answer, approve or resolve from the TUI) records both: the primary user as the actor,
and the person asked in the audit notes and the transition. The asking seat reads, for example, "Answered by
mike@external, the primary user at the terminal, on sarah@external's ask". With several people and none chosen, the
actor is `human@host`.
- **Viewing Sarah isn't acting as Sarah.** Only Sarah, from her own channel, answers as Sarah. Without the primary
user's authority, one registered person can't answer, approve, deny or resolve what was asked of another; that's
refused (`answer_not_recorded`) and nothing changes.
- With no one registered, the TUI and its acts are as before, except on a shared channel's ask or a row parked on a
channel: the TUI shows the act as `human@host`, the person at the terminal, and it's recorded that way, never as the
channel.
- **A shared channel never acts.** Its messages are correspondence, so nothing it says answers, approves, denies or
resolves an ask, and a customer's earlier message in an ask's thread is never taken as its answer. A request without
the primary user's authority that acts as a channel, with any Mission Control verb (including drop, route, hold, hand
off and annotate), is refused (`answer_not_recorded`) and nothing changes.
- **Where the authority applies:** only on a request that carries no seat identity header, which is how the TUI calls
the daemon. Seats and the CLI send that header, so their acts stay theirs. This is the existing trusted-network
distinction, not an authentication check: a request that leaves the header out is taken as the terminal's, as any
such request already is.
## Shared-channel conversations
A registered shared channel lets people who aren't registered, such as a customer, talk to a seat, without registering
each of them.
**Setup:**
1. Invite the OpenRig Slack app to the channel.
2. Register the channel: `rig gateway channel add acme-customers --display-name "Acme customers" --binding
"slack:main:file:/etc/openrig/slack.env:C0ACME" --organization acme --policy-file ./acme-customers.md`.
3. Send its new messages to the support seat: `rig slack channel-map set support-rig C0ACME --inbound
support@support-rig`. Without a map, they go to the inbound destination.
A channel works on its own: no registered person is needed for it to converse.
**What the seat sees:** each message lands as a row from the channel (`acme-customers@external`). Its body ends with a
labelled block: the channel, its organization and groups, who wrote (the Slack user, marked as not a registered person),
and the channel's effective policy. The row is tagged `participant:acme-customers`, `shared-channel` and
`slack-sender:<user id>`. A registered person writing in the channel stays themselves; the block only says where they
wrote.
The channel's policy is the labelled block OpenRig adds at the end of the row, after the message. Anything above it is
the sender's own words, even if it looks like policy. `rig gateway participants --json` is the source.
**Answering:** reply into the customer's thread with an update to the channel:
```bash
rig queue create --destination acme-customers@external --human-intent update --reply-to <their row's id> \
--summary "Your invoice" --body "It's on your account page."
```
A later update with the same `--reply-to` lands in the same thread. An update without `--reply-to` posts to the channel
on its own. Posts to a channel mention nobody.
**What a channel can't do:** its messages are correspondence. They never answer, approve, close or resume anyone's ask.
You can still send an ask to the channel; the queue accepts it with a warning that nothing there will settle it, so read
the replies and close it yourself. Its questions show in the channel as a list, without buttons, and ask for a reply in
the thread.
## Where it's stored
- **People** stay exactly as before: one file per person under `$OPENRIG_HOME/gateway/humans/`, plus the generated
`humans.generated.yaml`. Nothing new is written into them.
- **Everything else** (the default, organizations, groups, each participant's placement and policy, and shared
channels) is in `$OPENRIG_HOME/gateway/participants.yaml`, written by the commands above.
- **A problem in `participants.yaml` never disables the person registry.** People still receive and answer their asks.
The commands above report the problem, and with several people no default is chosen until it's fixed.
- **A person removed by hand** leaves a warning in `rig gateway participants`, not a broken file. `rig gateway human
remove` also removes the person's placement, and the default if it was them.
## Upgrading
For a one-person install, nothing to do: it keeps working as before, that person stays the default, pending rows keep
their addresses, and no new file is written until you use one of the commands above.
A TUI older than the daemon still works: it sends the actor the daemon serves, the person asked, which is still their
own act. On a shared channel's ask the daemon serves `human@host`. An older TUI's approve there is recorded as
`human@host`, and its answer is refused (`answer_not_recorded`), because answering someone else's ask takes the primary
user's authority, which only a current TUI sends.
**If several people are already registered** (earlier versions listed hand-added people with an advisory), choose a
default with `rig gateway default <id>` after upgrading. Until you do, escalations report
`undeliverable:no-default-person` instead of reaching whoever's id sorted first, and the notice digest waits.
## Rolling back to 0.7.2 or earlier
Older versions don't read `participants.yaml`, and the person files carry nothing they don't understand, so:
- **every registered person still loads**, and each can still answer their own asks from Slack;
- **another person's reply is guarded less on 0.7.1 and 0.7.0:** there, a different person's Slack reply in the thread
of a question parked on someone else can resume that question, and 0.7.0 can record such a reply to a pending ask as
an answer row (it still can't close the ask);
- **there's no default:** escalations and the notice digest go to the first person by id, Mission Control's my-queue
has no default person, and pairing needs `--human`;
- **the TUI** opens on the topology and records a terminal answer as the person asked, as before. Rows already
answered by the primary user keep their notes;
- **shared channels are unknown:** their customers' messages are refused again, as from any unregistered sender, and
rows addressed to a channel become undeliverable on the older version;
- **organizations, groups and policy aren't shown.**
Upgrading again restores all of it, because the older version never changes `participants.yaml`.
+46 -401
View File
@@ -1,104 +1,32 @@
# OpenRig setup reference
# Getting started: meet the kernel, then make one useful change
This is the full reference for installing and running OpenRig, written for the agent doing the setup and for anyone
who wants the detail. The short version for people is [Getting started](quickstart.md).
Install OpenRig, use a working provider login, and meet the kernel operator with
the advisor and TUI beside it. No starter team is needed to reach that view.
Tell the operator what you want to do, then choose a project team for a bounded
change you can exercise. Reuse the accounts and terminal tools you already have.
## For the agent doing the setup
The `rigs` skill is the procedure: follow it in order. This page is what it looks things up in.
- **Done** means the person is talking to the operator in a visible OpenRig view. A healthy daemon, a started kernel
or an opened window isn't done on its own. If they'd rather talk later, the handoff is still pending: say so, and
leave them the exact command.
- **Read readiness; don't assume it:**
- **The kernel:** the `Kernel:` line in `rig status`, and `rig ps --nodes --rig kernel`.
- **A team:** `rig ps --nodes --rig <team>`.
- A missing or unknown signal isn't ready.
- `rig up` exiting 1 with `Status: partial` and `Startup attention` means a seat is waiting at a prompt. Answer it,
or ask the person to, then run the `rig seat continue <seat>` it names.
- **Prerequisites:** check the person's existing toolchain first (mise, nvm, Homebrew or the nodejs.org installer)
and the PATH. Use what's there. Don't change a shared Homebrew's ownership, and don't make a local workaround the
standard route. If Node.js is installed after your session started, check that new shells and the session's hooks
can find it. In one published-0.6.11 Mac run, OpenRig's Claude hooks reported `node: command not found` until the
PATH was fixed.
- **Ask once, then reuse the answer:** the provider (Claude Code, Codex or both), Herdr, Ghostty on macOS,
remembered command allowances, and the team.
- **Before changing their machine:** say what applies to their route, then wait for a yes. See
[what OpenRig changes on your machine](../../README.md#what-openrig-changes-on-your-machine).
- **When something stops:**
- [Incomplete setup and restart](#incomplete-setup-and-restart) maps what you see to the next action.
- [What can interrupt installation and the OpenRig view](#what-can-interrupt-installation-and-the-openrig-view)
covers the view.
- [Headless or SSH handoff](#headless-or-ssh-handoff) covers no-display cases.
- A timed-out command may still have taken effect. Read `rig ps` or `rig seat status <seat>` before you run it
again.
- **Don't:**
- relaunch a team to reopen its view;
- start another seat to clear a prompt;
- build the person's project yourself. Give the goal to the operator.
After installation and sign-in, your agent opens the **OpenRig TUI and operator** for you. The path is `rig daemon start` (if stopped),
then `rig terminal open saved:kernel --window`. The second command opens the
desktop window itself. An agent can run it from its shell on the daemon's
desktop; it does not need to type into your current terminal. See
[Open the kernel conversations](#open-the-kernel-conversations).
`rig tui` is the team dashboard; installation ends when you are talking to the
operator in the OpenRig view.
## Before you start
Setup and launch are separate: declining `rig setup` avoids its `~/.tmux.conf` change, but starting Codex seats can
still write workspace trust and selected MCP/config fragments. `runtime.codex.hooks_enabled=false` controls hooks;
it isn't a no-global-config option. Setup has no project-only mode. Trusted project `.codex/config.toml` settings
don't redirect OpenRig's home-config writes. A separate selected `CODEX_HOME` keeps Codex config, login and sessions
together in that home; it needs its own working sign-in and doesn't relocate `~/.tmux.conf`.
> **What OpenRig changes on your machine**
> - **Install:** `@openrig/cli` goes in globally with npm.
> - **Setup:** `rig setup` adds an OpenRig block to `~/.tmux.conf`. It can install tmux (with Homebrew on macOS), and
> Herdr unless you pass `--no-herdr`. Plain `rig setup`, which is what the one-command install runs, checks both
> Claude Code and Codex and may install a missing one; `rig setup --providers claude` (or `codex`) limits it to
> yours.
> - **Starting OpenRig, for the coding agent you use** (paths are the usual defaults):
> - Codex: hooks, workspace trust and team-selected config fragments (including MCP) in the selected
> `CODEX_HOME/config.toml`, default `~/.codex/config.toml`. These affect other Codex sessions using that home.
> - Claude Code: workspace trust in `~/.claude.json`, and activity hooks in the project's
> `.claude/settings.local.json`, plus a status line unless you already have your own.
> - Discovery skills go in `~/.claude/skills` and `~/.agents/skills`.
> - Each seat's `CLAUDE.md` or `AGENTS.md` gets OpenRig's managed blocks (culture, identity, onboarding). One more
> line, pointing an agent at its seat skills, is added only on a yes: saved at setup, or set where the daemon runs
> (`OPENRIG_SKILLS_ROUTER_LINE_ENABLED`).
> - **Starting a team in your project:** adds managed guidance to `AGENTS.md` or `CLAUDE.md`, plus the team's skills
> and plugins.
> - **Permissions:** by default, OpenRig's own kernel gets broader permissions than a project team gets by default.
> On Codex that's full access with no approval prompts (`-s danger-full-access -a never`). On Claude Code, its file
> tools, web fetches and operational commands, including reads under your home folder, run without asking. A
> permission policy, per-agent choice or Codex profile changes that. What your team can do depends on the team you
> choose and your settings; `workshop`, for example, ships with broad access.
> - **Backups:** OpenRig doesn't guarantee it can preserve or roll back these files, so back them up first.
>
> [Everything OpenRig writes, and why →](https://github.com/mvschwarz/openrig#what-openrig-changes-on-your-machine)
You need Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude
Code or Codex account: one is enough, since the kernel operator adapts the
shipped `starter` team to whichever you have. On Linux, the distribution's own
Node.js can be older (Ubuntu 24.04's is 18); install a supported version with
[nvm](https://github.com/nvm-sh/nvm) (`nvm install 22`) or NodeSource. On a Mac
with Apple silicon, use Node.js 22 (see the [compatibility
history](../releases/v0.5.15.md#known-compatibility-limitation)). On a Mac where
you aren't an administrator, or where Homebrew belongs to another account (its
`brew install` is refused), install Node.js in your own home folder with
[nvm](https://github.com/nvm-sh/nvm): run `touch ~/.zshrc` (a new Mac account
has none, and nvm's installer only adds itself to a profile file that already
exists), then nvm's install command, for nvm v0.40.8
`curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.8/install.sh | bash`
([nvm's page](https://github.com/nvm-sh/nvm#install--update-script) has the
newest), open a new terminal, and run `nvm install 22`. nvm itself needs no
administrator password, and `npm install -g` then installs into your nvm folder.
If nvm's installer says you need Apple's command line tools, run
`xcode-select --install`, then its install command again. Native Windows isn't
supported. On Windows, use WSL2: it's the Windows route
OpenRig supports, though OpenRig's automated tests don't run on it yet. One
user's [working setup](#wsl2-a-reported-working-setup) is below. Node 20 and
You need Node.js 22 or 24 and tmux, on macOS or Linux. On Linux, the
distribution's own Node.js can be older (Ubuntu 24.04's is 18); install a
supported version with [nvm](https://github.com/nvm-sh/nvm) (`nvm install 22`)
or NodeSource. On a Mac with Apple
silicon, use Node.js 22 (see the [compatibility
history](../releases/v0.5.15.md#known-compatibility-limitation)). Native
Windows isn't supported. On Windows, use WSL2: it's the Windows route OpenRig
supports, though OpenRig's automated tests don't run on it yet. One user's
[working setup](#wsl2-a-reported-working-setup) is below. Node 20 and
odd-numbered releases (23, 25 and so on) are not supported; Node 26 and later
even-numbered releases are untested.
On Linux, if Codex cannot run commands and reports `bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted`,
ask the machine owner to follow [OpenAI's sandbox prerequisites](https://developers.openai.com/codex/concepts/sandboxing#prerequisites),
including its `bwrap` AppArmor profile guidance; changes to AppArmor are the owner's choice.
**Choose permissions before starting the team.** Ordinary OpenRig launches use
Codex's `-s workspace-write`, with approval policy from your native configuration,
or Claude Code's `acceptEdits`, which still leaves commands subject to native
@@ -170,32 +98,18 @@ user.
**Agents:** if you're setting OpenRig up for someone, load the `rigs` skill and follow it. It carries the install, the
OpenRig view and the handoff to the operator as one procedure. From 0.6.7, OpenRig installs it as `rigs` in
`~/.claude/skills` and `~/.agents/skills` when the daemon starts. To add it manually, use the release tag matching
your installed `rig --version`: replace `<release-tag>` below with that tag, including its `v` prefix.
`rig --version` prints the version and a commit, for example `0.6.11 (379f0a0d)`; the tag is `v0.6.11`.
The skill is in the repository from v0.6.6; on an older install, use the v0.6.6 tag or upgrade first.
Choose the command for the agent doing the setup:
```sh
# Claude Code
npx skills add "https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs" --skill rigs --agent claude-code
# Codex
npx skills add "https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs" --skill rigs --agent codex
```
These are the pinned, per-agent forms used on the [rigs pages](https://openrig.dev/rigs).
You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
`~/.claude/skills` and `~/.agents/skills` when the daemon starts. Before that, add it with
`npx skills add mvschwarz/openrig --skill rigs`, or read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md).
**One command, for the full setup.** It needs Node.js 22 or 24 with npm already installed. The preview prints what the
script will do and changes nothing:
```sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.6.7/scripts/install.sh | sh -s -- --dry-run
```
The same command without `--dry-run`,
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh`, installs the
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.6.7/scripts/install.sh | sh`, installs the
latest published `@openrig/cli` with `npm install -g`,
runs the Node.js and SQLite check, then `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
Codex and may install a missing one, as described below. A failed step prints
@@ -223,9 +137,7 @@ The other provider's CLI/login is optional. Herdr is installed by default unless
you decline it; cmux remains optional. Do not copy credentials
or start repeated sign-in attempts. Recheck the selected login after the user
completes it. `rig setup --dry-run` previews the broader setup; applying
`rig setup` checks **both** harnesses and installs a missing one with npm, unless
you pass the person's choice: `rig setup --providers claude` (or `codex`) leaves
the other out, so it reads not selected instead of failed. On
`rig setup` checks **both** harnesses and installs a missing one with npm; on
macOS it uses an existing Homebrew (it does not install Homebrew) to install a
missing tmux (elsewhere tmux is checked, not installed). Herdr installs on
macOS and Linux; pass `--no-herdr` to decline it. Setup also
@@ -233,40 +145,6 @@ writes an OpenRig-managed block (mouse on, a longer history) into `~/.tmux.conf`
It is optional for this selected-provider path, not a requirement to fix an unused
provider.
Setup also prints the one line OpenRig can add to each seat's `CLAUDE.md` or
`AGENTS.md`, plus OpenRig's markers around it so it can be removed cleanly: the
exact text it writes, which files it goes in, and what a no gives up. Show the person,
ask, and save their answer with `rig setup --router-line yes` or
`rig setup --router-line no`. No answer isn't a yes: with nothing saved and
nothing set in the environment, the line isn't added. The answer covers only
that line, and so does what setup says a no gives up; setup's "OpenRig may
modify" list names the other writes.
The block keeps `terminal.history_limit` rows of scrollback per pane (default
10000). To keep more, run `rig config set terminal.history_limit 50000`, then
`rig setup` again. A running tmux server keeps its old limit until it restarts
or you run `tmux source-file ~/.tmux.conf`; before tmux 3.7 that changes new
panes only.
New managed agents and handover successors on macOS/Linux start from the daemon's
selected launch environment and declared seat values, rather than stored tmux globals.
Values named by tmux's `update-environment` option, including its default SSH-agent
and display variables, come from the daemon's environment.
Normal shell startup and configured tmux shell commands still run. OpenRig reasserts its
selected `OPENRIG_DB` and configured `TMUX_TMPDIR` when starting the runtime after shell startup.
If your custom setup intentionally supplies values through tmux's global environment,
opt in with
`rig config set terminal.inherit_tmux_environment true`. Setup points out this option
when it detects custom tmux configuration; it never enables it automatically. The setting
applies to future launches and successors, not running agents or your personal tmux server.
If the scoped environment cannot be prepared, launch keeps its previous environment and
reports a warning in fresh launch results and the daemon log. For handovers, check
that log for this warning. This is not a promise that shell startup cannot add or override values.
On Linux, a seat also gets the daemon's `XDG_RUNTIME_DIR`, so tools whose sockets live there work in seats,
with `DBUS_SESSION_BUS_ADDRESS=disabled:` so its D-Bus clients don't find your session bus on their own. A seat's
own `env:` value for either name wins. See "The runtime directory, on Linux" in the rig spec reference.
## Start the kernel and check its state
With a working selected login, start the daemon if it is stopped, then read its
@@ -298,61 +176,9 @@ A missing or unknown readiness signal is not proof of readiness. Follow
startup or missing-seat problems.
`rig setup` itself does not start the daemon. Preserve a daemon and kernel that
already exist: daemon startup does not boot a second kernel when one is managed,
and `OPENRIG_NO_KERNEL=1` (`--no-kernel`) skips automatic kernel startup,
including the restore below. If neither runtime is authenticated, kernel boot
reports `auth_blocked`.
Daemon startup restores one existing kernel by itself: a managed kernel whose
seats are all `detached` and still bound to their newest session, which is what
startup reconcile leaves when every kernel terminal is gone, for example after a
reboot. It does not detect a reboot as such. It uses the same restore as
`rig up kernel --existing`, and `rig status` reports it as `Kernel: booting`
until the seats are ready. A kernel stopped with `rig down kernel`, including
one whose seats were already detached, stays down on later starts. So do a kernel
with only some seats stopped and seats you unclaimed. Start one again with
`rig up kernel --existing`.
When the tmux server is gone, an explicit `rig down` of any rig can start an
empty server and probe again. It records the stop and clears eligible bindings
only after confirming the sessions are absent. This can leave an empty tmux
server running; it never launches an agent or provider session. Unreadable or
permission results and changed occupants leave the stop bookkeeping unchanged.
While that automatic restore runs, `rig start` continues to your other rigs and
says the kernel is still restoring (`kernelRestore.state: "restoring"` with
`--json`). Its exit status 0 then does not mean the kernel is ready; check
`rig status`. When the restore has returned without a ready kernel, `rig start`
still goes on to your other rigs but exits 1 (`kernelRestore.state: "failed"`),
so `rig start && next` does not run `next`. A `rig down kernel` made during the
restore waits for it to return and then stops the kernel, which stays down on
later starts. A `rig up kernel --existing` with no fresh seats and no change to
the saved non-interruptive choice, made during the restore, or after it restored
every seat while those seats still run the sessions it started, reports that
restore's outcome instead of starting another. Explicitly repeating the saved
`--non-interruptive` or `--no-non-interruptive` choice also joins that restore.
`rig start --last` restores a rig unless every seat it launched was stopped on
purpose with `rig down` or `rig seat stop`. A rig whose stop was never recorded
counts as last running: one taken down with `rig down` before OpenRig 0.6.12, or
one whose seats exited or were lost without `rig down`. So does a rig with no snapshot
whose saved state can be restored. To keep such a rig down, run `rig down <rig>`
once, by rig id when names are shared; it records the stop, and `--last` then
leaves the rig down. `--last` never selects the kernel. Under `--no-kernel`
(`OPENRIG_NO_KERNEL=1`) the daemon skips the kernel's automatic restore too;
bring it back with `rig up kernel --existing`.
An explicit `rig up kernel --existing --fresh <seat...>` waits for startup's
restore to finish, then replaces only the named seats that still hold the exact
sessions startup launched. Other seats keep running. An operator's conversation
correction also takes effect for that seat on the next explicit `up` or
`restore`, using the corrected target; editing the token alone restarts nothing.
Each replacement takes new seat leases, rechecks the managed occupant and terminal,
and reports its own per-seat outcome. If the occupant changed, it is left alone.
A different `--non-interruptive` or `--no-non-interruptive` choice after startup
launched the seats is refused: the running launch choice stays unchanged. Stop
the rig, then rerun with the desired choice.
Do not restart or recreate an
already exist: ordinary daemon startup skips a managed kernel, and
`OPENRIG_NO_KERNEL=1` skips automatic kernel startup. If neither runtime is
authenticated, kernel boot reports `auth_blocked`. Do not restart or recreate an
existing kernel merely to open its view. The startup TUI (`rig`) offers its own
kernel setup and individual-seat recovery; it does not automatically boot agents
when it starts the daemon.
@@ -372,7 +198,7 @@ leaves existing cmux settings alone and does not open a view during setup.
On a Mac, the installing agent also asks once, "Install Ghostty for the OpenRig
view?" On Yes it reruns setup with `--ghostty`, preserving earlier choices such
as `--no-herdr` and `--providers`. This installs the
as `--no-herdr`. This installs the
[documented Homebrew cask](https://ghostty.org/docs/install/binary) and checks for
the app. On No it uses `--no-ghostty`; Terminal.app remains available. Plain setup
reports this offer without installing Ghostty or waiting for input. The person
@@ -403,18 +229,6 @@ same view and plan, or creates one if none matches, preserving its config and ex
caller's Herdr endpoint must match the daemon's endpoint; a mismatch is reported
without opening a window or putting the view in another session.
The shared TUI (`rig tui --shared`) runs in the daemon's tmux server, so its own environment never
carries the Herdr or Rex you are looking at; the markers are on the tmux client attached to it. When you
open a terminal view from it on Linux, the TUI passes the markers of the client that was active last
(normally the one that pressed the key) to the command, which then opens the view in that Herdr, or as
a new session on that Rex server. The
chosen client may belong to a different viewer: tmux only counts keys a client types, so a key sent
by an agent (`send-keys`) or by a control-mode client doesn't count as any client's activity, and the
view opens in the Herdr or Rex of the interactive client that typed last, however long ago. With no client
attached, more than one client active in the same second, or a client that is in neither or can't be
read, nothing is passed and the result is what it was before. Other platforms, and every other way of
running `rig terminal open`, keep the previous result too.
On Linux, a local graphical display and a supported hosting terminal are required
(Ghostty, GNOME Terminal, Konsole or xterm). Linux reports a window request; verify
what actually appeared. SSH, headless, CI and unsupported Linux hosts get a
@@ -490,7 +304,6 @@ different points in the same journey:
| Herdr shows its introduction or agent-integration panel | These are Herdr's first-run panels. Follow the displayed controls: Return continues the intro and Esc closes the integration panel. Opening this view does not require installing Herdr's agent integrations or changing your Claude/Codex settings. |
| A saved layout is cramped in an 80×24 window | The default kernel view gives each role a full-width page. A custom saved `kernel` view still controls its own layout; adjust that saved view if needed. Changing or restarting the team is unnecessary. |
| The command returned, but visibility is unconfirmed | With authorized desktop tools, inspect the new window and a screenshot of its contents. A window listing alone proves only that a window exists. If the agent has only shell access, report what the command confirmed and ask the person to confirm the visible operator and that the dashboard and advisor tabs or windows are available. Do not report visual success without seeing it. |
| In a Claude Code seat's terminal, Shift+Enter submits instead of adding a newline | Whether Shift+Enter reaches Claude Code as its own key depends on the terminal and on tmux's extended-key support and settings ([`extended-keys`](https://man.openbsd.org/tmux.1#extended-keys), [`terminal-features`](https://man.openbsd.org/tmux.1#terminal-features__)). To add a newline anyway, press Ctrl+J, or type `\` and then press Enter. Claude Code's [tmux configuration guide](https://code.claude.com/docs/en/terminal-config#configure-tmux) covers the setup. |
After a failure, inspect any newly opened surface before retrying. A tmux failure
may name a newly created viewing session for inspection. Do not replace a failed
@@ -510,11 +323,6 @@ rig terminal open saved:kernel --provider cmux --json
```
Use `rig terminal status --json` to inspect provider availability and liveness.
To keep rigs in separate Herdr sessions, select one explicitly:
`rig terminal open future-prod-rig --provider herdr --session future-prod`.
`--session default` selects Herdr's default session; omitting the option keeps
the daemon's configured endpoint. See [named terminal sessions](terminal-sessions.md)
for desktop windows, previews and session-specific status.
For the first desktop handoff, use `--window`. Only if the window cannot open,
`rig tui --shared` is the dashboard-only fallback, not the operator's conversation.
@@ -523,19 +331,17 @@ For the first desktop handoff, use `--window`. Only if the window cannot open,
For an explicitly requested manual attachment, inspect the current bindings:
```sh
rig ps --nodes --rig kernel --json --fields logicalId,canonicalSessionName,tmuxAttachCommand
rig ps --nodes --rig kernel --json
```
Find `logicalId: operator.agent` and run its `tmuxAttachCommand`, after `env -u TMUX`, in a new
Find `logicalId: operator.agent` and use its `canonicalSessionName` in a new
terminal on the same host and account:
```sh
env -u TMUX <tmuxAttachCommand>
env -u TMUX tmux attach-session -t '=<canonicalSessionName>'
```
When the daemon was started inside tmux on a non-default socket, its sessions live on that
server, and `tmuxAttachCommand` names it (`tmux -L <name> …` or `tmux -S <path> …`). The installing
agent fills in the actual command. This optional manual route
The installing agent fills in the actual session name. This optional manual route
is separate from the first-install desktop action above.
### Plain terminal: a new viewing session
@@ -617,8 +423,8 @@ rig ps --nodes --rig starter
Preview the selected seats, models and resources. Plan checks resolution and
preflight for the working directory. The kernel is already your entry point; this step starts the chosen project
team. Read readiness for the project seats, not only daemon health. Have the person answer a named authentication, trust, hook-review or
permission prompt before giving that seat work; a launch default, or their answer for another seat, isn't their answer to this one. When a seat stops at such a prompt, `rig up` reports
team. Read readiness for the project seats, not only daemon health. Resolve a named authentication, trust or permission prompt before giving
that seat work. When a seat stops at such a prompt, `rig up` reports
`Status: partial`, prints `Startup attention (<seat>): <reason>` and exits 1;
`rig ps --nodes` repeats it as "Startup details". The reason ends with the command
to run once the prompt is answered: `rig seat continue <seat>`, which delivers the
@@ -704,15 +510,9 @@ Herdr, use `rig terminal open starter --provider herdr`; for the shared
dashboard together with the kernel conversations, use the
[saved first view](#open-the-kernel-conversations). The equivalent
cmux provider is also available. Read the opened/absent/degraded result: a
partial terminal view is not a healthy team. Each `--provider herdr` open adds
a new workspace, numbered when the name is taken; return to the one already open
when you want to preserve it. From inside Herdr, `rig terminal open <view> --window`
focuses a matching space instead of adding one. Herdr's own status for a seat can
read idle or unknown while OpenRig shows it waiting (`needs-input`), for example in
a narrow tile that wraps Claude's footer, or in a tile that shows the inner tmux
status line (with `terminal.status_bar` on, or for a seat session OpenRig
adopted rather than created). Use OpenRig's view or the Feed to find waiting
seats. This is terminal integration, not native plugin enrollment.
partial terminal view is not a healthy team. Repeated terminal-open calls can
create another provider workspace; return to the one already open when you
want to preserve it. This is terminal integration, not native plugin enrollment.
For team views with interactive panes, the open result distinguishes the dashboard
overview from the team's lead conversation and asks whether you can see the team.
@@ -721,16 +521,6 @@ views. If the provider cannot open, each labelled fallback command joins a diffe
seat. Keep all returned commands
complete when sharing them; a created workspace alone does not confirm visibility.
### Built by the community
Tools built around OpenRig are maintained by their authors. We have not tested them;
this list does not promise support or compatibility with your OpenRig version.
- **[Rig HQ](https://github.com/dmelo/rig-hq)**, by [dmelo](https://github.com/dmelo):
a read-only pixel-office viewer for rigs, seats, attention and queue handoffs, as
described in the [author's introduction](https://github.com/mvschwarz/openrig/discussions/879).
The author reports testing it with OpenRig 0.6.5. See its repository for setup and support.
## Continue real project work
Return to the same owner with the next outcome, citing the earlier result.
@@ -762,56 +552,6 @@ with `rig grow` and no YAML, or optionally author a custom rig. It covers
new-seat context/work ownership, concurrency costs and saving the expanded spec.
This guide remains the short first-use path.
## Move a seat to the other runtime
When a provider reaches its usage limit, or a seat would do better on the other
runtime, move that one seat instead of rebuilding the team. Ask your operator
agent, or run it yourself:
```bash
rig seat set-runtime dev-review@starter --runtime claude-code --reason "Codex limit" --dry-run
rig seat set-runtime dev-review@starter --runtime claude-code --reason "Codex limit"
```
The dry run shows the target's settings and where each comes from (an explicit
option, the seat's agent spec for the target runtime, or the runtime's own
default), what is cleared, and the costs below. Nothing changes until you run it
without `--dry-run`.
The new runtime takes over before the old one stops. Its successor starts beside
the current occupant, reads a handover (the seat's recap, its open rows and the
predecessor's screen), and confirms it with `rig seat set-runtime <seat>
--confirm <switch-id> --note "<what it will do first>"`. Only then does the seat
move: same address, connections and queue rows, with in-progress claims moved
to the successor. If the successor doesn't confirm within 180 seconds
(`--confirm-timeout`), nothing changes and the current occupant keeps the seat.
A seat can switch itself. `rig seat status <seat>` shows an open or interrupted
switch, and `rig seat set-runtime <seat> --recover` finishes one from its record.
What it costs:
- **A new conversation.** Native history isn't converted; only the handover
carries over.
- **The same address, a different terminal.** The seat's pane and scrollback
change.
- **The team file isn't rewritten.** The database and `rig export` show the new
runtime; the YAML you imported keeps the old one, so a later converge against
it reports a deferred `change_runtime`. Export the rig, or edit the member's
`runtime:`, to keep them in step.
- **Source settings are cleared, not mapped.** The model, effort, Codex profile
and an explicit native permission choice don't carry over, and neither does a
model or effort the agent spec declares for the source runtime. Member and rig
permission policy are kept.
- **Two processes until cutover.** The successor runs on the target's account
while the switch is open.
- **Messages are held during the switch.** A `rig send` to the seat while it
switches isn't typed; it says the seat is switching and until when. Send it
again afterwards to reach whoever holds the seat.
- **Leftover files after a failed switch.** The target runtime's prepared seat
storage and projections can remain.
- **Codex and Claude Code, single-pane seats.** Startup slash commands aren't
run by a switch; the dry run lists them.
## Stop your teams
`rig down <rig-name>` ends that rig's agent sessions and work in progress.
@@ -845,22 +585,19 @@ the lifecycle API without booting a new kernel, then stop the remaining rigs.
| Tool missing or login fails | Use the specific setup/auth hint; recheck that executable in the launch shell. Do not send work to an unready seat. |
| "Startup attention" or "Startup details" names a prompt | Answer the login, trust or bypass prompt in that seat's terminal, then run the `rig seat continue <seat>` it shows. |
| "Readiness timeout after 30s — harness did not become interactive" | The harness took longer than the readiness window. Raise it for the next launch with `rig config set runtime.readiness_timeout_seconds <1-600>`, then relaunch that seat. |
| "Claude resume did not finish within Ns; Claude may still be starting, so the launch was kept" | A restored Claude seat was not interactive when the wait ended. Its pane and resume token are kept. Look at the pane; once Claude is up, run `rig seat clear-attention <seat>`. The wait is about `runtime.readiness_timeout_seconds` in total for each seat (default 30 s), counting the first 3.2 s, and the time adds up seat by seat in a restore. Raising the setting for slow fresh launches lengthens it. If `rig up` or `rig launch` times out at 120 s, that is not a rollback: check `rig ps` before running it again. |
| Daemon is healthy, kernel is still starting | Read `rig status` and `rig ps --nodes --rig kernel`; kernel readiness is separate. |
| Kernel seats fail within a second of the first start, and tmux reports "server exited unexpectedly" | Check that your tmux can capture a pane, then start the failed seats again; see the tmux check below this table. |
| Shared terminal is absent | Inspect the existing kernel binding and recovery state; use standalone `rig tui` while resolving it. |
| Viewing terminal was closed | Open the conversations again with `rig terminal open saved:kernel --window`; `rig tui --shared` is only the dashboard fallback if that window cannot open. Do not relaunch the team. |
| Daemon restarted but tmux survived | Re-read `rig status` and the existing queue; a daemon restart is not a fresh project. |
| Host reboot lost tmux sessions | Starting the daemon restores the kernel by itself when that start finds all of its seats lost; a kernel that was already down before the reboot stays down, and `rig start` names `rig up kernel --existing`. Check `rig status`, and if it reports the restore failed, run `rig up kernel --existing`. For other rigs, open `rig`, start the daemon if needed (press **S** if it opens on the work views), and select the existing rig and seats. Resume is the default; a fresh conversation needs a separate decision. |
| Host reboot lost tmux sessions | Open `rig`, start the daemon if needed (press **S** if it opens on the work views), and select the existing rig and seats. Resume is the default; a fresh conversation needs a separate decision. |
| Launch reports no usable snapshot | Inspect the existing rig and retained project files, then follow the same-seat recovery below. |
| Work is waiting on a prompt or decision | Read the row, transition and named prompt; preserve the obligation until the missing decision arrives. |
**When the kernel's seats all fail at the first start.** A report on Oracle Linux 10
([#980](https://github.com/mvschwarz/openrig/issues/980)) traced this to the distribution's packaged tmux, whose
`tmux -V` prints `next-3.4`: its server exited on every `capture-pane -p`, also outside OpenRig. `rig setup` (its tmux
step) and `rig doctor` (`tmux_capture` in `rig doctor --json`) run this check for you on a throwaway server and name the
fix when capture fails; they test what tmux does, not its version. To run it by hand, without touching your own
sessions:
`tmux -V` prints `next-3.4`: its server exited on every `capture-pane -p`, also outside OpenRig. To check your tmux on
a throwaway socket, without touching your own sessions:
```sh
tmux -L "openrig-check-$$" -f /dev/null new-session -d -s check
@@ -884,27 +621,8 @@ This recovery is for the case the reporter hit: the kernel's seats failed and no
IDs, for example `advisor.lead operator.agent operator.human queue.worker` when all four failed. `--fresh` starts a
new conversation for each seat you name. Seats you don't name follow their normal recovery policy and may remain
awaiting a decision if their old conversation can't be resumed. A later `rig daemon start` doesn't retry the failed
seats by itself: once a kernel exists, startup skips the built-in kernel boot and restores only a kernel whose seats
were all lost, not seats that failed. `rig status` then reads `Kernel: skipped` unless some of its seats are running
and ready, so check `rig ps --nodes --rig kernel --full`.
**When every agent stops at once on Ubuntu 22.04.** Ubuntu 22.04's packaged tmux, 3.2a, can exit its whole server
when it's asked about a pane or session that has just closed. OpenRig checks each seat about once a second, so when a
seat's session closes, the next check can stop every agent on that tmux server, and tmux reports "server exited
unexpectedly". This was reproduced with Ubuntu 22.04's `tmux 3.2a-4ubuntu0.3` for a closed pane, a closed session,
and a closed pane in a window that stayed open. If `tmux -V` prints `tmux 3.2a`, put tmux 3.4 or later first on
your `PATH`. Newer versions weren't tested against this crash; #980's reporter runs 3.5a, and none of OpenRig's own
machines runs 3.2a. One way to get one is to build a current release from the tmux project, as that reporter did:
```sh
sudo apt install build-essential pkg-config bison libevent-dev libncurses-dev
# in the unpacked release tarball:
./configure && make && sudo make install # installs into /usr/local/bin
```
Then restart the daemon from a shell where `tmux -V` shows the new tmux. A tmux server that's already running keeps
its version until it exits, so the change applies to servers started afterwards. If a crash already stopped your
agents, recover as for lost tmux sessions in the table above.
seats by itself: once a kernel exists, startup skips the built-in kernel boot, and `rig status` then reads
`Kernel: skipped` whatever its seats' state, so check `rig ps --nodes --rig kernel --full`.
If a snapshot is unavailable, the startup view checks the selected seat's
retained startup source and authoritative occupant relation. It reports a
@@ -1137,10 +855,6 @@ absolute working directory, then uses that exact path for discovery and launch.
It does not use interactive shell aliases or a shell's modified PATH. Relative
PATH entries and a relative `CLAUDE_CONFIG_DIR` resolve from the seat directory.
In `plan` mode Claude Code answers with Sonnet when the seat's model is the alias
`haiku` (a full Haiku model id, set with `rig seat set-model`, keeps Haiku), and the
launch result and `rig seat status` say so for that pairing.
For these explicit native modes, fresh, resume, fork and legacy restore use the
same managed environment: PATH, HOME, `CLAUDE_CONFIG_DIR` (default HOME/.claude)
and the configured classic-renderer setting. Other shell customizations are
@@ -1165,10 +879,7 @@ after an authorized launch before claiming its actual permission behavior.
The rig-level verbs are `rig policy permissions list`, `show`, `current` and
`apply`. Existing `rig policy list/show/current/apply` remain compatibility
aliases with the same JSON and exit behavior. Pi resource trust and the per-seat
typing guard are separate controls. To retain automatic messages while you have
an unfinished terminal input, opt that seat into [draft-aware delivery](seat-delivery.md).
The same typing guard offers `hold` for a manual terminal (`--enabled true`)
and `off` for ordinary automatic sends (`--enabled false`).
typing guard are separate controls.
### Claude Code: a different launch flag
@@ -1208,17 +919,7 @@ and managed restrictions still matter; this flag is not a promise about sandbox
or account access. See [Claude permissions](https://code.claude.com/docs/en/permissions).
**Already running:** changing a file or running `rig policy apply` does not revoke
a live agent's permissions, and a spec edit does not rewrite a stored rig's policy
on restore: restore and resume read each seat's stored policy. To change that
for an existing seat, run `rig policy apply <name> --seat <seat> --reason <text>`,
adding `--operator <address>` outside a managed seat. Restore, resume and launches
that read the seat's stored state then use it, unless an explicit native selection
(`rig seat set-permissions`) takes precedence; the running process and the spec
file are unchanged. `rig up <rig name>` uses it, while launching from a spec that
declares a policy replaces it with the spec's policy, which later restores then
use. `--seat none` is an explicit floor for that seat, not "no policy", and it does
not inherit the rig's policy. After `--seat`, a `rig launch --retry-startup-from`
file has to carry the new policy. Pause
a live agent's permissions, nor rewrite a stored rig's policy on restore. Pause
work and use the native permission controls for that conversation (current
Codex and Claude CLIs expose `/permissions`); inspect the effective mode again.
Keep the launch spec/profile consistent for subsequent launches. If the native
@@ -1227,62 +928,6 @@ same-seat stop/resume path after checking its retained policy; do not erase the
rig or start a duplicate to reset permissions. A resume can reapply the stored
launch mode, so verify the native mode again before continuing work.
## Optional Mac local-user check
On a Mac shared by multiple OS accounts, `transport.local_user_guard` can reduce
accidental CLI routing to another account's local daemon. It is **off by default**.
Enable it with `rig config set transport.local_user_guard true`, or set
`OPENRIG_TRANSPORT_LOCAL_USER_GUARD=true` in the CLI's environment. Set it to
`false` to turn it off; no daemon restart is needed for the CLI check.
When enabled, automatic local CLI data requests compare the daemon's reported
`/healthz` PID and effective UID (`euid`) with two `ps` UID/start observations and
a second health response, with at most one second of observation per check.
Only stable agreement on a foreign effective UID stops the request. This does
not require visibility into another user's socket descriptors. It does not
stop the daemon, choose another port or retry. Diagnostic health/status checks
remain available. Older daemons without `euid` remain unknown and are permitted.
`sudo rig …` uses the caller's **effective** UID, so an enabled check against a
user-owned daemon refuses when its reported/observed UID differs from root.
Explicit `OPENRIG_URL`/`RIGGED_URL` targets and registered remote hosts retain
their selected routing, including tunnels. An internally resolved local URL is
still checked. Default UI and TUI openings are checked before launch; this does
not police later browser or TUI connections.
Missing, disagreeing, restricted or timed-out evidence **continues today's
behavior**. This is a partial accidental-routing check, not authenticated user
isolation: it trusts the daemon's PID/UID report and corroborates that process;
it does not establish the socket's owner. Identity can change between observation
and connection, and equal UIDs do not distinguish separate instances belonging
to one account. The check
does not add authentication to the daemon or protect direct HTTP clients.
## Optional database-conflict stop
By default, daemon startup warns and continues when another live daemon is confirmed
using the selected database. To stop before opening that database instead, run
`rig config set daemon.db_conflict stop` (set it to `warn` for the default).
The check requires an open-file holder, a matching `daemon.json`, and a listener
reporting the same PID; missing or inconclusive evidence does not stop startup.
When the check can't run (`lsof` is missing, fails or times out, or a recorded daemon's
listener doesn't answer), startup warns that ownership is unknown rather than reporting
no conflict, in either setting.
Selecting a database outside `OPENRIG_HOME` alone only warns; a confirmed live-daemon
conflict there still stops when `daemon.db_conflict` is `stop`.
To proceed deliberately for one start, use
`rig daemon start --allow-db-conflict` with the original start options.
The flag sets the override on that child only and does not change the setting.
The underlying `OPENRIG_ALLOW_DB_CONFLICT=1` environment override is also supported:
exporting it applies to every later start from that shell, even after changing
`OPENRIG_HOME`. An inherited copy can also bypass the stop in a later seat or test
home. An inherited `OPENRIG_DAEMON_DB_CONFLICT` overrides that home's file setting;
inspect or unset inherited overrides when changing homes.
Separate instances should use separate databases; the check is a startup observation,
not a lock against later concurrent starts. Changing the setting applies on the next
daemon start.
## Custom settings and precedence
- **OpenRig:** member `permission_policy` overrides rig `permission_policy`.
-13
View File
@@ -390,19 +390,6 @@ ID reads retain them while the source can still project them. A recurrence after
clear receives a new episode ID. This is an on-demand projection, not a history
store; a detail 404 does not prove resolution.
In the TUI, the instance Table and the rig/pod Table and Overview load canonical
findings for their health summaries. These summaries previously could show `Not assessed`
even when the daemon served findings because they did not request the projection.
Once the topology drill is resolved, these pages use the existing bounded
`GET /api/health?limit=200` read; pod summaries continue to cover their rig.
Other topology views using focused reads keep this projection lazy; the Health
tab requests it explicitly.
This findings read is separate from the control-plane `GET /healthz` read.
If `GET /api/health?limit=200` fails, including HTTP 503, the summary shows
`Unknown · Unavailable`. A projection that was not requested remains
`Unknown · Not assessed`, and a loaded empty result is not a healthy verdict.
Lists default to 100 and cap at 200, with `total` and `truncated` explicit.
Ceremony findings take precedence before the cap so context pressure cannot
hide the primary signal. Narrow a truncated query; do not certify the unseen
+3 -12
View File
@@ -120,12 +120,6 @@ unknown outcome, check `rig seat status <seat>` before trying again. For a rig w
`--non-interruptive` on `rig up` or `rig bundle install` avoids Claude's warning and Codex's notices in the first
place; see `non-interruptive-mode.md` beside this file.
Codex's initial sign-in chooser and API-key entry are recognised as `login_required`, so startup reports the
sign-in step instead of waiting for a generic readiness timeout. Finish that step in the seat's terminal, then use
the continuation command above. A quoted or dismissed login screen is not a current login gate; other layouts
keep their existing readiness handling. Claude's persistent "Not logged in" footer alone is not a login refusal, because a
configured gateway may authenticate its requests.
### You can't tell which instance or configuration is involved
Read [instance layout](instance-layout.md) (`rig context get reference/instance-layout.md`) and
@@ -139,9 +133,7 @@ issue's current status; a similar symptom alone is not a diagnosis.
- Claude seats reported as needing attention after a restore: [#86](https://github.com/mvschwarz/openrig/issues/86),
[#273](https://github.com/mvschwarz/openrig/issues/273).
- Claude usage limits may not be detected when neither a status line window nor a current
`StopFailure/rate_limit` hook is available: [#99](https://github.com/mvschwarz/openrig/issues/99).
See [Claude usage-limit detection](claude-status-line.md#claude-usage-limit-detection) for the inputs and retry behavior.
- Claude usage limits may not be detected: [#99](https://github.com/mvschwarz/openrig/issues/99).
- Codex prompt variants not recognised as idle: [#79](https://github.com/mvschwarz/openrig/issues/79).
- Codex asks about hook trust at first launch: [#17](https://github.com/mvschwarz/openrig/issues/17), not reproduced
by the team.
@@ -149,9 +141,8 @@ issue's current status; a similar symptom alone is not a diagnosis.
daemon, when its Codex configuration sets network access off, a managed requirement could restrict it, or Codex
doesn't answer OpenRig's configuration read in time: [#275](https://github.com/mvschwarz/openrig/issues/275).
- Kernel seats fail within a second of the first start on a distribution tmux whose server exits on `capture-pane`
(`tmux -V` prints `next-3.4`): [#980](https://github.com/mvschwarz/openrig/issues/980). `rig doctor` checks for
it (`tmux_capture`); the manual check and recovery are in
[Incomplete setup and restart](getting-started.md#incomplete-setup-and-restart).
(`tmux -V` prints `next-3.4`): [#980](https://github.com/mvschwarz/openrig/issues/980). The check and recovery
are in [Incomplete setup and restart](getting-started.md#incomplete-setup-and-restart).
For everything else, search [open issues](https://github.com/mvschwarz/openrig/issues).
-114
View File
@@ -1,114 +0,0 @@
# Hosted members: a tool outside the terminal on your team
A hosted member is a tool that lives outside a terminal, such as an assistant, a chat bot or a cloud routine, and works as a member of a rig. You
address work to it like any seat. It reaches the team through OpenRig's MCP server under a grant you issue and can revoke. It never gets a pane.
Assistants that run on your machine can reach OpenRig's MCP server locally. Many assistants live in a vendor's cloud, and those can only reach a server on the internet.
> **Experimental and unsupported. Off by default.** Nothing in OpenRig listens on the internet unless you set it up yourself. You serve `rig mcp serve --http`, put it behind your own TLS tunnel or proxy, and issue a token. Doing that is like opening a port in your firewall. Anyone with the URL and a valid token can act as that member within its grant. Treat the token like a password, give the member the narrowest grant, and revoke it when you're done (`rig mcp token revoke <name>`). We don't support, and take no responsibility for, an OpenRig reachable from the internet, just as with running OpenRig itself on the open internet. Use at your own risk.
## Set one up
1. **Pick its address** on an existing rig: `<member>@<rig>`, for example `grok@research`. No node is added; the queue takes rows for it.
2. **Say what it's good at, and its limits,** in a roster: an entry in `<workspace.root>/rosters/<file>.json` with its seat, this machine's host id
(`hostSelfId` from `rig ps --nodes -A --json`), `capabilities`, `use_when`, `why`, a `caveat` for its limits, and `"hosted": true` (see
[Rosters](rosters.md)).
3. **Issue its grant:**
```sh
rig mcp token add grok --seat grok@research --rig research
```
It prints the token once. The member acts as `grok@research`: every row it creates or changes is attributed to that seat, and it reaches only
that rig's seats and work.
4. **Serve the MCP server over HTTP** behind a tunnel or proxy that adds TLS: `rig mcp serve --http`. See `rig mcp serve --help` for binding and
for how authentication works. If the proxy runs browser checks (such as Cloudflare's Browser Integrity Check), skip them for `/mcp`:
MCP clients aren't browsers.
5. **Add the server in the tool:** its `https://…/mcp` URL and the token as a bearer credential.
## How it works its rows
- **Hand it work** as you would any seat: `rig queue create --destination grok@research …`.
- **The member works it** through the MCP tools:
- `rig_queue_list` with `owned` lists its rows;
- `rig_queue_claim` takes one;
- `rig_queue_update` closes it done with the answer in `resolution`, or `rig_queue_handoff` hands it on to a seat in its rig.
- **It can send work too:** `rig_queue_create` to its rig's seats.
- **Its rows outlive the conversation.** A new conversation with the same grant reaches the same work.
- **`rig_whoami`** answers the seat its grant names, though the member has no session.
## Asking a person while keeping the work
When a row needs a person's approval before the member can finish it, the member keeps the row and asks:
- **The row came from a person:** the member sets it `blocked` with `blockedOn` that person, and puts the question, with a short form of
what it will do, in the row's `summary`. The person is asked with the summary and the row; a transition note stays in the row's history
and isn't part of the ask. A longer draft goes at an https link in `evidenceRef`, which the ask carries. The person's answer returns the
row to the member in progress.
- **The row came from a seat, or from a shared channel:** the member asks as a new row, to that seat, or for a channel's row to its rig's
lead (a shared channel takes correspondence and can't answer an ask). Then it sets its own row `blocked` on the new row's id. When the
question closes, the member's row returns to pending.
- **Keep the question and action summary short.** A long row body continues as numbered thread replies, within the existing delivery
limits. Use an https `evidenceRef` for a longer approval draft. The summary and framing can still be too large, so nothing promises
unlimited delivery.
Either way the row reads blocked, not done, while it waits, and the member finishes that same row. Nothing promises to wake the
member, so it checks its rows again.
The server tells the member this in its MCP instructions. The text is versioned, so you can check which version a tool received:
> Member workflow (member-workflow/v2). Your inbox is the items addressed to your seat: rig_queue_list with owned set. It's yours, not the team's queue. A wake, a ring or a message only points at it: list your inbox, then act. For each item: claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update), or hand it on (rig_queue_handoff). Listing or reading an item doesn't take it. Say what you can reach from your grant, not from the tool list: a listed tool isn't access. rig_artifact_read's answer for an item names the shared roots you may read, and none means no shared files. If the person connected you, that's basically authorized: claim the items in your inbox and do them without asking whether you may. When an item needs a person's approval before you can finish it, don't close it and don't take the action: keep the item and ask. If it came from a person (an address ending in @external that isn't a shared channel; the item's 'Shared channel:' line names one), set it blocked with blockedOn that person and, as its summary, the question with a short form of what you'll do: the person sees the summary and the item, not your note, which keeps the record. Link a longer draft as an https evidenceRef. Their answer returns the item to you in progress. Otherwise ask as a new item, to the seat it came from or, for a shared channel's item, to your rig's lead (rig_team shows it), with your question and draft. Then set yours blocked with blockedOn that new qitemId: when that item closes, yours returns to pending, to claim again. Nothing promises to wake you, so check your inbox again. Then finish the same item; don't open a new one.
## Ring it when work lands
If the tool has a trigger that starts it from outside, such as a webhook on a scheduled routine, add it to the member's roster
entry. The entry holds secret names, never values:
```json
"wake": { "webhook": { "url_ref": "GROK_WAKE_URL", "authorization_ref": "GROK_WAKE_AUTH", "secrets_env_file": "/abs/path/grok-wake.env" } }
```
- **The secrets:**
- `url_ref` names the secret holding the webhook URL.
- `authorization_ref`, when the tool needs one, names the secret holding the `Authorization` header value.
- Credentials in the URL itself (`https://user:pass@host/…`) are sent as Basic `Authorization` instead, unless
`authorization_ref` is set: then its value is sent, and the URL's credentials are dropped.
- The daemon reads each from its environment (the name, or `OPENRIG_<NAME>`), else from `secrets_env_file`, an env file kept at mode 0600.
- **The ring:** when a row lands for the member, the daemon POSTs
`{"source":"openrig.queue","schema_version":1,"event":"work-waiting","qitem_id":…,"destination":…,"summary":…,"from":…}`.
- It's a pointer, not the row's body: the member reads the row under its grant.
- A receiver should tolerate a repeated `qitem_id`. A ring accepted just before the daemon stops, before it's recorded,
is rung again on recovery, the same at-least-once window a terminal wake has.
- It waits up to 10 seconds and doesn't follow redirects.
- **What the trigger should do:** start a run that lists the member's rows (`rig_queue_list` with `owned`) and claims its work.
## What the record says, and doesn't
These facts stay separate on the row:
- **Wake:** with a trigger, the row's wake result is `wake-accepted:webhook (<url_ref>) answered <status>` when the trigger took the
ring, or `failed:webhook …`. Accepted means the tool's trigger answered, not that the member read the row.
- With no trigger, the result is `unroutable`, and OpenRig tells the person: the operator seat when it's a person, else whoever
created the row.
- It tells the person too when the member was rung but hasn't claimed the work by the stuck sweep's unclaimed age
(`queue.stuck_sweep_unclaimed_age_minutes`, 60 by default).
- The member sees new work when it checks: when it's rung, when its person asks it to, or on a schedule the tool runs.
- **Pickup:** its claim, a transition by its seat. Listing or reading a row isn't pickup.
- **Result:** the closing transition and its resolution, or the handoff.
- **Notification:** where a person asked, Slack's posted receipt. A posted notice isn't the person's acknowledgment.
**On the team:** `rig_team` lists the rig's hosted members (roster entries marked hosted that aren't one of its nodes), with their capabilities and caveat, no pane, and activity `unknown`.
OpenRig doesn't observe a hosted member between its calls, so it never reads idle. `rig roster find` shows it the same way.
- **Presence:** each entry carries `presence`, which says what OpenRig last saw of the member. It isn't pickup, and `null` means unknown:
- `lastCallAt`: its last authenticated call to this host's `rig mcp serve --http`, recorded at most once a minute in
`<OPENRIG_HOME>/mcp-http-presence.json`, beside the token file;
- `lastWake`: its latest ring and the result, among its 50 most recent rows.
- `rig roster show` and `find` show the last call; the last ring is in `rig_team`.
## Revoke
`rig mcp token revoke grok` refuses its next request. The rows it worked, and their answers, stay.
## Limits today
- **Webhook triggers only:** OpenRig rings a webhook. Email and other triggers aren't supported yet. A member without a trigger
isn't rung, and its person is told instead.
- **It can't act as a person:** it works rows addressed to its seat, a person's request to it included. It can't settle an ask addressed to a person, or hand work to one.
-5
View File
@@ -79,11 +79,6 @@ Missing or malformed selections fail; absence is never inferred as disablement.
`rig context work-install --json` reports the effective state, source, manifest,
context selectors, and skills. With `--runtime`, its managed skill loadout then
combines System World, topology, and Project World selectors with provenance.
The catalog is a Git checkout. A skill folder with uncommitted or untracked content is
skipped by itself and named. With `--runtime`, work-install reports selected skips with
exit 1. It inspects by default; `--apply-skills` projects the remaining clean skills while
keeping any previously projected copy of the skipped skill. An uncommitted change directly
in the catalog root, such as `catalog.yaml`, still makes the whole catalog unavailable.
For a pre-0.5.9 home, use the `openrig-upgrade` skill's
`migrate-telemetry-state-0.5.9.mjs` helper as an Agent-Operated Migration. Its
-274
View File
@@ -1,274 +0,0 @@
# Keeping a Watch for a Person
**How an operator turns "remind me in 20 minutes" or "tell me when X happens, check every hour" into a watch it
keeps, using today's queue commands.** The person speaks in sentences. They never see YAML, a job ID or a policy name
unless they ask.
> **Status (0.7.0).** The kernel operator's role guidance points here: when a person asks it to watch something or
> remind them, it reads this page (installed at `$OPENRIG_HOME/reference/keeping-a-watch.md`) first. Every step below
> was exercised with the CLI on an isolated private daemon: create, set aside, fire, re-arm, list, change, stop, a
> daemon restart and a handover. A native run with a real operator, install and channel has **not** been run yet.
> The usage warning's command and field names are checked against the served command on a test fixture; it hasn't
> been run as a watch.
## The shape
A watch is a **queue task the operator owns**, set aside with a **one-shot timer**:
1. The person asks. The operator creates one task for itself and sets it aside with `--wake-after`.
2. The timer fires once and wakes the operator. The operator reads the task and checks the condition.
3. Then it tells the person, re-arms the timer, or both. **A timer fires once: a watch that isn't re-armed goes
quiet.**
OpenRig keeps the record and the timer. The operator does the judgment: what to check, and whether it's worth telling
the person.
## Before you set one: can you reach the person?
Run `rig gateway human list --json`. A watch tells the person through the registered human channel (Slack today,
also shown in the TUI Feed), at `<entityId>@external`. **A terminal is never a person's address.** If no human is
registered, say so now, before setting anything up: a watch that can't reach them is a watch they'll never hear from.
## Setting a watch
**1. Write the task body for a successor.** You may be restarted, compacted or replaced before the watch ends. A new
operator must be able to act from the task alone. Put these in the body:
```markdown
Person's words: "Tell me when the release PR merges; check every hour."
Check: `gh pr view <n> --json state` — met when state is MERGED.
Interval: 1h.
Tell them: <entityId>@external (human channel), once, when met.
Stop: after telling them once, or when they say stop.
Unknown: not said yet.
```
**2. Create it for yourself,** with a `watch` tag so it can be listed later (tags can't be changed after creation).
`<your session>` is your session name from `rig whoami --json`:
```bash
rig queue create --destination <your session> --tags watch \
--summary "Watch: tell <person> when the release PR merges (hourly)" --body-file <file>
```
**3. Set it aside with the timer.** A new pending task can be set aside directly; no claim is needed:
```bash
rig queue block <task> --on external:<what you check> --wake-after 1h \
--continuation "check <what>; tell <person> if met, else re-arm 1h"
```
`--on` is always required; `external:<what>` names the outside thing you're waiting on. The timer first fires after
the interval, not now.
**4. Say back what you set, in one or two plain lines:** what you'll check, how often, how they'll be told, and when
it stops. For example: "I'll check the release PR every hour and message you here once it merges. Then I'll stop."
A plain reminder ("remind me in 20 minutes to look at the release notes") is the same with no condition. Set it aside
with `--wake-after 20m`, and when it fires, tell them and close it.
## Every time the timer fires: read the record first
When the timer's message arrives, **run `rig queue show <task> --full --json` before doing anything.** A message already
on its way can still arrive after the watch was stopped or changed. If the task is no longer set aside (done, canceled, or replaced),
do nothing.
Otherwise, check the condition, then do exactly one of these:
| What you found | Do |
|---|---|
| **Met, and the watch was "once"** | Tell the person (below), then close: `rig queue update <task> --state done --closure-reason no-follow-on --note "<what you saw>"` |
| **Met, and it's "every time"** | Tell the person, then re-arm |
| **Not met** | Re-arm. Say nothing to the person: never send "nothing yet" |
| **Can't tell** (no fresh reading) | The first time, tell them once that you can't tell, and why. Record that in a note (`Unknown: said <time>`), then re-arm. **Never say "all fine" for an unknown.** Stay quiet on later unknowns until the reading returns or changes |
**Re-arm** by setting it aside again with a fresh timer:
```bash
rig queue block <task> --on external:<what> --wake-after 1h --note "checked <time>: not met"
```
A new set-aside replaces the old one and retires any earlier timer, so a task never carries two live timers.
**Put what changed in `--note` or `--continuation`, not both.** When `rig queue block` gets `--continuation`, that is
the note it records, and `--note` is dropped.
Each check costs one of your turns on the person's subscription. **Use the longest interval that serves the ask.**
## Telling the person
```bash
rig queue create --destination <entityId>@external --human-intent update \
--summary "<subject>" --body-file <brief> --evidence-ref <watch task id> --verify --json
```
- Always pass `--evidence-ref`; the watch task's ID is a durable pointer the person can open.
- Later messages about the same watch can thread with `--reply-to <earlier message's id>`.
- **`posted` means the connector posted it, not that they read it.** Don't say they saw it.
- **If delivery fails or is indeterminate,** inspect that task before anything else. Never resend blindly.
## "What are you watching for me?"
List **every active state**, not just set-aside ones. A watch being checked can be in progress, and a new one can be
pending:
```bash
rig queue list --owned --state pending,in-progress,blocked --full --limit 200 -o json
```
Then keep the tasks tagged `watch`. Two rules:
- **Check that the number of tasks returned is below the limit.** The limit applies before you filter by tag. If you got
exactly the limit, raise it and list again. **Never report "no watch" from a page that was cut off.**
- **Use `--full`.** Compact output leaves out the body and the waiting view.
For each watch, say what it checks, how often, how they'll be told, and the next check. The next check is
`waiting.nextBackstop.dueAt` when `waiting.nextBackstop.mechanism` starts with `watchdog:`. If a set-aside watch shows
no timed backstop instead, its timer has fired and wasn't re-armed. Check it now and re-arm it.
Answer from the records, so a second reader would give the same list.
## Changing or stopping a watch
| The person says | Do |
|---|---|
| **"Check every 2 hours instead"** | Re-arm with the new interval and a note saying what changed: `rig queue block <task> --on external:<what> --wake-after 2h --note "<person's words>"` |
| **"Watch Y instead"** (what's checked changes) | A task's body can't be edited. Create the new watch, then retire the old one: `rig queue update <old> --state canceled --closure-reason superseded --closure-target <new> --note "<person's words>"` |
| **"Stop watching that"** | `rig queue update <task> --state canceled --note "<person's words>"`. Leaving the set-aside state retires its timer. A plain cancel takes no other closure fields |
Tell the person in one line what changed. After a stop, take no further action for that watch.
## Across a restart or handover
A set-aside watch's current timer is **kept** when the operator is restarted or replaced, and it fires at whoever is
the operator next. That's why the body must stand alone.
**One gap:** a timer fires once. If the operator is replaced **after a fire and before the re-arm**, the successor
inherits a set-aside watch with no live timer. OpenRig's idle-owner check may remind the new operator once, but only
under some conditions, with no delay guarantee. So:
- **Re-arm promptly** after every fire.
- **After a restart or handover, list your watches** (above) and re-arm any that show no timed backstop.
- **Handing a watch to another session** (`rig queue handoff <task> --to <session>`) closes the old task and retires its
timer (unless another task shares that timer through `--wake-watchdog`; a watch set up as above never does). The new
task arrives pending, with its body and `watch` tag but no timer: the new owner sets it aside again with a fresh
timer, as in step 3.
## A usage warning: "warn me when my allowance runs low"
This is a conditional watch like any other: the same task, timer, re-arm, stop and human channel. Only the check is
specific.
**The check is `rig provider status --all-hosts --json`.** It returns what every registered machine already holds,
this one included, and never starts a read on any account. With no host registry it covers this machine only, and
says so. Use it rather than the single-machine `rig provider status --json`: one allowance used from several
machines appears once, with each machine's own observation beside it, so you can't count it twice. If it answers that
usage windows are off, tell the person that before setting the watch, as you would an unreachable channel.
For a Claude login supplied by `CLAUDE_CODE_OAUTH_TOKEN`, an explicit one-credential read is available:
```sh
rig provider status --refresh --provider claude --claude-token-file /absolute/private/personal.token --credential-label Personal
```
The file must be readable by the daemon, regular, at most 8 KiB, and contain only the token. Ownership by the daemon user and mode 0600 or
0400 are recommended; different ownership, broader permissions and symlinks produce warnings, not refusals. Provision it through your existing credential
custody; never put token values in arguments, seat environment settings, evidence or a queue item. The path is on the selected daemon's
machine. Repeat the command for another labelled file when you want to compare subscriptions. This option does not fan out with
`--all-hosts` or `--hosts`.
**Each token refresh spends one model turn.** Status and TUI reads show the cached result and never probe. A timeout or refusal does not
prove no allowance was spent. The probe uses a temporary home/config/work directory and a single Haiku turn, with CLI flags requesting no tools, hooks or MCP. Unsupported CLI flags or missing events are reported as failures/unknown, not as free capacity. The event format is evidenced on
Claude Code 2.1.310; other versions may report different fields. These are credential labels you supplied, not provider-verified account
names, and do not prove a running seat uses that credential. The ordinary status-line sample/account join remains unproven.
Only actually reported window percentages and resets are measurements. A rejected window can have an unknown percentage; overage rejection
is separate from subscription rejection. Readings and their times survive daemon restart, marked historical until refreshed. Files with
different references/labels stay separate; equal resets or matching labels on another host never establish one allowance or extra room.
**Never put `--refresh` in the timer's check.** `rig provider status --all-hosts --refresh` starts a native read of the
accounts on every machine. Run it only when the person asks for a fresh reading now.
**Choose the allowance with the person once, and write its identity into the body.**
- **The allowance:** its `items[].key` and `items[].provider`, read from one run of the check. Show them the allowance
by its label in the plain `rig provider status --all-hosts` view, never a key they have to type. An item whose key
is null (a login that hasn't been identified) can't be watched: say so, and offer to watch it once it's identified.
- **For Codex, the login email too:** `items[].observations[].row.identity.email`, shown on each machine's line in the
plain view. The same Codex account can appear as more than one item with the same key, one per login email (or none)
it was seen under. So on each fire, use the one item whose key matches and whose observations carry the recorded
email. If none or several do, you can't tell. A Codex allowance whose login email is missing can't be pinned this
way, so it stays can't tell. A changed email isn't followed: if the person's login email changes, set the watch
again with them.
- **The window:** `items[].observations[].row.windows[].window`. Claude's are `five_hour` and `weekly`; Codex's are
`primary` and `secondary`, with `items[].observations[].row.windows[].durationMins` saying which is which.
- **The threshold:** on `items[].observations[].row.windows[].remainingPercent`. It's a percentage, and no absolute
amount is served. Remaining is 100 minus `items[].observations[].row.windows[].usedPercent`, never below 0. Both are
null when the machine reported no usage for the window.
**What each part of the answer means:**
| Field | What it tells you |
|---|---|
| `items[].linkage` | `single`: one machine saw it. `same`: several machines saw one allowance. Compare their observations; **never add or average them**. `conflict`: the machines' fresh observations disagree (their reset times are more than 60 s apart); every observation is kept and none is chosen. `unknown`: the same id seen under another login, or with no login email, and not merged with it. `items[].notes` says why. For Codex, the one item carrying the person's recorded key and email is still theirs: its `unknown` is about the other login's item (see the decision). Any other `unknown` item is not theirs to watch |
| `items[].observations[].hostId`, `items[].observations[].readAt` | Which machine the observation is from, and when this daemon read it |
| `items[].observations[].row.windows[].observedAt` | When that machine's reading was taken: the time to tell the person |
| `items[].observations[].row.windows[].resetsAt` | When that window resets: tell the person this too |
| `items[].observations[].row.windows[].freshness` | `fresh`, `stale` or `unavailable`, with `items[].observations[].row.windows[].freshnessReason` saying why. A reading is fresh for 15 minutes |
| `items[].observations[].row.windows[].level` | The served level, with `items[].observations[].row.windows[].levelReason`. Compare the person's threshold, not this level. But **`unknown` means the number can't be used**: no usage was reported, the window's reset time has passed (the number is from before it), or Claude seats report different windows (possibly different logins) |
| `items[].observations[].row.lastRefresh` | **Codex:** that machine's last native read. When `items[].observations[].row.lastRefresh.outcome` is `failed`, the reading it kept is older and reads `stale`. **Claude:** the last read of the daemon's own Claude login (its identity) only. Claude's windows come from the seats' status-line readings at their own times, so they can stay `fresh` beside a failed identity read |
| `items[].observations[].row.identityNote` | **Claude:** which login the identity is (as of the last identity read) and which seats the windows came from. The windows aren't proven to be that login. Say so when you tell the person |
| `hosts[].usage`, `hosts[].usageReason` | Whether each machine's usage was read: `served`, `off`, `older` or `unavailable`, and why. A machine that wasn't read contributes nothing, never a zero |
| `hosts[].lastRefresh` | The last refresh started from here for that machine, per provider. A `failed` one sits beside the older observation the machine kept |
| `seatAccounts[].seats[].configured`, `seatAccounts[].seats[].allowance` | The account each seat is **configured** to use. `seatAccounts[].seats[].allowance.linked` is always false: configuration isn't proof of which allowance a running seat draws on, or that a Claude seat's windows are the identity's login. Never tell the person a seat uses this allowance because of it |
| `seatAccounts[].seats[].observed` | What the seat itself was seen reporting: whether its status line has sent windows and when (`seatAccounts[].seats[].observed.lastSampledAt`), and `seatAccounts[].seats[].observed.differsFrom`, the seats whose readings disagree with it. Observed facts, still not a link to an allowance |
**The decision on each fire,** using the table in "Every time the timer fires". A usable reading is a `fresh`
observation of the chosen window whose `level` isn't `unknown`.
- **Met:** a usable reading is below the threshold. Tell them the lowest one, with its machine, `observedAt` and
`resetsAt`. If the linkage is `conflict`, also tell them the machines disagree, from `items[].notes`, rather than
presenting the lowest as the reading.
- **Not met:** every usable reading is at or above the threshold, and there is at least one.
- **Another login on the same Codex account:** when the item matched by key and recorded email has `items[].notes`
saying the account is also observed under another login email (or with none), its linkage reads `unknown` on one
machine, or `same` on several. Use its own usable readings as above. When you tell the person, say that login's
observations are unlinked: not added, and not merged.
- **Can't tell:** no usable reading: none `fresh`, or the fresh ones' `level` is `unknown`, which includes a null
`remainingPercent`. Also when the allowance is missing from the answer or its key is null, or (Codex) no login email
was recorded, or no item, or more than one, carries both the key and the recorded email. Use the once-then-quiet
unknown rule, and give the reason (`freshnessReason`, `levelReason`, or the machine's `usageReason`).
The task body, for the person's own allowance, window and threshold:
```markdown
Person's words: "Warn me when my Claude allowance drops below 20% of its 5-hour window; check every 30 minutes."
Check: `rig provider status --all-hosts --json` (served readings only; never --refresh).
Allowance: key <allowance key>, provider claude, window five_hour. Chosen with the person.
(For Codex, also: login email <email shown in the label>.)
Usable: a fresh observation of that window whose level isn't unknown.
Met: a usable reading with remainingPercent below 20. Report the lowest, with its machine, observedAt and resetsAt.
Never add machines' numbers. If the linkage is conflict, say the machines disagree (items[].notes).
Claude: say the windows come from seats' status lines and aren't proven to be the identity's login (identityNote).
Codex: the one item with the recorded key and email is theirs even if its linkage is unknown; say the other login's
observations are unlinked (items[].notes).
Can't tell: no usable reading (none fresh, or level unknown: give its levelReason), the allowance missing or its key
null, or (Codex) no email recorded, or no item, or more than one, carries both the key and the recorded email. Give the
reason.
Interval: 30m.
Tell them: <entityId>@external (human channel), once, when met.
Stop: after telling them once, or when they say stop.
Unknown: not said yet.
```
## What not to use
- **`rig watchdog register --policy periodic-reminder` for a person's watch.** It repeats every interval until it's
stopped (the first fire comes one interval after you register it), and it stops when the agent that registered it
is replaced. It's fine for agent-to-agent reminders. Your own watch timers also show as `periodic-reminder` in
`rig watchdog list`: that's the one-shot timer `--wake-after` arms, not a registered repeating reminder. It's retired
when it fires or when the task stops being set aside. A wake held back because you were typing (a retained
delivery) wasn't delivered, so it doesn't use the timer up: it's tried again at each interval until an attempt goes
through (delivered or failed), which retires it. A timer another task shares through `--wake-watchdog` stays live
for that task when this task is handed off or stopped, and the same rule applies to its wake. None of this is a
delivery guarantee.
- **Transcript bytes as "usage".** `context-usage-threshold` measures an agent's transcript, not an account's
allowance. For an allowance, see "A usage warning" above.
-9
View File
@@ -1,9 +0,0 @@
# HTTP operation correlation
`GET /api/operations/capabilities` advertises `operation-correlation.v1` and the supported kinds: `rig.create`, `rig.import`, and `member.add`. A client should check this marker before relying on correlation; an older daemon may ignore an unfamiliar request header.
For `POST /api/rigs`, `POST /api/rigs/import`, and `POST /api/rigs/:rigId/pods/:podNamespace/members`, send a UUID in `X-OpenRig-Operation-Id`. Keep the same daemon, route targets, original request text, and behavior-changing headers for a retry. IDs are case-normalized. An ID that isn't an RFC UUID receives HTTP 400 `Invalid X-OpenRig-Operation-Id` before the route does anything else. Once a request passes the route's own validation, a different request using the same ID receives HTTP 409 `operation_id_conflict`. Matching compares the exact request text, so a change in whitespace or key order is a different request, not an equivalent one; an exact replay receives HTTP 200 with `replayed: true` and current database state. A first request retains its usual status and response fields and adds `operation`.
The operation contains the rig and node IDs committed by that request. Its `current` field reads the database at response time, including whether recorded resources still exist and the latest recorded session for each node. It does not inspect tmux or prove a launch finished. A reservation with no IDs can mean that execution is still underway or that it failed before creating resources. Replaying an exact request that failed before creating resources returns HTTP 200 with `operation.rigId: null` and `operation.nodeIds: []`; inspect those fields to distinguish it from a replay that recorded resources. Replays never execute the mutation again, retry a launch, or recreate deleted resources. Use a new UUID only when deliberately starting a new execution.
This contract stores neither the original response nor a recovery verdict. An HTTP response can be lost after a database commit. External launch effects are outside the database transaction, so the contract is correlation rather than exactly-once execution of external effects.
+1 -94
View File
@@ -108,24 +108,6 @@ ownership receipt. A later seat start with no project-world input preserves it;
an explicit install whose `install.skills` is empty clears it. This keeps
"project not supplied" distinct from "project deliberately selects no skills."
Before refreshing a skill copy, don't take `rig skill audit` as the answer. The
audit reports declared provenance, date freshness (its `stale` is a verified date
past 90 days or one with no evidence source) and copy hashes. It doesn't say
whether a copy is safe to refresh, and two copies with different hashes aren't
evidence of a deliberate fork. For a managed copy in a working folder, the
read-only check is `rig skill loadout --runtime <claude-code|codex> --cwd
<target> --project-root <project> --json`, without `--apply`. It reports
`current`; `stale`, still OpenRig's last projection, so `--apply` can refresh it;
`conflicting`; `shadowed`; or `missing`. Read the detail of `conflicting` and
`shadowed`: `conflicting` can mean the bytes differ or that the target couldn't
be inspected, which leaves any difference unknown, and a confirmed difference
doesn't prove a deliberate local edit. A seat's own skills, kept outside the
project (0.7.1 and later), are listed with where they're stored by
`rig skill loadout --seat <session|self> --json`; that read doesn't classify
them. A global or untracked copy has no install
record, so its origin stays unknown: ask the skill's owner, and don't move global
skills into a project just to get one.
## Work-install project selection
`rig context work-install` picks one project from the catalog at
@@ -186,12 +168,6 @@ The read API adds `GET /api/scopes/projects` and optional `project` and
detail also requires the selected mission directory. Existing reads without a
project parameter keep their legacy contract.
`GET /api/scopes?counts=1` is the light list: each mission's slices with only
their `dirName`, `id`, `displayName`, `status`, `stage` and `locks` (and
`sourcePath` for a selected project), read from each slice's node file alone. It
leaves out readiness and proof pairing, which are most of the work and bytes of
the default list. `detail=1` takes precedence when both are given.
## UI Mapping
- `workspace.root` maps to the Project workspace.
@@ -213,11 +189,7 @@ ambiguity.
## Queue Mapping
A `slice:<id>` tag is the authoritative way to attach a queue item to a slice;
with a project selected, typed rows also need the `project:<id>` tag. The tag
value is either the slice's frontmatter `id` (a dot-ID such as `CUS.9775.1.1`)
or its directory name (`01-custody-funds`); both resolve to the same slice on
the Queue tab, the ownership lanes and the agents band. A row that carries both
forms counts once. As a
with a project selected, typed rows also need the `project:<id>` tag. As a
fallback, for slices with no typed rows, items attach when their body or tags
mention one of:
@@ -238,71 +210,6 @@ Slice: capture-product-ideas
This makes Story, Queue, Tests, and Topology tabs line up with the filesystem
slice without adding a separate project database schema.
### Workflow packets
Packets that `rig workflow` projects carry the scope of their instance. Pass the
scope when the instance is created and every packet it creates (the entry, each
hand-off and gate, exception items, and resume packets) is tagged, so they show
on the slice's Queue tab:
```bash
rig workflow instantiate ./spec.workflow.md \
--root-objective "Build the capture flow" --created-by orch@my-rig \
--project my-project --mission idea-ledger --slice capture-product-ideas \
--tags ticket-7
```
- `--project` should name a project in the workspace catalog. `--mission` and
`--slice` take the same ids as the slice routes. `rig workflow run` takes the
same flags. The CLI refuses what it can check itself (an id of the wrong shape,
a reserved tag, a tag that contradicts a flag) as a usage error, before
anything is sent. Only the daemon knows the catalog, so a mistyped `--project`
is **not refused**: the daemon creates the instance unscoped, its packets carry
no scope tags, and the CLI prints a warning on stderr (also with `--json`)
that names the instance and says the scope was not applied. The CLI does not
retry; instantiating again creates a second instance.
- Packets carry `project:<id>`, `mission:<id>` and `slice:<id>` for the parts
you set, then your `--tags`, after the tags the workflow system writes. Tags
compose as in `rig queue create`: duplicates collapse, so `--slice S --tags
slice:S` is one tag.
- `--tags` is refused locally when it carries what the workflow runtime reads
back to route packets: `workflow`, `handoff`, `gate`, `entry`, `resume`,
`re-route`, `workflow-exception`, and the `workflow:`, `instance:`, `step:`,
`exception:`, `occurrence:` and `recovery-for:` prefixes, a `project:`,
`mission:` or `slice:` tag that contradicts its flag, or a second value of
the same key.
- `rig workflow instantiate-lifecycle` needs no flags: it takes the project and
mission from the lifecycle identity it compiles. A compiled step's packets
carry the `slice:<directory>` of the slice that step was compiled from, even
when another slice's directory has the same name as its `metadata.id`. A step
an authored graph names carries `slice:<directory>` when it names an active
slice: by its exact directory name, else by a `metadata.id` that exactly one
active slice claims (an id two slices claim maps to nothing, with a compile
advisory). A step that names no slice, such as an authored closing step,
carries only `project:` and `mission:`, so it appears on no slice's Queue tab.
A project id or mission name the compiler reads from the manifests that cannot
be a tag (a space, a `:`, a leading `.`, `_` or `-`, or too long) is left off
the packets with a compile advisory; the lifecycle still instantiates. Slice
metadata the lifecycle does not run is read only leniently, for step names, and
never fails compile or instantiate.
- Over HTTP, `POST /api/workflow/instantiate` reads the scope from a `scope`
object (`{ project, mission, slice, tags }`). A scope that is valid and names
a catalogued project is applied as above. A scope the route cannot apply (a
project the catalog does not declare, a conflicting, reserved or malformed tag
or id, or a `scope` that is not an object) is ignored: the route does what it
did before the field existed and creates one unscoped instance, records
nothing the scope named, and never lets a rejected tag reach a packet. The
response `advisories` then carry a `Scope not applied (...)` note that names
the instance (`scopeIgnored` repeats it); `rig workflow run` prints
`advisories`. Bare `project`, `mission`, `slice` or `tags` fields in the body
are ignored the same way, with a note. A failure unrelated to the scope still
fails the request.
- A Projects view scoped to a project lists a slice's packets only when they
carry `project:<id>` as well as `slice:<id>`, so pass `--project` with
`--slice`.
- The scope is stored on the instance when it is created and does not change.
Instances created without it behave as before.
## Compatibility
The default discovery root is `workspace.slices_root=<workspace.root>/missions`.
+3 -27
View File
@@ -21,23 +21,7 @@ Two rules matter most for strangers:
- **Keep everything inside the folder.** Every `agent_ref` should be `local:` and point inside it. A path that exists on
your machine won't exist on anyone else's.
- **Never put credentials in it.** No tokens, keys or `.env` files. `rig bundle create` refuses well-known sensitive
file names (such as `.env`, `.pem` and `.key`) anyway. It also refuses contained `.claude/.credentials.json`,
`.codex/auth.json` and `.pi/agent/auth.json`, including nested copies in agents or a carried project. Remove them
and rebuild. Only if recipients should have these logins (for example, your own internal test bundle) and their
owner agrees, `rig bundle create --include-login-files` records the exact included login-file paths in the
manifest and consumers show a notice; anyone with the bundle could read those files.
Unrecorded login files and other sensitive paths still fail. See [harness login files](rig-bundle.md#harness-login-files).
To start from a running team, use `rig export <team> -o ./my-team/rig.yaml` after creating `my-team`.
Export keeps the rig's name, including an old `starter` name; choose a descriptive `name` in `rig.yaml`
before sharing. `rig bundle create --name` names the archive, not the installed rig.
For a local daemon, export copies the declared culture file from its recorded rig root and keeps any
existing destination file. Check that file before sharing. A missing, inaccessible or remote source is
reported for manual copying. If the daemon is reached through a localhost tunnel, copy the culture from
that host and check it yourself. Agent refs outside the folder are reported as nonportable: copy those agents and their
imports into the folder and use `local:` refs, or let `rig bundle create --rig-root <original-root>` vendor
agents into an archive. To include an existing README, declare `docs: [{ path: README.md }]` in `rig.yaml`;
export does not invent a README or its contents. Check the folder after these edits.
file names (such as `.env`, `.pem` and `.key`) anyway.
Two optional files sit beside `rig.yaml`:
- **`bundle.yaml`** declares what people need before installing: setup steps (`preconditions`, shown before install
@@ -120,11 +104,6 @@ or **"Not tested by OpenRig"**, which means exactly that. You're welcome to run
## 6. Submit it to openrig.dev/rigs
Before you submit, run `rig bundle check` on the folder at the commit you'll submit. openrig-registry's registry check
runs the same check, with the latest published OpenRig, on the folder at your `ref`, and any finding fails it. A team
made with `rig export` has no README of its own: add one and declare it in `rig.yaml` as `docs: [{ path: README.md }]`
(see [section 1](#1-lay-out-the-folder)).
Open a pull request to [mvschwarz/openrig-registry](https://github.com/mvschwarz/openrig-registry) that adds one file,
`registry/submissions/<your-team>.yaml`, with three fields: `repository` (your GitHub repository URL), `folder` (the
folder holding `rig.yaml`, or `.` for the repository root) and `ref` (a branch, tag or commit). No pull requests? Open
@@ -132,11 +111,8 @@ an issue there with the same three things. A maintainer then writes the full ent
defined in the [bundle formats reference](bundle-formats.md), with the parts only a review can produce, such as the
pinned commit and each configuration's package digest.
Rig names are unique on the site. If `registry/<your-team>.yaml` already exists, openrig-registry's registry check
still replies that your submission was received, and says the name is taken; choose a distinct one, for example
`<taken-name>-<your-name>`. A team whose `rig.yaml` name matches a listed team's, such as an unchanged copy of one,
passes the checks with a notice for the maintainer about the shared name. Whether it's listed is the maintainer's
review decision, not the checks'.
Rig names are unique on the site. If `registry/<your-team>.yaml` already exists, openrig-registry's registry check says the name is taken;
choose a distinct one, for example `<taken-name>-<your-name>`.
What happens next:
- a maintainer pins your link to an exact commit, runs the same check, and reads everything your bundle gives to an
-212
View File
@@ -1,212 +0,0 @@
# Getting started
OpenRig runs a team of coding agents (Claude Code, Codex and others) on your project, each in its own terminal. You
talk to one agent, and it hands work to the others. The team keeps its tasks and messages between sessions.
Most people let their coding agent set it up. This page explains what happens either way, so nothing is a black
box, and shows how to do each step by hand if you'd rather.
## The quick way: let your coding agent do it
In your project folder, add OpenRig's `rigs` skill to the agent you use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
```
For a new install, use the latest release's tag, for example `v0.7.4`. If OpenRig is already installed, use the tag
that matches `rig --version`, or upgrade first. If the command fails with `npx: command not found`, Node.js isn't
installed or isn't on your PATH yet (see [What you need](#what-you-need)). Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what
you want built.
The skill has your agent work through the steps below in order. It says what it will run and change before it runs
it, and it asks before installing anything on your system. It finishes when you're talking to the operator.
## How the pieces fit
- **`rig`** is the command you and your agents use. It comes from the npm package `@openrig/cli`.
- **The daemon** is a small background service on your machine. It keeps tasks, messages and state in a local
database. `rig daemon start` starts it.
- **tmux** runs each agent in its own terminal session. Agents keep running when you close a window, and one agent
can type into another's terminal.
- **The kernel** is OpenRig's own small team, which starts with the daemon. You talk to its **operator**, which
starts and runs teams for you. Its **advisor** helps you think through what you want.
- **Your team** (a *rig*) is the set of agents that work on your project, such as `starter`.
- **The OpenRig view** is a terminal window with the operator and, beside it or on another page, a dashboard of your
agents.
## What you need
- macOS or Linux. On Windows, use WSL2 ([one person's working setup](getting-started.md#wsl2-a-reported-working-setup)).
- Node.js 22 or 24 (on Apple silicon, Node.js 22) and tmux.
- Claude Code or Codex, signed in. One is enough to start.
If `node --version` does not show a supported version, first check your existing Node installation and PATH.
Ask your coding agent to help use the toolchain already on your machine. On a Mac, a shared Homebrew installation
may belong to another user; do not change its ownership to complete this setup. After correcting PATH or installing
Node, open a new terminal and check `node --version` and `npx --version` before continuing.
On a Mac where you aren't an administrator, or where another account owns Homebrew, install Node.js in your own home
folder with [nvm](https://github.com/nvm-sh/nvm); nvm itself needs no administrator password. A new Mac account has no
`~/.zshrc`, and nvm's installer only adds itself to a profile file that already exists, so create one first:
```sh
touch ~/.zshrc
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.8/install.sh | bash
```
Then open a new terminal and run `nvm install 22`. If nvm's installer says you need Apple's command line tools, run
`xcode-select --install`, then its install command again. That's nvm v0.40.8's install command; [nvm's
page](https://github.com/nvm-sh/nvm#install--update-script) has the newest.
## What happens, step by step
Each step shows the command, so you can run it yourself.
### 1. Install the `rig` command
```sh
npm install -g @openrig/cli
rig --version
```
This installs the CLI globally under your npm prefix. `rig --version` prints the version and a commit, such as
`0.6.12 (9e5382dd)`. With npm 11 or later, an `npm warn install-scripts` line is expected: it means only the
Node.js and SQLite check was skipped.
### 2. Set up the tools around it
```sh
rig setup --providers claude
```
Use `codex` or `both` in place of `claude` to match the coding agents you use. Add `--dry-run` first to see the plan without changing anything. Setup does four things:
- it checks Node.js, tmux and your sign-in;
- it installs a missing tmux (with Homebrew on macOS, if you have it) or the coding agent you chose;
- it installs Herdr, a terminal workspace app the OpenRig view can use, unless you add `--no-herdr`;
- it adds an OpenRig block to `~/.tmux.conf` (mouse support and longer scrollback).
It doesn't start anything. It ends with a summary of what's done and what still needs you. A missing sign-in shows
there: run `claude auth login` or `codex login`, then carry on.
### 3. Start OpenRig
```sh
rig daemon start
rig status
rig ps --nodes --rig kernel
```
The daemon starts and boots the kernel with the coding agents you're signed in to, or both if you're signed in to both.
**What starting OpenRig changes, for the coding agents you use** (paths are the usual defaults):
- **For Codex:** hooks and trust in `~/.codex/config.toml`, when OpenRig or a Codex agent starts.
- **For Claude Code:** workspace trust in `~/.claude.json`, and activity hooks in the project's
`.claude/settings.local.json`, plus a status line unless you already have your own.
- **For both:** small discovery skills in `~/.claude/skills` and `~/.agents/skills`.
- **Kernel access:** by default the kernel gets broader permissions than a project team gets by default. On Codex
that's full access with no approval prompts. On Claude Code, its file tools, web fetches and operational commands,
including reads under your home folder, run without asking. A permission policy, per-agent choice or Codex profile
changes that.
OpenRig doesn't guarantee it can preserve or roll back these files, so back them up first. [Everything OpenRig writes, and why](../../README.md#what-openrig-changes-on-your-machine).
Started isn't the same as ready. `rig status` shows a `Kernel:` line, and `rig ps --nodes --rig kernel` shows each
kernel agent's state. They can take a moment after the daemon is up.
### 4. Open the OpenRig view
```sh
rig terminal open saved:kernel --window
```
**What you'll see:**
- **Where it opens:** a new terminal window or tab. If your agent is already running inside Herdr, it opens a new
Herdr space instead. It uses Herdr when installed, plain tmux otherwise.
- **Layout:** in a narrow window, or when the width can't be measured, the operator fills the first page. From 120
columns, the dashboard and the operator share it. The advisor has its own tab.
- **On a Mac:** you may be asked to let your terminal app be controlled. Allowing it is fine.
- **Over SSH or with no display:** the window can't open. The command says why and, where it can, prints a command
to run in a new terminal. Otherwise your agent works out the exact command to join the operator.
You're installed when you're talking to the operator.
### 5. Start your first team
Tell the operator what you want built, and in which folder. It recommends one of three teams:
- `starter`: a builder and a reviewer, for one bounded change;
- `workshop`: a lead, a builder, QA and a reviewer, for ongoing work in one repository;
- `factory`: seven agents, for sustained product work.
Before it starts one, it tells you what starting a team in your folder changes: managed guidance in `AGENTS.md` or
`CLAUDE.md`, plus the team's skills. Then it waits for your yes. As shipped, `starter` uses both Claude Code and
Codex. With only one of them, ask the operator to adapt it.
By hand, from your repository (the first command shows what it would start, and starts nothing):
```sh
rig up starter --cwd . --plan
rig up starter --cwd .
rig ps --nodes --rig starter
```
If an agent stops at a sign-in, trust or permission prompt, `rig up` reports `Status: partial` and names the prompt.
Answer it in that agent's terminal, then run the `rig seat continue <seat>` command it prints. The team is ready
for work once its agents show as ready in `rig ps --nodes --rig starter`.
### 6. Give the team work, and stop it
```sh
rig send dev-build@starter 'Fix <one useful thing>. Ask dev-review to check it, and record the result and how I can try it.'
rig queue list --destination dev-build@starter
```
The builder records the work as a task, so you can follow it with `rig queue`. When you're done, `rig down starter`
stops that team. `rig daemon stop` stops the background service and leaves agent sessions running.
## The one-command install
Instead of steps 1 and 2 (the first line shows the plan and changes nothing):
```sh
curl -fsSL https://openrig.dev/install.sh | sh -s -- --dry-run
curl -fsSL https://openrig.dev/install.sh | sh
```
It checks Node.js and npm, installs the latest `@openrig/cli`, then runs `rig setup`. It doesn't know which coding agent
you use, so it checks and may install both. For just one, use steps 1 and 2. If a sign-in is missing, setup reports
it as the step to finish: sign in, then continue at step 3.
## What these words mean
- **Rig:** a team of agents, started with one command. Your first one is usually `starter`.
- **Seat:** one agent's role and address in a team, such as `dev-build@starter`.
- **Harness:** the software around the model that an agent runs in: Claude Code, Codex or Pi.
- **Kernel:** OpenRig's own small team, which starts with OpenRig. It isn't your project's team.
- **Operator:** the kernel's agent you talk to first. It recommends a team for your goal and starts it when you say
yes.
- **Advisor:** the kernel's agent for thinking through what you want before a team starts. It has its own tab.
- **OpenRig view:** the terminal view that opens on the operator. In a wide window the dashboard sits beside it;
otherwise, or when the width can't be measured, the dashboard is on its own page. `saved:kernel` in
`rig terminal open saved:kernel` is the name of this saved view.
## Learn it in this order
1. **How OpenRig works:** [the world and its purpose](../../packages/daemon/assets/onboarding/01-world-and-purpose.md),
the first page a new agent in a team gets. It's written to agents, and it's the shortest account of rigs, seats and
queued work. Your install has the same page under `$(npm root -g)/@openrig/cli/daemon/assets/onboarding/`.
2. **First use:** this page, and [what these words mean](#what-these-words-mean).
3. **One command:** `rig <command> --help` is the exact answer for the version you have installed, and `rig --help`
lists every command.
4. **Every command and flag:** the [CLI reference](../as-built/cli-reference.md).
It describes the source commit it names, so where it and your installed `--help` disagree, `--help` is right for
your version.
## More
- **Every detail of setup:** troubleshooting, permissions, headless and SSH use, and what can stop the view from
opening. See the [setup reference](getting-started.md).
- **Stuck?** Run `rig context get help`, or read [Help](help.md).
+5 -92
View File
@@ -16,38 +16,6 @@ it is the mechanism that makes shipped chain files (see
| Stop | ✓ | — | catch a long Claude turn crossing the growth threshold |
| PostCompact | ✓ | ✓ | retain exact compaction due-state; deliver on the next actionable prompt |
**How the hooks reach a seat.** A Claude seat whose resolved plugin selection includes the shipped `openrig-core` plugin
(for example `plugins: [shared:openrig-core]`, or the same plugin under another name) starts with `CLAUDE_CODE_PLUGIN_DIRS`
set to the vendored plugin (`$OPENRIG_HOME/plugins/openrig-core`), so Claude loads `hooks/claude.json` with no setup step, on a
fresh launch, a restore, a handover successor and a launch with a permission mode. A seat that didn't select the plugin is
launched as before and doesn't load these hooks. The folder a spec's plugin selection projects into the seat's workspace is
not what Claude loads. A list the seat declares itself in `CLAUDE_CODE_PLUGIN_DIRS` keeps working, with the vendored plugin
added after it.
Claude Code reads this variable from v2.1.280. OpenRig doesn't check the Claude version at launch: an older Claude ignores the
variable, starts normally, and doesn't load these hooks. The behaviour was checked on Claude Code 2.1.296 and not on
earlier versions.
A Claude started from inside a seat (its shell or Bash tool) inherits the seat's environment and so loads the plugin as well.
A seat that loads the plugin is also given `OPENRIG_SEAT_PROJECT_DIR`, its launch project, and the plugin's seat effects (the
activity and session-identity posts, the compaction hooks, refocus, and the first-launch marker) run only when that Claude's
`CLAUDE_PROJECT_DIR` names the same folder (compared by real path, so a trailing slash or symlink doesn't matter). A nested
Claude in another project, or in a subfolder of the seat's project, therefore changes nothing for the seat, and a `cd` inside
the seat's own session keeps working because `CLAUDE_PROJECT_DIR` is fixed when Claude starts. A nested Claude in the seat's
own project folder is the same project and still acts as the seat, as before this change. Without either variable (a seat
launched before this change, a manual run) the hooks do what they always did. The plugin's activity relay stays quiet next to
the seat's own `settings.local.json` copy only when that entry is exactly OpenRig's and the script it names is the same
script; an entry from an older install doesn't match, so both report until the seat's next launch refreshes it.
OpenRig automatically installs its Codex plugin for seats that select it. On its first installation, the six exact shipped
hook definitions and bundle content are trusted before launch, after checking the entire installed plugin against this daemon's shipped copy.
On upgrade, OpenRig renews content approval only for a previously approved OpenRig-owned bundle with unchanged approved hook definitions.
It keeps explicit disables, refusals, unknown config layers and other plugins' state. An installation without a proven prior approval stays under native review.
Codex's "continue without trusting" leaves no record: a skip of an otherwise eligible OpenRig-owned update may therefore be superseded on retry.
OpenRig records its approved bundle in `CODEX_HOME/.openrig-core-hook-trust.json` and leaves Codex's immutable `.hook-trust` baseline untouched.
Codex 0.164 checks bundle contents when hooks run; a later edit requires native approval again. Older Codex versions enforce their own trust rules.
The Codex
column above then applies only to a Codex seat whose plugin selection includes openrig-core: every Codex hook command
exits 0 before running anything unless OpenRig gave that seat `OPENRIG_SEAT_PROJECT_DIR` and an explicit
`OPENRIG_NODE`.
Claude's additional firing signal is **transcript growth** — not turns (one turn
can burn 200k tokens across fifty tool calls) and not wall-clock (the failure is
sustained work, not elapsed time). Its default threshold is ~2.6MB of JSONL
@@ -65,23 +33,11 @@ that request asks the seat to read the refocusing skill and consume the delivere
traces rather than rerun them, and the read-depth audit accounts for them. Otherwise the hook
is a no-op (it writes a stderr advisory when the transcript shrinks or the
session has no identity), and it degrades to silence on unrelated hook errors.
**Time budget.** By default each lookup (the trace, the content REF, `rig queue whoami`, `rig config`) gets 2 seconds,
as before, and a slow one ends as a reported gap (`TRACE GAP`, `REFOCUS CONTENT REF FAILED`, or a role marked
unknown). Set `OPENRIG_REFOCUS_BUDGET_MS` in a seat's environment to give a slow trace more time, up to 52000 (52 s).
Each lookup's cap then grows in proportion (at 45000 the trace may take 20 seconds; at 52000, about 23), and every lookup
is also cut to what is left of that budget, so the attempt delivers what it has and counts as failed. After a failed
attempt, the issue report gets its own 1.5 seconds once the lookups are done. Both registrations allow `refocus.cjs` 60
seconds, where they used to stop it at 5; the other OpenRig hooks keep theirs. So a default fire whose lookups together
pass 5 seconds now completes instead of being stopped with nothing delivered, and a hook that hangs outside its lookups
can hold a prompt for up to those 60 seconds.
A configured REF resolution failure degrades loudly in the delivered payload while still completing the hook — a
refocus must never break a seat's turn.
**Overlapping events (unresolved).** Two hook runs for the same seat and occupant are not serialized. If they overlap,
a `PostCompact` recorded while a prompt is delivering can be lost, and one refocus can be delivered twice. This is
reproduced between hook processes; whether a harness overlaps these events for one session is not established, so it is
an open gap, not one shown to be safe.
Both runtimes stop the hook after 5 seconds, and the content REF lookup gets 2;
a slow REF shows as `REFOCUS CONTENT REF FAILED`. A
configured REF resolution failure instead degrades
loudly in the delivered payload while still completing the hook — a refocus
must never break a seat's turn.
Failed trace or configured-content resolution keeps refocus due. After three
consecutive failed attempts for the same seat and occupant, the hook emits one
@@ -133,49 +89,6 @@ reports broken links as gaps rather than following pointers.
This automatic hook path is additive to one-shot manual injection through
`rig send --context`; neither mode substitutes for the other.
## The work packet (off by default on every runtime, for now)
Set `OPENRIG_REFOCUS_WORK_PACKET=1` in a seat's harness environment so its
refocus carries the work it is actually doing as text rather than as pointers.
`=0` (or `off`, `false`, `no`) turns it off. Unset, each runtime's default
applies. A runtime's default becomes on only after a native run shows the
packet reaching the agent and leading to the right next action. No runtime has
been shown that yet, so today both Claude Code and Codex default to off.
With the switch on:
- The hook asks `rig queue whoami --work-candidates` for labelled evidence.
That is each in-progress row's own `mission:` tag (a slice tag is optional),
or for an untagged row the nearest local handoff ancestor's mission, plus
blocked rows as held work and pending rows as possible next work. The
strict `currentWork` derivation is unchanged.
- One direct, resolved tag is shown as the work, labelled with its source so
the agent can correct a stale tag. Anything less certain lists every
candidate's one-line intent and asks the agent to name its mission (or to say
it has no current work), giving the `--work-start` route that then shows
that mission's intent as text. The project-level chain appears only as
labelled orientation.
- A tag that does not resolve on this host is named, never replaced. A
retargeted handoff or a remote ancestor gives no assumed mission. A mission
named only in body text is not evidence.
- The seat's `LEARNED.md` contributes its `MY JOB HERE` and `STANDING DUTIES`
sections, and notes and each node's `PROGRESS.md` contribute their
`Current state` section. When a file has
no such heading, the packet says so and shows a bounded excerpt. No heading
is required.
- A notes or `LEARNED.md` file older than
`OPENRIG_REFOCUS_NOTES_MAX_AGE_DAYS` (default 14) is named with its age, the
age's source (`updated:` frontmatter, else mtime) and the threshold: a cue
for judgment, not a staleness verdict.
- Each field is bounded on its own, with no total cap. A held or next row's
summary and continuation are one line each (about 160 characters), with
`rig queue show <id>` named when one was shortened. At most 20 held and 10
next rows are shown, with how many there are and the `rig queue list` command
for the rest. A matched section's body is cut at about 1,500 characters,
pointing to `--depth full`.
`trace-to-root.py --packet --work-candidates <path|->` renders the same packet
by hand.
## Relation to chain files
The chain files are the durable, altitude-addressed home of orientation
+2 -26
View File
@@ -191,7 +191,6 @@ integrity:
| `rig_spec` | string | yes | Relative path to the rig spec within the archive. Safe relative path. |
| `agents` | AgentEntry[] | yes | Array of vendored agent entries. |
| `culture_file` | string | no | Relative path to the culture file if present. |
| `included_login_files` | string[] | no | Exact archive-relative harness login files deliberately included by the producer. Only these paths are exempt from the login-file refusal; this unsigned declaration is not authentication or receiver approval. |
| `integrity` | Integrity | no | Per-file SHA-256 checksums for content verification. Validation doesn't require it, but install refuses an archive without it and inspect reports integrity FAIL. |
| `provenance` | object | no | Stated, not verified: `created_at`, `daemon_version`, `cli_version`, `source_host`, `author_session`, `source_rig_id`, `source_rig_name`, `notes`, and `source` for a GitHub link build. |
| `assembler` | object | no | `openrigVersion` of the OpenRig that built the archive. |
@@ -222,28 +221,6 @@ integrity:
| `original_ref` | string | yes | The original import ref before rewriting. |
| `hash` | string | yes | SHA-256 hash of the imported agent.yaml. |
### Harness login files
By default, create refuses `.claude/.credentials.json`, `.codex/auth.json` and `.pi/agent/auth.json`
at the root or nested anywhere in selected bundle content, including agents and an explicitly carried project.
The author check reports them too. Messages name paths, never contents, and explain both remedies:
remove the file from the agent folder or carried project and build again; or, only if recipients should have this
login (for example, your own internal test bundle) and its owner agrees, recreate with
`rig bundle create <spec> -o <archive.rigbundle> --include-login-files`.
Anyone with that archive could read the included login files.
The flag applies to this create only. The generated `included_login_files` manifest list names only the actual,
exact archive-relative login files included. Inspect, unpack, install and bootstrap honour those paths without
another flag and show a credential-bearing notice. An additional unrecorded login file still fails, as do malformed
records, directory or wildcard exemptions, and unrelated sensitive files. There is no machine setting or `--force`
bypass. The unsigned record states the producer's choice; it does not authenticate the producer, establish trust,
or constitute a fresh recipient approval. Review the source and files before using a bundle.
Compatibility change: previously accepted bundles containing these files now fail default creation and downstream
verification/install unless deliberately rebuilt with the record. Inspection remains diagnostic: it reports failed
integrity. Ordinary `auth.json` elsewhere and non-login harness files are unchanged. Filename checks do not scan
arbitrary contents for secrets.
### Integrity Section
| Field | Type | Required | Description |
@@ -295,7 +272,7 @@ where the daemon runs.
### Create a bundle
```bash
rig bundle create <spec-path-or-github-link> -o <output.rigbundle> [--rig-root <dir>] [--preset <name>] [--seat <pod.member=runtime>]... [--context-pack <dir>]... [--project-dir <dir>] [--name <name>] [--bundle-version <ver>] [--min-cli-version <ver>] [--min-daemon-version <ver>] [--notes <text>] [--include-packages <refs...>] [--allow-drift] [--include-login-files] [--json]
rig bundle create <spec-path-or-github-link> -o <output.rigbundle> [--rig-root <dir>] [--preset <name>] [--seat <pod.member=runtime>]... [--context-pack <dir>]... [--project-dir <dir>] [--name <name>] [--bundle-version <ver>] [--min-cli-version <ver>] [--min-daemon-version <ver>] [--notes <text>] [--include-packages <refs...>] [--allow-drift] [--json]
```
| Flag | Required | Default | Description |
@@ -312,7 +289,6 @@ rig bundle create <spec-path-or-github-link> -o <output.rigbundle> [--rig-root <
| `--min-cli-version`, `--min-daemon-version` | no | — | The oldest OpenRig CLI or daemon that may install the bundle, checked on install. For a link they override the author's `bundle.yaml` minimums. |
| `--notes <text>` | no | — | Notes stored in the manifest's provenance. |
| `--include-packages <refs...>` | no | all from the spec | Legacy (schema 1) specs only: the package refs to include. |
| `--include-login-files` | no | off | Only if recipients should have these logins and their owner agrees (for example, an internal test bundle), include them and record their exact archive-relative paths. Create and consumers show a credential-bearing notice. Does not exempt other sensitive files. |
| `--allow-drift` | no | off | Build a spec that differs from the running rig of the same name. Without it, create refuses; with it, the difference is written into the manifest's provenance notes. |
| `--json` | no | off | Machine-readable output. |
@@ -561,7 +537,7 @@ When an agent spec has `imports`, each import is resolved, vendored into the bun
7. Each agent must have `name`, `path` (safe relative), and `hash`
8. Optional blocks, when present, must be well formed: `provenance`, `compatibility`, `skills`, `plugins`,
`workflow_specs`, `context_packs`, `agent_images`, and `project` (an `id` made of letters, digits, `.`, `_` and `-`,
and a safe relative `path`), plus `included_login_files` (unique safe relative paths)
and a safe relative `path`)
The manifest validator doesn't check `integrity`; unpack does (below).
+14 -335
View File
@@ -174,12 +174,11 @@ edges:
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `version` | string | yes | — | Use `"0.2"`. Any non-empty string validates; a spec is treated as pod-aware because `pods` is an array. |
| `name` | string | yes | — | Rig name. Used in session naming (`{pod}-{member}@{name}`), snapshot identification, and spec library lookup. `rig up <spec> --name <rig>` launches the spec under another name instead (see below). |
| `name` | string | yes | — | Rig name. Used in session naming (`{pod}-{member}@{name}`), snapshot identification, and spec library lookup. |
| `summary` | string | no | — | Human-readable description. Shown in the spec library, review surfaces and `rig specs preview` (in `rig specs show` only with `--json`). |
| `culture_file` | string | no | — | Relative path to a rig-wide culture/constitution file. Must be a safe relative path (no `..`, no absolute). |
| `non_interruptive` | boolean | no | — | Pod-aware rig launch-warning default. With full-bypass Claude/Codex seats, true accepts or hides supported harness warnings using launch flags. Explicit CLI choices override this field; this field overrides the machine default. It does not grant permissions, change native settings files, or bypass login. |
| `permission_policy` | string | no | — | Permission policy attached to the rig. Either a built-in (`builtin:locked`, `builtin:standard`, `builtin:open`, `builtin:yolo`, `builtin:auto`) or a safe relative path to a custom policy file (resolved from this spec's directory; no `..`, no absolute, no empty segments, each segment `[A-Za-z0-9][A-Za-z0-9._-]*`), or `none`, a recorded choice of the floor. Absent uses the default floor with the scoped team launch allowances described below. A bare built-in name such as `yolo` is refused ("use 'builtin:yolo'"), and so is an explicit `null`. A custom file that is missing, unreadable or invalid isn't a validation error: it resolves to the floor, and preflight warns. A member may set its own `permission_policy` (not on a terminal member), which takes precedence over the rig-level one; there is no pod level. See "Attaching a permission policy" below. |
| `env` | map | no | — | Launch environment for every seat in the rig: `NAME: value` pairs. A member's own `env` overrides it name by name. Names match `^[A-Z_][A-Z0-9_]*$`, names starting `OPENRIG_` are reserved, and values must be non-empty strings (quote numbers and booleans). Values are not secret. See "Launch environment" below. |
| `managed_blocks` | map | no | `CLAUDE.md` | File that receives OpenRig's managed instruction blocks for Claude Code members. Only the `claude-code` key is accepted, with `CLAUDE.md` or `CLAUDE.local.md`. Codex, Pi and OMP members use `AGENTS.md`. See "Choosing the Claude instruction file" below. |
| `workspace` | object | no | — | The rig's workspace: `workspace_root` (required), `repos[]` of `{name, path, kind}` with `kind` one of `user`, `project`, `knowledge`, `lab` or `delivery` and unique names, an optional `default_repo` naming one of them, and an optional `knowledge_root`. Relative repo paths resolve against `workspace_root`. |
| `docs` | Doc[] | no | — | Documentation files that should travel with the rig. Included in rig bundles. Each entry has a `path` field (safe relative path). The engine does not consume these — they are for humans and agents setting up the environment before launch. |
@@ -188,25 +187,6 @@ edges:
| `pods` | Pod[] | yes | — | At least one pod required. Each pod is a bounded context containing members and pod-local edges. |
| `edges` | CrossPodEdge[] | no | `[]` | Cross-pod edges connecting members in different pods. Must use fully-qualified `pod.member` IDs. |
**Launching one spec under another name.** `rig up <spec> --name <rig>` creates the rig as `<rig>` instead of the spec's `name:`, so one spec can run as several rigs at once, for example `--name alpha` and `--name beta`, with seats `{pod}-{member}@alpha` and `{pod}-{member}@beta`. The spec file is not changed. The rig keeps the chosen name from then on: `rig ps`, `rig down alpha`, `rig up alpha --existing`, snapshots and restore all use it. The same checks as a launch without `--name` apply to the chosen name: a running rig of that name refuses the launch, and a stopped one is archived and replaced, as an explicit spec import does today. `--name` does not rename an existing rig, so the CLI refuses it with the name of an existing rig to restore, with a topology and with a GitHub bundle link. A library spec name works: `rig up starter --name beta` launches the library spec as `beta` even while a rig named `starter` exists. Over `--host`, pass the spec path or bundle file with `--name`. A spec that sets `services.project_name` refuses `--name`: every rig launched from it would share that Compose project, and `rig down` or a failed service boot of one would stop the others' services. Without `project_name`, each rig gets its own project. Two launches of the same spec started at the same moment can answer 409 (already in progress for this source) even under different names; start them one after the other.
### Starting one materialized seat
`rig import ./team.yaml --materialize-only` records the team without starting its harnesses.
For a seat that has never started, use its positional logical ID:
```bash
rig launch <rig-id> dev.lead --json
```
This starts only that seat, preserving the rig's node IDs and the other seats. The daemon retains
the materialized declaration and resolves its agent and startup files from the original source
root at launch time; keep those files available. Current saved model, effort and permission
choices take precedence over the retained declaration; launch reports the differences. A prior snapshot isn't required. Seats with
session history continue through restore. Explicit `--snapshot-id` and `--seats` use restore too.
`--plan` previews restore selection; it requires a snapshot and does not preflight this first start.
Rigs materialized by older daemons lack the retained declaration; this path doesn't retrofit them.
### Attaching a permission policy
Attach a policy to a rig with `permission_policy`, either at the rig level or on a member:
@@ -236,131 +216,6 @@ future managed launches of that stable seat; it does not rewrite this spec or
its inherited policy provenance. `inherit` removes that override. See
[per-seat permission mode](getting-started.md#per-seat-permission-mode).
### Per-member Claude tool rules
Claude members can record tool rules independently, even when they share a project folder:
```yaml
claude_permissions:
allow: ["Bash(node lane-reader.mjs *)"]
deny: ["Read(./private/**)"]
```
This field accepts only `allow` and `deny` lists of native Claude rule strings. It is
also supported on legacy flat Claude nodes. Other runtimes, `ask`, mode settings,
arbitrary settings and parenthesized MCP rules are rejected. Shape validation does
not establish support by every Claude version.
The initial spec records the rules on the stable seat. For an existing seat, put the
complete replacement in a JSON file and use the existing audited permission command:
```sh
rig seat set-permissions dev-reader@team --rules member-rules.json --reason "Select the reader lane"
rig seat status dev-reader@team --json
```
Choose either `--rules` or `--mode`, never both. Empty allow and deny lists clear the
seat's rules. Updating rules leaves the permission mode, running process and history
unchanged; they apply at the next separately authorized managed launch or resume.
Restore and same-seat handover read that same record. A fork copies the source's
currently recorded rules independently, including retained-seat updates. Export
contains the current record. An invalid or unreadable record refuses that seat's
launch, fork or export with a repair message rather than silently omitting its denies.
OpenRig composes these rules into its one operational `--settings` value, preserving
team/kernel defaults, hooks and managed-compaction settings. It never writes them
into the shared project's Claude settings. Without the field or a recorded update,
launch arguments are unchanged. Status reports the next-launch plan and lists the
added rules; it does not verify native enforcement.
Rules add to inherited native permissions; an allow list cannot withdraw broader
user or project grants. In bypass mode, allows do not constrain access. Managed
policy and installed mods can change rule behavior; see [Claude's permission
precedence](https://code.claude.com/docs/en/permissions#precedence-among-rules-hooks-and-mods).
These are native tool rules, not OS-user or credential isolation. Native acceptance
and effects must be checked on the Claude version and configuration being used.
**Downgrade:** a file-backed SQLite and launch-composition check against the
pre-feature reader at `75c1f045` found that it can open the upgraded database but
omits the recorded rules from launch arguments. This pair is not
restriction-preserving. The stored bytes survive and the supporting reader recovers
them; a process already launched by older code is unchanged. Keep a copy of the
intended rules and establish equivalent native restrictions before using an older
managed-launch path. Other version pairs and native enforcement need their own checks.
The launch still uses inline JSON, subject to the operating system's argument-size
limits.
### Per-member Codex permissions
Codex members can record their own sandbox and approval choice, even when they share a project folder:
```yaml
codex_permissions:
sandbox: workspace-write # workspace-write or read-only
approval: never # never, on-request or untrusted
network_access: false # workspace-write only
writable_roots: ["/abs/lease"] # workspace-write only; absolute paths
exclude_tmpdir_env_var: false # workspace-write only
exclude_slash_tmp: false # workspace-write only
```
OpenRig emits this envelope as explicit `-s`, `-a` and `sandbox_workspace_write.*` flags on
every managed launch and resume of the seat (fresh, both resume paths, restore and same-seat
handover), so a named `codex_config_profile` or a policy posture doesn't override those fields.
Omitted workspace-write fields are recorded as `false` or `[]` and emitted as such. Full access
is not an envelope: it stays the `full_bypass` mode. The envelope is the seat's explicit choice,
so team and kernel launch defaults don't apply to it, and status names the policy posture or
ambient `OPENRIG_YOLO` it replaces. With workspace-write, OpenRig still adds its queue-state root
and, on a fresh launch, git metadata folders; with read-only it adds no writable roots. It adds
no network setting of its own. Codex's implicit roots (the working directory and temporary
folders) still apply: this is a native sandbox and approval selection, not a complete
filesystem or credential-isolation guarantee. OpenRig's tests prove these launch flags, not
that Codex enforces them, so check the seat's actual behaviour before relying on a narrower
envelope. An OpenRig older than 0.7.3 doesn't read a recorded envelope, so after a downgrade
the seat launches without it. The field is also supported on legacy flat Codex nodes; other
runtimes reject it.
For an existing seat, put the envelope in a JSON file, or change its profile, with the existing
audited permission command:
```sh
rig seat set-permissions dev-impl@team --codex-permissions envelope.json --reason "Narrow the implementer"
rig seat set-permissions dev-impl@team --codex-profile lease-impl --reason "Use the lease profile"
rig seat status dev-impl@team --json
```
Choose one of `--mode`, `--rules`, `--codex-permissions` or `--codex-profile` per call. A seat
keeps one explicit permission choice: setting an envelope replaces a recorded mode, and setting
a mode (including `inherit`) clears the envelope, in the same audited write that names both.
A runtime switch keeps the envelope while the seat runs Claude Code and applies it again when
`rig seat set-runtime` brings the seat back to Codex. A switch back to Codex with an explicit
`--permission-mode` replaces the envelope when the switch commits, and the switch's plan and
event name it. A switch that doesn't commit leaves it in place.
A profile name is separate from the envelope and from approval: a resumed conversation keeps
its saved approval unless an envelope sets one. `--codex-profile inherit` clears the profile.
The profile command checks the profile in the config home this seat's launches use: the seat's
own `CODEX_HOME` when its `env:` declares one, else the daemon's explicit `CODEX_HOME`. There it
reports an absent file (Codex loads it as an empty layer, so base config applies), records the
file's hash so a later check can report a change, and refuses a profile that fails to load,
keeping the previous one. When neither sets a `CODEX_HOME`, a seat's pane keeps its own, and the
command reports the profile as unverified rather than checking a different home. Every launch
still runs the native profile check.
Updates leave the running process and history unchanged; they apply at the next separately
authorized managed launch or resume. Restore reads the seat's current record, so an older
snapshot doesn't bring back a replaced profile. A fork copies the source's current profile and
envelope independently. Export contains the current record. A new generation from `rig up
<spec>` starts from the spec. An invalid or unreadable envelope refuses that seat's launch, fork
or export with a repair message rather than launching broader. Without the field or a recorded
update, launch arguments are unchanged.
**Downgrade:** an older OpenRig keeps the stored envelope but doesn't read it, so its launches
of the seat use the earlier posture decision, which can be broader. This is not
restriction-preserving. Keep a copy of the intended envelope and establish equivalent native
restrictions before using an older managed-launch path. Native enforcement must be checked on
the Codex version and configuration being used.
### Team launch defaults
A non-kernel Claude or Codex seat with no authored permission policy, explicit
@@ -479,61 +334,6 @@ context for `rig seat continue`; `rig up --non-interruptive` or `rig bundle inst
except inside a recognized sandbox. See Claude Code's
[Choose a permission mode](https://code.claude.com/docs/en/permission-modes#skip-all-checks-with-bypasspermissions-mode).
### Launch environment
`env:` sets environment variables for the seats of a rig, so a team can share a setting such as a feature flag, a region or a log level without every member hand-editing a shell profile. Declare it on the rig, on a member, or both:
```yaml
env:
LOG_LEVEL: info
REGION: eu
pods:
- id: dev
members:
- id: impl
runtime: claude-code
env:
LOG_LEVEL: debug # overrides the rig's value for this seat only
```
- A seat gets the rig's `env` with its own `env` laid over it, name by name.
- The same environment is applied on first launch, on `rig up <rig-name>` restore, and to the successor of a seat handover.
- `rig up <spec> --env KEY=VALUE` (repeatable) adds or overrides a value for one launch, except `CLAUDE_CONFIG_DIR` and `CODEX_HOME`, which the CLI leaves out with a note (see "A seat's own account" below). A one-off value belongs to the terminal that launch creates: it stays in that terminal's environment for as long as the terminal lives, and OpenRig removes it from the tmux session's environment as soon as the terminal has started, so a terminal started later in that session does not get it. If OpenRig can't take it out, the seat keeps running and the launch warns, naming the variable and the tmux command to put it back or remove it. Until then, a terminal started later in that session would get it. A restore, a handover successor or any later launch starts from the spec's `env:` alone, so the value is not saved and does not carry forward. To keep a value, put it in `env:`. One exception to "does not carry forward", for a managed Claude seat (see below): a one-off name that the seat's spec does not declare stays on the session's list of names, `OPENRIG_SEAT_ENV_KEYS` (names only, no values), because the first launch and an in-place restart of the same terminal read that list. So if a later pane in that session gets a value for that name from the tmux server's own environment, its managed Claude launch can forward that value, where a launch without `--env` would leave it out. The first launch's one-off value itself is not passed on to that pane. This comes from reading the code and has not been measured. `--env` does not apply to `rig up <rig-name>` (a restore) or to a topology (several rigs from their own specs); the CLI refuses both. For a topology, put `env:` in each rig's spec.
- **The runtime directory, on Linux.** When the daemon runs with an absolute `XDG_RUNTIME_DIR` (usually `/run/user/<uid>`), a seat gets the same `XDG_RUNTIME_DIR`, so a tool that keeps its socket there, such as an MCP server's daemon or `gpg-agent`, works inside the seat without per-tool setup. It comes with `DBUS_SESSION_BUS_ADDRESS=disabled:`, an address that D-Bus clients refuse. sd-bus, GDBus and libdbus look for your session bus at `$XDG_RUNTIME_DIR/bus`, or start one, only when that variable is unset, so a seat doesn't find your session bus (the keyring, notifications) on its own. This is a default, not isolation: `systemctl --user` reaches your service manager through its own socket in the runtime directory, as any tool there can.
- A seat's own value wins. Set `DBUS_SESSION_BUS_ADDRESS` in `env:` to give a seat a bus. Set `XDG_RUNTIME_DIR` there to choose the directory yourself; the seat then gets no bus address, as before, so its D-Bus clients find the bus in that directory. A name listed in tmux's `update-environment` counts as the seat's own too.
- A shell startup file that runs in the seat's terminal can still change either name. One that sets only `XDG_RUNTIME_DIR` now leaves the seat with `disabled:`, where before its clients found your bus; set the bus address in `env:` or in that file to keep it.
- With `terminal.inherit_tmux_environment` on, a seat gets tmux's environment as before, and OpenRig adds neither name. On macOS, and for a daemon started without `XDG_RUNTIME_DIR`, nothing changes.
- Codex passes only a fixed list of variables to the MCP servers it starts, so a Codex seat's MCP server gets the runtime directory only when its entry in `config.toml` names it: `env_vars = ["XDG_RUNTIME_DIR"]`. Codex's hooks and shell commands get it without that, and so do Claude Code's MCP servers and hooks. Pi and OMP agent processes don't get it (see below).
- It applies at a seat's next launch, restore or handover; a seat that is up keeps the environment it started with.
- Names starting `OPENRIG_` are reserved for OpenRig and refused. Empty values are refused, because the launch omits empty variables.
- `rig export` writes the rig and member `env:` back out.
**Where the declared names reach, by runtime.** Declared values are handed to the seat's terminal as tmux environment, so most seats take them as given:
- Plain Claude Code (no permission mode selected), Codex and terminal seats take a spec `HOME`, `PATH` or `CLAUDE_CONFIG_DIR` as given.
- The cleared environment (`env -i`) belongs only to a managed Claude launch, which is the one used when a permission mode is selected for the seat (for example `permission_policy: builtin:auto`). It forwards the declared names through the cleared environment, except `PATH`, `HOME` and `CLAUDE_CONFIG_DIR`, which OpenRig sets for that launch itself, and `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`, for which it sets its own renderer setting. A spec value for any of those four is not forwarded as such; an absolute `CLAUDE_CONFIG_DIR` becomes the config dir OpenRig sets (see "A seat's own account" below). A seat that selected the shipped `openrig-core` plugin also has `CLAUDE_CODE_PLUGIN_DIRS` (the vendored plugin, added after a list the seat declares itself) among its declared names, so it is forwarded like the others and the plugin's hooks load in a seat with a permission mode as in one without; a seat that didn't select the plugin has no such name added. Beyond the declared names, the launch also forwards, by name and only when the seat's terminal has them set, the terminal and locale variables (`TERM`, `COLORTERM`, `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LANG` and the `LC_*` names) and the person's editor and display session: `EDITOR`, `VISUAL`, `DISPLAY`, `XAUTHORITY` and `WAYLAND_DISPLAY`. They let Claude's external-editor action open the person's editor and let image paste reach the clipboard, without a value in a spec. `WAYLAND_DISPLAY` is passed as an absolute path: an absolute value is kept, and a relative one is joined to the terminal's `XDG_RUNTIME_DIR`; if the terminal has no `XDG_RUNTIME_DIR` for it, the name is left out. The runtime directory and its `disabled:` bus address (see "The runtime directory, on Linux" above) are forwarded together, and the runtime directory is also forwarded beside a declared `DBUS_SESSION_BUS_ADDRESS`. A terminal's own `XDG_RUNTIME_DIR` or `DBUS_SESSION_BUS_ADDRESS`, from a shell startup file or tmux's `update-environment`, is not, because D-Bus clients look for the session bus at `$XDG_RUNTIME_DIR/bus` when no address is set; declare it in `env:` to forward it. `XDG_SESSION_TYPE` and `XDG_CURRENT_DESKTOP` are not forwarded. The SSH agent socket (`SSH_AUTH_SOCK`) is forwarded when OpenRig itself was started with one, so git over SSH works in the seat; a socket only the seat's terminal has stays out. To check, run `ssh-add -l` inside the seat. If it can't reach an SSH agent, start the OpenRig daemon from a terminal where `ssh-add -l` works. A running seat picks the names up at its next managed Claude launch, not before: `rig seat launch <seat> --fresh --stop --reason "<why>"` for that seat alone (a new conversation), or a restore of the whole rig (`rig down <rig>`, then `rig up <rig> --existing`, which resumes each conversation but restarts every seat in the rig), or a handover. A seat that is up keeps the environment it started with.
- Codex seats run OpenRig's own Codex executable whatever a spec `PATH` says. They use the seat's own `CODEX_HOME` when its `env:` declares an absolute one, else the daemon's when the daemon has one.
- On Pi and OMP seats the values reach the terminal. The agent process gets only what the runner passes on, so a spec or one-off value reaches it only for the names the runner always passes: `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL` and `TMPDIR`. Pi's agent process receives the runner's `HOME`, so a spec or one-off `HOME` value that reaches the runner is passed to Pi; OMP's agent process gets its seat state directory (`$OPENRIG_HOME/state/omp/<seat>`, see Oh My Pi below) as `HOME`, so a `HOME` value does not reach it. The selected provider's variables are passed separately, as described under each runtime.
**A seat's own account.** Claude Code keeps its login in its config dir and Codex in its home, so a seat whose `env:`
declares an absolute `CLAUDE_CONFIG_DIR` (Claude Code) or `CODEX_HOME` (Codex) runs on whatever login that directory
holds. The member's value wins over the rig's. OpenRig follows it everywhere it launches or reads that runtime: the
launch, Claude's first-run trust settings, Codex's managed `config.toml`, hooks and plugin, conversation capture for
resume, context usage, `rig seat status` and `rig provider status`. A value that isn't an absolute path isn't used:
the seat resolves as if it declared none, and `rig seat status` shows the ignored value. Name the home in `env:`: a
one-off `rig up --env` value for either variable isn't applied, because a restore, a handover and `rig seat status` read
only the stored `env:`. Seats may share a home. Live usage refresh (`rig provider status --refresh`) still reads only the
daemon's own logins; reading each seat's home would need a native reader per home. OpenRig doesn't copy logins or conversation history between homes, so a conversation saved under a seat's earlier home
doesn't resume after the seat moves to another one. The `openrig-user` skill has the setup steps.
**Do not put secrets in `env:` or `--env`.** The values are not secret, and nothing here encrypts or hides them (see #306):
- Spec values are stored in OpenRig's database and in rig snapshots, and `rig export` and bundles copy them, so a secret put there by mistake travels with the rig.
- While a seat launches, the values are passed to tmux as command arguments, so other users and processes on the same machine can see them for that time. They then sit in the seat's process environment. Only the managed Claude launch command is value-free: it forwards names, and the terminal supplies the values.
- A one-off `--env` value is not stored in the database or in snapshots, but it reaches tmux the same way, so it is visible in the same ways while that launch runs.
Use `env:` for settings only. Credentials belong in the provider's own login or a secret store the seat reads at run time.
### Choosing the Claude instruction file
OpenRig writes its instructions for Claude Code members into managed blocks in
@@ -596,66 +396,27 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
| `agent_ref` | string | yes | — | Reference to an AgentSpec. Must start with `local:` (relative) or `path:` (absolute). Exception: `builtin:terminal` for infrastructure nodes. |
| `profile` | string | yes | — | Profile name from the referenced AgentSpec. Use `default` for the default profile. Exception: `none` for terminal nodes. |
| `codex_config_profile` | string | no | — | Codex-only native profile passed as `-p <name>`; letters, numbers, `_`, `.`, `-`. Separate from the AgentSpec `profile`. With the normal launch mode, this replaces OpenRig's explicit workspace-write sandbox flag. A full-bypass policy instead emits danger-full-access and omits this profile argument. |
| `codex_permissions` | mapping | no | — | Codex-only sandbox and approval envelope, emitted as explicit flags on every managed launch and resume; see [per-member Codex permissions](#per-member-codex-permissions). |
| `runtime` | string | yes | — | Agent runtime. Current supported values: `claude-code`, `codex`, `pi`, `omp`, `terminal`, `stub`. `stub` is a deterministic test harness, not an agent: a Node runner in the seat's tmux pane goes through the normal launch, skill projection, startup-file and readiness path, follows `<cwd>/.openrig/stub/script.json` when present, and calls no model. See "What a stub can and cannot prove" in `docs/as-built/test-layers.md`. |
| `cwd` | string | yes | — | Working directory for the agent. A relative path resolves against the rig root (the directory containing the rig spec). If that path exists as written, it is used. Otherwise a leading `~` or `~/` expands to the home directory (`~user` does not) and `$VAR` / `${VAR}` expand from the daemon host's environment; a relative result still resolves against the rig root. A reference to an unset variable stays as written, so the cwd is that literal path and works only if it exists. Use `"."` for the rig root itself. Can be overridden at launch time with `rig up --cwd`. A cwd inside the OpenRig installation fails preflight unless `--cwd` is given. Editing this declaration does not update an existing seat; use [`rig seat set-cwd`](agent-startup-guide.md#change-an-existing-seats-working-directory) to save its future launch directory. |
| `cwd` | string | yes | — | Working directory for the agent. A relative path resolves against the rig root (the directory containing the rig spec); an absolute path is used as is. Use `"."` for the rig root itself. Can be overridden at launch time with `rig up --cwd`. A cwd inside the OpenRig installation fails preflight unless `--cwd` is given. |
| `label` | string | no | — | Human-readable member name. Shown in UI when present. |
| `model` | string | no | — | Model override. Runtime-specific (e.g., `claude-opus-4-6` for Claude Code). |
| `effort` | string | no | — | Reasoning effort override, trimmed. Claude gets `--effort`, Codex `-c model_reasoning_effort=…`, Pi `--thinking` (see "Pi" below). A non-string value is ignored with an advisory. |
| `advisor` | string | no | — | Claude-only Advisor model for this seat. Overrides the AgentSpec profile/default; blank or non-string values are ignored with an advisory. |
| `effort` | string | no | — | Reasoning effort override, trimmed. A non-string value is ignored with an advisory. |
| `permission_policy` | string | no | — | Member-level permission policy; overrides the rig's. Not valid on terminal members. |
| `env` | map | no | — | Launch environment for this seat, laid over the rig's `env`. Same rules as the rig-level field. Valid on terminal members too. See "Launch environment" below. |
| `agent_file` | string | no | — | Claude members only. A Claude agent definition (a `.md` file with `name` in its frontmatter) for this seat. It's copied into the seat's own storage and launched as `--agent openrig:<name>`. A path beside the spec: no `..`, not absolute. |
| `append_system_prompt_file` | string | no | — | Claude members only. A prompt file appended to Claude's system prompt for this seat. It's copied into the seat's own storage and passed as `--append-system-prompt-file`. A path beside the spec. |
| `plugin_dirs` | list | no | — | Claude members only. Claude plugin folders for this seat, each copied into the seat's own storage and loaded through `CLAUDE_CODE_PLUGIN_DIRS`. Paths beside the spec. Each is copied under its folder name, so two folders with the same name are refused; an empty list is none. A missing file or folder fails that seat's launch by name. `rig bundle create` carries all three; `rig export` names a seat whose additions it doesn't carry. |
| `role` | string | no | — | The seat's role, letters, digits, `_`, `.` and `-`. Not valid on terminal members. |
| `restore_policy` | string | no | the AgentSpec's | Restore behavior. One of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`. The default comes from the AgentSpec's `defaults.lifecycle` (else `resume_if_possible`) as narrowed by its profile; a member value may only narrow it further, in that order. |
| `compaction_strategy` | string | no | — | `default-compaction`, `managed-compaction`, `handover` or `apprentice-handover`; `harness_native` and `pod_continuity` are accepted as deprecated aliases, and `custom_prompt` is refused. `managed-compaction` and `apprentice-handover` create their jobs only for `claude-code` seats; see [Codex compaction and sizing](agent-startup-guide.md#codex-compaction-and-sizing). |
| `compaction_strategy` | string | no | — | `default-compaction`, `managed-compaction`, `handover` or `apprentice-handover`; `harness_native` and `pod_continuity` are accepted as deprecated aliases, and `custom_prompt` is refused. |
| `mechanic` | string | no | — | A canonical `seat@rig` session address, for `apprentice-handover`. |
| `session_source` | object | no | — | Start the seat from an existing session: `fork` with `native_id`, `rebuild` with `artifact_set`, or `agent_image` with `image_name`. A Claude Code `fork` needs Claude Code 2.0.73 or later. Not valid on terminal members. |
| `session_source` | object | no | — | Start the seat from an existing session: `fork` with `native_id`, `rebuild` with `artifact_set`, or `agent_image` with `image_name`. Not valid on terminal members. |
| `starter_ref` | object | no | — | `{ name }` of an agent starter (`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) whose files lead the seat's startup. Not valid on terminal members or with a fork. |
| `startup` | StartupBlock | no | — | Member-level startup files and actions. Applied only to this member. |
### Claude Advisor (`runtime: claude-code`)
Set `advisor` independently of the primary `model`:
```yaml
members:
- id: owner
agent_ref: local:agents/implementer
profile: default
runtime: claude-code
cwd: .
model: claude-sonnet-5-5
effort: medium
advisor: opus
```
OpenRig passes `--model claude-sonnet-5-5 --effort medium --advisor opus` to this
Claude session. The primary model stays Sonnet. Advisor resolution uses the member's
`advisor`, then the profile's `preferences.advisor`, then the agent's `defaults.advisor`.
Blank candidates are skipped before applying this precedence. Materialization stores
the resolved value, so node/graph output, exported specs and snapshots include it even
before the first launch. Launching the original spec re-resolves its current preferences.
The selection is supplied again on resume, restore, fresh restart and handover. A fork
uses the resulting member's configuration and keeps its own conversation identity.
This requires a Claude Code version that supports `--advisor`. OpenRig never writes
`advisorModel` to user or project settings. Members sharing a checkout can therefore
choose different Advisors. When Advisor is unset, no flag is emitted and Claude's
own defaults/settings apply. Other runtimes ignore this field.
### Pi (`runtime: pi`)
`runtime: pi` launches the Pi coding agent (`pi --mode rpc`) through OpenRig's RPC runner.
- **State:** Each seat uses `$OPENRIG_HOME/state/pi/<session>/agent` as its Pi agent directory, with `sessions/` beside it, instead of your default `~/.pi/agent`. The runner sets `PI_CODING_AGENT_DIR` to that directory. `<session>` is the seat's session name, unchanged; for a pod member it is `{podId}-{memberId}@{rigName}`. `$OPENRIG_HOME` defaults to `~/.openrig`. OpenRig creates these directories at launch and does not copy anything from `~/.pi/agent`.
- **Thinking level:** A Pi seat's effort (the choice saved with `rig seat set-effort`, else the spec's `effort`) is sent to Pi as `--thinking <level>` on every managed launch and resume: fresh launch, successor and restore. Pi accepts `off`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`; OpenRig passes the value as written and does not translate it, so Codex's `none` is not mapped to `off`. Only a value of the shape `set-effort` accepts (a letter, then up to 31 letters, digits, `-` or `_`) is sent; any other spec value, such as one with a space or one that starts with `--`, is left out, and `rig seat effort` says so. An unfamiliar level gets an advisory (a warning from `set-effort`), never a refusal, and Pi itself ignores an unknown level with a warning and keeps the level it would use without the flag: a model suffix, the resumed session's level, or its default. With no effort resolved, no flag is sent. The effort wins over a model suffix, and the model string is never changed. `rig seat effort` reports the level sent at launch and, on Pi 0.99.0 or later, the `thinkingLevel` Pi records on this launch's latest successful assistant turn (error and aborted turns are skipped); that is the session's level after Pi's own cap, not proof the provider applied it, and it is shown apart from the level sent when they differ. On older Pi it is NOT VERIFIED. Without an effort, you can still set the level as a model suffix, for example `model: openrouter/x-ai/grok-4:high`. Pi first looks for a model whose id is the whole pin, and uses it when there is one, suffix included. Only when there is none does Pi read the suffix as the level and record the model without it. Any other suffix, such as OpenRouter's `:free`, stays part of the model id. The model-divergence check reads the pin literally when the seat's `models.json` defines a model with that exact id; otherwise it accepts the model with or without the suffix. If the seat's `models.json` can't be read, a pin with a suffix stays unchecked, with the usual warning, when the recorded model matches the whole pin or the pin without its suffix. A record that matches neither is reported.
- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi/<session>/agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file. A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`.
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
- **Extension dialogs:** The RPC runner shows extension `select`, `confirm`, `input` and `editor` requests in the pane and cancels them because the managed pane cannot answer them. The seat needs attention through the end of the turn, until a new agent run starts. Review the cancelled request and send an instruction the extension can complete without a dialog. Extension `notify` text is shown without raising attention or sending a response.
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
### Oh My Pi (`runtime: omp`)
@@ -664,8 +425,8 @@ own defaults/settings apply. Other runtimes ignore this field.
- **State:** Each seat uses `$OPENRIG_HOME/state/omp/<seat>/agent` and `sessions/` instead of your default `~/.omp` profile, and runs with that seat directory as `HOME`. OpenRig does not copy OMP credentials. The runner finds the real `omp` binary before switching `HOME`, so a version-manager shim such as mise on the daemon's `PATH` still works.
- **Credentials:** Provision each seat separately, or put the provider's key variable in `recovery.provider_auth_env_allowlist` (for example `ANTHROPIC_API_KEY` or `MISTRAL_API_KEY`). The allowlist accepts the key variable of every provider in OpenRig's OMP provider map, from `anthropic` through `litellm`. A seat receives a key only when its `model` is written as `provider/id`, such as `anthropic/claude-sonnet-4-5`. Short names such as `opus` pass no key. A provider whose key is scoped to one endpoint also accepts that endpoint's variable: `anthropic`, `openai` and `litellm` each take their `<PROVIDER>_BASE_URL` — `ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL`, `LITELLM_BASE_URL`. The allowlist admits each variable on its own, so an endpoint named without its key is admitted too, which suits a keyless local proxy. The launch gate admits these endpoint names for OMP seats; the match against the seat's declared `provider/id` happens at the OMP child, which forwards a matching endpoint already in the runner's environment just as it forwards a key. OMP also reads the launch directory's `.env`, so use a trusted working directory.
- **Approval posture:** The default floor is `--approval-mode always-ask`. Because the runner is headless, OMP approval requests are cancelled and the seat stays in needing-attention after the turn ends, until the next agent run starts. A `full_bypass` permission policy selects `--approval-mode yolo`.
- **Model errors:** Errors are printed in the pane. A completed assistant failure with a numeric `errorStatus` of 408, 429, 500, 502, 503, 504, or 529 returns to idle at terminal `agent_end`, allowing an ordinary retry. Until that terminal boundary it stays in needing-attention. Intermediate `agent_end(isTerminal:false)` events do not release attention. A statusless failed retry summary retains that classification only when its attempt matches an announced retry and its final error exactly matches the latest typed transient assistant failure in that attempt. New assistant/turn boundaries invalidate the previous failure; unmatched summaries remain runtime errors. Rejected prompts, auth/configuration failures, exhausted retries without a typed transient status, and untyped errors (including text such as `529 overloaded`) retain attention until the next agent run starts. The runner does not classify diagnostic text. A transient failure does not clear a denied approval or another unresolved runtime failure in the same turn.
- **Restore:** OMP creates its session file after the first persisted turn. A new seat with no persisted turn has no resume token, so a restore leaves it awaiting a decision. Start it fresh with `rig seat launch <seat> --fresh --reason "<why>"`, the command `rig start` and `rig up --existing` print for that seat. After that file exists, OpenRig restores that exact session file. If a full rig restore leaves an OMP seat in `attention_required` or `failed`, `rig seat clear-attention` cannot yet reconcile it to `operator_recovered`, even with `--reason`, because restore reconciliation only verifies Claude Code and Codex processes ([#41](https://github.com/mvschwarz/openrig/issues/41)). Relaunch that seat with `rig seat launch <seat> --fresh --reason "<why>"`, or restore it manually. `rig seat launch --fresh` refuses a managed seat whose pane is still running unless you add `--stop`, which ends what is running there; add it only when you mean to replace that session.
- **Model errors:** A rejected prompt, a provider or authentication error during a turn, or exhausted automatic retries is printed in the pane and keeps the seat in needing-attention until the next agent run starts.
- **Restore:** OMP creates its session file after the first persisted turn. A new seat with no persisted turn has no resume token; restoring it requires `rig up --existing <rig> --fresh <seat>`. After that file exists, OpenRig restores that exact session file. If a full rig restore leaves an OMP seat in `attention_required` or `failed`, `rig seat clear-attention` cannot yet reconcile it to `operator_recovered`, even with `--reason`, because restore reconciliation only verifies Claude Code and Codex processes ([#41](https://github.com/mvschwarz/openrig/issues/41)). Relaunch that seat with `rig up --existing <rig> --fresh <seat>`, or restore it manually.
### Terminal Nodes
@@ -679,10 +440,6 @@ profile: none
All three must be present together. Any partial combination is a validation error.
A terminal seat no longer waits for a `--fresh` decision just because it has no resume token: on restore it relaunches fresh and re-runs its startup actions. The runtime, identity and replay prerequisites still apply. A runtime mismatch still waits for a decision, an ambiguous occupant still fails, and a missing required replay file still blocks.
`rig send` to a terminal node types the exact text, without the From/To envelope that agent seats get, because the shell would run the envelope lines. The sender's output says so.
### agent_ref Rules
- Must start with `local:` or `path:`
@@ -718,11 +475,7 @@ This is human-authored (you choose the pod/member IDs) and system-validated (the
| `spawned_by` | Source was spawned by target; the target launches first. Constrains launch order. | Child → parent in hierarchical topologies |
| `can_observe` | Source can observe target's output. Does NOT constrain launch order. | Reviewer → implementer, monitor → worker |
| `collaborates_with` | Peer collaboration relationship. Does NOT constrain launch order. | Co-equal peers working together |
| `escalates_to` | Fallback queue escalation for a seat without a declared delegation parent. Does NOT constrain launch order. | Local member or same-daemon `pod-member@rig` address |
A named `escalates_to` target may be in another rig on the same daemon and need not exist at import time.
See [fallback escalation across rigs](edge-types.md#fallback-escalation-across-rigs) for precedence, single-hop delivery,
cycle detection and visible fallback outcomes.
| `escalates_to` | Source escalates to target for decisions. Does NOT constrain launch order. | Worker → lead for escalation |
### Pod-Local Edges
@@ -757,68 +510,12 @@ edges:
Cross-pod edges must reference different pods. An edge where both `from` and `to` are in the same pod is a validation error — use pod-local edges instead.
### Changing edges on an existing rig
Use `rig edge add` to ensure relationships between seats that already exist. It
accepts a rig name or ID and **fully qualified logical IDs for both same-pod and
cross-pod edges**. This command only changes edges; it does not create pods,
restart sessions or change seat identity.
```sh
rig edge add my-rig orch.lead review.reviewer --kind delegates_to --plan --json
rig edge add my-rig orch.lead review.reviewer --kind delegates_to --json
```
Run it once per edge. It returns `added` with the new edge's ID, or `present` with
the existing edge's ID when that `{from, to, kind}` is already there, and then
writes nothing. Existing duplicate rows from other creation paths are retained;
this operation does not create more copies.
Unknown endpoints, an invalid kind or a launch-order cycle leave the graph
unchanged. Both endpoints must belong to the target rig. `--plan` performs the
same validation and returns `would_add` for a new edge without writing anything.
Each `added` receipt includes a rollback command,
`rig edge remove <rig-id> <edge-id>`, which removes exactly that edge in that rig.
Removal also supports `--plan` and `--json`. Removing an edge that is already gone changes nothing
and returns `edge_not_found` (HTTP 404, exit 1), so a repeated removal reports not found, not success.
Rig names resolve only among unarchived rigs, and an ambiguous name requires an
explicit ID. These commands target the local daemon, as `rig add` does.
A `delegates_to` edge also decides routing. A seat's stuck-row findings and recovery go to its most
recently started parent. A person's top-level Slack message in the rig's mapped channel goes to the rig's
representative, its one delegation root, unless that channel names an `inbound` seat; with no single root it
goes to Slack's inbound destination. When an added or removed edge changes either, the receipt says what
moved in `routing`:
`orchestrator: {seat, from, to}` and `representative: {from, to}`, where `null` is none. The plain output
prints a `routing:` line for each. A change that moves nothing has no `routing`, and `--plan` doesn't
preview it.
Changes are persisted immediately and included in `rig export`. Existing snapshots
remain unchanged, and restore reads the **selected snapshot's edges**. To restore
the modified topology, run `rig snapshot <rig-id>` and use its returned ID with
`rig restore <snapshot-id> --rig <rig-id>`.
---
## Startup Block
Startup blocks can appear at three levels: rig, pod, and member. They are merged additively via the startup layering model (see [the layering model](agent-startup-guide.md#the-layering-model)). The rig's `culture_file` joins as a required file with the `auto` hint in the culture layer, on both fresh start and restore.
`rig export` includes the rig's saved culture file and startup block, and now also
writes each pod's saved startup files under that pod. The file order, delivery
hints, required flags, orientation and `applies_on` selections are retained.
Previously the saved pod file lists were omitted. Pods without a recorded file
list, including older rigs, keep their existing export behavior; the exporter
does not reconstruct declarations from a member's combined startup context.
File paths remain relative to the exported spec's folder. When exporting to a
different folder, supply the referenced startup files there or import with the
original `--rig-root`. The CLI warns once per missing required rig or pod file;
missing optional files do not trigger that warning. Pod startup actions and
member startup blocks remain outside this export; keep the source spec when you
need those declarations.
### Files
| Field | Type | Required | Default | Description |
@@ -827,7 +524,7 @@ need those declarations.
| `orientation` | string | no | — | Set to `role` to identify a per-seat role file for `rig queue whoami --json` and refocus. Does not change delivery or replay startup. |
| `delivery_hint` | string | no | `auto` | How the file is delivered. One of: `auto`, `guidance_merge`, `skill_install`, `send_text`. |
| `required` | boolean | no | `true` | Whether startup fails if this file cannot be delivered. |
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this file is delivered. Subset of: `fresh_start`, `restore`. An exact resume of the saved conversation delivers none, apart from putting back a missing managed guidance block. |
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this file is delivered. Subset of: `fresh_start`, `restore`. |
#### Delivery Hints
@@ -845,8 +542,8 @@ need those declarations.
| `type` | string | yes | — | Action type. One of: `slash_command`, `send_text`, `startup_proof`. Note: `shell` is explicitly NOT supported in v1. |
| `value` | string | yes | — | Command/text to send, or `authenticated` / `none` for `startup_proof`. |
| `phase` | string | no | `after_files` | When to execute text/commands. One of: `after_files` (after startup files are delivered), `after_ready` (after harness readiness check passes). Proof selection is resolved before projection regardless of phase. |
| `idempotent` | boolean | yes | — | Whether this action is safe to run again on a restore without the earlier conversation or a retried start. **Required field.** Non-idempotent actions must NOT include `restore` in `applies_on`. |
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this action runs. Subset of: `fresh_start`, `restore`. An exact resume of the saved conversation runs none (see the startup guide). |
| `idempotent` | boolean | yes | — | Whether this action is safe to replay on restore. **Required field.** Non-idempotent actions must NOT include `restore` in `applies_on`. |
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this action runs. Subset of: `fresh_start`, `restore`. |
### Startup proof selection
@@ -891,26 +588,8 @@ The harness readiness window defaults to 30 seconds. On a loaded machine, set
`rig config set runtime.readiness_timeout_seconds 60` to give new seats and
handover successors longer to become interactive. The setting accepts 1–600
seconds, applies to the next launch without a daemon restart, and can also be
set with `OPENRIG_RUNTIME_READINESS_TIMEOUT_SECONDS`. It also bounds how long a
restore keeps watching a Claude seat whose resume is still starting, so raising
it for slow fresh launches lengthens that wait too. The wait is about the
setting in total per seat, counting the usual 3.2-second check: the adapter polls
for the rest of it (nominally: it counts polls, and each poll's tmux and process
reads add a little). A restore
starts seats one after another, so each seat that never starts Claude adds up to
about this much, minus the 3.2 seconds, to the restore; the time adds up seat by
seat, and a longer setting means a longer wait before a stuck seat is reported.
A seat that is still not interactive then ends `attention_required` with its
launch and resume token kept, and the later readiness wait is skipped for it.
That holds at every setting: at 1–3 seconds there are no extra polls, but an
unresolved boot is still kept rather than failed. A pane that shows a Codex or
another conversation's Claude is not waited for. `rig up` and
`rig launch` wait 120 seconds for the daemon, so a restore of several stuck
seats can pass that (in one measurement, about 31 seconds a seat at the
default, 95 for three). A client timeout and exit code 1 do not mean the
restore was rolled back or that it should be run again; check the real state
first with `rig ps`. Apart from this, the setting does not change the time
spent by the runtime adapter before readiness polling starts.
set with `OPENRIG_RUNTIME_READINESS_TIMEOUT_SECONDS`. It does not change the
time spent by the runtime adapter before readiness polling starts.
---
+2 -35
View File
@@ -49,22 +49,8 @@ Authored files contain recommendations, not runtime facts. Extra fields are igno
any authored runtime, model or activity. At read time the command joins by exact host self-id
and seat. Unmatched or conflicting observations are `unknown`; a seat not reported running is
`stale`, with current runtime/model unknown. The configured model is the registry declaration.
Daemon node listings, `rig ps --nodes --full` and roster members also carry the full
`modelObservation`: `status`, `model`, `source`, `observedAt`, `generation` and `reason`.
Compact JSON uses the smaller shape described below. The configured `model` stays separate; roster's
`live.observedModel` is the observation's model or null. Human output says **last observed**.
Claude (`claude-assistant-response`) and Pi (`pi-assistant-response`) name response evidence;
Codex (`codex-turn-context` or `codex-world-state`) names recorded turn configuration, not a
verified backend response. The timestamp is the native record's time, never file mtime or inventory
read time. None promises the model that will answer the next turn.
The daemon reuses the current-occupant record selectors and bounded readers used by divergence
monitoring, sharing a process census. Reporting does not need a configured model or comparator.
A changed or unverified occupant, a record predating its tenure, missing or invalid record time,
unreadable or ambiguous records, unsupported runtimes and older daemons report **UNKNOWN** with
a reason, never the configured value as a fallback. An older record from the same verified occupant
remains labelled last observed at its original time; this is not a freshness guarantee. No record
contents, transcript paths or credentials are returned. Activity includes its own sample time;
The observed native model remains unknown: these commands do not open native histories or verify
what model the provider is running. Activity includes its sample time when the daemon supplies it;
activity and running status do not promise that someone is available for new work.
The JSON `observations.readAt` is when the inventory read completed, not a native observation
@@ -73,22 +59,3 @@ valid rosters remain usable; duplicate IDs make `show` report ambiguity instead
Non-regular file targets, such as FIFOs and directories, are skipped with a warning; links to
regular files remain readable. Inventory uses `ps --no-cleanup` to preserve even stale local
daemon state files. Ordinary `rig ps` keeps its existing cleanup behavior unless given that flag.
The daemon reuses model facts only while the selected record's identity, size and
modification time and the occupant generation remain unchanged. Inventory reads
share two bulk pane snapshots and one process census; identity is checked after
the final snapshot. A different observation from duplicate host routes makes
only that observation UNKNOWN, preserving agreed registry fields.
`rig ps --nodes --json` keeps an observed model compact as
`modelObservation: ["literal-model-id", "evidence-source", "native-record-time"]`.
UNKNOWN is `{ "status": "unknown", "reason": "..." }`; older daemons may yield
`null`. `--full` and `--fields modelObservation` retain the full object, including
its status and occupant generation. Human output and roster reports use the full
labelled observation.
A member marked `"hosted": true` is a hosted member: a tool outside the terminal that reaches the team over MCP, with no node or pane.
`rig_team` lists it with its capabilities and caveat, no pane and activity unknown, unless its seat is one of the rig's nodes.
`show` and `find` say it's hosted rather than unmatched. See [Hosted members](hosted-members.md).
Its entry can declare `wake.webhook`, the secret names for a trigger OpenRig rings when work lands for it
(see [Ring it when work lands](hosted-members.md#ring-it-when-work-lands)).
@@ -212,11 +212,7 @@
"description": "Host settings (for example OPENRIG_YOLO, persisted or native settings) decide the effective permission at launch."
},
"permissionPrompts": {
"enum": [
"off",
"on",
"default"
],
"enum": ["off", "on", "default"],
"description": "Archive declaration per seat: off for full_bypass (including builtin:yolo) on any runtime; on for a Claude/Codex flag-surface floor policy, or Codex under auto; default for Claude/Codex with no declared policy. Omitted for Claude under auto, config-surface policies, host-resolved Codex profiles, and Pi except declared full bypass. Absent means not stated, never on. Host settings may differ; nativeEffect remains unknown."
},
"sourceRefs": {
@@ -421,10 +417,7 @@
"commands": {
"type": "array",
"description": "Unexecuted author setup lines, in order in one shell; the install command follows in the resulting working directory.",
"items": {
"type": "string",
"minLength": 1
}
"items": { "type": "string", "minLength": 1 }
},
"status": {
"const": "not_checked"
@@ -466,102 +459,6 @@
}
}
}
},
"services": {
"type": "array",
"description": "Optional opt-in service declarations from archived Compose YAML. Host-resolved values remain unresolved; no Compose or health probe ran.",
"items": {
"type": "object",
"required": [
"name",
"composeFile",
"image",
"ports",
"privileged",
"capAdd",
"securityOpt",
"devices",
"environment",
"mounts",
"healthcheck",
"waitFor",
"downPolicy",
"unresolved",
"notes"
],
"properties": {
"name": {
"type": "string"
},
"composeFile": {
"type": "string"
},
"image": {
"type": "string"
},
"ports": {
"type": "array",
"items": {
"type": "string"
}
},
"privileged": {
"type": [
"boolean",
"string"
]
},
"capAdd": {
"type": "array",
"items": {
"type": "string"
}
},
"securityOpt": {
"type": "array",
"items": {
"type": "string"
}
},
"devices": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "array",
"items": {
"type": "string"
}
},
"mounts": {
"type": "array",
"items": {
"type": "string"
}
},
"healthcheck": {},
"waitFor": {
"type": "array"
},
"downPolicy": {
"type": "string"
},
"unresolved": {
"type": "array",
"items": {
"type": "string"
}
},
"notes": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
},
"allOf": [
+2 -12
View File
@@ -81,18 +81,8 @@ their bindings and ten-field records. They do not imply either operating posture
Only human-led/delegated bindings participate in posture precedence. The legacy
`effective` and `posture: known|unknown_posture` fields remain for compatibility;
`unknown_posture` there means no legacy mode binding and does not override the
new `operatingPosture.source: product-default` result. A saved legacy mode and a
posture are kept side by side at the same target: a write replaces only the
binding of its own kind. Inspect effective posture after any mode change.
Eight of the record's fields are stored, validated and shown, but nothing in
OpenRig acts on them yet: `autonomy_scope`, `heartbeat_cadence`,
`inspection_depth`, `update_detail`, `escalation_threshold`, `concurrency_limit`,
`permission_prompt_posture` and `expiry_or_stale_rule`. `rig mode effective`,
`rig mode defaults` and the `rig mode set` preview mark them `(stored; not applied)`.
`scope` is the binding's key, and the posture reads `evidence_citation` into its
`binding.evidence`. `permission_prompt_posture` never changes a native permission
prompt.
new `operatingPosture.source: product-default` result. Setting another mode at the
same scope replaces that row; inspect effective posture after any mode change.
## Diagnosis effects
+2 -58
View File
@@ -37,7 +37,7 @@ one does not deprecate the others. Nothing below deletes or overrides an existin
| **PRODUCT / MISSION PREPARATION** | **The product-management pass** — the layer above planning, run by the advisor/PM seat before planners touch a mission. The build is a lossy compression pipeline (PM intent → planner spec → builder code); nothing leaks downward by proximity, so this pass grounds in the raw sources, synthesizes ONE scope of record, presents owner decisions decision-ready, mints slices at plan time, deposits the full design contract INLINE in each design-bearing spec (owner words verbatim, settled decisions, evidence, shape of done, anti-goals), routes the complete reading list and internet-research assignments from the spec itself, frames each slice's planning-dial rung with its reason, and rereads every spec as the narrow downstream reader. The measuring stick: SPEC.md alone, plus what it explicitly routes to, reproduces the intent in a reader with none of the author's context. Full reference: `product-management-pass.md`. |
| **PLAN** | **The planning dial** — planning rigor is a spectrum chosen per piece: P0 mini-requirements and pointers (simple, reversible work) · P1 an authored spec with a proof contract (the default) · P2 plus a research round before the spec freezes, run BEFORE build dispatch · P3 plus an adversarial pass by a non-author, judged against product goals, landing amendments with proof-contract teeth · P4 plus a blind from-scratch design diffed against priors before proceeding. Dial up by what the piece is, exactly as build care is priced below. Full reference: `planning-dial.md`. |
| **BUILD** | **Part A, the simple flow** (below — the default) · **product-journey components** — a mission-selectable menu for greenfield work, drift recovery, public journey probes, one evidence-gated look at the layer below, minimal repair, independent QA/review, integration, and installed smoke; compose only what the work needs in mission/slice YAML, never as a new mode or mandatory pipeline. Full reference: `product-journey-sdlc.md` · **the wave model** — slices build in parallel in disjoint file territories, an integrator merges serially, and independent review fires once per wave (two reviewers with different vantages, never the writers; fix rounds re-earn verdicts at one final revision; per-slice discipline stays failing-test-first and verify-by-effect). The wave IS the care dial: what shares a wave decides how much review each piece gets · **Part B, the rigorous overlay** (below — only when assigned). These combine: a wave can carry one Part-B piece. Full wave reference: `wave-sdlc.md`. |
| **DISPATCH DATA** | **The execution-data conventions (S27)** — three tiny data disciplines that make execution state derivable instead of hand-tabulated, consumed by `rig view show execution`. **EC-1, edges in frontmatter:** every open slice carries its hard edges in `depends_on:` (an inline JSON array; an explicit `[]` is a statement, absence is INDETERMINATE; when the slice's `slice.yaml` carries `execution.depends_on`, that list takes precedence); soft/serialization edges are one machine-readable Territory line, exactly `SOFT-AFTER: [ids] — reason`. **EC-2, wave-as-data:** wave composition + review model live in `mission.yaml` `arrangement.waves` (`{id, slices, review_model?}`; a slice may also name its wave in `slice.yaml` `execution.wave`). Only when the mission has no `mission.yaml`, or it is malformed, does the view fall back to the legacy form: the newest queue row tagged `format:wave-map-v1` and `mission:<mission>` whose body carries one fenced ```json block (`{format:"wave-map-v1", mission, waves:[{id, slices, serialized_order?, review_model?}]}`) — composition only, never status labels (status derives at read time); the planning dial recorded at lock surfaces as the frontmatter field `approved-spec-dial:` beside the approve stamp. **EC-3, dispatch names the worktree:** build batons carry the exact body line `worktree_path=<path>` from dispatch onward — it is Q1's join key; batons without it join by naming only and the view marks them `fragile_join`. **Lanes:** Q1 lists each in-progress row as a lane; a row parked on `external:<what>` also appears as a waiting lane, marked `waiting: {on}`. `external:` records an outside dependency (a review, a decision, a capacity limit, a lifecycle step); it does not mean an outside party owns all the work, or that the parked seat is the slice's builder. The views show it as a waiting row (the seat, the blocker, a link to the row), not as the slice's assignment. It yields to an in-progress lane on the same slice, and it is not counted in `lanes_live`, so `q1_lanes` can be longer than `lanes_live`. Rows parked on a qitem, a human seat, or a `fold:`/`auth:` gate are not lanes. Existing batons are never rewritten. |
| **DISPATCH DATA** | **The execution-data conventions (S27)** — three tiny data disciplines that make execution state derivable instead of hand-tabulated, consumed by `rig view show execution`. **EC-1, edges in frontmatter:** every open slice carries its hard edges in `depends_on:` (an inline JSON array; an explicit `[]` is a statement, absence is INDETERMINATE; when the slice's `slice.yaml` carries `execution.depends_on`, that list takes precedence); soft/serialization edges are one machine-readable Territory line, exactly `SOFT-AFTER: [ids] — reason`. **EC-2, wave-as-data:** wave composition + review model live in `mission.yaml` `arrangement.waves` (`{id, slices, review_model?}`; a slice may also name its wave in `slice.yaml` `execution.wave`). Only when the mission has no `mission.yaml`, or it is malformed, does the view fall back to the legacy form: the newest queue row tagged `format:wave-map-v1` and `mission:<mission>` whose body carries one fenced ```json block (`{format:"wave-map-v1", mission, waves:[{id, slices, serialized_order?, review_model?}]}`) — composition only, never status labels (status derives at read time); the planning dial recorded at lock surfaces as the frontmatter field `approved-spec-dial:` beside the approve stamp. **EC-3, dispatch names the worktree:** build batons carry the exact body line `worktree_path=<path>` from dispatch onward — it is Q1's join key; batons without it join by naming only and the view marks them `fragile_join`. Existing batons are never rewritten. |
| **RELEASE** | The release ceremony conventions (publication identity, honest release notes) and the CAPABILITY-DELTA LIFECYCLE LAW: each release's delta is authored at the fence and bound to the exact candidate, then reconciled to the published cut. Expiry requires both the canon header naming the absorbed delta and a distinct successor delta existing; a draft header alone is insufficient. Verify the actual boundary event and stop citing an expired delta. Full statement: `release-boundary.md` §6. |
| **BOUNDARY** | **The release boundary** — housekeeping run once per fence so the next release starts clean: seats reset or re-primed per their continuity needs, memory distilled (keep what changed a decision recently), every queue swept by destination and dispositioned, substrate torn down, boards frozen as records, a clean-box baseline captured. A checklist of judgment guides, not gates. Full reference: `release-boundary.md`. |
@@ -83,25 +83,6 @@ truth is reconciled to main. Continuous integration to main and a
release-line fold followed by one explicit merge are both valid; leaving the
choice implicit is not.
A candidate that merges by squash or rebase is never an ancestor of that ref, so
ancestry alone cannot read it as merged. A `merged:<pr-number>` tag on a queue row
bound to the slice (the `slice:` tag, like `candidate:`) is a recorded report of a
merge, not a verified one: `rig view show execution` shows it on the "merged" rung
(`folded`) as `merge_report` (`pr`, `row`, `recorded_at`, `linked`) and in the basis
text, and `recorded_at` is when the row recorded the merge, not when it happened.
A report never changes the merged answer by itself: without a candidate, with an
unresolvable candidate, with an unusable declared integration ref or with no
repository context, the answer, next-up and dependent slices stay as ancestry gives
them. A row that carries a `merged:` tag is a report, not a build, so it never picks
which commit counts as built: that stays the newest `candidate:<sha>` tag on a row
without a `merged:` tag. A report is `linked: true` when its own row's `candidate:<sha>`
resolves, through the repository context, to that built commit, and `linked: false`
otherwise (no candidate on the row, or a different commit). The answer changes only
for a linked report when ancestry answered false (not an ancestor): then the rung
reads merged and the basis says so. A linked report never turns an answer ancestry
could not give into merged. The report never changes which commit counts as built
or reviewed.
**The heavier flow — proof contracts, plan-lock, C1 drops, proof-lock — is PART B and is not in
force unless assigned.**
@@ -136,54 +117,17 @@ Legacy `README.md`, `IMPLEMENTATION-PRD.md`, and pre-convention trees remain
readable indefinitely. New scaffolds do not create or mirror them, and repair
is additive: it never deletes, renames, or rewrites legacy bytes.
A slice's frontmatter carries `stage:` (`wip`, `provisional`, `established`,
`canonical`, or the exits `superseded` and `retired`) and `verified:`
(`<date> against <source>`). Set them with `rig scope slice stage <slice> <stage>`
and `rig scope slice verified <slice> --against <source>`; no approval stamp
changes either. `rig scope` reads an `established` or `canonical` slice whose
`verified:` is missing, has no source, names a tool-scaffolded placeholder, or is
older than 90 days as `provisional`.
When the owning scope selects `proofPolicy.judges`, use `rig proof judge` to
record an attributed accept, reject or withdraw decision on a contract item,
and `rig proof show` to read derived readiness up through slice, mission and
project. The nearest slice, mission or project policy selects authorized judges;
`rig proof --help` describes setup, selectors and evidence requirements. In a
multi-project workspace catalog, name the project with `--project <id>` or a
`<id>:` scope prefix (`alpha:m0/slices/01-t001`); unqualified scopes use the
daemon's selected workspace. The prefix counts only when it names a catalogued
project and no scope exists at that literal path, so a mission folder with a
colon (`alpha:trial/slices/01-t001`) still resolves as written. The scopes views
read a catalog project's readiness against the same project root, so they agree
with `rig proof show`. A catalog-project read names the project
(`project: { id, root }`) and reports its source watcher as unavailable; a
judgment carries that root and is refused (409 `project_changed`) if the
catalog has since moved the project. A
`rig proof --help` describes setup, selectors and evidence requirements. A
correction preserves history and unrelated judgments without editing ancestor
status checklists. An artifact can identify non-code work without a fabricated
commit. Evidence capture (`proof add`), policy acceptance, higher outcome
judgment and publication are distinct. This optional component does not select
Part B or add a mandatory review gate.
A judgment binds the slice's path, the item's text and the policy. Moving or
renaming a slice (`rig scope slice move`, or by hand) reopens its judged items:
`rig proof show` reports each as `unknown`, and each needs a new judgment on its
current revision. `rig scope audit` checks structure only and never reads proof
readiness, so a passing audit doesn't mean the proof still stands.
The project readiness that `rig proof show --project` reports counts only
missions whose `mission.yaml` carries `metadata.status: active` or
`metadata.status: release-candidate`. `rig scope mission create` writes
`metadata.status: planned`, for ordinary and release missions alike, so creating
a mission does not change the project's readiness. A `planned` mission stays
outside the rollup, even after its slices are accepted, until someone changes
its status to `active` (or `release-candidate` for a release that is ready to
judge). Nothing changes the status automatically. Missions created before this
default carry no status and are not counted either; add the line by hand to
include them. It also writes `metadata.name`, the mission's folder name, which
the project lifecycle compiler requires; a mission created without it refuses to
compile with `lifecycle_field_missing` until you add that line.
The three sections project into the UI's one review structure: a vertical
stack of **INTENT → PLAN → DELIVERED**. A slice missing a section still
renders (a missing Intent or Delivered source shows a muted "—", missing
-263
View File
@@ -1,263 +0,0 @@
# Per-seat terminal delivery
The typing guard has six modes. `off` and `hold` are the two ends. Draft-aware adds an
opt-in check for unfinished input without a second control or daemon-owned retry
scheduler. Between off and hold, three interruption levels choose which automatic messages may
type: `openrig-urgent`, `urgent-only` and `sends-only`. Sibling seats keep their own mode.
| Mode | Terminal input | When input is unavailable |
|---|---|---|
| `off` (default) | Existing delivery and prompt checks | Existing send result |
| `draft-aware` | Paste into a recognized empty composer; check ownership again before Enter | Hold input and retain this attempt for inspection when bookkeeping is available |
| `openrig-urgent` | OpenRig's own messages (watchdogs, scheduled wakes, notices) and anything marked urgent type, never over a draft | Other agents' messages are held, the sender is told, and held messages stay readable |
| `urgent-only` | Only messages marked urgent type (`rig send --urgent`, or a queue item at priority urgent or critical), never over a draft | Everything else is held, the sender is told, and held messages stay readable |
| `sends-only` | Deliberate sends from people and agents type, as with `off`; a terminal member's typing isn't protected, because its shell input can't be read | OpenRig's own notices are held. A terminal member defaults to this level |
| `hold` | No automatic terminal input, even at an empty prompt | Retain for inspection outside the pane |
Lifecycle work is refused at `openrig-urgent` and `urgent-only`, as at `hold`.
A terminal member (`runtime: terminal`) defaults to `sends-only`, which holds OpenRig's own notices. Set one
to `off` and those notices, such as park reminders and other watchdog messages, type into whatever the pane
is running, and a shell prompt runs each line as a command, From and To lines included: OpenRig doesn't check
a terminal pane's foreground process. `openrig-urgent` and `draft-aware` type only into a recognized empty agent composer, so
a shell prompt holds them. Keep the default for a seat that may fall back to a shell.
Runtime permissions are separate. Recipient and interactive-prompt checks still
apply. `--raw` and `--force` do not bypass the typing guard. A deliberate
`--dangerously-interact --reason` remains available at every interruption level
and uses the existing-input check below. Direct human input remains available.
A message sent to a busy Codex seat joins its running turn and may redirect it.
For separate work, create a queue row instead:
`rig queue create --destination <seat> --summary "<one line>" --body-file <file>`.
A row that's saved keeps the work apart from its wake, the short pointer typed
into the seat. The wake is still typed input, and it isn't proof that the seat
claimed or finished the work: the row's state says that.
`rig send <seat> "<message>" --wait-for-idle <seconds>` suits a sender who
watches the result. If the seat is still busy, or its activity is unknown, when
the wait ends, nothing is sent (exit 1, "No text was sent.") and OpenRig keeps
nothing to send later.
## Configure and inspect
### Who may intervene
`rig seat status <seat>` shows the seat's terminal interaction account alongside
its native permissions and interruption level. These choices are independent.
The presets `no-peer-interventions`, `help-within-instructions` and `full-control`
set the peer row. Self and OpenRig retain their existing authority unless an
explicit exception changes it. Named helpers and the delegating instruction are
shown; OpenRig does not interpret free-text instructions as executable rules.
~~~sh
rig seat set-terminal-policy reviewer@my-rig --preset help-within-instructions \
--helper lead@my-rig --peer-model deny \
--reason "Help with compaction; keep the review model"
rig seat status reviewer@my-rig
~~~
Each `--peer-compaction`, `--self-compaction`, `--openrig-compaction`,
`--peer-model`, `--self-model` or `--openrig-model` exception takes `allow` or
`deny`. Model includes reasoning effort. Exceptions apply to the whole actor
row: `--peer-compaction allow` also allows unnamed peers. A choice with
exceptions reads as Custom. The setter replaces the complete choice; omit
exceptions to return to the preset's defaults. Outside a managed seat, use `--operator <address>` for
audit attribution. This does not establish an authenticated identity.
Enforcement covers managed compaction and saved model/effort changes only.
The latter take effect at the next managed continuation, not in the current
process. Other interventions remain guidance-only. Managed compaction applies
to Claude seats; it is not offered for Codex or Pi. A policy change during
preparation is checked before further input. It does not undo a submitted
`/compact` or abandon an already-owed restore/audit.
Existing seats retain current behaviour on upgrade. To choose a default for
newly added seats in a rig, add `--rig-default` to the setter above: existing
seats keep their own copied choices. OpenRig's standing managed-compaction
authority stays active unless its exception is deliberately changed.
Caller attribution is cooperative. An unattributed caller retains current
behaviour rather than being labelled the person or a peer. The existing
`rig send --dangerously-interact --reason` path remains available; this policy
adds no restriction there. Its existing-input check applies independently of the
typing mode. Plain tmux is not prevented.
### Deliberate interaction and existing input
A managed `rig send <seat> "<answer>" --dangerously-interact --reason "<why>" can
drive a recognized native menu as before. Otherwise, with the delivery guard
attached, it reads the visible composer under the seat's lease immediately before
pasting, after buffer preparation. Empty input proceeds. A recognized dim
suggestion is displayed separately and counts as empty; typed or pasted text
(author unknown) stops the send with `override_input_present`. An unreadable input
stops with `override_input_unknown`, never an empty-input claim. Both return single-send HTTP
409, `sent: false`, and caller-only `existingInput` evidence (visible rows, suggestion
rows, cursor/dimensions and target binding). A collapsed paste shows its label,
not the hidden contents. OpenRig neither holds nor retries this stopped override.
Inspect the shown input in the native terminal, deliberately clear the prompt with
native/tmux controls, then issue the send again. Manual clearing runs outside
OpenRig's lease and observation checks and is not recorded. For a deliberate
unchecked write, add `--type-without-checking` to that same command. It skips the
read and does **not** clear input: the text may join or submit what is already there.
The result names that choice. It requires `--dangerously-interact` and `--reason`.
Observed input is returned only to the caller, never saved in an outbox, held
message, capture-observer record or override audit. The audit keeps classifications,
counts and the choice, without input contents. The lease serializes OpenRig writes;
human typing can still race the capture and paste. Ordinary sends and wakes keep
their existing behaviour.
### Terminal delivery
Run from a seat shell with `OPENRIG_SESSION_NAME` set; the daemon derives the
audit actor from transport identity.
~~~sh
rig seat set-typing-guard dev-impl@my-rig --mode draft-aware \
--reason "preserve my unfinished input"
rig seat status dev-impl@my-rig
rig seat held-messages dev-impl@my-rig --json
rig seat held-messages dev-impl@my-rig --id <outbox-id> --json
rig seat retire-held-message dev-impl@my-rig <outbox-id> --reason "read outside the pane"
~~~
Use exactly one of `--mode off|draft-aware|openrig-urgent|urgent-only|sends-only|hold` or the compatible
`--enabled true|false`: true selects hold, false selects off. There are no
retry settings or deadlines. Activation waits for an already-started seat operation
to finish. The API may return HTTP 202 with `pending: true`; wait for
`pending: false` before relying on the requested mode. Status reports desired
and effective modes and retains the text `Typing guard: on (hold)` for hold.
A held send immediately returns `outcome: "retained"`, `sent: false`, its exact
reason (such as `draft_input_busy`) and the saved attempt's `outboxIds`. No automatic
retry is scheduled. Each explicit draft-aware request is a new attempt, including
one with a reused `deliveryId`; the result warns older callers that this field no
longer deduplicates input. The CLI does not generate a deduplication key.
Clearing a draft, changing modes or restarting does not flush saved messages or
callbacks. To try again, send a current request: it rechecks the recipient and input.
A caller abort before paste cancels input; after a write may have started, the
result preserves that uncertainty rather than claiming nothing was sent.
Bookkeeping is observational. If its write fails, an otherwise valid input attempt
still proceeds and the response includes a warning instead of a saved receipt.
Actual transport success stays success; an unknown write stays unknown. If input
was held and no record could be saved, the response reports failure with `sent:
false` and the protection reason, rather than claiming durable custody.
Reading never sends. Retirement frees that record's active retention quota while
preserving evidence; it does not deliver a message or close queue work.
## Input evidence and outcomes
The cursor-aware reader lives in `session-transport.ts` and shares the composer
matchers with ordinary delivery. It recognizes framed Claude input and Codex input
with a known footer. The visible input containing the cursor is checked independently
of activity hooks, again after buffer preparation, and immediately before Enter.
Copy mode, an unreadable capture and an unsupported layout cannot authorize input.
The final check preserves authored whitespace: the cursor distinguishes trailing
spaces from screen padding or omitted blank capture cells. A numbered message is
recognized as this operation's own text only within its previously empty composer;
a newly observed numbered menu is still refused. Human edits after paste stay
visible and Enter is refused.
Faint placeholders and autocomplete suffixes are display hints only when the
capture preserves their styling. Typing the same words and moving the cursor to
the start still counts as a draft. Current footer and indentation anchor the
Claude frame, so quoted prompt/frame examples inside a draft do not become proof
of empty input.
A large paste can appear as several labels plus a literal tail. The existing
`hasExpectedStagedText` check validates the tail and the labels' combined source
boundary. That complete rendering must be observed after this operation's paste
and remain unchanged before Enter. Labels alone do not prove arbitrary content.
| Historical delivery state | Meaning |
|---|---|
| `sending` | This saved attempt has an unresolved write outcome |
| `held` | No input was written; this attempt remains retained or has been retired |
| `complete` | Transport execution succeeded, including render verification if requested; native consumption is not asserted |
| `indeterminate` | Input may have occurred or requested render verification failed |
| `failed` | A definite failure before input, such as caller cancellation |
Migration `100_typing_guard_modes` extends the existing guard/audit rows and
adds receipt metadata to the original outbox rows; it creates no tables, retry
indexes or executable requests. Combined wakes retain each original ID and body.
A held queue wake stays on the existing queue recovery path. Interrupted historical
writes become indeterminate at startup. That history never suppresses a current
explicit request and is not consulted to determine its result.
At startup, the queue recovery pass retries still-current failed or unconfirmed
wake notifications. It preserves their original envelopes and warns that delivery
may repeat. Each notification is attempted once per pass, including coalesced
wakes; a failed attempt cannot spin in that pass. A no-write failure on a later
retry does not resolve an earlier unknown delivery. Prior-process claims are
reconciled before new startup sends can begin. Known deliveries and held or
retired messages stay untouched. Closed, claimed or reassigned work is not woken,
and the retry never repeats the queue mutation or a lifecycle action.
The existing retention limits apply: 100 records and 8 MiB per seat, with a 1 MiB
message limit. Hold mode cannot retain another message at capacity. Draft-aware
input attempts continue with a bookkeeping warning if a record cannot be saved;
already-committed queue intent remains durable.
## Rollback and lifecycle
Persisted legacy guard bits are on for both hold and draft-aware, so an older
daemon holds messages instead of typing into a protected seat. On re-upgrade,
the most recent guard audit row and change timestamp determine whether the saved
mode is still authoritative. A later boolean write by the older daemon takes
precedence, including a same-value on-to-on write in the same clock tick.
Without a later setting change, the draft-aware mode survives re-upgrade.
Held and uncertain deliveries are never replayed by this reconciliation.
Current API `desired` and `effective` booleans continue to describe hold-all;
mode fields describe draft-aware protection. Storage's fail-safe legacy bits
are deliberately broader.
With hold, writing lifecycle operations refuse before effects. With draft-aware,
an active seat requires an explicit switch to off before writing lifecycle work.
Fresh startup is exempt only after creating a new pane or successfully respawning
a dead pane within the same lifecycle operation. Adopting or rebinding an existing
pane grants no such exemption.
Terminal capture is observational, not an atomic editor API. A direct tmux writer
can still act between the last observation and paste. The resulting changed input
refuses Enter and is not replayed. Unsupported provider layouts remain held.
Use off when that tradeoff is unsuitable.
HTTP configuration uses `POST /api/seat/set-typing-guard/:seatRef` with
`{mode, reason}` or `{enabled, reason}`. Status and held-message routes are
unchanged; no RigSpec field is required.
## Verification scope
Regression tests exercise rollback/re-upgrade, same-value legacy writes, exact
retained outcomes, current requests after restart, coalesced queue bodies, trailing
spaces, numbered bodies, real-menu refusals and multi-placeholder ownership. Native tests
use private real tmux panes with scripted editable composers. These verify the
transport mechanics; they are not live Claude/Codex provider sessions.
## Watchdog reminders
A currently due watchdog reminder makes a new delivery attempt, including after an interrupted attempt or job
accounting. Historical outbox receipts do not suppress a lone reminder or prevent it from joining a digest. The
message tells its recipient that delivery may repeat after an unconfirmed attempt. Its job history records the
current transport result; an unknown write is not proof that nothing arrived.
The transport still protects approval/question prompts, human drafts and recipient identity. Scheduler timing,
current parked-row eligibility and stop checks still apply. This changes message retries, not the idempotence of
structured continuity actions such as creating a cutover baton.
The parked-owner supervisor also retries current obligations despite an earlier failed, unknown or unrecorded attempt.
Its first reminder to a seat waits until the seat's waiting state has been unchanged for 5 minutes, then goes on the next
watchdog tick, so a seat that just handed work off or asked a person isn't interrupted; a seat still holding the work after
that is reminded as before. A seat at an approval or question prompt gets no grace, so the prompt-blocked alert isn't delayed.
A recorded success to the same known occupant waits for a change in its obligations. Rotation includes retained attempts,
so a held input cannot starve another owner. Interactive prompts stay with the existing prompt-refusal recovery.
Its own reminders wait while managed compaction owes the turn boundary, restore or audit, including a fresh check
at the paste and Enter boundary. A stage that begins after paste leaves an honest partial-input result; OpenRig
does not erase or replay that text. Peer messages and unrelated reminder policies keep their existing behavior.
-17
View File
@@ -1,17 +0,0 @@
# Seat settings by node ID
Clients that present a specific seat can save its later-launch settings using its durable `nodeId`. The node ID is distinct from the current session and native conversation. These routes never launch, stop, or resume the seat.
| Read or save | Node-addressed route |
| --- | --- |
| Status | `GET /api/seat/status/node/:nodeId` |
| Native permissions | `POST /api/seat/set-permissions/node/:nodeId` |
| Permission policy | `POST /api/seat/set-policy/node/:nodeId` |
| Model | `POST /api/seat/set-model/node/:nodeId` |
| Effort | `POST /api/seat/set-effort/node/:nodeId` |
The POST bodies and responses follow the corresponding name-addressed routes. A successful save's `seat.nodeId` and `seat.rigId` identify the node actually updated. The exact status response adds `node_id`, `contract: "seat-node.v1"`, and `supported_actions` listing the four setters. Existing name status responses retain their shape.
An unknown, deleted, or archived node returns `seat_not_found` (HTTP 404) at node resolution. After awaited validation of a native Claude permission mode or Codex profile returns, the exact node-ID path checks availability again before saving; an unavailable node then returns `seat_not_found` without a setting or audit write. Existing native validation may refuse a changed target before this check: for example, Claude's managed context guard can report `permission_selection_refused` during preparation. The daemon never resolves a node ID through a reusable seat name. Setting writes have no general session precondition; a changed occupant on the same available node can receive a later save.
For example, `POST /api/seat/set-model/node/<nodeId>` accepts the existing `{ "model": "...", "reason": "...", "operator": "..." }` body. The existing audit actor, reason, native validation, permission precedence, and terminal policy rules still apply. The older `/:seatRef` routes remain available, including their name ambiguity behavior.
+29 -279
View File
@@ -30,13 +30,8 @@ been confirmed against a real creation run. Slack's form may ask for something t
not fill in; if so, follow the form.
1. Open the link from `rig slack manifest --url` in a browser.
> **Note on Slack's manifest dialog:** If opening the link results in an empty manifest `{}`
> or Slack reports an error translating the manifest, copy the output of `rig slack manifest`
> (run without flags) and paste it directly into the **YAML** tab in Slack's "Create an app from
> an app manifest" dialog.
2. Sign in to Slack if asked, pick the workspace, review the prefilled manifest, and click
**Next**, then **Create**. (If **Create** stays disabled, go **Back** and check that a
development workspace is selected and the manifest step shows no error).
**Create**.
3. Under **Socket Mode**, confirm it is enabled. Enable it if it is not.
4. Under **Basic Information → App-Level Tokens**, generate a token with the
`connections:write` scope and copy it (it starts with `xapp-`).
@@ -58,15 +53,6 @@ not fill in; if so, follow the form.
one, it is refused.
10. Run `rig slack setup --channel <channel-id> --secrets-env-file <path>`, then
`rig slack verify`, then `rig slack enable`.
11. Enabling treats the asks already open as seen, so none of them post. To see them in Slack, run
`rig slack post-open-asks`. Each goes through ordinary delivery at the level it was asked at
(quiet hours, digest and the posting floor apply), and the command says what became of each:
posted, already posted, queued awaiting its receipt, held, below the posting floor, no longer
active, superseded, recipient changed, state unavailable, transport failed or uncertain. An ask
asked at RECORD stays on Steering and the Feed without a post. Running it again reuses each ask's
one decision rather than making another, so an ask whose post is confirmed isn't posted again. As
with any Slack post, one whose earlier attempt Slack couldn't confirm may post once more, marked as
a possible repeat. `--wait <seconds>` (default 10) is how long it waits for Slack receipts.
## What the app asks for
@@ -77,10 +63,10 @@ groups:
a member of, and reading channel details.
`rig slack verify` checks these.
- **Feature scopes**: `files:read` (download attachments people send), `files:write` (upload
attachments to Slack), `app_mentions:read` (receive @-mentions of the app), `reactions:read`
(receive emoji reactions), and `users:read` (identify a named bot, below). `rig slack verify` warns
when one of these is missing (if Slack returns the granted scopes) but does not require them, so a
READY from verify does not prove attachments, mentions, reactions or named bots will work.
attachments to Slack), `app_mentions:read` (receive @-mentions of the app), and `reactions:read`
(receive emoji reactions). `rig slack verify` warns when one of these is missing (if Slack returns
the granted scopes) but does not require them, so a READY from verify does not prove attachments,
mentions or reactions will work.
If a feature scope was not granted, the effect differs by feature:
@@ -92,10 +78,8 @@ If a feature scope was not granted, the effect differs by feature:
Only `rig slack verify` warns that the scope is missing; nothing reports the missing events.
- **Reactions** (`reactions:read`): Slack does not deliver `reaction_added` events, so a reaction
on an ask doesn't reach the asking agent. A reply in the ask's thread still does.
- **Named bots** (`users:read`): Slack refuses the bot lookup, so a named bot's posts are not
delivered, and the daemon log names the refusal. Nothing else changes.
So after installing, compare the granted scopes Slack shows for the app with all eight scopes that
So after installing, compare the granted scopes Slack shows for the app with all seven scopes that
`rig slack manifest --json` lists.
The app subscribes to messages in public channels it is a member of (`message.channels`), to
@@ -114,22 +98,6 @@ questions by clicking a button (`rig queue create --human-questions-file`). In S
clicks arrive over the same socket, so no request URL is needed. An app created from an older
manifest has Interactivity off: turn it on under **Interactivity & Shortcuts**, or the buttons
will do nothing. A typed reply in the thread still answers the decision either way.
Each click gets one short reply in the decision's thread: what was recorded, or, when OpenRig
refuses the click or can't record it, why and what to do instead (the sender isn't registered, the
decision is no longer waiting for answers or was sent to someone else, the button no longer
matches its questions, or the message can't be matched to a decision). Slack's redelivery of a
click OpenRig already handled gets no second reply. The reply is best effort: while Slack is
rate-limiting OpenRig's posts it may not be posted, and the click is handled the same either way.
In the decision's `humanAnswers`, clicked answers remain option-id strings. A whole typed reply
is stored as `{kind: "typed-reply", text: "…", placement: "first-unanswered", unansweredCount: N}`
under the first unanswered question. That placement is automatic; it does not mean the person
selected that question. `N` counts the other question slots still empty in `humanAnswers` after
that placement. Earlier button answers are preserved; no other questions are filled in.
Read `text` as the person's words,
even when it equals an option id. The reply still closes the decision, so `done` is not approval.
A file-only reply closes the decision without recording a typed answer. If all button answers were
already recorded, they stay final and the typed reply remains in the correlated reply row.
## What the connector does with the tokens
@@ -146,9 +114,7 @@ part of OpenRig.
## One channel per rig or seat (optional)
Every ask to a person posts to Slack by default: that's their Slack subscription's `every-ask` preset. A person who wants
Slack for urgent asks only sets `rig gateway human set <id> slack-subscription urgent-only`: then only an ask marked
`--alert` posts, and the rest wait on Steering and the TUI. An ask marked `--alert` stands out either way (an interrupt with a mention). By default every post goes to the one channel from `rig slack setup --channel`.
By default every human-bound item posts to the one channel from `rig slack setup --channel`.
To follow each rig separately, post a rig's items, or a single seat's, to its own channel:
```bash
@@ -174,274 +140,58 @@ rig slack channel-map remove pr@my-rig
Without a map, OpenRig records nothing about a post's channel, so if the default channel
changes while a post is being retried, the retry goes to the new default channel (as it always
has); that also applies to a post first tried before the map was added.
- **Rate limits on posts.** When Slack rate-limits a post and asks for a pause of 10 seconds or
less, OpenRig waits that long and retries the post once. A longer pause, or a second refusal,
is left to the retry of retained posts.
- **Replies and new messages.** A reply in an item's thread reaches that item's seat, in any
channel. To answer it in the same thread, the seat sends `rig queue create --human-intent update
--summary "<subject>" --reply-to <the inbound reply's row>`.
- **A new message a person starts in a rig's channel** goes to that rig's representative, the
seat the rest of its rig reports to (its one `delegates_to` root). To send it to another seat,
name it: `rig slack channel-map set my-rig C0EXAMPLE1 --inbound lead@my-rig`. A rig entry on the
connector's default channel only sends that rig's posts there: new messages in the default
channel still go to the inbound destination, unless the entry names `--inbound`.
- **Every other new message,** in the default channel or a seat's channel, goes to the
connector's inbound destination, as does a reply in a thread OpenRig doesn't know, or a rig's
channel whose representative can't be found.
- **Answers come back to where the person asked.** A person's new message is the root of its
answer: when the seat it reached replies with `--reply-to` that message's row, the answer and
later updates land in the person's thread, in the channel they used.
- **One conversation, whoever writes.** Messages routed to the same continuing agent
conversation can use that conversation's earlier context, whoever sent them. A queue row
records who sent a message and which seat owns it; it doesn't start a separate agent
conversation. If people need separate conversations, route them to separate seats
(`--inbound` names the seat a channel's new messages reach). Separate channels help only when
their routing also keeps the conversations separate: new messages in a seat's own channel
still go to the inbound destination unless its entry names `--inbound`. A conversation shared
on purpose is fine.
- Missed-message recovery (below) covers the default channel only.
channel. A message the human starts (or a reply in a thread OpenRig does not know) goes to
the connector's inbound destination, whichever channel it is in. Missed-message recovery
(below) covers the default channel only.
Invite the app to every mapped channel (`/invite @OpenRig`). `rig slack verify` checks
membership in the default channel and every mapped channel, and reports NOT ready until the
app is in all of them. A saved change reaches a running connector when it next rewires, which a
daemon restart does (`rig daemon stop`, then `rig daemon start`). `rig slack disable --reason
"<why>"` then `rig slack enable` rewires too, but alerts still waiting to be posted when it's
re-enabled are treated as already seen and aren't posted. Only an earlier post that failed or
didn't finish still posts: an alert whose row is still open, a decision-resolved notice, or a digest. `rig slack post-open-asks`
then shows the asks still open. The TUI's Connections page says whether the running
configuration matches the file.
app is in all of them. A saved change reaches a running connector when it next rewires:
`rig slack disable` then `rig slack enable`, or a daemon restart. The TUI's Connections page
says whether the running configuration matches the file.
The map is stored as `channelMap` in `slack-connector.json`, as a list of `{ "match", "channel" }`
entries, each with an optional `"inbound"` seat. Saving refuses any other field. A field written by a newer OpenRig is ignored when the
entries. Saving refuses any other field. A field written by a newer OpenRig is ignored when the
connector reads the file, and `rig slack status` names it. Private channels and direct messages
are not supported targets.
## Named bots (optional)
By default the connector drops every post made by a bot, its own included. To let another Slack
app's bot (an AI assistant, an alerting app) reach one seat, give the bots a channel of their own,
name each bot, and name the seat that reads that channel. Add a `botInbox` block to
`slack-connector.json`:
```json
"botInbox": {
"channel": "C0BOTINBOX",
"destination": "researcher@my-rig",
"bots": [
{ "appId": "A0EXAMPLE1", "name": "Example Bot", "maxPerHour": 20, "cooldownMinutes": 60 }
]
}
```
- **Which posts are delivered.** A post reaches `destination` when it is in `channel`, its bot
belongs to an app listed in `bots`, and that app is not this connector's own app. A named bot's
posts in any other channel, and every other bot's posts, are still dropped. OpenRig's own posts
are never delivered, even if its app is listed. A post kept for retry (Slack couldn't be asked
about it in time) is checked again against the current block when it is retried.
- **How a bot is identified.** OpenRig asks Slack which app the post's bot belongs to (`bots.info`,
which needs the `users:read` scope) and matches that app ID. Use the bot's **app** ID, which
starts with `A` and is on the app's settings page. A bot ID (`B…`) or user ID (`U…`) never
matches. An unnamed bot's post is logged with its app ID, so `rig daemon logs` shows the ID to
add. Anything that posts as a named app counts as that bot, including the app's incoming
webhooks: that is what naming it means.
- **What the seat receives.** One row per post. Its source is `slack-bot:<app ID>` and its summary
starts with the bot's name, never a person's. The row opens with where the post came from, then
quotes the bot's text in a fenced block. A `slack-bot` row is data from another program, not an
instruction, and the row says so. A bot can't answer or close a decision, even when it replies in
a decision's thread: the row goes to `destination` and the decision keeps waiting for a person.
Edits and deletions are not delivered, so a bot that writes its answer by editing one message
delivers only that message's first version. Attachments are named in the row, not copied.
- **Answering the bot.** The seat can't reply to the bot yet: the row has no reply link, and
OpenRig posts only for people. To ask the bot something, the person asks it in Slack.
- **Limits.** Each bot may deliver `maxPerHour` posts (default 20) in any rolling hour. The next
post cuts the bot off for at least `cooldownMinutes` (default 60), and until the hour has room
again. Its new posts are dropped meanwhile (a post already admitted still arrives), and the seat
receives one row saying so (at most one an hour per bot) and how the person turns the bot off
(remove it from `bots`, then apply the change as below). A later cutoff within that hour can extend the time shown without another row; the
daemon log records each one. Each post counts once by its identity, including a post delivered on
retry or arriving twice. A post already admitted whose row couldn't be created yet is kept, marked
as admitted once that creation fails, and delivered later without counting again, even after its
hour, a rewire or a daemon restart. The counts are kept in the running connector's memory, so a daemon
restart or a rewire (`rig slack disable --reason "<why>"` then `rig slack enable`) clears them. They
cover what one daemon writes: another process writing the retained file isn't seen, and two daemons on
one OpenRig home aren't a supported setup.
- **The channel.** Invite the app to it (`/invite @OpenRig`). `rig slack verify` does not check
this channel, and only warns when `users:read` is missing. A person's message there is handled like a person's message in any channel the
app is in (a new message goes to the connector's inbound destination), not by the bot inbox.
Missed-message recovery does not cover the bot inbox.
`rig slack status` shows a `bot-inbox` line: what it delivers, or why it is off. An invalid block
(for example a bot ID where an app ID belongs) turns only the bot inbox off. A saved change
reaches a running connector when it next rewires, which a daemon restart does (`rig daemon stop`,
then `rig daemon start`). `rig slack disable --reason "<why>"` then `rig slack enable` rewires too,
but alerts still waiting to be posted when it's re-enabled are treated as already seen and aren't
posted. Only an earlier post that failed or didn't finish still posts: an alert whose row is still
open, a decision-resolved notice, or a digest. `rig slack post-open-asks` then shows the asks still open.
## One app across machines (optional)
Slack delivers each event of an app to one of its connections, so one app can serve several
OpenRig machines only if one machine, the hub, holds the connection and relays each other machine's
channels to it. Each machine still decides who its messages reach, keeps its own threads and
decisions, and posts its own replies.
- **On the hub:** the bot token and the app-level token, as usual, plus `forwardChannels` in
`slack-connector.json`, one entry per channel another machine owns:
```json
"forwardChannels": [
{ "channel": "C0EXAMPLE3", "host": "vps04" },
{ "channel": "C0EXAMPLE4", "host": "vps04" }
]
```
`host` is that machine's id in the hub's host registry (`rig host list`). It needs an http
entry, which `rig host pair` writes with that machine's token; the hub sends it with each relay,
and the machine checks it when it has `OPENRIG_AUTH_BEARER_TOKEN` set.
- **What the receiving machine trusts:** a relayed event comes from whoever holds that machine's
daemon token (or, with no token set, anything that can reach its daemon), not from Slack directly, and nothing checks Slack's own signature on it. The
credential issued by `rig host pair` is the daemon API bearer, not a relay-only credential; it also authorizes the other daemon write APIs that use that bearer. The
machine still looks up the sender in its own list of people, but on a relayed event that's no
proof a person just typed or clicked it in Slack: it trusts the caller for where the event came
from.
- **On every other machine:** the same app's bot token only, with no app-level token, so it opens
no connection of its own. Set its default `channel` to its own channel and, for a rig's channel,
a channel map entry. Its connector is enabled as usual. Its `rig slack status` reports
`relayedInbound`: that it can take relayed events, and how many have arrived since it started.
It also retries its own kept events every five minutes, including any it kept before it stopped
holding a connection of its own, so those still land.
- **What happens to a message:** the hub relays the event, a reaction or a button click untouched
to the machine that owns the channel. That machine handles it exactly as if it had received it:
who may send, where it goes, its thread and its decisions. If the relay fails (the machine is
down or its registry entry is wrong), the hub keeps it and retries every five minutes, after its
own kept events and clicks; `rig slack status` counts it in the dead-letter backlog. A machine
that's down costs one attempt per retry: its other relays wait for the next one. A relay the
machine keeps refusing is kept and retried with no limit, because dropping it would lose a
person's message.
- **Changing forwarding entries is not a drain or cancellation:** relays already retained keep
their original host and remain visible in `rig slack status`, even after the last entry is
removed. An enabled connector retries them; disabling it pauses retry without deleting them.
Let relays finish before removing a forward entry. A zero dead-letter backlog alone does not
prove they have drained: it does not count relay HTTP requests still in flight.
- **Who answered:** every post starts `From <seat> on <machine>`, using the machine's host id (set
it with `host.name`).
- **The hub's own channels** are handled on the hub, as before. A channel can be relayed to one
machine only, and never the hub's own default channel.
Messages that match neither forwarding nor an existing thread or channel route fall back locally
to `inboundDestination`; `rig slack status` names that policy and destination. These lists do not
establish exclusive channel ownership: two machines can still both handle a channel.
- **Missed messages:** each machine recovers the channels it owns: the hub its own channels, and
every other machine its default channel and its channel map's (the hub never recovers a channel
it relays). See [Reconnect recovery and status](#reconnect-recovery-and-status).
- **Not covered yet:** `rig slack verify` on the hub doesn't check the app's membership in relayed
channels.
## Quiet hours for a person (optional)
Quiet hours hold a person's routine asks while they're on, and post them once they end.
- **Set them** with `rig gateway human quiet set <entityId> --daily 22:00-07:00` (every day) or `--until 07:00` (from now
until then), with `--tz` for the person's zone. `show` reports whether they're on, `end` ends tonight's early ("I'm back";
a daily rule stays for tomorrow), and `clear` removes the rule. A person's mode can set them too (`rig gateway human
mode save … --quiet`).
- **While quiet is on,** an ask that would post to the person isn't posted and doesn't mention them, whatever their delivery
class: it's held, and its row records the hold.
- **Not held:** an urgent ask, meaning one tagged `escalation` or `wake-escalation` that isn't an update, and the operator's
wake-ladder alerts. These reach the person exactly as they would outside quiet. An ask below the posting level is only
logged, as at any time.
- **When quiet ends,** each held ask that never started posts once, on its own, with no mention. One that no longer applies
isn't posted, and its row says why: it closed, a newer notification replaced it, its recipient changed, or it's now below
the posting level.
- **A post that may already be in Slack**, confirmed or only partly sent, isn't held: it finishes as itself, in its own
thread.
- **If quiet hours can't be read,** nothing is posted to that person until they can.
## Reconnect recovery and status
The daemon scans available top-level messages and replies broadcast with **Also send to
channel** in the channels this machine owns: its default channel and its channel map's, except a
channel it relays to another machine. It also scans replies in the threads it tracks in those
channels whose root was posted, or which closed, within the last seven days. A root's age is its
Slack posting time, so rebuilding the thread map doesn't make an old thread eligible again. An old
thread that closed within seven days is scanned from its root, so this isn't a seven-day history
limit. Missed replies in older open threads aren't caught up; live delivery is unchanged.
The hub scans after a Socket Mode connection and on its existing five-minute retry cadence. A
machine the hub relays to scans every five minutes, and recovers only the channels it owns that the
hub has relayed it an event for. Each such channel's coverage starts when its first relayed event
arrives, and is kept across restarts, so a restart needs no new event. A channel the machine no
longer owns isn't scanned, and a machine nothing is relayed to never reads its channels' history.
Broadcast replies keep their thread's routing. Recovered tasks say **Recovered after a gap** and
show the original Slack posting time. A recovered thread reply lands as a reply in its thread,
marked late, and never answers a decision, even one still open: the seat reads it and decides. A
recovered broadcast reply does the same in every channel except the default channel of the machine
that holds the Socket Mode connection, where it behaves as before; a relayed machine's default
channel counts as newly covered. A dead-lettered recovered reply keeps that mark when it's retried.
The daemon scans available top-level messages in the configured channel after a
Socket Mode connection and on its existing five-minute retry cadence. Recovered
tasks say **Recovered after a gap** and show the original Slack posting time.
They use the same sender admission, routing, attachment handling, fixed identity
and dead-letter path as live messages. A durable dead letter is custody of a
failed delivery, not successful delivery.
Recovery keeps a per-channel checkpoint across reconnects, restarts and channel
switches. For the hub's default channel it initializes once from the newest retained accepted
channel landing; without such evidence it starts at feature adoption. Every other channel starts
when recovery first adopts it. **Older history is unknown.** A tracked thread's checkpoint starts
at its root, so its first scan reads the whole thread; replies that already landed are recognized
and not delivered twice. New live messages never move the recovery checkpoint. A
switches. On upgrade it initializes once from the newest retained accepted
channel landing; without such evidence it starts at feature adoption. **Older
history is unknown.** New live messages never move the recovery checkpoint. A
partial scan resumes its saved interval; only an exhausted interval advances the
covered boundary. The checkpoint moves past a message once it landed, was a duplicate, was
dead-lettered, was refused because its sender isn't admitted, or was skipped as a kind inbound
doesn't take (a bot's post, an edit or join, an empty message). Each new interval ends five seconds before the local clock to
covered boundary. Each new interval ends five seconds before the local clock to
allow recent messages to become visible; larger clock skew or visibility lag is
not covered by that margin. A corrupt or unreadable checkpoint leaves recovery unavailable
and preserves the existing file; live inbound continues.
Each pass admits at most four history calls, 100 entries and 15 seconds of work, with a
five-second request ceiling. Channels and threads take turns within that one budget, so with many
tracked threads each thread is scanned every few passes; `rig slack status` shows how far behind
the oldest waiting thread is. Work already started on a message, such as
Each pass admits at most four pages, 100 entries and 15 seconds of work, with a
five-second history-request ceiling. Work already started on a message, such as
an attachment transfer, is not cancelled by these limits or by a stop, and a new
pass waits for it to finish. Rate limits retain Slack's Retry-After across restart, and hold back
only the method Slack limited (channel history or thread replies).
pass waits for it to finish. Rate limits retain Slack's Retry-After across restart.
Transport and server failures use a five-second backoff unless Slack supplies Retry-After.
When Slack reports a plan history limit, recovery still lands the available page
and advances normally, retaining an older-history limitation in status across restarts.
Missing bot credentials, history scope, membership, retention limits and API
failures appear as recovery limitations. A tracked thread Slack can't find stays incomplete in
status, with no further call, until its thread mapping changes or the daemon restarts or rewires
the connector; that isn't proof the thread was deleted. No additional scope is required to keep
live delivery working. Threads nobody tracks, older open threads, threads closed more than seven
days ago and other channels remain outside this recovery scope; global chronological order is
not promised. This change does not replay intervals already checkpointed before broadcast-reply
recovery was added.
failures appear as recovery limitations. No additional scope is required to keep
live delivery working. Thread-reply catch-up and dead-connection detection remain
outside this recovery scope; global chronological order is not promised.
`rig slack status` retains local configuration checks and adds a bounded daemon
snapshot: socket state/generation, last event, recovery interval/state/reason for each
channel, a thread line (tracked, scanned, waiting and why any are backing off or incomplete),
snapshot: socket state/generation, last event, recovery interval/state/reason,
retry time and accepted/dead-lettered recovery counts since the connector last
started, including threads that have since left the window (they reset when it is enabled or disabled, and when the daemon restarts).
It also reports the durable dead-letter backlog: the retained records for inbound messages,
reactions and click answers that await retry, including retained relays, counted across all three
dead-letter files so the number survives a restart. Retry is paused while the connector is disabled.
If a file cannot be read, status shows unknown with a reason and no count.
started (they reset when it is enabled or disabled, and when the daemon restarts).
Human-readable coverage and pending bounds use ISO timestamps; JSON keeps Slack timestamps.
The status read calls no Slack API and starts no scan. If the daemon cannot be
observed, local configuration remains visible and live state is unknown. A
connected socket or valid configuration alone does not prove end-to-end delivery.
### Slack rejects a message with `invalid_blocks`
`rig queue show <qitemId> --json` records the failure as
`class=slack-invalid-blocks error=invalid_blocks` in the delivery transition.
Slack rejected that message's blocks, so OpenRig retains the notification as
undelivered and does not post the same payload again, including after a daemon
restart. The rejection is tied to the rendered payload: corrected content can
make a new attempt. Network failures, server errors and rate limits still retry
as before; a timed-out request remains uncertain until reconciliation.
The source seat gets a `slack-undeliverable` queue item explaining the failure.
Check the evidence links and message content, then send a corrected ask as a new
queue item. Close the original when you have handled it. If a long ask was split
into several messages, earlier parts may already be in Slack; the notice says
when only part of the ask was posted. A failed part never counts as delivery of
the whole ask.
@@ -102,34 +102,6 @@ transport or inventory. Failed runs preserve already written output; inspect
`unavailable`, `remaining`, call count and stop reason rather than treating a file
as proof of success.
From 0.7.2, `rig project wake --experiment` records its attempts under
`<classifier-version>+experiment`. `rig stream show` labels its classifications
`experiment`, not intake's, and its abstentions don't keep an item from the typed
decisions path below. It still creates no work.
## Typed decisions in the intake turn (`--decide`)
With `decisions.enabled` and the `stream` consumer configured (`rig project decisions
status`), `rig project wake --decide` takes the same options as a wake and asks a typed
decision for each eligible item, within the run's call budget. An accepted answer is
written with the item's classification and becomes owned work (a created or joined queue
row) or a recorded no-action or duplicate; `rig stream show <id>` shows what became of it.
Everything else stays with the intake seat: abstentions, errors, a deferral's reason, and
any item that intake or an earlier version already abstained on (an experiment's
abstentions excepted). The manual path (`rig project classify`, or a wake with
`--decisions`) is unchanged and remains the fallback.
**Turning decisions off, or changing them mid-run:**
- Off means no new provider calls. A run rechecks the settings before every send and halts
with `settings_changed`; an answer that arrives after a change is `stale` and isn't applied.
- Nothing already committed is discarded. Classifications, dispositions and work are kept,
and a disposition accepted before the change but not yet applied is still applied:
`rig project wake --decide` with decisions off asks nothing, takes the classifier lease
and applies those effects (`recovered` in its output).
- One classifier lease serves every path; an effect is applied only by the lease holder.
- Old unclassified items are bounded by stream retention (`retention.stream_days`,
`retention.stream_max_items`), which keeps items any of these records name.
## One bounded wake
From the selected occupant, read candidates using the configured project ID:
-3
View File
@@ -21,9 +21,6 @@ An empty match set can still have more source records: check `page.hasMore` and
`page.nextCursor`. The cursor binds the stream and filters; retain the same filter
options when continuing. Do not use `--start` with `--cursor`.
`rig mcp serve` offers the events read as the `rig_telemetry_events` tool, with the
same parameters and envelope; its `rigId` also takes a rig name.
## HTTP and envelope
These reads use the daemon's existing browser/access boundary:
-308
View File
@@ -1,308 +0,0 @@
# Choose a Herdr session for a terminal view
Use `--session <name>` to open a view in a named Herdr session on the daemon's
host. For example, keep development and production rigs in separate sessions:
```sh
rig terminal open future-rig --provider herdr --session default --json
rig terminal open future-prod-rig --provider herdr --session future-prod --json
rig terminal status --provider herdr --session future-prod --json
```
The explicit provider form places the view in the existing Herdr session
without opening a desktop window. Use Herdr's own session management to prepare
the session. An unavailable selected session returns `herdr_unavailable`; it
does not redirect the view to a different session.
The `session` field in an open result confirms the requested selection. Status
also names the session and returns its resolved control socket in `status.launch`.
Session names are a single non-empty name, such as `future-prod`, rather than a
socket path. `--session default` explicitly selects Herdr's default session.
## Default selection and scope
When Herdr is selected with no `--session`, the daemon uses the endpoint selected when it started:
its `HERDR_SOCKET_PATH`, then its `HERDR_SESSION`, then the default endpoint.
Setting either variable only on a later `rig terminal open` command does not
change that daemon configuration. Pass `--session` for a per-command selection.
With neither a provider nor a session, the daemon's open and preview APIs try
a live Herdr first, then a live cmux. An explicit session always selects Herdr
and skips that fallback, including when the daemon's default Herdr endpoint is down.
Session paths use the daemon's `XDG_CONFIG_HOME` when set, or `HOME/.config`
when unset. Relative paths (including an empty value) resolve from the daemon's
startup directory, matching Herdr's configuration selection. Named sessions live under
`herdr/sessions/<name>/herdr.sock`; `default` uses `herdr/herdr.sock` directly.
These settings are captured when the daemon starts.
If the requested name equals the daemon's `HERDR_SESSION`, it keeps that
session's explicit `HERDR_SOCKET_PATH`. A different requested name uses the
configuration-tree path, without reusing the override or inferring a session
from an arbitrary socket path. With an explicit socket but no configured
session name, omit `--session` to keep that exact endpoint. A missing selected
socket never falls back to another session. Viewer and follow operations carry
that exact socket to the CLI through `HERDR_SOCKET_PATH`, without an explicit
CLI `--session` that would override it. They check that the CLI reports the
selected socket before changing its panes. Daemon-side CLI calls retain the
startup environment; desktop follow uses the socket confirmed by the daemon.
The selection belongs to the request. Saved views still describe membership
and layout, so pass `--session` again when reopening the view in that session.
The existing view rules apply: saved views, rig and pod views, read-only mission
and slice views, pagination, absent seats and partial results all use the same
composer. Selecting a session changes where those tiles are placed. It does
not relaunch the seats' tmux sessions or move queue ownership.
## Open results
For Herdr, a successful layout request alone does not count its seats as opened.
OpenRig checks the returned tab's pane list. A missing tab identity or an
unreadable, empty or unattributable listing puts the affected seats in
`degraded`, with `code: "pane_unconfirmed"`, instead of `opened`. The CLI and
TUI name those seats as unconfirmed. Inspect the terminal before retrying:
the layout may have taken effect even though its panes could not be confirmed.
Confirmed seats on other pages still count; with none confirmed, the command
returns a nonzero exit status. Reusing an existing workspace keeps its separate
reuse result. This pane check does not prove desktop visibility or create a
verified viewer mapping.
## Replacing a seat's session
New tiles keep their place when a seat's tmux session is replaced. They wait up
to 30 seconds for the same session name and managed seat identity to return,
then reattach with the original read-only setting. This uses the shared tile
command for Herdr, cmux, Rex and plain tmux, including SSH tiles. In-place
handover and restart keep the existing attachment.
A deliberate detach closes the tile after about two seconds. A different seat
under the old name is never followed. If no replacement returns in time, or a
session has no managed identity, reopen the rig view with
`rig terminal open rig:<name> --window` (or your explicit provider). Views
opened before this change must be closed and reopened to get this behavior.
## Desktop windows and previews
```sh
rig terminal open future-prod-rig --session future-prod --window --json
```
The window launcher, readiness checks, workspace inventory, reuse and layout
application use the selected endpoint. When launched inside Herdr, the current
Herdr endpoint must match that selection to reuse the current terminal. To
place a view in another existing session from such a caller, use the explicit
`--provider herdr` form without `--window`.
An endpoint-mismatch error also gives a command for the caller's own session,
such as `rig terminal open future-rig --session work`. Previously it only
explained how to place the view in the daemon's selected session. The caller's
name comes from the same resolved socket path used by the endpoint check:
`~/.config/herdr/sessions/<name>/herdr.sock`, or `default` for
`~/.config/herdr/herdr.sock`. An explicit `HERDR_SOCKET_PATH` takes precedence
over `HERDR_SESSION` for both the check and the suggestion. Other custom socket
paths keep the existing guidance without guessing a session name, even when
`HERDR_SESSION` is set. Reporting the mismatch opens nothing.
Previews include the selected session in their fingerprint. A preview from one
session cannot authorize an open in another; refresh the preview after changing
the selection. The CLI asks the daemon to confirm named-session support before
requesting an open, so an older daemon cannot silently ignore the option and
write into its default session. An unconfirmed reply after an open is reported
as an unknown outcome; inspect the selected session before retrying.
`--session` applies to Herdr. A named session does not fall back to plain tmux
when Herdr is absent, and combining a session with cmux is rejected. Existing
workspaces are kept during reuse checks. Any suggested cleanup command for a
stale named-session workspace includes the selected socket.
## Rex
```sh
rig terminal open future-rig --provider rex --json
rig terminal open future-rig --window --json
rig terminal status --provider rex --json
```
`--provider rex` opens the view as a new session on the daemon's Rex server,
one window per page of the view, without switching any app to it. A tile whose
attach fails within ten seconds stays open showing the error, and the result
lists that seat as degraded when the failure shows up in the check made about
two seconds after opening. OpenRig picks
Rex only when it is named, or for `--window` (or no provider) when the command
runs inside a Rex block, including in a tmux started there. Everywhere else the
defaults above are unchanged, and the daemon's open and preview APIs never pick
Rex for a request that names no provider.
From inside Rex, the window form first checks that the daemon uses the same Rex
server as the calling terminal. When they differ, nothing is opened. After the
open it asks the Rex app showing the caller's session to switch to the new one,
choosing the app whose window holds the caller's terminal when several show it.
When no app can be chosen, or the app refuses the switch, the result names the
session to select by hand. A Rex app accepts the switch only with Remote Control
turned on in its Settings, under Rex Server.
The daemon finds `rex` in `$REX_BIN_DIR`, then on `PATH`, then inside the Rex
app on macOS, and uses its own `REX_SERVER` when set. It never starts a Rex
server. An open view is not reused: opening it again adds a numbered session,
such as `future-rig (2)`. `--session` applies to Herdr only. An unreachable Rex
server returns `rex_unavailable` and a failed session create returns
`rex_session_failed`, both as HTTP 200 results with `ok: false`. When Rex
doesn't confirm a session create or a page, for example because its reply
timed out, OpenRig closes nothing: the result says `rex_outcome_unknown`, or
adds a note when other pages opened, and names the session to check in Rex
before retrying. `rig terminal status` gives Rex about a second to answer.
## HTTP API
- `POST /api/terminal/open`: `{ view, provider?: "herdr", session?: string, expectedPlan?: string }`.
- `GET /api/terminal/preview?view=<view>&provider=herdr&session=<name>`.
- `GET /api/terminal/status?provider=herdr&session=<name>`.
- The alias `POST /api/rigs/:rigId/terminal/open` accepts the same session and
preview binding, with the rig determining the view.
Malformed names and unsupported provider/session combinations return HTTP 400
before socket calls. A changed preview returns HTTP 409. An unavailable Herdr
session keeps the normal HTTP 200 provider result with `ok: false`, its selected
`session`, an error code and direct-attach guidance.
The `kernel_seats_unavailable` result also keeps the selected session, so the CLI
reports that known failure instead of replacing it with an unconfirmed-session error.
## Task terminal views
`rig terminal open qitem:<id> --provider herdr --json` opens a derived, read-only
view of the current owners of a local queue episode. The episode includes the
requested row and explicitly linked descendants; only pending, in-progress and
blocked obligations contribute owners. It does not include historical senders,
closed predecessors, parent episodes or siblings. Membership is not input
permission.
The existing daemon endpoints accept the same view:
```text
GET /api/terminal/preview?view=qitem:<id>&provider=herdr
POST /api/terminal/open {"view":"qitem:<id>","provider":"herdr","expectedPlan":"<preview planId>"}
```
Preview and open include `task: {qitemId, name, complete, issues, members}`. Each member
names its structured `{hostId, nodeId}` key (null when unresolved), seat,
contributing `obligations`, `availability` and `viewerStatus`. Stopped, missing,
external and unresolved remote owners remain visible. Remote queues are not
crawled and no remote tmux socket is guessed. A bounded or damaged lineage is
explicitly incomplete; it is never silently presented as the whole team.
The local lineage read has work limits: 50,000 scanned rows (plus one overflow
sentinel), 8 MiB of encoded lineage, 100,000 links, and 16 KiB per chain record.
It reads one snapshot from the requested task forward, then probes earlier active
obligations using the queue's existing state index and follows references by exact
ID, including closed intermediaries. A child may arrive before its referenced root.
All these reads share the work limits; unrelated older closed history is not scanned.
Only the requested episode is expanded, up to 256 rows. Caller-supplied task IDs
are supported.
Hitting a work limit preserves known members and reports incomplete membership;
it does not claim that an owner outside the scanned history is absent.
Preview and open also include `viewDescriptor: {kind, name}`: `following` for
derived views and `fixed` for a saved layout. A task uses its row summary as
its name, falling back to the qitem ID. The existing preview `view` string is
unchanged. A following view derives membership when requested; it does not
mean an open workspace updates itself.
Preview is read-only. A member without owned viewer provenance is
`not-provisioned`; the ensure operation below can establish a reusable provider
mapping. An explicit open retains the provider's existing workspace behavior;
it does not continuously reconcile the view or change an agent's lifecycle.
## Following one task
`rig terminal open qitem:<id> --follow` keeps one workspace following the
task's current obligation owners. A joining member gets a verified local
viewer tile without taking focus. A departing member loses only its viewer,
after a grace period; the agent and its conversation keep running. The
explanation strip names joins, departures, unavailable terminals and limits.
The first workspace on an empty provider may become focused.
This increment supports the qualified **Linux Herdr 0.9.5/protocol 22**
endpoint. Other platforms/providers and other selector kinds retain ordinary
one-shot open. Use `--provider herdr --json` to operate without opening a
desktop window, and `--session <name>` to select that endpoint. Opening a tile
can change the agent's terminal size; `ignore-size` does not guarantee isolation.
```bash
rig terminal open qitem:example --follow --provider herdr --json
rig terminal open qitem:example --follow --provider herdr --pin reviewer@team
rig terminal open qitem:example --follow --provider herdr --max-tiles 12
rig terminal open qitem:example --follow --provider herdr --suppress reviewer@team --suppression-ms 60000
rig terminal open qitem:example --no-follow
```
`--unpin` and `--unsuppress` reverse those presentation choices. The default
cap is eight tiles; explicitly raising `--max-tiles` overrides it. Pins can
exceed the cap rather than being evicted. A departed tile retained for typed or
unconfirmed input does not consume a slot for joining members. Disabling removes
the saved following selector and keeps the existing layout intact.
The default departure grace is four seconds (`--grace-ms`). A focused leaver
gets another twelve seconds for reading (`--focus-retention-ms`); before its
tile closes, focus moves to a verified surviving member, or to the explanation
strip when none remains. Typed or unconfirmed input keeps the tile, labeled
as no longer participating. Explicit suppression also retains a typed draft.
Pi, terminal and community harness input may be unreadable to the composer
inspector: those tiles remain while input is unconfirmed, even after unpinning.
Each input-retained departure gets one delayed recheck after thirty seconds.
After that, queue or node/session lifecycle events, or an explicit repeat of
the follow command, can reconsider it. Activity hooks do not wake following,
and input is not repeatedly polled. Draft text is never saved.
Following consumes Core events and one-shot retention/suppression deadlines,
not repeated CLI membership queries. Complete empty membership drains the
tiles and preserves the workspace. Once no retained tiles remain, following
stops and the selector releases its active slot; new work does not automatically
restart it. Run `--follow` again to resume the same workspace. An incomplete source, failed provider read
or changed pane/terminal generation retains the previous view and reports
the uncertainty. It never authorizes a blind workspace rebuild or close.
`rig terminal views --json` and `rig terminal status --json` include the saved
active selectors and latest following status. Active selectors and owned viewer provenance
survive daemon restarts; task membership is always re-derived.
The owner-authorized HTTP operation is `POST /api/terminal/follow` with
`{view:"qitem:<id>",provider:"herdr",session?,enabled:true,maxTiles?,pin?,unpin?,
suppress?,unsuppress?,suppressionMs?,graceMs?,focusRetentionMs?,expectedPlan?}`.
Seat controls are arrays of names from the current/retained view. It uses the
existing terminal bearer boundary, like ensure-viewer. The response includes
the stable `workspaceId`, normal open partitions, `viewDescriptor`, and
`following:{id,enabled,state,maxTiles,tiles,events,overflow,nextAt?}`. Presentation
provenance grants no membership or input authority. This is a local native
viewer operation; browser input and remote-host provisioning are separate.
## Ensuring an owner viewer
The owner-facing HTTP operation `POST /api/terminal/viewer/ensure` accepts
`{view:"qitem:<id>", member:{hostId,nodeId}, provider:"herdr", session,
expectedPlan}`. Use a current preview's member key and plan. This explicit
operation uses the daemon's existing terminal bearer and browser boundary;
a bridge must retain its signed-in owner, Origin and expiry controls. It does
not grant authority based on task membership or accept a caller terminal ID.
On the qualified Linux Herdr 0.9.5/protocol 22 endpoint, it creates one viewer
or reuses an owned, verified `{workspaceId,paneId,terminalId,readOnly}` mapping.
A repeated/concurrent request does not create another viewer. A lost create
reply is reconciled before starting anything else; a lost run reply is checked
against the actual foreground process, never blindly repeated. Unknown or
changed terminal identity remains unconfirmed. The operation does not relayout,
close departed viewers, take over control, focus existing panes or launch/stop
agents. A first workspace on an otherwise empty Herdr server may become focused.
Preview remains passive and names `not-provisioned`, `verified` or `unconfirmed`
viewer status. Studio can ensure a new member, add its browser frame, and retain
a pinned leaver labeled as no longer participating. The provider mapping is
small presentation provenance, separate from queue membership. Herdr display
labels are not that provenance. Other providers/platforms remain unsupported
for ensure-viewer until qualified; ordinary open retains its existing behavior.
Writable tile clients use `tmux attach -f ignore-size`; read-only clients keep
`-r` (which also implies ignore-size). This can avoid contributing to sizing
when other clients establish the agent window's dimensions. It is **not resize
isolation**: opening a tile can change the agent's terminal size when it is the
only sizing client. Observers of a Herdr terminal do not themselves resize it;
a controlling browser can. No cross-version resize guarantee is implied.
-9
View File
@@ -23,15 +23,6 @@ Runtime, model and account belong in configuration. Changing a provider does not
change the role's name. Keep machine and instance identifiers stable rather than
renaming them whenever their workload changes.
What a seat runs has its own words:
| Term | Meaning | Example |
| --- | --- | --- |
| Runtime | What a seat runs: a harness, a terminal, or the stub (a scripted stand-in used in tests). It's the rig spec's `runtime` field and the TUI's runtime column | `claude-code`, `codex`, `terminal` |
| Harness | The agent software around a model | Claude Code, Codex, Pi |
| Community harness | A harness an outside contributor maintains. It's installed as a preset under `$OPENRIG_HOME/community-harnesses/` ([format](community-harness-presets.md)), listed by `rig community-harness list`, and OpenRig doesn't test it | A preset's name in a member's `runtime` |
| Terminal | A seat with no agent: a shell for infrastructure processes | `runtime: terminal` |
## How the software teams grow
| Team | Logical seats |
+116 -174
View File
@@ -1,192 +1,134 @@
# What changed in OpenRig 0.7.1
# What changed in OpenRig 0.6.7
This note is for an agent on an install that was just upgraded to 0.7.1. It lists only what behaves differently from
0.7.0 and what to do about it. For everything OpenRig can do, read the capability map:
This note is for an agent on an install that was just upgraded to 0.6.7. It lists only what behaves differently from
0.6.6 and what to do about it. For everything OpenRig can do, read the capability map:
`rig context get onboarding-width/public-what-you-can-do.md`. Each release replaces this file, so
`rig context get reference/whats-new.md` describes the installed version once the upgraded daemon is running. Without
a running daemon, read `$OPENRIG_HOME/reference/whats-new.md`. The daemon refreshes that copy when it starts, so right
after an upgrade it can still hold the previous version's note.
## After the upgrade
## The OpenRig view is now the handoff
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Eight migrations run on the first start (124, 125,
127, 128, 129, 130, 131 and 135). Nine settings are added: `policies.claude_compaction.preparation_root` (unset),
`skills.router_line.enabled` (off), `decisions.enabled` (false), `decisions.consumers`, `decisions.template_dir`,
`retention.stream_days` (30), `retention.stream_max_items` (20000), `provider.selected` (empty) and
`daemon.db_conflict` (`warn`). None is removed.
- **Stream retention is on.** Stream items emitted after the upgrade are deleted after 30 days, or beyond the newest
20,000, unless a record names them (a classification, duplicate link, attempt or disposition). History from before the
upgrade is kept. Raise `retention.stream_days` or `retention.stream_max_items` to keep more.
- **The bundled openrig-core plugin goes from 0.1.17 to 0.1.18.** Startup replaces the installed plugin's changed files:
its Claude hook definitions, three hook scripts and the pre-compaction hook (so hooks start on a seat whose PATH has
no Node), and the `openrig-user`, `messaging-the-human` and `forming-an-openrig-mental-model` skills. As in
0.7.0, it first copies each file it replaces under `$OPENRIG_HOME/backups/managed-refresh/<id>/`, edited or not, and
logs `[plugin-vendor] preserved previous contents of <file> at <copy> before managed refresh`. A copy doesn't mean
anyone edited the file; if someone did, reapply the change from the copy.
- **Claude seats need Claude Code 2.1.280 or newer to see their team skills,** which now arrive as a per-seat plugin
through `CLAUDE_CODE_PLUGIN_DIRS` (below). OpenRig doesn't check the version. A running seat gets the new layout at its
next launch, restore or handover.
- **If a worktree's `.claude/settings.local.json` says `"defaultMode": "acceptEdits"` and the person didn't choose it,**
an earlier OpenRig wrote it when it added or freshly launched a managed Claude seat. 0.7.1 no longer writes it but
doesn't repair it; ask the person which mode they want and set it once.
- **Open the view as the step after install or start; don't wait to be asked.** Say you're opening it, run
`rig terminal open saved:kernel --window`, then tell the person where it opened. Don't ask "Open the OpenRig view
now?", don't wait for a phrase like "show me my agents", and don't finish by printing a command for them to copy. The
same command brings their agents back later.
- **The layout follows the person's terminal width.** On a narrow terminal the operator gets the first tab. On a wide
one the dashboard and operator sit side by side and the advisor has its own tab. Inside herdr it opens a focused
`openrig kernel` space. A `kernel` view the person saved themselves still wins.
- **It opens where the caller is.** Terminal gets a window and Ghostty a tab. From Claude Desktop, iTerm or VS Code on a
Mac it opens a new Ghostty or Terminal window, and macOS may ask to allow it: mention that clicking Allow is fine only
in that case. Over SSH, in CI or with no display, it returns a definite no-window result with the reason. Relay the
`run:` command it prints exactly, or give `env -u TMUX tmux attach-session -t '=<canonicalSessionName>'` for the
operator.
- **`rig terminal open <view>` opens a window by default.** Pass `--provider herdr` or `--provider cmux` only to add
tiles to a workspace that's already visible. With `--json`, `reusedWorkspace` and `opened: []` mean an existing view
was reused, which is success.
- **The TUI's action is now "Open terminals ▸"** (it was `term ▸`). It opens a desktop window through the same
launcher, so the TUI must run on the daemon's desktop.
- **The `rigs` skill is installed for the person's own agent.** The daemon writes it to `~/.claude/skills/rigs` and
`~/.agents/skills/rigs` and refreshes it on every upgrade. It now triggers on any mention of OpenRig, rigs or a team
of coding agents. A copy added earlier with skills.sh is left alone and never refreshed.
## New things you can do
## Installing and setting up
- **Choose how much automatic input reaches a seat:** `rig seat set-typing-guard <seat> --mode <mode> --reason "…"`, where
`<mode>` adds `openrig-urgent` (OpenRig's own notices and anything urgent) and `urgent-only` between `off` and `hold`.
Mark a message urgent with `rig send --urgent`, or create the row at priority `urgent` or `critical`. Held input stays
readable (`rig seat held-messages`) and is never replayed.
- **Choose how far OpenRig and peers may act on a seat:** `rig seat set-terminal-policy <seat> --preset
no-peer-interventions|help-within-instructions|full-control --reason "…"`, with `--helper <addresses…>`,
`--rig-default` (seats added later), and exceptions such as `--peer-compaction deny` or `--openrig-model allow`.
Managed compaction and saved model or effort changes are checked; a denial is `terminal_policy_refused`. Other
interventions are guidance only.
- **Read a seat's planned permissions:** `rig seat status <seat>` shows what its next fresh managed launch will pass and
where each part comes from, and its terminal policy. `rig setup --router-line yes|no` decides whether OpenRig adds its
one line to shared guidance files.
- **Let a person choose whether every ask reaches Slack:** `rig gateway human set <id> slack-subscription
every-ask|urgent-only|unset`. Every ask is on the TUI's Feed either way. Under `urgent-only`, only an ask
created or handed off with `--alert` posts. An ask marked `--alert` stands out in Slack (an interrupt with a mention)
under either setting, so use it only for something only they can do, or an approval whose wait costs now.
- **Give one Claude member its own setup** in the rig spec: `agent_file`, `append_system_prompt_file`, `plugin_dirs` and
`advisor` (an Advisor model). They're copied into that seat's storage, never the project.
- **See a seat's own skills:** `rig skill loadout --seat <session|self>`.
- **Reach the team from an assistant without a shell:** the MCP server has 34 tools, including `rig_whoami`,
`rig_restore_status`, `rig_team`, `rig_telemetry_events`, following a handoff, reading a finished item's artifact and
deciding inside a person's decision room. `rig mcp serve --http` serves them over HTTP; `rig mcp token add <name>
--seat <session>` gives each caller its own seat.
- **Hand a row's task to an outside agent:** `rig acp run <row>` for an ACP agent (Hermes, OpenClaw).
`docs/reference/a2a-agents.md` covers A2A agents.
- **Run a community harness that names, reports or reopens its conversations:** preset fields `launch_result`, `resume`,
`names_conversation` and `startup_assisted`, with examples for Cursor, Hermes and OpenClaw under
`docs/reference/community-harness-examples/`.
- **Change one edge on a running rig:** `rig edge add <rig> <from> <to> --kind <kind>` and `rig edge remove <rig>
<edge-id>` (`--plan`, `--json`). A `delegates_to` change says when it moves who work routes to.
- **Run one spec as several rigs:** `rig up <spec> --name <rig>`.
- **Retune a watchdog in place:** `rig watchdog update <jobId> [--interval-seconds n] [--message text] [--spec path]`
keeps its id and attachments. The context watch takes `--threshold-tokens` or `--threshold-percent`.
- **Put restore maps where seats may write:** `rig config set policies.claude_compaction.preparation_root <abs path>`.
- **Pick a Herdr session:** `rig terminal open <view> --provider herdr --session <name>`.
- **Close a row with its artifact:** `rig queue update <id> --state done --evidence-ref <path>`.
- **File and read subscription move reports:** `rig provider moves report` and `rig provider moves`. `--host <id>` and
`--codex-home <dir>` on `rig auth status|list|switch` and `rig seat restart` act on another machine's Codex home.
- **Route stream observations with typed decisions (opt-in, off by default):** once `decisions.enabled` is true,
`decisions.consumers` includes `stream` and the intake seat has `OPENROUTER_API_KEY`, `rig project wake --decide`
gives each eligible stream observation one bounded typed decision during that seat's wake: create a row for a supplied
owner, join supplied work, or record a duplicate or no action. Anything uncertain, refused or failed goes to the
intake seat as its own disposition, and the model isn't asked again. `rig project decisions status` shows the state
without making a call. Without `--decide`, or with decisions off, nothing changes; turning decisions off no longer
strands an accepted create or join whose effect hadn't landed.
- **Read the stream as a subscriber:** `rig stream subscribe <seat>` (`--tags`, `--type`; re-subscribing keeps the
place), then `rig stream read` returns the next bounded page for the calling seat (at most 20 items and 64 KiB, with
`more`, `unread` and any `gap`). `rig stream subscriptions` lists readers; `rig stream unsubscribe <seat>` stops one.
`rig stream show <id>` says what became of an observation.
- **Keep the kernel to the harnesses you chose:** `rig setup --providers <list>` is now saved as `provider.selected`, and
the kernel starts seats only for those.
- **Stop a second daemon on a live database:** `rig config set daemon.db_conflict stop` refuses a start whose database a
matching live daemon already holds (the default `warn` only warns). `rig daemon start --allow-db-conflict` allows one
deliberate start.
- **Show the install preview first.** The published install command has a `--dry-run` preview
(`rig context get reference/getting-started.md` has both forms). Run the install when the person agrees. If the only
failures left are provider sign-ins, have them sign in and continue at "Start the kernel".
- **If your own harness refuses a tool call while installing,** say so and show the refusal: Claude Code's auto mode can
deny calls as well as approve them. Let the person review it in their harness or run that step themselves. A refused
call doesn't prove the OpenRig command failed.
- **`rig setup` installs herdr by default** on macOS and Linux; `--no-herdr` declines it. On a Mac, ask once about
Ghostty and on yes rerun `rig setup --ghostty`, keeping `--no-herdr` if it was chosen. Setup no longer installs cmux,
and an existing cmux still works. In `--json` output the `cmux_install` step is gone and `herdr_install` and
`ghostty_install` appear.
- **`rig doctor` checks Claude and Codex installs, and a login or configured provider credential,** the way setup does.
It exits 1 when either harness is missing or has neither, including one the person doesn't use, so ignore a failure
on an unused harness. A pass means a login or configured provider credential is available locally, not that a
provider accepts the credential or that an agent can work; no provider request or agent work is tested.
- **When setting up permissions,** recommend keeping the team default and offer remembered allowances only if the
person wants fewer prompts. People slowed by prompts can be pointed to the workshop listing.
- **Windows users go through WSL2.** For Pi seats there, offer either credential route; a global Pi login still isn't
shared with managed seats.
## Behaviour that changed
## Fewer prompts
- **Team skills live in each seat's own storage** (`$OPENRIG_HOME/state/seats/<rig id>/<logical seat>/<runtime>/`), not
in the project's `.claude/skills` or `.agents/skills`. Claude seats get them as a generated plugin (`openrig:<id>`),
Codex seats as their own catalog. If a link sits in that path, the seat starts without them and the result names
what wasn't delivered.
- **Urgent wakes are labelled:** an urgent or critical row's wake starts with `Urgent (marked by …):` at every seat. A
seat on `hold` gets one held notice per wake instead of re-nudges, and lifecycle work is refused at the middle levels as
at `hold`.
- **A daemon restart picks up a managed compaction where it stopped.** An owed restore step resumes, a `/compact` that
may have been sent is never sent again, and `rig compact --state` shows anything stranded or owed.
- **A Claude seat whose startup turn is refused** (an expired login, say) holds the rest of its startup context as owed.
A startup input sent before the refusal is noticed can still be submitted (see Known issues in the
[0.7.1 release notes](https://github.com/mvschwarz/openrig/releases/tag/v0.7.1)).
- **`rig send` refuses with `target_needs_input`** when a Claude seat's hook has just reported it running and its screen
shows an MCP enablement menu, a question or a permission prompt.
- **`rig down` asks before stopping the caller's own rig.** Without a terminal it exits 1, stops nothing and prints the
`--yes` command.
- **`rig up --plan` doesn't start a stopped daemon:** it exits 1 with `daemon_not_running` and names `rig daemon start`
and `--no-kernel`.
- **`rig daemon start`** waits up to 20 seconds for a slow daemon, and starts with a warning when only a secondary
listener, such as Tailscale's, can't be verified.
- **A seat joined with `rig attach` as an outside app** (Claude Desktop) stays attached through `rig down` and restore
(`left_attached`). To replace it, `rig unclaim <name>`, then launch.
- **New seats get the daemon's `OPENRIG_DB` and `TMUX_TMPDIR`.**
- **A rebuilt Codex seat keeps its observed permissions,** and a read-only Codex profile launches without OpenRig's
writable roots.
- **A seat's own role** goes only to that seat, never into a shared `CLAUDE.md` or `AGENTS.md`, and `rig down` returns
guidance files byte for byte.
- **Workflow actions** (`project`, `route`, `resume`, `abort`) record the calling agent; a request with neither identity
nor actor gets the shared `actor_required` error.
- **`rig provider moves` is back** for subscription move reports. It switches nothing.
- **`--dangerously-interact` into a seat's input box stops first** when the box already holds typed or pasted text: it
shows that text (apart from suggestions) and types nothing. Clear the box and retry, or pass `--type-without-checking`,
which clears nothing. Keys for a recognized native menu are sent as before.
- **A terminal seat gets exactly the text sent to it,** without the From/To lines (the result says `envelopeOmitted`).
A queue wake to a terminal seat types nothing and fails `failed:terminal-seat`; the row stays queued.
- **A daemon restart that can't see the seats' tmux server** no longer detaches seats whose recorded process still
runs, so it doesn't relaunch them beside themselves.
- **Provider credentials and the activity-hook token stay off tmux, shell and `env` command lines** at launch and
handover. A launch whose staging fails stops instead of falling back.
- **An answer retried from Slack or Steering** after an interrupted close reuses the first reply and finishes the
request once.
- **One bad row no longer stops the stuck sweep** from flagging the others; **a malformed unrelated diagnosis row** no
longer stops health diagnoses from listing.
- **In Claude team seats, help on lifecycle commands runs without a prompt.** `rig down --help` and `-h` forms run;
the lifecycle actions themselves still ask. Pipelines, command substitutions, redirects and heredocs aren't allowed
automatically.
- **The kernel operator's Claude session runs routine inspection without prompts:** Python, command lookup, `cd`, text
helpers and WebFetch. Compound commands and redirects still go to Claude's own permission check.
- **Managed Codex seats no longer stop at the update menu on start.** Updating Codex is the operator's job: if a Codex
seat says its model needs a newer Codex, offer to update Codex and restart that seat, without stopping the team. If a
Codex seat shows a model-switch menu near a rate limit, ask the person; don't change the team's model.
## Output that changed shape
## Messages, wakes and Slack
- **`rig ps --nodes`** separates every cell with one space (the default row is 5 columns wider), adds an `ATTN` column
(`yes`, `no` or `?`) before `REASON`, and marks a `running` seat whose tmux couldn't be reached as unconfirmed.
- **`rig queue show --json`** no longer has `lastNudgeInputBox`, and send results no longer describe the input box.
- **`rig queue show` and `rig queue whoami`** print their human notices on stderr, so stdout is the JSON.
- **A claimed row whose owner waits on a person** reads pickup `needs-input`, with the reason.
- **`rig down --json`** adds `leftRunning`; `rig reconcile-session` says whether resume metadata was recorded.
- **`rig compact-plan`** labels its thresholds as its own and says whether automatic compaction is on.
- **A send held for a draft** in the seat's input box reports `inputPhase: "not_reached"`: it was never typed.
- **A stream body over 16 KiB** is stored truncated, ending `[truncated by OpenRig: kept N of M bytes]`, and reads mark it
`bodyTruncated`. `rig stream list --after <sortKey>` from a pruned item returns 410 `stream_cursor_expired`, naming
`--since` to resume, instead of an empty list.
- **A send or wake no longer answers a question that's waiting for a person.** It's refused with
`target_needs_input`, and the reason ends `; latest hook, pane unrecognized` when the screen can't be read, even if
the hook is minutes old. Read the seat with `rig capture` and get the question answered by whoever should answer it.
Don't retry the send.
- **Slack can post to a channel per rig or seat:** `rig slack channel-map list`, `set <match> <channel>` and
`remove <match>`. After a change, invite the app to each channel, run `rig slack verify`, then `rig slack disable`
and `rig slack enable` (or restart the daemon). Thread replies still reach the seat in any channel.
- **A reaction on any part of a Slack ask reaches the seat that asked,** as a task tagged `human-reaction`. Treat it as
a signal to interpret, not an answer; the ask stays open. An existing app needs the `reactions:read` scope, the
`reaction_added` event and a reinstall, and `rig slack verify` warns when the scope is missing.
- **A long Slack ask is posted in parts.** If it still can't be posted, you get a task `<ask id>-undeliverable`, tagged
`slack-undeliverable`: shorten the ask or link a file for the long part, and send it as a new ask.
- **Replies sent with "Also send to #channel" now arrive** at the seat that asked, like any other thread reply.
## Running teams
- **`rig down` prints what each agent seemed to be doing before it stops them,** on one stderr line, or "unknown". It's
a best-effort snapshot, not a record of what was interrupted. `rig down --host` now exits 2 when the remote teardown
reports errors.
- **Before a handover or compaction, write the seat's recap** with
`rig context recap-write --rig <rig> --seat <seat> --file <draft.md>`, not a file tool, so earlier recaps are kept.
`rig seat handover <seat>` now works on the same seat again and again; a seat left half-handed-over recovers by
running it again.
- **After a managed compaction, read the `refocusing` skill and consume the current topology and work traces
delivered with the restore.** Don't rerun them just to duplicate the delivery; name a missing trace. For a new trace
outside that context, use the skill's one-command form. The daemon now installs the skill globally.
- **The built-in starter and factory leads publish their team's roster** at first start, so `rig roster list` and
`rig roster find <topic>` know those teams.
- **Claiming a parked queue item clears its `blocked_on`.** To park it again on the same gate, run
`rig queue update <id> --state blocked` without `--blocked-on`: it reuses the previous gate. Add the usual wake when
one is required.
- **A periodic reminder first fires one interval after you register it,** not immediately.
- **Workflows:** `rig workflow run` and `rig workflow watch` exit 3 for an aborted instance. When a step that was sent
back completes again, the steps that depend on it run again too, including ones that had passed.
- **Sharing teams:** `rig bundle create` and `rig up <link>` name the bundle after `rig.yaml`'s `name`, and
`rig bundle check` names the file in each finding. Submissions go to `mvschwarz/openrig-registry`. The bundle safety
check always runs, even with `--skip-version-check --force`.
- **`rig context add <url> --git` retrieves shallowly by default:** the initial retrieval fetches only the current
commit, and a server that refuses shallow retrieval falls back to a full clone with a warning. Use
`rig context source update` for later revisions.
- **Values that used to fail quietly are refused:** a bad `rig chatroom history --since` (fix the value; the room isn't
empty) and a `--wake-after` such as `7d` (use hours, for example `168h`).
- **Pi seats print a `[pi-runner] seat …:` line at start** saying whether a credential was found and, if not, how to
fix it. A MiniMax model gets `MINIMAX_API_KEY` when that name is in `recovery.provider_auth_env_allowlist`.
- **`agents.advisor_session` defaults to the kernel's advisor seat.**
## What to stop doing
- **Don't read `lastNudgeInputBox`.** It's gone.
- **Don't parse `rig ps --nodes` by column position.** Use `--json`, with `--fields` to pick fields.
- **Don't look for a team seat's skills in the project folders.** Use `rig skill loadout --seat`.
- **Don't stop and re-register a watchdog to change it.** Use `rig watchdog update`.
- **Don't take a rig down to fix one edge.** Use `rig edge add` or `remove`.
- **Don't print `OPENRIG_ACTIVITY_*` values** while looking for identity. Use `rig whoami`.
- **Don't use `--dangerously-interact` to type over a half-written input box.** It stops and shows the text; choose
explicitly, or use `rig send --urgent` where the seat's interruption level allows it.
- **Stop asking whether to open the OpenRig view, or waiting for a phrase.** Open it after install or start.
- **Stop using `--provider herdr` for the first view,** and stop pre-warning about macOS prompts from Terminal or
Ghostty.
- **Stop suggesting `--skip-version-check --force` to get past a bundle pre-check failure.** The check always runs.
- **Stop retrying a send refused with `target_needs_input`.** Get the question answered instead.
- **Stop telling people to untick "Also send to #channel" when they answer in Slack.** Those replies now arrive.
## Known gaps in 0.7.1
## Known gaps in 0.6.7
- **A new Claude seat's startup text can stay in its input box unsent.** If a fresh Claude seat sits idle with text at
its prompt, press Enter in its pane, or ask the person to.
- **Claude seats on a macOS native install can read as needing attention after a restore.** Check the pane before
acting on it.
- **A Slack channel-mapping change needs a daemon restart.** Don't use `rig slack disable` then `enable` for it: enabling
marks any alerts waiting to post to Slack as already seen.
- **Ubuntu 22.04's tmux 3.2a:** the query known to crash its server is gone, but a narrower race remains. Avoid 3.2a
(`tmux -V`).
- **`rig daemon start` can take 20 to 40 seconds** before its listener warning, longer than the TUI's 30-second "restore
everything" wait. On a slow host, start the daemon yourself first.
- **A launch that warns "Scoped tmux environment unavailable; retaining the previous environment"** didn't give that seat
the clean environment. Check `env` in the seat before relying on OpenRig's variables.
- **Going back to 0.7.0 with a Slack subscription set:** 0.7.0 can't read a person file that holds one, and its people
registry stops loading. Save the choice first (`rig gateway human show <id> --json > <id>.json`), then run
`rig gateway human set <id> slack-subscription unset` (`urgent-only` becomes every-ask, as on 0.7.0); set it again after
returning to 0.7.1. A person file still holding a `subscriptions:` entry fails on 0.7.0 too. Don't restore an older
gateway folder or database instead: that can lose newer people, bindings and delivery records.
- **A Herdr tile over SSH** doesn't come back by itself after a Herdr restart. Reopen it with `rig terminal open <view>
--provider herdr`.
- **Delegated decisions:** the ask is created before the decision, so without quiet hours it can post to the person
first. Pair a decision room with quiet hours, or decide before asking.
- **`restore-check` still treats a missing startup file as rig-wide.** Trust `rig up --existing`'s per-seat result.
- **Managed Claude launch requires `/usr/bin/env` support for `-0` and `-u`** (option support checked on GNU coreutils
and macOS 15; BusyBox and older macOS unverified). Without the required option support, launch stops instead of
falling back to credential-bearing command-line arguments.
- **In draft-aware mode, an agent's multi-line message to a Claude seat can remain unsubmitted when the paste changes
Claude's footer hint.** OpenRig withholds Enter, leaving the text in the composer and later sends waiting. Observed on
Claude 2.1.309 with both 0.7.0 and the 0.7.1 pre-cut build; #14 remains open.
- **A Claude seat signed in with a long-lived token (`claude setup-token`) gets no usage reading.** The reader accepts
only claude.ai subscription logins and reports `not_subscription_login`.
- **Not yet checked on a real machine:** opening the view from Claude Desktop, and the plain-tmux layout without herdr.
The full first install from nothing and a Linux run come after this release.
- **Only one selected lifecycle-help command has been checked in a live Claude session;** source and parser coverage
is broader. If a help command still asks, report it.
- **Codex team seats aren't asked before lifecycle commands yet.**
- **The send gate trusts the most recently recorded hook.** If hooks are recorded out of order, it can hold a send for
a question that's already answered, or miss one that's waiting. `rig capture` shows the real screen.
- **A Codex seat whose permission request was approved automatically can still read "needs input".**
- **A launched team still writes its skills into its working folder.**
- **After a restart, `rig status` shows `Kernel: skipped` whatever the kernel seats' state.** Read them with
`rig ps --nodes --rig kernel --full`.
+33 -8
View File
@@ -1,15 +1,38 @@
# Release Notes
This directory holds one note per release for OpenRig v0.1.12 through v0.6.3, plus v0.6.12 (for example `v0.1.12.md`).
This directory is the lightweight release history for OpenRig.
Notes for later releases are in [`CHANGELOG.md`](../../CHANGELOG.md) and on the [GitHub Releases page](https://github.com/mvschwarz/openrig/releases).
It is intentionally simpler than a monolithic `CHANGELOG.md`.
Each shipped release gets its own note:
- `v0.1.12.md`
- `v0.2.0.md`
- `v0.3.0.md`
- and so on
## Why This Exists
We want a practical release-management pattern that works with how OpenRig is actually being shipped today:
- npm package release
- optional Git tag
- optional GitHub Release
- short, human-written summary of what is included
This keeps release notes:
- easy to author
- easy to link in GitHub Releases
- easy to paste into announcements
- durable in the repo
## Minimal Process
For each release:
1. Add the release's section to [`CHANGELOG.md`](../../CHANGELOG.md).
2. Write the release's GitHub Release notes. No new file is added to this directory.
1. Copy `_template.md` to `vX.Y.Z.md`.
2. Fill in the release summary, included changes, operator notes, known limitations, and verification performed.
3. Run the **substance gate** at the exact release cut. The gate derives every
file under the shipped pack sources, verifies a hash-bound human judgment
and an individual disposition for each mechanical candidate, scans the
@@ -56,16 +79,16 @@ For each release:
git push origin vX.Y.Z
```
5. Create the GitHub Release with the notes from step 2:
5. Create a GitHub Release using the same file:
```bash
gh release create vX.Y.Z \
--repo mvschwarz/openrig \
--title "OpenRig X.Y.Z" \
--notes-file <release-notes.md>
--title "OpenRig vX.Y.Z" \
--notes-file docs/releases/vX.Y.Z.md
```
6. Publish the npm package.
6. Publish the npm package if that is part of the release flow.
## Guidance
@@ -78,3 +101,5 @@ For each release:
## Scope
This directory is a release-note archive, not a full historical changelog taxonomy.
If OpenRig later wants a curated `CHANGELOG.md`, it can be generated or summarized from the release notes here.
+1 -1
View File
@@ -260,7 +260,7 @@ Operators can install `gstack` (45 skills) or `obra-superpowers`
- TypeScript checks clean on all touched files.
- Code review (review-lead, advisory-clean) on every merge in the
release window.
- Founder-walk VM at `http://192.0.2.10:17459/` walked end-to-end
- Founder-walk VM at `http://100.95.124.51:17459/` walked end-to-end
on host-main `4d88d98d` with realistic demo seed (8 seeded queue
items, 8 feed events, 4 verified `/for-you` action scenarios
including the inline error path).
-238
View File
@@ -1,238 +0,0 @@
# OpenRig 0.6.12
0.6.12 is a fix release about recovery and everyday operation, and most of it came from the community: 121 of its 206 changes are from 23 outside contributors. Restore brings back what you left running and leaves down what you stopped: an ordinary terminal seat relaunches fresh, `rig start --last` keeps a rig you took down on purpose down, and a slow Claude start ends as needing attention instead of failed. Managed compaction sends a `/compact` short enough to run again, and Claude seats now load the hooks OpenRig ships for them. You can set one seat's reasoning effort, give a team environment variables in its spec, change a seat's permission policy in place, launch a rig by its name, and read what each seat reports it costs. The CLI starts faster, and the daemon does much less work on a busy machine.
```
npm install -g @openrig/cli
```
It's built from `9e5382dd`: main at `48fe7827`, which holds 198 changes made since 0.6.11 (and five that 0.6.11 already shipped), plus eight changes merged later and picked onto it: [#1491](https://github.com/mvschwarz/openrig/pull/1491), [#1488](https://github.com/mvschwarz/openrig/pull/1488), [#1492](https://github.com/mvschwarz/openrig/pull/1492), [#1496](https://github.com/mvschwarz/openrig/pull/1496), [#1483](https://github.com/mvschwarz/openrig/pull/1483), [#1504](https://github.com/mvschwarz/openrig/pull/1504), [#1506](https://github.com/mvschwarz/openrig/pull/1506) and [#1518](https://github.com/mvschwarz/openrig/pull/1518), without #1506's one edit to an agent startup guide section this release doesn't have, and without #1518's edits to code from [#1498](https://github.com/mvschwarz/openrig/pull/1498), which isn't in this release. Nothing else merged after `48fe7827` is in this release. If something breaks for you, please open an issue.
## Before you upgrade
**Restart the daemon after upgrading the CLI.** The running daemon keeps its old build until it restarts: run `rig daemon stop`, then `rig daemon start`. Twelve database migrations run on the first start, whatever you use:
- `100` adds the typing guard's mode columns ([#1481](https://github.com/mvschwarz/openrig/pull/1481));
- `104` adds `nodes.effort_choice` ([#1209](https://github.com/mvschwarz/openrig/pull/1209));
- `105` adds two columns to `usage_samples` for Claude's client-side cost estimate ([#1233](https://github.com/mvschwarz/openrig/pull/1233));
- `107` adds the `pi_usage_entries` table ([#1237](https://github.com/mvschwarz/openrig/pull/1237)), and `108` adds two columns to it and a `pi_usage_charges` table ([#1244](https://github.com/mvschwarz/openrig/pull/1244));
- `109` makes the mode store's unique index per category, keeping every row ([#1274](https://github.com/mvschwarz/openrig/pull/1274));
- `110` adds `workflow_instances.scope_json` ([#1273](https://github.com/mvschwarz/openrig/pull/1273));
- `111` adds `rigs.env_json` and `nodes.env_json` ([#1294](https://github.com/mvschwarz/openrig/pull/1294));
- `112` adds two indexes for reading recent queue activity, about 10 to 12 MB on a long-running install's database ([#1326](https://github.com/mvschwarz/openrig/pull/1326));
- `113` adds `applied_launch_observations.daemon_opt_out` ([#1371](https://github.com/mvschwarz/openrig/pull/1371));
- `114` adds `sessions.agent_app` ([#1392](https://github.com/mvschwarz/openrig/pull/1392));
- `115` rebuilds the Slack thread map keyed by channel and thread timestamp, keeping existing rows ([#1436](https://github.com/mvschwarz/openrig/pull/1436)).
There are no migrations `102` or `106`, and no earlier migration was changed.
**The bundled openrig-core plugin goes from 0.1.9 to 0.1.13, and Claude seats now run its hooks.** A Claude seat whose plugins include `openrig-core` (every agent spec OpenRig ships selects it) now starts with `CLAUDE_CODE_PLUGIN_DIRS` pointing at OpenRig's copy, so its activity relay, compaction save and restore hooks, and refocus actually run. Before, the plugin was copied to a folder Claude Code doesn't load. It needs Claude Code 2.1.280 or later; an older Claude Code ignores the setting, and OpenRig doesn't check the version. A seat that's already running picks it up at its next launch, restore or handover ([#1344](https://github.com/mvschwarz/openrig/pull/1344)). The refocus hook may now take up to 60 seconds instead of 5, so a refocus that hangs can hold up a prompt for up to a minute ([#1475](https://github.com/mvschwarz/openrig/pull/1475)).
**Six new settings, all off except one:** `output.whoami_peers` (default 8, used only while `output.compact` is on), plus `bundle.services_enabled`, `provider.subscription_moves`, `usage.pi_cost_facts`, `usage.pi_openrouter_charge` and `transport.verify_input_box`, all false. "Off by default" below says what each does and what acts with it off. `output.compact` and `provider.usage_windows` stay off and cover more than in 0.6.11.
**Behaviour that changed:**
- **`rig start --last` leaves down a rig you stopped on purpose** with `rig down` or `rig seat stop`, and prints how to bring it back (`rig start --rigs <rig>`); `--json` carries `keptDown`. `--all`, `--rigs` and the picker are unchanged ([#1238](https://github.com/mvschwarz/openrig/pull/1238)). After `rig down`, `rig ps` and the TUI read the rig `stopped` instead of recoverable ([#1254](https://github.com/mvschwarz/openrig/pull/1254)), and the printed restore command is `rig up '<name>' --existing`, which works even when the name matches a shipped starter ([#1425](https://github.com/mvschwarz/openrig/pull/1425)).
- **An ordinary terminal seat relaunches fresh on restore** and re-runs its startup actions, instead of stopping at `awaiting-decision` on every restore after its first. A required startup file missing for such a seat now blocks the whole restore before anything launches (`required_startup_file_missing`) ([#1328](https://github.com/mvschwarz/openrig/pull/1328)).
- **Restore stops a seat whose current runtime differs from the one its snapshot started with,** before creating its terminal session, and `--fresh` doesn't bypass it ([#1190](https://github.com/mvschwarz/openrig/pull/1190)).
- **A slow start ends as needing attention, not failed.** A restored Claude seat that hasn't drawn its screen is watched for up to `runtime.readiness_timeout_seconds` (30 s by default) and then ends `attention_required` with its pane and attempted resume token kept, so a restore can take longer: up to about 27 seconds more for each seat that never starts, one after another ([#1359](https://github.com/mvschwarz/openrig/pull/1359)). A seat is described as "returned to a shell" only when the process tree shows an idle shell ([#1357](https://github.com/mvschwarz/openrig/pull/1357), [#1395](https://github.com/mvschwarz/openrig/pull/1395), [#1373](https://github.com/mvschwarz/openrig/pull/1373)), and the older restore path for rigs without pods keeps such a seat for attention instead of rolling it back ([#1364](https://github.com/mvschwarz/openrig/pull/1364)).
- **A Pi or OMP seat whose agent has exited to a bare shell reads `attention_required`** (`process_identity_mismatch`) instead of running, so `rig start` offers its rig as a restore candidate ([#1391](https://github.com/mvschwarz/openrig/pull/1391)).
- **A send to a pane in tmux copy or view mode leaves the mode once and delivers.** A pane that can't leave its mode gets no text, and the send is refused with `pane_in_mode` (HTTP 409). A person scrolled back in that pane is returned to the bottom ([#912](https://github.com/mvschwarz/openrig/pull/912), reported in [#331](https://github.com/mvschwarz/openrig/issues/331)).
- **`rig send --dangerously-interact` types exactly the bytes and keys you give it,** in any state and over every transport: a lone CR, Tab, Space or Esc acts as that key. Enter is added only when a complete answer is visibly staged in a recognised Claude Code or Codex input box; anywhere else, put CR in the payload yourself ([#1366](https://github.com/mvschwarz/openrig/pull/1366)).
- **Managed `/compact` is kept to 600 characters, sent only to an idle seat, and reported separately.** Since 0.6.10 the managed command had grown long enough that Claude Code took it as pasted text, so it didn't run while the attempt read as delivered. The rest of the instructions move into the restore map and the restore request. `rig compact` and `rig compact --state` show a separate `compact` result: `submitted, unconfirmed` (it reached the seat; that isn't proof compaction ran), `uncertain` (not replayed) or `not sent`. A running draft-aware seat with no recorded pane is refused with `pane_unknown_protected`: `rig compact` exits 1 (was 2) and the route answers 409 (was 502). Over the shared transport, a write that throws without a code makes `rig send` exit 1 with HTTP 409 instead of 2 with 500 ([#1483](https://github.com/mvschwarz/openrig/pull/1483), towards [#27](https://github.com/mvschwarz/openrig/issues/27)).
- **`rig queue update` refusals you can fix answer HTTP 400 and exit 1,** not 500 and exit 2: eleven error codes for closure fields, `blocked_on` or wake options where they can't be stored, and a watchdog that isn't live or targets someone else. `blocker_not_found`, `blocker_not_live` and `blocker_malformed` still exit 2 ([#1319](https://github.com/mvschwarz/openrig/pull/1319)).
- **`rig ask` exits 1 for an unknown or ambiguous rig** instead of 0 ([#1202](https://github.com/mvschwarz/openrig/pull/1202)).
- **`rig add`, single-seat `rig launch` (including `--retry-startup-from`) and `rig launch --seats` wait up to 120 seconds** for the daemon instead of 5. A timeout there still isn't a rollback, so check `rig ps` before running it again ([#1287](https://github.com/mvschwarz/openrig/pull/1287), [#1271](https://github.com/mvschwarz/openrig/pull/1271)).
- **A runtime's `--version` check gives up after 30 seconds.** At daemon start a hung probe no longer keeps the daemon from listening, and `rig bootstrap` now blocks on a runtime that is installed but fails or times out its check, which it reports apart from one that's missing ([#1211](https://github.com/mvschwarz/openrig/pull/1211)). `rig up`'s preflight fails a Claude, Codex or OMP check that doesn't answer within 30 seconds (a warning for Pi), and stopping the daemon ends the probes it started ([#1301](https://github.com/mvschwarz/openrig/pull/1301)).
- **Claude login detection is narrower.** Only a current refusal (`Not logged in · Please run /login`, `Login expired · Please run /login` or `OAuth token revoked · Please run /login`) reads `login_required`. The "Not logged in" footer alone no longer does, so a seat signed in through a gateway starts, resumes and restores normally ([#1417](https://github.com/mvschwarz/openrig/pull/1417), reported in [#1405](https://github.com/mvschwarz/openrig/issues/1405)).
- **The `fable` model alias now names `claude-fable-5-1`,** so a `fable` pin on a seat running `claude-fable-5` raises a divergence notice. A Claude pin ending `[1m]` no longer does when the seat runs that model ([#1482](https://github.com/mvschwarz/openrig/pull/1482)). Pinned Pi seats are now checked, and a Pi thinking-level suffix such as `:high` doesn't count as a divergence ([#1349](https://github.com/mvschwarz/openrig/pull/1349), [#1403](https://github.com/mvschwarz/openrig/pull/1403)).
- **A custom permission policy saved with Windows (CRLF) line endings now takes effect.** It used to fail to parse, so the seat fell back to the advisory floor; now it resolves to its declared posture at the next launch or restore ([#1402](https://github.com/mvschwarz/openrig/pull/1402)).
- **`rig bundle create` refuses harness login files** (`.claude/.credentials.json`, `.codex/auth.json`, `.pi/agent/auth.json`) anywhere in an agent folder or carried project. An existing archive holding one without a record now fails verify, unpack and install; install answers 400 even with `--force`. "Off by default" covers the opt-in ([#1488](https://github.com/mvschwarz/openrig/pull/1488)).
- **`rig scope mission create` writes `status: planned`,** which keeps a new mission out of the project's proof rollup until you set it `active` ([#1289](https://github.com/mvschwarz/openrig/pull/1289)). With `--workspace <project>` it creates the mission inside that project, under its declared `missions.root`, which now wins over a `missions/` folder for every command that reads missions ([#1430](https://github.com/mvschwarz/openrig/pull/1430), reported in [#70](https://github.com/mvschwarz/openrig/issues/70)).
- **On Linux and macOS, the daemon runs tmux directly instead of through `/bin/sh`.** Set `OPENRIG_TMUX_SHELL=1` when starting the daemon to keep the old path ([#1241](https://github.com/mvschwarz/openrig/pull/1241)).
- **The daemon writes Codex's `config.toml` only when a Codex seat exists or launches,** so a daemon that never runs Codex leaves `~/.codex/config.toml` alone ([#1297](https://github.com/mvschwarz/openrig/pull/1297)).
- **Slack:** a person's new message in a rig's own mapped channel now goes to that rig's representative (or a seat named with `--inbound`), and every seat post's sender line names the machine (`from <seat> on <host id>`), on one machine too ([#1436](https://github.com/mvschwarz/openrig/pull/1436)). The app manifest asks for one more scope, `users:read` ([#1433](https://github.com/mvschwarz/openrig/pull/1433)).
- **`rig remove` and `rig shrink` keep a seat that receives work while it's being stopped:** the late work goes to your `--fallback`, or the stopped seat is kept and the command answers 409 with the work listed ([#1459](https://github.com/mvschwarz/openrig/pull/1459)). A stale `rig unclaim` that would detach the seat's newer occupant is refused with 409 `session_not_current` ([#1379](https://github.com/mvschwarz/openrig/pull/1379)).
- **`rig seat status` reads `Typing guard: on (hold)` for hold,** and its JSON's `desired` and `effective` booleans now mean hold only ([#1481](https://github.com/mvschwarz/openrig/pull/1481)).
- **`rig usage top` no longer counts a new conversation, or a reset window, as burn.** Tokens and rates are measured within one history and one window, so figures can drop and rankings change ([#1348](https://github.com/mvschwarz/openrig/pull/1348), reported in [#774](https://github.com/mvschwarz/openrig/issues/774); [#1473](https://github.com/mvschwarz/openrig/pull/1473)).
- **The execution view binds a queue row to a mission by its exact `mission:` tag first** ([#1304](https://github.com/mvschwarz/openrig/pull/1304)), and a `slice:` tag can name a slice by its SPEC `id:` as well as its folder ([#1331](https://github.com/mvschwarz/openrig/pull/1331)).
## Highlights
**Restore and launch.**
- **`rig launch` takes a rig's name as well as its ID,** locally and with `--host`. A name two active rigs share stops before anything is sent and lists their IDs (`rig_ambiguous`). Before, it took only an ID, and a name failed with not found ([#1312](https://github.com/mvschwarz/openrig/pull/1312)).
- **A Codex seat that forks a conversation keeps its startup context when the new conversation's ID arrives late.** The launch waits up to the readiness timeout for a verified child; if none arrives, the seat stops at `attention_required`, and `rig seat continue <seat>` delivers its role once ([#1438](https://github.com/mvschwarz/openrig/pull/1438), reported in [#317](https://github.com/mvschwarz/openrig/issues/317)). A handover to a forked Codex conversation completes with the ID marked pending instead of failing ([#1216](https://github.com/mvschwarz/openrig/pull/1216)).
- A seat whose startup failed for a recoverable readiness reason becomes ready after a successful send or capture ([#1236](https://github.com/mvschwarz/openrig/pull/1236), towards [#7](https://github.com/mvschwarz/openrig/issues/7)). A periodic snapshot taken while `rig down` was stopping a rig no longer replaces the complete pre-down snapshot ([#1147](https://github.com/mvschwarz/openrig/pull/1147)).
**Set up a seat without touching the spec.**
- **Set one seat's reasoning effort:** `rig seat set-effort <seat> --effort <level> --reason "<why>"` (or `--clear`) saves it, and it's used at the seat's next launch, restore, handover or in-place Codex restart; nothing restarts. `rig seat effort <seat>` reports what was requested, what's saved, what the running process was launched with and what the session recorded ([#1209](https://github.com/mvschwarz/openrig/pull/1209)).
- **Change an existing seat's permission policy:** `rig policy apply <policy> --seat <seat> --reason "<why>"` stores it for the seat's next restore, resume or launch. The running agent and the spec file don't change ([#1285](https://github.com/mvschwarz/openrig/pull/1285)).
- **Give a team environment variables in its spec:** an `env:` block on the rig and on any member in `rig.yaml` reaches every seat, including Claude Code's cleared environment, carries into restores and handovers, and `rig export` writes it back. `rig up <spec> --env KEY=VALUE` adds a value for one launch. Names match `^[A-Z_][A-Z0-9_]*$`, and `OPENRIG_*` is reserved. **The values aren't secret:** they're stored in OpenRig's database and snapshots and copied by export and bundles ([#1294](https://github.com/mvschwarz/openrig/pull/1294)).
**Compaction and hooks.** Besides the shorter managed `/compact` and the plugin hooks above: automatic compaction ignores a context reading taken before a seat was stopped, replaced, handed over or restarted ([#1365](https://github.com/mvschwarz/openrig/pull/1365)), and with managed compaction on, Claude sessions OpenRig launches carry `idleCompaction: false`, so Claude Code 2.1.290 or later doesn't compact them on its own while idle ([#1282](https://github.com/mvschwarz/openrig/pull/1282)). A seat can give a slow refocus more time with `OPENRIG_REFOCUS_BUDGET_MS`, from 4500 (the default) to 52000 ms ([#1475](https://github.com/mvschwarz/openrig/pull/1475)).
**Queue rows that say what happened.**
- `rig queue update <id> --summary "<text>"` on its own corrects a row's subject in any state ([#1270](https://github.com/mvschwarz/openrig/pull/1270), reported in [#1040](https://github.com/mvschwarz/openrig/issues/1040)).
- `rig queue update <id> --state <closing state> --resolution "<outcome>"` stores the outcome on the row, `rig queue show` returns it, and a later fallback adds its line instead of erasing it ([#1292](https://github.com/mvschwarz/openrig/pull/1292)).
- `rig queue show <id> --full` on a closed row adds `closingTransition`: its state, reason, target, who closed it, when, and the closing note ([#1290](https://github.com/mvschwarz/openrig/pull/1290)).
- An agent with no shell can hand work to the team through `rig mcp serve`, which gains five queue tools (`rig_queue_create`, `rig_queue_show`, `rig_queue_list`, `rig_queue_claim` and `rig_queue_update`), acting as the seat named by `OPENRIG_SESSION_NAME`. Nothing wakes such an agent; it sees new work when it checks ([#1441](https://github.com/mvschwarz/openrig/pull/1441)).
**What a seat costs.** `rig usage cost [--seat <session>] [--json]` shows what each seat's harness reports about cost beside its tokens: for Claude, the status line's own estimate for the current session (labelled an estimate, not an invoice); for Codex, tokens and a note that Codex reports no cost. A missing figure reads "not reported", never $0. It needs no setting ([#1233](https://github.com/mvschwarz/openrig/pull/1233)), and the TUI's new System → Cost page shows the same lines ([#1261](https://github.com/mvschwarz/openrig/pull/1261)).
**Faster.** The CLI loads only the command being run and turns on Node's compile cache, so `rig queue list` and `rig ps` start several times faster in the author's measurements ([#1380](https://github.com/mvschwarz/openrig/pull/1380)). The daemon runs tmux without a shell, captures up to 24 transcripts in one tmux call, skips reading terminals that haven't changed, and reads queue activity and obligations with far fewer queries ([#1241](https://github.com/mvschwarz/openrig/pull/1241), [#1242](https://github.com/mvschwarz/openrig/pull/1242), [#1262](https://github.com/mvschwarz/openrig/pull/1262), [#1263](https://github.com/mvschwarz/openrig/pull/1263), [#1326](https://github.com/mvschwarz/openrig/pull/1326), [#1369](https://github.com/mvschwarz/openrig/pull/1369), [#1384](https://github.com/mvschwarz/openrig/pull/1384), [#1389](https://github.com/mvschwarz/openrig/pull/1389); reported in [#161](https://github.com/mvschwarz/openrig/issues/161) and [#308](https://github.com/mvschwarz/openrig/issues/308)). The timings are the authors' own.
## Off by default, and what acts anyway
- **`output.compact`** (`OPENRIG_OUTPUT_COMPACT`, from 0.6.11) now also shortens `rig queue show` and `rig queue transitions` (`--full` prints everything; `rig queue transitions --full` is new) ([#1224](https://github.com/mvschwarz/openrig/pull/1224)), and bounds `rig whoami --json` peers to `output.whoami_peers` (new, default 8), connected peers first; `--all-peers` lists everyone ([#1225](https://github.com/mvschwarz/openrig/pull/1225)). Over SSH `--host`, the note's full-list command now names the host ([#1248](https://github.com/mvschwarz/openrig/pull/1248)).
- **`provider.usage_windows`** (`OPENRIG_PROVIDER_USAGE_WINDOWS`, daemon, from 0.6.11) now also shows Claude's 5-hour and weekly windows from the seats' status lines in `rig provider status`, with `--provider codex|claude` to limit a refresh ([#1197](https://github.com/mvschwarz/openrig/pull/1197)); each seat's configured account ([#1235](https://github.com/mvschwarz/openrig/pull/1235)); and, with `--all-hosts` or `--hosts <ids>`, every registered machine's windows, listed once per allowance and never added up ([#1246](https://github.com/mvschwarz/openrig/pull/1246)). **A Claude account's current availability always reads `unknown`,** because nothing links a seat's reading to the login it used; each window still shows as that seat's reading ([#1504](https://github.com/mvschwarz/openrig/pull/1504)). The TUI's System → Usage and Usage · all hosts pages are always listed and, with the setting off, say how to turn it on ([#1243](https://github.com/mvschwarz/openrig/pull/1243), [#1259](https://github.com/mvschwarz/openrig/pull/1259)).
- **`usage.pi_cost_facts`** and **`usage.pi_openrouter_charge`** (`OPENRIG_USAGE_PI_COST_FACTS`, `OPENRIG_USAGE_PI_OPENROUTER_CHARGE`): Pi's own cost estimate in `rig usage cost`, counted once with a fork's inherited entries kept apart ([#1237](https://github.com/mvschwarz/openrig/pull/1237)), and OpenRouter's actual charge beside it, never added to it. With both on, the Pi runner asks OpenRouter for each request's charge with the key OpenRig already gives the seat ([#1244](https://github.com/mvschwarz/openrig/pull/1244)). A Pi seat reads both switches when it launches or resumes, so a change takes effect then; what was recorded stays visible.
- **`provider.subscription_moves`** (`OPENRIG_PROVIDER_SUBSCRIPTION_MOVES`): a record of moves of a Codex home's seats between subscriptions, `rig provider moves [--home|--seat|--limit]`, `rig provider moves show <id>` and `rig provider moves record --file <json|->`, plus a section of the `openrig-user` skill for an agent a person asks to manage subscriptions. While it's off, the commands exit 1 and the routes answer 409 ([#1450](https://github.com/mvschwarz/openrig/pull/1450)).
- **`bundle.services_enabled`** (`OPENRIG_BUNDLE_SERVICES_ENABLED`): a bundle carries its declared Compose file, `rig bundle inspect` shows each service before anything runs, and `rig up` and `rig bootstrap` install it into a lasting target folder ([#1474](https://github.com/mvschwarz/openrig/pull/1474)).
- **`transport.verify_input_box`** (`OPENRIG_TRANSPORT_VERIFY_INPUT_BOX`): a `--verify` send to Claude Code or Codex reads verified only when the text was seen in the input box and the box was empty after Enter. It only reports; nothing is retried. Multi-line sends and wakes to Claude Code stay unconfirmed ([#1267](https://github.com/mvschwarz/openrig/pull/1267)).
- **Per seat:** `rig seat set-typing-guard <seat> --mode draft-aware --reason "<why>"` checks the input box before typing and before Enter, and holds or refuses a message instead of typing over someone's unsent text; nothing retries it. `--mode hold` is the existing on, and `--mode off` the default ([#1481](https://github.com/mvschwarz/openrig/pull/1481)).
- **Per bundle:** `rig bundle create --include-login-files` includes those login files only when recipients should have them and the login's owner agrees, and records their paths as `included_login_files` ([#1488](https://github.com/mvschwarz/openrig/pull/1488)).
- **Daemon environment:** `OPENRIG_TMUX_CONTROL=1` sends the daemon's routine tmux commands through one long-lived control client ([#1383](https://github.com/mvschwarz/openrig/pull/1383)).
- **What acts with every setting off:** the twelve migrations; the plugin hooks in Claude seats; Claude's cost estimate is recorded from the status line, and `rig usage cost` and the TUI's Cost page work; a Pi `models.json` notice can report a provider key's presence instead of unknown, because `models.json` is now read the way Pi reads it ([#1244](https://github.com/mvschwarz/openrig/pull/1244)); and the draft-aware guard's shared send and startup code runs for every seat.
## Dependency and install-script changes
- **npm dependencies:** the daemon now declares `string-width` (`^8.3.0`), which was already in the installed tree, so the lockfile adds no package ([#1481](https://github.com/mvschwarz/openrig/pull/1481)). The daemon package exports two more entry points, `./composer-prompts` ([#635](https://github.com/mvschwarz/openrig/pull/635)) and `./workflow-scope` ([#1273](https://github.com/mvschwarz/openrig/pull/1273)). The Node requirement is unchanged (`node >=22`).
- **Install scripts:** unchanged. The package's only lifecycle script is still `postinstall: node scripts/check-abi.mjs`.
- **Database:** the twelve migrations above.
## Everything else since 0.6.11
**Seats, launch and restore.**
- A failed process-lineage check says what it found (for example, that the running Codex was launched with a different conversation than the seat records), instead of blaming the pane's foreground label ([#1398](https://github.com/mvschwarz/openrig/pull/1398)). `rig reconcile-session` no longer reports a runtime drift for a pane reading `sh` when Claude or Codex is running in it ([#1395](https://github.com/mvschwarz/openrig/pull/1395), reported in [#1199](https://github.com/mvschwarz/openrig/issues/1199)); a Codex seat started through a shell wrapper isn't reported as returned to a shell while it starts ([#1373](https://github.com/mvschwarz/openrig/pull/1373), reported in [#79](https://github.com/mvschwarz/openrig/issues/79)).
- When a Claude seat's startup prompt is still unsent, the launch presses Enter up to twice, each time only while the box holds exactly that prompt, and more Claude footers are recognised, so fewer healthy launches warn "Startup submission unverified" ([#1295](https://github.com/mvschwarz/openrig/pull/1295)).
- In a Claude seat started with a permission mode, Shift+Enter inserts a newline when tmux's `extended-keys` is on: the launch passes the pane's `TERM_PROGRAM` and `TERM_PROGRAM_VERSION` to Claude ([#1399](https://github.com/mvschwarz/openrig/pull/1399)). The getting-started guide says how to add a newline when Shift+Enter submits ([#1404](https://github.com/mvschwarz/openrig/pull/1404)).
- The Codex resume command OpenRig prints keeps `--no-daemon` when the seat's last managed launch used it; for seats launched before this, the guidance says the choice wasn't recorded ([#1371](https://github.com/mvschwarz/openrig/pull/1371), reported in [#69](https://github.com/mvschwarz/openrig/issues/69)). The daemon logs why it couldn't capture a Codex seat's resume token, once per reason, and stops those captures when it shuts down ([#1284](https://github.com/mvschwarz/openrig/pull/1284)).
- `rig seat set-resume-token` writes to the seat's latest session even after the system clock moved backwards ([#1204](https://github.com/mvschwarz/openrig/pull/1204)). When `rig seat clean` refuses because an older session is still alive, its advice names that session instead of suggesting `rig seat stop`, which would act on the current occupant ([#1206](https://github.com/mvschwarz/openrig/pull/1206)).
- A launch leaves a project's `.claude/settings.local.json` or `.mcp.json` untouched, with a warning, when it isn't a JSON object, instead of overwriting it ([#1311](https://github.com/mvschwarz/openrig/pull/1311)). OpenRig no longer rewrites a Claude seat's settings file every 30 seconds when its status line is already in place ([#1286](https://github.com/mvschwarz/openrig/pull/1286)), and replaces a read-only copy of its context collector instead of failing ([#1251](https://github.com/mvschwarz/openrig/pull/1251)). The temporary launch script removes itself with `/bin/rm` or, if that's missing, the shell's own `rm`, so the agent still launches under `set -e` ([#1310](https://github.com/mvschwarz/openrig/pull/1310)).
- A daemon with its own `OPENRIG_HOME` but the real `HOME` or `CODEX_HOME` writes one advisory line to `daemon.log` and starts anyway ([#1299](https://github.com/mvschwarz/openrig/pull/1299)). With `HOME` unset, `rig auth` and the daemon look for Codex sign-in files in the account's real home ([#1418](https://github.com/mvschwarz/openrig/pull/1418)). An exported but empty `OPENRIG_HOME` is treated as unset by the TUI's socket, the terminal token lookup and `rig crash-cart` ([#1470](https://github.com/mvschwarz/openrig/pull/1470)).
- An OMP seat's restore catch-up and history refresh work past 1 MiB of history when OMP offers its RPC protocol v2, up to 64 MiB ([#1279](https://github.com/mvschwarz/openrig/pull/1279), reported in [#1275](https://github.com/mvschwarz/openrig/issues/1275)). A failed Pi or OMP turn shows the first usable error field ([#1208](https://github.com/mvschwarz/openrig/pull/1208)).
- `rig bootstrap` and `rig requirements` resolve a relative spec path from your own folder when the target is this install's local daemon ([#1213](https://github.com/mvschwarz/openrig/pull/1213)). `rig create --json` says why a create failed, in `detail` ([#1378](https://github.com/mvschwarz/openrig/pull/1378)).
- `rig status --json` prints the daemon, kernel, workspace root, rigs and cmux state as one object; `daemon.reachable: null` means unconfirmed, not down ([#1291](https://github.com/mvschwarz/openrig/pull/1291)).
- OpenRig 0.6.12 adds `rig community-harness list` for installed community-harness presets, showing their maintainer and declared limits. These harnesses are not tested by OpenRig. Support is launch-only: no resume or fork, no resource projection or startup-file delivery, and identity remains unconfirmed. A quiet screen is not proof that the seat is idle ([#1392](https://github.com/mvschwarz/openrig/pull/1392), [#1518](https://github.com/mvschwarz/openrig/pull/1518)).
**Sends, wakes and the queue.**
- Codex's `»` prompt is recognised when reading an input box, so a staged message on a Codex seat can get its one guarded Enter ([#635](https://github.com/mvschwarz/openrig/pull/635)), and `rig send --verify` tells a message still in a Codex input box from one that was delivered ([#1220](https://github.com/mvschwarz/openrig/pull/1220)).
- A Codex seat reads as needing a person only when its pane shows a real approval or selection prompt; an unreadable pane still gets there after a short grace, as `permission_request_unverified` ([#943](https://github.com/mvschwarz/openrig/pull/943), towards [#896](https://github.com/mvschwarz/openrig/issues/896)).
- **A held-back wake no longer uses up a one-shot park timer** (0.6.11's known issue): a wake the typing guard holds back is tried again at the timer's interval, and a shared one-shot timer retires when its last attachment is consumed ([#1222](https://github.com/mvschwarz/openrig/pull/1222)).
- Stopping or retiring a watchdog while a check is running stops it: no late wake and no late record ([#1325](https://github.com/mvschwarz/openrig/pull/1325)); its last attempt's time is still recorded ([#1439](https://github.com/mvschwarz/openrig/pull/1439)). A waiting row whose blocker changes while an earlier wake is being sent still gets its next wake ([#1435](https://github.com/mvschwarz/openrig/pull/1435)). A row parked on its owner's own repeating reminder isn't counted as stranded once the reminder has delivered, and `rig parked` marks it `servedByOwnReminder` ([#1424](https://github.com/mvschwarz/openrig/pull/1424)).
- Closing a row as handed off without a verifiable successor now points to `rig queue handoff`, and the stuck sweep flags a row closed onto itself ([#1358](https://github.com/mvschwarz/openrig/pull/1358), refs [#318](https://github.com/mvschwarz/openrig/issues/318)). A cross-host handoff's closing record carries the sender's identity, as a local one does ([#1333](https://github.com/mvschwarz/openrig/pull/1333)). Absorbing an inbox entry into queue work is one transaction, so overlapping absorbs make one row ([#1451](https://github.com/mvschwarz/openrig/pull/1451)).
- Help, warnings and the shipped skills say `--summary` is a row's subject everywhere it's listed, and carry the `project:`, `mission:` and `slice:` tags ([#1354](https://github.com/mvschwarz/openrig/pull/1354)); the reply hint for messages from a person includes `--summary` ([#1355](https://github.com/mvschwarz/openrig/pull/1355)). The TUI's RECENT list shows the first line of a row's body when it has no summary ([#1393](https://github.com/mvschwarz/openrig/pull/1393)).
- With nobody registered, a send to a person says to register one with `rig gateway human add` instead of reporting a missing registry projection ([#1219](https://github.com/mvschwarz/openrig/pull/1219)).
**Status, transcripts and the TUI.**
- `rig capture --lines N` that cut lines says activity is unknown, on stderr and as `activity: "unknown"` in `--json` ([#1226](https://github.com/mvschwarz/openrig/pull/1226)). `rig ps` with a context-usage filter but no `--nodes` says on stderr that the filter wasn't applied ([#1437](https://github.com/mvschwarz/openrig/pull/1437)).
- A stopped or removed seat's transcript capture stops, and its saved transcript stays readable at any age ([#1281](https://github.com/mvschwarz/openrig/pull/1281)). A quiet seat's transcript health can read `live` with reason `capture_unchanged` while its last capture is more than 10 seconds old ([#1262](https://github.com/mvschwarz/openrig/pull/1262)). `rig transcript --tail N` no longer cuts off the first requested line ([#1227](https://github.com/mvschwarz/openrig/pull/1227)).
- The TUI's slice header says what its proof count counts: `accepted` with a judgment policy, `paired` without ([#1288](https://github.com/mvschwarz/openrig/pull/1288)). TUI helpers keep working after the folder you started it in is deleted ([#1485](https://github.com/mvschwarz/openrig/pull/1485), reported in [#1479](https://github.com/mvschwarz/openrig/issues/1479)); Open terminals sends the window's width, so a wide terminal opens the default view ([#1205](https://github.com/mvschwarz/openrig/pull/1205)); the file reader finds headings with combining marks ([#1471](https://github.com/mvschwarz/openrig/pull/1471)); the Help list stops at its last command ([#1469](https://github.com/mvschwarz/openrig/pull/1469)); a sideways trackpad swipe no longer scrolls up or down ([#1458](https://github.com/mvschwarz/openrig/pull/1458)); and rows cut to width keep emoji whole ([#1453](https://github.com/mvschwarz/openrig/pull/1453)).
- `GET /api/hosts` reads an IPv6 HTTP host as reachable when it is ([#1457](https://github.com/mvschwarz/openrig/pull/1457)). The web UI's Log page keeps emoji whole in shortened previews ([#1454](https://github.com/mvschwarz/openrig/pull/1454)).
- `GET /api/events` takes `?replay=0|<n>|all` and streams its replay 500 events at a time; the default is unchanged ([#1387](https://github.com/mvschwarz/openrig/pull/1387)).
**Workflows, scope and proof.**
- `rig workflow instantiate` and `rig workflow run` take `--project`, `--mission`, `--slice` and `--tags`, stamped on every packet so the work shows on the slice's Queue tab; `instantiate-lifecycle` takes them from the lifecycle. An unknown project creates the instance unscoped with a warning ([#1273](https://github.com/mvschwarz/openrig/pull/1273)). A workflow's `preferred_targets` entry can be written `member@{rig}` to address whichever rig the instance is bound to ([#1293](https://github.com/mvschwarz/openrig/pull/1293)). An edited workflow spec restored with an older modification time is picked up ([#1363](https://github.com/mvschwarz/openrig/pull/1363)).
- The execution view shows a slice parked on an `external:` gate as a waiting lane ([#1303](https://github.com/mvschwarz/openrig/pull/1303)), and shows a `merged:<pr>` report on the merged rung, counting it as merged only with a `candidate:<sha>` that matches the slice's built commit ([#1302](https://github.com/mvschwarz/openrig/pull/1302)). A slice dependency can name a sibling by its SPEC `id:` ([#1372](https://github.com/mvschwarz/openrig/pull/1372)).
- Files saved with Windows (CRLF) line endings read the same as Unix ones in `SPEC.md` intents ([#1316](https://github.com/mvschwarz/openrig/pull/1316)), checkbox blocks ([#1315](https://github.com/mvschwarz/openrig/pull/1315)), `PROGRESS.md` ([#1314](https://github.com/mvschwarz/openrig/pull/1314)), proof and review headers ([#1402](https://github.com/mvschwarz/openrig/pull/1402)) and artifact-pool watchdog reads ([#1322](https://github.com/mvschwarz/openrig/pull/1322)).
- The Scopes view reads a proof drop's folded evidence references ([#1461](https://github.com/mvschwarz/openrig/pull/1461)) and mini-requirements numbered `1)` ([#1460](https://github.com/mvschwarz/openrig/pull/1460)). The daemon's scope audit answers "mission not found" for a mission name that leads outside the slices root ([#1221](https://github.com/mvschwarz/openrig/pull/1221)).
- `rig mode` keeps a saved mode and a work posture side by side at one target, and `rig mode unset --category mode|posture` removes one ([#1274](https://github.com/mvschwarz/openrig/pull/1274)); the `rig mode set` preview reads an unusable answer as unknown ([#1283](https://github.com/mvschwarz/openrig/pull/1283)); and `rig mode`'s human output marks the eight saved fields nothing in OpenRig acts on yet ([#1307](https://github.com/mvschwarz/openrig/pull/1307)).
**Usage.** `rig usage series --since/--until` ([#1305](https://github.com/mvschwarz/openrig/pull/1305)) and `rig bundle history --since` ([#1306](https://github.com/mvschwarz/openrig/pull/1306)) say when a bound isn't a date or time. `rig usage top` no longer shows a negative rate after a window resets ([#1473](https://github.com/mvschwarz/openrig/pull/1473)).
**Slack and notifications.**
- One Slack app can serve several machines: the machine holding the app-level token relays the channels listed in `forwardChannels` to the machines that own them, and a person's answer posts in their own thread ([#1436](https://github.com/mvschwarz/openrig/pull/1436), reported in [#192](https://github.com/mvschwarz/openrig/issues/192)). A `botInbox` block delivers named bots' posts in one channel to one seat, as rows marked as data from a program ([#1433](https://github.com/mvschwarz/openrig/pull/1433)).
- A workflow gate waiting on a person shows that person the question, not the agent packet ([#1143](https://github.com/mvschwarz/openrig/pull/1143), reported in [#1013](https://github.com/mvschwarz/openrig/issues/1013)), and still posts when its tags are very long ([#1240](https://github.com/mvschwarz/openrig/pull/1240)). A seat's follow-up to a button answer, or to a reaction on a resolved ask, lands in that ask's thread ([#1228](https://github.com/mvschwarz/openrig/pull/1228)). `rig slack manifest --help` and the setup guide say how to paste the manifest when Slack shows it empty ([#1207](https://github.com/mvschwarz/openrig/pull/1207), reported in [#158](https://github.com/mvschwarz/openrig/issues/158)).
- An ntfy notification title with an em dash, an emoji or other non-ASCII text is delivered as written ([#1317](https://github.com/mvschwarz/openrig/pull/1317)), and shortened notification bodies, slice stories and `rig ask` excerpts keep emoji whole ([#1472](https://github.com/mvschwarz/openrig/pull/1472)).
**Bundles, specs and packs.**
- A member's own `mechanic` survives bundling and other spec rewrites ([#1464](https://github.com/mvschwarz/openrig/pull/1464)). A blank list item in a rig spec, agent spec or startup `files:` list gets a validation error at its path instead of a crash ([#1318](https://github.com/mvschwarz/openrig/pull/1318)), and an agent spec profile written as a bare key reads as an empty profile ([#1320](https://github.com/mvschwarz/openrig/pull/1320)).
- Context packs ([#1234](https://github.com/mvschwarz/openrig/pull/1234)) and agent images ([#1465](https://github.com/mvschwarz/openrig/pull/1465)) accept file names with two dots inside a name, such as `notes..md`; a real `..` segment is still refused. An empty Docker Compose `ps` result reads as no services instead of unparseable output ([#1452](https://github.com/mvschwarz/openrig/pull/1452)). On macOS, `rig file copy` reports how many bytes it copied ([#1232](https://github.com/mvschwarz/openrig/pull/1232)).
**Requests the daemon now answers cleanly.** A route given a JSON body of `null` answers 400 `invalid_json` instead of 500 ([#1342](https://github.com/mvschwarz/openrig/pull/1342)); a limit of 2^63 or more on queue overdue and usage series answers 400 ([#1456](https://github.com/mvschwarz/openrig/pull/1456)); chat search behind `rig ask` matches `_` and `%` literally ([#1455](https://github.com/mvschwarz/openrig/pull/1455)); `rig restore`'s not-found hint gives the working `rig snapshot list <rig>` ([#1466](https://github.com/mvschwarz/openrig/pull/1466)); `rig watchdog register` warns when a cadence such as `5m` will be read as 5 seconds ([#1463](https://github.com/mvschwarz/openrig/pull/1463)); and `rig whoami --host <id> --full` forwards `--full`, which a remote older than v0.4.0 refuses ([#1231](https://github.com/mvschwarz/openrig/pull/1231)).
**Skills and guidance.**
- The `rigs` skill has a person's own Claude Code or Codex offer to join the team before its first message to the operator, so the operator sees a named teammate; inside tmux it says the effects first and the person chooses ([#1492](https://github.com/mvschwarz/openrig/pull/1492), [#1496](https://github.com/mvschwarz/openrig/pull/1496)). For a Codex user it makes one optional offer of a removable rules file so six `rig` commands run without approval prompts ([#1467](https://github.com/mvschwarz/openrig/pull/1467)). Setup re-checks a failed network check with network access before asking anyone to sign in again, installs through `rig setup`, and the operator offers the Workshop configuration that fits the person's providers ([#1434](https://github.com/mvschwarz/openrig/pull/1434)).
- `delegating-work` says to brief a peer according to the model it runs, which `rig ps --nodes --json` shows as `model` ([#1486](https://github.com/mvschwarz/openrig/pull/1486)). The `openrig-user` skill adds an opt-in loop that wakes an attached app's agent when work arrives ([#1444](https://github.com/mvschwarz/openrig/pull/1444)). `rig ask --wake` help and two skills explain the target forms ([#1426](https://github.com/mvschwarz/openrig/pull/1426)).
- The operator reads the keeping-a-watch page when a person asks it to watch something or remind them ([#1217](https://github.com/mvschwarz/openrig/pull/1217)), and the page teaches a "warn me when my allowance runs low" watch ([#1266](https://github.com/mvschwarz/openrig/pull/1266), [#1268](https://github.com/mvschwarz/openrig/pull/1268)).
- User-visible text says "harness" for the agent software around a model, and "community harness" for one an outside contributor maintains; `docs/reference/topology-naming.md` defines the terms ([#1506](https://github.com/mvschwarz/openrig/pull/1506)).
**Docs.** The agent startup guide explains how a Codex seat compacts and how to size it ([#1340](https://github.com/mvschwarz/openrig/pull/1340)); a new page explains how to keep your own Claude status line with OpenRig's context collection ([#1308](https://github.com/mvschwarz/openrig/pull/1308), reported in [#1272](https://github.com/mvschwarz/openrig/issues/1272)); the rig spec reference says a `HOME` value reaches Pi's agent ([#1335](https://github.com/mvschwarz/openrig/pull/1335)); and getting started pins the `rigs` skill to your installed release, with separate Claude Code and Codex commands and a Linux note for Codex's sandbox ([#1431](https://github.com/mvschwarz/openrig/pull/1431)). In the repository: the README says only the older web UI is in maintenance mode ([#1491](https://github.com/mvschwarz/openrig/pull/1491), reported in [#55](https://github.com/mvschwarz/openrig/issues/55)) and points contributors' agents to the `developing-openrig` skill ([#1370](https://github.com/mvschwarz/openrig/pull/1370)); the roadmap links the public board ([#1423](https://github.com/mvschwarz/openrig/pull/1423)); the contributor guides describe the one-home skill workflow ([#1432](https://github.com/mvschwarz/openrig/pull/1432)); and the coordination notes say which queue fields answer pane delivery ([#1277](https://github.com/mvschwarz/openrig/pull/1277), reported in [#165](https://github.com/mvschwarz/openrig/issues/165)).
**Tests and repository checks.** The Linux checks run on pushes to main ([#1487](https://github.com/mvschwarz/openrig/pull/1487)); shipped skills regenerate from one home in the repository ([#1396](https://github.com/mvschwarz/openrig/pull/1396)); a daemon-restart scenario joins the installed-package checks ([#1440](https://github.com/mvschwarz/openrig/pull/1440)); the snapshot tick looks up its restore source only when a seat changed ([#1375](https://github.com/mvschwarz/openrig/pull/1375)); tests use stub runtimes ([#1298](https://github.com/mvschwarz/openrig/pull/1298)), clean up their temporary folders ([#1300](https://github.com/mvschwarz/openrig/pull/1300)), keep compaction packets out of the shared folder ([#1352](https://github.com/mvschwarz/openrig/pull/1352), [#1362](https://github.com/mvschwarz/openrig/pull/1362)) and pass on macOS's temporary folder ([#1330](https://github.com/mvschwarz/openrig/pull/1330)); timing-sensitive tests wait on conditions ([#1245](https://github.com/mvschwarz/openrig/pull/1245), [#1255](https://github.com/mvschwarz/openrig/pull/1255), [#1427](https://github.com/mvschwarz/openrig/pull/1427), [#1443](https://github.com/mvschwarz/openrig/pull/1443), [#1447](https://github.com/mvschwarz/openrig/pull/1447)); the native transcript suite runs concurrently again ([#1377](https://github.com/mvschwarz/openrig/pull/1377)); cross-host usage gains tests ([#1265](https://github.com/mvschwarz/openrig/pull/1265)); and two repository scripts were fixed ([#1229](https://github.com/mvschwarz/openrig/pull/1229), [#1230](https://github.com/mvschwarz/openrig/pull/1230)).
## How it was tested
- **The final package:** 4,438 files and 7,594,843 bytes (sha256 `c16c934850d44139cd6b0cce400e84f976db448766fa15dccf8213cdc721598c`),
built from `9e5382dd` and stamped with it. An independent read matched every file against the release's source.
- **Compared with 0.6.11:** 206 changes; the package grows from 4,208 files and 7,102,511 bytes to 4,438 files and 7,594,843 bytes.
- **The source:** an independent read confirmed that the release's source is exactly main at `48fe7827` plus the first six picked changes, each identical to its merged original; the same read checked [#1506](https://github.com/mvschwarz/openrig/pull/1506), the seventh, and [#1518](https://github.com/mvschwarz/openrig/pull/1518), the eighth, at the sites where each was picked without its edits that depend on [#1498](https://github.com/mvschwarz/openrig/pull/1498). That's a check of what's in the release, not of how it behaves.
- **Each change** ran the repository's automated tests, merged into the main of its day, before it merged. On the release commit itself, 344 selected tests, the build, the CLI type check, the generator and the docs and audit checks passed, and the packaged help ran for three commands; that's a selected set, not the full test suite.
- **Upgraded installs:** three long-running Linux installs moved to this release, two from the released 0.6.11 and one from a pinned 0.6.11 build. Each kept its queue rows and their contents, its running agent processes and its team layout, and each database passed its integrity check. That doesn't show that each agent's own conversation carried on.
- **Teams at work after the upgrade:** two Codex seats started through the shipped launcher sent messages, handed work off and were woken in both directions. A daemon restart in the middle of work kept a parked row and a deliberately stopped seat as they were. The delivery and idle-detection checks matched 0.6.11's except for one deliberate change, the clearer message when pressing Enter fails (163 of 164, against 164 of 164). A Pi agent launched, replied and received its messages; when woken for work, it answered early, sent a message to itself and didn't close its row. The agents were Claude Code 2.1.299 and 2.1.303 and Codex 0.163.
- **A Claude agent through ordinary prompts, compaction and restore, with the plugin hooks loaded:** six managed compactions were started by command, not by the automatic threshold. Four compacted and restored: one of them needed a person to point the agent to its restore map, and another needed seven approvals. Two failed, for different reasons (see Known issues). After compaction, refocus didn't meet its check: for active work its trace reported a gap after timing out, and for parked work it showed a general fallback. No prompt stall from the hooks showed up in the samples taken ([#1344](https://github.com/mvschwarz/openrig/pull/1344)), which doesn't show there's none. A Codex seat compacted natively; Codex refocus wasn't run.
- **A full snapshot restore** brought back the three seats meant to be running and kept the stopped seat stopped, but reported `partially_restored` overall, with that stopped seat left awaiting a decision. Work and each conversation after the restore weren't checked.
- **The first-user check, on a Mac with fresh user accounts:** a person using Claude Code as their own agent installed OpenRig, reached a running team and got a useful change on a local branch, with some one-off approvals along the way. A person using Codex got the same result end to end after a workaround: Codex ran with `--no-daemon`, and the person answered 19 one-off approvals. Running both people on one Mac found the cross-user problem in Known issues. The Mac had been used before, so this isn't a brand-new-machine result.
- **Sharing a team through the registry:** a submission passed the registry's real pull-request check. We opened and closed the pull request for the person, so a person's own GitHub sign-in and submission weren't run.
- **Compared with 0.6.11,** the paths checked showed no problem newly introduced by this release. That covers the paths compared, not everything. Where problems did show up (the Mac cross-user routing and the two compaction failures), the code involved matches 0.6.11's, but 0.6.11 wasn't run alongside to compare.
- **Our own team's install** moved to this release, stalled while its host was under heavy load, and was put back on 0.6.10. The evidence points to the host running short of resources, but whether the stall came from this release isn't established. The install then moved to this release again from a fresh backup: its database passed its integrity check, its running agent sessions were kept, and a queue round trip worked at normal load. That shows a good start, not how it holds up over time.
- **Not run on this release:** a working day of use, the automatic compaction threshold and slow leaks (these checks were deliberate events over a few hours); a team with one machine still on an older release; the 30-second `--verify` wait from the package; a shared-server restart on a Mac; a brand-new Mac; native Windows; the installed-package scenarios on this exact release (they ran on an earlier build); Codex refocus and its packet; a real Codex fork; and a restore after a reboot. Other behaviour described in these notes comes from each change's own tests and reviews. **Slack wasn't run on this release:** the gate question ([#1143](https://github.com/mvschwarz/openrig/pull/1143)), one app across several machines ([#1436](https://github.com/mvschwarz/openrig/pull/1436)) and the standing Slack checks ship without a native run, and they're checked in real use after release.
## Known issues
- **On a Mac with more than one user account, a second user's `rig` commands can reach the first user's OpenRig.** In the first-user check, the second person's ordinary commands reached the first person's daemon on its default port (7433), read one of its queue rows and sent a request to the first person's operator, which ran its checks and refused to work on the other user's private project. Running each user's daemon on its own port let both people work, but it doesn't keep them apart. The code involved matches 0.6.11's; 0.6.11 wasn't run to compare. It's being diagnosed for 0.6.13.
- **Managed compaction failed in two of six runs on this release.** In one, saving the agent's recap needed a one-off approval, so the agent stopped at the approval and the compaction was never sent. In the other, the restore map was ready about ten seconds after the 120-second preparation deadline, so the compaction wasn't sent. The code involved matches 0.6.11's; 0.6.11 wasn't run to compare. Confirm that a compaction ran as described in the next item.
- **After a compaction, refocus may not show the agent's current work.** For active work, its trace can time out and report a gap; for parked work, it can show a general fallback. The code involved matches 0.6.11's; 0.6.11 wasn't run to compare.
- **The one full snapshot restore run on this release reported `partially_restored`:** the three seats meant to be running came back, and a deliberately stopped seat was left awaiting a decision.
- **Managed compaction's command budget is 600 characters,** set from one comparison on Claude Code 2.1.282 (792 characters ran as a command, 850 didn't), and a later Claude Code could move that line. Nothing confirms compaction ran; read `submitted, unconfirmed` as reached, and confirm by the restore request or the drop in context ([#1483](https://github.com/mvschwarz/openrig/pull/1483)).
- **Two plugin hook runs for the same seat aren't serialized:** if a harness overlaps them, a post-compaction record can be lost or a refocus delivered twice. Overlapping runs weren't tested, and the samples that showed no prompt stall don't show that overlaps or stalls can't happen ([#1344](https://github.com/mvschwarz/openrig/pull/1344), [#1475](https://github.com/mvschwarz/openrig/pull/1475)).
- **`rig policy current` and plain `rig ps` don't show a policy stored with `rig policy apply --seat`;** `rig seat status` and pod-aware export do ([#1285](https://github.com/mvschwarz/openrig/pull/1285)).
- **`env:` and `--env` values aren't secret,** and members added later with `rig grow` can't carry a member-level `env:` ([#1294](https://github.com/mvschwarz/openrig/pull/1294)).
- **`rig up --env` is not forwarded for flat-node specs or v1 bundles,** although the launch may succeed. Do not rely on the flag for those formats in this release ([#1294](https://github.com/mvschwarz/openrig/pull/1294)).
- **A fresh handover of a Claude seat in the classic renderer can report `complete` before the new Claude is ready,** because the readiness check can match the previous occupant's text still in the pane, so the handover packet may be typed before the successor starts. 0.6.11 has the same behaviour. Until it's corrected, check that the successor read its packet ([#1544](https://github.com/mvschwarz/openrig/issues/1544)).
- **One Slack app across machines was simulated in one process, never run on two real machines.** Relayed events are trusted from the calling daemon, a relay that keeps being refused is retried with no limit, `rig slack verify` on the hub doesn't check relayed channels, and an answer from a seat other than the one the message reached still posts at the top level ([#1436](https://github.com/mvschwarz/openrig/pull/1436)).
- **The draft-aware typing guard waits for idle inside the seat's delivery lease** when the input box is empty, which can delay other work for that seat (not measured), and it can't guard text a person types in the instant before OpenRig types ([#1481](https://github.com/mvschwarz/openrig/pull/1481)).
- **A Claude account's availability always reads `unknown` with usage windows on,** until a seat's reading can be linked to its login ([#1504](https://github.com/mvschwarz/openrig/pull/1504)).
- **A restore starts seats one after another,** so each seat that never draws its screen adds about the readiness timeout ([#1359](https://github.com/mvschwarz/openrig/pull/1359)).
- **A delivery already in transport when its watchdog stops still completes,** with its records ([#1325](https://github.com/mvschwarz/openrig/pull/1325)).
- **The recent-activity index costs about 10 to 12 MB** on a long-running install, and each new transition writes one more index entry ([#1326](https://github.com/mvschwarz/openrig/pull/1326)).
- **The `openrig-user` skill's subscription section mentions apps that don't ship in this release;** don't look for them ([#1450](https://github.com/mvschwarz/openrig/pull/1450)).
- **With `bundle.services_enabled` on, `rig up <file>.rigbundle` installs into the current folder when you don't give `--target`** ([#1474](https://github.com/mvschwarz/openrig/pull/1474)).
- **A send to a seat can still be refused for a quoted permission prompt** that isn't really waiting; this is unchanged from earlier releases.
- **Carried from 0.6.11:**
- **`rig seat restart --keep-conversation` supports Linux only,** and its native result hasn't been re-run on 0.6.12.
- **The rig status API can call a running rig `blocked` because of one seat that isn't running:** it folds a restore forecast into current status ([#1141](https://github.com/mvschwarz/openrig/pull/1141)).
- **A Codex seat can keep reading as at its usage limit** in a constructed screen case that hasn't been seen in a real session ([#1185](https://github.com/mvschwarz/openrig/pull/1185)).
- **After `rig scope slice create` says it couldn't verify membership, the slice was kept.** Check `mission.yaml` before creating it again ([#1007](https://github.com/mvschwarz/openrig/pull/1007)).
- **The first-install check is still limited:** on this release it ran on a Mac that had been used before, with fresh user accounts, for Claude Code and Codex (see How it was tested). A brand-new Mac and native Windows haven't been checked, and Linux was last checked with 0.6.11.
- A second session in the same folder can misread a running team; the operator can point a person without a display at the shared dashboard without saying how to open it; the skill installer may not select Claude Code when it detects another agent; `rig doctor` checks both harnesses; `rig tui --shared` attaches on the default tmux server; a team's first steps can still ask for approval; a seat waiting at a permission prompt shows no product notice of its own; a launched team writes into its working folder; a resumed Claude seat can't be confirmed after `/clear`, an in-process `/resume` or a hand-started process; Codex team seats don't ask before lifecycle commands; the send gate trusts the latest recorded hook; and after a restart `rig status` shows `Kernel: skipped`.
- A Claude Code seat whose permission request was approved automatically can still read "needs input"; the Codex case is fixed ([#943](https://github.com/mvschwarz/openrig/pull/943), [#896](https://github.com/mvschwarz/openrig/issues/896)).
- Slack missed-message recovery covers the default channel only, and private channels and direct messages aren't supported targets.
- Older issues, as in 0.6.11: the starter's Codex reviewer needs a Codex newer than 0.145; a restore can report a problem with a seat that works ([#273](https://github.com/mvschwarz/openrig/issues/273)); a `claude` alias defined only in a nushell rc doesn't resolve; after a reboot, start the daemon with `rig daemon start` and bring your rig back with `rig up <name> --existing`; the web UI's file preview runs scripts, so preview only files you trust; browser access isn't complete browser isolation; and Codex network inside the sandbox hasn't been checked with real logins, managed policy bundles or on Linux.
## Thanks
To the people whose pull requests are in this release:
- [@akushonkamen](https://github.com/akushonkamen), [@alxledesma](https://github.com/alxledesma), [@Jacob-9909](https://github.com/Jacob-9909), [@Mr-Neutr0n](https://github.com/Mr-Neutr0n), [@Sethhhhhhh](https://github.com/Sethhhhhhh) and [@sn1thy](https://github.com/sn1thy), in their first contributions;
- [@AdamWhitehurst](https://github.com/AdamWhitehurst), [@adampog](https://github.com/adampog), [@Coder8124](https://github.com/Coder8124), [@dajiaohuang](https://github.com/dajiaohuang), [@danielkuykendall23-boop](https://github.com/danielkuykendall23-boop), [@DeryFerd](https://github.com/DeryFerd), [@haralez](https://github.com/haralez), [@Nikhi00718](https://github.com/Nikhi00718), [@oodadoudou](https://github.com/oodadoudou), [@Quentin-M](https://github.com/Quentin-M), [@rudycelekli](https://github.com/rudycelekli), [@shravansumanthanan](https://github.com/shravansumanthanan), [@smota](https://github.com/smota), [@Totopo27](https://github.com/Totopo27), [@Vaishnavi220506](https://github.com/Vaishnavi220506), [@XTLine](https://github.com/XTLine) and [@zichen0116](https://github.com/zichen0116).
To the people whose earlier work was finished here: [@Aschenbath](https://github.com/Aschenbath) ([#1072](https://github.com/mvschwarz/openrig/pull/1072), re-landed with [@Mr-Neutr0n](https://github.com/Mr-Neutr0n)'s commits as [#1481](https://github.com/mvschwarz/openrig/pull/1481), and [#1158](https://github.com/mvschwarz/openrig/pull/1158) in [#1438](https://github.com/mvschwarz/openrig/pull/1438)) and [@dajiaohuang](https://github.com/dajiaohuang) ([#104](https://github.com/mvschwarz/openrig/pull/104), in [#1430](https://github.com/mvschwarz/openrig/pull/1430)).
And to the people whose reports shaped fixes here:
- [@a4w-h4y](https://github.com/a4w-h4y) ([#158](https://github.com/mvschwarz/openrig/issues/158));
- [@AlexGhanem](https://github.com/AlexGhanem) ([#1275](https://github.com/mvschwarz/openrig/issues/1275));
- [@archuity](https://github.com/archuity) ([#1405](https://github.com/mvschwarz/openrig/issues/1405));
- [@celaya-solutions](https://github.com/celaya-solutions) ([#79](https://github.com/mvschwarz/openrig/issues/79));
- [@d3fvxl](https://github.com/d3fvxl) ([#774](https://github.com/mvschwarz/openrig/issues/774));
- [@davey64](https://github.com/davey64) ([#1199](https://github.com/mvschwarz/openrig/issues/1199) and [#1479](https://github.com/mvschwarz/openrig/issues/1479));
- [@floynk-agent](https://github.com/floynk-agent) ([#896](https://github.com/mvschwarz/openrig/issues/896));
- [@jethrolarson](https://github.com/jethrolarson) ([#55](https://github.com/mvschwarz/openrig/issues/55));
- [@kainne44](https://github.com/kainne44) ([#70](https://github.com/mvschwarz/openrig/issues/70));
- [@korallis](https://github.com/korallis) ([#161](https://github.com/mvschwarz/openrig/issues/161) and [#308](https://github.com/mvschwarz/openrig/issues/308));
- [@m3ac-AllbrittenJ](https://github.com/m3ac-AllbrittenJ) ([#317](https://github.com/mvschwarz/openrig/issues/317), [#318](https://github.com/mvschwarz/openrig/issues/318) and [#331](https://github.com/mvschwarz/openrig/issues/331));
- [@mlc-factory-bot](https://github.com/mlc-factory-bot) ([#192](https://github.com/mvschwarz/openrig/issues/192));
- [@natefaerber](https://github.com/natefaerber) ([#1272](https://github.com/mvschwarz/openrig/issues/1272));
- [@psbanka](https://github.com/psbanka) ([#1040](https://github.com/mvschwarz/openrig/issues/1040));
- [@reisalbuquerque](https://github.com/reisalbuquerque) ([#69](https://github.com/mvschwarz/openrig/issues/69));
- [@roALAB1](https://github.com/roALAB1) ([#165](https://github.com/mvschwarz/openrig/issues/165));
- [@shintaii](https://github.com/shintaii) ([#27](https://github.com/mvschwarz/openrig/issues/27));
- [@tianqizhao0704](https://github.com/tianqizhao0704) ([#1013](https://github.com/mvschwarz/openrig/issues/1013)).
+92 -374
View File
File diff suppressed because it is too large. Load diff
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.7.1",
"version": "0.6.7",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -28,7 +28,6 @@
"gate": "node scripts/gate-lane.mjs"
},
"engines": {
"node": "^22 || ^24",
"npm": ">=10"
"node": "^22 || ^24"
}
}
+3 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.7.1",
"version": "0.6.7",
"type": "module",
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
"keywords": [
@@ -38,7 +38,7 @@
"scripts"
],
"scripts": {
"build": "tsc && node ../../scripts/prepare-workspace-build.mjs cli",
"build": "tsc && chmod +x dist/bin-wrapper.js && mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/ && mkdir -p dist/lib/scope-templates && cp src/lib/scope-templates/*.md dist/lib/scope-templates/ && cp ../../LICENSE LICENSE && cp ../../README.md README.md",
"postinstall": "node scripts/check-abi.mjs",
"prepublishOnly": "bash ../../scripts/build-package.sh",
"test": "vitest run",
@@ -47,8 +47,7 @@
"dependencies": {
"@hono/node-server": "^1.13.0",
"@hono/node-ws": "^1.3.1",
"@modelcontextprotocol/node": "^2.1.2",
"@modelcontextprotocol/server": "^2.4.0",
"@modelcontextprotocol/sdk": "^1.28.0",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
"better-sqlite3": "^13.0.3",
@@ -63,8 +62,6 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@modelcontextprotocol/client": "^2.4.0",
"@modelcontextprotocol/sdk": "^1.32.2",
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
-257
View File
@@ -1,257 +0,0 @@
import { spawn } from "node:child_process";
import { appendFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { JsonRpcRequestError, JsonRpcTransportClosed, StdioJsonRpcPeer } from "./json-rpc-stdio.js";
import { findRunProcesses, RUN_MARKER } from "./run-processes.js";
// `rig acp run`: one queue row's task to one ACP agent turn, with the outcome back on that row. OpenRig carries the
// agent's permission requests to an owning seat and returns that seat's answer; it holds no permission policy of its
// own. The connector runs as the row's custodian seat and names the agent as executor; it never claims as the agent.
export interface AcpQueueRow {
qitemId: string;
state: string;
destinationSession: string;
body: string;
resolution?: string | null;
tags?: string[] | null;
}
/** The queue verbs the connector uses, as the calling seat. `rig acp` wires these to the daemon's queue API. */
export interface AcpQueue {
show(qitemId: string): Promise<AcpQueueRow>;
claim(qitemId: string): Promise<void>;
/** A note, optionally with a state change. A note-only update stores no resolution (the queue keeps one only on close). */
update(qitemId: string, change: { note: string; state?: string }): Promise<void>;
create(row: { destination: string; body: string; summary: string; tags: string[] }): Promise<string>;
}
export interface AcpRunOptions {
qitemId: string;
/** The agent's program and arguments, e.g. ["hermes", "acp"]. No shell. */
agent: string[];
cwd: string;
/** The calling seat, which must own the row. */
self: string;
/** Who answers permission requests (default: the calling seat). */
permissionsTo?: string;
/** Load this earlier session instead of starting one; refused rather than replaced when it can't load. */
loadSession?: string;
runId: string;
logPath: string;
pollMs?: number;
/** Live progress for the terminal running the connector. */
progress?: (line: string) => void;
/** Resolves when the custodian asks to cancel the turn (Ctrl-C or `rig acp cancel`). */
cancelRequested?: Promise<void>;
/** Resolves when the custodian detaches without waiting (a second Ctrl-C): stdin closes and the turn reads unknown. */
detachRequested?: Promise<void>;
/** How long to wait for the agent to exit after its stdin closes (default 5 s). */
exitWaitMs?: number;
}
export type AcpOutcomeKind = "completed" | "stopped" | "cancelled" | "failed" | "unknown";
export interface AcpOutcome { kind: AcpOutcomeKind; summary: string; text: string; sessionId?: string }
/** Agent text that reports a failed turn while still ending it `end_turn` (OpenClaw #178057; its #44294 is open). */
export const INTERRUPTION_MARKERS = ["[OpenClaw interruption]"];
const FINAL_STATES = new Set(["done", "canceled", "handed-off", "failed", "denied"]);
const NOTE_TEXT_LIMIT = 6000;
/** The command a permission row's owner runs; `done` needs a closure reason. */
export const answerCommand = (row: string) => `rig queue update ${row} --state done --closure-reason no-follow-on --resolution <optionId>`;
class DetachedBeforeTurnEnd extends Error {}
interface PermissionOption { optionId: string; name?: string; kind?: string }
export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions): Promise<AcpOutcome> {
const progress = options.progress ?? (() => {});
const pollMs = options.pollMs ?? 2000;
const permissionsTo = options.permissionsTo ?? options.self;
let row = await queue.show(options.qitemId);
if (row.destinationSession !== options.self) {
throw new Error(`${options.qitemId} belongs to ${row.destinationSession}; run rig acp from that seat, or hand the row to ${options.self} first`);
}
if (row.state === "pending") { await queue.claim(options.qitemId); row = await queue.show(options.qitemId); }
if (row.state !== "in-progress") throw new Error(`${options.qitemId} is ${row.state}; rig acp runs an in-progress or pending row`);
mkdirSync(dirname(options.logPath), { recursive: true });
const log = (entry: Record<string, unknown>) => appendFileSync(options.logPath, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`);
const child = spawn(options.agent[0]!, options.agent.slice(1), {
cwd: options.cwd, stdio: "pipe", env: { ...process.env, [RUN_MARKER]: options.runId },
});
child.stderr.on("data", (data: Buffer) => log({ direction: "stderr", text: data.toString() }));
const spawned = new Promise<void>((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
const peer = new StdioJsonRpcPeer(child, (direction, packet) => log({ direction, packet }));
let sessionId: string | undefined;
let text = "";
let usage: { used?: number; size?: number; cost?: unknown } | undefined;
let toolCalls = 0;
let lastTool = "";
let lastProgressNote = Date.now();
let turnOver = false;
let cancelRequestedAt: string | undefined;
peer.onNotification("session/update", (params) => {
const { sessionId: from, update } = params as { sessionId?: string; update?: Record<string, unknown> };
if (!update || from !== sessionId) return; // another session's events never reach this row
if (update.sessionUpdate === "agent_message_chunk") {
const content = update.content as { type?: string; text?: string } | undefined;
if (content?.type === "text" && content.text) { text += content.text; progress(content.text); }
} else if (update.sessionUpdate === "tool_call") {
toolCalls += 1; lastTool = `${update.title ?? "tool"} (${update.kind ?? "other"})`;
progress(`\n[tool] ${lastTool}\n`);
} else if (update.sessionUpdate === "usage_update") {
usage = { used: update.used as number, size: update.size as number, cost: update.cost };
}
if (Date.now() - lastProgressNote >= 60_000) {
lastProgressNote = Date.now();
void queue.update(row.qitemId, { note: `ACP run ${options.runId} progress: ${toolCalls} tool calls (latest ${lastTool || "none"}), ${text.length} characters of output so far` }).catch(() => {});
}
});
peer.onRequest("session/request_permission", async (params, requestId) => {
const request = params as { sessionId?: string; toolCall?: Record<string, unknown>; options?: PermissionOption[] };
if (request.sessionId !== sessionId) throw Object.assign(new Error(`no turn of session ${request.sessionId} is running here`), { code: -32602 });
const offered = request.options ?? [];
const toolCall = request.toolCall ?? {};
for (;;) {
if (turnOver || cancelRequestedAt) return { outcome: { outcome: "cancelled" } };
const askId = await queue.create({
destination: permissionsTo,
summary: `Permission for ${row.qitemId}: ${String(toolCall.title ?? toolCall.toolCallId ?? "a tool call")} (${String(toolCall.kind ?? "other")})`,
tags: ["acp-permission", ...(row.tags ?? []).filter((tag) => /^(project|mission|slice):/.test(tag))],
body: permissionBody(row.qitemId, options.runId, sessionId!, requestId, toolCall, offered, options.logPath),
});
await queue.update(row.qitemId, { note: `ACP request ${requestId} (session ${sessionId}): permission asked of ${permissionsTo} as ${askId}; the turn waits for that answer` });
progress(`\n[permission] asked ${permissionsTo} as ${askId}\n`);
for (;;) {
await new Promise((resolve) => setTimeout(resolve, pollMs));
if (turnOver || cancelRequestedAt) {
await queue.update(askId, { state: "canceled", note: `Stale: the turn of ${row.qitemId} was cancelled before an answer; any answer here is not sent` }).catch(() => {});
return { outcome: { outcome: "cancelled" } };
}
const ask = await queue.show(askId);
// The answer point: a cancel that landed during the read above still wins, and nothing is awaited between here and the reply.
if (turnOver || cancelRequestedAt) {
const stale = `Stale: the turn of ${row.qitemId} was cancelled; ${FINAL_STATES.has(ask.state) ? "this answer was" : "any answer here is"} not sent`;
await queue.update(askId, FINAL_STATES.has(ask.state) ? { note: stale } : { state: "canceled", note: stale }).catch(() => {});
return { outcome: { outcome: "cancelled" } };
}
if (!FINAL_STATES.has(ask.state)) continue;
const chosen = chooseOption(offered, ask.resolution);
if (chosen) {
void queue.update(row.qitemId, { note: `ACP request ${requestId}: ${permissionsTo} answered ${chosen.optionId} on ${askId}; sent to the agent once` }).catch(() => {});
return { outcome: { outcome: "selected", optionId: chosen.optionId } };
}
await queue.update(row.qitemId, { note: `ACP request ${requestId}: ${askId} closed ${ask.state} without an offered option (${JSON.stringify(ask.resolution ?? null)}); asking again` });
break;
}
}
});
const record = async (outcome: AcpOutcome): Promise<AcpOutcome> => {
const shown = outcome.text.length > NOTE_TEXT_LIMIT ? `${outcome.text.slice(0, NOTE_TEXT_LIMIT)}\n… (truncated; the full transcript is in ${options.logPath})` : outcome.text;
await queue.update(row.qitemId, {
note: `ACP run ${options.runId} outcome ${outcome.kind}: ${outcome.summary}${shown ? `\n\n${shown}` : ""}\nThis is the agent's report, not a judgment of the work; the row stays open for you to judge and close with your own resolution.`,
});
return outcome;
};
try {
await spawned;
const init = await peer.request("initialize", { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: "openrig", version: "1" } }) as
{ protocolVersion?: number; agentCapabilities?: { loadSession?: boolean }; agentInfo?: { name?: string; version?: string } };
const loads = init.agentCapabilities?.loadSession === true;
if (options.loadSession) {
if (!loads) return await record({ kind: "failed", summary: `the agent doesn't advertise loadSession, so session ${options.loadSession} wasn't loaded and no new session was started`, text: "" });
await peer.request("session/load", { sessionId: options.loadSession, cwd: options.cwd, mcpServers: [] });
sessionId = options.loadSession;
} else {
sessionId = (await peer.request("session/new", { cwd: options.cwd, mcpServers: [] }) as { sessionId: string }).sessionId;
}
const agent = `${init.agentInfo?.name ?? options.agent[0]} ${init.agentInfo?.version ?? "(no version reported)"}`;
await queue.update(row.qitemId, {
note: `ACP run ${options.runId}: executor ${agent} (protocol ${init.protocolVersion ?? "?"}, loadSession ${loads ? "advertised" : "not advertised"}), session ${sessionId} ${options.loadSession ? "loaded" : "new"}, custodian ${options.self}; transcript ${options.logPath}`,
});
void options.cancelRequested?.then(async () => {
if (turnOver || cancelRequestedAt) return;
cancelRequestedAt = new Date().toISOString();
peer.notify("session/cancel", { sessionId });
await queue.update(row.qitemId, { note: `ACP run ${options.runId}: cancel requested at ${cancelRequestedAt}; upstream inference and spend are unknown unless the provider shows them` });
});
// A detach settles the turn here even when the agent keeps its stdout open after stdin closes.
const detached = new Promise<never>((_, reject) => {
void options.detachRequested?.then(() => { void peer.end(0); reject(new DetachedBeforeTurnEnd()); });
});
detached.catch(() => {});
const response = await Promise.race([peer.request("session/prompt", { sessionId, prompt: [{ type: "text", text: row.body }] }), detached]) as { stopReason?: string };
turnOver = true;
return await record(classify(response.stopReason, text, usage, cancelRequestedAt, sessionId));
} catch (error) {
turnOver = true;
if (error instanceof JsonRpcRequestError) {
return await record({ kind: "failed", sessionId, text, summary: `${error.method} returned error ${error.error.code} ${error.error.message}${error.error.data === undefined ? "" : ` ${JSON.stringify(error.error.data)}`}` });
}
if (error instanceof DetachedBeforeTurnEnd) {
return await record({ kind: "unknown", sessionId, text, summary: "detached before the turn ended; the agent may still be working" });
}
if (error instanceof JsonRpcTransportClosed) {
return await record({ kind: "unknown", sessionId, text, summary: "the agent's connection closed before the turn ended" });
}
return await record({ kind: "failed", sessionId, text, summary: `the agent couldn't be run: ${(error as Error).message}` });
} finally {
turnOver = true;
const exit = await peer.end(options.exitWaitMs ?? 5000);
const left = await findRunProcesses(options.runId).catch(() => undefined);
const settled = left === undefined ? "unknown (the process table couldn't be read)"
: left.length === 0 ? "settled: no process carrying this run's marker remains"
: `incomplete: ${left.length} process(es) carrying this run's marker still run (${left.map((p) => p.pid).join(", ")}); stop them with the runtime's own stop, or rig acp stop ${options.runId} -- <its stop command>`;
await queue.update(row.qitemId, { note: `ACP run ${options.runId} detached: agent ${exit === "still-running" ? "still running after stdin closed" : `exited (code ${exit.code ?? "none"}, signal ${exit.signal ?? "none"})`}; ${settled}` }).catch(() => {});
peer.release(); // a still-running agent keeps running, but holds nothing that keeps this process alive
}
}
/** The stop reason alone doesn't say the work happened: a silent `end_turn` is unknown, not done. */
export function classify(stopReason: string | undefined, text: string, usage: { used?: number } | undefined, cancelRequestedAt: string | undefined, sessionId?: string): AcpOutcome {
if (stopReason === "cancelled") {
return { kind: "cancelled", sessionId, text, summary: `cancel ${cancelRequestedAt ? `requested at ${cancelRequestedAt}, ` : ""}acknowledged by the agent at ${new Date().toISOString()} (stopReason cancelled); upstream spend is unknown` };
}
if (stopReason !== "end_turn") return { kind: "stopped", sessionId, text, summary: `the agent stopped with ${stopReason ?? "no stop reason"}` };
const interruption = INTERRUPTION_MARKERS.find((marker) => text.includes(marker));
if (interruption) return { kind: "unknown", sessionId, text, summary: `the agent ended its turn but reported an interruption (${interruption}); not treated as done` };
if (!text.trim() && !(usage?.used && usage.used > 0)) {
return { kind: "unknown", sessionId, text, summary: `the agent ended its turn with no output and ${usage ? "zero" : "no"} usage reported; not treated as done` };
}
return { kind: "completed", sessionId, text, summary: "the agent ended its turn (end_turn)" };
}
/** An owner answers with the offered option's id; its kind (allow_once …) works when only one option has it. */
export function chooseOption(offered: PermissionOption[], answer: string | null | undefined): PermissionOption | undefined {
const value = answer?.trim();
if (!value) return undefined;
const byId = offered.find((option) => option.optionId === value);
if (byId) return byId;
const byKind = offered.filter((option) => option.kind === value);
return byKind.length === 1 ? byKind[0] : undefined;
}
function permissionBody(qitemId: string, runId: string, sessionId: string, requestId: string | number, toolCall: Record<string, unknown>, offered: PermissionOption[], logPath: string): string {
const input = toolCall.rawInput === undefined ? undefined : JSON.stringify(toolCall.rawInput);
const where = Array.isArray(toolCall.locations) ? (toolCall.locations as Array<{ path?: string }>).map((l) => l.path).filter(Boolean).join(", ") : "";
return [
`# The ACP agent working on ${qitemId} asks permission`,
"",
`- **Action:** ${String(toolCall.title ?? "(no title)")} (kind ${String(toolCall.kind ?? "other")}, tool call ${String(toolCall.toolCallId ?? "?")})`,
where ? `- **Target:** ${where}` : undefined,
input === undefined ? undefined : input.length > 2000 ? `- **Input:** \`${input.slice(0, 2000)}\` … (truncated; the full input is in ${logPath})` : `- **Input:** \`${input}\``,
`- **Asked by:** ACP run ${runId}, session ${sessionId}, request ${requestId}`,
"",
"**Options the agent offered:**",
...offered.map((option) => `- \`${option.optionId}\`: ${option.name ?? ""}${option.kind ? ` (${option.kind})` : ""}`),
"",
`Answer on this row with \`${answerCommand("<this row>")}\`; don't hand it off.`,
"The agent's own settings and policy decide what it asks; your answer goes back to this one request, once. The turn waits until you answer.",
].filter((line) => line !== undefined).join("\n");
}
-141
View File
@@ -1,141 +0,0 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { createInterface } from "node:readline";
// ACP v1 is JSON-RPC 2.0 over newline-delimited JSON on the agent's stdio. This peer covers what an OpenRig client
// needs (a few outgoing requests, incoming requests and notifications); the official SDK would add a dependency for
// a handful of methods, so swap to it only if the method set grows.
export interface JsonRpcErrorObject {
code: number;
message: string;
data?: unknown;
}
/** An error the agent returned, with its original code, message and data kept for the record. */
export class JsonRpcRequestError extends Error {
constructor(readonly method: string, readonly error: JsonRpcErrorObject) {
super(`${method} failed: ${error.code} ${error.message}`);
this.name = "JsonRpcRequestError";
}
}
/** The connection ended before the agent answered; the request's effect is unknown. */
export class JsonRpcTransportClosed extends Error {
constructor(readonly method: string) {
super(`${method}: the agent's connection closed before it answered`);
this.name = "JsonRpcTransportClosed";
}
}
export type JsonRpcId = number | string;
export type IncomingRequestHandler = (params: unknown, id: JsonRpcId) => Promise<unknown>;
export type NotificationHandler = (params: unknown) => void;
/** Every packet in both directions, in order; lines that aren't JSON arrive as `in-text`. */
export type PacketRecorder = (direction: "out" | "in" | "in-text", packet: unknown) => void;
export class StdioJsonRpcPeer {
private nextId = 0;
private readonly pending = new Map<JsonRpcId, { method: string; resolve: (value: unknown) => void; reject: (error: Error) => void }>();
private readonly requestHandlers = new Map<string, IncomingRequestHandler>();
private readonly notificationHandlers = new Map<string, NotificationHandler>();
private ended = false;
private readonly lines: ReturnType<typeof createInterface>;
readonly exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
constructor(private readonly child: ChildProcessWithoutNullStreams, private readonly record: PacketRecorder = () => {}) {
this.lines = createInterface({ input: child.stdout });
this.lines.on("line", (line) => this.receive(line));
this.exited = new Promise((resolve) => child.once("close", (code, signal) => {
this.ended = true;
for (const [id, request] of this.pending) {
this.pending.delete(id);
request.reject(new JsonRpcTransportClosed(request.method));
}
resolve({ code, signal });
}));
child.stdin.on("error", () => { /* EPIPE after the agent exits; pending requests reject on close */ });
}
request(method: string, params: unknown): Promise<unknown> {
if (this.ended) return Promise.reject(new JsonRpcTransportClosed(method));
const id = ++this.nextId;
return new Promise((resolve, reject) => {
this.pending.set(id, { method, resolve, reject });
this.send({ jsonrpc: "2.0", id, method, params });
});
}
notify(method: string, params: unknown): void {
if (!this.ended) this.send({ jsonrpc: "2.0", method, params });
}
onRequest(method: string, handler: IncomingRequestHandler): void {
this.requestHandlers.set(method, handler);
}
onNotification(method: string, handler: NotificationHandler): void {
this.notificationHandlers.set(method, handler);
}
/** Close the agent's stdin (detach) and wait for the process to exit, up to `timeoutMs`. */
async end(timeoutMs: number): Promise<{ code: number | null; signal: NodeJS.Signals | null } | "still-running"> {
this.child.stdin.end();
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<"still-running">((resolve) => { timer = setTimeout(() => resolve("still-running"), timeoutMs); });
try { return await Promise.race([this.exited, timeout]); } finally { clearTimeout(timer); }
}
/** Drop this side's hold on the agent (its pipes and process handle) without signalling it, so a detached agent that
* keeps running doesn't keep the caller's process alive. */
release(): void {
this.lines.close();
for (const stream of [this.child.stdin, this.child.stdout, this.child.stderr]) stream.destroy();
this.child.unref();
}
private send(packet: Record<string, unknown>): void {
this.record("out", packet);
this.child.stdin.write(`${JSON.stringify(packet)}\n`);
}
private receive(line: string): void {
if (!line.trim()) return;
let packet: Record<string, unknown>;
try {
const parsed = JSON.parse(line) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
packet = parsed as Record<string, unknown>;
} catch {
this.record("in-text", line);
return;
}
this.record("in", packet);
const { id, method } = packet;
if (typeof method === "string" && (typeof id === "number" || typeof id === "string")) {
void this.answer(method, id, packet.params);
} else if (typeof method === "string") {
this.notificationHandlers.get(method)?.(packet.params);
} else if (typeof id === "number" || typeof id === "string") {
const request = this.pending.get(id);
if (!request) return; // a reply to nothing we asked; already recorded
this.pending.delete(id);
if (packet.error && typeof packet.error === "object") request.reject(new JsonRpcRequestError(request.method, packet.error as JsonRpcErrorObject));
else request.resolve(packet.result);
}
}
private async answer(method: string, id: JsonRpcId, params: unknown): Promise<void> {
const handler = this.requestHandlers.get(method);
if (!handler) {
this.send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Client does not handle ${method}` } });
return;
}
try {
const result = await handler(params, id);
if (!this.ended) this.send({ jsonrpc: "2.0", id, result });
} catch (error) {
const code = typeof (error as { code?: unknown }).code === "number" ? (error as { code: number }).code : -32603;
if (!this.ended) this.send({ jsonrpc: "2.0", id, error: { code, message: (error as Error).message } });
}
}
}
-33
View File
@@ -1,33 +0,0 @@
import { execFile } from "node:child_process";
import { readdir, readFile } from "node:fs/promises";
import { promisify } from "node:util";
/** Set in the agent's environment by `rig acp run`; every process it launches inherits it, even one that detaches
* (PPID 1), which is how a run finds what it started without touching anything it didn't. */
export const RUN_MARKER = "OPENRIG_ACP_RUN";
/** Only the pid: on macOS the listing carries the environment, which may hold secrets, so nothing else leaves here. */
export interface RunProcess { pid: number }
/** The caller's processes carrying this run's marker. Linux reads /proc; elsewhere `ps e` shows own environments. */
export async function findRunProcesses(runId: string): Promise<RunProcess[]> {
const marker = `${RUN_MARKER}=${runId}`;
if (process.platform === "linux") {
const found: RunProcess[] = [];
for (const entry of await readdir("/proc")) {
if (!/^\d+$/.test(entry)) continue;
try {
const environ = await readFile(`/proc/${entry}/environ`, "utf8");
if (!environ.split("\0").includes(marker)) continue;
found.push({ pid: Number(entry) });
} catch { /* exited, or not ours to read */ }
}
return found;
}
const { stdout } = await promisify(execFile)("ps", ["axeww", "-o", "pid=,command="], { maxBuffer: 32 * 1024 * 1024 });
return stdout.split("\n").flatMap((line) => {
const match = /^\s*(\d+)\s+(.*)$/.exec(line);
if (!match || !match[2]!.split(/\s+/).includes(marker)) return [];
return [{ pid: Number(match[1]) }];
});
}
+5 -64
View File
@@ -1,19 +1,12 @@
#!/usr/bin/env node
import module from "node:module";
import path from "node:path";
import { existsSync, realpathSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { fileURLToPath, pathToFileURL } from "node:url";
import type { RunProgramIo } from "./cli-error.js";
const REEXEC_GUARD_ENV = "OPENRIG_BIN_REEXEC";
// Each `rig` is a new process: Node's on-disk compile cache lets the next call skip compiling the modules
// again. The cache dates from Node v22.1.0, but this JavaScript API from v22.8.0: before it, `?.()` does
// nothing. NODE_COMPILE_CACHE and NODE_DISABLE_COMPILE_CACHE still control it.
module.enableCompileCache?.();
export function resolveBinEntry(invokedPath = process.argv[1], moduleUrl = import.meta.url): string {
const wrapperPath = invokedPath ? realpathSync(invokedPath) : realpathSync(fileURLToPath(moduleUrl));
const packageRoot = path.resolve(path.dirname(wrapperPath), "..");
@@ -86,70 +79,18 @@ export function canLoadInstalledNative(node: string, wrapper: string): boolean {
return probe.status === 0;
}
/** A command whose modules cannot be loaded fails as any other command does: the shared error object on stdout under
* `--json`, `error: …` on stderr otherwise, and exit 1. A human run also gets the stack, which names the module and
* line that failed, as Node's own crash did. The error helpers are imported only here: statically, they and the
* client they import would load before the compile cache is on. Should they fail to load too, Node's crash stands. */
async function failedToLoad(argv: string[], error: unknown, io: RunProgramIo): Promise<void> {
let helpers: typeof import("./cli-error.js");
try {
helpers = await import("./cli-error.js");
} catch {
throw error;
}
const { formatCliError, wantsJsonOutput } = helpers;
const failure = formatCliError(error);
const out = io.out ?? ((l: string) => process.stdout.write(l + "\n"));
const err = io.err ?? ((l: string) => process.stderr.write(l + "\n"));
if (wantsJsonOutput(argv.slice(2))) {
out(JSON.stringify(failure));
} else {
err(`error: ${failure.error.message}`);
if (error instanceof Error && error.stack) err(error.stack);
}
(io.exit ?? ((c: number) => process.exit(c)))(1);
}
export async function run(argv = process.argv, io: RunProgramIo = {}): Promise<void> {
export async function run(argv = process.argv): Promise<void> {
const normalizedArgv = [...argv];
if (normalizedArgv[1]) {
normalizedArgv[1] = realpathSync(normalizedArgv[1]);
}
const entryPath = resolveBinEntry(normalizedArgv[1], import.meta.url);
// A named command loads only its own module (lazy-program.ts), which ships beside the entry.
if (normalizedArgv.length > 2) {
const lazyPath = path.join(path.dirname(entryPath), "lazy-program.js");
let lazy: {
createLazyProgram: (first: string | undefined) => Promise<import("commander").Command | null>;
runProgram: (program: import("commander").Command, argv: string[], io?: RunProgramIo) => Promise<number>;
};
let program: import("commander").Command | null;
try {
lazy = await import(pathToFileURL(lazyPath).href) as typeof lazy;
program = await lazy.createLazyProgram(normalizedArgv[2]);
} catch (error) {
await failedToLoad(normalizedArgv, error, io);
return;
}
if (program) {
await lazy.runProgram(program, normalizedArgv, io);
return;
}
}
const entryUrl = pathToFileURL(entryPath).href;
let mod: {
const entryUrl = pathToFileURL(resolveBinEntry(normalizedArgv[1], import.meta.url)).href;
const mod = await import(entryUrl) as {
createProgram: () => import("commander").Command;
runProgram: (program: import("commander").Command, argv: string[], io?: RunProgramIo) => Promise<number>;
runProgram: (program: import("commander").Command, argv: string[]) => Promise<number>;
runFrontDoor?: (argv: readonly string[]) => Promise<boolean>;
};
try {
mod = await import(entryUrl) as typeof mod;
} catch (error) {
await failedToLoad(normalizedArgv, error, io);
return;
}
// Slice 17: the PUBLIC bin is the real front door — bare `rig` in a TTY
// opens the TUI here, not only under a direct `node dist/index.js` run
// (guard finding 1: importing the entry makes its isDirectRun false).
@@ -158,7 +99,7 @@ export async function run(argv = process.argv, io: RunProgramIo = {}): Promise<v
if (owned) return;
// Slice 15: run through the shared error path so `--json` failures emit a JSON
// error object with a nonzero exit instead of plain Commander text.
await mod.runProgram(mod.createProgram(), normalizedArgv, io);
await mod.runProgram(mod.createProgram(), normalizedArgv);
}
if (isDirectRun()) {
-19
View File
@@ -29,23 +29,6 @@ export function formatBundleBehaviour(view: BundleBehaviour): string[] {
if (view.posture.some(item => item.nonInterruptive === "available" && item.nonInterruptiveDefault !== true)) lines.push("Non-interruptive mode is available using rig bundle install <archive-or-link> --non-interruptive for the declared full-bypass Claude/Codex seats. This view does not select the mode or check whether you've already accepted the warning on this machine; login and other harness preconditions still apply.");
lines.push("Told files:", ...view.toldFiles.map(f => ` ${f.seat ? plain(f.seat) + ": " : ""}${plain(f.pathOrRef)} [${f.resolution}${f.delivery ? `; ${plain(f.delivery)}` : ""}]`));
lines.push("Also declared to run:", ...view.alsoRuns.map(f => ` ${f.seat ? plain(f.seat) + ": " : ""}${plain(f.kind)}: ${plain(f.pathOrRef)} [${f.resolution}${f.trigger ? `; ${plain(f.trigger)}` : ""}]`));
if (view.services) {
lines.push("Service declarations from the archive (not effective host configuration):");
for (const service of view.services) {
lines.push(` ${plain(service.name)} (${plain(service.composeFile)}): image ${plain(service.image)}`);
lines.push(` Published ports: ${service.ports.map(plain).join(", ") || "none declared"}`);
lines.push(` Added privileges: privileged=${plain(service.privileged)}, cap_add=${service.capAdd.map(plain).join(", ") || "none"}, security_opt=${service.securityOpt.map(plain).join(", ") || "none"}, devices=${service.devices.map(plain).join(", ") || "none"}`);
lines.push(` Environment names: ${service.environment.map(plain).join(", ") || "none declared"}`);
lines.push(` Mounts: ${service.mounts.map(plain).join(", ") || "none declared"}`);
lines.push(` Healthcheck: ${plain(JSON.stringify(service.healthcheck))}; rig wait_for: ${plain(JSON.stringify(service.waitFor))}`);
const downEffect = service.downPolicy === "leave_running" ? "leaves the Compose project running"
: service.downPolicy === "down_and_volumes" ? "stops the Compose project and removes its volumes"
: service.downPolicy === "down" ? "stops the Compose project" : "effect unresolved";
lines.push(` rig down policy: ${plain(service.downPolicy)} (${downEffect}; a live successor can retain the project)`);
if (service.unresolved.length) lines.push(` Unresolved on the installing host: ${service.unresolved.map(plain).join(", ")}`);
for (const note of service.notes) lines.push(` ${plain(note)}`);
}
}
lines.push("Writes and library additions (destinations, runtime support and conflicts resolved at launch):", ...view.writes.map(w => ` ${w.seat ? plain(w.seat) + ": " : ""}${plain(w.destinationBase)}/${plain(w.path)} — ${plain(w.operation)}, ${plain(w.phase)}`));
lines.push(`Outside domains found in files (not predicted traffic): ${view.outsideAddresses.map(a => plain(a.domain)).join(", ") || "none found"}`);
lines.push("Needs (not checked):");
@@ -72,8 +55,6 @@ export async function showBundleBehaviourBeforeAction(
): Promise<BundleBehaviour | undefined> {
try {
const response = await inspect();
const integrity = response.data.integrityResult as { warnings?: string[] } | undefined;
for (const warning of integrity?.warnings ?? []) output(plain(warning));
const view = response.data.behaviour as BundleBehaviour | undefined;
if (response.status < 400 && view?.schema === "openrig.bundle-behaviour/v1") {
for (const line of formatBundleBehaviour(view)) output(line);
-8
View File
@@ -42,7 +42,6 @@ export function enumArg(allowed: readonly string[]): (value: string) => string {
export function wantsJsonOutput(argv: readonly string[]): boolean {
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
if (a === "--") break;
if (a === "--json") return true;
if ((a === "-o" || a === "--output" || a === "--format") && argv[i + 1] === "json") return true;
if (/^(?:-o|--output|--format)=json$/.test(a)) return true;
@@ -146,13 +145,6 @@ export function renderDaemonTransportError(
return true;
}
/** For a command's own catch around a daemon request: renders a daemon transport error as the shared
* path does (a timeout says the outcome is unknown and to check its effect before any retry) and returns
* true; returns false for any other error, which the caller reports as before. */
export function renderCaughtTransportError(e: unknown, json = false): boolean {
return renderDaemonTransportError(e, { out: (l) => console.log(l), err: (l) => console.error(l), json });
}
/**
* Parse+run the program through the shared error path. Injectable IO for tests.
* Returns the process exit code (0 on success / clean help/version).
+34 -121
View File
@@ -1,11 +1,9 @@
import fs from "node:fs";
import { assessLocalDaemonOwner, ForeignLocalDaemonError, type LocalDaemonOwner } from "./local-daemon-owner.js";
export { ForeignLocalDaemonError } from "./local-daemon-owner.js";
import path from "node:path";
import { ConfigStore } from "./config-store.js";
import { getOpenRigHome, readOpenRigEnv, readOpenRigUrl } from "./openrig-compat.js";
import { getOpenRigHome, readOpenRigEnv } from "./openrig-compat.js";
import { fetchWithTimeout, FetchTimeoutError } from "./fetch-with-timeout.js";
import { FETCH_HEADERS_TIMEOUT_MS, nodeHttpFetch } from "./long-request-fetch.js";
import { readLocalOrigin } from "./local-origin.js";
export function terminalAuthHeaders(): Record<string, string> {
const token = resolveTerminalToken();
@@ -15,33 +13,23 @@ export function terminalAuthHeaders(): Record<string, string> {
/** The header carrying the caller's seat identity, derived ONCE at the transport chokepoint. */
export const SENDER_IDENTITY_HEADER = "X-OpenRig-Session";
/** The caller's occupant generation (its seat env), read only by a runtime switch's own checks. */
export const OCCUPANT_GENERATION_HEADER = "X-OpenRig-Occupant-Generation";
/**
* P18 sender-provenance — derive the caller's identity from the seat ENV (never a request-body
* claim). Routes that record authorship into the channel of record read ONLY this header, so a
* buggy/stale caller cannot write false history by fat-fingering or stale-copying a body field.
* Absent env ⇒ no header ⇒ the daemon DELIVERS and labels the write `claimed:v1` (P18 deliver-and-label;
* a write with no actor at all is 400 actor_required — parameter completeness, not a refusal). One
* chokepoint: `DaemonClient.fetch` stamps this on every request; callers never set it by hand. A client built for one
* authenticated caller (`sessionName`) carries that caller's seat instead of the process env.
* chokepoint: `DaemonClient.fetch` stamps this on every request; callers never set it by hand.
*/
export function senderIdentityHeaders(originSelfHostId?: string, sessionName?: string): Record<string, string> {
const session = (sessionName ?? readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME"))?.trim();
export function senderIdentityHeaders(originSelfHostId?: string): Record<string, string> {
const session = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")?.trim();
if (!session) return {};
// Preserve qualified senders; append only a locally derived instance id.
const value =
originSelfHostId && originSelfHostId.length > 0 && session.split("@").length < 3
? `${session}@${originSelfHostId}`
: session;
// Slice 16: the caller's own occupant generation from its seat env, so the two processes of one seat during a
// runtime switch can be told apart. Only a client acting for its own process carries it.
const generation = sessionName === undefined ? process.env.OPENRIG_OCCUPANT_GENERATION?.trim() : undefined;
return {
[SENDER_IDENTITY_HEADER]: value,
...(generation && /^[0-9A-Za-z-]{1,64}$/.test(generation) ? { [OCCUPANT_GENERATION_HEADER]: generation } : {}),
};
return { [SENDER_IDENTITY_HEADER]: value };
}
/** Construct registered remote clients with the origin derived from this instance's durable store. */
@@ -60,7 +48,7 @@ function resolveTerminalToken(): string | null {
const env = process.env.OPENRIG_TERMINAL_BEARER_TOKEN?.trim();
if (env) return env;
try {
const homeDir = getOpenRigHome();
const homeDir = process.env.OPENRIG_HOME ?? path.join(process.env.HOME ?? "", ".openrig");
const tokenPath = path.join(homeDir, "terminal-token");
const token = fs.readFileSync(tokenPath, "utf-8").trim();
return token || null;
@@ -122,7 +110,6 @@ export class DaemonResponseError extends Error {
export interface DaemonResponse<T = unknown> {
status: number;
data: T;
headers?: Headers;
}
/**
@@ -145,17 +132,8 @@ interface DaemonRequestOptions {
}
interface DaemonClientOptions {
ownerProbe?: (baseUrl: string, timeoutMs?: number) => Promise<LocalDaemonOwner>;
fetchImpl?: typeof fetch;
/** For a request allowed FETCH_HEADERS_TIMEOUT_MS or longer, which fetch would cut; default: fetchImpl when given, else
* node:http. */
longFetchImpl?: typeof fetch;
timeoutMs?: number;
/** The seat this client acts as, for a client built per authenticated caller; default: the process env. */
sessionName?: string;
/** With sessionName: the caller whose grant the MCP adapter checked, sent as X-OpenRig-Principal (JSON of its source,
* id and, for a grant-service grant, grant id and version; never a credential). */
principal?: { source: "file" | "grant-service"; id: string; grant?: string; version?: number | string };
}
/** Routing state published by daemon start, not a health check or sibling-home discovery. */
@@ -177,86 +155,48 @@ function localDaemonUrl(): string | undefined {
export class DaemonClient {
readonly baseUrl: string;
private fetchImpl: typeof fetch = fetch;
private longFetchImpl: typeof fetch = nodeHttpFetch;
private timeoutMs = 5_000;
private ownerProbe: (baseUrl: string, timeoutMs?: number) => Promise<LocalDaemonOwner>;
/** Set by remoteDaemonClient; absent origin is labelled explicitly on remote requests. */
originSelfHostId?: string;
remoteTarget = false;
private identity?: Promise<Record<string, string>>;
private originUnknown = false;
private readonly sessionName?: string;
private readonly principal?: DaemonClientOptions["principal"];
private async identityHeaders(): Promise<Record<string, string>> {
const { headers, local } = await this.senderHeaders();
// Only beside a seat this client was built for, and only to this host's own daemon: the endpoint itself must answer
// as this host's instance, whichever way it was chosen (OPENRIG_URL, or an explicit URL such as rig mcp serve --port,
// which can be a forwarded port). The daemon records it only on a direct, attributed request.
if (!this.principal || this.sessionName === undefined || !local || !(await this.endpointIsThisHost())) return headers;
// A header value can't carry characters beyond Latin-1 (fetch throws), and a grant name may hold any: everything past
// ASCII travels as JSON's own \u escapes, which the daemon's JSON.parse reads back.
const principal = JSON.stringify(this.principal).replace(/[\u0080-\uffff]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
return { ...headers, "X-OpenRig-Principal": principal };
}
/** Whether this client's endpoint answers /healthz with the id this host's own daemon minted. */
private async endpointIsThisHost(): Promise<boolean> {
if (this.remoteTarget) return false;
// Imported here, not at the top: it loads better-sqlite3 and yaml.
const origin = this.originSelfHostId ?? (await import("./local-origin.js")).readLocalOrigin();
return !!origin && (await this.endpointSelfHostId()) === origin;
}
/** The endpoint's own instance id from /healthz, or undefined when it doesn't answer within a second. */
private async endpointSelfHostId(): Promise<unknown> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
(async () => {
const res = await this.fetchImpl(`${this.baseUrl.replace(/\/+$/, "")}/healthz`, { signal: controller.signal });
return res.ok ? (await res.json() as { selfHostId?: unknown }).selfHostId : undefined;
})(),
new Promise<undefined>(resolve => { timer = setTimeout(() => resolve(undefined), Math.min(this.timeoutMs, 1000)); }),
]);
} catch { return undefined; }
finally { clearTimeout(timer); controller.abort(); }
}
/** The sender headers, and whether the endpoint is this host's own daemon: no direct endpoint is configured, or the
* endpoint answered with this host's own instance id. */
private async senderHeaders(): Promise<{ headers: Record<string, string>; local: boolean }> {
const localHeaders = senderIdentityHeaders(undefined, this.sessionName);
const localHeaders = senderIdentityHeaders();
const session = localHeaders[SENDER_IDENTITY_HEADER];
if (!session || session.split("@").length >= 3) return { headers: localHeaders, local: false };
const directTarget = readOpenRigUrl();
if (!this.remoteTarget && !directTarget) return { headers: localHeaders, local: true };
// Imported here, not at the top: it loads better-sqlite3 and yaml, which only a seat on a direct endpoint needs.
const origin = this.originSelfHostId ?? (await import("./local-origin.js")).readLocalOrigin();
if (!session || session.split("@").length >= 3) return localHeaders;
const directTarget = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
if (!this.remoteTarget && !directTarget) return localHeaders;
const origin = this.originSelfHostId ?? readLocalOrigin();
if (!origin) {
this.originUnknown = true;
return { headers: { ...localHeaders, "X-OpenRig-Origin-Unknown": "true" }, local: false };
return { ...localHeaders, "X-OpenRig-Origin-Unknown": "true" };
}
// Loopback may be a forwarded endpoint. Only matching instance identity proves locality; unproved, the sender carries
// the known origin, never the endpoint's identity.
if (!this.remoteTarget && (await this.endpointSelfHostId()) === origin) return { headers: localHeaders, local: true };
return { headers: senderIdentityHeaders(origin, this.sessionName), local: false };
}
/** The sender this client stamps on its requests (the X-OpenRig-Session value, absent without a seat), and whether it marked
* the origin unknown. */
async senderIdentity(): Promise<{ session: string | undefined; originUnknown: boolean }> {
this.identity ??= this.identityHeaders();
const headers = await this.identity;
return { session: headers[SENDER_IDENTITY_HEADER], originUnknown: headers["X-OpenRig-Origin-Unknown"] === "true" };
if (!this.remoteTarget) {
// Loopback may be a forwarded endpoint. Only matching instance identity proves locality.
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const target = await Promise.race([
(async () => {
const res = await this.fetchImpl(`${this.baseUrl.replace(/\/+$/, "")}/healthz`, { signal: controller.signal });
return res.ok ? (await res.json() as { selfHostId?: unknown }).selfHostId : undefined;
})(),
new Promise<undefined>(resolve => { timer = setTimeout(() => resolve(undefined), Math.min(this.timeoutMs, 1000)); }),
]);
if (target === origin) return localHeaders;
} catch { /* Unproved locality: carry the known origin, never the endpoint's identity. */ }
finally { clearTimeout(timer); controller.abort(); }
}
return senderIdentityHeaders(origin);
}
constructor(baseUrl?: string, options?: DaemonClientOptions) {
if (baseUrl) {
this.baseUrl = baseUrl;
} else {
const envUrl = readOpenRigUrl();
const envUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
if (envUrl) {
this.baseUrl = envUrl;
} else {
@@ -273,28 +213,7 @@ export class DaemonClient {
}
this.fetchImpl = options?.fetchImpl ?? fetch;
this.longFetchImpl = options?.longFetchImpl ?? options?.fetchImpl ?? nodeHttpFetch;
this.ownerProbe = options?.ownerProbe ?? ((url, timeoutMs) => assessLocalDaemonOwner(url, { fetchImpl: this.fetchImpl, timeoutMs }));
this.timeoutMs = options?.timeoutMs ?? 5_000;
this.sessionName = options?.sessionName;
this.principal = options?.principal;
}
/** Shared with raw CLI data readers. Constructor URLs alone do not establish explicit intent. */
localOwnerCheckEnabled(): boolean {
if (this.remoteTarget) return false;
const explicit = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
if (explicit && explicit.replace(/\/+$/, "") === this.baseUrl.replace(/\/+$/, "")) return false;
let enabled = false;
try { enabled = new ConfigStore().resolveWithSource("transport.local_user_guard").value === true; }
catch { /* Unreadable owner evidence/config is not a new default-on refusal. */ }
return enabled;
}
async assertLocalOwner(timeoutMs = 1000): Promise<void> {
if (!this.localOwnerCheckEnabled()) return;
const owner = await this.ownerProbe(this.baseUrl, timeoutMs);
if (owner.state === "foreign") throw new ForeignLocalDaemonError(this.baseUrl, owner);
}
async get<T = unknown>(path: string, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
@@ -347,8 +266,6 @@ export class DaemonClient {
}
private async fetch(path: string, init: RequestInit, options?: DaemonRequestOptions, consumeResponse?: (response: Response) => Promise<void>): Promise<Response> {
// Diagnostic health/status remains reachable even for a known foreign listener.
if (path.split("?")[0] !== "/healthz") await this.assertLocalOwner();
const timeoutMs = options?.timeoutMs ?? this.timeoutMs;
if (options?.signal) init = { ...init, signal: options.signal };
if (options?.headers) {
@@ -362,7 +279,7 @@ export class DaemonClient {
let responseStatus: number | undefined;
try {
const response = await fetchWithTimeout(
timeoutMs >= FETCH_HEADERS_TIMEOUT_MS ? this.longFetchImpl : this.fetchImpl,
this.fetchImpl,
`${this.baseUrl}${path}`,
init,
{
@@ -416,11 +333,7 @@ export class DaemonClient {
private async requestText(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<DaemonResponse<string>> {
let data = "";
let headers: Headers | undefined;
const res = await this.fetch(path, init, options, async (response) => {
headers = response.headers;
data = await response.text();
});
return { status: res.status, data, headers };
const res = await this.fetch(path, init, options, async (response) => { data = await response.text(); });
return { status: res.status, data };
}
}
-175
View File
@@ -1,175 +0,0 @@
import { Command } from "commander";
import { execFileSync, spawnSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { DaemonClient, type DaemonResponse } from "../client.js";
import { daemonStatusGuard, getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { processIdentity } from "../local-daemon-owner.js";
import { getOpenRigHome, readOpenRigEnv } from "../openrig-compat.js";
import { answerCommand, runAcpAssignment, type AcpQueue } from "../acp/connector.js";
import { findRunProcesses } from "../acp/run-processes.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
const runsDir = () => join(getOpenRigHome(), "acp", "runs");
const newId = (prefix: string, bytes: number) => `${prefix}-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(bytes).toString("hex")}`;
interface RunFile { runId: string; qitemId: string; pid: number; processStart?: string; agent: string[]; startedAt: string; endedAt?: string }
/** The connector's queue verbs over the daemon API, acting as the calling seat (the client stamps its session). */
export function daemonAcpQueue(client: DaemonClient): AcpQueue {
const ok = <T>(response: DaemonResponse<T>, what: string): T => {
if (response.status >= 400) throw new Error(`${what} returned HTTP ${response.status}: ${JSON.stringify(response.data)}`);
return response.data;
};
return {
async show(qitemId) {
const data = ok(await client.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`), `queue show ${qitemId}`);
const row = (data.item ?? data) as Record<string, unknown>;
return {
qitemId: String(row.qitemId), state: String(row.state), destinationSession: String(row.destinationSession),
body: typeof row.body === "string" ? row.body : "", resolution: (row.resolution as string | null) ?? null, tags: (row.tags as string[] | null) ?? null,
};
},
async claim(qitemId) { ok(await client.post(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {}), `queue claim ${qitemId}`); },
async update(qitemId, change) {
ok(await client.post(`/api/queue/${encodeURIComponent(qitemId)}/update`, { transitionNote: change.note, state: change.state }), `queue update ${qitemId}`);
},
async create(row) {
const qitemId = newId("qitem", 8);
ok(await client.post("/api/queue/create", { qitemId, destinationSession: row.destination, body: row.body, summary: row.summary, tags: row.tags }), `queue create for ${row.destination}`);
return qitemId;
},
};
}
function readRun(runId: string): RunFile | undefined {
try { return JSON.parse(readFileSync(join(runsDir(), `${runId}.json`), "utf8")) as RunFile; } catch { return undefined; }
}
/** A process's start time, the discriminator the CLI already uses for daemon ownership (pids get reused). `ps` from PATH,
* as the other process readers use it: the setuid /bin/ps can't run in a sandbox (macOS CI runs a plain copy). */
export function processStart(pid: number): string | undefined {
try {
return processIdentity(execFileSync("ps", ["-o", "uid=", "-o", "lstart=", "-p", String(pid)], { encoding: "utf8", env: { ...process.env, LC_ALL: "C" } }))?.start;
} catch { return undefined; }
}
/** The run is live only while it hasn't ended and its pid is still the process that started it. */
function liveConnector(run: RunFile): boolean {
return !run.endedAt && run.processStart !== undefined && processStart(run.pid) === run.processStart;
}
export function acpCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("acp").description("Hand a queue row's task to an ACP (Agent Client Protocol) agent and keep its outcome on the row");
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
const connect = async (): Promise<DaemonClient | undefined> => {
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
return daemonStatusGuard(status) ? deps.clientFactory(getDaemonUrl(status)) : undefined;
};
const self = () => readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
cmd
.command("run <qitemId> <agent...>")
.usage("<qitemId> [options] -- <agent program> [args...]")
.description("Send one row's task to an ACP agent as a single turn, and record its outcome on that row")
.option("--cwd <dir>", "The agent session's working directory", process.cwd())
.option("--permissions-to <session>", "The seat that answers the agent's permission requests (default: this seat)")
.option("--load-session <id>", "Continue an earlier session; if the agent can't load it, nothing new is started")
.option("--poll-seconds <n>", "How often to look for a permission answer", "2")
.addHelpText("after", `
Runs as this seat, the row's custodian; the agent is recorded as executor. The row's notes get the binding
(agent, session), progress, each permission request and its answer, and the outcome: completed, stopped,
cancelled, failed or unknown. The outcome is the agent's report, not a judgment of the work: the row stays open
for you to judge, then close or hand on with your own resolution.
A permission request becomes a row to --permissions-to, listing the options the agent offered. Answer it with
${answerCommand("<that row>")}
on that row; don't hand it off. OpenRig holds no policy of its own: the agent's settings decide what it
asks, and the owner decides the answer.
Ctrl-C (or rig acp cancel <runId>) asks the agent to cancel the turn; a second Ctrl-C detaches without waiting.
Ending the run closes the agent's stdin and reports whether any process carrying the run's marker remains.
Example:
rig acp run <qitem-id> --cwd ~/work/app -- openclaw acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token
Guide: rig context get reference/acp-agents.md`)
.action(async (qitemId: string, agent: string[], opts: { cwd: string; permissionsTo?: string; loadSession?: string; pollSeconds: string }) => {
const seat = self();
if (!seat) { console.error("rig acp run acts as the seat that owns the row, but OPENRIG_SESSION_NAME isn't set"); process.exitCode = 1; return; }
const client = await connect();
if (!client) return;
const runId = newId("acp", 4);
mkdirSync(runsDir(), { recursive: true });
const runFile: RunFile = { runId, qitemId, pid: process.pid, processStart: processStart(process.pid), agent, startedAt: new Date().toISOString() };
const writeRun = (run: RunFile) => writeFileSync(join(runsDir(), `${runId}.json`), `${JSON.stringify(run)}\n`);
writeRun(runFile);
let cancel!: () => void;
let detach!: () => void;
const cancelRequested = new Promise<void>((done) => { cancel = done; });
const detachRequested = new Promise<void>((done) => { detach = done; });
let interrupts = 0;
const onInterrupt = () => {
interrupts += 1;
if (interrupts === 1) { console.error("\n[rig acp] cancel requested; Ctrl-C again to detach without waiting"); cancel(); } else detach();
};
process.on("SIGINT", onInterrupt);
console.error(`[rig acp] run ${runId} for ${qitemId}; cancel with Ctrl-C or rig acp cancel ${runId}`);
try {
const outcome = await runAcpAssignment(daemonAcpQueue(client), {
qitemId, agent, cwd: resolve(opts.cwd), self: seat, permissionsTo: opts.permissionsTo, loadSession: opts.loadSession,
runId, logPath: join(runsDir(), `${runId}.jsonl`), pollMs: Math.max(1, Number(opts.pollSeconds) || 2) * 1000,
progress: (text) => process.stdout.write(text), cancelRequested, detachRequested,
});
console.log(`\n[rig acp] ${outcome.kind}: ${outcome.summary}`);
if (outcome.kind !== "completed") process.exitCode = 2;
} catch (error) {
console.error(`rig acp run: ${(error as Error).message}`);
process.exitCode = 1;
} finally {
process.removeListener("SIGINT", onInterrupt);
writeRun({ ...runFile, endedAt: new Date().toISOString() });
}
});
cmd
.command("cancel <runId>")
.description("Ask a running rig acp run to cancel its agent's turn (the same as Ctrl-C there)")
.action((runId: string) => {
const run = readRun(runId);
if (!run || !liveConnector(run)) { console.error(`No running rig acp run ${runId}`); process.exitCode = 1; return; }
process.kill(run.pid, "SIGINT");
console.log(`Cancel requested for ${runId}; its row ${run.qitemId} records what the agent acknowledges`);
});
cmd
.command("stop <runId> [stopCommand...]")
.usage("<runId> [-- <the runtime's own stop command> [args...]]")
.description("Run the agent runtime's own stop command, if given, then report whether every process the run launched has exited")
.addHelpText("after", `
rig acp never kills a process it didn't launch. A gateway the agent connects to but didn't start (OpenClaw's, or a
shared one) is left alone; stop it with its own command, if it's yours to stop. The result goes on the run's row.`)
.action(async (runId: string, stopCommand: string[]) => {
const run = readRun(runId);
if (!run) { console.error(`No rig acp run ${runId} on this machine`); process.exitCode = 1; return; }
if (liveConnector(run)) { console.error(`${runId} is still running; cancel it or detach first`); process.exitCode = 1; return; }
let ran = "no stop command was given";
if (stopCommand.length > 0) {
const result = spawnSync(stopCommand[0]!, stopCommand.slice(1), { stdio: "inherit" });
ran = `${stopCommand.join(" ")} exited ${result.error ? `with ${result.error.message}` : result.status ?? result.signal}`;
}
const left = await findRunProcesses(runId).catch(() => undefined);
const report = left === undefined ? "unknown: the process table couldn't be read"
: left.length === 0 ? `settled: no process from ${runId} remains`
: `incomplete: ${left.length} process(es) from ${runId} still run (${left.map((p) => p.pid).join(", ")}); they were not stopped`;
console.log(`${ran}; ${report}`);
if (left?.length !== 0) process.exitCode = 2;
const client = self() ? await connect() : undefined;
await client?.post(`/api/queue/${encodeURIComponent(run.qitemId)}/update`, { transitionNote: `ACP run ${runId} stop: ${ran}; ${report}` }).catch(() => {});
});
return cmd;
}
+4 -60
View File
@@ -1,9 +1,6 @@
import { Command } from "commander";
import { readFileSync } from "node:fs";
import { DaemonClient } from "../client.js";
import { shellQuote } from "../cross-host-executor.js";
import { readSelectedHost } from "../host-selection.js";
import { resolveRigHandle } from "../rig-handle.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -23,29 +20,17 @@ interface AddMemberResponse {
error?: string;
}
/** Quote a shell argument only when it needs it, so plain suggestions stay readable. */
function shellArg(s: string): string {
return /^[\w@%+=:,./-]+$/.test(s) ? s : shellQuote(s);
}
export function addMemberCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("add").description("Add a member to an existing pod in a running rig")
.addHelpText("after", `
The rig can be named the way rig whoami --json reports it, or by id. A name that
matches more than one rig is refused; use one of the listed ids instead.
Examples:
rig add my-rig dev ./reviewer.member.yaml
rig add 01KXYZ... dev ./reviewer.member.yaml --json`);
const cmd = new Command("add").description("Add a member to an existing pod in a running rig");
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
cmd
.argument("<rig>", "Rig name or ID of the target rig")
.argument("<rig-id>", "ID of the target rig")
.argument("<pod-namespace>", "Namespace of the existing pod to add the member to")
.argument("<member-fragment-path>", "Path to YAML/JSON member fragment file (spec snake_case fields)")
.option("--json", "JSON output for agents")
.option("--rig-root <path>", "Root directory for agent resolution")
.action(async (rigHandle: string, podNamespace: string, fragmentPath: string, opts: { json?: boolean; rigRoot?: string }) => {
.action(async (rigId: string, podNamespace: string, fragmentPath: string, opts: { json?: boolean; rigRoot?: string }) => {
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
if (!daemonStatusGuard(status)) return;
@@ -95,47 +80,9 @@ Examples:
if (opts.rigRoot) body["rigRoot"] = opts.rigRoot;
const client = deps.clientFactory(getDaemonUrl(status));
const refuse = (error: Record<string, unknown> & { message: string }) => {
if (opts.json) console.log(JSON.stringify({ ok: false, ...error }, null, 2));
else console.error(error.message);
process.exitCode = 1;
};
// Resolve before the POST: an ambiguous name must stop here, before any
// mutation, rather than add the member to whichever rig matched first.
const resolution = await resolveRigHandle(client, rigHandle);
// rig add has no --host and always targets the local daemon, while a
// selected remote host makes whoami/ps report THAT host's rigs. A name
// copied from them must not land on a same-named local rig (every
// install has a `kernel`). Exact local ids keep working as before.
const selectedHost = readSelectedHost();
if (selectedHost !== "local" && (resolution.kind === "ambiguous" || (resolution.kind === "resolved" && resolution.byName))) {
refuse({
code: "remote_host_selected",
message: `Host '${selectedHost}' is selected, but rig add only targets the local daemon, so it does not resolve the rig name '${rigHandle}'. Nothing was added. `
+ "Run rig add on that host, or pass the local rig id (find it with rig ps --json after rig host select local).",
});
return;
}
if (resolution.kind === "ambiguous") {
const rigRoot = opts.rigRoot ? ` --rig-root ${shellArg(opts.rigRoot)}` : "";
const suggestions = resolution.ids.map((id) => `rig add ${id} ${shellArg(podNamespace)} ${shellArg(fragmentPath)}${rigRoot}`);
refuse({
code: "rig_ambiguous",
message: `'${rigHandle}' matches ${resolution.ids.length} rigs. Nothing was added. Re-run with the specific id, e.g. ${suggestions.join(" | ")}`,
candidates: resolution.ids,
});
return;
}
const rigId = resolution.kind === "resolved" ? resolution.id : rigHandle;
const res = await client.post<AddMemberResponse>(
`/api/rigs/${encodeURIComponent(rigId)}/pods/${encodeURIComponent(podNamespace)}/members`,
body,
{ timeoutMs: 120_000 },
);
const data = res.data;
@@ -156,9 +103,6 @@ Examples:
?? (data.errors && data.errors.length > 0 ? data.errors.join("; ") : data.error)
?? `Add member failed (HTTP ${res.status})`;
console.error(msg);
if (resolution.kind === "passthrough" && data.code === "rig_not_found") {
console.error("Could not list rigs to resolve a name. If this is a rig name, pass the rig id instead (rig ps --json).");
}
process.exitCode = 1;
return;
}
@@ -167,7 +111,7 @@ Examples:
const icon = node.status === "launched" ? "OK" : "FAIL";
const session = node.sessionName ? ` (${node.sessionName})` : "";
const error = node.error ? ` - ${node.error}` : "";
console.log(`Added member to rig ${rigId === rigHandle ? rigId : `${rigHandle} (${rigId})`}`);
console.log(`Added member to rig ${rigId}`);
console.log(` Pod: ${data.result!.podNamespace}`);
console.log(` Member: [${icon}] ${node.logicalId}${session}${error}`);
+8 -10
View File
@@ -15,9 +15,7 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl , statusGuardMessage } from "../daemon-lifecycle.js";
import { renderCaughtTransportError } from "../cli-error.js";
import { daemonMayAnswer } from "../daemon-reachability.js";
import { getDaemonStatus, getDaemonUrl , statusGuardMessage} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -117,7 +115,7 @@ Examples:
async function getClient(): Promise<DaemonClient> {
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
if (!daemonMayAnswer(status)) {
if (status.state !== "running" || status.healthy === false) {
// B8-1b: epistemic-matched language via the one helper (down ≠ busy).
const gm = statusGuardMessage(status); throw new Error(`${gm.fact} ${gm.action}`);
}
@@ -267,7 +265,7 @@ Examples:
}
console.log(`Captured ${r.manifest.name} v${r.manifest.version} (${r.manifest.runtime}) at ${r.imagePath}`);
} catch (err) {
if (!renderCaughtTransportError(err)) console.error((err as Error).message);
console.error((err as Error).message);
process.exitCode = 1;
}
});
@@ -293,7 +291,7 @@ Examples:
}
console.log(`Deleted ${entry.id}${opts.force ? " (forced)" : ""}.`);
} catch (err) {
if (!renderCaughtTransportError(err)) console.error((err as Error).message);
console.error((err as Error).message);
process.exitCode = 1;
}
});
@@ -311,7 +309,7 @@ Examples:
if (opts.json) console.log(JSON.stringify(res.data, null, 2));
else console.log(`Pinned ${entry.id}.`);
} catch (err) {
if (!renderCaughtTransportError(err)) console.error((err as Error).message);
console.error((err as Error).message);
process.exitCode = 1;
}
});
@@ -329,7 +327,7 @@ Examples:
if (opts.json) console.log(JSON.stringify(res.data, null, 2));
else console.log(`Unpinned ${entry.id}.`);
} catch (err) {
if (!renderCaughtTransportError(err)) console.error((err as Error).message);
console.error((err as Error).message);
process.exitCode = 1;
}
});
@@ -375,7 +373,7 @@ Examples:
for (const err of r.errors ?? []) console.log(` error: ${err.imageId}: ${err.error}`);
}
} catch (err) {
if (!renderCaughtTransportError(err)) console.error((err as Error).message);
console.error((err as Error).message);
process.exitCode = 1;
}
});
@@ -397,7 +395,7 @@ Examples:
console.log(`Indexed ${res.data.count} agent image(s).`);
for (const e of res.data.errors) console.log(` error: ${e.source}: ${e.error}`);
} catch (err) {
if (!renderCaughtTransportError(err)) console.error((err as Error).message);
console.error((err as Error).message);
process.exitCode = 1;
}
});
+3 -16
View File
@@ -3,7 +3,6 @@ import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { resolveRigHandle } from "../rig-handle.js";
interface ArchiveResult {
ok: boolean;
@@ -30,7 +29,7 @@ export function archiveCommand(depsOverride?: StatusDeps): Command {
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
cmd
.argument("<rig>", "Rig name or identifier to archive")
.argument("<rigId>", "Rig identifier to archive")
.option("--force", "Archive even if the rig is running or degraded")
.option("--json", "JSON output for agents")
.action(async (rigId: string, opts: { force?: boolean; json?: boolean }) => {
@@ -38,20 +37,8 @@ export function archiveCommand(depsOverride?: StatusDeps): Command {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (!daemonStatusGuard(status)) return;
const client = deps.clientFactory(getDaemonUrl(status));
const resolution = await resolveRigHandle(client, rigId);
if (resolution.kind === "ambiguous") {
const msg = `'${rigId}' matches ${resolution.ids.length} active rigs. Nothing was archived. Re-run with the specific id, e.g. ${resolution.ids.map((id) => `rig archive ${id}`).join(" | ")}`;
if (opts.json) {
console.log(JSON.stringify({ ok: false, code: "rig_ambiguous", message: msg, candidates: resolution.ids }, null, 2));
} else {
console.error(msg);
}
process.exitCode = 1;
return;
}
const resolvedRigId = resolution.kind === "resolved" ? resolution.id : rigId;
const res = await client.post<ArchiveResult | ThreePartErrorBody>(
`/api/rigs/${encodeURIComponent(resolvedRigId)}/archive`,
`/api/rigs/${encodeURIComponent(rigId)}/archive`,
{ force: opts.force ?? false },
);
@@ -87,7 +74,7 @@ export function archiveCommand(depsOverride?: StatusDeps): Command {
if (r.archived) {
console.log(`Rig ${rigId} archived (reversible). It is hidden from the default view.`);
console.log(` See it with: rig ps --include-archived`);
console.log(` Bring it back: rig unarchive ${resolvedRigId}`);
console.log(` Bring it back: rig unarchive ${rigId}`);
} else {
console.log(`Rig ${rigId} was already archived.`);
}
+7 -27
View File
@@ -85,7 +85,7 @@ export function askCommand(depsOverride?: StatusDeps): Command {
.option("--json", "JSON output for agents")
.option("--seat <session-name>", "Scope the search to ONE seat's transcript across every generation that sat in it (cross-generation archaeology)")
.option("--session <token>", "Search ONE specific session's JSONL by its session token (read-only)")
.option("--wake <target>", "EXECUTES: wake member@rig[@N], or pass a session ID or title to the runtime — ask one question, get a snapshot answer, back to cold (runtime cost; explicit, never an implicit escalation from a search)")
.option("--wake <seat[@gen]|token>", "EXECUTES: wake a session (by seat[@generation] or raw resume token) — ask one question, get a snapshot answer, back to cold (runtime cost; explicit, never an implicit escalation from a search)")
.option("--runtime <runtime>", "runtime for --wake: claude (default) or codex")
.option("--wake-timeout <seconds>", "bounded wake timeout in seconds (default 180)")
.addHelpText("after", `
@@ -95,15 +95,11 @@ rig ask is one verb for information about the PAST, three ways to reach it:
generation that sat in it (L1, read-only)
3. rig ask <rig> "<q>" --session <token> search ONE session's JSONL by
token — "I have the token, find it" (L2, read-only)
4. rig ask <rig> "<q>" --wake <target>
WAKE that session (by member@rig[@N]
or runtime ID/title), ask, get a snapshot
4. rig ask <rig> "<q>" --wake <seat[@gen]|token>
WAKE that session (by seat[@generation]
or raw token), ask, get a snapshot
answer, back to cold (L3, EXECUTES)
Wake seat targets must include the rig: member@rig or member@rig@N (1 = newest tenure).
Values without @ go unchanged to the runtime as a session ID or title; a bare member
name is not resolved within <rig>.
Levels 1-2 are read-only archaeology (cheap, safe). Level 3 (--wake) EXECUTES an
agent — the only level with runtime cost. The answer is snapshot testimony:
checked, not believed.
@@ -112,17 +108,12 @@ Examples:
rig ask my-rig "what decisions were made about deployment?"
rig ask my-rig "what did we decide" --seat dev-planner@my-rig
rig ask my-rig "SECRET_MARKER" --session 3f2a-...-9c1
rig ask my-rig "summarize the gateway plan" --wake dev-planner@my-rig
rig ask my-rig "summarize the previous plan" --wake dev-planner@my-rig@2
rig ask my-rig "summarize the gateway plan" --wake 3f2a-...-9c1
Exit codes:
0 Success
1 Daemon not running, the rig is unknown or ambiguous, or --runtime or
--wake-timeout is invalid
2 Failed to fetch data from daemon; the --wake seat has no such tenure, or its
tenure isn't Claude or Codex or has no resume token; or the wake failed or
timed out`);
1 Daemon not running
2 Failed to fetch data from daemon (or wake timed out)`);
const getDeps = (): AskCommandDeps => (depsOverride as AskCommandDeps | undefined) ?? {
lifecycleDeps: realDeps(),
@@ -148,7 +139,7 @@ Exit codes:
}
let token = target;
let runtime = opts.runtime ?? "claude";
let runtime: "claude" | "codex" = opts.runtime === "codex" ? "codex" : "claude";
if (target.includes("@")) {
const status = await getDaemonStatus(deps.lifecycleDeps);
@@ -182,13 +173,6 @@ Exit codes:
runtime = resolution.runtime;
}
if (runtime === "claude-code") runtime = "claude";
if (runtime !== "claude" && runtime !== "codex") {
console.error(`Wake runtime '${runtime || "unknown"}' is not supported. Supported runtimes are claude and codex.`);
process.exitCode = 1;
return;
}
const runner = deps.wakeRunner ?? defaultWakeRunner;
const fileLocator = deps.wakeFileLocator ?? defaultWakeFileLocator;
const outcome = await runWake({ runner, fileLocator }, { question, token, runtime, timeoutMs });
@@ -231,10 +215,6 @@ Exit codes:
}
const result = res.data;
// The daemon answers rig: null only when the rig name matched no rig or
// more than one. Exit 1 like rig send/transcript/release do for a missing
// target, so a script or agent can tell a mistyped rig from "no matches".
if (!result.rig) process.exitCode = 1;
if (opts.json) {
console.log(JSON.stringify(result));
+14 -16
View File
@@ -13,11 +13,6 @@ function shellQuote(value: string): string {
const defaultTmuxExec: TmuxExecFn = (cmd: string) => execSync(cmd, { encoding: "utf-8" }).trim();
// tmux writes a control character in its output as "_", so a newline between fields can't separate them, and a
// session name may hold spaces or underscores. Ask for the two fixed-shape ids first; the rest of the line is the name.
const TMUX_CONTEXT_FORMAT = "#{pane_id} #{window_id} #{session_name}";
const TMUX_CONTEXT_LINE = /^(%\d+) (@\d+) (.+)$/;
export function resolveAttachContext(tmuxExec: TmuxExecFn = defaultTmuxExec):
| { attachmentType: "external_cli" }
| { attachmentType: "tmux"; tmuxSession: string; tmuxWindow?: string; tmuxPane: string } {
@@ -27,11 +22,15 @@ export function resolveAttachContext(tmuxExec: TmuxExecFn = defaultTmuxExec):
}
try {
const output = tmuxExec(`tmux display-message -p -t ${shellQuote(tmuxPane)} ${shellQuote(TMUX_CONTEXT_FORMAT)}`);
const match = TMUX_CONTEXT_LINE.exec(output.trim());
if (match) {
const [, resolvedPane, tmuxWindow, tmuxSession] = match;
return { attachmentType: "tmux", tmuxSession: tmuxSession!, tmuxWindow, tmuxPane: resolvedPane! };
const output = tmuxExec(`tmux display-message -p -t ${JSON.stringify(tmuxPane)} "#{session_name}\n#{window_id}\n#{pane_id}"`);
const [tmuxSession, tmuxWindow, resolvedPane] = output.split("\n").map((part) => part.trim());
if (tmuxSession && resolvedPane) {
return {
attachmentType: "tmux",
tmuxSession,
tmuxWindow: tmuxWindow || undefined,
tmuxPane: resolvedPane,
};
}
} catch {
// Fall back to external_cli if tmux metadata cannot be resolved.
@@ -58,9 +57,9 @@ export function attachCommand(depsOverride?: StatusDeps): Command {
.option("--member <name>", "Member name for pod attach mode")
.option("--runtime <runtime>", "Runtime for pod attach mode; optional guard for node mode")
.option("--cwd <path>", "Working directory to record", process.cwd())
.option("--display-name <name>", "Display label inside tmux; external session name outside tmux")
.option("--display-name <name>", "External session/display name to record")
.option("--print-env", "Print shell exports for OPENRIG_NODE_ID and OPENRIG_SESSION_NAME")
.option("--json", "JSON output, including the activity env the attached app needs. Its token is a credential: don't print or paste it, and use rig whoami for identity")
.option("--json", "JSON output")
.action(async (opts: {
self?: boolean;
rig: string;
@@ -120,8 +119,9 @@ export function attachCommand(depsOverride?: StatusDeps): Command {
body["tmuxSession"] = attachContext.tmuxSession;
if (attachContext.tmuxWindow) body["tmuxWindow"] = attachContext.tmuxWindow;
body["tmuxPane"] = attachContext.tmuxPane;
} else if (opts.displayName) {
body["displayName"] = opts.displayName;
}
if (opts.displayName) body["displayName"] = opts.displayName;
if (hasNode) {
body["logicalId"] = opts.node;
@@ -160,9 +160,7 @@ export function attachCommand(depsOverride?: StatusDeps): Command {
} else {
console.log(`Created node ${logicalId} and attached this shell in rig ${opts.rig}`);
}
console.log(`Sender: ${sessionName}`);
const physicalSession = res.data["sessionName"];
if (typeof physicalSession === "string" && physicalSession !== sessionName) console.log(`Tmux: ${physicalSession}`);
console.log(`Session: ${sessionName}`);
console.log(`Transport: ${
res.data["attachmentType"] === "tmux"
? "tmux"
+11 -41
View File
@@ -17,8 +17,6 @@ import {
SEAT_REGISTRY_DISCLAIMER,
type CodexAuthPaths,
} from "../lib/codex-auth.js";
import { localSelfHostId, originFor, runOnSshHost, type RunOnHostDeps } from "../lib/run-on-host.js";
import { realDeps } from "./daemon.js";
export interface AuthCommandDeps {
/** env source (default process.env) — tests point CODEX_HOME at a fixture. */
@@ -27,8 +25,6 @@ export interface AuthCommandDeps {
loginStatus?: (codexHome: string) => "logged_in" | "not_logged_in" | "unavailable";
/** clock for seats set updated_ts (default new Date().toISOString()). */
now?: () => string;
/** `--host`: the host registry and the SSH runner (tests inject both). */
host?: RunOnHostDeps;
}
function fail(reason: string): void {
@@ -48,34 +44,18 @@ function ensureRuntime(runtime: string): boolean {
export function authCommand(depsOverride?: AuthCommandDeps): Command {
const env = depsOverride?.env ?? process.env;
const paths = (codexHome?: string): CodexAuthPaths => resolveCodexHome(codexHome ? { ...env, CODEX_HOME: codexHome } : env);
const paths = (): CodexAuthPaths => resolveCodexHome(env);
const withRuntime = (c: Command): Command => c.option("--runtime <runtime>", "Runtime axis (MVP: codex)", "codex");
// Slice 18: the commands that move a Codex home act on a named home, here or on another registered machine.
const withHome = (c: Command): Command => c
.option("--codex-home <dir>", "The Codex home to act on (default: CODEX_HOME, else ~/.codex)")
.option("--host <id>", "Run it on this registered machine instead, over its SSH entry, as this seat");
type HomeOpts = { runtime: string; codexHome?: string; host?: string };
const onHost = async (verb: string, args: string[], opts: HomeOpts) => {
const seat = (env.OPENRIG_SESSION_NAME ?? env.RIGGED_SESSION_NAME)?.trim() || undefined;
const origin = await originFor(seat, depsOverride?.host?.selfHostId ?? (() => localSelfHostId(realDeps())));
const home = opts.codexHome ? ["--codex-home", opts.codexHome] : [];
await runOnSshHost(opts.host!, `auth ${verb}`, ["auth", verb, ...args, "--runtime", opts.runtime, ...home], {
origin,
// A switch changes that home; status and list only read.
...(verb === "switch" ? { changes: { check: `rig auth status --host ${opts.host} --codex-home ${opts.codexHome} (a switch copies the same saved profile again if repeated)` } } : {}),
}, depsOverride?.host);
};
const auth = new Command("auth").description(
"Manage agent auth profiles (CLI-local; runtime via --runtime). Tokens are never printed, logged, or stored.",
);
withHome(withRuntime(auth.command("status")))
withRuntime(auth.command("status"))
.description("Auth-file presence + login state (no secrets).")
.action(async (opts: HomeOpts) => {
.action((opts: { runtime: string }) => {
if (!ensureRuntime(opts.runtime)) return;
if (opts.host) return onHost("status", [], opts);
const r = authStatus(paths(opts.codexHome), { loginStatus: depsOverride?.loginStatus });
const r = authStatus(paths(), { loginStatus: depsOverride?.loginStatus });
console.log(`codex_home: ${r.codexHome}`);
console.log(`codex_home_present: ${r.codexHomePresent ? "yes" : "no"}`);
console.log(`active_auth_present: ${r.activeAuthPresent ? "yes" : "no"}`);
@@ -85,12 +65,11 @@ export function authCommand(depsOverride?: AuthCommandDeps): Command {
console.log(`login_status: ${r.loginStatus}`);
});
withHome(withRuntime(auth.command("list")))
withRuntime(auth.command("list"))
.description("List saved profiles by name.")
.action(async (opts: HomeOpts) => {
.action((opts: { runtime: string }) => {
if (!ensureRuntime(opts.runtime)) return;
if (opts.host) return onHost("list", [], opts);
for (const name of authList(paths(opts.codexHome))) console.log(name);
for (const name of authList(paths())) console.log(name);
});
withRuntime(auth.command("save <profile>"))
@@ -104,24 +83,15 @@ export function authCommand(depsOverride?: AuthCommandDeps): Command {
console.log(`profile_mode: ${r.mode}`);
});
withHome(withRuntime(auth.command("switch <profile>")))
.description("Restore saved credential bytes (sign-in is unverified).")
.addHelpText("after", `
With --host, the switch runs on that machine, over its SSH entry, and copies a profile already saved there; name the
home with --codex-home (rig auth status --host <id> shows the home it would use). Only the command and its output
cross the connection.`)
.action(async (profile: string, opts: HomeOpts) => {
withRuntime(auth.command("switch <profile>"))
.description("Activate a saved profile.")
.action((profile: string, opts: { runtime: string }) => {
if (!ensureRuntime(opts.runtime)) return;
if (opts.host && !opts.codexHome) {
return fail(`switch --host needs --codex-home: name the home on ${opts.host} (rig auth status --host ${opts.host} shows the home it would use)`);
}
if (opts.host) return onHost("switch", [profile], opts);
const r = authSwitch(paths(opts.codexHome), profile);
const r = authSwitch(paths(), profile);
if (!r.ok) return fail(r.reason);
console.log(`activated_profile: ${r.name}`);
console.log(`active_auth: ${r.activePath}`);
console.log(`active_mode: ${r.mode}`);
console.log("note: Restored saved credential bytes only; sign-in is unverified. Saved credentials may expire, be revoked, or be superseded by refresh elsewhere. Restart the affected seat and use the existing account verification; fresh sign-in may be required.");
console.log(`note: ${r.note}`);
});
+5 -9
View File
@@ -28,11 +28,10 @@ export function bootstrapCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("bootstrap").description("Bootstrap a rig from a spec file or bundle");
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
async function getTarget(deps: StatusDeps) {
async function getClient(deps: StatusDeps): Promise<DaemonClient | null> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (!daemonStatusGuard(status)) return null;
// Only the recorded local-daemon branch has a PID; URL/config targets may be remote.
return { client: deps.clientFactory(getDaemonUrl(status)), local: status.pid !== undefined };
return deps.clientFactory(getDaemonUrl(status));
}
cmd
@@ -46,15 +45,12 @@ export function bootstrapCommand(depsOverride?: StatusDeps): Command {
.option("--json", "Output as parseable JSON")
.action(async (spec: string, opts: { plan?: boolean; yes?: boolean; cwd?: string; target?: string; skipVersionCheck?: boolean; force?: boolean; json?: boolean }) => {
const deps = getDeps();
const target = await getTarget(deps);
if (!target) { process.exitCode = 1; return; }
const { client, local } = target;
const client = await getClient(deps);
if (!client) { process.exitCode = 1; return; }
// Library name resolution: if spec looks like a name (not a path), check library
let sourceRef = spec;
const isPath = spec.includes("/") || /\.(ya?ml|rigbundle)$/i.test(spec);
// Resolve caller paths only for the local daemon. Explicit URLs (even loopback
// tunnels), stateless targets and catalog sources keep daemon-side addressing.
let sourceRef = isPath && local ? nodePath.resolve(spec) : spec;
if (!isPath) {
try {
const { resolveLibrarySpec } = await import("./specs.js");
Loaded 100 of 2009 files, more files were not shown because too many files have changed in this diff. Show more