mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d95f9c8cb6 | ||
|
|
760024dd7c | ||
|
|
65e0696892 | ||
|
|
1d92174e26 | ||
|
|
2493794d1b | ||
|
|
a24237e6d6 | ||
|
|
f02dbd49a0 | ||
|
|
aacaa8159e | ||
|
|
b07f76b768 | ||
|
|
2abe5d4d4a | ||
|
|
46059e6267 | ||
|
|
6ad55f3bb3 | ||
|
|
64c07ff3ff | ||
|
|
9d7d40f218 | ||
|
|
aa379f44b8 | ||
|
|
cb6087c1ce | ||
|
|
12b9a842f3 | ||
|
|
6da8f5e903 | ||
|
|
f10e343eb6 | ||
|
|
88cadcbd74 | ||
|
|
061822c816 | ||
|
|
aef37577ed | ||
|
|
48af9fa8c9 |
No files matched your search
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "openrig-app",
|
||||
"description": "The OpenRig plugin: work with your OpenRig team from this app.",
|
||||
"owner": {
|
||||
"name": "Mike Schwarz",
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "openrig",
|
||||
"source": "./packages/daemon/assets/plugins/openrig",
|
||||
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "openrig-app",
|
||||
"description": "The OpenRig plugin: work with your OpenRig team from this app.",
|
||||
"owner": {
|
||||
"name": "Mike Schwarz",
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "openrig",
|
||||
"source": "./packages/daemon/assets/plugins/openrig",
|
||||
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
github: mvschwarz
|
||||
@@ -3,8 +3,7 @@ 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:
|
||||
@@ -77,46 +76,12 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npm run test:repo
|
||||
|
||||
linux-seat-environment:
|
||||
if: ${{ github.event_name != 'push' }}
|
||||
# Cover /proc seat environments and Linux canonical-input limits that macOS cannot exercise.
|
||||
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 test/pi-shell-launch-native.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
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
+3
-4
@@ -290,10 +290,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
|
||||
|
||||
-2726
File diff suppressed because it is too large.
Load diff
@@ -26,9 +26,9 @@ use:
|
||||
|
||||
```sh
|
||||
# Claude Code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent claude-code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.0/skills/rigs --skill rigs --agent claude-code
|
||||
# Codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.0/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
|
||||
@@ -68,14 +68,14 @@ Requires Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude Co
|
||||
|
||||
### Install it yourself
|
||||
|
||||
This runs OpenRig's install script from the `v0.7.5` 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:
|
||||
This runs OpenRig's install script from the `v0.7.0` 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.5/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.0/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.0/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
If the last step isn't ready, the installer reports `INSTALLED, SETUP NOT COMPLETE` and points to the details from `rig setup` above. Follow that output and any reported Fix, then rerun `rig setup`; the CLI does not need reinstalling. If the only remaining failures are provider sign-ins, sign in to each selected provider as below and continue.
|
||||
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.
|
||||
|
||||
**Or step by step:**
|
||||
|
||||
|
||||
+8
-2
@@ -6,9 +6,15 @@ This isn't a schedule or a promise: we release often, and an item ships when a g
|
||||
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.9
|
||||
## Planned for 0.7.1
|
||||
|
||||
[0.7.9 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.9): it follows through on 0.7.8. A person's typed words on a runner seat's permission ask now reach the agent, each permit OpenRig grants a runner by default is recorded, and a runner can keep a long, quiet turn reading working. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
|
||||
- **Team skills:** Team skills load through one router and stay out of your project.
|
||||
- **Interruption levels:** Per-seat interruption levels, plus presets for managed compaction and model or effort changes.
|
||||
- **Permissions:** See each existing seat's planned and current permissions.
|
||||
- **Bundles:** Preview and install bundles that run services; the feature remains off by default.
|
||||
- **Subscriptions (if ready):** One Fleet agent manages two registered Codex subscriptions across two Linux machines, following
|
||||
your standing preferences.
|
||||
- **Community:** Community contributions to launch, lifecycle, runtimes, observation and delivery.
|
||||
|
||||
## How to help
|
||||
|
||||
|
||||
@@ -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 ---
|
||||
@@ -49,8 +45,7 @@ COPY ${OPENRIG_TARBALL} /tmp/openrig.tgz
|
||||
RUN set -eux; \
|
||||
npm install -g /tmp/openrig.tgz; \
|
||||
rm -f /tmp/openrig.tgz; \
|
||||
rig --version; \
|
||||
rig --help
|
||||
rig --version
|
||||
|
||||
# --- layer 4: stub runtime assets (zero-token by construction; staged by the build verb into the
|
||||
# build context as ./stub-assets/, hashed into the manifest via deriveStubAssetsHash) ---
|
||||
|
||||
@@ -281,34 +281,6 @@ 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`)
|
||||
|
||||
@@ -384,47 +356,6 @@ and stays on Herdr when neither is (`providerNameFor`,
|
||||
`terminal-service.ts:175`); a named provider is used as named. 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`).
|
||||
|
||||
### Tmux server selection
|
||||
|
||||
Before reconciliation, `startup.ts` calls `selectTmuxServer` in
|
||||
`domain/tmux-server-selection.ts`. `terminal.tmux_server` defaults to `inherit`;
|
||||
`isolated` selects a socket derived from the canonical OpenRig home. The effective
|
||||
choice is persisted in `tmux-server.json`. A registered live seat, or an
|
||||
uninspectable old server, defers a requested change until a later restart.
|
||||
The persisted `pinned` flag and active marker make isolation and migration records
|
||||
mandatory, while ordinary inherited records remain advisory. Returning to inheritance
|
||||
commits an unpinned record before clearing the marker, so offline cleanup cannot reuse
|
||||
a stale inherited socket or keep enforcing a completed isolation choice.
|
||||
|
||||
`TmuxAdapter` receives the selection's `executionArgs` for both shell and argv
|
||||
transports, including its direct probe/cleanup calls. They are empty for ordinary
|
||||
`inherit` mode, preserving tmux's environment-based selection; `serverArgs` still
|
||||
names the server for attachments. The opt-in control client uses the same execution
|
||||
arguments for its connection, probes and fallback commands. The same startup choice feeds node
|
||||
inventory, `TerminalService.status()` and previews, composed panes, shared TUI attachment and
|
||||
CLI viewer management. Inherited SSH panes retain the remote-tmux replacement
|
||||
wrapper and plain recovery command without requiring remote OpenRig. Isolated
|
||||
SSH panes resolve the remote instance through its own `rig terminal attach`.
|
||||
For isolation, self-attachment checks the caller's socket before binding;
|
||||
offline destruction reads the persisted socket before removing state.
|
||||
|
||||
See [tmux server selection](../../reference/tmux-servers.md) for configuration
|
||||
loading, first-upgrade migration, discovery scope and platform behavior.
|
||||
|
||||
## 3. Resume honesty
|
||||
|
||||
The daemon does not assume a harness resumed just because the launch command
|
||||
|
||||
@@ -65,35 +65,6 @@ resolve → project → deliver pre-launch files → persist replay context →
|
||||
|
||||
## 2. Execution and projection types
|
||||
|
||||
The member's optional `claude_mcp: none` is normalized as `claudeMcp` and
|
||||
persisted independently in `nodes.claude_mcp` (migration 142). The
|
||||
expansion HTTP route preserves both `claude_mcp` and `claudeMcp`, including
|
||||
invalid values for the canonical validator to reject. An authored relaunch
|
||||
updates the seat choice after launch eligibility and startup-source resolution;
|
||||
an already-bound seat or a failed tmux launch keeps its prior value. Export
|
||||
describes the current runtime; a dormant choice on another runtime is omitted
|
||||
and must be restored in the Claude declaration when rebuilding from that export.
|
||||
The YAML export route names those agents in the existing `# Not carried:` report;
|
||||
the JSON export is unchanged. `rigspec-routes.test.ts` covers both pod-aware and
|
||||
legacy exports, omission controls and successful JSON export.
|
||||
|
||||
The shared `StartupOrchestrator` reads the current seat record, so both an authored start
|
||||
and saved-context restore pass the choice to Claude. Legacy resume and handover
|
||||
successors read it separately; a runtime-switch successor reads it only when
|
||||
the target is Claude. `claude-mcp.ts` builds the strict empty-server arguments
|
||||
for the adapters, shutdown resume probes and displayed recovery commands,
|
||||
including the conversation picker. Shutdown skips a probe with an unreadable
|
||||
saved choice and keeps the resume token without adding a shutdown refusal.
|
||||
The native lineage selectors recognize these exact
|
||||
arguments without treating JSON contents as session options. Invalid stored
|
||||
values refuse launch. With no choice, the adapters add no MCP arguments.
|
||||
|
||||
`claude-mcp-lifecycle.test.ts` exercises real schema/storage, export, snapshot
|
||||
and restore with simulated tmux/provider processes. `claude-mcp-launch.test.ts`
|
||||
executes managed and unmanaged shell commands against a private argv-printing
|
||||
fake, including fresh/resume/fork and legacy resume. These tests verify command
|
||||
plumbing and identity handling; they do not claim live provider enforcement.
|
||||
|
||||
Restore/snapshot types are detailed in `lifecycle-snapshot-restore.md`; the
|
||||
spec/projection types live here.
|
||||
|
||||
@@ -148,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
|
||||
|
||||
@@ -214,26 +214,16 @@ inside a caller-managed transaction, `eventBus.persistWithinTransaction(...)`
|
||||
(`event-bus.ts:70`) with subscribers notified after commit — across domain
|
||||
services (`stream-store.ts`, `workflow-runtime.ts`, `restore-orchestrator.ts`,
|
||||
`node-launcher.ts`, etc.) and route handlers. The event log is append-only and
|
||||
SQLite-backed, and it holds stored events only. `view.changed`, which the view
|
||||
event bridge (`view-event-bridge.ts`) produces for each queue, inbox, proof or project-classification change,
|
||||
is transient: `eventBus.publishTransient(...)` delivers it to the live
|
||||
subscribers without a row, without a `seq` of its own and without moving the
|
||||
notify watermark. It carries `transient: true` and, as its `seq`, the `seq` of
|
||||
the stored event that caused it.
|
||||
|
||||
Upgrade note: `view.changed` rows stored by older versions stay in the log, so
|
||||
`/api/events` replays and the telemetry export still return them until an events retention
|
||||
(a separate change) removes them.
|
||||
SQLite-backed.
|
||||
|
||||
SSE delivery surfaces include the following. The daemon has **11**
|
||||
`streamSSE(` call sites across **10** files in `packages/daemon/src/routes/`
|
||||
(`git grep -o 'streamSSE(' -- packages/daemon/src/routes | wc -l`;
|
||||
`git grep -l 'streamSSE(' -- packages/daemon/src/routes | wc -l`).
|
||||
|
||||
- `GET /api/events` — global stream of the stored events (`server.ts:730`
|
||||
`app.route("/api/events", eventsRoute)`); `?rigId=` keeps one rig's.
|
||||
Transient events (`view.changed`) are not stored, so they appear here
|
||||
neither live nor in a replay. Each event's SSE `id` is its `seq`. Before going live, a connection replays
|
||||
- `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;
|
||||
@@ -244,10 +234,6 @@ SSE delivery surfaces include the following. The daemon has **11**
|
||||
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.
|
||||
- `GET /api/views/sse` (alias `/api/views/watch`) and
|
||||
`GET /api/views/:viewName/sse` — `view.changed`, live only (`routes/views.ts`).
|
||||
An event's `id` there is the `seq` of the stored event that caused it, so it
|
||||
repeats across the views one change touches; it is not a replay cursor.
|
||||
- `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`).
|
||||
|
||||
@@ -178,60 +178,6 @@ 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
|
||||
@@ -461,23 +407,6 @@ 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.
|
||||
|
||||
Clients that need only current FYIs can use `GET /api/queue/open-human-updates`.
|
||||
It returns `{items, limit, truncated}` with open human-addressed updates, ordered
|
||||
critical then urgent then routine, without requiring a delivery receipt. `limit`
|
||||
defaults to 20 and accepts integers from 1 to 100. Optional `view` follows the
|
||||
same person perspective as `/api/attention`; a perspective read scans at most
|
||||
1000 rows and signals `truncated` when the window may omit matching items.
|
||||
|
||||
A finished item's result file is its `evidenceRef`, read through the daemon's
|
||||
shared roots with `rig_artifact_read` (MCP) or `rig queue artifact <qitemId>
|
||||
--json` (CLI). For a code result, the assignee writes a JSON file of kind
|
||||
`openrig.code-result/v1`, with `repo` (an absolute path or a URL), `base` and
|
||||
`revision` (full commit ids, SHA-1 or SHA-256), and optionally `paths`, `branch`
|
||||
and `fetch: {remote, ref}`. Both reads then add `codeResult`, the exact pair a
|
||||
client can diff. A manifest whose base or revision isn't a full commit id gets
|
||||
`codeResultIssue` instead. `branch` and `fetch` are hints for finding the commits,
|
||||
never the revision. Other files read as before.
|
||||
|
||||
- `/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
|
||||
@@ -500,9 +429,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
|
||||
|
||||
@@ -73,26 +73,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.
|
||||
@@ -293,7 +277,7 @@ cited here by number with the code that carries them):
|
||||
for one seat. `resume` captures an eligibility-checked `auto-rehydrate`
|
||||
snapshot when none exists and goes through `launchSingleNode`.
|
||||
- **Seat handover** — `rig seat handover <seat> --source
|
||||
fresh|rebuild|fork:<seat>|discovered:<id> --reason <reason> [--dry-run]`
|
||||
fresh|rebuild|fork:<id>|discovered:<id> --reason <reason> [--dry-run]`
|
||||
(the reason is required, dry run included; without it the command exits 2
|
||||
before contacting the daemon)
|
||||
(`packages/cli/src/commands/seat.ts:364`) calls
|
||||
@@ -405,20 +389,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
|
||||
|
||||
@@ -103,7 +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`) |
|
||||
| `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`) |
|
||||
| `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 |
|
||||
|
||||
`annotate` requires `annotation`, `hold` and `drop` require `reason`, `route`
|
||||
@@ -114,8 +114,8 @@ 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
|
||||
|
||||
@@ -462,40 +462,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
|
||||
|
||||
@@ -112,7 +112,3 @@ When a regression reaches a release, add one line: date, issue, artery, and whic
|
||||
|
||||
- 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.
|
||||
+28
-120
@@ -9,23 +9,24 @@ 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: 92a2b8f7157661716a9c79de742cd82a96d1e6f7
|
||||
last-updated: 2026-10-09
|
||||
---
|
||||
|
||||
# CLI Reference — Registered Commands and Options
|
||||
|
||||
Verified against source commit `34fdcb80543903b054a982cc48bb302ae6871682`.
|
||||
Verified against source commit `92a2b8f7157661716a9c79de742cd82a96d1e6f7`.
|
||||
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,084 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. The provider-switch and set-runtime rows include the later account-home options; those additions are not included in the snapshot counts. The `environment` section and the `host edit`, `host remove` and `terminal attach` rows were added later from source at `522d635c`, and aren't in the snapshot counts either. With `environment`, main registers 91 top-level commands.
|
||||
These are counts for that source snapshot, not a claim about a deployed release.
|
||||
The terminal rows below also include the later named-session options.
|
||||
|
||||
## Reading this reference
|
||||
|
||||
@@ -143,7 +144,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` |
|
||||
@@ -188,8 +189,10 @@ 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 show <entityId>` | — | `--json` |
|
||||
| `rig gateway human set <entityId> <field> <value>` | — | `--reason <reason>`<br>`--actor <actor>` |
|
||||
| `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` |
|
||||
@@ -205,26 +208,8 @@ the sections that were not read are `null`.
|
||||
| `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
|
||||
@@ -350,47 +335,17 @@ the sections that were not read are `null`.
|
||||
| `rig host add` | — | `--id <id>` **required**<br>`--transport <transport>` **required**<br>`--target <target>`<br>`--user <user>`<br>`--url <url>`<br>`--bearer-env <name>`<br>`--bearer-file <path>`<br>`--notes <text>`<br>`--json` |
|
||||
| `rig host select <id>` | — | `--json` |
|
||||
| `rig host rename <name>` | — | `--json` |
|
||||
| `rig host edit <id>` | — | `--target <target>`<br>`--user <user>`<br>`--url <url>`<br>`--notes <text>`<br>`--json` |
|
||||
| `rig host remove <id>` | — | `--json` |
|
||||
| `rig host pair <url>` | — | `--id <id>`<br>`--timeout <seconds>`<br>`--human <address>`<br>`--json` |
|
||||
| `rig host list` | `ls` | `--json` |
|
||||
| `rig host doctor <id>` | — | `--posture <profile>`<br>`--public-addr <ip>`<br>`--json` |
|
||||
|
||||
`rig host pair` needs the target daemon to run with `OPENRIG_AUTH_BEARER_TOKEN` set,
|
||||
even when it is bound to a tailnet address and so starts without one: pairing hands
|
||||
over that token. Without it, the target's `POST /api/hosts/pair-request` answers 409
|
||||
`pair_target_no_bearer`; it answers 409 `pair_human_required` when no single
|
||||
registered person can approve (none, or several with no default; name one with
|
||||
`--human`) (`packages/daemon/src/routes/hosts.ts:167-186`).
|
||||
|
||||
The request stays open on the target for as long as `--timeout` waits (600 seconds by default), and the person's
|
||||
approval says how long and until when, in UTC. A target older than this CLI ignores the timeout and keeps the request
|
||||
10 minutes.
|
||||
|
||||
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`. `startedWithoutRequired` lists the required startup files a handover's successor started without, and the compact read keeps it.
|
||||
- `--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
|
||||
|
||||
@@ -406,7 +361,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 serve` | — | `--port <port>`<br>`--http`<br>`--bind <host:port>` |
|
||||
| `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` |
|
||||
@@ -418,21 +373,9 @@ do not establish connectivity to a live peer.
|
||||
|---|---|---|
|
||||
| `rig acp` | — | — |
|
||||
| `rig acp run <qitemId> <agent...>` | — | `--cwd <dir>`<br>`--permissions-to <session>`<br>`--load-session <id>`<br>`--poll-seconds <n>` |
|
||||
| `rig acp serve` | — | `--to <member@rig>`<br>`--as <address>`<br>`--poll-seconds <n>` |
|
||||
| `rig acp cancel <runId>` | — | — |
|
||||
| `rig acp stop <runId> [stopCommand...]` | — | — |
|
||||
|
||||
### exec
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig exec` | — | — |
|
||||
| `rig exec harnesses` | — | `--json` |
|
||||
| `rig exec run <qitemId>` | — | `--harness <name>` **required**<br>`--cwd <dir>`<br>`--permissions-to <session>`<br>`--load-session <id>`<br>`--wait`<br>`--plan`<br>`--again`<br>`--poll-seconds <n>` |
|
||||
| `rig exec status <runIdOrQitemId>` | — | `--json` |
|
||||
| `rig exec cancel <runId>` | — | — |
|
||||
| `rig exec connector <runId>` (hidden) | — | — |
|
||||
|
||||
### agent
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
@@ -460,7 +403,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>`--urgent`<br>`--host <id>`<br>`--from <session>`<br>`--context <ref>`<br>`--json` |
|
||||
|
||||
### stream
|
||||
|
||||
@@ -476,12 +419,6 @@ do not establish connectivity to a live peer.
|
||||
| `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` |
|
||||
|
||||
Stream intake: the `stream.intake` setting (default `queue.worker`, or a canonical `<seat>@<rig>`) receives each emitted item.
|
||||
It opens an intake row for that seat, which wakes it, or adds a note to that seat's intake row still pending; a `critical`
|
||||
or interrupting item makes the row urgent. `rig config set stream.intake off` keeps emits passive. A runner's
|
||||
default-permit record (tag `runner-permission-permit`) opens no row.
|
||||
|
||||
### queue
|
||||
|
||||
@@ -491,17 +428,16 @@ default-permit record (tag `runner-permission-permit`) opens no row.
|
||||
| `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>`--closure-required-at <iso>`<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>`--host <id>`<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>`--host <id>`<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 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 fallback <qitemId>` | — | `--destination <session>` **required**<br>`--reason <text>`<br>`--json` |
|
||||
| `rig queue show <qitemId>` | — | `--full`<br>`--json`<br>`--host <id>` |
|
||||
| `rig queue artifact <qitemId>` | — | `--root <name>`<br>`--json` |
|
||||
| `rig queue transitions <qitemId>` | — | `--json`<br>`--full`<br>`--host <id>` |
|
||||
| `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`<br>`--host <id>` |
|
||||
| `rig queue show <qitemId>` | — | `--full`<br>`--json` |
|
||||
| `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` |
|
||||
| `rig queue inbox-drop <destinationSession>` | — | `--sender <session>`<br>`--body <text>`<br>`--body-file <path>`<br>`--tags <tags>`<br>`--urgency <urgency>`<br>`--audit <pointer>`<br>`--id <inboxId>`<br>`--json` |
|
||||
@@ -516,7 +452,7 @@ default-permit record (tag `runner-permission-permit`) opens no row.
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig slack` | — | — |
|
||||
| `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>`--reactions <mode>`<br>`--reason <reason>`<br>`--actor <actor>` |
|
||||
| `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` |
|
||||
@@ -526,7 +462,6 @@ default-permit record (tag `runner-permission-permit`) opens no row.
|
||||
| `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` | — | — |
|
||||
|
||||
@@ -572,7 +507,6 @@ default-permit record (tag `runner-permission-permit`) opens no row.
|
||||
| `rig terminal open <view>` | — | `--provider <name>`<br>`--session <name>`<br>`--window`<br>`--expected-plan <id>`<br>`--json` |
|
||||
| `rig terminal views` | — | `--json` |
|
||||
| `rig terminal status` | — | `--provider <name>`<br>`--session <name>`<br>`--json` |
|
||||
| `rig terminal attach <session>` | — | `--read-only` |
|
||||
|
||||
See [named terminal sessions](../reference/terminal-sessions.md) to select a
|
||||
Herdr session per request, including session-bound previews and window reuse.
|
||||
@@ -605,10 +539,9 @@ Herdr session per request, including session-bound previews and window reuse.
|
||||
| `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>` | — | `--full`<br>`--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 unregister [spec]` | — | `--prune-missing`<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` |
|
||||
@@ -619,7 +552,7 @@ Herdr session per request, including session-bound previews and window reuse.
|
||||
|
||||
| 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>`--history-plus-pane`<br>`--host <id>`<br>`--json` |
|
||||
|
||||
### broadcast
|
||||
|
||||
@@ -661,7 +594,7 @@ Only Claude and Codex tenures can be woken.
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig specs` | — | — |
|
||||
| `rig specs ls` | — | `--kind <kind>`<br>`--include-builtins`<br>`--json` |
|
||||
| `rig specs ls` | — | `--kind <kind>`<br>`--json` |
|
||||
| `rig specs show <name-or-id>` | — | `--kind <kind>`<br>`--json` |
|
||||
| `rig specs preview <name-or-id>` | — | `--kind <kind>`<br>`--json` |
|
||||
| `rig specs add <path>` | — | `--json` |
|
||||
@@ -730,24 +663,6 @@ Only Claude and Codex tenures can be woken.
|
||||
| `rig community-harness` | — | — |
|
||||
| `rig community-harness list` | — | `--json` |
|
||||
|
||||
### environment
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig environment` | — | — |
|
||||
| `rig environment setup` | — | `--directory <path>` **required**<br>`--binding <id>`<br>`--seat <address>`<br>`--rig <name>`<br>`--mcp-url <url>`<br>`--guest-base-url <origin>`<br>`--artifact-root <path>`<br>`--json` |
|
||||
| `rig environment create <binding>` | — | `--seat <address>` **required**<br>`--node <id>`<br>`--gateway <ref>` **required**<br>`--workspace <ref>` **required**<br>`--name <name>` **required**<br>`--image <digest>` **required**<br>`--provider <ref>` **required**<br>`--policy <path>` **required**<br>`--guest-base-url <origin>` **required**<br>`--operation <id>`<br>`--json` |
|
||||
| `rig environment attach <binding>` | — | `--seat <address>` **required**<br>`--node <id>`<br>`--gateway <ref>` **required**<br>`--workspace <ref>` **required**<br>`--name <name>` **required**<br>`--image <digest>` **required**<br>`--provider <ref>` **required**<br>`--policy <path>` **required**<br>`--guest-base-url <origin>` **required**<br>`--operation <id>`<br>`--json`<br>`--resource-id <id>` **required**<br>`--fingerprint <value>` |
|
||||
| `rig environment inspect <binding>` | — | `--live`<br>`--json` |
|
||||
| `rig environment connect <binding>` | — | `--generation <id>` **required** |
|
||||
| `rig environment export <binding>` | — | `--request <path>` **required**<br>`--json` |
|
||||
| `rig environment checkpoint <binding>` | — | `--generation <id>` **required**<br>`--operation <id>` **required**<br>`--json` |
|
||||
| `rig environment recreate <binding>` | — | `--request <path>` **required**<br>`--json` |
|
||||
| `rig environment completion <binding>` | — | `--request <path>` **required**<br>`--json` |
|
||||
| `rig environment effect <binding>` | — | `--effect <name>` **required**<br>`--generation <id>`<br>`--resource-id <id>`<br>`--operation <id>` **required**<br>`--engine-socket <path>`<br>`--json` |
|
||||
|
||||
It drives the maintained OpenShell reference; see the [OpenShell example](../reference/community-harness-examples/openshell/README.md).
|
||||
|
||||
### fork
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
@@ -841,12 +756,12 @@ It drives the maintained OpenShell reference; see the [OpenShell example](../ref
|
||||
| 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`<br>`--refresh`<br>`--provider <p>`<br>`--all-hosts`<br>`--hosts <ids>` |
|
||||
| `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>`--api-key-file <path>`<br>`--stored-login`<br>`--json` |
|
||||
| `rig provider switch` | — | `--seat <s>` **required**<br>`--to-account <a>`<br>`--token-file <path>`<br>`--api-key-file <path>`<br>`--stored-login`<br>`--force-unsafe`<br>`--clear-account-home`<br>`--account-home <path>`<br>`--reason <text>`<br>`--model <id>`<br>`--codex-config-profile <name>`<br>`--dry-run`<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` |
|
||||
| `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` |
|
||||
|
||||
@@ -1024,7 +939,6 @@ 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 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` |
|
||||
@@ -1032,16 +946,10 @@ for input and receipt examples, rollback, and snapshot behavior.
|
||||
| `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 set-permissions-to <seat>` | — | `--to <person>`<br>`--clear`<br>`--deny`<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>`--clear-account-home`<br>`--account-home <path>`<br>`--token-file <path>`<br>`--account-label <label>`<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` |
|
||||
|
||||
@@ -88,11 +88,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 +104,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 +143,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/`: 17 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` |
|
||||
@@ -168,28 +153,6 @@ Exit codes: 0 pass, 1 a leg failed, 2 lane busy, 3 the runner itself errored.
|
||||
|
||||
### Host mode: while you're writing a scenario
|
||||
|
||||
The tmux server selection regression also uses these scaffold and lifecycle helpers
|
||||
in `packages/daemon/test/tmux-server-isolation-daemon.test.ts`. It runs a real stub
|
||||
seat through config changes, daemon restarts, capture, self-attachment and cleanup,
|
||||
while native socket observations verify that a personal namesake survives. The
|
||||
test also removes the effective-server record and verifies that `destroy --all`
|
||||
leaves the daemon, database and both servers' sessions intact before restoring
|
||||
the record and checking normal cleanup. The
|
||||
YAML grammar has no config-write or socket-inventory surface, so these assertions
|
||||
live in the Vitest integration test. It does not exercise a real provider model,
|
||||
SSH connection or graphical terminal window.
|
||||
|
||||
`packages/daemon/test/tmux-server-two-instances.test.ts` adds two real daemons
|
||||
sharing a scratch OS home and inherited personal tmux server, with separate
|
||||
OpenRig homes, databases and ports. Both enable isolation and control mode. It
|
||||
checks same-named seats through inventory, capture and verified writes, observes
|
||||
the marked control session on each selected socket, then stops one instance and
|
||||
checks that the other instance's pane identity, control session and delivery still
|
||||
work. The personal namesake remains untouched. This test requires a tmux version
|
||||
that supports control mode and the built CLI and daemon. Like CI's package suites,
|
||||
it should run with scratch homes and outbound network restricted to localhost;
|
||||
scripted seats prove terminal routing and rendered delivery, not model receipt.
|
||||
|
||||
```sh
|
||||
npm run build # the runner drives packages/cli/dist/bin-wrapper.js
|
||||
node --import tsx packages/daemon/scripts/run-scenarios.mjs # the fixture scenario-*.yaml files
|
||||
@@ -212,12 +175,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:
|
||||
|
||||
@@ -227,13 +188,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
|
||||
@@ -300,7 +258,7 @@ means:
|
||||
| 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. |
|
||||
| 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 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). |
|
||||
| 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. |
|
||||
@@ -321,17 +279,13 @@ The most useful next pieces of harness work, roughly in dependency order:
|
||||
|
||||
### 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:
|
||||
Four scenarios run in CI: `queue-baton-survives-restart` (since #243) with its seeded pair,
|
||||
and `transcript-reads-addressed-seat`, `capture-returns-addressed-seat` and
|
||||
`daemon-restart-preserves-team-identity` in passing form only. 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. Those six 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 |
|
||||
|---|---|---|
|
||||
@@ -340,10 +294,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
|
||||
@@ -486,12 +441,8 @@ that loses the team's row, or changes its seat counts or status, fails on the po
|
||||
`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.
|
||||
port-and-process half of the row, or that the panes are gone after `down`. Everything else
|
||||
has none.
|
||||
|
||||
Before you start, two honest constraints:
|
||||
|
||||
@@ -540,7 +491,7 @@ Before you start, two honest constraints:
|
||||
| registry | `host` | add/list/select/rename isolated entries that resolve to fixture endpoints. Pairing needs two container daemons. |
|
||||
| coordination | `parked` | An owed item with a stopped worker is reported. A held worker with a live wake is distinguished. |
|
||||
| coordination | `stream` | emit/list/show/archive an exact item. A restart and a bounded watch keep the order with no duplicates. |
|
||||
| coordination | `queue` | (partial) handoff → successor owned by `--to` and source asserted closed (`handed-off`), block → parked blocked, resolve → unpark to in-progress (owner nudge suppressed), unclaim → pending with destination kept — each with exact items and destinations asserted via `expect queue` (`rig queue list --json --full --all`) in `queue-transitions-reach-exact-states.yaml`; baton restart coverage stays in `queue-baton-survives-restart.yaml`. |
|
||||
| coordination | `queue` | (partial) Next: handoff, block, resolve and unclaim, each with exact items and destinations. |
|
||||
| coordination | `project` | Acquire a lease, classify one item twice, read one projection. The wrong holder cannot act. |
|
||||
| coordination | `view` | Register a view over known items. list/show return the exact filtered result after a mutation. |
|
||||
| coordination | `watchdog` | Register one short wake, see one response, stop it, prove no further deliveries. |
|
||||
@@ -614,6 +565,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -181,11 +181,7 @@ UI event consumption is centralized through one shared `/api/events`
|
||||
connection (`lib/topology-events.ts`) so related surfaces avoid duplicate
|
||||
SSE connections. Current consumers: For You feed hydration, topology
|
||||
activity rings + hot-potato movement, the activity feed/system surfaces,
|
||||
and rig event surfaces. That connection carries stored events only:
|
||||
`view.changed` is delivered on the live view streams (`/api/views/sse`) and
|
||||
is not stored, so it no longer appears in the activity feed, as a For You
|
||||
observation card or in the telemetry export (entries stored by older
|
||||
versions still replay until an events retention, a separate change, removes them). `useGlobalEvents` reports whether that connection
|
||||
and rig event surfaces. `useGlobalEvents` reports whether that connection
|
||||
is up and refetches review, slice and mission data on reconnect and on
|
||||
`proof.*` events. Reduced-motion preference is honoured for pulse/packet
|
||||
animation. (Daemon SSE surfaces: `/api/events`, `/api/stream/watch`,
|
||||
|
||||
@@ -55,17 +55,8 @@ transitions before trying again. Do not silently send the task a second time.
|
||||
|
||||
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.
|
||||
returns an echo artifact. Its README explains startup; it 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.
|
||||
@@ -137,8 +128,7 @@ async def main():
|
||||
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:
|
||||
Run it from your seat, replacing the row and peer URL:
|
||||
|
||||
```sh
|
||||
export A2A_URL=http://127.0.0.1:9999
|
||||
@@ -270,7 +260,7 @@ export A2A_SEAT=<worker-session-from-rig-ps>
|
||||
# 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".'
|
||||
export A2A_TEXT='Return the words hello from the receiving seat.'
|
||||
.venv/bin/python a2a_client.py
|
||||
```
|
||||
|
||||
@@ -287,8 +277,6 @@ rig queue update "$ROW" --state done --closure-reason no-follow-on --resolution
|
||||
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
|
||||
|
||||
@@ -13,8 +13,7 @@ The row's notes get, in order:
|
||||
- **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 and the tokens and cost it reported, each labelled with where it came from (unknown when it reported
|
||||
none);
|
||||
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
|
||||
@@ -63,78 +62,6 @@ shared one running; stop your own with its own command.
|
||||
- `failed`: the agent returned an error. The row keeps its original code, message and data.
|
||||
- `cancelled`, `stopped`: as above.
|
||||
|
||||
## Claude Code and Codex, through their ACP adapters
|
||||
|
||||
Both have maintained ACP adapters. What follows was checked on 2026-11-06 with OpenRig 0.7.8: one small file task per
|
||||
case, with `claude-haiku-5-5` and with `gpt-6.1-sol` at low effort. Other models weren't checked.
|
||||
|
||||
Install the adapters once; running them through `npx` adds about 10 seconds a turn:
|
||||
|
||||
```sh
|
||||
npm install --prefix <dir> @agentclientprotocol/[email protected] @agentclientprotocol/[email protected]
|
||||
export PATH="<dir>/node_modules/.bin:$PATH"
|
||||
|
||||
rig acp run <row> --cwd <work folder> -- env CLAUDE_CODE_EXECUTABLE=<your claude> ANTHROPIC_MODEL=<model> claude-agent-acp
|
||||
rig acp run <row> --cwd <work folder> -- codex-acp
|
||||
```
|
||||
|
||||
**Which versions run.** The binding note on the row names the adapter and its version.
|
||||
- `claude-agent-acp` 0.101.0 runs Claude Code through the Claude Agent SDK 0.3.318. With `CLAUDE_CODE_EXECUTABLE` set,
|
||||
it runs that `claude`, with that `claude`'s own login; the check ran Claude Code 2.1.310 this way. Without it, an npm
|
||||
install runs the Claude binary from the SDK's platform package (Claude Code 2.1.318, by the SDK's package metadata),
|
||||
which wasn't checked. Installing with `--omit=optional` leaves that binary out, and then, without
|
||||
`CLAUDE_CODE_EXECUTABLE`, the adapter refuses to start; it never falls back to a `claude` on PATH. The adapter also
|
||||
loads your Claude configuration, skills included.
|
||||
- `codex-acp` 2.5.0 depends on `@openai/codex` `^0.165.0`, and 0.165.0 was the only release in that range when this
|
||||
was checked. A later install can resolve a newer 0.165 release; `npm ls @openai/codex` in `<dir>` shows yours. It uses
|
||||
the machine's existing Codex login.
|
||||
- `@zed-industries/claude-code-acp` and `@zed-industries/codex-acp` are deprecated on npm in favour of these. The zed
|
||||
`codex-acp` 0.16.0's Codex was too old for the model in the check.
|
||||
|
||||
**Permissions.** Each agent keeps its own permission mode, and OpenRig adds no restriction. In the check, neither
|
||||
asked anything for file writes or shell commands: Claude started in `auto` mode and Codex in `workspace-write`. To
|
||||
have the agent ask, so that each request becomes a permission row:
|
||||
- **Claude:** give it a dedicated `CLAUDE_CONFIG_DIR` whose settings set `permissions.defaultMode` to `"default"`.
|
||||
- **Codex:** set `INITIAL_AGENT_MODE=read-only` in its environment. Approval and sandbox keys in `CODEX_CONFIG` didn't
|
||||
make it ask.
|
||||
|
||||
These are the settings the check used to make the agents ask. Whether to use them is the caller's choice; they're not a
|
||||
recommended default. Send permission rows to a seat that can be woken: a row to a `terminal` seat sets off the stuck
|
||||
sweep.
|
||||
|
||||
**Answers differ between the two.**
|
||||
- **Claude** offers `allow-once`, `allow-with-updates` (allow always) and `reject`. `reject` refuses that one action,
|
||||
and the turn carries on.
|
||||
- **Codex** offers `allow_once`, `accept_execpolicy_amendment` (allow always) and `cancel`. Its only refusal, `cancel`,
|
||||
ends the whole turn, and the outcome is `cancelled`.
|
||||
|
||||
**Continuing, and a session that doesn't exist.**
|
||||
- **Continuing:** `--load-session` continued an earlier session on a second row with both adapters.
|
||||
- **A made-up session id:** both refused it and started nothing, but they report it differently. Claude returns
|
||||
`-32002 Resource not found`, and Codex returns `-32603 Internal error` with "no rollout found" in its data. The row
|
||||
shows `failed` with the agent's own error. Neither code by itself means a missing session, so read the message.
|
||||
|
||||
**A completed turn isn't finished work.** In the check, Claude was asked to run a command and wait for it. It moved the
|
||||
command into a background task, said it would report when it finished, and ended the turn `completed`. The task died
|
||||
with the adapter, and its file never appeared. Check the work itself before you close the row. Beyond this one case,
|
||||
long and background work weren't checked.
|
||||
|
||||
**Cancel.** `rig acp cancel` was acknowledged in 20-30 ms for both, sent while the agent waited on a permission
|
||||
answer; a cancel mid-generation or mid-tool wasn't timed. Straight after, Claude's run reported `incomplete`
|
||||
with one process still running, which was gone a few seconds later; Codex's reported `settled`. An acknowledgement
|
||||
isn't a finished shutdown, and it says nothing about further spend.
|
||||
|
||||
**Usage and cost.** Claude's adapter reports token usage and a cost in USD; Codex's reports tokens only. Both are in
|
||||
the run's log file, not on the row.
|
||||
|
||||
**Not checked:**
|
||||
- long or multi-tool tasks, and background work;
|
||||
- concurrent runs;
|
||||
- `rig acp stop` with a real stop command;
|
||||
- an adapter that crashes mid-turn;
|
||||
- continuing after a daemon restart;
|
||||
- MCP servers.
|
||||
|
||||
## Notes for specific agents
|
||||
|
||||
- **OpenClaw:** `openclaw acp` is a bridge to a running OpenClaw Gateway, so start the Gateway first, then run, for
|
||||
@@ -143,109 +70,8 @@ the run's log file, not on the row.
|
||||
- **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.
|
||||
|
||||
## Running it in the background: `rig exec`
|
||||
|
||||
`rig exec` runs the same single turn on a named harness, `claude` or `codex`, through that harness's ACP adapter, in a
|
||||
connector that outlives the command:
|
||||
|
||||
```sh
|
||||
rig exec harnesses # the adapter and native versions installed here, and how each differs
|
||||
rig exec run <row> --harness claude --plan # what would run, with versions; starts nothing
|
||||
rig exec run <row> --harness claude [--wait] # start it; --wait waits here for the outcome
|
||||
rig exec status <runId|row> # state, outcome, usage and cost, and what still runs
|
||||
rig exec cancel <runId>
|
||||
```
|
||||
|
||||
- **The connector owns the run.** Closing the terminal, or leaving `--wait` with Ctrl-C, doesn't stop it. Its record
|
||||
in `$OPENRIG_HOME/acp/runs/` holds its process and its state: `requested`, `started`, `finished` or `interrupted`.
|
||||
The row gets the same notes as with `rig acp run`.
|
||||
- **One run per row.** Running it again returns the row's run, still going or ended, instead of sending the task
|
||||
twice. `--again` starts another turn once that run has ended.
|
||||
- **Interrupted.** A run whose connector died without recording an outcome reads `interrupted`, outcome unknown, with
|
||||
one note on the row, the next time `status`, `run` or `--wait` looks at it. Whether any of the agent's processes
|
||||
remain is said there too.
|
||||
- **Missing.** With `--load-session`, a session reads `missing` only on the adapter's own evidence: for Claude,
|
||||
`-32002` naming that session; for Codex, its "no rollout found for thread id" error for that session. Any other error
|
||||
reads `failed`, with the agent's code and message, and no new session is started either way.
|
||||
- **The outcome is the agent's report.** `finished` with `completed` means the turn ended; accepting the work stays
|
||||
with you, on the row. `--wait` exits 0 for `completed`, 2 for any other outcome, and 3 when you stop waiting.
|
||||
Without `--wait`, a run that has already ended exits the same way, and one still going exits 0. Either way the code
|
||||
follows the row's run, including one another caller started.
|
||||
- **Permissions:** the agent keeps its own mode, and `rig exec` adds no policy; your environment passes through.
|
||||
`rig exec harnesses` lists what each harness reads.
|
||||
|
||||
`rig acp run` is unchanged: it stays in your terminal for the length of the turn.
|
||||
|
||||
## 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; it records only what the agent reports.
|
||||
|
||||
## OpenRig as an agent in ACP apps
|
||||
|
||||
The other direction: `rig acp serve`. An app that speaks ACP and can launch a custom agent command can offer your OpenRig team as its agent. The app starts
|
||||
`rig acp serve` and talks to it on stdin and stdout:
|
||||
|
||||
```sh
|
||||
rig acp serve --to <member@rig>
|
||||
```
|
||||
|
||||
Without `--to`, the receiver is this machine's `acp.to` setting: `rig config set acp.to <member@rig>`, set once. An
|
||||
app that starts the agent from the ACP registry passes no arguments, so that's how it reaches your team.
|
||||
|
||||
- **A prompt** becomes a queue row from you to `--to`, the seat that takes your team's new work. The row's body is your
|
||||
prompt, plus a line saying which app sent it and that its closing resolution is your answer.
|
||||
- **Progress:** each row the work reaches shows in the app as a step (`Sent to …`, then `Handed to …` at each handoff),
|
||||
with the row's notes as they're written, each with the seat that wrote it. The step's title also shows its latest
|
||||
note, for apps that show steps collapsed.
|
||||
- **The reply** is the closing resolution, with who closed the row and how (`done`, `done (canceled)`, `denied`, …). The
|
||||
turn ending says only that the turn ended: read the reply for how the work ended.
|
||||
- **A follow-up** in the same session goes to `--to` as a new row whose body names the earlier rows and their states,
|
||||
and the app first shows any earlier row that's still open.
|
||||
- **A question the team asks you** while a turn is open comes to the app with the ask's own choices, when its row is
|
||||
tagged for that chat (`acp-session:<id>`; the task's row tells the team how). An app that takes forms (it
|
||||
advertises ACP elicitation's form mode, as T3 Code does) gets a one-choice form with each choice by its label; any
|
||||
other app gets a permission request with one option per choice, as Zed shows them. An untagged
|
||||
question from a seat the work reached is named in the chat with where to answer it, because it could be another
|
||||
chat's. Your choice is recorded as your answer, as a click in Slack would be, and the seat that asked gets it.
|
||||
Dismissing it, or cancelling the turn, records nothing: the question stays open in the TUI and Slack, and no further
|
||||
question opens after a cancel. A question that ends unanswered here (dismissed, cancelled, closed with the work, or
|
||||
one the app couldn't show) is named in the chat as still open, as well as on its card. A question that wants an
|
||||
answer in words is shown with where to answer it.
|
||||
- **Sessions are kept in the queue:** the app can list your earlier sessions (`session/list`) and reopen one
|
||||
(`session/load`). Reopening replays each prompt, its work, the questions the team asked you during it with your
|
||||
answers, and the reply, as they stand now. Nothing is asked again on reopening: a question still open comes up in
|
||||
your next prompt's turn, and a new prompt continues the session.
|
||||
- **Cancel** stops `rig acp serve` waiting. The team's work carries on, and the reply says so.
|
||||
- **Work handed to a seat on another machine** can't be followed from here; the reply says where it went.
|
||||
|
||||
**Who you are:** `--as <address>`, else `OPENRIG_SESSION_NAME`, else this machine's default person: the only
|
||||
registered person, or the one chosen with `rig gateway default <id>` when there are several (the address is
|
||||
`<id>@external`). The app's sign-in step, "OpenRig on this machine", checks
|
||||
that the daemon answers and that you resolve to an address; until then, signing in and new sessions fail with the
|
||||
reason and the fix. The agent talks to the same daemon as `rig` on this computer: the local one, so start it first
|
||||
(`rig daemon start`), unless `OPENRIG_URL` names another.
|
||||
The team's questions reach you only as a registered person: an `<id>@external` address needs
|
||||
`rig gateway human add <id> --display-name "<your name>" --delivery-class <A|B|C|D>` first. Until then, the app says
|
||||
so at your first prompt, and your work is still sent.
|
||||
|
||||
**In Zed**, add the agent to `settings.json`:
|
||||
|
||||
```json
|
||||
"agent_servers": {
|
||||
"OpenRig": { "type": "custom", "command": "rig", "args": ["acp", "serve", "--to", "lead@my-team"] }
|
||||
}
|
||||
```
|
||||
|
||||
Other apps take the same command and arguments wherever they configure a custom ACP agent.
|
||||
|
||||
**Limits:**
|
||||
- One receiver per agent command (or the `acp.to` setting); the app has no team picker.
|
||||
- Questions show only while a turn is open; one asked while no turn is open waits in your usual places (the TUI, Slack).
|
||||
- An app that shows a permission request's options only as fixed buttons, such as Approve and Decline, and doesn't
|
||||
take forms, can't show the choices: answer those questions in the TUI or Slack.
|
||||
- Prompts are text. Links and embedded text files are passed on as written; images and audio aren't accepted.
|
||||
- Progress is read every 2 seconds while a turn is open (`--poll-seconds`).
|
||||
- Sessions are found among the 2,000 newest rows you've sent, and a reopened session's questions among the 2,000 newest
|
||||
sent to you, so a caller with a lot of traffic, such as a busy seat, can't list or fully reopen its oldest sessions.
|
||||
- Spend is whatever the agent's provider charges; OpenRig doesn't measure it.
|
||||
@@ -229,10 +229,9 @@ 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]`. For a Claude seat selecting the vendored `openrig-core`, the daemon loads the plugin
|
||||
itself through `CLAUDE_CODE_PLUGIN_DIRS`, including its skills and hooks, without projecting a second copy of its skills
|
||||
(see `refocus-channel.md`). Other selected plugin skills follow the projection rules below. A seat that didn't select
|
||||
`openrig-core` does not get its hooks through this selection.
|
||||
`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.
|
||||
|
||||
---
|
||||
|
||||
@@ -288,11 +287,7 @@ Each array contains resource IDs. These can be:
|
||||
- **Qualified** (`shared:systematic-debugging`) — resolves against a specific imported spec's resources
|
||||
|
||||
The `uses` categories are: `skills`, `guidance`, `subagents`, `plugins`, `runtime_resources`. A `uses.hooks` key, even an
|
||||
empty list, is refused: hooks ship inside plugins. Any other key selects nothing and gets a validation advisory, shown at
|
||||
launch and by `rig agent validate`, so a typo such as `skils` doesn't go unnoticed. A single value written in place of a
|
||||
category's list (`skills: vault-user`) is read as a one-item list, with an advisory suggesting list syntax, and resolves
|
||||
exactly like that list: when the profile is selected, a resource that isn't available fails that member's resolution with
|
||||
"not found in resource pool".
|
||||
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
|
||||
@@ -375,10 +370,10 @@ version.
|
||||
|
||||
| Category | Fields | Description |
|
||||
|----------|--------|-------------|
|
||||
| `skills` | `id`, `path`, `on_restore`* | Skill directories containing a SKILL.md (see "Skill Resources"). 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 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. |
|
||||
| `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. Claude Code and Codex do not read skills from that projected plugin folder, so selected plugin 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. The exception is the vendored `openrig-core` selected by a Claude seat: Claude loads that plugin directly, skills included, so its skills are not projected a second time. 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 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. |
|
||||
| `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.
|
||||
@@ -424,16 +419,6 @@ Where a fragment's table is one the user already declares, the user's table
|
||||
wins: the managed table is dropped, their values are never merged, rewritten or
|
||||
overwritten, and the rest of the fragment still applies.
|
||||
|
||||
**A fragment can't install a named Codex profile.** Codex versions with named
|
||||
profiles read each one from its own file, `<CODEX_HOME>/<name>.config.toml`,
|
||||
with top-level keys, and won't load a profile while `config.toml` still holds a
|
||||
legacy `[profiles.<name>]` table. A fragment only adds tables to `config.toml`,
|
||||
so neither form works there. Create the profile file yourself and select it with
|
||||
the member's `codex_config_profile`; see "Codex: select sandbox and approvals
|
||||
together" in [getting-started.md](getting-started.md#codex-select-sandbox-and-approvals-together).
|
||||
Before a launch or restore, OpenRig checks that a selected profile loads, and for
|
||||
a legacy table it names the file to move the settings into.
|
||||
|
||||
### Resource Path Rules
|
||||
|
||||
- All resource paths must be safe relative paths (no `..` traversal, no absolute paths), except a plugin's
|
||||
@@ -442,55 +427,6 @@ a legacy table it names the file to move the settings into.
|
||||
- Resource IDs must be unique within their category
|
||||
- Resource IDs are the identifiers used in `uses` references
|
||||
|
||||
### Skill Resources
|
||||
|
||||
```yaml
|
||||
skills:
|
||||
- id: house-style
|
||||
path: skills/house-style
|
||||
on_restore: follow_source # optional — keep_saved by default
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `id` | string | yes | — | Resource identifier, used in `uses.skills`. |
|
||||
| `path` | string | yes | — | Relative path to the skill's folder, which holds its `SKILL.md`. |
|
||||
| `on_restore` | string | no | `keep_saved` | What a restore (`rig up <rig>`) does with a Claude or Codex seat's copy of this skill in the seat's own storage: `keep_saved` or `follow_source`. |
|
||||
|
||||
An unknown `on_restore` value produces an advisory and uses `keep_saved`; it does not invalidate the spec.
|
||||
|
||||
A restore keeps the skills a seat was launched with. With `keep_saved`, it puts back the saved copy and never takes
|
||||
today's bytes from the source. With `follow_source`, before the seat's session starts, the restore reads the seat's agent
|
||||
spec as it is now, using the saved launch recipe and rig folder, and:
|
||||
|
||||
- replaces the seat's copy with the source folder as it is now, and records it, so `rig down` removes it and the next
|
||||
restore puts it back as usual;
|
||||
- copies in a `follow_source` skill the profile now selects that the seat didn't have;
|
||||
- removes a skill that followed its source and that the profile no longer selects.
|
||||
|
||||
A copy edited in the seat (changed bytes or recorded file permissions, or an added file), or a folder with the same id that OpenRig didn't write, is kept as
|
||||
it is and named in the restore's warnings. If the agent spec can't be read or resolved, the seat keeps its saved skills
|
||||
and the restore says why; a source folder without a `SKILL.md` keeps that skill's saved copy. A skill changed to
|
||||
`keep_saved` stops following at the next restore, and its copy stays. Older seat records without file permissions
|
||||
retain their byte-only ownership checks until a new projection or refresh records permissions.
|
||||
|
||||
Source file symlinks are dereferenced, including links to files outside the skill folder, just as during Claude and
|
||||
Codex launch projection. The seat receives an ordinary file containing the target's bytes. A broken link or a link to
|
||||
a directory makes the source unreadable and keeps the saved copy; links in the seat's destination remain protected.
|
||||
|
||||
Only a restore does this; a running seat isn't changed, and neither is a Codex seat restarted in place. A Claude seat
|
||||
loads its seat plugin when its session starts, so it sees the new copies. A Codex seat with following skills receives
|
||||
its rebuilt catalog after its resumed session is ready. The catalog lists the skills actually present, replaces its
|
||||
earlier OpenRig skill list, and asks it to read current skill files before using them. This includes newly selected
|
||||
skills and the removal of the last following skill. An edited or linked catalog is kept and not sent, with a warning;
|
||||
a delivery failure is reported instead of claiming a complete restore. Neither runtime erases skill instructions
|
||||
already read into the retained conversation.
|
||||
|
||||
Following covers the whole selected skill folder, including scripts and references. It does not install external
|
||||
tools, change MCP configuration, or update bundled plugins. The saved launch recipe supplies the rig directory,
|
||||
member and profile; today's agent spec supplies the skill selection. Today's rig YAML and other agent settings are
|
||||
not applied by this refresh.
|
||||
|
||||
### Guidance Resources
|
||||
|
||||
```yaml
|
||||
@@ -504,7 +440,7 @@ guidance:
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `id` | string | yes | — | Resource identifier. A `rig-role` guidance resource is never merged; deliver a role with a `send_text` startup file. |
|
||||
| `path` | string | yes | — | Relative path to the guidance file. |
|
||||
| `target` | string | no | — | Parsed but not used: the runtime decides the file. Claude merges into the rig's `managed_blocks` file (`CLAUDE.md` by default, or `CLAUDE.local.md`); Codex, Pi and OMP merge into `<cwd>/AGENTS.md`, except that a fresh Pi seat normally uses its own agent folder's `AGENTS.md` and a fresh Codex 0.164.0+ seat normally gets launch developer instructions; the conditions are in [Codex guidance at launch](agent-startup-guide.md#codex-guidance-at-launch) and [Runtime Differences](agent-startup-guide.md#runtime-differences). |
|
||||
| `target` | string | no | — | Parsed but not used: the runtime decides the file. Claude merges into the rig's `managed_blocks` file (`CLAUDE.md` by default, or `CLAUDE.local.md`); Codex, Pi and OMP merge into `<cwd>/AGENTS.md`. |
|
||||
| `merge` | string | no | `managed_block` | Only `managed_block` is applied. Any other value, such as `append`, merges nothing. |
|
||||
|
||||
---
|
||||
@@ -546,7 +482,6 @@ restore behavior, and the distinction between readiness and verified proof.
|
||||
9. All resource paths must be safe relative paths (a plugin's `source.path` excepted).
|
||||
10. Resource IDs must be unique within their category, and each `resources.<category>` must be an array.
|
||||
11. `runtime_resources` entries require `runtime` and `type` fields.
|
||||
11a. An unknown skill `on_restore` value produces an advisory naming the known values and falls back to `keep_saved`.
|
||||
12a. `resources.hooks` and `profiles.<name>.uses.hooks` are refused: "removed in plugin-primitive (Phase 3a)". Declare
|
||||
a plugin instead.
|
||||
12b. A plugin needs a unique non-empty `id`, a `source` object with `kind: local` and a non-empty `path`, and a
|
||||
|
||||
@@ -63,33 +63,6 @@ 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.
|
||||
|
||||
## After a restored seat's prompt
|
||||
|
||||
A restore that stops at a prompt, such as Claude's resume selection, records the
|
||||
seat's restore outcome as `attention_required`. `rig seat continue` doesn't change
|
||||
that record: it only delivers pending startup context. While the record stands, it
|
||||
alone keeps the seat's lifecycle at `attention_required`, even with startup `ready`:
|
||||
`rig ps --nodes` shows LIFECYCLE `att`, `needsAttention` is true, and the seat counts
|
||||
in the rig's `attentionCount` and the TUI's attention totals.
|
||||
|
||||
After a full restore of a Claude or Codex seat, answering the prompt is enough. When
|
||||
the seat's activity next turns to running or idle, or when the whole restore completes
|
||||
after the seat already reads running or idle, the daemon checks the live seat: its tmux
|
||||
session is alive, and the pane runs the agent resuming the conversation the seat
|
||||
records. When the check passes, the restore outcome becomes `operator_recovered`, so
|
||||
the restore no longer makes the seat need attention. When it fails, the restore outcome
|
||||
stays and the seat still needs attention, which is true when the pane runs another
|
||||
conversation. A check that started before a newer restore of the seat doesn't settle
|
||||
the newer outcome.
|
||||
|
||||
`rig seat clear-attention <seat>` runs the same check on demand. When it fails, the
|
||||
command says which check failed, and `--reason` doesn't bypass it. If
|
||||
the seat records the wrong conversation and you know the right one, set it with
|
||||
`rig seat set-resume-token <seat> --token-stdin --reason <text>`, then run
|
||||
`clear-attention` again. After a restore of only some seats, or a community
|
||||
harness's assisted startup, `--reason "<what you saw>"` can acknowledge the
|
||||
outcome instead.
|
||||
|
||||
## Two Categories of Startup
|
||||
|
||||
Everything an agent receives at boot time falls into one of two categories:
|
||||
@@ -241,7 +214,7 @@ After the files, OpenRig sends a session-identity message (rig, pod, member, log
|
||||
| Delivery Hint | When | How | Use For |
|
||||
|---------------|------|-----|---------|
|
||||
| `auto` | Depends on the file | Resolved per file: a `SKILL.md` (or content starting `# SKILL`) is `skill_install`, any other `.md` is `guidance_merge`, and any other file is `send_text` | Default — let OpenRig decide |
|
||||
| `guidance_merge` | Before harness boot | Merged as a managed block into the repository `AGENTS.md` (Codex, OMP), a fresh Pi seat's own agent-directory `AGENTS.md` (see [Runtime Differences](#runtime-differences)), or `CLAUDE.md` (Claude; `CLAUDE.local.md` with the rig's `managed_blocks: { claude-code: CLAUDE.local.md }`). A fresh launch of Codex 0.164.0 or later can carry it as launch developer instructions instead (see [Codex guidance at launch](#codex-guidance-at-launch)) | Role guidance, culture, project context |
|
||||
| `guidance_merge` | Before harness boot | Merged as a managed block into `AGENTS.md` (Codex, Pi) or `CLAUDE.md` (Claude; `CLAUDE.local.md` with the rig's `managed_blocks: { claude-code: CLAUDE.local.md }`) | Role guidance, culture, project context |
|
||||
| `skill_install` | Before harness boot | Copied to runtime's skill directory | Skills |
|
||||
| `send_text` | After harness is ready | Sent as text to agent's terminal via tmux | Boot-time grounding, identity hints, instructions to read skills |
|
||||
|
||||
@@ -271,15 +244,6 @@ interactive"), and the post-launch files aren't sent. A Codex fork that the rig
|
||||
`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.
|
||||
|
||||
**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
|
||||
same prompt still sitting in the input, it presses Enter once more; if it's still there, it warns "Startup prompt still
|
||||
@@ -391,46 +355,10 @@ declaration isn't changed either; update it separately if a future recreation sh
|
||||
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: the guidance file's managed blocks are brought up to date before the harness starts. A missing block is put back, and a stale one is refreshed (see below).
|
||||
|
||||
**A seat handover does the same put-back.** Before a fresh, fork or rebuild successor starts, the handover puts the seat's missing managed guidance blocks back in its guidance file from the saved startup context. That's the rig's Claude file (`CLAUDE.md`, or `CLAUDE.local.md` when the rig uses it) for Claude, and `AGENTS.md` for Codex, Pi and omp. So a seat rebuilt into a new working directory keeps its guidance.
|
||||
|
||||
**A block you edited by hand is never overwritten.** OpenRig records what it last wrote in each managed block, so a launch, an exact resume, a handover or a runtime switch can refresh a block whose source file changed since. The record follows the file itself, so two links to one file share it. A block you've edited since OpenRig wrote it is left as you wrote it. The start's result names it (`guidance_block_edited`), and the seat is told in one line once it's ready, or in a handover's packet. A block OpenRig has no record of writing (any block written before these records were kept) that differs from its source works like this:
|
||||
- a launch replaces it, as launches always did, and names it (`guidance_block_refreshed_unrecorded`);
|
||||
- an exact resume or a handover leaves it, and names it as possibly stale (`guidance_block_may_be_stale`).
|
||||
|
||||
An exact resume doesn't type anything into the resumed conversation. So there, the restore result names the block (`guidanceNotes`), but the seat isn't told.
|
||||
|
||||
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) |
|
||||
| Launch wrapper (`launch_wrapper`) | Member `launch_wrapper` in `rig.yaml` (`pi` and `codex` only; `launch_wrapper: []` clears, omitting retains on existing-seat apply; surfaced as `launchWrapper` in `rig ps --nodes --json` when set; no live `rig seat` edit verb) | The literal wrapper argv that prefixes the resolved Codex entry (preserving TTY stdio) or the resolved Pi child executable inside OpenRig's host runner (preserving RPC pipes and runner lifecycle) when that session launched; a configured prefix is not proof of isolation, tooling, memory or governance | Supported spec create/recreate/apply flow, then the seat's next managed launch, resume, restore or handover (fails closed on missing/unexecutable wrapper, or for Codex non-POSIX pane/missing session identity, retrying long Pi commands via host runner with wrapper flags preserved; never falls back to bare child launch; [details](rig-spec.md#launch-wrapper-runtime-pi-and-codex)) |
|
||||
| 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -470,11 +398,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 |
|
||||
@@ -564,58 +487,6 @@ This separates provider files, not OS users or accounts. Project-local resources
|
||||
still use the selected project directory; shared project and system settings
|
||||
remain shared.
|
||||
|
||||
### Codex guidance at launch
|
||||
|
||||
On a fresh launch of Codex 0.164.0 or later, OpenRig passes a seat's managed guidance (OpenRig's start file and the
|
||||
rig's and project's context, culture and SOP files) with the launch, as `-c developer_instructions=…`, instead of
|
||||
merging it into the repository `AGENTS.md`. Neither the tracked `AGENTS.md` nor the Codex home is written for it, and
|
||||
both still load as usual. The text opens: "OpenRig seat guidance for this session follows. Where it conflicts with
|
||||
AGENTS.md guidance in the repository or the Codex home, follow AGENTS.md."
|
||||
|
||||
A launch value replaces a configured one, so this happens only when no Codex config layer OpenRig reads sets
|
||||
`developer_instructions`. OpenRig reads:
|
||||
|
||||
- `/etc/codex/config.toml` and `/etc/codex/managed_config.toml`;
|
||||
- the Codex home's `config.toml`, profiles included, and the selected profile's `<profile>.config.toml`;
|
||||
- each `.codex/config.toml` from the seat's working directory up.
|
||||
|
||||
The Codex home it reads is the seat's own (a `CODEX_HOME` in its `env:`, or a recorded account switch), else the
|
||||
daemon's `CODEX_HOME`, else `~/.codex`.
|
||||
|
||||
The guidance still goes to the repository `AGENTS.md`, as before, when:
|
||||
|
||||
- a layer OpenRig reads sets `developer_instructions`, or can't be read. A launch warning names the file;
|
||||
- the launch resumes or forks a conversation. Developer instructions on a resume haven't been tested, and an exact
|
||||
resume puts the guidance back in the repository `AGENTS.md`;
|
||||
- the seat is restored, even when the restore starts a new conversation. A restore keeps the guidance file its launch
|
||||
recorded, which for Codex is the repository `AGENTS.md`;
|
||||
- a handover successor starts (fresh, fork or rebuild). Its launch carries no guidance of its own, so the handover
|
||||
first puts the seat's missing blocks back in the repository `AGENTS.md` from its saved startup context;
|
||||
- Codex is older than 0.164.0, or its version is unknown.
|
||||
|
||||
A seat's role still arrives as startup text, and the seat-skills line, after the person's yes, still goes to
|
||||
`AGENTS.md`.
|
||||
|
||||
A launch that carries developer instructions writes no new blocks to the repository `AGENTS.md`. But blocks an earlier
|
||||
handover or resume left there are refreshed from their sources, by the same rule as every other managed block. That
|
||||
matters because the launch text asks Codex to follow `AGENTS.md` where the two differ.
|
||||
|
||||
**If your shell sets `CODEX_HOME`.** When neither the seat nor the daemon declares a `CODEX_HOME`, a seat's pane keeps
|
||||
its own, and OpenRig reads `~/.codex`. A `CODEX_HOME` that only your shell startup sets (in `~/.zshrc`, for example),
|
||||
with the daemon started without it, isn't seen. A `developer_instructions` set in that home is then replaced by the
|
||||
launch value for the session. To have it checked, declare that same absolute path as `CODEX_HOME` in the member's or
|
||||
rig's `env:` (see [Launch environment](rig-spec.md#launch-environment), "A seat's own account"). OpenRig then reads
|
||||
that home at the seat's next fresh launch, and a `developer_instructions` set there keeps the repository `AGENTS.md`.
|
||||
The seat keeps the same home and login.
|
||||
|
||||
**What it doesn't cover.** OpenRig doesn't read Codex's packaged defaults, or MDM- or enterprise-managed settings. A
|
||||
`developer_instructions` set there may be replaced for the session. Legacy MDM-managed config ranks above launch flags,
|
||||
so it can override OpenRig's guidance, and the seat then gets none of it.
|
||||
|
||||
**Not established.** The order Codex assembles developer instructions and `AGENTS.md` in, and how it settles every
|
||||
conflict, aren't established; the opening sentence asks it to follow `AGENTS.md`. The native behaviour was observed on
|
||||
Codex 0.164.0 alone, on fresh launches without tools.
|
||||
|
||||
### Runtime Differences
|
||||
|
||||
**Claude Code:**
|
||||
@@ -646,17 +517,10 @@ Codex 0.164.0 alone, on fresh launches without tools.
|
||||
- Can self-install dependencies from instructions but timer/recurring behavior is not reliably available
|
||||
|
||||
**Pi and OMP:**
|
||||
- Read `AGENTS.md` in the working directory, added to Pi's own system prompt. Pi also reads the one in its agent
|
||||
directory: on a fresh launch, a Pi seat's guidance is merged there, under OpenRig's state, not into the
|
||||
repository's. When that file or its directory is a link, the repository `AGENTS.md` is used instead. A restore keeps
|
||||
the file the seat launched with. OMP keeps its guidance in the repository `AGENTS.md`
|
||||
- Read `AGENTS.md` in the working directory; guidance is merged there, added to Pi's own system prompt
|
||||
- Selected skills are projected into the seat's own agent directory under OpenRig's state, not into a shared
|
||||
workspace folder
|
||||
|
||||
For Pi tool-selection diagnosis and deliberate in-place recovery, see
|
||||
[Pi tool-selection recovery](pi-tool-selection-recovery.md). It distinguishes
|
||||
configured, currently intended, registered and active tools, including the TUI/RPC boundary.
|
||||
|
||||
**Terminal seats** (`builtin:terminal`) get no agent or profile layers and no startup proof.
|
||||
|
||||
### Claude compaction preparation directory
|
||||
@@ -763,29 +627,6 @@ When authoring startup content, note which instructions are runtime-specific. Fo
|
||||
|
||||
---
|
||||
|
||||
## Files OpenRig writes into a project
|
||||
|
||||
These are the files OpenRig writes into a seat's working directory, and what `rig down` does with each. Paths are
|
||||
relative to that directory.
|
||||
|
||||
| Path | When OpenRig writes it | What `rig down` does |
|
||||
| --- | --- | --- |
|
||||
| Managed blocks in `CLAUDE.md` (or the rig's `managed_blocks` file, such as `CLAUDE.local.md`) or `AGENTS.md` | Each launch merges the seat's guidance blocks. A fresh Codex launch that takes developer instructions puts them in its launch text instead: in `AGENTS.md` it adds only the seat-skills line, after your yes, and refreshes blocks already there. A fresh Pi seat writes its own agent-directory `AGENTS.md` outside the project, except when that folder or file is a link: then its guidance stays in the repository `AGENTS.md`. | Removes OpenRig's blocks, current or legacy, and keeps everything else. When nothing else is left, it deletes the file if the file began with an OpenRig block, or held only blocks in an older format. A block-only file isn't proof that OpenRig created it. The seat-skills line is kept if you edited it. |
|
||||
| `.openrig/context-collector.cjs` | Claude seats, at launch. | Removes it while its bytes are still the shipped collector's; otherwise keeps it and says so. |
|
||||
| `.claude/settings.local.json` | Claude seats, at launch: OpenRig's status line and its activity hooks are merged in, separately. Your own status line is never replaced, but the activity hooks are still merged beside it. A file that doesn't parse gets neither. | Removes OpenRig's status line and hooks and keeps the file, even if `{}` is all that's left. |
|
||||
| `.claude/settings.local.json` fragments and `.mcp.json` | When a profile or spec declares `claude_settings_fragment` or `claude_mcp_fragment`. | Leaves the merged content in place. |
|
||||
| `.claude/agents/`, `.claude/plugins/<id>/`, `.claude/extensions/<id>/`; `.agents/<id>.yaml`, `.codex/plugins/<id>/`, `.agents/extensions/<id>/` | When the seat's projection includes subagents, plugins or runtime resources (Claude, then Codex). | Leaves them in place. |
|
||||
| `.claude/skills/<id>/`, `.agents/skills/<id>/` | Only for a seat without seat storage. New Claude and Codex seats keep their skills under `$OPENRIG_HOME/state/seats/`, outside the project. | Leaves them in place. |
|
||||
| The managed compaction preparation folder described in [Claude compaction preparation directory](#claude-compaction-preparation-directory), with its `.gitignore` of `*` and each attempt's `RESTORE-MAP.md` | Managed Claude compaction, unless a preparation root is configured elsewhere. | Leaves it in place. |
|
||||
| `.rigged-checkpoint.md` | A restore that didn't resume a seat's native conversation (by choice or not) and had a checkpoint to hand it. | Leaves it in place. |
|
||||
|
||||
`rig down` leaves a file alone while another seat still uses it: a running seat of the same rig, or any seat of
|
||||
another rig that isn't archived. That is decided file by file. A guidance file is kept while another seat's
|
||||
guidance targets the same file. The Claude settings and collector are kept while another Claude seat works in that
|
||||
directory. So another rig's Codex seat keeps `AGENTS.md` without keeping `CLAUDE.md` or the Claude settings. It
|
||||
removes `.openrig/` and `.claude/` only when they are empty.
|
||||
`rig down --delete` does nothing more to project files. OpenRig doesn't edit your project's own `.gitignore`.
|
||||
|
||||
## Patterns and Anti-Patterns
|
||||
|
||||
### Good Patterns
|
||||
@@ -855,32 +696,6 @@ rig uses it, so it stays until then. To remove it sooner, by hand:
|
||||
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.
|
||||
|
||||
**The kernel's own folder.** A newly created default kernel works in
|
||||
`<workspace.root>/kernel`, beside your projects (`<workspace.root>/projects` by
|
||||
default) rather than above them. Claude reads `CLAUDE.md` from a seat's folder
|
||||
and from every folder above it, so a kernel working in the workspace root put its
|
||||
guidance into every Claude project seat below it. If that folder can't be made,
|
||||
the new kernel works in the workspace root, as before.
|
||||
|
||||
A kernel that already exists keeps its folders, usually `workspace.root`.
|
||||
Upgrading moves no seat, changes no restored conversation and removes no file.
|
||||
To move an existing kernel yourself, if you want to:
|
||||
|
||||
1. Save the new folder for each kernel seat with `rig seat set-cwd <seat> --cwd
|
||||
<workspace.root>/kernel --reason <text>`, using the absolute path. It creates
|
||||
the folder ([details](#change-an-existing-seats-working-directory)).
|
||||
2. Choose when each seat adopts it. A seat keeps its current folder until it
|
||||
starts again, and the new start doesn't continue its current native
|
||||
conversation: `rig seat launch <seat> --fresh --stop --reason <text>` starts a
|
||||
blank one, and `rig handover <seat> --source rebuild --reason <text>` carries
|
||||
durable context into a new one. Nothing does this for you.
|
||||
3. Tidy the old `<workspace.root>/CLAUDE.md` (or `AGENTS.md`) by hand, once no
|
||||
running rig's seat still works in that folder: a seat there still reads those
|
||||
blocks, and `rig down` checks the same before it removes them. Remove only
|
||||
OpenRig's managed blocks, each from its `<!-- BEGIN OpenRig MANAGED BLOCK: … -->`
|
||||
line through its `END` line, as in the steps above, and keep everything else,
|
||||
including your own text. Don't move or delete the whole file.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -23,10 +23,7 @@ Only the activity axis is served today. The session and resumability values are
|
||||
`activity-taxonomy.ts` but no surface reads them: `rig ps --nodes --full` shows the raw session
|
||||
STATUS, both node views show a LIFECYCLE of `running`, `detached`, `recoverable` or `attention_required` (`run`, `det`, `rec`,
|
||||
`att`), and node inventory serves `occupantLifecycle` (`active`, `retiring`, `retired`,
|
||||
`context_walled`, `compacted`, `crashed`, `unknown`). Node inventory's `sessionStatus` is the
|
||||
recorded session status, with two observed corrections: a running session whose pane is verified
|
||||
to have exited (identity reason `pane_exited`) reads `exited`, with `startupStatus:
|
||||
"attention_required"`, and a running session confirmed missing reads `detached`.
|
||||
`context_walled`, `compacted`, `crashed`, `unknown`).
|
||||
|
||||
The axes never blend. Reachability is strict; revival optimism is a different field
|
||||
(omnigent's strict-liveness split). `unknown` is a first-class honest value — the oracle
|
||||
@@ -44,13 +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
|
||||
@@ -62,21 +52,6 @@ the existing live-hook fallback in effect; lifecycle, startup, held and error si
|
||||
can independently raise attention. A false attention flag means no known trigger, not
|
||||
proof that an unobserved input box is clear.
|
||||
|
||||
A handover whose successor started without a required file also raises attention. That's
|
||||
a projected guidance file or a guidance startup file marked `required` still missing from
|
||||
the guidance file, or, for a fresh or rebuild successor, a required `send_text` startup
|
||||
file that's missing. The seat serves `startedWithoutRequired` with each file's path, the
|
||||
table's reason column names each file, and the handover's own output says it's degraded.
|
||||
The mark belongs to that successor's session, so the seat's next launch or handover
|
||||
clears it. Time alone never does. Repairing the file in place doesn't clear it either,
|
||||
because no route delivers guidance into a running conversation.
|
||||
|
||||
A fresh or rebuild successor's first turn opens with the seat's `send_text` startup files,
|
||||
the ones a fresh start delivers, as absolute paths with the role first. A pointer asks the
|
||||
successor to read the file; it isn't the pasted text a start sends. A fork successor
|
||||
carries its parent's conversation and gets no list. `send_text` startup actions, such as
|
||||
an `after_ready` preload, aren't run for a successor.
|
||||
|
||||
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
|
||||
@@ -120,7 +95,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
|
||||
@@ -134,10 +109,7 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
|
||||
PER-HARNESS when that harness's hook rung earns trust through a measured agreement
|
||||
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). For Codex, this fallback also accepts a fresh working status adjacent to
|
||||
the composer, or an advancing counter across two fresh captures when output separates
|
||||
them. This positive evidence expires after five seconds; unrecognized, frozen, old or
|
||||
pre-occupant frames add nothing. It uses the existing pane capture. Undeclared and partial-coverage rungs are identity-only, which is also where
|
||||
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.
|
||||
@@ -160,26 +132,6 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
|
||||
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).
|
||||
|
||||
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
|
||||
@@ -206,13 +158,6 @@ LIFECYCLE); see the
|
||||
- **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.
|
||||
An `attention_required` outcome, or `failed` with a running session, makes the seat's
|
||||
lifecycle `attention_required`, whatever its startup status, so it raises `needsAttention`.
|
||||
After a full restore, the daemon reconciles it to `operator_recovered` when the seat's
|
||||
activity next turns to running or idle (or reads so when the restore completes) and the
|
||||
live seat passes its check, and `rig seat clear-attention <seat>` runs that check on
|
||||
demand; `rig seat continue` doesn't change it
|
||||
([startup guide](agent-startup-guide.md#after-a-restored-seats-prompt)).
|
||||
|
||||
## Rejected oracles — dated receipts (2026-08-26), so nobody re-derives them expensively
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
# Integration author contracts — DRAFT, first published with 0.7.6
|
||||
|
||||
OpenRig composes agents into a team. An integration author supplies the pieces that let a new harness participate, or let an existing harness run in a new environment. This guide divides that work into two contracts:
|
||||
|
||||
1. **[Harness and runner](harness-runner-contract.md):** launch the agent, report its conversation identity and activity, deliver input, handle permission requests and cancellation, and reopen the requested conversation.
|
||||
2. **[Execution and platform](execution-platform-contract.md):** choose placement, deliver files and credential references, distinguish owned from attached resources, preserve native state, return artifacts and reconcile shutdown.
|
||||
|
||||
These are draft author responsibilities. Some use implemented OpenRig interfaces; others describe a proposed integration whose code remains with its author. Including the guide in release documentation does not make every described capability available.
|
||||
|
||||
## Release and support status
|
||||
|
||||
This draft was published with **OpenRig 0.7.6** (tag [`v0.7.6`](https://github.com/mvschwarz/openrig/tree/v0.7.6), source `c00c6321`). Its implemented-interface baseline is commit [`b6bd4633`](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63). That base includes the runner declaration and `passes_model` changes. The published 0.7.6 adds release repairs and documentation on top of that base. **OpenRig 0.7.8** adds seat activity grants, OpenRig's side of the answered permission path and the maintained OpenShell reference driver (`rig environment`); the rows below that say 0.7.8 describe it. References below use the immutable base instead of a moving branch, except the community preset reference, which is the in-tree copy.
|
||||
|
||||
| Area | Status | What the status means |
|
||||
|---|---|---|
|
||||
| Community launcher presets, RigSpec members and AgentSpec role/startup declarations | Implemented interfaces | They compose configuration. A launcher must implement its declared effects; successful parsing does not prove delivery or native behavior. |
|
||||
| `runner: true` with `launch_result: true` | Released in 0.7.6 | The declaration is retained at launch, and Core uses the runner identity/activity path. It does not supply a runner executable. |
|
||||
| `passes_model: true` | Released in 0.7.6 | Passes the declared model through `OPENRIG_MODEL`. It does not observe or verify the effective native model. Cursor and Hermes examples leave it off pending native qualification. |
|
||||
| The dsh/ACP input, cancellation and answered permission recipe (a person answers, and the default is permit) | OpenRig's side released in 0.7.8; runner side shown on two runners, not qualified in general | OpenRig asks a person for a runner's permission requests and returns the answer. In 0.7.8's release testing on a Claude Haiku model, dsh-seat 0.3.0 (dsh 0.2.1-rc.2 over ACP) showed input, activity, cancel, exact resume and shutdown, and the dsh example runner showed a person's allow-once and reject-once; see the [runner contract](harness-runner-contract.md)'s status for what wasn't shown. Native reopen capabilities and the tools-off case remain implementer questions. |
|
||||
| Exact native resume via an author launcher | Existing interface; integration-specific evidence | A requested conversation must reopen exactly or fail honestly. One provider/harness result does not establish portability to another. |
|
||||
| Environment preparation, projection, artifact export and owned-resource cleanup | OpenShell reference driver released in 0.7.8; generic seam proposed | The platform chapter makes the responsibilities reusable. Core ships one maintained reference driver, OpenShell 0.1.6, as `rig environment`. It doesn't provide a generic platform driver or `DeploySpec` loader. |
|
||||
| `HarnessSpec`, `DeploySpec`, generic lifecycle fields and wake-on-demand declarations | Proposed vocabulary or unsupported configuration | They are not new accepted YAML keys. Use existing composition and an explicit author implementation. |
|
||||
| Cross-host native resume, provider-wide cancellation or permission support | Unresolved unless qualified by an integration | Unsupported and untested are distinct from observed failure. Publish the actual boundary and version. |
|
||||
|
||||
## How the pieces compose
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
R["RigSpec: members, relationships, work arrangement"] --> A["AgentSpec: role, context and startup declarations"]
|
||||
R --> H["Built-in runtime or community preset"]
|
||||
H --> N["Harness / runner: native conversation and control"]
|
||||
H --> P["Author platform integration: placement and resources"]
|
||||
P --> N
|
||||
P --> F["Workspace, retained state and returned artifacts"]
|
||||
R --> S["Optional application services"]
|
||||
```
|
||||
|
||||
**RigSpec remains the composer.** AgentSpec describes reusable agent intent; the runtime or preset selects how the native agent starts. An integration can surround that launch with an environment-specific wrapper. A bundled application's database or Compose service is software the team operates; it is distinct from the environment in which an agent executes.
|
||||
|
||||
The terminal provider, native harness protocol and network connection also have different jobs. A terminal provider carries input and output; a runner understands native turn boundaries; a granted MCP or other connection exposes coordination operations. An HTTPS connection does not by itself provision compute, awaken a suspended application or establish native resume. Each integration states which of those effects it implements.
|
||||
|
||||
| Owner | Responsibility |
|
||||
|---|---|
|
||||
| OpenRig Core | Team and work identity, queue records, existing launcher contracts, activity interpretation and declared configuration. |
|
||||
| Harness/runner author | Native launch and protocol, exact native reference, prompt framing, truthful activity, permission/cancel behavior and native resume. |
|
||||
| Platform integration author | Placement binding, destination projection, resource ownership, state transfer, artifact delivery and resource reconciliation. |
|
||||
| Operator / credential owner | Selected environments, grants, provider credentials and spending authority. |
|
||||
|
||||
The queue records who owns work. It does not prove that an old provider process has stopped, that a new runtime consumed its history, or that guest bytes reached the consumer. Those observations belong in the launch, resume and export receipts alongside the work record. This division reuses the queue rather than creating another task system.
|
||||
|
||||
## Start from an existing example
|
||||
|
||||
For a terminal harness, begin with the [community preset reference](community-harness-presets.md) and the pinned [Cursor](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/cursor) or [Hermes](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/hermes) directory. Their presence is source guidance, not an effective-model or complete integration guarantee.
|
||||
|
||||
For a structured runner, the [runner chapter](harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch) supplies a minimal preset, launch record and activity message. The executable named by the preset must still be implemented and installed by its author.
|
||||
|
||||
For a contained worker, the [platform composition](execution-platform-contract.md#a-minimal-composition-using-existing-surfaces) pairs an ordinary coordinator with a worker preset. It uses existing member and preset fields. The wrapper must prepare or attach to the selected environment explicitly; declaring a runtime does not provision it. Its illustrated member list is a fragment, and its launcher is a skeleton, not a ready-to-run provider package.
|
||||
|
||||
A rig bundle can carry declarations, roles and supporting scripts. Do not confuse those files with hydrated image layers, installed binaries, registered presets, credentials or live sessions. State what the recipient must supply.
|
||||
|
||||
## Lifetime and recovery
|
||||
|
||||
**Persistent** and **Non-persistent** describe execution lifetime. A non-persistent invocation has an explicit completion condition tied to its assigned work. **Durable** describes interruption recovery and is independent of that lifetime. An agent can run briefly and still resume its retained conversation later.
|
||||
|
||||
Retaining a workspace and reconstruction material can restore useful competence without restoring the same native conversation. Label that as reconstruction. Native resume additionally requires the correct harness-qualified reference and supported native state. The platform chapter's measured example restored copied history into recreated containers on the same host; it does not establish new-host migration or RAM restoration.
|
||||
|
||||
## Questions contributors can help resolve
|
||||
|
||||
- At a pinned dsh version, which advertised method reopens a session, and can a useful first case run without permission requests?
|
||||
- Which additional harness would justify a shared protocol runner rather than a separate wrapper?
|
||||
- Which provider effects can use the existing projection, native-reference and lifecycle dispatch, and which need a new maintained interface?
|
||||
- What exact state and ownership must travel when compute is recreated or moved?
|
||||
|
||||
These questions remain part of the draft. An unanswered contributor thread does not prevent authors from using the implemented interfaces or reviewing the proposed responsibilities. Compatibility claims should name versions, observed behavior and unsupported operations; they should not imply a support guarantee from a manifest alone.
|
||||
@@ -8,15 +8,4 @@ Read `GET /api/seat/stop-precondition/:seatRef` immediately before an operator-c
|
||||
|
||||
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`. 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.
|
||||
|
||||
A `selection_changed` or `stop_uncertain` response from a checked stop says how far the stop got in `stopPhase`. A refusal before the stop starts carries neither `stopPhase` nor `partialEffect`, and nothing was sent. The stop detaches the seat's clients, checks the selection again, then kills the session:
|
||||
|
||||
| `stopPhase` | What was sent to the seat |
|
||||
| --- | --- |
|
||||
| `none` | Nothing. |
|
||||
| `detach_attempted` | The client detach, which may not have finished. No kill. |
|
||||
| `detached` | The client detach. No kill. |
|
||||
| `kill_attempted` | The kill. |
|
||||
|
||||
`partialEffect` is `true` whenever `stopPhase` isn't `none`, so on its own it doesn't say whether the kill went out. `selection_changed` with `stopPhase: "kill_attempted"` is a change seen after the kill, when the stop state is saved: the session was killed, so re-read the seat rather than offering the stop again.
|
||||
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.
|
||||
@@ -67,45 +67,3 @@ 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.
|
||||
@@ -4,11 +4,10 @@ name: cursor
|
||||
command: openrig-cursor-launch
|
||||
maintainer: Your name or team
|
||||
limits:
|
||||
- startup assisted; check the pane and submit any staged identity text
|
||||
- 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
|
||||
passes_model: false # the launcher maps OPENRIG_MODEL for a new session; set true once checked with the real harness
|
||||
startup_assisted: true
|
||||
@@ -34,19 +34,6 @@ chat_store() {
|
||||
printf '%s/chats/%s/%s/store.db' "$CURSOR_CONFIG_DIR" "$sum" "$1"
|
||||
}
|
||||
|
||||
# The seat's declared model (OPENRIG_MODEL, when the preset sets passes_model) goes to a new chat only. A resumed chat isn't
|
||||
# passed one: whether Cursor's --resume keeps the chat's earlier model or takes a new one hasn't been checked.
|
||||
if [ -n "${OPENRIG_MODEL:-}" ] && [ -z "${OPENRIG_RESUME_REF:-}" ]; then
|
||||
set -- --model "$OPENRIG_MODEL" "$@"
|
||||
fi
|
||||
|
||||
# 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; }
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
# An example runner for DeepSeek Harness (dsh)
|
||||
|
||||
`openrig-dsh-runner.mjs` is a small runner for the draft [harness and runner contract](../../harness-runner-contract.md).
|
||||
It runs in the seat's pane, hosts `dsh --profile acp` over ACP on stdio, and does the runner's part of each contract
|
||||
section:
|
||||
- **identity:** the launch record, written once the conversation is open;
|
||||
- **one prompt per block:** each submitted block becomes one `session/prompt`, using bracketed paste in a TTY;
|
||||
- **turn boundaries:** reported as activity hooks, with a working beat every 10 s through a long, quiet turn;
|
||||
- **cancel:** `/abort` or Ctrl-C sends `session/cancel`, and a turn reads cancelled only when dsh says so. Type `/abort`
|
||||
in the seat's pane: one sent with `rig send` arrives inside a From/To envelope, so it's an ordinary prompt;
|
||||
- **permissions:** each request goes to a person through OpenRig;
|
||||
- **resume:** it reopens the exact conversation, or says honestly that it couldn't.
|
||||
|
||||
It has no dependencies, and it isn't installed by OpenRig. Copy it with [`dsh.yaml`](dsh.yaml) and maintain both as your
|
||||
own. [bpamiri/dsh-seat](https://github.com/bpamiri/dsh-seat) is a fuller dsh runner by its own maintainer.
|
||||
|
||||
## Set it up
|
||||
|
||||
1. **Install dsh and Node.** Install Node 22 or later, then `npm i -g @deepseek-ai/[email protected]`. This is the version
|
||||
it was written for, and it has been checked only against a stand-in agent.
|
||||
2. **Install the runner and preset.** Make the runner executable and put it on the seat's `PATH`. Copy `dsh.yaml` to
|
||||
`$OPENRIG_HOME/community-harnesses/`. Then declare a member with `runtime: dsh`.
|
||||
3. **Give the seat credentials.** Each seat gets its own dsh home, at `$OPENRIG_HOME/state/dsh/<node>/home`, because dsh
|
||||
rewrites its profile there on every start. Put a `.credentials.yaml` there, or put a provider key in the seat's
|
||||
`env:`. To use a home you prepared yourself, set `DSH_SEAT_HOME` in the member's `env:`; `DSH_SEAT_BIN` there names
|
||||
another `dsh` binary. The runner doesn't take dsh's own `DSH_HOME` from its environment, because your shell's startup
|
||||
files run after the seat's `env:` and could set it. A member's `env:` can't set `OPENRIG_*` names, which OpenRig
|
||||
reserves, but the runner still reads `OPENRIG_DSH_HOME` and `OPENRIG_DSH` when something exports them to it, such as
|
||||
a preset `command` wrapper.
|
||||
|
||||
The runner's own defaults for dsh:
|
||||
- **Uploads:** the seat home gets a `cordis.patch.yml` that turns off dsh's session-log and plugin-list uploads to
|
||||
DeepSeek, and the runner starts dsh with `DSH_TELEMETRY_DISABLED=1`.
|
||||
- **Permission mode:** `DSH_PERMISSION_MODE` is `workspace-write` unless you set it.
|
||||
- **The hook token:** OpenRig's activity token isn't passed to dsh, whose shell tool inherits the runner's environment.
|
||||
- **Its version:** the launch record names dsh's version from `dsh --version`. If that fails or takes over 5 seconds, it
|
||||
names the version dsh's ACP handshake reports, marked as such: dsh 0.2.1-rc.2 reports 0.0.1 there.
|
||||
|
||||
## Permissions
|
||||
|
||||
The runner submits each request to OpenRig, which asks one person:
|
||||
- the default person;
|
||||
- or the person chosen for the seat with `rig seat set-permissions-to <seat> --to <person> --reason "…"`.
|
||||
|
||||
That person answers one of the options dsh offered, and the runner gives that answer to the same live request, once.
|
||||
|
||||
**When the person answers in their own words** rather than with a choice, a yes or a no, OpenRig permits by default and
|
||||
passes their words back as `said`. The runner gives dsh its option as usual, shows the words in the pane, and queues
|
||||
them like any other message, so dsh gets them after the current turn and after any message already waiting. The words
|
||||
are never the answer.
|
||||
|
||||
**When no one can be asked, the request is permitted** with dsh's own allow option: `allow_once` when dsh offers it,
|
||||
else `allow_always`. That's OpenRig's default. The runner does the same when OpenRig can't answer: an older OpenRig
|
||||
without this path, an OpenRig it can't reach, or a refusal other than `--deny`.
|
||||
|
||||
**`allow_always` creates a standing grant in dsh.** OpenRig doesn't record or revoke it, which is why the runner prefers
|
||||
`allow_once`. Where dsh keeps the grant, and for how long, isn't checked.
|
||||
|
||||
**The runner refuses** only when the seat's operator opted in to refusal, with `rig seat set-permissions-to <seat>
|
||||
--deny`, or when dsh offered no allow option. A refused request is shown in the pane, and the seat reads needs input
|
||||
until the next prompt.
|
||||
|
||||
**The runner answers cancelled, holding nothing,** when the person says no and dsh offered no reject option, or when
|
||||
OpenRig says the request isn't the seat's current occupant's (for example, after a relaunch).
|
||||
|
||||
## Before you declare resume
|
||||
|
||||
The preset leaves `resume` out, so a restore stops and asks, and a fresh launch starts a new conversation.
|
||||
`rig seat restart --keep-conversation` is for Codex seats only. The runner reopens a conversation by the method dsh
|
||||
advertises: `session/resume`, or `session/load`. When dsh advertises neither, it tries `session/resume`. It never starts
|
||||
a new conversation on a resume.
|
||||
|
||||
**Telling a missing conversation from one that won't reopen.** dsh `0.2.1-rc.2` answers `-32602` both for a
|
||||
conversation it doesn't have and for one already active elsewhere, so the runner never reads that error as missing.
|
||||
- **Only dsh's own not-found error (`-32002`) counts as missing.** Any other failure leaves the resume unconfirmed.
|
||||
- **When dsh advertises `session/list`** (it does), the runner first asks the list, following its pages. A conversation
|
||||
the list names but that won't reopen stays unconfirmed, even on a not-found.
|
||||
- **An unlisted conversation is still reopened.** dsh's list can leave out a conversation another running dsh holds
|
||||
unsaved, so it can't show a conversation is gone.
|
||||
|
||||
**Limit:** dsh answers `-32602`, not `-32002`, for a conversation it doesn't have, so the runner can't report a missing
|
||||
dsh conversation as missing. It reads unconfirmed.
|
||||
|
||||
Add `resume: true` after a real dsh has reopened a conversation and recalled what was said in it.
|
||||
|
||||
## Check it without a model
|
||||
|
||||
Point dsh's `DEEPSEEK_BASE_URL` at a local server that speaks DeepSeek's Anthropic-compatible Messages API. Then run the
|
||||
contract's real check:
|
||||
- a seat reads ready only after the handshake;
|
||||
- one multi-line message becomes one prompt;
|
||||
- the seat reads working, then idle;
|
||||
- a refused permission reads needs input;
|
||||
- `/abort` ends a turn as cancelled.
|
||||
|
||||
`packages/daemon/test/community-harness-dsh-example.test.ts` runs the same cases against a stand-in agent.
|
||||
@@ -1,20 +0,0 @@
|
||||
# Install as $OPENRIG_HOME/community-harnesses/dsh.yaml, with openrig-dsh-runner.mjs (executable) on the seat's PATH.
|
||||
format: 1
|
||||
name: dsh
|
||||
command: openrig-dsh-runner.mjs
|
||||
maintainer: Your name or team
|
||||
runner: true
|
||||
launch_result: true
|
||||
first_input: true
|
||||
limits:
|
||||
- written for dsh 0.2.1-rc.2 (npm @deepseek-ai/dsh) over ACP protocol 1, with Node 22 or later; run with a real dsh on two stacks only, dsh's Claude route with a Claude Haiku model in 0.7.8's release testing (with that release's copy of this runner, for an allow-once and a reject-once), and dsh's pi-ai OpenRouter chat-completions profile with the model deepseek/deepseek-v4-flash on OpenRig 0.7.8 (for queue work, activity through a quiet tool call, an allow-once and a reject-once, and cancel); in both, the permission answers came from a test answerer registered as a person, not a live person; dsh's DeepSeek Messages adapter pointed at OpenRouter failed with HTTP 400 in the second, cause not established; no other dsh route or model is checked
|
||||
- each seat gets its own dsh home under $OPENRIG_HOME/state/dsh/<node>/home, holding a patch that turns off dsh's session-log and plugin-list uploads to DeepSeek; give it credentials (.credentials.yaml there) or a provider key in the seat's env; set DSH_SEAT_HOME in the member's env to use a home you prepared, and DSH_SEAT_BIN for another dsh binary (a member's env can't set OPENRIG_* names, and the runner doesn't read dsh's own DSH_HOME)
|
||||
- DSH_PERMISSION_MODE defaults to workspace-write; a permission request goes to the default person or the seat's override (rig seat set-permissions-to), is permitted with dsh's allow_once (else allow_always) when no one can be asked or OpenRig can't answer, and is refused and held as needs input only after --deny
|
||||
- allow_always creates a standing grant in dsh, which OpenRig doesn't record or revoke; the runner prefers allow_once, and where dsh keeps the grant and for how long isn't checked
|
||||
- resume isn't declared; a real dsh has reopened a conversation after down, snapshot and up and recalled it, with resume added to a copy of this preset, but a missing conversation's refusal hasn't been run; the runner reopens by the method dsh advertises and reports missing only on dsh's own not-found error, not when dsh's session list names the conversation; dsh answers -32602 for a missing conversation (and for one active elsewhere), so a missing dsh conversation reads unconfirmed
|
||||
- a prompt whose whole text is /abort can't be sent; a Ctrl-C byte inside a paste also aborts; without a TTY, input is read line by line; this isn't a binary-safe channel
|
||||
- one prompt at a time; a message that arrives mid-turn waits for the next turn
|
||||
- the seat's model isn't passed; dsh uses its own configured model
|
||||
- no guidance, skills or startup files are placed; dsh reads its own AGENTS.md and skill folders
|
||||
- when the pane closes, the runner cancels a running turn and stops dsh; anything dsh's tools started may keep running
|
||||
- fork unsupported
|
||||
@@ -1,626 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Example OpenRig runner for DeepSeek Harness (dsh), not installed by OpenRig. Checked against a stand-in agent only;
|
||||
// written for dsh 0.2.1-rc.2 (npm @deepseek-ai/dsh), ACP protocol 1 and Node 22 or later. No dependencies.
|
||||
//
|
||||
// It follows docs/reference/harness-runner-contract.md: it hosts `dsh --profile acp` over ACP on stdio, writes the
|
||||
// launch record once the conversation is open, turns each submitted block into one prompt, reports turn boundaries as
|
||||
// activity hooks with a working beat through a long, quiet turn, cancels on /abort or Ctrl-C, and sends permission
|
||||
// requests to OpenRig: a person answers, or OpenRig permits when no one can be asked, and so does the runner when OpenRig
|
||||
// can't answer (refusing and holding needs input only when the seat opted in to refusal). Copy it and maintain it as your
|
||||
// own.
|
||||
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const RUNNER = "openrig-dsh-runner 1";
|
||||
const NAME = process.env.OPENRIG_RUNTIME || "dsh"; // the preset's name: OpenRig passes it, and checks the record against it
|
||||
const LIVENESS_SECONDS = 60;
|
||||
const BEAT_MS = 30_000;
|
||||
// The working beat (contract section 4): every 10 s, more often than half its 30 s lifetime.
|
||||
const WORK_LIVENESS_SECONDS = 30;
|
||||
const WORK_BEAT_MS = 10_000;
|
||||
const ACTIVE_EVERY_MS = 15_000;
|
||||
const POST_TIMEOUT_MS = 2_000;
|
||||
const WAIT_SECONDS = 25;
|
||||
const READ_TRIES = 3; // reads in a row that get no answer before the runner permits
|
||||
// Refusals that say the request certainly isn't the seat's current occupant's live one: cancel it, and hold nothing.
|
||||
const NOT_LIVE = ["stale_generation", "activity_grant_wrong_seat", "activity_grant_stale"];
|
||||
// A read's states that end the request, answered cancelled (an `answered` that reaches there names an option dsh didn't
|
||||
// offer). Any other state, like an unlisted status, permits.
|
||||
const ENDED = ["refused", "withdrawn", "stale", "answered"];
|
||||
const CANCELLED = { outcome: { outcome: "cancelled" } };
|
||||
const ACP_RESOURCE_NOT_FOUND = -32002;
|
||||
// The most pages of dsh's session list read before a resume; a longer list decides nothing.
|
||||
const LIST_PAGES = 50;
|
||||
|
||||
const PASTE_START = "\x1b[200~";
|
||||
const PASTE_END = "\x1b[201~";
|
||||
|
||||
/** Turns terminal bytes into submitted blocks. Everything up to an Enter outside a paste is one block, line breaks
|
||||
* included; a block that is exactly /abort, or a Ctrl-C byte, is the only control. */
|
||||
export class InputFramer {
|
||||
#buffer = "";
|
||||
#pasting = false;
|
||||
#partial = "";
|
||||
|
||||
push(chunk) {
|
||||
const events = [];
|
||||
const data = this.#partial + chunk;
|
||||
this.#partial = "";
|
||||
let i = 0;
|
||||
while (i < data.length) {
|
||||
if (data[i] === "\x1b") {
|
||||
const rest = data.slice(i);
|
||||
if (rest.startsWith(PASTE_START) || rest.startsWith(PASTE_END)) {
|
||||
this.#pasting = rest.startsWith(PASTE_START);
|
||||
i += PASTE_START.length;
|
||||
continue;
|
||||
}
|
||||
if (PASTE_START.startsWith(rest) || PASTE_END.startsWith(rest)) { this.#partial = rest; break; }
|
||||
if (!this.#pasting) { i += escapeLength(rest); continue; } // arrow keys and the like
|
||||
}
|
||||
const ch = data[i];
|
||||
i += 1;
|
||||
if (ch === "\x03") { this.#buffer = ""; this.#pasting = false; events.push({ type: "abort" }); continue; }
|
||||
if (this.#pasting) {
|
||||
if (ch === "\r") { if (data[i] === "\n") i += 1; this.#buffer += "\n"; } else this.#buffer += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\r" || ch === "\n") {
|
||||
const text = this.#buffer;
|
||||
this.#buffer = "";
|
||||
if (text.trim() === "/abort") events.push({ type: "abort" });
|
||||
else if (text.trim()) events.push({ type: "block", text });
|
||||
continue;
|
||||
}
|
||||
if (ch === "\x7f" || ch === "\b") {
|
||||
if (this.#buffer) { this.#buffer = this.#buffer.slice(0, -1); events.push({ type: "erase" }); }
|
||||
continue;
|
||||
}
|
||||
if (ch >= " ") { this.#buffer += ch; events.push({ type: "echo", text: ch }); }
|
||||
}
|
||||
return events;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeLength(rest) {
|
||||
if (rest[1] === "[") {
|
||||
let j = 2;
|
||||
while (j < rest.length && !(rest.charCodeAt(j) >= 0x40 && rest.charCodeAt(j) <= 0x7e)) j += 1;
|
||||
return Math.min(j + 1, rest.length);
|
||||
}
|
||||
return rest[1] === "O" ? Math.min(3, rest.length) : 1;
|
||||
}
|
||||
|
||||
/** JSON-RPC 2.0 with the agent, one message per line on its stdio. */
|
||||
class AgentPeer {
|
||||
#child;
|
||||
#next = 1;
|
||||
#waiting = new Map();
|
||||
#requests = new Map();
|
||||
#notifications = new Map();
|
||||
|
||||
constructor(child, onNoise) {
|
||||
this.#child = child;
|
||||
createInterface({ input: child.stdout }).on("line", (line) => {
|
||||
let message;
|
||||
try { message = JSON.parse(line); } catch { message = null; }
|
||||
if (!message || typeof message !== "object") { if (line.trim()) onNoise(line); return; }
|
||||
if (message.method === undefined) {
|
||||
const waiter = this.#waiting.get(message.id);
|
||||
if (!waiter) return;
|
||||
this.#waiting.delete(message.id);
|
||||
if (message.error) waiter.reject(Object.assign(new Error(message.error.message ?? "error"), { code: message.error.code }));
|
||||
else waiter.resolve(message.result);
|
||||
return;
|
||||
}
|
||||
if (message.id === undefined) { this.#notifications.get(message.method)?.(message.params ?? {}); return; }
|
||||
const handler = this.#requests.get(message.method);
|
||||
if (!handler) { this.#write({ id: message.id, error: { code: -32601, message: `Method not found: ${message.method}` } }); return; }
|
||||
Promise.resolve().then(() => handler(message.params ?? {}, message.id)).then(
|
||||
(result) => this.#write({ id: message.id, result }),
|
||||
(error) => this.#write({ id: message.id, error: { code: error.code ?? -32603, message: error.message } }));
|
||||
});
|
||||
}
|
||||
|
||||
request(method, params) {
|
||||
const id = this.#next++;
|
||||
return new Promise((resolve, reject) => { this.#waiting.set(id, { resolve, reject }); this.#write({ id, method, params }); });
|
||||
}
|
||||
|
||||
notify(method, params) { this.#write({ method, params }); }
|
||||
onRequest(method, handler) { this.#requests.set(method, handler); }
|
||||
onNotification(method, handler) { this.#notifications.set(method, handler); }
|
||||
|
||||
failAll(reason) {
|
||||
for (const waiter of this.#waiting.values()) waiter.reject(new Error(reason));
|
||||
this.#waiting.clear();
|
||||
}
|
||||
|
||||
#write(message) {
|
||||
if (this.#child.stdin.writable) this.#child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const openrigHome = () => process.env.OPENRIG_HOME || join(homedir(), ".openrig");
|
||||
|
||||
/** The daemon's base URL and the seat's token: from the environment, else from activity-endpoint.json. */
|
||||
function openrigEndpoint() {
|
||||
const env = process.env;
|
||||
let base = env.OPENRIG_URL || (env.OPENRIG_PORT ? `http://${env.OPENRIG_HOST || "127.0.0.1"}:${env.OPENRIG_PORT}` : "");
|
||||
let token = env.OPENRIG_ACTIVITY_HOOK_TOKEN || "";
|
||||
if (!base || !token) {
|
||||
try {
|
||||
const file = JSON.parse(readFileSync(join(openrigHome(), "activity-endpoint.json"), "utf8"));
|
||||
base ||= file.baseUrl ?? "";
|
||||
token ||= file.token ?? "";
|
||||
} catch { /* no daemon has written one yet */ }
|
||||
}
|
||||
return base && token ? { base: base.replace(/\/+$/, ""), token } : null;
|
||||
}
|
||||
|
||||
async function callOpenRig(method, path, body, timeoutMs) {
|
||||
const endpoint = openrigEndpoint();
|
||||
if (!endpoint) return { status: 0, body: null };
|
||||
try {
|
||||
const response = await fetch(`${endpoint.base}${path}`, {
|
||||
method, signal: AbortSignal.timeout(timeoutMs),
|
||||
headers: { authorization: `Bearer ${endpoint.token}`, ...(body ? { "content-type": "application/json" } : {}) },
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
let parsed = null;
|
||||
try { parsed = await response.json(); } catch { /* not JSON */ }
|
||||
return { status: response.status, body: parsed };
|
||||
} catch {
|
||||
return { status: 0, body: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** Activity posts go one at a time, in order, and never hold up the agent. */
|
||||
class Activity {
|
||||
#sequence = 0;
|
||||
#chain = Promise.resolve();
|
||||
|
||||
constructor(identity) { this.identity = identity; }
|
||||
|
||||
post(hookEvent, subtype, extra = {}) {
|
||||
const body = { ...this.identity, sequence: ++this.#sequence, occurredAt: new Date().toISOString(), hookEvent, subtype, ...extra };
|
||||
this.#chain = this.#chain.then(() => callOpenRig("POST", "/api/activity/hooks", body, POST_TIMEOUT_MS)).then(() => {}, () => {});
|
||||
}
|
||||
|
||||
flushed() { return this.#chain; }
|
||||
}
|
||||
|
||||
const UPLOADS_OFF = [
|
||||
"# Written by openrig-dsh-runner for this seat; safe to edit, and an existing file is left alone.",
|
||||
"# Don't attach this seat's session log, or its plugin package names and versions, to DeepSeek API requests.",
|
||||
"- id: session-log-deepseek",
|
||||
" config:",
|
||||
" enabled: false",
|
||||
"- id: plugin-package-inventory-deepseek",
|
||||
" config:",
|
||||
" enabled: false",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
/** Each seat gets its own writable dsh home (dsh rewrites its profile there on every start). DSH_SEAT_HOME, which a rig
|
||||
* spec's env: can set, or OPENRIG_DSH_HOME names one you prepared yourself, which is used as it is. Not dsh's own
|
||||
* DSH_HOME: a person's shell startup files can export it, and they run after the seat's env:. */
|
||||
function seatHome() {
|
||||
const prepared = process.env.OPENRIG_DSH_HOME || process.env.DSH_SEAT_HOME;
|
||||
if (prepared) return prepared;
|
||||
const seat = process.env.OPENRIG_NODE_ID || process.env.OPENRIG_SESSION_NAME || "default";
|
||||
const home = join(openrigHome(), "state", "dsh", seat, "home");
|
||||
mkdirSync(home, { recursive: true, mode: 0o700 });
|
||||
const patch = join(home, "cordis.patch.yml");
|
||||
if (!existsSync(patch)) writeFileSync(patch, UPLOADS_OFF, { mode: 0o600 });
|
||||
return home;
|
||||
}
|
||||
|
||||
/** dsh's version as its CLI reports it, or null when it can't say. dsh 0.2.1-rc.2's ACP handshake reports 0.0.1.
|
||||
* SIGKILL at the timeout, because a synchronous call waits for the child to exit, and a child can ignore SIGTERM. */
|
||||
function cliVersion(bin, env) {
|
||||
try {
|
||||
const out = execFileSync(bin, ["--version"], { encoding: "utf8", env, timeout: 5_000, killSignal: "SIGKILL", stdio: ["ignore", "pipe", "ignore"] });
|
||||
return out.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?/)?.[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeLaunchRecord(fields) {
|
||||
const path = process.env.OPENRIG_LAUNCH_RESULT;
|
||||
if (!path) return;
|
||||
const record = { format: 1, attempt: process.env.OPENRIG_LAUNCH_ATTEMPT, harness: NAME, ...fields };
|
||||
const temp = `${path}.${process.pid}.tmp`;
|
||||
writeFileSync(temp, `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
||||
renameSync(temp, path);
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
const say = (line) => process.stdout.write(`${line}\n`);
|
||||
const firstLine = (text) => {
|
||||
const lines = text.split("\n");
|
||||
return lines.length > 1 ? `${lines[0]} … (+${lines.length - 1} lines)` : lines[0];
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const env = process.env;
|
||||
const cwd = process.cwd();
|
||||
let firstInput = null;
|
||||
if (env.OPENRIG_FIRST_INPUT) {
|
||||
try { firstInput = readFileSync(env.OPENRIG_FIRST_INPUT, "utf8"); } catch (error) {
|
||||
console.error(`openrig-dsh-runner: the first startup turn couldn't be read (${error.message}); not starting`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
const home = seatHome();
|
||||
const activity = new Activity({ sessionName: env.OPENRIG_SESSION_NAME, nodeId: env.OPENRIG_NODE_ID,
|
||||
runtime: NAME, generation: env.OPENRIG_OCCUPANT_GENERATION });
|
||||
const agentEnv = { ...env, DSH_HOME: home };
|
||||
delete agentEnv.OPENRIG_ACTIVITY_HOOK_TOKEN; // dsh's shell tool inherits this environment
|
||||
agentEnv.DSH_PERMISSION_MODE ??= "workspace-write";
|
||||
if (agentEnv.DSH_TELEMETRY_DISABLED === undefined && agentEnv.DSH_TELEMETRY_MODE === undefined) agentEnv.DSH_TELEMETRY_DISABLED = "1";
|
||||
|
||||
// DSH_SEAT_BIN, which a rig spec's env: can set, or OPENRIG_DSH names another dsh binary.
|
||||
const bin = env.OPENRIG_DSH || env.DSH_SEAT_BIN || "dsh";
|
||||
const version = cliVersion(bin, agentEnv);
|
||||
const child = spawn(bin, ["--profile", "acp"], { cwd, env: agentEnv, stdio: ["pipe", "pipe", "inherit"] });
|
||||
child.stdin.on("error", () => {});
|
||||
const peer = new AgentPeer(child, (line) => say(`[dsh] ${line}`));
|
||||
const state = { sessionId: null, turn: 0, running: false, cancelRequestedAt: null, holding: false, refused: false, queue: [], asks: new Set(), lastActive: 0, asking: 0, waiting: 0, resolvedAsk: null };
|
||||
const inflight = new Set();
|
||||
const track = (promise) => { inflight.add(promise); promise.finally(() => inflight.delete(promise)); return promise; };
|
||||
let agentExited = false;
|
||||
let stopping = false;
|
||||
let heartbeat;
|
||||
let workBeat;
|
||||
let tty = false;
|
||||
// The turn may read working: it runs, nothing holds needs input, no permission request is being submitted or waits for its
|
||||
// permission_resolved, and dsh is alive. The beat and the agent's own reports both go by it.
|
||||
const working = () => state.running && !state.holding && state.asking === 0 && !state.resolvedAsk && !agentExited;
|
||||
|
||||
const restoreTty = () => {
|
||||
if (!tty) return;
|
||||
tty = false;
|
||||
process.stdout.write("\x1b[?2004l");
|
||||
try { process.stdin.setRawMode(false); } catch { /* already closed */ }
|
||||
};
|
||||
const finish = async (code) => {
|
||||
clearInterval(heartbeat);
|
||||
clearInterval(workBeat);
|
||||
restoreTty();
|
||||
await Promise.race([Promise.allSettled([...inflight, activity.flushed()]), sleep(2_000)]);
|
||||
process.exit(code);
|
||||
};
|
||||
const fatal = async (message, code) => {
|
||||
stopping = true;
|
||||
say(`[openrig-dsh] ${message}`);
|
||||
if (!agentExited) {
|
||||
child.kill("SIGTERM");
|
||||
await Promise.race([new Promise((done) => child.once("exit", done)), sleep(3_000)]);
|
||||
}
|
||||
await finish(code);
|
||||
};
|
||||
const postIdle = (subtype) => activity.post("Stop", subtype, { livenessSeconds: LIVENESS_SECONDS });
|
||||
const withdraw = (askId, reason) => track(callOpenRig("POST", `/api/activity/permission-requests/${encodeURIComponent(askId)}/withdraw`, { reason }, POST_TIMEOUT_MS));
|
||||
|
||||
child.once("error", (error) => { agentExited = true; void fatal(`dsh couldn't be started (${error.message}); is it on PATH?`, 1); });
|
||||
child.on("exit", (code, signal) => {
|
||||
agentExited = true;
|
||||
peer.failAll("dsh exited");
|
||||
if (stopping) return;
|
||||
for (const ask of state.asks) ask.end("exited");
|
||||
say(`\n[openrig-dsh] dsh exited (code ${code ?? "none"}, signal ${signal ?? "none"})`);
|
||||
activity.post("Stop", `${NAME}_exited`);
|
||||
void finish(code === 0 ? 0 : 1);
|
||||
});
|
||||
|
||||
peer.onNotification("session/update", (params) => {
|
||||
if (params.sessionId !== state.sessionId || !params.update) return;
|
||||
const update = params.update;
|
||||
const reporting = working();
|
||||
if (update.sessionUpdate === "agent_message_chunk") {
|
||||
if (update.content?.type === "text") process.stdout.write(update.content.text);
|
||||
if (reporting && Date.now() - state.lastActive >= ACTIVE_EVERY_MS) { state.lastActive = Date.now(); activity.post("active", "agent_message"); }
|
||||
} else if (update.sessionUpdate === "tool_call") {
|
||||
say(`\n[tool] ${update.title ?? "tool"} (${update.kind ?? "other"})`);
|
||||
if (reporting) activity.post("PreToolUse", String(update.kind ?? "other"));
|
||||
}
|
||||
});
|
||||
|
||||
const refuse = (why) => {
|
||||
state.holding = true;
|
||||
state.refused = true; // the seat's own refusal holds needs input until the next prompt
|
||||
say(`[permission] refused: ${why}. The seat reads needs input until the next prompt.`);
|
||||
activity.post("Notification", "permission_prompt", { turnId: String(state.turn) });
|
||||
return CANCELLED;
|
||||
};
|
||||
|
||||
// The answered path: OpenRig opens one ask to the default person (or the seat's override, `rig seat
|
||||
// set-permissions-to`), and the runner reads the answer back for this same live request. With no one to ask, OpenRig
|
||||
// permits at once, and so does the runner when OpenRig can't answer. Only the seat's opt-in (`--deny`) refuses; a
|
||||
// request OpenRig says isn't the current occupant's, or that a person said no to with no reject option, is cancelled.
|
||||
const askOwner = async (requestId, toolCall, options) => {
|
||||
const turn = state.turn;
|
||||
const live = () => state.running && state.turn === turn && !state.cancelRequestedAt && !agentExited;
|
||||
const submitted = await callOpenRig("POST", "/api/activity/permission-requests", {
|
||||
sessionName: env.OPENRIG_SESSION_NAME, nodeId: env.OPENRIG_NODE_ID, generation: env.OPENRIG_OCCUPANT_GENERATION,
|
||||
nativeRef: state.sessionId, turnId: String(turn), requestId: String(requestId), toolCall,
|
||||
options: options.map((option) => ({ optionId: option.optionId, name: option.name ?? option.optionId, kind: option.kind })),
|
||||
occurredAt: new Date().toISOString(),
|
||||
}, 10_000);
|
||||
const askId = submitted.body?.ok ? submitted.body.askId : undefined;
|
||||
if (!live()) {
|
||||
if (askId) withdraw(askId, state.cancelRequestedAt ? "turn_cancelled" : agentExited ? "exited" : "turn_ended");
|
||||
return CANCELLED;
|
||||
}
|
||||
const offered = (optionId) => options.some((option) => option.optionId === optionId);
|
||||
// No one can be asked: OpenRig's default is permit, with the agent's own allow option.
|
||||
if (submitted.body?.ok && submitted.body.state === "permitted" && offered(submitted.body.optionId)) {
|
||||
say(`[permission] permitted: ${submitted.body.reason ?? "no one can be asked"} (${submitted.body.optionId})`);
|
||||
return { outcome: { outcome: "selected", optionId: submitted.body.optionId } };
|
||||
}
|
||||
// When OpenRig can't answer, nobody can be asked either, so the same default applies. allow_once is preferred, so
|
||||
// dsh doesn't remember a permission nobody chose.
|
||||
const allow = options.find((option) => option.kind === "allow_once") ?? options.find((option) => option.kind === "allow_always");
|
||||
const permit = (why) => {
|
||||
if (!allow) return refuse(`${why}, and the agent offered no allow option`);
|
||||
state.holding = state.refused || state.waiting > 0; // another ask may still wait on a person
|
||||
say(`[permission] permitted: ${why} (${allow.optionId})`);
|
||||
return { outcome: { outcome: "selected", optionId: allow.optionId } };
|
||||
};
|
||||
// A person who answered in their own words: OpenRig passes them back as `said`. They join the prompt queue like any
|
||||
// message, so dsh gets them after this turn and after any message already waiting. They're never the answer.
|
||||
const passOn = (said) => {
|
||||
if (typeof said !== "string" || !said.trim()) return;
|
||||
say(`[permission] the person also wrote: ${firstLine(said)}`);
|
||||
submit(`The person who answered your permission request (${toolCall.title}) also wrote:\n\n${said}`);
|
||||
};
|
||||
if (!askId) {
|
||||
if (submitted.body?.code === "denied_by_seat") return refuse("the seat's operator chose to refuse its permission requests");
|
||||
if (NOT_LIVE.includes(submitted.body?.code)) {
|
||||
say(`[permission] cancelled: OpenRig says this isn't the seat's current request (${submitted.body.code})`);
|
||||
return CANCELLED;
|
||||
}
|
||||
return permit(submitted.status === 404 ? "this OpenRig has no answered path"
|
||||
: submitted.status === 0 ? "OpenRig couldn't be reached" : `OpenRig couldn't ask anyone (${submitted.body?.code ?? submitted.status})`);
|
||||
}
|
||||
state.holding = true; // OpenRig recorded the seat's permission prompt when it opened the ask
|
||||
state.waiting += 1;
|
||||
say(`[permission] OpenRig asked a person (${askId}); waiting for their answer`);
|
||||
// This ask no longer waits on a person; the seat still needs input while another one does, or after its own refusal.
|
||||
const stopWaiting = () => { state.waiting -= 1; state.holding = state.refused || state.waiting > 0; };
|
||||
// The agent got an answer or a permit for this ask: once nothing else waits, permission_resolved says the turn goes on.
|
||||
const resolved = () => { state.resolvedAsk = { turn, askId }; };
|
||||
return new Promise((resolve) => {
|
||||
let done = false; // the agent has its answer
|
||||
let ended = false; // OpenRig was told the request is over
|
||||
const ask = { end: (reason) => {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
state.asks.delete(ask);
|
||||
withdraw(askId, reason);
|
||||
if (done) return;
|
||||
done = true;
|
||||
stopWaiting(); // the turn ended the person's ask
|
||||
resolve(CANCELLED);
|
||||
} };
|
||||
state.asks.add(ask);
|
||||
void (async () => {
|
||||
let unanswered = 0;
|
||||
while (!done) {
|
||||
const read = await callOpenRig("GET", `/api/activity/permission-requests/${encodeURIComponent(askId)}?waitSeconds=${WAIT_SECONDS}`, null, (WAIT_SECONDS + 10) * 1_000);
|
||||
if (done) return;
|
||||
if (read.status === 0 && ++unanswered < READ_TRIES) { await sleep(2_000); continue; }
|
||||
if (read.status === 200 && read.body?.state === "pending") { unanswered = 0; continue; }
|
||||
done = true;
|
||||
const answer = read.body ?? {};
|
||||
// A person's option, or OpenRig's permit when they closed it without one.
|
||||
if ((answer.state === "answered" || answer.state === "permitted") && offered(answer.optionId) && live()) {
|
||||
state.asks.delete(ask);
|
||||
stopWaiting();
|
||||
say(`[permission] ${answer.state}: ${answer.optionId}`);
|
||||
resolve({ outcome: { outcome: "selected", optionId: answer.optionId } });
|
||||
resolved();
|
||||
passOn(answer.said);
|
||||
} else if (live() && (read.status === 200 ? !ENDED.includes(answer.state) : !NOT_LIVE.includes(answer.code))) {
|
||||
// OpenRig stopped answering, or answered in a way the contract doesn't list: nobody can be asked. The ask stays
|
||||
// tracked, so the turn's end still withdraws it.
|
||||
stopWaiting();
|
||||
resolve(permit(read.status === 0 ? "OpenRig couldn't be reached" : `OpenRig couldn't answer (${answer.code ?? read.status})`));
|
||||
if (!state.refused) resolved(); // permitted, not refused for want of an allow option
|
||||
} else {
|
||||
state.asks.delete(ask);
|
||||
stopWaiting(); // a person said no, or the request is over
|
||||
say(`[permission] not answered (${answer.reason ?? answer.state ?? answer.code ?? read.status}); answered cancelled`);
|
||||
resolve(CANCELLED);
|
||||
passOn(answer.said);
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
};
|
||||
|
||||
peer.onRequest("session/request_permission", (params, requestId) => {
|
||||
if (params.sessionId !== state.sessionId) throw Object.assign(new Error(`no turn of conversation ${params.sessionId} runs here`), { code: -32602 });
|
||||
const toolCall = { title: String(params.toolCall?.title ?? "a tool call"), kind: String(params.toolCall?.kind ?? "other") };
|
||||
const options = Array.isArray(params.options) ? params.options : [];
|
||||
say(`\n[permission] dsh asks: ${toolCall.title} (${toolCall.kind}); options ${options.map((option) => option.optionId).join(", ")}`);
|
||||
if (!state.running || state.cancelRequestedAt) return CANCELLED;
|
||||
state.asking += 1; // no beat while a permission request is open: it may be waiting on a person
|
||||
return askOwner(requestId, toolCall, options).finally(() => { state.asking -= 1; setImmediate(settle); });
|
||||
});
|
||||
|
||||
// Once nothing waits on a person or is being submitted, the seat holds no refusal of its own and dsh is alive, say the turn
|
||||
// goes on. It goes out after the agent has its answer, and before the next beat.
|
||||
const settle = () => {
|
||||
const done = state.resolvedAsk;
|
||||
if (!done || state.holding || state.asking > 0) return;
|
||||
state.resolvedAsk = null;
|
||||
if (state.running && state.turn === done.turn && !agentExited) activity.post("active", "permission_resolved", { turnId: String(done.turn), askId: done.askId });
|
||||
};
|
||||
|
||||
const abort = () => {
|
||||
if (!state.running) { say("[openrig-dsh] no turn is running"); return; }
|
||||
if (state.cancelRequestedAt) return;
|
||||
state.cancelRequestedAt = new Date().toISOString();
|
||||
peer.notify("session/cancel", { sessionId: state.sessionId });
|
||||
say(`\n[openrig-dsh] cancel requested at ${state.cancelRequestedAt}; the turn ends when dsh says so`);
|
||||
if (!state.refused) state.holding = false; // the seat's own refusal holds until the next accepted prompt
|
||||
for (const ask of state.asks) ask.end("turn_cancelled");
|
||||
};
|
||||
|
||||
const runTurn = async (text) => {
|
||||
state.running = true;
|
||||
state.turn += 1;
|
||||
state.holding = false;
|
||||
state.refused = false;
|
||||
state.waiting = 0;
|
||||
state.resolvedAsk = null;
|
||||
state.cancelRequestedAt = null;
|
||||
state.lastActive = 0;
|
||||
const turnId = String(state.turn);
|
||||
say(`\nyou ▸ ${firstLine(text)}`);
|
||||
activity.post("UserPromptSubmit", "accepted", { turnId });
|
||||
let stopReason;
|
||||
try {
|
||||
const result = await peer.request("session/prompt", { sessionId: state.sessionId, prompt: [{ type: "text", text }] });
|
||||
stopReason = String(result?.stopReason ?? "unreported");
|
||||
} catch (error) {
|
||||
if (agentExited) return;
|
||||
say(`\n[openrig-dsh] the turn failed: ${error.message}`);
|
||||
activity.post("Notification", "runtime_error", { turnId });
|
||||
stopReason = "error";
|
||||
}
|
||||
state.running = false;
|
||||
for (const ask of state.asks) ask.end("turn_ended");
|
||||
say(`\n── turn end (${stopReason}) ──`);
|
||||
if (state.holding) say("[openrig-dsh] a permission request was refused, so the seat reads needs input until the next prompt");
|
||||
else postIdle(stopReason);
|
||||
pump();
|
||||
};
|
||||
|
||||
const pump = () => {
|
||||
if (state.running || agentExited || state.queue.length === 0) return;
|
||||
void runTurn(state.queue.shift());
|
||||
};
|
||||
|
||||
const submit = (text) => {
|
||||
if (agentExited) return;
|
||||
state.queue.push(text);
|
||||
if (state.running) say(`[queued: ${state.queue.length}] this goes to dsh after the current turn`);
|
||||
pump();
|
||||
};
|
||||
|
||||
// Handshake, then open or reopen the conversation, then report it. Nothing is read as ready before this.
|
||||
let init;
|
||||
try {
|
||||
init = await peer.request("initialize", { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: "openrig-dsh-runner", version: "1" } });
|
||||
} catch (error) {
|
||||
return fatal(`dsh didn't complete the ACP handshake (${error.message})`, 1);
|
||||
}
|
||||
const capabilities = init?.agentCapabilities ?? {};
|
||||
const reported = init?.agentInfo?.version ? `${init.agentInfo.version} (as its ACP handshake reports it)` : "(version not reported)";
|
||||
const producer = `${RUNNER}; dsh ${version ?? reported}; ACP ${init?.protocolVersion ?? "?"}`;
|
||||
const resumeRef = env.OPENRIG_RESUME_REF;
|
||||
// Whether dsh's own session list names a conversation, following its pages: true, false when every page was read and
|
||||
// none names it, or undefined when the list fails, answers in another shape, or runs past LIST_PAGES pages. An ACP
|
||||
// cursor is an opaque string, possibly empty, passed back unchanged; an absent or null one ends the list.
|
||||
const listsConversation = async (id) => {
|
||||
let cursor;
|
||||
for (let page = 0; page < LIST_PAGES; page += 1) {
|
||||
let listing;
|
||||
try { listing = await peer.request("session/list", cursor === undefined ? {} : { cursor }); } catch { return undefined; }
|
||||
if (!Array.isArray(listing?.sessions) || listing.sessions.some((session) => typeof session?.sessionId !== "string")) return undefined;
|
||||
if (listing.sessions.some((session) => session.sessionId === id)) return true;
|
||||
if (listing.nextCursor === undefined || listing.nextCursor === null) return false;
|
||||
if (typeof listing.nextCursor !== "string") return undefined;
|
||||
cursor = listing.nextCursor;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
if (resumeRef) {
|
||||
// The method dsh advertises; with neither advertised, session/resume, the method dsh 0.2.1-rc.2 answered in its
|
||||
// maintainer's checks. dsh 0.2.1-rc.2 answers -32602 both for a conversation it doesn't have and for one active
|
||||
// elsewhere, so that error can't mean missing. Only dsh's own "not found" counts as missing, and not when dsh's list
|
||||
// names the conversation. The list can't show a conversation is gone: one another running dsh holds unsaved may be
|
||||
// left out, so an unlisted conversation is still reopened. Anything else leaves the resume unconfirmed.
|
||||
const method = capabilities.sessionCapabilities?.resume ? "session/resume" : capabilities.loadSession ? "session/load" : "session/resume";
|
||||
const listed = capabilities.sessionCapabilities?.list ? await listsConversation(resumeRef) === true : false;
|
||||
try {
|
||||
await peer.request(method, { sessionId: resumeRef, cwd, mcpServers: [] });
|
||||
} catch (error) {
|
||||
if (error.code === ACP_RESOURCE_NOT_FOUND && !listed) {
|
||||
writeLaunchRecord({ error: "missing", producer });
|
||||
return fatal(`dsh says conversation ${resumeRef} doesn't exist (${error.message}); no new one was started`, 3);
|
||||
}
|
||||
return fatal(`dsh couldn't reopen conversation ${resumeRef} (${method}: ${error.message}); the resume is unconfirmed and no new conversation was started`, 4);
|
||||
}
|
||||
state.sessionId = resumeRef;
|
||||
} else {
|
||||
try {
|
||||
state.sessionId = (await peer.request("session/new", { cwd, mcpServers: [] }))?.sessionId ?? null;
|
||||
} catch (error) {
|
||||
return fatal(`dsh couldn't open a conversation (${error.message})`, 1);
|
||||
}
|
||||
if (!state.sessionId) return fatal("dsh opened a conversation without naming it", 1);
|
||||
}
|
||||
writeLaunchRecord({ nativeRef: state.sessionId, producer });
|
||||
say(`[openrig-dsh] ready · ${producer} · conversation ${state.sessionId}`);
|
||||
say(`[openrig-dsh] DSH_HOME ${home} · DSH_PERMISSION_MODE ${agentEnv.DSH_PERMISSION_MODE}`);
|
||||
say("[openrig-dsh] each submitted message is one prompt; /abort or Ctrl-C cancels the running turn");
|
||||
|
||||
heartbeat = setInterval(() => { if (!state.running && !state.holding && !agentExited) postIdle("idle_heartbeat"); }, BEAT_MS);
|
||||
// While a turn runs and nothing waits on a person: the turn is still outstanding and dsh is alive. Not progress.
|
||||
workBeat = setInterval(() => {
|
||||
if (working()) {
|
||||
activity.post("active", "liveness", { turnId: String(state.turn), livenessSeconds: WORK_LIVENESS_SECONDS });
|
||||
}
|
||||
}, WORK_BEAT_MS);
|
||||
for (const signal of ["SIGHUP", "SIGTERM"]) {
|
||||
process.on(signal, () => {
|
||||
if (state.running && !state.cancelRequestedAt) peer.notify("session/cancel", { sessionId: state.sessionId });
|
||||
for (const ask of state.asks) ask.end("exited");
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => child.kill("SIGKILL"), 3_000).unref();
|
||||
});
|
||||
}
|
||||
process.on("SIGINT", abort);
|
||||
|
||||
const framer = new InputFramer();
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdout.write("\x1b[?2004h");
|
||||
tty = true;
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => {
|
||||
for (const event of framer.push(chunk)) {
|
||||
if (event.type === "echo") process.stdout.write(event.text);
|
||||
else if (event.type === "erase") process.stdout.write("\b \b");
|
||||
else if (event.type === "abort") { process.stdout.write("\n"); abort(); }
|
||||
else if (event.type === "block") { process.stdout.write("\n"); submit(event.text); }
|
||||
}
|
||||
});
|
||||
} else {
|
||||
createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
if (line.trim() === "/abort") abort();
|
||||
else if (line.trim()) submit(line);
|
||||
});
|
||||
}
|
||||
|
||||
if (firstInput !== null) submit(firstInput);
|
||||
else postIdle("idle_heartbeat");
|
||||
}
|
||||
|
||||
const invoked = (() => {
|
||||
try { return process.argv[1] ? pathToFileURL(realpathSync(process.argv[1])).href : null; } catch { return null; }
|
||||
})();
|
||||
if (invoked === import.meta.url) {
|
||||
main().catch((error) => { console.error(`openrig-dsh-runner: ${error.stack ?? error}`); process.exit(1); });
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
# 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
|
||||
# Hermes runs every tool command in one gateway per machine, 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.
|
||||
|
||||
@@ -5,9 +5,8 @@ command: openrig-hermes-launch # no model, provider or --max-turns here: they
|
||||
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
|
||||
- closing a seat's pane detaches it; Hermes finishes any accepted turn, and its gateway exits when idle
|
||||
- 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
|
||||
passes_model: false # the launcher maps OPENRIG_MODEL for a new session; set true once checked with the real harness
|
||||
@@ -24,21 +24,6 @@ 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
|
||||
|
||||
# The seat's declared model (OPENRIG_MODEL, when the preset sets passes_model): Hermes takes it only from config.yaml, and
|
||||
# Hermes v0.22.4 fixes a session's launch settings when it creates it. So it's written for a new session only, and only
|
||||
# into a config.yaml that is absent or empty: a seat's own config is left byte-for-byte as it is, whatever it says. On a
|
||||
# resume this launcher applies no OpenRig model.
|
||||
if [ -n "${OPENRIG_MODEL:-}" ] && [ -z "${OPENRIG_RESUME_REF:-}" ]; then
|
||||
case "$OPENRIG_MODEL" in
|
||||
''|*[!A-Za-z0-9._:/@+-]*) echo "openrig-hermes-launch: not writing unusual model id \"$OPENRIG_MODEL\" into $HERMES_HOME/config.yaml" >&2 ;;
|
||||
*) if [ -s "$HERMES_HOME/config.yaml" ]; then
|
||||
echo "openrig-hermes-launch: the seat has its own $HERMES_HOME/config.yaml; OPENRIG_MODEL was not applied; set the model there" >&2
|
||||
else
|
||||
printf 'model: "%s"\n' "$OPENRIG_MODEL" >> "$HERMES_HOME/config.yaml"
|
||||
fi ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
|
||||
exec "$hermes" chat -c "$OPENRIG_RESUME_REF"
|
||||
fi
|
||||
|
||||
@@ -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
|
||||
@@ -4,13 +4,10 @@ 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
|
||||
- resume not declared until reopening a named session, and a missing one, have been checked with a real OpenClaw
|
||||
- runs locally in the pane (openclaw chat), with no Gateway; Gateway-attached use isn't covered
|
||||
- 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 +1,38 @@
|
||||
#!/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.
|
||||
# A reference launcher for OpenClaw'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): OpenClaw's local chat starts on the session key OpenRig named;
|
||||
# - a resume (OPENRIG_RESUME_REF set): it first checks that this seat's sessions list the key, and stops if they
|
||||
# don't, so a gone conversation isn't quietly recreated under its old key.
|
||||
# It runs `openclaw chat` (the TUI with --local): the agent runs in the seat's own pane, with no Gateway. Arguments after
|
||||
# the launcher's name go to every launch and resume alike.
|
||||
set -eu
|
||||
|
||||
openclaw="${OPENRIG_OPENCLAW:-openclaw}"
|
||||
mode=local
|
||||
if [ "${1:-}" = --gateway ]; then mode=gateway; shift; fi
|
||||
|
||||
# Each seat gets its own OpenClaw state folder: --local needs it to itself, and seats don't share sessions.
|
||||
# Its provider settings and credentials come from that folder's config or the environment, not the person's ~/.openclaw.
|
||||
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 '
|
||||
key="$OPENRIG_RESUME_REF"
|
||||
# A key may be stored bare or as agent:<id>:<key>; either counts as this conversation.
|
||||
if ! "$openclaw" sessions --json --limit all | KEY="$key" 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); }
|
||||
const key = process.env.KEY;
|
||||
const sessions = JSON.parse(raw).sessions || [];
|
||||
process.exit(sessions.some((s) => s.key === key || String(s.key).endsWith(`:${key}`)) ? 0 : 1);
|
||||
});'; 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"
|
||||
echo "openrig-openclaw-launch: this seat's OpenClaw sessions don't list \"$key\" (or couldn't be listed); not starting a new one under that key" >&2
|
||||
exit 3
|
||||
fi
|
||||
exec "$openclaw" chat --session "$key" "$@"
|
||||
fi
|
||||
if [ "$mode" = gateway ]; then exec "$openclaw" tui --session "$key" "$@"; fi
|
||||
exec "$openclaw" tui --local --session "$key" "$@"
|
||||
if [ -n "${OPENRIG_CONVERSATION:-}" ]; then
|
||||
exec "$openclaw" chat --session "$OPENRIG_CONVERSATION" "$@"
|
||||
fi
|
||||
echo "openrig-openclaw-launch: OpenRig passed no conversation name; is names_conversation set in the preset?" >&2
|
||||
exit 2
|
||||
@@ -1,5 +0,0 @@
|
||||
# OpenShell reference driver
|
||||
|
||||
Run `rig environment setup --help` to install the maintained OpenShell 0.1.6 / Claude Code reference from the packaged daemon assets. Its README, image recipe, endpoint-specific policy, example RigSpec/AgentSpecs and versioned driver schema are installed together. Setup prepares files and an ordinary scoped work grant; it does not create compute or launch a provider.
|
||||
|
||||
This first cut supports explicit owned create or exact attached binding, required destination projection and verified JSON artifact return through authenticated MCP work tools. Explicit owned lifecycle operations retain separate observed receipts. Exact same-host recreate/restore requires verified private history and the selected checkpoint; attached native, storage and compute are preserved. A native process receipt is not a successful model-work claim. Existing unbound host runtimes retain their launch/projection paths.
|
||||
@@ -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
|
||||
@@ -4,36 +4,6 @@ A community harness is a coding harness an outside contributor maintains. You in
|
||||
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.
|
||||
|
||||
For what an integration author owns end to end, including a runner's duties and running a harness somewhere else, see
|
||||
the draft [integration author contracts](author-contracts.md).
|
||||
|
||||
## 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. A [runner preset](#runner-presets-runner-true)'s own reports decide it instead. |
|
||||
| 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. A [runner preset](#runner-presets-runner-true) is the exception: its runner submits the agent's permission requests to OpenRig, which asks a person, and the default is permit (section 7 of the [harness and runner contract](harness-runner-contract.md)). |
|
||||
| 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, or let a launcher that opts in take the seat's declared model (`passes_model`, below).
|
||||
|
||||
**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
|
||||
@@ -50,9 +20,6 @@ launch_result: true # optional: the launcher reports the conversatio
|
||||
# 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)
|
||||
# runner: true # optional, needs launch_result: the launcher reports the seat's activity (below)
|
||||
# passes_model: true # optional: the launcher takes the seat's declared model from OPENRIG_MODEL (below)
|
||||
```
|
||||
|
||||
- **`command`** is a single program and its arguments. Shell syntax such as `|`, `&&`, `;`, redirections or `$(…)`
|
||||
@@ -65,15 +32,11 @@ first_input: true # optional: launcher takes the first turn from
|
||||
|
||||
### 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.
|
||||
Set `startup_assisted: true` when the harness doesn't reliably submit OpenRig's startup text. OpenRig still pastes the
|
||||
text and presses Enter once. If it sends startup text, the running seat then shows **attention required**, rather than
|
||||
ready: check its pane and submit any text still in the composer. Don't resend the identity prompt. After 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`)
|
||||
|
||||
@@ -149,45 +112,6 @@ For a reported conversation, OpenRig also says so plainly when:
|
||||
- **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.
|
||||
|
||||
## Runner presets (`runner: true`)
|
||||
|
||||
A plain preset's seat reads its activity from the screen. A **runner** preset is for a launcher that hosts the agent itself
|
||||
and reports what it's doing, as OpenRig's own Pi runner does. The seat's activity then follows those reports.
|
||||
|
||||
- **It needs `launch_result: true`.** A runner reports the conversation it opened in its launch record, so `runner: true`
|
||||
without `launch_result` is refused.
|
||||
- **The declaration is fixed at launch.** Each launch, a restore's included, records on the session what the preset
|
||||
declares at that moment. Editing the preset changes the next launch, not a running seat. A session recorded before this
|
||||
field existed reads as plain.
|
||||
- **No record means attention.** When a runner's launch record doesn't arrive within the wait, or its folder can't be
|
||||
made, the seat needs attention instead of reading ready. A plain `launch_result` preset still starts, with its identity
|
||||
unavailable.
|
||||
- **Input is as for any seat.** A send pastes the text and presses Enter, so a runner should submit everything up to that
|
||||
Enter as one prompt.
|
||||
|
||||
**What the runner posts.** The runner posts each event to `POST /api/activity/hooks` with the local activity hook token,
|
||||
one at a time and in order, without making the agent wait. It finds the address and token in `OPENRIG_URL` (or
|
||||
`OPENRIG_PORT`) and `OPENRIG_ACTIVITY_HOOK_TOKEN`, or else in `$OPENRIG_HOME/activity-endpoint.json`. Every post names:
|
||||
- the seat, as `sessionName` and `nodeId`;
|
||||
- `runtime`, the preset's name;
|
||||
- `generation`, from `OPENRIG_OCCUPANT_GENERATION`;
|
||||
- an increasing `sequence`, and `occurredAt`;
|
||||
- `hookEvent` and `subtype`, as in the table.
|
||||
|
||||
| The runner posts | When | The seat reads |
|
||||
|---|---|---|
|
||||
| `UserPromptSubmit` | it gave the agent a prompt | working |
|
||||
| `active` or `PreToolUse` | the agent is working | working |
|
||||
| `Notification` / `permission_prompt` | the agent asked for a permission | needs input |
|
||||
| `Stop` / the turn's stop reason | the turn ended | idle |
|
||||
| `Stop` / `<preset name>_exited` | the agent's process ended | unknown, never idle |
|
||||
|
||||
- **The screen still counts,** but only as evidence of work: on its own it never reads the seat idle.
|
||||
- **The generation attributes a post; it isn't authentication.** A post with an earlier occupant's generation doesn't
|
||||
count for the live seat. The hook token is one local token, so any process that holds it can post for any seat.
|
||||
- **A runner's full duties** (liveness, input, cancellation and permissions) are in the draft
|
||||
[harness and runner contract](harness-runner-contract.md).
|
||||
|
||||
## An example: Hermes
|
||||
|
||||
[`community-harness-examples/hermes/`](community-harness-examples/hermes/) has a preset and a small launcher for Hermes
|
||||
@@ -199,9 +123,7 @@ Agent's CLI.
|
||||
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
|
||||
- **Let Hermes run `rig` as the seat:** Hermes runs every tool command in one gateway per machine, 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
|
||||
@@ -212,8 +134,7 @@ Agent's CLI.
|
||||
- **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.
|
||||
accepted, and the seat's gateway exits once idle. The preset says so in its limits.
|
||||
|
||||
Copy them and maintain them as your own.
|
||||
|
||||
@@ -221,21 +142,17 @@ Copy them and maintain them as your own.
|
||||
|
||||
[`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.
|
||||
- **The launcher:** it runs `openclaw chat` (the TUI with `--local`) on the session key OpenRig named, with its own
|
||||
OpenClaw state folder per seat, so the agent runs in the seat's pane with no Gateway.
|
||||
- **Its resume check:** before reopening a key, the launcher checks that the seat's `openclaw sessions --json` lists it,
|
||||
and stops if not, so a gone conversation isn't recreated under its old key. That check is the launcher's code, not
|
||||
OpenRig's.
|
||||
- **Resets must stay off:** OpenClaw can start a new conversation under the same key on a reset rule
|
||||
(`session.reset`). That's off by default; with it on, a key no longer names one conversation. Likewise, typing `/new`
|
||||
in the pane moves the chat to a fresh session key, while the seat still holds the name OpenRig gave it.
|
||||
- **The preset:** it declares `names_conversation` but not `resume`, until both have been checked with a real OpenClaw.
|
||||
|
||||
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.
|
||||
Copy both and maintain them as your own.
|
||||
|
||||
**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.
|
||||
@@ -250,201 +167,13 @@ CLI.
|
||||
- **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.
|
||||
- **Startup is assisted:** identity text has remained in Cursor's composer after the initial Enter. The preset declares
|
||||
`startup_assisted: true`; check and submit it in the pane as above. Unattended startup submission is not confirmed.
|
||||
- **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.
|
||||
|
||||
## An example runner: dsh
|
||||
|
||||
[`community-harness-examples/dsh/`](community-harness-examples/dsh/) has a runner preset (`runner: true`) and a small runner
|
||||
for DeepSeek Harness, which speaks ACP on stdio and has no terminal UI of its own. It's written for dsh 0.2.1-rc.2 and
|
||||
checked only against a stand-in agent.
|
||||
- **The runner:**
|
||||
- it opens the conversation and writes the launch record;
|
||||
- it turns each submitted block into one prompt;
|
||||
- it reports turn boundaries as activity hooks;
|
||||
- it cancels on `/abort` or Ctrl-C, as the [runner contract](harness-runner-contract.md) describes.
|
||||
- **Permissions:** it sends each request to OpenRig, which asks the default person or the seat's override.
|
||||
- **When no one can be asked, or OpenRig can't answer,** it permits with dsh's own allow option (`allow_once`, else
|
||||
`allow_always`), which is OpenRig's default. `allow_always` creates a standing grant in dsh.
|
||||
- **It refuses and holds needs input** only after `--deny`. A person's no when dsh offered no reject option, or a
|
||||
request OpenRig says isn't the current occupant's, is answered cancelled.
|
||||
- **Each seat gets its own dsh home,** with dsh's session-log and plugin-list uploads to DeepSeek turned off. Give that
|
||||
home credentials, or put a provider key in the seat's environment.
|
||||
- **Resume isn't declared.** The runner reopens a conversation by the method dsh advertises, and reports one missing only
|
||||
on dsh's own not-found error. Declare `resume: true` only after a real dsh has reopened a conversation and refused a
|
||||
missing one.
|
||||
|
||||
Its [README](community-harness-examples/dsh/README.md) has the setup and a check you can run without a model. 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`, `first_input`, `runner` or `passes_model` is refused by an older
|
||||
module into its daemon. A preset that sets `launch_result`, `names_conversation` or `startup_assisted` 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.
|
||||
|
||||
## Pass the seat's declared model at launch
|
||||
|
||||
A format-1 preset may set `passes_model: true` (a boolean, default false). Then a model declared for the seat (in the rig
|
||||
spec, or set later with `rig seat set-model`) reaches its launcher as `OPENRIG_MODEL`, on new and resumed launches alike. A seat that declares no model gets none,
|
||||
and an inherited `OPENRIG_MODEL` is cleared. The launcher maps it into its harness's own setting; Core knows no harness's
|
||||
model flag or config key.
|
||||
|
||||
OpenRig keeps three things apart:
|
||||
- **requested:** the declared model, as `rig ps --nodes` shows it ("configured", or "not-declared");
|
||||
- **passed:** the launcher was handed the model. OpenRig records `node.startup_model_passed` with the model and whether the
|
||||
launch was a resume only when the launch command's send succeeded, and a later failure of the launch doesn't undo that.
|
||||
It never says passed from the declaration alone or for a launch that failed before anything was typed. When typing
|
||||
began and the send then failed, the model's dispatch is uncertain: it isn't recorded as passed, and it isn't retyped;
|
||||
- **effective:** the model that actually runs. OpenRig doesn't observe it for a community harness, so it reads UNKNOWN.
|
||||
|
||||
Passing a model never makes OpenRig pick a default, substitute another model, or retry with one. A model the harness
|
||||
refuses is the harness's own error.
|
||||
|
||||
The examples apply the request for a new session only; on a resume, the example launcher doesn't apply it, and which
|
||||
model a resumed session runs stays unknown unless the harness's own evidence says otherwise:
|
||||
- **Cursor:** `--model "$OPENRIG_MODEL"` for a new chat. What a resumed chat runs hasn't been checked: the launcher leaving
|
||||
out `--model` doesn't show that it keeps its earlier model.
|
||||
- **Hermes:** Hermes takes its model only from the seat's `config.yaml`, so for a new session the launcher writes
|
||||
`model: "<id>"` there only when that file is absent or empty. A seat that has its own `config.yaml` keeps it
|
||||
byte-for-byte, and the launcher says the model wasn't applied: set it in that file. A seat set up from
|
||||
`config.yaml.example` already has a config, so set its model there. Hermes v0.22.4 fixes a session's
|
||||
launch settings when it creates it (its own source), so a resumed Hermes session runs with what it was created with.
|
||||
|
||||
Both example presets ship with `passes_model: false` until that mapping has been checked with the real harness. Presets
|
||||
without the field and built-in runtimes are unchanged, and older OpenRig versions reject it as unknown.
|
||||
|
||||
## 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.
|
||||
@@ -1,210 +0,0 @@
|
||||
# Writing a daemon client
|
||||
|
||||
What a program that talks to the OpenRig daemon over HTTP needs and can't read from `rig --help`: how to find the
|
||||
daemon, its health route, which bearer token each route family checks, the event streams, and some route
|
||||
details. It describes the daemon as it is. It adds no promise beyond what each section says, and route shapes not
|
||||
listed here can change.
|
||||
|
||||
## Finding the daemon
|
||||
|
||||
`rig status --json` is the discovery surface. Its `daemon` object gives `state` and `reachable`, and `pid` and
|
||||
`port` when they are known: a stopped daemon's response has no `port`, and a running daemon found through
|
||||
`OPENRIG_URL` has no `pid`. `reachable` is `true` when the daemon answered its health probe, `false` when it is
|
||||
positively down, and `null` when that isn't confirmed (see `rig status` in
|
||||
[the command reference](../as-built/cli-reference.md#status)). It reads the recorded state and probes; it never
|
||||
starts a daemon.
|
||||
|
||||
The CLI itself finds the daemon in these orders:
|
||||
|
||||
- **Data commands** (everything that sends a request): `OPENRIG_URL` (or the legacy `RIGGED_URL`); else
|
||||
`$OPENRIG_HOME/daemon.json` when it holds a valid `pid` and `port`; else the config keys `daemon.host` and
|
||||
`daemon.port` (environment `OPENRIG_HOST` and `OPENRIG_PORT`), which default to `127.0.0.1:7433`.
|
||||
- **Status** (`rig status`, `rig daemon status`): `OPENRIG_URL` (or `RIGGED_URL`) first, probed directly; else
|
||||
the state file below.
|
||||
- **Start and stop** (`rig daemon start`, `stop`): the state file, `$OPENRIG_HOME/daemon.json`, then the legacy
|
||||
`~/.rigged/daemon.json`.
|
||||
|
||||
`daemon.json` holds `pid`, `port`, `host`, `db` and `startedAt`. `rig daemon start` writes a new daemon's record
|
||||
only after its health check passes, so while a start is pending, the file holds no record of the new daemon: it
|
||||
may be missing, or hold an earlier daemon's stale record. It is the CLI's own state file, not a published
|
||||
contract: prefer `rig status --json`.
|
||||
|
||||
## `GET /healthz`
|
||||
|
||||
The liveness and identity read. It always returns `status: "ok"` and `pid`. Every other field is present only
|
||||
in the case named:
|
||||
|
||||
| Field | When it is present |
|
||||
| --- | --- |
|
||||
| `euid` | On platforms with effective user ids. A self-report for the CLI's owner check, not authentication. |
|
||||
| `databaseWarnings` | When the daemon opened its database with warnings. |
|
||||
| `semver`, `commit`, `dirty`, `builtAt` | On a stamped (released) build; a development run has none. |
|
||||
| `selfHostId`, `selfHostIdSource` | After the boot reconcile has set this host's id. |
|
||||
| `eventLoop` | When the event-loop monitor runs: `lagMeanMs`, `lagP99Ms`, `utilization` (0 to 1), `lastTickAgeMs` and `healthy`. |
|
||||
| `routeTimings` | With `eventLoop`. |
|
||||
| `slowOperations` | When the slow-operation recorder runs. |
|
||||
| `bind` | The bind plan: `mode` (`explicit` or `default`), `hosts` (the addresses bound), `tailscaleDetected`, and `ignoredRoutingHost` when a routing value was ignored. |
|
||||
| `stuckSweep`, `wakeLadder` | The heartbeats of the stuck sweep and the wake ladder, when they run. |
|
||||
|
||||
## Bearer tokens
|
||||
|
||||
The daemon reads two tokens from its environment at start:
|
||||
|
||||
- **`OPENRIG_AUTH_BEARER_TOKEN`** checks Mission Control's write routes (`POST /api/mission-control/action` and
|
||||
`/notifications/test`), the `/api/hosts` writes (`add`, `update`, `remove`, `pair`), and the `/api/rig-mode`
|
||||
binding writes. `rig host pair` hands this token to the paired machine, so a daemon that will be paired with
|
||||
needs it set (see `rig host pair` in [the command reference](../as-built/cli-reference.md)).
|
||||
- **`OPENRIG_TERMINAL_BEARER_TOKEN`** checks every `/api/transport` and `/api/compaction` route, `POST
|
||||
/api/terminal/follow` and `/viewer/ensure`, and every `GET /api/terminal/<name>` route, the terminal websocket
|
||||
included. When the daemon is bound to an explicit address (`rig daemon start --host`) that isn't loopback or a
|
||||
tailnet address, and this token isn't set, these routes check `OPENRIG_AUTH_BEARER_TOKEN` instead.
|
||||
|
||||
A route whose token isn't set lets every request through. Send a token as `Authorization: Bearer <token>`; the
|
||||
`GET /api/terminal/<name>` routes, the websocket included, also take `?token=<token>`. The CLI sends the terminal token from `OPENRIG_TERMINAL_BEARER_TOKEN`,
|
||||
or else from the file `$OPENRIG_HOME/terminal-token`, which the person writes; the daemon doesn't create it.
|
||||
|
||||
A wrong or missing token gets HTTP 401. The routes that check `OPENRIG_AUTH_BEARER_TOKEN`, and `/api/transport`
|
||||
and `/api/compaction`, answer `{ "error": "unauthorized", "message", "what_failed", "why_it_matters", "what_to_do" }`.
|
||||
The `GET /api/terminal/<name>` routes, the websocket included, answer `{ "error": "unauthorized", "hint" }`. A valid configured bearer token in the Authorization header can pass the browser boundary's target-name check
|
||||
for a well-formed but unaccepted Host. It cannot pass a malformed Host or a refused Origin; see
|
||||
[Browser access](browser-access.md).
|
||||
|
||||
## Event streams
|
||||
|
||||
All of them are server-sent events (`text/event-stream`):
|
||||
|
||||
- **`GET /api/events`**: the daemon's event log, stored events only. `?rigId=` limits it to one rig. Each event
|
||||
carries `id: <seq>`. `view.changed` is not stored, so it does not appear here, live or replayed; `view.changed`
|
||||
rows stored by older versions still replay until an events retention (a separate change) removes them.
|
||||
Without `Last-Event-ID`, `?replay=<n>` first sends the newest `n` stored events (`0`: none, live events only);
|
||||
`?replay=all`, no `replay`, or any other value sends the whole log first. A `Last-Event-ID` header (what
|
||||
`EventSource` sends when it reconnects) sends the events after that id instead, whatever `replay` says.
|
||||
Live events that arrive during the replay follow it, without duplicates.
|
||||
- **`GET /api/activity/events`**: live only, no replay or ids. Each message is named after its event type:
|
||||
`seat.activity_changed`, `seat.rung_health`, `proof.judged` or `proof.sources_changed`. It tells a view to read
|
||||
again; it carries no derived state.
|
||||
- **`GET /api/views/sse`** (alias `/api/views/watch`) and **`GET /api/views/<viewName>/sse`**: `view.changed`, live
|
||||
only. Its `id:` is the `seq` of the stored event that caused it, so it can repeat across views and is not a replay
|
||||
cursor.
|
||||
- **`GET /api/queue/sse`** (alias `/api/queue/watch`): live only, with `id: <seq>`, for `queue.created`,
|
||||
`queue.handed_off`, `queue.claimed`, `queue.unclaimed`, `qitem.fallback_routed`, `qitem.closure_overdue`,
|
||||
`inbox.absorbed` and `inbox.denied`.
|
||||
|
||||
## People, asks and updates without Slack
|
||||
|
||||
`GET /api/gateway/participants` returns the same JSON projection as `rig gateway participants --json`:
|
||||
`{ ok, participants, default, organizations, groups, warnings, participantFile }`. Pick a person by `kind: "person"`
|
||||
and use their `id` for a Feed `view`, or their `address` for the queue's `destinationSession`. The effective
|
||||
`default` is `{ id, source }`, or `{ id: null, reason }`; it does not establish who is using the client.
|
||||
Participant records omit connector credential pointers. Check `warnings` and `participantFile.error` for a partial
|
||||
registry read; an unreadable human registry returns HTTP 503. See [Gateway participants](gateway-participants.md).
|
||||
|
||||
| Read | Meaning |
|
||||
| --- | --- |
|
||||
| `GET /api/queue/list?destinationSession=<address>&state=pending,in-progress,blocked&limit=100` | Open rows for exactly that address. `humanIntent: "update"` identifies an FYI; other values are asks. Each row has `qitemId`, `state`, `summary` and `body`. |
|
||||
| `GET /api/queue/open-human-updates?view=<person-id>&limit=20` | Open human FYIs, even when no Slack delivery receipt exists. Returns `{ items, limit, truncated, perspective? }`. |
|
||||
| `GET /api/queue/<qitemId>` and `/<qitemId>/transitions` | The current row and its history, including closed state and recorded outcome. Closing or acknowledging a row is different from delivering it. |
|
||||
| `GET /api/queue/human-updates?view=<person-id>&limit=20` | Existing delivered-update history: explicit FYIs with a recorded Slack posting receipt, including closed rows. Items add `deliveredAt` and `deliveryReceipt`. It is not the open-update inbox. |
|
||||
|
||||
Both update lists accept integer `limit` values from 1 to 100 (default 20); invalid values return HTTP 400.
|
||||
Open FYIs are ordered by priority, then newest first. `view=all` (also the default) shows all perspectives;
|
||||
`view=default` selects the effective default person, or all when no default is available. A named view that cannot
|
||||
resolve a person has `perspective.basis: "unknown"` and no items when a registry is available. With no registered
|
||||
people there is no perspective filter: inspect `perspective` rather than assuming the requested view was applied.
|
||||
A person's view also includes instance or unassigned items when that person is the default. These are views on a
|
||||
trusted daemon, not per-person authentication or access control; existing daemon and browser access rules still apply.
|
||||
|
||||
Always display `truncated: true` as incomplete, even if fewer than `limit` items arrive. A person-view scan examines
|
||||
at most 1,001 open updates (101 for delivered history) before filtering; older matching items may be outside that
|
||||
window. These endpoints have no pagination cursor. Reading an open update never posts it to Slack, acknowledges it,
|
||||
or manufactures a delivery receipt.
|
||||
|
||||
For example, this Node client takes a daemon URL, a registered person id and a rig name. It uses no Slack API and
|
||||
makes only reads; use the daemon discovery and access rules above. In an ES module, `process.argv.slice(1)` is the
|
||||
argument list for `node --input-type=module -e '<code>' <url> <person-id> <rig>`:
|
||||
|
||||
```js
|
||||
// client-people-example
|
||||
const [base, personId, rig] = process.argv.slice(1);
|
||||
async function get(path) {
|
||||
const response = await fetch(new URL(path, base));
|
||||
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
const registry = await get("/api/gateway/participants");
|
||||
const person = registry.participants.find(p => p.kind === "person" && p.id === personId);
|
||||
if (!person) throw new Error("Choose a registered person id");
|
||||
const rows = await get(`/api/queue/list?destinationSession=${encodeURIComponent(person.address)}&state=pending,in-progress,blocked&limit=100`);
|
||||
const updates = await get(`/api/queue/open-human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
|
||||
const receipts = await get(`/api/queue/human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
|
||||
const rigHistory = await get(`/api/queue/list?rig=${encodeURIComponent(rig)}&limit=100`);
|
||||
const messages = await get(`/api/queue/outbox/list?rig=${encodeURIComponent(rig)}&limit=100`);
|
||||
console.log(JSON.stringify({ participants: registry.participants, default: registry.default,
|
||||
warnings: registry.warnings, participantFile: registry.participantFile,
|
||||
asks: rows.filter(row => row.humanIntent !== "update"), updates, receipts, rigHistory, messages }, null, 2));
|
||||
```
|
||||
|
||||
The queue and outbox lists are bounded snapshots, not subscriptions. A full queue list may need a larger `limit`.
|
||||
An outbox entry's `deliveryState` reports the send attempt; only `delivered` confirms delivery. A queue transition,
|
||||
an open FYI, or a closed ask is not that receipt. The rig-wide reads and their limits are described below; the existing
|
||||
`outbox/list?senderSession=<session>` read remains available.
|
||||
|
||||
## Route details
|
||||
|
||||
- **A rig's queue history:** `GET /api/queue/list?rig=<rig>&updatedSince=<ISO time>&sort=updated` returns the
|
||||
rig's queue items (any whose source or destination seat is in the rig) updated at or after that time: open items
|
||||
(`pending`, `in-progress`, `blocked`) first, then the rest by latest update. `limit` defaults to 100 and has no
|
||||
upper cap. The list is cut after `limit` rows in that order, so when it comes back full, raise `limit` to read
|
||||
them all. Each item's own history is `GET /api/queue/<qitemId>/transitions`. This read scans the queue table, so
|
||||
read it occasionally rather than polling it. For live changes, treat `GET /api/queue/sse` events as hints to read
|
||||
`queue/list?rig=` again. Not every event names both seats (`queue.claimed` and `queue.unclaimed` carry only the
|
||||
destination), and not every state change is on that stream, so the events alone don't track a rig's items.
|
||||
- **A rig's messages:** `GET /api/queue/outbox/list?rig=<rig>` returns the messages sent from or to the rig's
|
||||
seats, newest first; `?senderSession=<session>` still returns one sender's. One of the two is required; with
|
||||
both, `senderSession` is used and `rig` is ignored. `limit` defaults to 100. No event is sent when a message is delivered, so read this list to see delivery
|
||||
states.
|
||||
- **`GET /api/queue/recent-transitions`** returns at most 20 rows. A positive `limit` above 20 is capped at 20;
|
||||
a missing, zero, negative or unparseable `limit` means 20.
|
||||
- **`POST /api/transport/send`** takes `urgent: true`, the same as `rig send --urgent`. The daemon records who
|
||||
marked it from the sender identity, not from the body.
|
||||
- **`POST /api/scope/approve`** is what `rig scope slice approve` and `rig scope mission approve` call. The body
|
||||
takes `scopeTier` (`slice` or `mission`), `scopePath`, and optionally `approvalScope` (`spec` or `delivery`, the
|
||||
default), `missionsRoot`, `onBehalfOf`, `reApprove`, `reason` and `lockedArtifacts`. The approver is the
|
||||
request's sender identity (`actorSession` in the body is recorded beside it, and a different value is
|
||||
superseded). Success is 201 with `scopeTier`, `scopeId`, `scopePath`, `approvalScope`, `approvedBy`,
|
||||
`approvedAt`, `onBehalfOf`, `actionId`, `freezeFired` and `reApproved`. Refusals include 400 for an invalid
|
||||
`scopeTier`, `scopePath` or `approvalScope`, or a missing actor (`actor_required`); 503 `action_log_unavailable`;
|
||||
and, from the approval itself, 404 `scope_not_found`, 409 `already_approved` or `nothing_to_reapprove`, 503
|
||||
`workspace_not_configured`, 500 `audit_write_failed`, and 400 for its other refusals. An unexpected error is 500
|
||||
`internal`.
|
||||
- **`POST /api/proof/judge`** is what `rig proof judge` calls. The body takes `scope`, `item`, `verdict`
|
||||
(`accept`, `reject` or `withdraw`), `reason`, `expectedRevision` and `expectedPrevious` (a judgment id or
|
||||
`null`), and optionally `evidence`, `expectedEvidence`, `subject`, `operationId`, `replace`, `project` and
|
||||
`projectRoot`. The judge is the request's sender identity. The response is `judgment`, the slice's `readiness`,
|
||||
`replayed` and `notification`: 201 for a new judgment, 200 when an `operationId` replays an earlier one.
|
||||
- **`POST /api/workflow/instantiate`** requires `specPath`, `rootObjective` and `createdBySession`, and takes
|
||||
`entryOwnerSession`, `targetRig` and `scope` (see [Project workspace](project-workspace.md) for `scope`).
|
||||
Success is 201 with `instance`, `spec`, `entryQitemId` and `entryOwnerSession`, any `advisories`, and
|
||||
`scopeIgnored` when a scope wasn't applied. The instance keeps `rootObjective`, which every instance read returns
|
||||
(`/api/workflow/list`, `/api/workflow/<id>` and its `trace`). An instance started before it was kept reads `null`.
|
||||
- **`GET /api/provider/usage`** returns `{ hostUsage }`, this host's usage rows (`host: "local"`, a `provider`
|
||||
and a `state`). A Claude row's `windows` are the seats' status-line readings, one entry per reading, each with
|
||||
its `window`, `usedPercent`, `resetsAt`, `asOf` and `seatSession`. A Codex row has `windows: []`. Treating the
|
||||
readings as one account rests on the deployment assumption the row's `provenance` names (one account per
|
||||
host), not on a measured identity. Readings whose reset times disagree are reported as `anomalies`, and the
|
||||
row's `state` can then be `explicit_unknown`. Show the row's `state`, `anomalies` and each reading's seat and
|
||||
`asOf` as reported, rather than merging readings that happen to match.
|
||||
- **Slack channels and people:** `GET /api/gateway/slack/channels` and `GET /api/gateway/slack/users` each return
|
||||
one page from Slack, read with the daemon's configured bot token, which never leaves the daemon. Channels are
|
||||
public and unarchived, as `{ id, name, isMember }`; people are `{ id, name, realName, displayName, isBot,
|
||||
deleted }`, with no email. Pass `cursor` (the previous page's `nextCursor`, `null` on the last page) and
|
||||
`limit` (default 100, at most 200). Inbound delivery needn't be enabled. Failures: 503 `slack_not_configured`
|
||||
when no bot token resolves, which is different from an empty list; 403 `slack_missing_scope` with the scope
|
||||
Slack names in `needed` (an older app may lack `users:read`); 429 `slack_rate_limited` with Slack's
|
||||
`Retry-After`; otherwise 502. Each request is a live Slack call, so page through a list when someone opens a
|
||||
chooser rather than polling it. Private channels and direct messages aren't listed.
|
||||
- **Slices are found by folder name alone.** `GET /api/slices/:name`, `/:name/doc/*`, `/:name/proof-asset/*` and
|
||||
`GET /api/review/slice/:name` take only the name; the detail route reads `?mission` only together with
|
||||
`?project`. When two missions have a slice folder of the same name, the daemon keeps the first one it finds:
|
||||
that one is listed and served, and the other doesn't appear in `GET /api/slices` at all. Keep slice names unique
|
||||
across missions, as [Project workspace](project-workspace.md) says.
|
||||
@@ -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
|
||||
@@ -1,138 +0,0 @@
|
||||
# Execution and platform integration contract — draft
|
||||
|
||||
This chapter describes the placement and resource responsibilities of an integration author. The harness/runner chapter owns native protocol, model input, activity, attention, permission exchange and turn cancellation. This chapter owns where that harness runs, which files and credentials it can reach, how native state survives an environment change, and how results and resources return to their owners.
|
||||
|
||||
**Status:** a design contract for authors, with an existing-compatible composition and one measured private implementation. Since 0.7.8, OpenRig also ships one maintained reference driver, OpenShell 0.1.6, as `rig environment` (see the [OpenShell example](community-harness-examples/openshell/README.md)). The measured baseline used OpenRig 0.7.4, OpenShell 0.1.6 and Claude Code 2.1.310. 0.7.6 published this draft; neither it nor the reference driver implements a generic environment driver, a `DeploySpec` loader or a cross-host native-resume guarantee. Proposed operations below are not commands or accepted configuration keys. Capability support must be stated for the actual integration and version.
|
||||
|
||||
## Keep the identities and responsibilities separate
|
||||
|
||||
RigSpec composes members, their AgentSpecs/profiles, runtimes, working directories and relationships. AgentSpec supplies reusable role, startup and context declarations. A built-in runtime or registered community preset supplies a launch path. An environment integration supplies placement and provider-specific resource handling around that path. Existing declarations do not automatically provision a remote filesystem, credentials or compute.
|
||||
|
||||
| Identity or handle | Meaning and responsibility |
|
||||
|---|---|
|
||||
| Stable seat and work identity | The member's canonical address/node and owned work records. OpenRig retains this identity across executions; an environment IP or display name does not replace it. |
|
||||
| Native session identity | The harness's conversation token/UUID, qualified by harness and resume-reference type. The harness owns its format and resume semantics. |
|
||||
| Environment binding | The integration's durable record linking placement, immutable provider resource IDs, native-state location, projection and ownership. It is separate from the native session token. |
|
||||
| Execution generation | A particular launch/process instance, with observable process/start identity when available. A new process can resume an old conversation. |
|
||||
| Viewer/client | A terminal attachment or connection to an execution. Closing it need not stop either the native process or compute. |
|
||||
|
||||
An application service bundled with a rig is a separate responsibility. An artifact browser or database in `RigSpec.services` serves the team's application; its presence does not place an agent inside that service. Bundling service definitions also does not bundle image bytes, native binaries, authenticated homes or live runtime state.
|
||||
|
||||
## A minimal composition using existing surfaces
|
||||
|
||||
The following is a **fragment of one pod** (`pods[].members`), using existing RigSpec fields, not a complete standalone RigSpec:
|
||||
|
||||
```yaml
|
||||
pods:
|
||||
- id: dev
|
||||
label: Development
|
||||
members:
|
||||
- id: coordinator
|
||||
agent_ref: local:agents/coordinator
|
||||
profile: default
|
||||
runtime: claude-code
|
||||
- id: worker
|
||||
agent_ref: local:agents/worker
|
||||
profile: default
|
||||
runtime: example-contained-claude
|
||||
```
|
||||
|
||||
The worker runtime names an author-installed community preset. This preset shape uses existing fields exercised on the 0.7.4 baseline:
|
||||
|
||||
```yaml
|
||||
format: 1
|
||||
name: example-contained-claude
|
||||
maintainer: Integration author
|
||||
command: ./scripts/launch-contained-worker
|
||||
launch_result: true
|
||||
resume: true
|
||||
startup_assisted: true
|
||||
limits:
|
||||
- Requires an explicitly prepared environment binding and guest projection.
|
||||
- Native resume is qualified only for the documented retained-state recipe.
|
||||
- Viewer closure does not implement turn cancellation or resource deletion.
|
||||
```
|
||||
|
||||
This is an integration skeleton, not an installed or qualified preset. The author must supply the executable, resolve its path against the actual launch working directory, register the preset in the existing OpenRig home, and prepare the member's host working directory. The launcher implements the preset's declared launch-result and typed-resume behavior; the runner chapter defines those mechanics. `startup_assisted` requires a startup-submission check; it does not hold back submission or establish successful context delivery or ingestion. A member's model setting is not automatically honored by a custom launcher: its resolved native model/effort must be explicit in that launcher's supported configuration. With `passes_model: true`, Core passes the declared model through `OPENRIG_MODEL`; the launcher must map and apply it. The effective model remains unknown to Core.
|
||||
|
||||
For a contained worker, the launcher may resolve a private binding, create or attach to the selected resource, project explicit inputs, then connect to the native process inside it. In the measured recipe, OpenRig ran on the host, the plain coordinator used the built-in native runtime, and the worker used a private community launcher. The wrapper performed projection, provider-reference delivery, resume validation and artifact export explicitly. Those effects were not supplied by the preset declarations themselves. The integration must not assume host guidance/skills, hook paths or environment variables automatically reach a community runtime's guest.
|
||||
|
||||
A downstream author can implement this composition today around a qualified provider API and native harness. It remains an author-maintained integration until maintained platform support is separately implemented and documented. Validate against the target OpenRig schema and effective launch configuration; a parsed field or successful process start alone does not establish its effect.
|
||||
|
||||
## Placement and launch receipts
|
||||
|
||||
Before mutation, resolve the selected host/provider, workspace, image/native versions, reachability, credential references, resource request and ownership. Record existing resources and the selected resource scope. Host headroom includes supervisors, gateway and other team members; a workload memory request is not proof of their combined cap. Report requested, admitted and observed resources separately. Do not infer enforced CPU/memory or isolation policy from accepted flags.
|
||||
|
||||
Persist a launch intent before a create operation. Use an attempt/retry identity where the provider supports it; otherwise reconcile the original operation before another create. A public binding receipt carries only nonsecret values. If the harness's native resume reference works like a bearer credential (anyone holding it can reopen the conversation), keep it in secret-held receipt data, never in the public receipt. A useful public receipt identifies:
|
||||
|
||||
- canonical seat, harness version and native resume-reference type;
|
||||
- provider/host/workspace, immutable environment ID and execution generation;
|
||||
- image/policy versions, requested resources and observed readiness;
|
||||
- inner working directory, private native-state location and projection manifest;
|
||||
- a public-safe native reference (never a bearer-like resume token), creator or attached-consumer disposition, and exact owned resource handles;
|
||||
- lifecycle operations supported, completion observations, pending effects and errors.
|
||||
|
||||
A resource name or IP can be reused. Reconcile immutable ID, workspace and any available generation/fingerprint before acting. A lost response is **unconfirmed**, not success or absence. Record provider failures without exposing credentials. Readiness should identify what is ready: provider reachable, filesystem projected, native process started, team identity authenticated, or task completed.
|
||||
|
||||
## Project files and credential references deliberately
|
||||
|
||||
Stage only declared inputs: role/startup material, selected skills, current work context, schemas and required native settings. Preserve unmanaged files and report conflicts. Review every path-bearing setting for its destination: host-only hook scripts, interpreters, status collectors and loopback URLs may not exist or be reachable in the guest. Missing activity or permission transport must remain unsupported rather than being inferred from the base harness name.
|
||||
|
||||
Distinguish three observations: host staging, destination delivery and native ingestion. A destination manifest can record relative path, byte count and hash; a useful task using the current context revision/nonce can show consumption. Neither observation substitutes for the other. Current working input may change between tasks while an earlier artifact stays immutable.
|
||||
|
||||
Retained native history is a different data class from ordinary project context. Preserve its exact bytes through the harness's supported resume path; do not edit records or synthesize a conversation. Preserve a complete checkpoint prefix with byte length/hash if the harness may append after the checkpoint. Record which native-state files are required and which remain unavailable. Never substitute a fresh conversation seeded with a report while calling it native resume.
|
||||
|
||||
Keep secret values out of RigSpec, preset YAML, bindings, argv and public receipts. The provider/credential owner supplies scoped references or native-supported credential helpers and controls revocation. A successful login, provider registration or model-list request is not evidence of usable inference credit or an authorized spend envelope. Do not infer subscription portability from a provider name or copy an authenticated owner home into a guest.
|
||||
|
||||
Authenticate guest coordination through the actual granted member route. A shared source IP is not a seat identity. Record the grant-bound principal and scope separately from resource placement; distinct principals do not imply per-seat row ACLs or isolation between co-located secrets.
|
||||
|
||||
## Own only the effects assigned to the integration
|
||||
|
||||
Record **created** versus **attached** for each launch receipt. The creator may stop/delete the exact resource it created under the selected policy. An attached consumer normally closes only its own client and any separately identified process it owns; it must preserve the external environment, native session, workspace and shared services. Attachment must not silently transfer provider custody. An explicit owner-authorized transfer is a separate recorded action.
|
||||
|
||||
Describe these effects independently:
|
||||
|
||||
| Operation | Required distinction |
|
||||
|---|---|
|
||||
| Cancel a turn | Ask the runner to stop current work and observe its result. Unsupported cancellation must not be replaced silently by killing compute. |
|
||||
| Close a viewer | Release that client/connection; report whether native execution continues or is unknown. |
|
||||
| Stop native execution | End the identified native process and observe its exit. This is separate from task success. |
|
||||
| Stop compute | Release the documented provider allocation; identify retained storage and actual provider/process state. |
|
||||
| Delete an owned environment | Remove the exact creator-owned resource and reconcile final absence, including provider-managed dependents. |
|
||||
|
||||
Use graceful shutdown first where supported, then only explicitly owned, identified handles for stronger termination. Never equate pane disappearance with process or resource release. Persistent/non-persistent lifetime describes when execution should stop; durability describes recovery. An explicit non-persistent completion condition must name its invocation/work scope. An unrelated queue closure or an empty queue is not permission to terminate a member, and no new lifecycle YAML syntax is specified here.
|
||||
|
||||
## Resume exactly, or return an honest disposition
|
||||
|
||||
A resume request identifies the seat, harness-qualified native reference, prior binding, expected environment boundary and retained state. Validate those before launching the native harness. Reject mismatched reference types, missing/changed history, wrong immutable resources, unavailable required files and duplicate active executions. Never use “latest” as a replacement for an exact requested conversation or silently start fresh on failure.
|
||||
|
||||
Separate the outcomes: same native conversation resumed; new conversation rebuilt from artifacts; fresh launch; unsupported; refused; or unconfirmed. Environment continuity has its own outcome: same retained environment, recreated compute with copied state, different host, or unknown. A matching UUID alone does not show that native history was consumed.
|
||||
|
||||
If a provider stops exposing file transfer after canonical-process exit, capture artifacts/history and publish any retained resume descriptor **while transfer is still available**. Verify destination bytes before shutdown. After completion, use host/provider observations instead of assuming guest exec still works. For recreated compute, explicitly transfer the retained checkpoint and required artifact into the successor environment and validate them before publishing its resume descriptor. A new environment ID is expected and must be recorded as a successor, not disguised as the old environment.
|
||||
|
||||
The measured private recipe demonstrated this latter boundary: the original sandbox was deleted; copied native history and task bytes were restored into new containers on the same physical host; the native harness launched with `--resume` and the retained UUID. Its first new reply recalled a private conversation-only marker before tools, with no marker in the new input and no native-history tool read. It then verified artifact 1, used changed task context, produced artifact 2 and returned it for independent coordinator verification. This establishes one bounded native-return/work result. It does **not** establish new-host migration, RAM restoration, same-container stop/start, cgroup enforcement, general portability or shipped platform support.
|
||||
|
||||
## Export results before teardown
|
||||
|
||||
A guest path/hash note means bytes exist in a guest; it is not delivery to the consumer. Identify the actor holding the provider transfer handle. In the measured recipe that actor was the host wrapper, not the guest worker.
|
||||
|
||||
The worker publishes its report once and records the task/path/hash on its existing child. The wrapper validates the binding, task, allowed path, file type, size and expected hash; transfers to a fresh host staging path; checks the returned bytes/schema/context; and publishes without overwriting an existing result. It records an actual consumer-accessible destination, hash, byte count and actor through the authenticated work route. The worker closes its child after the matching export receipt; the coordinator independently reads/verifies those bytes before closing its parent. An ambiguous transfer is reconciled by existing bytes/hash, not a new report or duplicate task.
|
||||
|
||||
Complete exports before resource deletion. If output cannot be returned, preserve the exact resource/owner and failure state. Cleanup then reconciles the exact owned workload, supervisor, volumes and provider records to the intended stopped/absent state. Keep reused engines, caches, authenticated environments and attached resources. Revoke scoped credentials/grants through their owners and record remaining state. “Delete requested” and “absence verified” are different receipts.
|
||||
|
||||
## Proposed generic platform seam — not implemented
|
||||
|
||||
The existing composition need not acquire another YAML format to describe these responsibilities. If a maintained platform seam is selected, the following **design pseudocode** names the smallest useful operations; it is not a loader or API available in this release:
|
||||
|
||||
```text
|
||||
prepare(placement, ownership, resourceRequest, credentialRefs) -> bindingReceipt
|
||||
project(binding, explicitFiles, nativeCheckpoint?) -> deliveryReceipt
|
||||
launch(binding, harnessLaunch, exactResumeRequest?) -> executionReceipt
|
||||
observe(binding, execution) -> observedState | unsupported | unconfirmed
|
||||
export(binding, artifactRequest) -> publicationReceipt
|
||||
shutdown(binding, execution, selectedEffect) -> effectReceipt
|
||||
```
|
||||
|
||||
The provider implementation interprets its image/network/compute configuration. The runner interprets native protocol and control. Core would retain typed provenance and route the selected effects; it must not reinterpret arbitrary provider keys, leak secrets or collapse ownership into a stop boolean. Each mutating operation needs an attempt identity and reconciliation route; partial receipts remain useful after failure. Capability reporting must expose unsupported projection, cancellation, resume or cleanup rather than letting method names imply support.
|
||||
|
||||
The most useful author evidence follows the real work path: exact resolved placement and native launch; delivered current context; authenticated work ownership; returned artifact bytes; exact resume boundary and consumed retained state; and final resource disposition. A parser pass, launcher success, viewer reconnect or same display name leaves the corresponding effect unconfirmed. These distinctions let an author implement and diagnose an integration; they do not create a new release gate or require another runtime campaign.
|
||||
@@ -1,180 +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.
|
||||
|
||||
For a remote client, `GET /api/gateway/participants` returns the same participant, organization, group, default and
|
||||
warning fields as `rig gateway participants --json`. Add `?organization=<id>` to filter the participant list; the default
|
||||
and organization and group maps still describe the whole registry. The result names the participant file and reports
|
||||
an unreadable side file without treating it as an empty one. If the human registry itself cannot be read, the route
|
||||
returns `503` with `error: "human_registry_unavailable"`. The result does not include connector secret pointers.
|
||||
|
||||
## Participants
|
||||
|
||||
A participant is either:
|
||||
- **a person**, registered with `rig gateway human add`, with or without a connector binding (such as Slack or an HTTP loopback [relay](gateway-relay.md)); 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.
|
||||
|
||||
A registered person is addressed as `<id>@external`, including without Slack: for example,
|
||||
`rig gateway human add alex --display-name "Alex" --delivery-class B` creates `alex@external`, reached in the Feed and Steering.
|
||||
A person can also bind to a local bridge adapter using a `relay` binding:
|
||||
`rig gateway human add bob --display-name "Bob" --delivery-class B --binding "relay:local-bridge:file:/etc/openrig/relay.env:primary:handle=bob-ops"`
|
||||
(see [Gateway Relay Reference](gateway-relay.md) for endpoint, secret provisioning, and custody architecture).
|
||||
`human@host` is the unnamed person at the terminal; `human@kernel` is also accepted as a human/gate address.
|
||||
These aliases (including `human-<name>@host` and `human-<name>@kernel`) are not agent sessions: reply with
|
||||
`rig queue create --destination human@host --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`,
|
||||
substituting the sender's address and your summary, evidence path and reply. A reply destination doesn't identify
|
||||
who acted or determine who may answer an ask. Clients acting as a registered person name that person's
|
||||
`actorSession`; a seat identity header takes precedence. The terminal's primary-user authority records the unnamed
|
||||
`human@host` actor, not the person whose queue is being viewed.
|
||||
|
||||
```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 TUI records their acts as unnamed (`human@host`),
|
||||
whatever person's queue it shows or who is configured as the default.
|
||||
- **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 unnamed terminal
|
||||
actor `human@host` and the person asked in the audit notes and transition. The asking seat reads, for example,
|
||||
"Unattributed (human@host) answered sarah@external's ask".
|
||||
- **Viewing Sarah isn't acting as Sarah.** A registered person may answer, approve, deny or resolve another person's
|
||||
ask as themselves; both the actor and person asked are recorded. No connector binding is required, and viewing
|
||||
someone else's queue doesn't make its viewer that person.
|
||||
- **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:** primary-user authority is accepted only on a request that carries no seat identity
|
||||
header, which is how the TUI calls the daemon. A headerless client can instead name a person's `actorSession` without
|
||||
that authority. Seats and the CLI send the identity header, so their acts stay theirs. This is the existing
|
||||
trusted-network distinction, not an authentication check.
|
||||
|
||||
## 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`.
|
||||
@@ -1,239 +0,0 @@
|
||||
# Gateway Relay: HTTP Loopback Transport Adapter & Custody Reference
|
||||
|
||||
OpenRig's Gateway Relay is a local HTTP loopback transport adapter. It enables registered people to receive outbound notifications and questions (asks, updates, and operator digests) through an operator-owned local bridge process, and to post answers back into OpenRig with sender attribution resolved against registered human bindings.
|
||||
|
||||
OpenRig does not embed third-party messaging platform SDKs (such as WhatsApp, Matrix, or Signal). An external bridge process translates between OpenRig loopback HTTP requests and external messaging platforms.
|
||||
|
||||
```
|
||||
+--------------------+ HTTP Loopback +--------------------+
|
||||
| OpenRig Daemon | POST <OPENRIG_RELAY_URL> ---> | Operator Bridge |
|
||||
| (Gateway Subsys) | <--- POST .../relay/inbound | (Loopback Server) |
|
||||
+--------------------+ +--------------------+
|
||||
|
|
||||
Platform-specific transport
|
||||
v
|
||||
+--------------------+
|
||||
| External Platform |
|
||||
| (Mobile / Client) |
|
||||
+--------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. System Topology & Custody Boundaries
|
||||
|
||||
### Outbound Custody & Recovery
|
||||
- OpenRig enqueues outbound decisions to a local `DispatchBuffer` before issuing any HTTP `POST` to `OPENRIG_RELAY_URL`.
|
||||
- Each logical decision freezes its complete `relayEnvelope` onto the parent `DispatchBuffer` entry before the first network attempt, with no self-imposed outbound 64 KiB cap or multi-part splitting.
|
||||
- Transport is at-least-once: OpenRig sends the complete JSON envelope in a single HTTP `POST` per attempt with `deliveryId = <decisionId>` (preserving the full `body`, `humanDetail`, `humanQuestions`/options, `summary` overflow, and digest `items` without silent clipping).
|
||||
- An HTTP `2xx` response from the bridge acknowledges durable bridge custody of that complete envelope (`deliveryId`). The bridge must persist each envelope by `deliveryId` and deduplicate replayed `deliveryId`s idempotently.
|
||||
- OpenRig records the logical queue acceptance receipt (`relay-owner-notification-accepted` with `deliveryId`) and drains the decision from `DispatchBuffer` once the HTTP `POST` returns `2xx`. Bridge acceptance confirms custody by the bridge process only (and is projected as `accepted` / `"Accepted by bridge"`, whereas Slack posts use `posted` / `"Delivered"`). It does not indicate downstream platform delivery, posting, or human receipt.
|
||||
- Failed dispatches remain in OpenRig's `DispatchBuffer` and record a relay-specific failure transition note (`relay-owner-notification-transport-failed` naming the `connectorRef`, distinct from Slack's `slack-owner-notification-transport-failed`). Replays after a lost ACK or daemon/bridge restart re-send the exact frozen `relayEnvelope` with the same `deliveryId` and bytes. If the relay subsystem stops while an outbound HTTP `POST` is in flight (or before it starts), the transport aborts cleanly (`stopped`) without recording a false failure transition note or false acceptance receipt, retaining the buffered decision for replay on restart. There is currently no periodic background timer redriving outbound dispatches on timeout.
|
||||
|
||||
### Inbound Custody & Resolution
|
||||
- The operator bridge posts replies to `POST /api/gateway/relay/inbound`.
|
||||
- Requests require `Authorization: Bearer <OPENRIG_RELAY_INBOUND_TOKEN>` matching a configured, ready relay profile.
|
||||
- OpenRig verifies that the caller is a direct loopback peer (`127.0.0.1/8` or `::1`) over the underlying TCP socket. Proxy and forwarding headers (`Via`, `X-Forwarded-*`, `Forwarded`, `X-Real-Ip`) are ignored rather than rejected because caller identity is established directly from the loopback socket peer and the bearer token.
|
||||
- Sender attribution resolves by matching `sender.handle` against human fragments registered to the authenticated connector. Harmless extra fields on the top-level inbound JSON object or inside `sender` are ignored and can never override handle-to-person attribution or alter the event fingerprint. The handle is asserted by the trusted bridge; it does not provide independent proof of the physical human on the external platform.
|
||||
- When an inbound reply is accepted (`200 OK`), OpenRig reserves the event in `relay-inbound-seen.jsonl` (`received:<fingerprint>`), durably commits the answer/resolve action and its `mission_control_actions` audit record atomically through its Mission Control write contract (using existing `excludeOperationReply` revision checking without a second answer store or synthetic revision refresh), and marks the event `processed:<fingerprint>`. Exact duplicate replays of the same `eventId` and payload succeed idempotently (`deduplicated: true`).
|
||||
|
||||
### Exclusions & Scope
|
||||
The relay adapter does not handle interactive UI buttons, message attachments, group rooms, unsolicited inbound triggers, multi-bridge failover clustering, or third-party platform SDK backends.
|
||||
|
||||
---
|
||||
|
||||
## 2. Secrets & Environment Configuration
|
||||
|
||||
Each relay connector loads configuration from an absolute file referenced by the binding's `secretsRef` (`file:<absolute-path>`).
|
||||
|
||||
### Security & File Requirements
|
||||
- **Absolute Path Only**: Must begin with `file:/`. Relative paths, bare paths, and `file://` authority URLs are rejected.
|
||||
- **File Permissions**: The file should restrict access to the owner only (no group or other permission bits set, i.e. `(mode & 0o077) === 0`, such as `0600` or `0400`). Owner-only modes (`0600`, `0400`) produce no warning. If group or world permission bits are set (e.g. `0644`), OpenRig logs a redacted warning in the profile's readiness `warnings` and `reason` (matching the Slack secret-file posture) while keeping the profile `ready`. Missing or unreadable secret files remain `not-ready`.
|
||||
- **No Environment Fallback**: OpenRig does not fall back to process environment variables (`process.env`). All variables must come directly from the referenced file.
|
||||
- **Profile Independence**: If a secrets file is missing, unreadable, or missing required keys, only connectors bound to that file report `not-ready`. Other connectors remain functional.
|
||||
|
||||
### Required Secrets Keys
|
||||
|
||||
```ini
|
||||
OPENRIG_RELAY_URL=http://127.0.0.1:8088/webhook
|
||||
OPENRIG_RELAY_OUTBOUND_TOKEN=sec_outbound_token_value
|
||||
OPENRIG_RELAY_INBOUND_TOKEN=sec_inbound_token_value
|
||||
```
|
||||
|
||||
- `OPENRIG_RELAY_URL`: Must use `http:`, loopback host (`127.0.0.1/8`, `::1`, or `localhost`), without URL credentials or hash fragments. Redirects (3xx) are rejected.
|
||||
- `OPENRIG_RELAY_OUTBOUND_TOKEN`: Non-empty Bearer token sent by OpenRig on outbound requests to the bridge.
|
||||
- `OPENRIG_RELAY_INBOUND_TOKEN`: Non-empty Bearer token required on inbound requests from the bridge. Must be unique across different configured relay connector profiles (`connectorRef`); sharing the same inbound token across multiple relay profiles is rejected as ambiguous (`not-ready`). Using the same token value for both `OPENRIG_RELAY_OUTBOUND_TOKEN` and `OPENRIG_RELAY_INBOUND_TOKEN` within a single profile is allowed, with a redacted readiness warning explaining why separate directional tokens are recommended so an outbound capture cannot authenticate inbound replies.
|
||||
|
||||
---
|
||||
|
||||
## 3. Wire Protocol Specifications
|
||||
|
||||
### Outbound Envelope (`POST <OPENRIG_RELAY_URL>`)
|
||||
|
||||
```json
|
||||
{
|
||||
"v": 1,
|
||||
"deliveryId": "dec-019283-abc",
|
||||
"recipient": { "handle": "alice-ops" },
|
||||
"intent": "decision",
|
||||
"outcome": "interrupt",
|
||||
"summary": "Database Migration Approval",
|
||||
"body": "Production migration script 004_add_indexes.sql is ready to execute. Reply YES to approve, NO to abort.",
|
||||
"replyRef": "eyJ2IjoxLCJwYXlsb2FkSGFzaCI6...<base64url>"
|
||||
}
|
||||
```
|
||||
|
||||
- `v`: Protocol version integer (`1`).
|
||||
- `deliveryId`: Stable whole-decision delivery identifier (`<decisionId>`).
|
||||
- `recipient.handle`: Recipient's registered safe alias for this connector.
|
||||
- `intent`: `"decision"` (actionable question) or `"update"` (informational notice).
|
||||
- `outcome`: Delivery classification (`"interrupt"`, `"notify"`, or `"digest"`).
|
||||
- `summary`: Plain text summary preview (up to 1,000 characters on the `summary` field, cut on a Unicode code-point boundary; any summary text exceeding 1,000 characters is preserved in full at the beginning of `body` or `item.body`).
|
||||
- `body`: Complete plain text content (including full `summary` overflow, `body`, `humanDetail`, and rendered `humanQuestions` headers, prompts, and option labels/descriptions) sent in a single complete JSON envelope without silent character clipping or self-imposed outbound byte splitting.
|
||||
- `replyRef`: Fixed-size opaque base64url token (`<= 4,096` characters for any supported ID lengths — `246`–`266` characters in protocol v1, hashing the canonical frozen `RelayReplyRefPayload` and binding `connectorHash`, `mode`, and optional `digest: true`) for actionable decisions; `null` for informational updates (and `null` at the top level of multi-item digests).
|
||||
- `items`: Present when `outcome` is `"digest"`. Array of objects with `qitemId`, `notificationKey`, `summary`, optional `body`, and per-item `replyRef` (or `null`).
|
||||
|
||||
### Inbound Envelope (`POST /api/gateway/relay/inbound`)
|
||||
|
||||
```json
|
||||
{
|
||||
"eventId": "evt-relay-20261107-001",
|
||||
"sender": { "handle": "alice-ops" },
|
||||
"replyRef": "eyJ2IjoxLCJwYXlsb2FkSGFzaCI6...<base64url>",
|
||||
"text": "Approved. Proceed with rollout.",
|
||||
"ordering": "after-question"
|
||||
}
|
||||
```
|
||||
|
||||
- `eventId`: Bridge-assigned identifier (`1..256` chars, matching `/^[A-Za-z0-9._:-]{1,256}$/`).
|
||||
- `sender.handle`: Sender's alias, matching `/^[A-Za-z0-9._-]+$/`. Must match a human fragment bound to the authenticated connector. Harmless extra fields inside `sender` or on the top-level JSON object are ignored and never override `sender.handle` resolution or affect the event fingerprint.
|
||||
- `replyRef`: Exact fixed-size opaque base64url token copied from the outbound envelope (maximum 4,096 characters). Bridges must not parse or alter `replyRef`.
|
||||
- `text`: Plain text response (maximum 32,768 characters, non-empty after trim, no NUL bytes). Processed as unstructured text without automatic natural-language approval parsing.
|
||||
- `ordering`: Optional enum (`"after-question"` | `"uncertain"`, default `"uncertain"`). When `"uncertain"`, text is accepted as recorded context rather than verified sequence relative to the question.
|
||||
|
||||
#### Inbound Statuses & Responses
|
||||
- **200 OK**: Request accepted (`{ ok: true, deduplicated: false, reconciled: false, qitemId, verb, actorSession, actionId }`). Exact duplicate submissions of the same `eventId` return `ok: true` with `deduplicated: true`.
|
||||
- **400 Bad Request**: Malformed JSON, non-object payload, or invalid required field syntax/bounds (`eventId`, `sender.handle`, `replyRef`, `text`, or `ordering`). Harmless extra fields are ignored.
|
||||
- **401 Unauthorized**: Missing, malformed, unrecognized, unready, or cross-profile ambiguous inbound Bearer token, or no humans configured (`noHumans`).
|
||||
- **403 Forbidden**: Non-loopback TCP socket peer, browser cross-origin request, connector mismatch (`replyRef` emitted by another connector), or unregistered `sender.handle`. (`Via` and `X-Forwarded-*` headers are ignored).
|
||||
- **404 Not Found**: Queue item referenced in `replyRef` does not exist.
|
||||
- **409 Conflict**: Stale `decisionRevision` (question cancelled, answered, or re-asked), attempt to answer an informational update, unrecognized `replyRef` when both durable receipts and the pending dispatch buffer are readable, or conflicting payload submitted under an existing `eventId`.
|
||||
- **413 Payload Too Large**: Request body exceeds 64 KiB (65,536 bytes).
|
||||
- **503 Service Unavailable**:
|
||||
- `human_registry_unavailable`: Local human registry (`gateway/humans/*.yaml`) failed to load due to a broken fragment or filesystem read error (normal `noHumans` returns `401`).
|
||||
- `queue_store_unavailable`: Local SQLite queue/transition store read failed while resolving or verifying `replyRef`, returning `503` with zero seen-store reservation or Mission Control action side effects.
|
||||
- `dispatch_buffer_unavailable`: An un-receipted `replyRef` required fallback verification against `<openrig-home>/gateway/dispatch-buffer.json`, and reading the buffer failed (`pendingStrict()`), returning `503` with zero seen-store reservation or Mission Control action side effects. (`replyRef`s already vouched for by durable accepted receipts in `queue_transitions` or `queue_transitions_archive` are verified before consulting the buffer and still succeed.)
|
||||
- `seen_store_unavailable` (response includes `seenPath`): Local seen-ledger (`state/relay-inbound-seen.jsonl`) is unreadable, contains unparseable JSONL or unkeyable records (missing `id`), failed a reservation/processed mark, or has a corrupt/unvouched record for the requested `eventId`'s key. Keyed suspect records (`missing/invalid status` or conflicting recorded fingerprints for the same key; diagnostic `ts` or extra metadata fields are ignored) scope refusal to that `eventId` key only so unrelated `eventId`s continue to operate normally, while never treating unparseable or suspect history as unseen.
|
||||
|
||||
---
|
||||
|
||||
## 4. Identity & Binding Configuration
|
||||
|
||||
Bindings are managed through human fragment files (`gateway/humans/<entityId>.yaml`) and CLI verbs (`rig gateway human`).
|
||||
|
||||
### Binding Syntax
|
||||
```
|
||||
relay:<connectorRef>:file:<absolute-env-file>:<role>[:handle=<alias>]
|
||||
```
|
||||
- `kind`: `relay`.
|
||||
- `connectorRef`: Identifier for the connector instance (e.g. `local-bridge`).
|
||||
- `secretsRef`: Absolute file path reference (`file:/path/to/relay.env`).
|
||||
- `role`: `primary` or `secondary` (at least one primary binding required for outbound dispatch).
|
||||
- `handle`: Optional platform alias (`handle=<alias>`). Required for inbound reply resolution; omitted means outbound-only.
|
||||
|
||||
Handles must match `/^[A-Za-z0-9._-]+$/` and are unique per `connectorRef`. Multiple people can share a connector, each registered with their own handle.
|
||||
|
||||
---
|
||||
|
||||
## 5. Operational Lifecycle
|
||||
|
||||
### Step 1: Provision Secrets File
|
||||
Create an owner-only secrets file (no group/other access bits, e.g. `0600` or `0400`):
|
||||
```bash
|
||||
mkdir -p "$HOME/.config/openrig-relay"
|
||||
touch "$HOME/.config/openrig-relay/relay.env"
|
||||
chmod 600 "$HOME/.config/openrig-relay/relay.env"
|
||||
|
||||
cat << 'EOF' > "$HOME/.config/openrig-relay/relay.env"
|
||||
OPENRIG_RELAY_URL=http://127.0.0.1:8088/webhook
|
||||
OPENRIG_RELAY_OUTBOUND_TOKEN=replace_with_outbound_token
|
||||
OPENRIG_RELAY_INBOUND_TOKEN=replace_with_inbound_token
|
||||
EOF
|
||||
```
|
||||
|
||||
### Step 2: Register Human Binding
|
||||
```bash
|
||||
# Add a person with a primary relay binding
|
||||
rig gateway human add alice --display-name "Alice Smith" --delivery-class B \
|
||||
--binding "relay:local-bridge:file:$HOME/.config/openrig-relay/relay.env:primary:handle=alice-ops"
|
||||
|
||||
# Replace an existing binding slot (requires the full binding spec)
|
||||
rig gateway human set alice binding.0 "relay:local-bridge:file:$HOME/.config/openrig-relay/relay.env:primary:handle=alice-ops"
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> `rig gateway human set alice binding.<n>` requires a full binding specification string. Supplying `unset` is not supported for binding slots. To reassign a participant, overwrite the slot with a full binding string or remove and re-add the human fragment.
|
||||
|
||||
Operator notices use the existing default person. If several people are registered, choose the recipient explicitly when needed:
|
||||
|
||||
```bash
|
||||
rig gateway default alice
|
||||
```
|
||||
|
||||
### Step 3: Activate Daemon
|
||||
When bindings or secrets files change, restart the daemon process:
|
||||
```bash
|
||||
rig daemon stop
|
||||
rig daemon start
|
||||
```
|
||||
*(OpenRig does not provide a `rig daemon restart` command).*
|
||||
|
||||
### Step 4: Verify Readiness & Status
|
||||
```bash
|
||||
# Check delivery readiness for all participants
|
||||
rig gateway human list
|
||||
|
||||
# Inspect detailed readiness status for an individual
|
||||
rig gateway human show alice
|
||||
|
||||
# Check daemon process status
|
||||
rig daemon status
|
||||
```
|
||||
|
||||
In `rig gateway human show <entityId> --json`, `deliveryReadiness.state` reports `ready` when the binding configuration and local secrets file are valid. This confirms local readiness, not whether the operator bridge process is currently running.
|
||||
|
||||
### Step 5: Removing Bindings or Participants
|
||||
Before removing a participant, ensure any pending queue items and conversations assigned to them are resolved.
|
||||
```bash
|
||||
rig gateway human remove alice
|
||||
```
|
||||
*(The remove verb refuses to delete a participant if open conversations or non-terminal items exist, unless `--force` is specified).*
|
||||
|
||||
---
|
||||
|
||||
## 6. Troubleshooting Common Conditions
|
||||
|
||||
| Condition | Observation | Resolution |
|
||||
| :--- | :--- | :--- |
|
||||
| **Missing Secrets File** | `deliveryReadiness.state: not-ready`<br>`reason: relay secret file ... is missing or unreadable` | Verify file path in `secretsRef` is absolute and readable. |
|
||||
| **Insecure Permissions** | `deliveryReadiness.state: ready` (with warning)<br>`reason: ... warning: ... insecure permissions (mode 0644; group/other access bits are set ...)` | Restrict permissions so group/other have no access (`chmod 600 <secrets-file>` or `chmod 400 <secrets-file>`). |
|
||||
| **Identical Directional Tokens** | `deliveryReadiness.state: ready` (with warning)<br>`reason: ... warning: ... uses the same token for OPENRIG_RELAY_OUTBOUND_TOKEN and OPENRIG_RELAY_INBOUND_TOKEN; separate tokens are recommended ...` | Provision distinct outbound and inbound tokens so outbound capture cannot authenticate inbound replies. |
|
||||
| **Invalid Loopback URL** | `deliveryReadiness.state: not-ready`<br>`reason: ... OPENRIG_RELAY_URL host must be loopback ...` | Ensure URL uses `http://` and host is `127.0.0.1/8`, `::1`, or `localhost`. |
|
||||
| **Reused Inbound Token Across Profiles** | `deliveryReadiness.state: not-ready`<br>`reason: ... reuses an ambiguous OPENRIG_RELAY_INBOUND_TOKEN shared with relay profile(s): ...` | Each distinct relay connector profile (`connectorRef`) must declare a unique inbound token. |
|
||||
| **Stale Question Reply** | HTTP 409 on inbound endpoint | Question was already answered, cancelled, or superseded by a newer revision. |
|
||||
| **Unregistered Handle** | HTTP 403 on inbound endpoint | The `sender.handle` is not bound to the authenticated `connectorRef`. |
|
||||
| **Non-Loopback Inbound** | HTTP 403 on inbound endpoint | Connection originated from a non-loopback TCP socket peer or included a cross-origin browser header. (`Via` and `X-Forwarded-*` headers are ignored). |
|
||||
| **Registry or Buffer Read Failure** | HTTP 503 on inbound endpoint (`human_registry_unavailable` or `dispatch_buffer_unavailable`) | Inspect local `gateway/humans/*.yaml` fragments or `<openrig-home>/gateway/dispatch-buffer.json` for syntax/permission corruption before retrying the bridge event. |
|
||||
| **Seen-Ledger Corrupt / Suspect** | HTTP 503 on inbound endpoint (`seen_store_unavailable`, includes `seenPath`) | Unreadable/unparseable/unkeyable `relay-inbound-seen.jsonl` refuses all inbound requests; a keyed suspect entry refuses only that specific `eventId`. Follow the conservative recovery procedure below. |
|
||||
|
||||
### Conservative Seen-Ledger Recovery (`seen_store_unavailable`)
|
||||
|
||||
When `POST /api/gateway/relay/inbound` returns `503` with `error: "seen_store_unavailable"`, the response body and message include the resolved `seenPath` (`<openrig-home>/state/relay-inbound-seen.jsonl`):
|
||||
|
||||
1. **Quiesce writers first**: Pause the loopback bridge and stop the daemon (`rig daemon stop`) so no concurrent inbound request or retry appends to the ledger during inspection.
|
||||
2. **Preserve a full backup**: Copy `<seenPath>` to a timestamped backup outside `state/` before making any edit.
|
||||
3. **Correlate against durable evidence**: Each ledger entry keys `id` by `eventKey = SHA256(JSON.stringify([connectorRef, eventId]))` (64-character lowercase hex digest) and stores `status` as `received:<fingerprint>` or `processed:<fingerprint>`. Because `eventKey` is a one-way hash, correlate any damaged line or keyed suspect `id` against `mission_control_actions.audit_notes_json` (which records `operationId: "relay:<eventKey>:<fingerprint>"`, `connectorRef`, and `eventId`) and bridge delivery logs by computing `SHA256(JSON.stringify([connectorRef, eventId]))` for candidate events—never by guessing or assuming the hash is reversible.
|
||||
4. **Apply only evidence-verified, history-preserving repair**: Repair a damaged record only when its exact `id` (`<eventKey>`) and `status` (`received:<fingerprint>` or `processed:<fingerprint>`) are verified from `mission_control_actions.audit_notes_json` or bridge evidence, or restore only from a backup that preserves every subsequently acknowledged event. Do **not** delete, truncate, or skip unknown/unparseable records, and do **not** restore an older backup that omits acknowledged events; if an entry cannot be verified without losing history, leave the ledger in place (continuing to fail closed with `503`) and retain the backup.
|
||||
5. **Resume and retry**: After a verified, history-preserving repair is saved at `<seenPath>`, start the daemon (`rig daemon start`), resume the loopback bridge, and retry the original inbound delivery with the same `eventId`.
|
||||
|
||||
For automated loopback bridge test reference, see `packages/daemon/test/fixtures/recording-relay.ts`.
|
||||
@@ -39,12 +39,6 @@ The `rigs` skill is the procedure: follow it in order. This page is what it look
|
||||
|
||||
## 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
|
||||
@@ -52,11 +46,9 @@ together in that home; it needs its own working sign-in and doesn't relocate `~/
|
||||
> 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.
|
||||
> - 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. `rig down` takes the hooks
|
||||
> and status line back out once no Claude seat uses that directory.
|
||||
> `.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
|
||||
@@ -76,17 +68,8 @@ 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.
|
||||
|
||||
For a tarball or manual Node install on Linux, put its bin directory on PATH in
|
||||
`~/.profile`, for example `export PATH="/path/to/node/bin:$PATH"`, rather than only
|
||||
in an interactive `~/.bashrc`. Bash reads the first of `~/.bash_profile`,
|
||||
`~/.bash_login` and `~/.profile`; if one of the first two exists, source `~/.profile`
|
||||
there. Other shells use their own login file (for example, zsh's `~/.zprofile`).
|
||||
Check `bash -lc 'command -v node rig'`: both paths must appear in the output.
|
||||
Use Codex's tool shell for this check if it differs from Bash.
|
||||
|
||||
On a Mac with Apple silicon, use Node.js 22 (see the [compatibility
|
||||
[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
|
||||
@@ -201,17 +184,16 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
|
||||
script will do and changes nothing:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.0/scripts/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
|
||||
The same command without `--dry-run`,
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh`, installs the
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.0/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
|
||||
`FAILED [n/4] <command or check> (exit <code>)` for steps 1–3. If step 4 is not ready, it reports
|
||||
`INSTALLED, SETUP NOT COMPLETE` and points to the details from `rig setup` above. Follow that output and any Fix, then rerun
|
||||
`rig setup`; the CLI does not need reinstalling. If the only remaining failures are provider
|
||||
`FAILED [n/4] <command or check> (exit <code>)`. Where a provider isn't signed in yet, step 4 ends that way and
|
||||
`rig setup` lists each 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, then continue at
|
||||
[Start the kernel](#start-the-kernel-and-check-its-state). To install only what the selected providers need, go step
|
||||
by step instead:
|
||||
@@ -270,23 +252,9 @@ 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.
|
||||
|
||||
Managed Codex seats run tool commands in a non-login shell: OpenRig passes Codex
|
||||
`-c allow_login_shell=false`. Tool commands inherit the environment their pane's shell set up,
|
||||
OpenRig's command folder included, so plain `rig` works however OpenRig was installed.
|
||||
Inherited setup isn't re-run for each command. Setup that lives only in login or rc files, such as
|
||||
lines in `~/.bashrc` that your login file doesn't source, or a tmux `default-command` that doesn't
|
||||
start a login shell, may be missing from tool commands, and so may shell functions and aliases.
|
||||
To defer to Codex's own setting instead, run `rig config set launch.codex_login_shell true`. That
|
||||
omits OpenRig's override rather than forcing login shells, and a login file that sets `PATH`
|
||||
outright can then hide `rig`. It applies at each seat's next launch; a restart in place keeps
|
||||
the running choice.
|
||||
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
|
||||
@@ -312,17 +280,6 @@ fine; if both accounts are authenticated, the kernel uses both even when your
|
||||
later project uses just one. A request to use only one provider for everything
|
||||
is a separate choice.
|
||||
|
||||
`/api/kernel/status` retains the chosen template (`rig.yaml`,
|
||||
`rig-claude-only.yaml` or `rig-codex-only.yaml`) across daemon restarts and
|
||||
recovery, including starts with `--no-kernel`. It reads the rig's latest rig-spec
|
||||
bootstrap record, including a partial launch, so an older kernel's recorded
|
||||
selection is also available. This is the original selection; the rig's live spec
|
||||
describes its current configuration. The saved source must match a template under
|
||||
the current installation’s built-in specs directory. A custom project path with
|
||||
the same directory suffix, an old source path that can no longer be verified
|
||||
(for example, after a Node version change moves the global install), or a kernel
|
||||
without bootstrap history reports an unknown variant.
|
||||
|
||||
**Started is not ready.** Read the `Kernel:` boot state in `rig status` and the
|
||||
individual seat state in `rig ps --nodes --rig kernel`. The view may open while
|
||||
agents finish starting; describe that state, without claiming they are ready.
|
||||
@@ -364,16 +321,6 @@ 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
|
||||
@@ -437,14 +384,13 @@ 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
|
||||
carries the Herdr 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
|
||||
(normally the one that pressed the key) to the command, which then opens the view in that Herdr. 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
|
||||
view opens in the Herdr 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 isn't in Herdr 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.
|
||||
|
||||
@@ -515,7 +461,7 @@ different points in the same journey:
|
||||
| What you see | Why and what to do next |
|
||||
| --- | --- |
|
||||
| Codex offers to switch to a cheaper model near a rate limit | This is Codex's own menu. Ask the person to choose in the seat's terminal; do not silently change the team's configured model. OpenRig disables Codex's startup update check for managed launches, but does not answer this model-choice menu. |
|
||||
| A missing Node.js, npm or tmux; an install download or command fails | The named prerequisite or install step did not finish. Read its error and the [platform and provider instructions](#install-and-sign-in), resolve that step, then recheck it. `INSTALLED, SETUP NOT COMPLETE` means the CLI is installed but setup needs attention; follow its output and any Fix, then rerun `rig setup`. |
|
||||
| A missing Node.js, npm or tmux; an install download or command fails | The named prerequisite or install step did not finish. Read its error and the [platform and provider instructions](#install-and-sign-in), resolve that step, then recheck it. `FAILED [4/4]` with only selected-provider sign-ins outstanding means the package installed but those logins still need the person. |
|
||||
| The installing harness refuses a tool call or asks for permission | Its native rules, sandbox or automatic permission decision apply before OpenRig can run. Claude Code's [auto mode can deny calls](https://code.claude.com/docs/en/auto-mode-config) as well as approve them. Show the specific refusal and ask the person to review it in the harness permission controls or run that named step themselves. Keep their chosen restrictions; a refused call does not prove the OpenRig command failed. |
|
||||
| Herdr or Ghostty installation is unavailable or declined | Herdr is optional for the view: `--window` uses plain tmux when Herdr is absent, and its install failure is a warning. On macOS, Terminal.app is the fallback when supported Ghostty is absent. An explicitly requested Ghostty install can fail setup; resolve that install or decline it with `--no-ghostty`, preserving earlier choices. Address separate required-tool or login failures. Existing cmux remains supported. |
|
||||
| macOS asks for Automation access, or the window action is denied | The command asks the desktop terminal app to open a new surface. Let the person answer the system prompt. Read the error and inspect any new window before retrying an uncertain action; do not silently switch apps and open a duplicate. |
|
||||
@@ -542,12 +488,7 @@ rig terminal open saved:kernel --provider herdr --json
|
||||
rig terminal open saved:kernel --provider cmux --json
|
||||
```
|
||||
|
||||
Use `rig terminal status --json` to inspect provider availability, liveness and the
|
||||
daemon's effective tmux server. To keep managed seats out of your personal tmux
|
||||
session list, opt into [an isolated server](tmux-servers.md) with
|
||||
`rig config set terminal.tmux_server isolated`; existing live seats defer the
|
||||
switch until they are stopped and the daemon restarts.
|
||||
|
||||
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
|
||||
@@ -742,15 +683,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.
|
||||
@@ -800,56 +735,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.
|
||||
@@ -882,58 +767,21 @@ 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. |
|
||||
| A restored seat still counts as needing attention after its prompt was answered | Its restore outcome is still `attention_required`, and answering the prompt or running `rig seat continue` doesn't change it. Once the agent is up in its pane, run `rig seat clear-attention <seat>`; see [After a restored seat's prompt](agent-startup-guide.md#after-a-restored-seats-prompt). |
|
||||
| "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 | Check the kernel's `operator.human` terminal separately from its agents; recover only that seat as described below. Standalone `rig tui` remains available while resolving it. |
|
||||
| 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 | Starting the daemon restores the kernel by itself when all of its seats were lost; 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. |
|
||||
| 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 shared kernel dashboard is missing.** The dashboard runs in the
|
||||
kernel's `operator.human` terminal seat. Restoring the operator, advisor and
|
||||
queue worker does not also restore an unselected terminal. Read
|
||||
`rig ps --nodes --rig kernel --json` and use the terminal's registered
|
||||
`canonicalSessionName` for the `operator.human` row. Replace `<dashboard-session>`
|
||||
in the commands below with that name. Check the actual pane with
|
||||
`rig capture "<dashboard-session>"`: a persisted running row alone does not prove
|
||||
that its tmux session is alive.
|
||||
A daemon or transport error is not proof that the terminal is absent.
|
||||
|
||||
- If only the viewing window closed and the TUI is still running, reopen it
|
||||
with `rig terminal open saved:kernel --window`.
|
||||
- If the TUI exited but its terminal shell remains, run `rig tui` once in that
|
||||
existing pane. A fresh seat is unnecessary.
|
||||
- If the terminal session itself is absent, start only that existing seat:
|
||||
|
||||
```sh
|
||||
rig seat launch "<dashboard-session>" --fresh --reason "recover missing shared kernel dashboard"
|
||||
```
|
||||
|
||||
The terminal has no agent conversation to resume. Its fresh-start action
|
||||
runs `rig tui` after the shell is ready; sibling agent sessions stay
|
||||
unchanged. This command intentionally omits `--stop`, so it refuses to
|
||||
replace a live occupant. Check the result before repeating it, then run
|
||||
`rig capture "<dashboard-session>"` to confirm the TUI and
|
||||
`rig ps --nodes --rig kernel --full` to check the seats. Once the dashboard
|
||||
is running, open its view with `rig terminal open saved:kernel --window`.
|
||||
|
||||
Opening `saved:kernel` places a view of existing sessions; it cannot create a
|
||||
missing terminal. Whole-kernel recovery is for the all-seats-lost case in the
|
||||
table, and `rig up kernel --existing` refuses a kernel with live sessions.
|
||||
A retained `excluded_from_subset` reason may come from an earlier subset
|
||||
restore; inspect its event before attributing it to the latest reboot.
|
||||
|
||||
**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
|
||||
@@ -1162,15 +1010,8 @@ rig up ./openrig-specs/rigs/launch/starter/rig.yaml --cwd .
|
||||
|
||||
OpenRig's `permission_policy: builtin:yolo` setting selects
|
||||
`-s danger-full-access -a never` on fresh, resume and fork launches, replacing
|
||||
the named-profile argument. That drops the whole profile, not only its sandbox
|
||||
and approval settings. To keep a named profile with full access, put full access
|
||||
in the profile itself and leave `permission_policy` absent, as in the recipe
|
||||
above. To keep it with a workspace-write or read-only posture that OpenRig
|
||||
records, use the member's `codex_permissions` (see
|
||||
[per-member Codex permissions](rig-spec.md#per-member-codex-permissions)):
|
||||
OpenRig passes `-p` and adds that envelope's flags, and for that seat the
|
||||
envelope replaces a policy posture such as `builtin:yolo`. Full access isn't an
|
||||
envelope. The legacy
|
||||
the named-profile argument. The profile recipe above remains useful when you
|
||||
want to maintain those choices in native configuration. The legacy
|
||||
environment-only `OPENRIG_YOLO=1` path remains sandbox-only when no resolved
|
||||
policy is present. A standalone `codex --yolo` command is not an OpenRig setting.
|
||||
At full access, Codex can show its full-access and GPT-5.1 migration notices at
|
||||
@@ -1217,10 +1058,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
|
||||
@@ -1338,31 +1175,6 @@ 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`.
|
||||
|
||||
@@ -1,468 +0,0 @@
|
||||
# Harness and runner contract (DRAFT, published with 0.7.6)
|
||||
|
||||
> **Status.** This is a draft for contributors.
|
||||
> - **Released in 0.7.6:** the `runner` field and OpenRig's side of declaration, identity and activity
|
||||
> (sections 2-4).
|
||||
> - **Released in 0.7.8:** seat activity grants (section 4), and OpenRig's side of the answered permission path
|
||||
> (section 7).
|
||||
> - **Runner-author behavior, not yet qualified:** the input, cancellation and permission recipe in sections 5-7, and
|
||||
> the runner-side shutdown obligation in section 9. Two runners have shown parts of it natively (below); no
|
||||
> runner is qualified beyond that.
|
||||
> - **Existing interfaces with author obligations:** launch-result identity, exact resume, first input and model
|
||||
> handoff. These interfaces do not establish that a particular runner implements them correctly.
|
||||
> - **Unchanged:** plain community presets and the built-in runtimes (Claude Code, Codex, Pi, OMP, terminal).
|
||||
>
|
||||
> **What has run natively, in 0.7.8's release testing, against a Claude Haiku model:**
|
||||
> - **dsh-seat 0.3.0**, an outside runner for dsh 0.2.1-rc.2 over ACP: launch and first input, useful queue work, a
|
||||
> multiline message with a `/abort` line inside it delivered as one prompt, activity that stayed working through a
|
||||
> 52-second quiet tool interval and then read idle, cancel by a bare `/abort` typed in the pane, and the same
|
||||
> conversation resumed by `session/resume` after `rig down --snapshot` and `rig up`, with the runner and its child
|
||||
> gone after the managed down.
|
||||
> - **The dsh example runner** (`community-harness-examples/dsh/`): dsh's native `session/request_permission` reached
|
||||
> a person, whose allow-once took effect and whose reject-once didn't.
|
||||
>
|
||||
> **Not shown natively:** a DeepSeek model, the example runner's own resume (its preset declares none), the default
|
||||
> permit when nobody can be asked (checked without a model only), a typed or Slack answer, a second person's
|
||||
> answer, reboot recovery, and the model OpenRig observes. Any other runner, provider or path is unqualified until
|
||||
> it's shown.
|
||||
>
|
||||
> **Since 0.7.8, on one stack:** the dsh example runner has run with a real dsh 0.2.1-rc.2 on dsh's pi-ai OpenRouter
|
||||
> chat-completions profile with `deepseek/deepseek-v4-flash`: queue work, activity, an allow-once and a reject-once
|
||||
> answered by an automated test operator, cancel, and an exact return with resume added to a copy of the preset. A
|
||||
> missing conversation's refusal hasn't been run, and the preset still declares no resume.
|
||||
>
|
||||
> Release status and pinned references are in the [joined author guide](author-contracts.md). The
|
||||
> [community presets reference](community-harness-presets.md) describes the implemented fields, including the runner
|
||||
> preset's exception for approvals; this chapter also labels runner-author behavior that isn't qualified.
|
||||
|
||||
This chapter covers **which agent runs in a seat and how OpenRig talks to it**. *Where* that agent runs (a host, a
|
||||
sandbox, another machine) is the [execution platform chapter](execution-platform-contract.md). The two are separate on purpose:
|
||||
the same harness can run in many places, and one place can host many harnesses.
|
||||
|
||||
## 1. Plain preset or runner preset?
|
||||
|
||||
| | Plain community preset | Runner preset (`runner: true`) |
|
||||
|---|---|---|
|
||||
| **What runs in the pane** | The harness's own terminal app | A small program you maintain, *the runner*, which hosts the agent (for example an ACP agent over stdio) |
|
||||
| **How OpenRig reads activity** | From the screen: a changing screen reads working; a quiet one reads **unknown**, never idle | From the runner's reports: working, idle, needs input |
|
||||
| **How input arrives** | Typed into the app's prompt | Typed into the runner, which turns each submitted message into one agent prompt |
|
||||
| **Choose it when** | The harness has a usable terminal UI (Cursor, Hermes, OpenClaw examples) | The harness speaks a protocol rather than a screen (ACP, JSON-RPC, stream-JSON), or you can report turn boundaries reliably |
|
||||
|
||||
**If your harness works well as a terminal app, a plain preset is enough. Use a runner only when you can report state
|
||||
honestly.** A runner that guesses is worse than a quiet screen read as unknown.
|
||||
|
||||
**Vocabulary.** "Preset", "runner preset", `launch_result`, `resume`, `first_input` and `passes_model` are existing
|
||||
terms in 0.7.6. Broader names you may see in design discussion, such as *HarnessSpec*, are **proposals**, not file
|
||||
formats or loaders that exist.
|
||||
|
||||
## 2. Declare it: what the preset says, fixed at launch
|
||||
|
||||
```yaml
|
||||
# $OPENRIG_HOME/community-harnesses/dsh.yaml
|
||||
format: 1
|
||||
name: dsh
|
||||
command: dsh-seat # your runner; it starts the agent itself
|
||||
maintainer: <you>
|
||||
runner: true # since 0.7.6
|
||||
launch_result: true # required with runner
|
||||
resume: true # only after you've checked a real reopen and a refused missing one
|
||||
limits:
|
||||
- permission requests go to a person through OpenRig (section 7), and are permitted when no one can be asked
|
||||
- a prompt whose whole text is /abort can't be sent
|
||||
- fork unsupported
|
||||
```
|
||||
|
||||
- **`runner: true` requires `launch_result: true`,** and can't be combined with `names_conversation`.
|
||||
- **The declaration is fixed at launch.** Each launch, a restore's included, records what the preset declares at that
|
||||
moment. Editing the preset changes the next launch, not a running seat.
|
||||
- **A preset without `runner` behaves exactly as today,** and so does a session recorded before the field existed.
|
||||
- **The other fields keep their existing meanings:**
|
||||
- `first_input: true` passes the seat's first startup turn in a file named by `OPENRIG_FIRST_INPUT`;
|
||||
- `passes_model: true` passes the seat's declared model as `OPENRIG_MODEL`.
|
||||
|
||||
## 3. Identity: report the conversation once, in the launch record
|
||||
|
||||
- **When:** after the agent has opened or reopened its conversation (for ACP: after `session/new`, `session/load` or
|
||||
`session/resume`).
|
||||
- **How:** write the attempt's launch record to the path in `OPENRIG_LAUNCH_RESULT`, with the attempt from
|
||||
`OPENRIG_LAUNCH_ATTEMPT`. The record is either the conversation:
|
||||
|
||||
```json
|
||||
{"format": 1, "attempt": "<OPENRIG_LAUNCH_ATTEMPT>", "harness": "dsh", "nativeRef": "<conversation id>", "producer": "dsh-seat 0.3.0; dsh 0.2.1-rc.2; ACP 1"}
|
||||
```
|
||||
|
||||
or the agent's own word that it doesn't exist:
|
||||
|
||||
```json
|
||||
{"format": 1, "attempt": "<OPENRIG_LAUNCH_ATTEMPT>", "harness": "dsh", "error": "missing", "producer": "dsh-seat 0.3.0"}
|
||||
```
|
||||
|
||||
- **`producer` is free text** for your versions. There are no structured version or capability fields.
|
||||
- **The launch record is the only identity channel.** Don't report identity on the activity endpoint.
|
||||
- **No record means attention.** For a runner preset:
|
||||
- **a new launch** with no usable record within OpenRig's wait leaves the seat needing attention, not ready. So does a
|
||||
new launch where OpenRig can't create the record's folder: the runner still starts, without `OPENRIG_LAUNCH_RESULT`;
|
||||
- **on a resume,** a folder OpenRig can't create means nothing starts, because the launcher can't be told what to
|
||||
reopen. When the folder exists but no usable record arrives, the resume command has already been sent, and the
|
||||
resume reads unconfirmed (section 8).
|
||||
- **OpenRig shows this identity as launcher-reported.** It doesn't observe the conversation itself.
|
||||
|
||||
## 4. Activity: report turn boundaries
|
||||
|
||||
Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer <token>`.
|
||||
- **Finding the base and token:** take them from `OPENRIG_URL` (or `OPENRIG_PORT`, with `OPENRIG_HOST` defaulting to
|
||||
`127.0.0.1`) and `OPENRIG_ACTIVITY_HOOK_TOKEN`, or else from `$OPENRIG_HOME/activity-endpoint.json`.
|
||||
- **Posting:** post one at a time, in order, with a short timeout. Never make the agent wait on a post.
|
||||
|
||||
```json
|
||||
{"sessionName": "dev-builder@my-rig", "nodeId": "<node id>", "runtime": "dsh",
|
||||
"generation": "<OPENRIG_OCCUPANT_GENERATION>", "sequence": 42, "occurredAt": "2026-10-29T12:00:00Z",
|
||||
"hookEvent": "UserPromptSubmit", "subtype": "accepted", "turnId": "7"}
|
||||
```
|
||||
|
||||
| When | `hookEvent` / `subtype` | The seat reads |
|
||||
|---|---|---|
|
||||
| You gave the agent the prompt for a submitted message | `UserPromptSubmit` / `accepted`, `turnId` = your prompt number | working |
|
||||
| The agent is working (message chunks, tool calls) | `active` / `agent_message`; `PreToolUse` / `<tool kind>` | working |
|
||||
| Optional working beat, from 0.7.9: the turn is still outstanding and the agent alive, with nothing new to report (a long tool or model call) | `active` / `liveness`, with `turnId`, `sequence` and `livenessSeconds` | working while beats keep coming |
|
||||
| From 0.7.9: you delivered an answer or permit to a request OpenRig had shown as needing input, and the agent continues the same turn (section 7) | `active` / `permission_resolved`, with `turnId` and `askId` | working |
|
||||
| You refused a permission request (section 7) | `Notification` / `permission_prompt` | needs input |
|
||||
| The turn ended | `Stop` / its stop reason (`end_turn`, `cancelled`, `max_tokens`, `refusal`) | idle. While you hold attention (section 7), don't post it: OpenRig reads any non-exit `Stop` as idle and clears needs input |
|
||||
| Optional idle heartbeat | `Stop` / `idle_heartbeat`, with `livenessSeconds` | idle while beats keep coming |
|
||||
| The agent process ended | `Stop` / `<preset name>_exited` | unknown, **never idle** |
|
||||
| Your runner or the agent hit an error | `Notification` / `runtime_error` | recorded, but not used as activity: the prior activity evidence keeps its usual lifetime. It doesn't itself request or clear attention |
|
||||
|
||||
- **Liveness:** state `livenessSeconds` (a whole number of seconds from 1 to 3600; any other value is ignored) on every
|
||||
idle `Stop`, the turn's end as well as each beat. OpenRig stops reading the seat idle after 2 × `livenessSeconds` without
|
||||
a newer idle report. Without it, an idle report holds until your next post, even if your runner has died. Never send a
|
||||
non-exit `Stop` or a beat while holding permission attention.
|
||||
- **Working evidence lasts 15 seconds.** A working post (`UserPromptSubmit`, `active`, `PreToolUse`) decides the seat's
|
||||
activity for 15 s. After that the seat reads from its other evidence: the screen can still show work, but it never
|
||||
reads a runner seat idle, so with nothing else the seat reads unknown.
|
||||
- **The working beat (from 0.7.9)** keeps a long, quiet turn reading working. It says only that this turn's prompt is
|
||||
outstanding and the agent process you started is alive. It isn't progress.
|
||||
- **When to beat:** only while a prompt is outstanding and the agent is alive, more often than every
|
||||
`livenessSeconds` ÷ 2.
|
||||
- **When to stop:** stop at once on the turn's `Stop`, on a permission request that needs a person, or on the agent's
|
||||
exit, and post that boundary first.
|
||||
- **`livenessSeconds`** is how long one beat counts as working. OpenRig clamps it to 5-60 s, and uses 30 s when a beat
|
||||
has none. After that the beat stops counting, and the seat reads from its other evidence, as a working post's does.
|
||||
- **A beat counts only when all of these hold:**
|
||||
- it carries `generation`, equal to the seat's live occupant generation, and so did the `UserPromptSubmit` /
|
||||
`accepted` post that opened its turn;
|
||||
- its `turnId` is your latest `UserPromptSubmit` / `accepted` turn, and you haven't posted that turn's `Stop` or an
|
||||
exit;
|
||||
- it carries a `sequence`, greater than every earlier post's in the same generation that OpenRig counted, beats
|
||||
included;
|
||||
- the seat has no attention outstanding, such as a permission request waiting on a person.
|
||||
|
||||
So number every post with one `sequence` that rises across your whole runner process, beats included. OpenRig
|
||||
orders your posts by `occurredAt`, then `sequence`. A post older than one OpenRig already counted changes nothing:
|
||||
a late `Stop` for an earlier turn doesn't end the current one, and an older beat doesn't count. A post without a
|
||||
valid `occurredAt` is dated when it arrives, by OpenRig's clock, so it can read older than it is when your clock
|
||||
runs ahead. Give every post its `occurredAt`.
|
||||
- **After you deliver an answer or permit** to a request OpenRig had shown as needing input (section 7; it created an
|
||||
ask, and a permit at submit opens none), post `active` / `permission_resolved` with the turn's `turnId` and the
|
||||
`askId`, before your next beat. That clears the seat's attention. So does any other working or idle post OpenRig
|
||||
counts, and an operator's `rig seat clear-attention`. OpenRig doesn't match `permission_resolved` to its `askId`.
|
||||
Beats are ignored until attention clears.
|
||||
- **A beat that fails any of these is ignored.** The seat reads as it would without it. A beat never starts or extends
|
||||
a turn, never clears attention, and never stands in for a `Stop`.
|
||||
- **OpenRig keeps what it needs to judge beats in memory.** After a daemon restart, a beat already counted keeps its
|
||||
lifetime, but new beats are ignored until your next accepted prompt, and the seat reads as it would without them.
|
||||
- **A runner that sends no beats** behaves as before.
|
||||
- **Exit:** post `Stop` / `<preset name>_exited` only when you observe the agent's process exit.
|
||||
|
||||
- **The screen still counts,** but only as evidence of work. On its own it never reads a runner seat idle.
|
||||
- **"Accepted" means your runner submitted its Nth prompt.** It isn't the agent's own acknowledgement, and it isn't
|
||||
tied to a particular `rig send`.
|
||||
- **The generation attributes a post; it isn't authentication.**
|
||||
- A post carrying an earlier occupant's generation never counts for the live seat.
|
||||
- The hook token is one local token, so any process holding it can post for any seat.
|
||||
- **A seat activity grant** is what a runner in another environment, such as a sandbox guest, receives instead of the
|
||||
local token. It arrives in the same `OPENRIG_ACTIVITY_HOOK_TOKEN`, with an `OPENRIG_URL` the guest can reach, and it
|
||||
speaks only for its own seat and launch generation:
|
||||
- with a grant, the generation is authenticated, and you may leave out `nodeId` and `generation`;
|
||||
- a post for another seat is refused with `403 activity_grant_wrong_seat`;
|
||||
- a post claiming another generation, or from an occupant that has since been replaced, is refused with
|
||||
`409 activity_grant_stale`;
|
||||
- a post before the launch has registered its occupant is refused with `409 activity_grant_pending`;
|
||||
- a post with a revoked grant is refused with `401 activity_grant_revoked`, including a post whose grant was revoked
|
||||
while its body was arriving.
|
||||
|
||||
A refused post records nothing, so the seat's activity stays as it was, or unknown.
|
||||
|
||||
**The guest's boundary is its listener, not the grant.** A guest reaches OpenRig only through a guest-only listener that
|
||||
serves this route and section 7's three permission-request routes, and accepts only a grant. It must never reach the
|
||||
daemon's main port. The grant scopes activity posts and permission requests, and the main port's other routes don't
|
||||
check it.
|
||||
- The listener is off by default. Turn it on with `OPENRIG_GUEST_ACTIVITY_PORT`, which must differ from the daemon's
|
||||
main port or the daemon refuses to start.
|
||||
- It binds `OPENRIG_GUEST_ACTIVITY_HOST`, default `127.0.0.1`: set it to an address the guest can reach.
|
||||
|
||||
## 5. Input: one submitted message is one prompt
|
||||
|
||||
Sections 5-7 specify the first dsh/ACP runner recipe; no runner has been qualified against it yet. They do not change
|
||||
permission or input handling in existing built-in runtimes or require every possible runner protocol to use ACP.
|
||||
|
||||
- **Set up the pane:** put your TTY in raw mode and turn on bracketed paste (`\e[?2004h`).
|
||||
- **What arrives:** `rig send` and queue reminders paste the whole message, then press Enter. Everything up to that Enter
|
||||
is **one block**, newlines included. Send it to the agent as **one** prompt (for ACP, one `session/prompt`).
|
||||
- **Never split a block,** and never act on lines inside it.
|
||||
- **Control input:** the only control is a block that is exactly `/abort`, or Ctrl-C typed outside a paste.
|
||||
- Ordinary sends and reminders arrive inside a From/To envelope, so they never equal `/abort`, whatever lines they
|
||||
contain.
|
||||
- **A message that arrives mid-turn** waits for the next turn. Report it as queued, never as steering the current turn.
|
||||
- **Limits to keep and to list under `limits`:**
|
||||
- a prompt whose whole text is `/abort` can't be sent as a prompt;
|
||||
- a Ctrl-C byte inside a paste also aborts;
|
||||
- without a TTY, input is read line by line;
|
||||
- this isn't a binary-safe channel.
|
||||
|
||||
## 6. Cancel: requested isn't the same as done
|
||||
|
||||
- **On `/abort` or Ctrl-C:** send the agent's cancel (for ACP, the `session/cancel` notification, which has no reply),
|
||||
and record when you sent it.
|
||||
- A person cancels by typing `/abort` in the seat's pane. A `/abort` sent with `rig send` arrives inside a From/To
|
||||
envelope, so it's an ordinary prompt.
|
||||
- **Report "cancelled" only when the agent says so:** for ACP, when `session/prompt` returns `stopReason: "cancelled"`.
|
||||
Then post `Stop` / `cancelled`.
|
||||
- **Whether the provider stopped work or spend is unknown.** Don't claim it.
|
||||
|
||||
## 7. Permissions: a person answers, and the default is permit
|
||||
|
||||
### The first version: refuse every request (history; still what `--deny` does)
|
||||
|
||||
- **Refuse at once:** answer every permission request immediately with a refusal (for ACP,
|
||||
`{"outcome": {"outcome": "cancelled"}}`).
|
||||
- **Make it visible:** show what was asked in the pane, and post `Notification` / `permission_prompt`.
|
||||
- **Hold that attention** as your last post through the end of the turn, so the seat reads needs input. Don't post the
|
||||
turn's `Stop` or idle beats while holding it.
|
||||
- **It clears** when your next prompt is accepted, or when an operator clears it
|
||||
(`rig seat clear-attention <session> --reason "…"`).
|
||||
- **Never leave a request pending.**
|
||||
- **What it couldn't do:**
|
||||
- answer from the seat's permission setting;
|
||||
- take answers typed into the pane;
|
||||
- route an approval to another seat.
|
||||
|
||||
So a job that needed an edit or a command approved couldn't finish. The answered path below, added in 0.7.8,
|
||||
replaces this as the default.
|
||||
- **Unknown requests from the agent:** answer with JSON-RPC `-32601` and carry on.
|
||||
|
||||
### The answered path (0.7.8)
|
||||
|
||||
**Only requests the agent actually makes reach anyone.** The harness's own permission mode decides that, and a harness
|
||||
that runs without asking gains no approval step. Ordinary conversation and `rig send` are unchanged.
|
||||
|
||||
**What happens to a request, in the person's terms:**
|
||||
|
||||
| Situation | What happens |
|
||||
|---|---|
|
||||
| **There's a default person** (chosen with `rig gateway default`, or the only registered person), and no override | They get an ordinary ask in the Feed and on Slack. No setup is needed. |
|
||||
| **The seat's operator chose someone** (`rig seat set-permissions-to <seat> --to <person> --reason "…"`) | That person gets the ask instead. |
|
||||
| **No one can be asked** | **Permitted at once**, with the agent's own allow option. No ask is opened, and OpenRig records the permit (below). That covers: no registered person; several and none the default; the chosen person no longer registered; the people unreadable; or options a person can't be shown (bad ids, or not 2-4). The agent's `allow_once` is used, else its `allow_always`. If the agent offers only `allow_always`, the harness then remembers a permission nobody chose. |
|
||||
| **The seat's operator opted in to refusal** (`rig seat set-permissions-to <seat> --deny --reason "…"`) | Refused at once and held as needs input, as in the first version. |
|
||||
|
||||
**What the ask shows:**
|
||||
- the seat and the conversation;
|
||||
- the tool call, as the agent described it;
|
||||
- why this person was asked;
|
||||
- every option the agent offered, each with its native meaning. `allow_always` means the agent's harness remembers
|
||||
the choice and allows similar requests without asking. OpenRig doesn't record or revoke that.
|
||||
|
||||
**How a person answers, and what the agent gets:**
|
||||
|
||||
| The person… | The agent gets |
|
||||
|---|---|
|
||||
| clicks an option: the asked person, any other registered person, or the primary user in the TUI | that option |
|
||||
| replies with just a yes ("yes", "ok", "allow", "go ahead" …) | the agent's allow (`allow_once`, else `allow_always`) |
|
||||
| replies with just a no ("no", "deny", "reject", "stop" …), or denies it in the Feed | the agent's reject (`reject_once`, else `reject_always`), or, with no reject option, cancelled. A person's no is never a grant |
|
||||
| routes or hands it to another person | the ask waits for that person, who gets the same options; their answer counts as if they'd been asked. The handoff itself never permits |
|
||||
| routes or hands it to an agent seat | that seat gets no options. The request waits until that row is closed, by the seat or a person. Closed as done without a choice (including a drop), or as failed, is no answer, so it's permitted. Closed as denied (the Feed's deny, `--closure-reason denied`, or the state `denied`) is a no. A row set to the state `canceled` cancels the request, and a further handoff is followed; a person it reaches gets the same options. Or the request waits until the agent's turn ends |
|
||||
| closes it without choosing: drop, approve, or other typed words | no answer, so the default applies: **permitted** with the agent's allow, and OpenRig records the permit (below) |
|
||||
| doesn't answer | the request **stays pending and visible** (the seat reads needs input) until the agent's turn ends. It's never timed out into a grant. |
|
||||
|
||||
- **An answer reaches only the live request it was asked for,** once. A request from an earlier occupant reads
|
||||
`stale`, and one withdrawn at the turn's end reads `withdrawn`. Neither can take a later answer.
|
||||
- **Text typed into the runner's pane, an agent's own text, and `rig send` are never an answer.** While a request waits
|
||||
on a person, the seat reads needs input, and sends are normally refused. A send that does get through reaches the
|
||||
agent as a prompt.
|
||||
- **From 0.7.9, each permit OpenRig decides by default is recorded,** once, as a stream item from the seat. That covers
|
||||
no one to ask, and an ask closed without a choice (the row passed to an agent seat included). The record names:
|
||||
- the tool call;
|
||||
- why no person answered, including, for a closed ask, who closed which row and how;
|
||||
- the option the agent got, with its kind. An `allow_always` option is marked as remembered by the agent's harness;
|
||||
- the conversation and request, or the ask.
|
||||
|
||||
For an ask opened before 0.7.9, the record may omit the option kind and whether the harness remembers it.
|
||||
|
||||
Read the records with `rig stream list --tag runner-permission-permit`, or `--source <seat>`. A record wakes no one,
|
||||
and one that can't be written doesn't change the permit.
|
||||
|
||||
**The runner's protocol:**
|
||||
1. **Submit** the agent's live request to `POST <base>/api/activity/permission-requests`, with the same token or seat
|
||||
grant as section 4:
|
||||
|
||||
```json
|
||||
{"sessionName": "dev-builder@my-rig", "nodeId": "<node id>", "generation": "<OPENRIG_OCCUPANT_GENERATION>",
|
||||
"nativeRef": "<the conversation>", "turnId": "7", "requestId": "<the agent's JSON-RPC id>",
|
||||
"toolCall": {"title": "Edit src/app.ts", "kind": "edit"},
|
||||
"options": [{"optionId": "allow", "name": "Allow", "kind": "allow_once"},
|
||||
{"optionId": "reject", "name": "Reject", "kind": "reject_once"}],
|
||||
"occurredAt": "2026-10-30T12:00:00Z"}
|
||||
```
|
||||
|
||||
- **A person is asked:** the reply is `{"ok": true, "askId": "…", "created": true}`. OpenRig records the seat's
|
||||
`permission_prompt`, so it reads needs input. Don't post it yourself.
|
||||
- **No one can be asked:** the reply is `{"ok": true, "state": "permitted", "optionId": "…", "reason": "…"}`.
|
||||
Deliver that option.
|
||||
- **`requestId` and `turnId`** may be strings or numbers. Pass the agent's id unchanged: a number is read as its
|
||||
decimal string, so `7` and `"7"` are the same request.
|
||||
- **Resubmitting** the same generation, conversation and request id returns the same `askId`.
|
||||
2. **Read** the answer with `GET <base>/api/activity/permission-requests/<askId>?waitSeconds=25` (25 at most), and read
|
||||
again until the agent's turn ends.
|
||||
- **The `state`** is `pending` (read again, don't cancel), `answered` or `permitted` (each with `optionId`),
|
||||
`refused` (with `reason`: `said_no` when the person said no and the agent offered no reject option, or
|
||||
`no_allow_offered` when it was closed without a choice and the agent offered no allow option), `withdrawn` or
|
||||
`stale`.
|
||||
- **From 0.7.9, `said`,** with `permitted` or `refused` (`no_allow_offered`), is the person's own words, when they
|
||||
answered in words that were neither a choice nor a plain yes or no. It never changes the outcome.
|
||||
- **A seat grant reads and withdraws only its own launch's requests.** Once its occupant is replaced, the grant is
|
||||
refused (`409 activity_grant_stale`), even during a read's wait.
|
||||
3. **Deliver** `{"outcome": {"outcome": "selected", "optionId": "<optionId>"}}` to the agent once, and only while that
|
||||
request is still its live one. For `refused`, `withdrawn` or `stale`, answer `cancelled` and hold no attention: no
|
||||
person is still waiting. A refusal of the read itself follows the refusals below. From 0.7.9, when the read carries
|
||||
`said`, queue those words for the agent as an ordinary prompt: they reach it after the current turn, behind any prompt
|
||||
already waiting. They're never the answer. A runner that drops them still keeps this contract.
|
||||
4. **Withdraw** when the turn is cancelled or ends, or the agent exits:
|
||||
`POST <base>/api/activity/permission-requests/<askId>/withdraw` with
|
||||
`{"reason": "turn_cancelled" | "turn_ended" | "exited"}`.
|
||||
- A later answer isn't sent.
|
||||
- A relaunch makes the earlier occupant's open requests stale on its own.
|
||||
|
||||
**Refusals, at submit or while reading.** One rule covers both. Cancel only a request that certainly isn't the current
|
||||
occupant's live one, and hold no attention. Anything that may still be a live request permits, as when OpenRig can't
|
||||
answer. Only the seat's opt-in refuses and holds attention.
|
||||
|
||||
| Code | Status | Meaning | The runner |
|
||||
|---|---|---|---|
|
||||
| `denied_by_seat` | 409 | The seat's operator opted in to refusal (`--deny`). | refuses and holds attention |
|
||||
| `options_unsupported` | 422 | No one can be asked, and the agent offered no allow option to permit. | refuses and holds attention |
|
||||
| `stale_generation` | 409 | With the activity token: the post isn't from the seat's current occupant. | answers `cancelled` |
|
||||
| `activity_grant_wrong_seat` | 403 | The grant speaks for another seat. | answers `cancelled` |
|
||||
| `activity_grant_stale` | 409 | The grant's launch generation no longer occupies the seat. | answers `cancelled` |
|
||||
| `activity_grant_revoked` | 401 | The grant was revoked while its launch generation still occupies the seat, or before it has registered, so this may still be the live request; a replaced occupant's grant reads `activity_grant_stale` instead, even once revoked. | permits |
|
||||
| `activity_grant_pending` | 409 | The grant's launch hasn't registered its occupant yet. | permits |
|
||||
| `duplicate_request` | 409 | This request id was already submitted in another turn; an agent may reuse an id. | permits |
|
||||
|
||||
**When the runner can't get an answer from OpenRig,** nobody can be asked, so it permits with the agent's own allow
|
||||
option: `allow_once` when offered, else `allow_always`. Prefer `allow_once`, so the harness doesn't remember a
|
||||
permission nobody chose. Such a permit is the runner's own, so OpenRig has no record of it: the runner prints it in
|
||||
its pane. That covers:
|
||||
- an OpenRig without this path, which answers 404;
|
||||
- an OpenRig the runner can't reach, at submit or while reading (a runner may retry a read that got no answer a few
|
||||
times first);
|
||||
- a refusal the table marks "permits", and any status or code this section doesn't list.
|
||||
|
||||
**Limits of this path:**
|
||||
- **The answer owner is a registered person.** Choosing a seat (another agent) to answer is a later change.
|
||||
- **A colleague's answer is followed on this host.** If the ask is handed to another host, the request waits, shown as
|
||||
needs input, until the agent's turn ends.
|
||||
- **An override or `--deny` is set with the command above, never in a rig spec.** A spec field is a possible later
|
||||
change.
|
||||
- **A person's answer is recorded on the ask itself,** and a default permit as the stream item above. That item lasts as
|
||||
long as stream retention keeps it. A permit the runner decides on its own is in its pane only.
|
||||
|
||||
## 8. Resume: exactly the same conversation, or say honestly that you couldn't
|
||||
|
||||
- On a resume, OpenRig passes the conversation to reopen in `OPENRIG_RESUME_REF`. Use the method your agent
|
||||
advertises: ACP's `session/load` (capability `loadSession`), ACP's `session/resume` (capability
|
||||
`sessionCapabilities.resume`, which dsh `0.2.1-rc.2` uses), or your agent's own reopen method, named with its
|
||||
capability and pinned version in your `limits`.
|
||||
- **Telling "missing" from "won't reopen":** an agent may answer one error for both. dsh `0.2.1-rc.2` answers `-32602`
|
||||
for a conversation it doesn't have and for one already active elsewhere. So don't read an error's code or text as
|
||||
missing unless the agent defines it that way, as ACP's not-found `-32002` is.
|
||||
- **Report missing only on the agent's own not-found,** and leave anything else unconfirmed.
|
||||
- **When the agent advertises `sessionCapabilities.list`,** its `session/list` can show that a conversation exists:
|
||||
one it lists but won't reopen is unconfirmed, even on a not-found. Follow `nextCursor` (an opaque string, possibly
|
||||
empty), and treat a page you can't read as deciding nothing.
|
||||
- **A list doesn't show that a conversation is gone,** unless your agent guarantees it lists every conversation. An
|
||||
agent may leave out one that another running process holds unsaved; dsh `0.2.1-rc.2` can. So still try the reopen
|
||||
for a conversation the list doesn't name.
|
||||
|
||||
| What happened | Write in the launch record | What OpenRig does |
|
||||
|---|---|---|
|
||||
| The same conversation reopened | the same `nativeRef` | resumed |
|
||||
| The agent says it doesn't exist | `error: "missing"` | reports it missing; nothing new starts |
|
||||
| Anything else: a timeout, an error, or the working folder no longer matches the conversation's | no record, or an error other than `missing` | **unconfirmed**: the seat keeps its conversation and needs attention |
|
||||
| A different conversation opened | the other id | a mismatch; OpenRig doesn't switch to it |
|
||||
| OpenRig couldn't create the record's folder | (nothing to write) | nothing starts |
|
||||
|
||||
- **Never start a new conversation on a resume.**
|
||||
- A conversation saved under another harness's runtime is never handed to your launcher.
|
||||
|
||||
## 9. Context, model and shutdown: whose job is what
|
||||
|
||||
| Responsibility | OpenRig does | Your preset or runner does |
|
||||
|---|---|---|
|
||||
| **First startup turn** | Writes it to a private file and passes `OPENRIG_FIRST_INPUT` (with `first_input: true`) | Gives those exact bytes to the agent through its own first-prompt route |
|
||||
| **Guidance, skills, startup files** | Doesn't place them for community harnesses; a launch warns that they weren't delivered | Puts whatever the agent needs where *your* harness reads it, or says in `limits` that it isn't supported |
|
||||
| **Model** | Passes the seat's declared model as `OPENRIG_MODEL` (with `passes_model: true`); never substitutes one | Maps it to the harness's own setting. OpenRig shows the effective model as unknown |
|
||||
| **Placing the agent somewhere else** (a sandbox, another machine) | See the [execution platform chapter](execution-platform-contract.md) | Keeps the runner independent of where it runs |
|
||||
| **Shutdown** | `rig seat stop` and `rig down` end the seat's tmux session at once: no graceful stop handshake reaches the runner, its pane just closes. OpenRig ends nothing outside that session, and does no external provider cleanup | **(Proposed)** When the runner's pane closes (a hangup) or it's asked to stop, it attempts where possible to cancel any running turn and end the agent process it started, and lists in `limits` anything that may keep running. Cleanup of external compute needs the platform owner to reconcile it |
|
||||
|
||||
## 10. What counts as evidence
|
||||
|
||||
- **Keep three things separate:**
|
||||
- **`rig send --verify`** checks the pane before and after a send. It's pane evidence, not proof the agent received
|
||||
or finished anything. Mirror each accepted prompt in your pane (for example `you ▸ …`); OpenRig doesn't yet promise
|
||||
that `--verify` recognises it.
|
||||
- **A turn ending isn't the work being done.** Task completion is whatever the work's own record says: a queue task
|
||||
marked done, or an artifact written.
|
||||
- **Your own tests** should run against a fake agent that covers each section above. The
|
||||
[runner conformance check](runner-conformance/README.md) does that for you: point it at your runner, and it reports
|
||||
which promises your runner keeps.
|
||||
- **The useful real check** for a maintainer:
|
||||
- a seat starts and reads ready only after the handshake;
|
||||
- one multi-line message becomes one prompt;
|
||||
- the seat reads working, then idle;
|
||||
- a refused permission reads needs input;
|
||||
- `/abort` ends a turn as cancelled;
|
||||
- a resume reopens the same conversation, and a missing one is refused, never replaced.
|
||||
|
||||
That's the same promotion test the presets reference describes, applied to these declarations. Passing it is
|
||||
compatibility evidence for the versions you name, not certification.
|
||||
|
||||
## Contributor checklist
|
||||
|
||||
- [ ] The preset sets `runner: true` and `launch_result: true`, and not `names_conversation`.
|
||||
- [ ] After the agent opens or reopens its conversation, the runner writes the launch record, with versions in `producer`.
|
||||
- [ ] Activity posts carry `sessionName`, `nodeId`, `runtime`, `generation`, `sequence`, `occurredAt`, `hookEvent` and
|
||||
`subtype`. They're sent in order and never block the agent.
|
||||
- [ ] One submitted message becomes exactly one prompt. Only a bare `/abort` block, or Ctrl-C outside a paste, is control.
|
||||
- [ ] Cancel records "requested" when sent, and "cancelled" only on the agent's own stop reason.
|
||||
- [ ] Every permission request goes to OpenRig's answered path, and the runner delivers the option that comes back: the
|
||||
person's, or OpenRig's permit. When OpenRig can't answer (404, unreachable, or a refusal section 7's table marks
|
||||
"permits"), the runner permits with the agent's `allow_once`, else `allow_always`. A refusal that says the request
|
||||
isn't the current occupant's, or a `refused` answer, means answer `cancelled` and hold nothing. Only
|
||||
`denied_by_seat`, or no allow option to permit, means refuse at once, show it, and hold needs input until the next
|
||||
accepted prompt.
|
||||
- [ ] An agent exit posts `Stop` / `<preset name>_exited`, never a plain idle.
|
||||
- [ ] A resume reopens the exact conversation. It reports `missing` only on the agent's word, and otherwise leaves it
|
||||
unconfirmed.
|
||||
- [ ] Unknown agent requests get `-32601`.
|
||||
- [ ] `limits` names the input limits, the permission limit, what keeps running after a stop, and anything not supported.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Answered:** which method and capability does dsh advertise for reopening a session, at a pinned version? dsh
|
||||
`0.2.1-rc.2` advertises `sessionCapabilities` `{close, list, resume}`, without `loadSession`, and reopens with
|
||||
`session/resume` (section 8). This was observed with a stand-in model, not a real one.
|
||||
2. **For the dsh maintainer:** can dsh's ACP profile run with tools off? A first journey that needs no permission
|
||||
answers is the simplest to check.
|
||||
3. **For OpenRig:** should a later version route permission requests to a chosen seat, as `rig acp run` already does
|
||||
with queue tasks? 0.7.8 routes them to the default person, or to a person chosen for the seat (section 7). A chosen
|
||||
seat is still open.
|
||||
4. **For OpenRig:** once a second ACP harness exists, should one shared ACP runner live in OpenRig rather than one per
|
||||
harness?
|
||||
@@ -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
@@ -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).
|
||||
|
||||
|
||||
@@ -1,183 +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.
|
||||
|
||||
## Assistants connected through a grant service
|
||||
|
||||
With `rig mcp serve --http --grant-service <url>`, an outside service answers each connection's key with its grant
|
||||
(`openrig.grant/v1`): the seat it acts as, its rigs, and the object/action pairs it may use. Each pair offers one tool,
|
||||
and nothing else is offered:
|
||||
|
||||
| Pair | Tool |
|
||||
|---|---|
|
||||
| `queue`/`create`, `queue`/`show`, `queue`/`list` | `rig_queue_create`, `rig_queue_show`, `rig_queue_list` |
|
||||
| `queue`/`claim`, `queue`/`update` | `rig_queue_claim`, `rig_queue_update` |
|
||||
| `team`/`list` | `rig_team` |
|
||||
| `artifact`/`read` | `rig_artifact_read` |
|
||||
| `question`/`read`, `question`/`answer` | `rig_question_list`, `rig_question_answer`, only when the grant names its person (below) |
|
||||
|
||||
- **One seat per connected assistant:** the service gives each connection its own seat and keeps it across that
|
||||
connection's reconnects. The seat must be one of the rig's nodes; a node created without a launch is enough. The caller
|
||||
is exactly that seat.
|
||||
- **What it reaches:**
|
||||
- It hands work to its rigs' other seats, never to a person or another host's seat.
|
||||
- It reads the items it sent and the items addressed to it from those seats, never the team's internal items.
|
||||
- With `queue`/`claim` and `queue`/`update`, it claims and closes only items addressed to its own seat that it can read,
|
||||
and waits (blocked) only on an item it names and can read. Its answers hide what reading the item hides.
|
||||
- **Two assistants together:** a grant with `team`/`list` sees its rigs' seats with `rig_team`, and may address and hear from
|
||||
another connected assistant's seat. A grant without it never reaches a seat in the service's caller form
|
||||
(`cus-<id>-<connection>@<rig>`), so an existing connection keeps the reach it had.
|
||||
- **Messages are items:** a message is an item the receiver reads and closes, and a reply is an item back to its sender.
|
||||
- **What the sender can tell, from the item it reads:**
|
||||
- **Queued:** the item exists and is `pending` with no claim.
|
||||
- **Picked up:** the receiver's claim (`in-progress`).
|
||||
- **Answered:** closed `done`, with the answer in `resolution`.
|
||||
- Reading or listing an item records nothing, and nothing wakes an assistant, so an item stays queued until its receiver
|
||||
claims it.
|
||||
|
||||
### A person answering the team's questions through their assistant
|
||||
|
||||
A grant can name the person who owns the connection: `person.entity_id`, their gateway entity id on this instance, so their
|
||||
address is `<entity_id>@external`. The service sends it only for a connection that person owns. With it and the `question`
|
||||
pair, the person's assistant can read the team's questions waiting on them and submit their answers, without opening
|
||||
Steering. The caller's seat is never taken for a person: without `person`, the `question` pair offers nothing, and an
|
||||
unusable `person` withholds only these two tools while the grant's other tools keep working.
|
||||
|
||||
- **Register the person** on the instance, once: `rig gateway human add <entity_id> --display-name "<name>" --default`.
|
||||
Without `--binding`, they're asked in the Feed and on Steering only. As the default person, today's asks addressed to no
|
||||
registered person (such as `human@host`) are theirs too.
|
||||
- **`rig_question_list`** shows what Steering shows that person as answerable: an ask addressed to them, or a row parked on
|
||||
them. Each comes with its options when it has any, and the `revision` an answer names. A question that moved to someone
|
||||
else between Steering's view and the read of its row is left out.
|
||||
- **`rig_question_answer`** takes the question, the revision the person saw, and either the options they chose (one per
|
||||
question) or exactly what they typed. It records through Steering's own write path (`answer` for an ask, `resolve` for a
|
||||
park), as the person, so Steering shows the same answer and the asker's work continues once. The answer's audit names the
|
||||
route it came by: the caller's seat, the grant's principal and the grant's id and version.
|
||||
- **What it says back:** a success names what was submitted (`submitted`), never a fresh read of the question's answers.
|
||||
- `answered`;
|
||||
- `recorded`, when parts of the question are still unanswered;
|
||||
- `already-recorded`, for an identical retry whose submission is already on the question, recognised by its operation id
|
||||
(never by an equal answer). That includes a settled ask or a park that's back at work;
|
||||
- `question_changed`, with the current question, when someone else changed it since the revision the person saw;
|
||||
- a retry of a submission that stopped partway confirms the parts already recorded and stops there (`question_changed`,
|
||||
with `recorded` and `notRecorded`). The rest goes in a new answer to the question as `rig_question_list` shows it now,
|
||||
so a stale retry never overwrites an answer someone gave in the meantime, including one recorded with no action (a
|
||||
Slack click);
|
||||
- a refusal partway names which parts were recorded (`recorded`, `notRecorded`). A send that got no answer, or a server
|
||||
error, leaves that part `indeterminate`: send the same call again to confirm what was recorded;
|
||||
- `not_waiting_on_person`, when it was answered or closed, or isn't theirs;
|
||||
- `person_not_registered`, when the grant's person isn't registered on this instance. While no one is registered, Steering
|
||||
shows every request, so nothing is answered for anyone.
|
||||
- **What it never does:** answer another person's question, reach a team-internal row, or record a decision of its own. A
|
||||
delegated decision (`rig_queue_decide`) isn't offered to a grant-service caller.
|
||||
|
||||
## 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, except through a grant service's grant that names its person, which records that person's own answers (above).
|
||||
@@ -110,17 +110,6 @@ limitation: use the live spec-library commands to determine where a spec is
|
||||
served from, and do not infer convergence merely because the canonical
|
||||
directory exists.
|
||||
|
||||
The spec library scans the built-in specs, `$OPENRIG_HOME/specs` and, when
|
||||
present, that legacy library. At any depth below them it skips directories
|
||||
named `.git`, `.worktrees`, `node_modules`, `dist`, `build`, `.turbo` and
|
||||
`.next`, which usually hold build output or dependencies (`SKIP_DIRS` in
|
||||
`packages/daemon/src/domain/spec-library-service.ts`). A spec inside one of
|
||||
them, such as `specs/build/rig.yaml`, isn't listed, and nothing reports that it
|
||||
was skipped. To list it, rename its directory and run `rig specs sync`.
|
||||
`rig specs add <directory>` copies a directory under its own name, so adding a
|
||||
directory named `build` doesn't help. `rig up <path-to-rig.yaml>` still launches
|
||||
a spec by its path, without the library.
|
||||
|
||||
Config updates publish a complete replacement file atomically where directory
|
||||
permissions and the filesystem allow it. On POSIX systems, replacement preserves
|
||||
the existing owner, group, and read/write/execute permission bits. If creating or
|
||||
|
||||
@@ -107,28 +107,6 @@ rig queue create --destination <entityId>@external --human-intent update \
|
||||
- **`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.
|
||||
|
||||
## If the timer cannot reach its recipient
|
||||
|
||||
A `periodic-reminder` occurrence that definitely fails before input remains owed. The watchdog
|
||||
retries through its existing loop, using `queue.wake_retry_interval_seconds` and
|
||||
`queue.wake_retry_cap` (by default, three retries five minutes apart). A failed attempt does not
|
||||
advance the reminder to its next regular cycle. A confirmed send advances the normal interval;
|
||||
recovery does not replay every elapsed cycle.
|
||||
|
||||
`rig watchdog show <jobId> --json` includes the failed attempt, its retained outbox IDs, retry
|
||||
time and next action. If input may have reached the recipient, that occurrence ends unconfirmed
|
||||
and is not automatically sent again. Inspect the recipient and exact outbox record before any
|
||||
manual replay. A recurring reminder keeps its next normal cycle under a new occurrence identity. A confirmed receipt survives a daemon restart, so
|
||||
finishing its accounting does not type the same occurrence twice.
|
||||
|
||||
Exhausted retries end only the failed occurrence, with its cause and next action; a recurring
|
||||
reminder keeps its next normal cycle. A permanent transport failure leaves a terminal job:
|
||||
correct the destination and register a replacement reminder. A one-shot park timer records its
|
||||
failed or unconfirmed wake on the parked row before it ends. A retryable failure is recorded
|
||||
there while the timer stays armed. Deliberately held
|
||||
input keeps its existing retained-message custody. Condition watches and parked-owner policy
|
||||
behavior are unchanged. This does not replay failed occurrences recorded by older versions.
|
||||
|
||||
## "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
|
||||
@@ -187,27 +165,6 @@ says so. Use it rather than the single-machine `rig provider status --json`: one
|
||||
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.
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
# Pi tool-selection recovery
|
||||
|
||||
Use this when a live Pi conversation has fewer active tools than its operator
|
||||
currently intends. This does not establish why the selection changed or that
|
||||
stock Pi/OpenRig lost tools. The motivating report used a customized Pi 1.4.0
|
||||
deployment; it is not a stock cross-platform reproduction.
|
||||
|
||||
## Diagnose before changing the selection
|
||||
|
||||
Confirm the exact seat, occupant and conversation, then use an idle boundary.
|
||||
Preserve pending work and reconcile unknown writes before any replacement.
|
||||
Keep four things separate:
|
||||
|
||||
- **Configured:** the effective merged `defaultTools` setting. Do not infer it
|
||||
from one settings file; retain any unresolved patterns or modifiers as such.
|
||||
- **Currently intended:** the tools the operator wants enabled now, including
|
||||
deliberate exclusions. Configuration alone does not establish that intention.
|
||||
- **Registered:** names the current Pi runtime knows about.
|
||||
- **Active:** names returned by `getActiveTools`, which can differ from both.
|
||||
|
||||
A live RPC connection does not prove the tools are usable. Record observation
|
||||
time and conversation binding; an unavailable query stays unknown. Reloading
|
||||
unchanged settings can retain session-disabled tools by design, so a reload
|
||||
that restores nothing is not itself evidence of accidental loss.
|
||||
|
||||
## Choose the interface the seat actually has
|
||||
|
||||
Pi 1.4.0 includes an opt-in selector at
|
||||
`packages/coding-agent/examples/extensions/tools.ts` in the Pi repository. Its
|
||||
`/tools` command remembers selection per branch and needs Pi's own TUI. Use it
|
||||
where that extension and native TUI are available. **Do not send `/tools` to an
|
||||
agent OpenRig drives over RPC**: that interface cannot operate the selector.
|
||||
|
||||
For RPC seats, an optional [community-maintained helper][helper] provides
|
||||
read-only status and an explicit command that adds only named intended tools.
|
||||
It is maintained outside OpenRig, is not bundled, and this link is not a support
|
||||
claim. Loading it also registers the agent-callable `openrig_runtime_status` and
|
||||
`openrig_slots` tools; the latter reads a snapshot of the bound rig's agents and
|
||||
active queue items. It can add a prompt section when a guidance file is set.
|
||||
Review its [shared implementation][runtime] and
|
||||
[setup/capability limits][setup] before opting in through Pi's per-seat extension
|
||||
configuration. Keep the two helper files together; do not install the separate
|
||||
RepoStew application just to use the neutral bridge. Verify what the target
|
||||
session actually loaded after the supported activation path.
|
||||
|
||||
With that helper already loaded, these are **Pi extension commands**, not new
|
||||
`rig` commands:
|
||||
|
||||
```text
|
||||
/openrig-status
|
||||
/openrig-restore-tools read
|
||||
```
|
||||
|
||||
For an agent OpenRig drives over RPC, send each extension command with
|
||||
`rig send <agent> --raw "<command>"` so it arrives without a messaging envelope;
|
||||
the usual interactive-prompt guard still applies.
|
||||
|
||||
The second command means that `read` is intended now. It does not restore every
|
||||
configured default. If `bash` was deliberately disabled, leave it disabled
|
||||
unless the operator explicitly intends to add it too. With no names, this helper
|
||||
refuses to change the selection.
|
||||
|
||||
## Verify the recovery
|
||||
|
||||
1. Establish the explicit current intended names. Reject unregistered or hidden
|
||||
requested tools before changing anything; do not infer replacement names.
|
||||
2. Preserve current active extras: `setActiveTools` **replaces** the selection,
|
||||
so pass the union of current active names and only the requested names. It can
|
||||
ignore names it cannot activate; sending the command is not proof of recovery.
|
||||
3. Read `getActiveTools` back. Retain a before/after receipt with the intended
|
||||
names and any missing names, then make a harmless actual call to a recovered
|
||||
tool within the existing task authority. Listed-active is not proof of usable
|
||||
permissions or working execution.
|
||||
|
||||
This changes tool selection, not permission policy, task authority or conversation
|
||||
identity. It does not automatically persist a new branch selection; verify again
|
||||
after branch navigation or reload. If the runtime cannot support or verify this
|
||||
in place, report that capability gap and follow its supported replacement path
|
||||
with retained work. Do not manufacture a fresh conversation or claim that another
|
||||
harness has Pi's capabilities.
|
||||
|
||||
[helper]: https://github.com/dajiaohuang/repostew/blob/adf9f36c23e27cf40cf8dd2dae887972a0dc776b/skill/integrations/pi-openrig.js
|
||||
[runtime]: https://github.com/dajiaohuang/repostew/blob/adf9f36c23e27cf40cf8dd2dae887972a0dc776b/skill/integrations/pi-runtime.js
|
||||
[setup]: https://github.com/dajiaohuang/repostew/blob/adf9f36c23e27cf40cf8dd2dae887972a0dc776b/skill/references/harnesses/pi.md
|
||||
@@ -7,10 +7,6 @@ without daemon-internal knowledge.
|
||||
This subtree is part of the wider [OpenRig Instance Layout](instance-layout.md).
|
||||
The instance initializer delegates these exact workspace bytes to this owner.
|
||||
|
||||
To inspect or read an exact reconstruction under a selected work home, use
|
||||
[`rig context reconstruction`](reconstructions.md). It shares catalog and work-home
|
||||
resolution with `context work-install` and performs no installation or projection.
|
||||
|
||||
## Default Shape
|
||||
|
||||
`rig config init-workspace` creates the default workspace at
|
||||
@@ -112,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
|
||||
|
||||
@@ -28,17 +28,6 @@ Two rules matter most for strangers:
|
||||
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.
|
||||
|
||||
Two optional files sit beside `rig.yaml`:
|
||||
- **`bundle.yaml`** declares what people need before installing: setup steps (`preconditions`, shown before install
|
||||
and never run), the oldest OpenRig that works (`compatibility.min_cli_version`), and skills shared by several
|
||||
@@ -120,11 +109,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
|
||||
|
||||
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
|
||||
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.5`. If OpenRig is already installed, use the tag
|
||||
For a new install, use the latest release's tag, for example `v0.7.0`. 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.
|
||||
@@ -90,8 +90,7 @@ Use `codex` or `both` in place of `claude` to match the coding agents you use. A
|
||||
- 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. Setup also tries to record the selected providers
|
||||
for a new kernel's first boot; its summary warns if it could not save the choice.
|
||||
there: run `claude auth login` or `codex login`, then carry on.
|
||||
|
||||
### 3. Start OpenRig
|
||||
|
||||
@@ -101,16 +100,12 @@ rig status
|
||||
rig ps --nodes --rig kernel
|
||||
```
|
||||
|
||||
On the kernel's first boot, the daemon uses the providers selected by `rig setup --providers` that are signed in.
|
||||
If no provider choice was recorded, it considers every signed-in provider. An existing kernel keeps its seats;
|
||||
changing the setup choice does not replace them. An `OPENRIG_PROVIDER_SELECTED` value in the daemon's environment
|
||||
takes precedence over the saved choice.
|
||||
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. `rig down` takes the hooks and
|
||||
status line back out once no Claude seat uses that directory.
|
||||
`.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,
|
||||
@@ -198,18 +193,6 @@ it as the step to finish: sign in, then continue at step 3.
|
||||
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
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
# Read a reconstruction of selected work
|
||||
|
||||
A reconstruction records an author's working understanding, its evidence routes,
|
||||
constraints and gaps. `rig context reconstruction` reads an exact immutable
|
||||
`openrig.reconstruction/v0` revision locally. It does not start a daemon, fetch
|
||||
remote context, write records or transfer the author's authority.
|
||||
|
||||
Inspect the purpose, depth, author-declared coverage, observation dates, complete
|
||||
source manifest and approximate size before reading a view:
|
||||
|
||||
```bash
|
||||
rig context reconstruction inspect <full-sha256> --project example --mission task --slice 01-example
|
||||
rig context reconstruction view <full-sha256> --project example --mission task --slice 01-example --item decision --json
|
||||
```
|
||||
|
||||
Without `--store`, the store is `reconstructions/` under the selected project,
|
||||
mission or slice home. Project selection follows `context work-install`: an
|
||||
explicit catalog project, the only entry, the calling rig's association, the
|
||||
deepest project containing `--cwd`, or the only unclaimed entry. Ambiguity is an
|
||||
error. The project's configured `missions.root`, declared mission membership,
|
||||
slice directory names and slice frontmatter IDs are honored. A workspace
|
||||
without a catalog can use its own project home. No skill installation or context
|
||||
projection happens during this lookup.
|
||||
|
||||
Use `--store /absolute/local/store` for an imported store without resolving a
|
||||
project. Supplying work selectors as well still validates that work home.
|
||||
Revisions are full lowercase SHA-256 IDs; there is no implicit latest revision.
|
||||
|
||||
## Faithful views and gaps
|
||||
|
||||
Repeat `--item`, `--category` or `--tag` to select their union. A filtered view
|
||||
always retains authority, constraint and gap items, plus transitive item
|
||||
dependencies. It names omitted item IDs and uncited evidence routes, and keeps
|
||||
declared reconstruction coverage separate from actual view coverage. Inspect
|
||||
always provides the complete manifest; a filtered view provides cited routes.
|
||||
A full view also includes uncited registered routes.
|
||||
|
||||
Base records, deltas and pinned compositions retain inherited observation dates.
|
||||
A revision's creation date does not refresh those observations. Composition
|
||||
members do not imply an atomic capture. Authors are claimed attribution, and
|
||||
coverage is authored description rather than proof of runtime state or acceptance.
|
||||
|
||||
Unavailable records, missing selections, dependencies or citations make the
|
||||
result `complete: false` and set exit status 1. Available composition members
|
||||
remain visible with the missing member's exact store and revision. Unknown
|
||||
schemas, invalid content hashes and unsupported numeric representations are
|
||||
reported explicitly. The initial reader supports safe integer JSON numbers;
|
||||
floating-point/exponent and unsafe integer representations are rejected rather
|
||||
than assigned a different ID. Unicode keys follow v0's codepoint sorting.
|
||||
|
||||
## Relocate a store without changing its records
|
||||
|
||||
Parent and member pointers include absolute source-store paths in the hash.
|
||||
Do not edit those paths in immutable records. Copy their closure into local
|
||||
custody, then supply a separate JSON mapping:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"sourceStore": "/original/work/reconstructions",
|
||||
"localStore": "/local/import/reconstructions",
|
||||
"sourceHost": "original-host"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
```bash
|
||||
rig context reconstruction inspect <full-sha256> --store /local/import/reconstructions --store-map /local/map.json
|
||||
```
|
||||
|
||||
The mapping is read-time location information, not an identity registry or a
|
||||
remote fetch permission. It preserves source-host provenance. Since v0 pointers
|
||||
do not contain a host discriminator, duplicate mappings for the same source
|
||||
store are ambiguous and rejected. A store path that exists on this host can be
|
||||
read directly; a path that has moved needs a mapping to its local location.
|
||||
|
||||
## Bounds and byte checks
|
||||
|
||||
Reads are limited to 8 MiB per regular revision file, 32 MiB per closure, 64
|
||||
record references and 128 JSON nesting levels. Symlink revision files are not
|
||||
followed. Output defaults to 256 KiB; `--max-bytes` accepts 1 KiB through 8 MiB.
|
||||
Oversized output fails explicitly rather than silently truncating a purportedly
|
||||
complete view. Size estimates use UTF-8 bytes divided by four, not a model tokenizer.
|
||||
|
||||
`--check-sources` hashes declared local regular files within 128 files and 64 MiB
|
||||
total. It reports changed, unchanged, unavailable or unchecked bytes without
|
||||
executing source commands, fetching opaque routes or updating observations.
|
||||
Discovery declarations are shown but not scanned. Unchanged known files do not
|
||||
establish semantic freshness or detect new queue decisions. There is no queue
|
||||
discovery cursor, native writer or continuity-consumer replacement in this reader.
|
||||
@@ -37,12 +37,10 @@ launched before this change, a manual run) the hooks do what they always did. Th
|
||||
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.
|
||||
hook definitions are trusted before launch, after checking the installed hook files against this daemon's shipped copy.
|
||||
Existing plugin disables and hook choices are retained. An existing untrusted installation stays under native review:
|
||||
Codex's "continue without trusting" leaves no record, so OpenRig does not assume an absent trust record is permission.
|
||||
The file check happens only when trust is granted: a later edit to the cached hook scripts keeps that trust, as with any hook trusted in Codex.
|
||||
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
|
||||
|
||||
@@ -91,10 +91,10 @@ my-rig.rigbundle.sha256 — "a1b2c3d4..." (64-char hex SHA-256)
|
||||
The packer produces deterministic output:
|
||||
- Files are sorted alphabetically
|
||||
- A fixed mtime is used (`2026-01-01T00:00:00Z`)
|
||||
- Portable mode omits machine-specific owner metadata; file mode bits can be retained
|
||||
- Portable mode normalizes uid/gid/mode
|
||||
- Maximum gzip compression (level 9)
|
||||
|
||||
Identical staged bytes and file modes (including identical manifest timestamps and provenance) produce the same archive hash. A mode change can change the archive hash even when the file contents are unchanged. Use the package digest to compare packaged file content independently of manifest metadata and file modes.
|
||||
Identical staged bytes (including manifest timestamps and provenance) produce the same archive hash. Use the package digest to compare packaged file content independently of manifest metadata.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+15
-505
@@ -2,7 +2,7 @@
|
||||
|
||||
Version: 0.2 (pod-aware)
|
||||
Last validated against code: 2026-10-07, at the 0.6.6 cut `2620dea8` (whole document at `fcaf1f8e`; changes to its sources since then checked)
|
||||
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/types.ts`, `packages/daemon/src/domain/launch-wrapper.ts`, `packages/daemon/src/domain/startup-validation.ts`, `packages/daemon/src/domain/permission-policy/policy-ref.ts`, `packages/daemon/src/domain/profile-resolver.ts`, `packages/daemon/src/domain/rigspec-preflight.ts`, `packages/daemon/src/adapters/kernel-authority.ts`, `packages/daemon/src/domain/native-permission-store.ts`, `packages/daemon/src/domain/codex-team-workspace.ts`
|
||||
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/types.ts`, `packages/daemon/src/domain/startup-validation.ts`, `packages/daemon/src/domain/permission-policy/policy-ref.ts`, `packages/daemon/src/domain/profile-resolver.ts`, `packages/daemon/src/domain/rigspec-preflight.ts`, `packages/daemon/src/adapters/kernel-authority.ts`, `packages/daemon/src/domain/native-permission-store.ts`, `packages/daemon/src/domain/codex-team-workspace.ts`
|
||||
|
||||
This is the canonical reference for the pod-aware RigSpec YAML format. Every field, validation rule, and default documented here was traced from the actual parser and validator code, not from prior documentation.
|
||||
|
||||
@@ -180,7 +180,7 @@ edges:
|
||||
| `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`, except that a fresh Pi seat normally uses its own agent folder's `AGENTS.md` and a fresh Codex 0.164.0+ seat normally gets launch developer instructions; a link, a conflicting or unreadable Codex config layer, or a resume keeps the repository file. See [Codex guidance at launch](agent-startup-guide.md#codex-guidance-at-launch) and [Runtime Differences](agent-startup-guide.md#runtime-differences). See "Choosing the Claude instruction file" 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. |
|
||||
| `startup` | StartupBlock | no | — | Rig-level startup files and actions. Applied to all members via the startup layering model. |
|
||||
@@ -190,23 +190,6 @@ edges:
|
||||
|
||||
**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 +219,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
|
||||
@@ -404,22 +262,6 @@ ask rules; help can prompt in that fallback. A helper that disappears or times o
|
||||
after launch cannot supply a decision, leaving Claude's remaining native rules
|
||||
in effect. The hook is a convenience policy, not a containment boundary.
|
||||
|
||||
Claude sessions launched with a named `team` or `kernel` hook policy read the
|
||||
installed built-in lists on each Bash hook call. Replacing those files at the
|
||||
same path changes the hook's lists on its next call; native inline allowances
|
||||
remain as launched. Sessions launched with an encoded policy keep their launch
|
||||
lists. A session retaining a versioned runtime path continues to use the files
|
||||
at that path, so installing another version elsewhere does not change its hook.
|
||||
This does not reload all permission settings in a running session.
|
||||
|
||||
An in-place rollback to a hook from before named policies has a different effect:
|
||||
that helper cannot read `team` or `kernel` and returns no hook decision. Lifecycle
|
||||
asks can then disappear under the native `Bash(rig:*)` allowance. Relaunch affected
|
||||
Claude sessions before relying on those asks after such a rollback. Native
|
||||
personal, project and managed restrictions are not removed. This applies when
|
||||
the rollback replaces the hook at the path a running named-policy session uses;
|
||||
it does not describe every rollback or sessions using a retained versioned path.
|
||||
|
||||
Codex keeps `workspace-write` with its existing approval policy and receives the
|
||||
configured OpenRig workspace root plus its pod's shared state directory as
|
||||
additional writable directories. The team workspace preparer creates missing
|
||||
@@ -514,145 +356,16 @@ pods:
|
||||
|
||||
- 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.
|
||||
- `rig up <spec> --env KEY=VALUE` (repeatable) adds or overrides a value for one launch. 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.
|
||||
- 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.
|
||||
|
||||
**A seat's own subscription token.** A Claude member can start on a prepared subscription token from its first launch,
|
||||
with no conversation to switch first. Name the token file beside its config dir:
|
||||
|
||||
```yaml
|
||||
- id: dev
|
||||
runtime: claude-code
|
||||
env:
|
||||
CLAUDE_CONFIG_DIR: /abs/claude-dev
|
||||
claude_token: { label: dev-sub, file: /abs/tokens/dev.token }
|
||||
```
|
||||
|
||||
The label and the absolute path are stored on the seat when the rig is created, as `rig provider switch --seat <seat>
|
||||
--to-account <label> --token-file <path>` stores them. Its launches run the same token step, which reads the file as
|
||||
Claude starts; a launch path that can't run it (a pane shell other than a POSIX one, for example) starts without it and
|
||||
warns. The file's contents never pass through OpenRig: validation doesn't read it, and they don't reach the spec, the
|
||||
seat's record or the launch command. A missing, empty or unreadable file doesn't refuse the spec; the seat starts on its
|
||||
config dir's stored login (or a token the daemon gives every seat) and says so. A launch also warns when a setting Claude
|
||||
Code ranks above the token would be used instead: an `apiKeyHelper`, or an outranking `env` entry in the user, project
|
||||
or local settings. Managed settings aren't inspected. `rig export` writes the seat's current reference, except for a seat
|
||||
with an account home or an API-key selection (select those again after import), so a later switch shows there, and `rig provider switch --stored-login` removes it. Only
|
||||
`claude-code` members and legacy flat nodes accept it, and there's no rig-level default.
|
||||
|
||||
**Switching to an isolated account home.** A running Codex, Claude Code or Pi seat can change its account home
|
||||
while staying in its rig:
|
||||
|
||||
```sh
|
||||
rig provider switch --seat dev-impl@my-rig --to-account api-account \
|
||||
--account-home /home/me/accounts/codex-api --model gpt-5.5 --reason "Use the API budget" --dry-run
|
||||
rig seat set-runtime dev-impl@my-rig --runtime claude-code \
|
||||
--account-home /home/me/accounts/claude-api --model claude-sonnet-4-6 --reason "Codex limit"
|
||||
```
|
||||
|
||||
The directory must already exist and contain the runtime's login or API settings. You choose the account and budget;
|
||||
OpenRig does not choose or create credentials. `--account-home` sets `CODEX_HOME` for Codex or `CLAUDE_CONFIG_DIR` for
|
||||
Claude Code before the successor starts. For Pi, the account directory must contain a readable `auth.json`; OpenRig links
|
||||
that file into a separate managed successor directory. The existing Pi process keeps its credentials and runner state
|
||||
until the successor confirms. Pi account changes keep the current model and effort unless explicitly overridden.
|
||||
|
||||
```sh
|
||||
rig provider switch --seat pi-worker@my-rig --to-account work \
|
||||
--account-home /home/me/accounts/pi-work --reason "Use the work account" --dry-run
|
||||
# Run again without --dry-run to stage the successor and request its confirmation.
|
||||
rig provider switch --seat pi-worker@my-rig --clear-account-home --reason "Return to configured login"
|
||||
```
|
||||
|
||||
Pi account changes work with or without `launch_wrapper`. The wrapper receives the successor's managed Pi directory;
|
||||
it must preserve the selected credentials if you want OpenRig's account choice to apply. Switching a seat between Pi
|
||||
and another runtime is not supported by this account-home feature.
|
||||
|
||||
This switch starts a fresh conversation with a handover, even when the runtime stays the same. The existing
|
||||
subscription-login switch preserves the conversation. Once the successor confirms, OpenRig commits its home, runtime,
|
||||
model, profile and permission selection together. The seat keeps its address, edges, queue rows and claims. If OpenRig
|
||||
refuses the switch before cutover, the predecessor's configuration stays unchanged.
|
||||
|
||||
For a Claude plan authenticated with `setup-token`, supply the token file with the home:
|
||||
|
||||
```sh
|
||||
rig provider switch --seat dev-impl@my-rig --to-account claude-plan \
|
||||
--account-home /home/me/accounts/claude-plan --token-file /home/me/credentials/claude-plan-token \
|
||||
--reason "The plan reset"
|
||||
# Across runtimes, use set-runtime with --account-home, --token-file and --account-label.
|
||||
```
|
||||
|
||||
To return to the seat's configured login:
|
||||
|
||||
```sh
|
||||
rig provider switch --seat dev-impl@my-rig --clear-account-home --reason "The plan reset" --dry-run
|
||||
rig provider switch --seat dev-impl@my-rig --clear-account-home --reason "The plan reset"
|
||||
# Across runtimes: rig seat set-runtime dev-impl@my-rig --runtime claude-code --clear-account-home --reason "Return to configured login"
|
||||
```
|
||||
|
||||
Clearing the selection also starts a successor with a fresh conversation and a handover. OpenRig removes the saved
|
||||
account-home override and its token reference only after the successor confirms. The successor starts with the member's
|
||||
or rig's configured home, falling back to the daemon's selection and then the native default, as an ordinary launch does.
|
||||
If there was no account-home override, OpenRig preserves any unrelated legacy per-seat token. Clear uses the current
|
||||
configuration; it does not remember a prior login or restore old credentials. Use clear on its own, without
|
||||
`--to-account`, `--stored-login`, `--account-home` or a new token reference.
|
||||
|
||||
**Anthropic profile limitation.** An active or default profile in Anthropic's configuration directory can override a
|
||||
stored Claude login. `CLAUDE_CONFIG_DIR` does not isolate that directory, and OpenRig does not change `ANTHROPIC_CONFIG_DIR`
|
||||
or rewrite global profiles. Check or deselect the profile before using a stored-login account home, and verify the
|
||||
running account privately.
|
||||
|
||||
An isolated Claude launch removes `ANTHROPIC_PROFILE`, `ANTHROPIC_FEDERATION_RULE_ID` and `ANTHROPIC_ORGANIZATION_ID`
|
||||
from its inherited environment. It also refuses profile selectors in the selected home's or project's settings `env`
|
||||
blocks. An explicit subscription token takes precedence over these selectors, including in the legacy
|
||||
subscription-token switch.
|
||||
|
||||
OpenRig receives only the token's label and file path. The launch reads the token; if the required file is unusable, no agent
|
||||
starts. Selecting an API home without `--token-file` clears an older seat token reference when the selection commits.
|
||||
The home's API helper can stay in its user settings. For a selected plan token, OpenRig refuses a home whose API helper
|
||||
or credential environment would take precedence over that token. Both modes refuse project credential overrides.
|
||||
OpenRig leaves those settings unchanged.
|
||||
|
||||
The selected home persists across managed launches and restores. These launches exclude inherited Claude/OpenAI
|
||||
credentials and routing variables so credentials from the daemon or shell cannot override the selected home. OpenRig
|
||||
refuses to launch if it cannot read the saved selection or the shell cannot enforce isolation. Launches without a home
|
||||
selection and existing subscription-login switches keep their behavior. A Claude seat using account-home mode must
|
||||
use that mode for later account changes too.
|
||||
|
||||
User-managed provider settings can still affect authentication. After a real tool turn, check which account and billing
|
||||
identity the runtime used. Do not print tokens or the process environment. The configured home alone cannot verify the
|
||||
running account.
|
||||
|
||||
Before downgrading to a version without account-home switching, stop the managed seats. Select their accounts using
|
||||
that version's supported setup before resuming. Older versions ignore the saved selection and may launch with inherited
|
||||
or default credentials. Rolling back the binary alone does not preserve the selected billing account.
|
||||
|
||||
OpenRig leaves the imported team file unchanged. Exporting and reimporting a team does not preserve account-home
|
||||
isolation, so configure it explicitly for each new rig. If a switch is interrupted, inspect it with `rig seat status`
|
||||
and follow the printed recovery command. Wait until the outcome is known before starting another switch.
|
||||
- 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. 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. `XDG_RUNTIME_DIR` itself is not forwarded, because D-Bus clients look for the session bus at `$XDG_RUNTIME_DIR/bus` and would reach the keyring and `systemctl --user`. `DBUS_SESSION_BUS_ADDRESS`, `XDG_SESSION_TYPE`, `XDG_CURRENT_DESKTOP` and `SSH_AUTH_SOCK` are not forwarded either. 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, and use the daemon's `CODEX_HOME` 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`, `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.
|
||||
|
||||
**Do not put secrets in `env:` or `--env`.** The values are not secret, and nothing here encrypts or hides them (see #306):
|
||||
|
||||
@@ -662,52 +375,6 @@ and follow the printed recovery command. Wait until the outcome is known before
|
||||
|
||||
Use `env:` for settings only. Credentials belong in the provider's own login or a secret store the seat reads at run time.
|
||||
|
||||
### Switching credentials while keeping the conversation
|
||||
|
||||
`rig provider switch` resumes a running seat in its existing home and pane. A stopped Claude Code
|
||||
seat isn't relaunched: the switch saves the new reference for its next launch, but only when the
|
||||
launch it predicts resumes the seat's conversation. That prediction covers the snapshot that
|
||||
launch would use and restore's automatic periodic carry. It then prints the launch that resumes:
|
||||
`rig launch <rig> <seat> --snapshot-id <id>` for a saved conversation, or a plain
|
||||
`rig launch <rig> <seat>` when the resume comes through the periodic carry, which naming a
|
||||
snapshot would turn off. Otherwise it refuses and changes nothing. `rig provider precheck` gives
|
||||
the same answer.
|
||||
For Claude Code, select an API-key file, a subscription-token file, or the home's stored login:
|
||||
|
||||
```sh
|
||||
rig provider precheck --seat dev-a@my-rig --to-account api-budget --api-key-file /secure/claude-api-key
|
||||
rig provider switch --seat dev-a@my-rig --to-account api-budget --api-key-file /secure/claude-api-key
|
||||
rig provider switch --seat dev-a@my-rig --to-account plan --token-file /secure/claude-plan-token
|
||||
rig provider switch --seat dev-a@my-rig --stored-login
|
||||
```
|
||||
|
||||
The API-key file must be private (0600 or 0400), nonempty, and at most 8192 bytes, containing
|
||||
one key with optional surrounding whitespace. Only the reference is stored. The child reads the
|
||||
key at launch; key bytes never enter command arguments, snapshots or switch reports. Classic
|
||||
launches require a supported POSIX pane shell. Missing/invalid API-key files prevent launch.
|
||||
Settings that could override the key cause a refusal; OpenRig does not edit those settings.
|
||||
Native managed settings and a signed-in Claude gateway cannot be ruled out; Claude may require API-key approval before its
|
||||
first turn. Check `/status` after a real tool turn to confirm the authentication method.
|
||||
|
||||
For Codex, a saved native API-key login is selected with the existing `--to-account <name>`
|
||||
path, exactly like a saved subscription login. Prepare it using native `codex login --with-api-key`
|
||||
with the key on stdin, then `rig auth save --codex-home <home> <name>`; never put a key in argv.
|
||||
Use the seat's existing home. Preparing a login replaces that home's active credentials, so do it
|
||||
before running seats there, or prepare/import a native auth profile separately. Switches preserve
|
||||
an outgoing credential copy and report peers sharing the home. A native keyring or administrator
|
||||
policy can override `auth.json`; the report remains unverified until native account verification.
|
||||
|
||||
Select or clear an account home in a separate operation: `--api-key-file` cannot be combined with
|
||||
`--account-home` or `--clear-account-home`. A credential switch inside an already selected home
|
||||
keeps that home.
|
||||
|
||||
Both paths retain the conversation and launch settings, interrupt in-flight work, and report
|
||||
uncertain restarts without retrying. They do not move account homes or start a fresh conversation.
|
||||
The caller chooses subscription-first order and configures/enforces the API budget; this command
|
||||
neither selects accounts automatically nor imposes a spending limit. Downgrade only after switching
|
||||
API-key Claude seats back to subscription credentials: older OpenRig versions cannot read the new
|
||||
credential kind correctly.
|
||||
|
||||
### Choosing the Claude instruction file
|
||||
|
||||
OpenRig writes its instructions for Claude Code members into managed blocks in
|
||||
@@ -769,23 +436,18 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
|
||||
| `id` | string | yes | — | Member identifier. Must not contain dots or `@`. Must be unique within the pod. |
|
||||
| `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. To keep the profile, put full access in the profile itself, or record a workspace-write or read-only `codex_permissions` envelope, which keeps it. |
|
||||
| `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). |
|
||||
| `claude_token` | mapping | no | — | Claude-only `{ label, file }`: the seat's own subscription token file, by absolute path, used from its first launch; see "A seat's own subscription token" under [launch environment](#launch-environment). |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `claude_mcp` | `none` | no | normal loading | Claude members only. Disable MCP server loading for this agent on every launch and resume. Omit the field to keep Claude's normal configuration; see [Claude MCP loading](#claude-mcp-loading-runtime-claude-code). |
|
||||
| `launch_wrapper` | string[] | no | — | Local built-in `pi` and `codex` members only. Literal argument prefix prepended before the managed launch command on fresh launch, relaunch, rebuild, resume, fork, restore and handover; `[]` explicitly clears a stored wrapper when re-applying an authored spec, while omitting the key leaves an existing seat's stored choice unchanged. See [Launch wrapper](#launch-wrapper-runtime-pi-and-codex). |
|
||||
| `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. |
|
||||
| `plugin_channels` | list | no | — | Claude members only. Plugin channels this agent registers for, as `plugin:<name>@<marketplace>` entries, passed as one `--channels` on every launch; see [Claude plugin channels](#claude-plugin-channels-runtime-claude-code). |
|
||||
| `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). |
|
||||
@@ -794,99 +456,6 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
|
||||
| `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 MCP loading (`runtime: claude-code`)
|
||||
|
||||
Set `claude_mcp: none` on an individual member to launch it without MCP servers:
|
||||
|
||||
```yaml
|
||||
members:
|
||||
- id: reviewer
|
||||
agent_ref: local:agents/reviewer
|
||||
profile: default
|
||||
runtime: claude-code
|
||||
cwd: .
|
||||
claude_mcp: none
|
||||
```
|
||||
|
||||
OpenRig passes `--strict-mcp-config --mcp-config '{"mcpServers":{}}'` to
|
||||
Claude for this seat. Strict mode selects only the supplied empty server set,
|
||||
excluding servers from user, project, team and plugin configuration. It does
|
||||
not disable plugin skills or change the seat's permission mode. The choice is
|
||||
saved on the seat and carried through fresh starts, forks, resumes (including
|
||||
saved startup context) and handover successors. Export and bundle round trips
|
||||
preserve it. The same field is supported on legacy `nodes` declarations.
|
||||
|
||||
Other agents in the same directory keep their own choices; OpenRig does not
|
||||
rewrite shared MCP configuration files. Omit the field for Claude's normal
|
||||
loading. A later launch from an authored spec without the field clears the
|
||||
seat's saved opt-out once the launch is accepted for startup; a rejected
|
||||
launch leaves the choice unchanged. Restarting from saved context keeps it.
|
||||
Only `none` is accepted, and the field is invalid on other runtimes. If the
|
||||
seat switches to another runtime, the Claude choice stays dormant and applies
|
||||
again if it switches back. A currently running Claude process is unaffected.
|
||||
Export describes the current runtime and omits a dormant Claude choice. YAML
|
||||
exports name each affected agent in a `# Not carried:` comment, which `rig export`
|
||||
also prints after writing the file; JSON exports keep their existing shape. If
|
||||
you rebuild from that export and switch back to Claude, restore `claude_mcp: none`
|
||||
in the Claude declaration. Re-check this setting after downgrading
|
||||
OpenRig too: older versions can ignore it.
|
||||
|
||||
Shutdown resume probes and the displayed native recovery commands (including
|
||||
the conversation picker) also carry the saved MCP choice. If shutdown cannot
|
||||
read that choice, it skips the probe and retains the token; shutdown proceeds.
|
||||
|
||||
This opt-out is incompatible with an active enterprise `managed-mcp.json`:
|
||||
[Claude rejects strict overrides of that managed server set](https://code.claude.com/docs/en/managed-mcp#servers-passed-with---mcp-config-or---strict-mcp-config).
|
||||
OpenRig keeps the requested opt-out flags, so startup and resume remain subject
|
||||
to that native refusal; it does not fall back to loading servers. Use a host
|
||||
without that managed configuration, or omit the opt-out to use the managed set.
|
||||
|
||||
### Claude plugin channels (`runtime: claude-code`)
|
||||
|
||||
Set `plugin_channels` on a Claude member to register that agent for channel
|
||||
notifications (messages a plugin's MCP server pushes into the session):
|
||||
|
||||
```yaml
|
||||
members:
|
||||
- id: support
|
||||
agent_ref: local:agents/support
|
||||
profile: default
|
||||
runtime: claude-code
|
||||
cwd: .
|
||||
plugin_channels:
|
||||
- plugin:example-channel@example-marketplace
|
||||
```
|
||||
|
||||
OpenRig passes `--channels plugin:example-channel@example-marketplace` to this
|
||||
agent's Claude on every launch, resume, fork, restore and handover. Several
|
||||
entries follow one `--channels`, space-separated, and OpenRig's own session
|
||||
options come after them. Claude Code 2.1.316 hides `--channels` from
|
||||
`claude --help`, so it isn't listed there. An agent without the field gets no
|
||||
`--channels`, and nothing is added for other agents or other runtimes; there is
|
||||
no rig-wide or environment default.
|
||||
|
||||
Each entry is `plugin:<name>@<marketplace>`, both names made of letters, digits,
|
||||
`.`, `_` and `-`, starting with a letter or digit. `server:` entries and Claude's
|
||||
`--dangerously-load-development-channels` aren't supported. Combining a non-empty
|
||||
channel list with `claude_mcp: none` produces a validation warning: that setting
|
||||
starts Claude without MCP servers (plugin servers included), so no channel could
|
||||
register. OpenRig keeps both settings and launches the member normally. Remove
|
||||
`claude_mcp: none` to use the channels, or remove `plugin_channels` to keep the MCP
|
||||
opt-out. OpenRig doesn't install plugins or marketplaces, or change the settings
|
||||
that admit a channel: whether each channel registers is Claude's decision (the plugin installed, and
|
||||
admitted by your organisation's channel settings or Claude's approved list).
|
||||
|
||||
Selecting a channel for one agent doesn't isolate what the plugin shares with
|
||||
other agents. A token or other credential the plugin keeps in its own
|
||||
configuration is the same for every agent that loads that plugin.
|
||||
|
||||
The selection is saved with the seat's other launch additions when the seat
|
||||
starts from the spec, and starting it from a spec without the field removes it.
|
||||
`rig seat launch --plan` and `rig skill loadout --seat` list it, a fork keeps it,
|
||||
and `rig bundle create` and `rig export` carry it in the member; a bundle's
|
||||
before-install view names each channel as a need. A running Claude process is
|
||||
unaffected.
|
||||
|
||||
### Claude Advisor (`runtime: claude-code`)
|
||||
|
||||
Set `advisor` independently of the primary `model`:
|
||||
@@ -917,40 +486,14 @@ This requires a Claude Code version that supports `--advisor`. OpenRig never wri
|
||||
choose different Advisors. When Advisor is unset, no flag is emitted and Claude's
|
||||
own defaults/settings apply. Other runtimes ignore this field.
|
||||
|
||||
### Launch wrapper (`runtime: pi` and `codex`)
|
||||
|
||||
Set `launch_wrapper` on a local built-in `pi` or `codex` member to prefix its managed launch command with a literal argv vector:
|
||||
|
||||
```yaml
|
||||
members:
|
||||
- id: impl
|
||||
agent_ref: local:agents/impl
|
||||
profile: default
|
||||
runtime: codex
|
||||
cwd: .
|
||||
launch_wrapper: ["launcher", "exec", "--profile", "seat-a", "--"]
|
||||
```
|
||||
|
||||
- **Scope and literal argv:** Supported only on local built-in `runtime: pi` and `runtime: codex` members (and legacy flat `pi`/`codex` nodes); other runtimes (`claude-code`, `omp`, `terminal`, `stub` and community harnesses) reject the field, including `[]`. There is no rig-level, pod-level or fork inheritance: a fork uses its target member's own `launch_wrapper`. Every element must be a string without NUL, CR or LF characters and is quoted literally with `shellQuote`, preserving argument order, spaces, quotes, shell metacharacters and empty non-executable arguments without shell or template expansion.
|
||||
- **Executable resolution and `exec` contract:** `launch_wrapper[0]` must be a non-empty bare command filename on the daemon's launch `PATH` or an absolute path (no relative paths with slashes such as `./x` or `../x`, no `/` in a bare name, and no shell aliases or functions). Preflight and launch resolve `launch_wrapper[0]` to an executable file using the daemon's launch `PATH` and daemon working directory (before the seat's `rig` bin directory is prepended). For `codex`, the prefix appears immediately before the resolved Codex entry (`'<abs node>' '<abs codex.js>' …` or `'<abs codex>' …`) inside the public seat environment envelope, preserving the session's TTY stdio. For `pi`, OpenRig's host runner stays host-side and unwrapped (`node <pi-runner.js> …`), preserving its RPC lifecycle, sidecars, and status reporting; the wrapper prefixes the resolved Pi child executable (`pi --mode rpc`) inside that runner, and the child's stdio pipes preserve RPC stdin, stdout, and stderr (not a TTY). The wrapper must prepare its environment and `exec` the target command in place so the child process preserves working directory, child stdio pipes (for Pi) or TTY stdio (for Codex), OpenRig session/identity environment, process lineage, exit status and signals; supervising wrappers that keep a parent process are unsupported.
|
||||
- **Fail-closed (never unwraps):** When a wrapper is selected, OpenRig applies it across fresh launch, relaunch, rebuild, native resume, fork, restore, handover successor and supported runtime switches, and never falls back to bare launch. For `codex`, wrapper resolution and launch environment assembly occur within the shell prefix: if the pane shell is non-POSIX (`nu`, `fish`, etc.), OpenRig session identity is missing or mismatched, `SeatLaunchEnvironment` is unavailable, or `launch_wrapper[0]` is missing or unexecutable, Codex launch fails closed (`OpenRig: launch_wrapper unavailable; no agent started.`). For `pi`, wrapper lookup resolves against the daemon launch `PATH` and daemon working directory before state creation or process dispatch, failing closed before pending launch state if `launch_wrapper[0]` cannot be resolved or executed; runtime child wrapper failures are subsequently reported via the host runner sidecars and markers. If Pi encounters a long launch command (`launch_path_too_long`), it retries execution using the raw host runner command; because that command carries the exact same child wrapper arguments, the Pi child executable never runs unwrapped. In both runtimes, malformed stored wrapper metadata fails closed during reads.
|
||||
- **Persistence, `[]` clear vs absent retain, and recreation:** On a new seat, omitting `launch_wrapper` or declaring `launch_wrapper: []` stores no wrapper (`NULL`). When an authored spec is applied to an existing seat, omitting `launch_wrapper` retains the seat's stored wrapper, `launch_wrapper: []` explicitly clears it, and a non-empty array replaces it. A rig that is recreated or imported, including a replacement import, creates new nodes from the incoming spec and uses only what that spec declares, so omitting the field gives the new seat no wrapper. Stored wrapper definitions remain strictly validated; corrupted wrapper rows fail closed during reads without silent unwrap. Restoring an older snapshot that predates `launch_wrapper` preserves the seat's current stored wrapper. First-start retry (`rig launch <rig> <member>`) compares the retained declaration with the node row and refuses to add, change or remove a wrapper on retry. Switching a wrapped seat to an unsupported runtime (`claude-code`, `omp`, etc.) is refused before side effects. There is no live `rig seat` edit verb or management route for `launch_wrapper`; to change or clear a wrapper, edit the `RigSpec` (use `launch_wrapper: []` to clear) and use the supported create/recreate/apply flow. Export (`rig export`) and cached node inventory / `rig ps --nodes --json` (including `--full` and `--fields launchWrapper`) include `launch_wrapper` / `launchWrapper` when configured and omit it when absent.
|
||||
- **Boundary and limits:** A `launch_wrapper` is only an argv prefix. Configuring or observing a prefix is **not** proof of sandbox isolation, tooling availability, memory wiring or governance enforcement; verify the wrapper's own guarantees and the runtime independently.
|
||||
|
||||
### 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`. A confirmed account switch moves the seat into an isolated `state/pi/account-switches/<generation>/<session>/` directory; subsequent launches and resumes use that committed directory.
|
||||
- **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 (or the corresponding committed account-switch directory after a switch). 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`, `NODE_OPTIONS` for the selected-account Node preload, 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.
|
||||
|
||||
- **Account homes:** Select an account with `rig provider switch --account-home`, or declare an absolute `PI_CODING_AGENT_DIR` in the seat’s `env:` as its configured account home; the persisted selection takes precedence. `~` is not expanded. A relative value is ignored for managed account selection (and reported by status), leaving the managed seat login in use when no persisted account is selected. The chosen directory must already contain a readable `auth.json`; env-only credentials and wrappers that create credentials later are outside this prepared-account path. Launches with neither a persisted selection nor a declared absolute account home retain their existing credential behaviour. The runner links that file into its managed agent directory before starting Pi, including launches through `launch_wrapper`; settings, models, extensions and sessions remain seat-local. Selected accounts use a Node preload shim that redirects only their auth-lock filesystem operations to the chosen account’s `auth.json.lock` pathname, matching direct Pi use even when the source `auth.json` is a symlink. The preload removes its own `NODE_OPTIONS` entry before Pi starts tools, preserving other Node options; descendants and unrelated agent directories keep their normal filesystem behaviour. This requires a Node-hosted Pi executable and a wrapper that preserves `NODE_OPTIONS`; compiled executables and wrappers that discard the preload are not covered. Removing the selection removes only the managed auth link, not the source account file. A missing account file stops the launch. Direct launch refuses to replace an existing seat-local login or an unmanaged auth link; use a confirmed account switch to preserve that login. An ambient pane-shell `PI_CODING_AGENT_DIR` does not select an account; only the resolved seat configuration or persisted account selection does. Persisted account selections survive resume and cannot be bypassed by the long-command fallback; explicit selections clear inherited provider-key overrides. Use `rig provider switch --account-home` or `--clear-account-home` to change an existing Pi seat through a confirmed successor with a fresh conversation and handover. The account reference and committed state directory survive daemon restart and resume; unconfirmed successors leave the old account and state intact. Clearing returns to the configured home, or the original managed seat login if no home was configured. Status reports a configured account home when present; without one, its home is null because Pi uses the managed seat directory rather than the operator's default login.
|
||||
|
||||
**Upgrade note for prepared accounts:** An existing built-in Pi seat that declares an absolute `PI_CODING_AGENT_DIR` now selects that home on fresh launch, restart, resume and restore, even without a stored account override. Before upgrading, either prepare a readable `auth.json` there or remove that declaration to keep the managed seat login. Env-only accounts and wrappers that create the file after Pi starts are not supported by this path. Migration 151 preserves switch history; it does not create or copy credentials, or rewrite existing seat declarations. Relative values remain available to community harnesses and are not globally removed.
|
||||
|
||||
- **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.
|
||||
- **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`, `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`)
|
||||
|
||||
@@ -1013,11 +556,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
|
||||
|
||||
@@ -1100,22 +639,6 @@ the modified topology, run `rig snapshot <rig-id>` and use its returned ID with
|
||||
|
||||
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.
|
||||
|
||||
For member `startup:`, export uses that seat's retained launch recipe (or its pre-launch materialization) rather than guessing from projected files. If that source is unavailable or a member's files came from a different rig root, the YAML names the member's omitted startup; JSON export names omissions in the `X-OpenRig-Export-Warnings` response header. An older seat without a recipe may have had no member startup or an unretained one: inspect its source before re-importing. When member startup files are present in the export, `rig export` checks required paths against the output folder and warns for missing files. The export does not copy member startup files.
|
||||
|
||||
`rig export` includes the rig's saved culture file and startup block, and writes
|
||||
saved pod startup files and actions under their pod. The file and action order,
|
||||
delivery hints, required flags, orientation and `applies_on` selections are retained.
|
||||
Pod actions are saved for export when a new rig is created; older rigs and pods
|
||||
added from a separate expansion may not have those declarations recorded. Export
|
||||
does not reconstruct them from a member's combined startup context; keep the source
|
||||
spec when those declarations are needed. This does not change which startup actions
|
||||
are inherited when a seat is added later.
|
||||
|
||||
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, pod or member
|
||||
file; missing optional files do not trigger that warning.
|
||||
|
||||
### Files
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
@@ -1124,14 +647,14 @@ file; missing optional files do not trigger that warning.
|
||||
| `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
|
||||
|
||||
| Hint | Behavior |
|
||||
|------|----------|
|
||||
| `auto` | System chooses based on file type and context. |
|
||||
| `guidance_merge` | Merged as a managed block into the runtime's guidance file: for Claude, the `managed_blocks` file (`CLAUDE.md` or `CLAUDE.local.md`); for Codex, Pi and OMP, `AGENTS.md`, except that a fresh Pi seat normally uses its own agent folder's and a fresh Codex 0.164.0+ seat normally gets launch developer instructions (the conditions are in [Codex guidance at launch](agent-startup-guide.md#codex-guidance-at-launch) and [Runtime Differences](agent-startup-guide.md#runtime-differences)). Delivered before harness boot. |
|
||||
| `guidance_merge` | Merged as a managed block into the runtime's guidance file: for Claude, the `managed_blocks` file (`CLAUDE.md` or `CLAUDE.local.md`); for Codex, Pi and OMP, `AGENTS.md`. Delivered before harness boot. |
|
||||
| `skill_install` | Installed as a skill in the runtime's skill directory. Delivered before harness boot. |
|
||||
| `send_text` | Sent as text to the agent's terminal after the harness is ready. Requires the agent TUI to be active. |
|
||||
|
||||
@@ -1142,8 +665,8 @@ file; missing optional files do not trigger that warning.
|
||||
| `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
|
||||
|
||||
@@ -1356,7 +879,6 @@ spec can pass `rig spec validate` and then fail at preflight or import. Run
|
||||
24. Startup action `idempotent` is a required boolean.
|
||||
25. Non-idempotent actions must not include `restore` in `applies_on`.
|
||||
26. `applies_on` values must be from: `fresh_start`, `restore`.
|
||||
27. `launch_wrapper` is valid only on `pi` and `codex` members (`[]` included); it must be an array of strings without NUL/CR/LF, and `launch_wrapper[0]` (when non-empty) must be an absolute path or a bare command name on the daemon `PATH`.
|
||||
|
||||
---
|
||||
|
||||
@@ -1376,15 +898,3 @@ These are the built-in specs shipped with OpenRig. Read them as worked examples.
|
||||
Also shipped, in the same tree: `launch/kernel`, OpenRig's own team, which starts automatically. `first-project` is
|
||||
starter's old name and still resolves to it. `workshop`, the four-agent middle team, is a rig bundle rather than a
|
||||
built-in. List the built-ins with `rig specs ls --kind rig`.
|
||||
|
||||
To omit built-in rigs, agents and workflows from the shared spec inventory, run
|
||||
`rig config set specs.builtins_visible false`. This changes the CLI, TUI and other
|
||||
consumers of the library listing; user specs remain visible. Use
|
||||
`rig specs ls --include-builtins` to list them deliberately, and set the value
|
||||
back to `true` to restore the default. Explicit `show`, `preview`, and launch
|
||||
lookups can still find hidden built-ins. When built-ins are hidden and a user
|
||||
spec has the same name, the user spec wins the name lookup; use the built-in's
|
||||
ID from `rig specs ls --include-builtins --json` to select it. With built-ins visible,
|
||||
same-name matches remain ambiguous. This is an inventory preference, not an
|
||||
access-control boundary; other endpoints and on-disk files can still expose the
|
||||
built-ins.
|
||||
@@ -86,9 +86,3 @@ 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)).
|
||||
@@ -1,77 +0,0 @@
|
||||
# Runner conformance check
|
||||
|
||||
A runnable check for runner authors. Point it at your runner and it reports which promises of the
|
||||
[harness and runner contract](../harness-runner-contract.md) your runner keeps. It needs Node and tmux, and no OpenRig
|
||||
daemon, model, credentials or network.
|
||||
|
||||
```sh
|
||||
node check-runner.mjs --runner 'my-runner --agent-command {agent}' --name my-harness --resume --first-input
|
||||
```
|
||||
|
||||
- **`{agent}`** is replaced with the path of the fake ACP agent beside this file. Your runner starts it where it would
|
||||
start your real agent. It accepts any arguments your runner adds.
|
||||
- **`--name`** is your preset's name, which your posts carry as `runtime`.
|
||||
- **`--resume` and `--first-input`** say your preset declares `resume: true` and `first_input: true`. Without them,
|
||||
those cases are skipped, because OpenRig wouldn't pass that input.
|
||||
- **`--cases 2,4`** runs only some cases. `--json` prints a machine-readable report. `--keep` keeps each launch's
|
||||
folder: the agent's message log, the launch record and the scenario.
|
||||
- **`--wait-ms 20000`** waits longer for each step (default 8000), for a runner whose agent takes a while to start. The check's shorter settling times, and the fake agent's own delays and holds, grow in proportion.
|
||||
- **`--pass-env NAME`** passes one more variable through. Otherwise the runner gets only `PATH`, `HOME`, `USER`,
|
||||
`SHELL`, `LANG` and `TMPDIR`, plus the OpenRig variables.
|
||||
- **The exit status** is 0 when nothing fails, 1 when a case fails, and 2 for a usage or setup problem. An advisory result (4c, 7c) never changes it.
|
||||
|
||||
In an installed OpenRig, the check is at `$(npm root -g)/@openrig/cli/daemon/docs/reference/runner-conformance/`.
|
||||
|
||||
## How it runs
|
||||
|
||||
- **Your runner** starts in a private tmux server, with the environment a runner-preset launch gives it:
|
||||
`OPENRIG_URL`, `OPENRIG_ACTIVITY_HOOK_TOKEN`, `OPENRIG_SESSION_NAME`, `OPENRIG_NODE_ID`, `OPENRIG_RUNTIME`,
|
||||
`OPENRIG_OCCUPANT_GENERATION`, `OPENRIG_LAUNCH_RESULT` and `OPENRIG_LAUNCH_ATTEMPT`, plus `OPENRIG_RESUME_REF` or
|
||||
`OPENRIG_FIRST_INPUT` when a case needs them. `OPENRIG_HOME` is an empty folder, so nothing reaches a real OpenRig.
|
||||
- **The fake agent** speaks ACP v1 over stdio. Each case gives it a scenario, such as a slow handshake, a permission
|
||||
request, a turn that ends cancelled, or an exit mid-turn. It logs every message in both directions. Its own request
|
||||
ids are numbers, as JSON-RPC agents commonly use, so a runner passes them on unchanged.
|
||||
- **A local endpoint** stands in for `POST /api/activity/hooks`. It records each post, and refuses one without the
|
||||
launch's bearer token, as OpenRig does.
|
||||
- **In cases 4 and 4b,** it also stands in for the answered path's three routes: submit, read with `waitSeconds`, and
|
||||
withdraw. It checks a submit the way OpenRig does: the seat and generation, `nativeRef` as a non-empty string, `turnId`
|
||||
and `requestId` as a string or a number (a number reads as its decimal string). Options a person can't be shown (not
|
||||
2-4 of them, ids that repeat or use other characters, or other kinds) are permitted with the agent's allow option, as OpenRig does. Each launch scripts the answer,
|
||||
the permit or the refusal it gives, or closes the connection unanswered.
|
||||
- **Where a launch scripts none of that,** those routes answer 404, as an OpenRig without the answered path does, so
|
||||
the runner permits.
|
||||
- **Input arrives the way `rig send` delivers it:** the text is pasted as one bracketed paste with raw newlines, then
|
||||
Enter is pressed. Ordinary messages carry a From/To envelope.
|
||||
|
||||
## The cases
|
||||
|
||||
| Case | Contract section | What it checks |
|
||||
|---|---|---|
|
||||
| 1 | 3 | No launch record until `session/new` is answered. Then a record holding format 1, this attempt, the preset's name, the agent's conversation id and a `producer` |
|
||||
| 1b | 3, 4 | A handshake that fails at `initialize` or `session/new` (an error or an exit) writes no conversation in a launch record, and posts no accepted prompt or idle report |
|
||||
| 2 | 5, 6 | One pasted block becomes exactly one `session/prompt`, byte for byte. A line reading `/abort` inside a block isn't control. A block sent mid-turn waits for the next turn. A block that is exactly `/abort`, or Ctrl-C outside a paste, sends one `session/cancel`. `Stop/cancelled` is posted only when the agent itself returns `cancelled` |
|
||||
| 3 | 4 | `UserPromptSubmit/accepted` with a `turnId`, then `Stop/end_turn`. Every post carries the contract's fields, the hook token, this seat's identity and a rising `sequence`. `livenessSeconds`, when given, is from 1 to 3600 |
|
||||
| 3b | 4 | **Optional, from 0.7.9; skipped when the runner sends no beat.** In a long, quiet turn, each `active/liveness` beat names the accepted turn, `sequence` rises with the runner's own `occurredAt` across every post, beats come more often than every `livenessSeconds` ÷ 2, and none follows the turn's `Stop`. While a person is asked, no beat is posted; after the answer, `active/permission_resolved` names the turn and the ask, before any beat. A permit at submit opens no ask, so the quiet turn's beats go on |
|
||||
| 4 | 7 | When no one can be asked, the request is permitted: an OpenRig without the answered path (404) at once, and one the runner can't reach, each get the agent's `allow_once` option, never its `allow_always` while `allow_once` is offered. The turn then reports as usual, with no `permission_prompt`. Only `denied_by_seat`, or a request with no allow option, is answered at once with a refusal, never an allow option. `Notification/permission_prompt` is then posted, and for `denied_by_seat` the pane shows what was asked (the tool call's title). No idle `Stop` or beat is posted while that attention is held, even when `/abort` then cancels the turn. The next prompt is accepted as usual |
|
||||
| 4b | 7 | The live request is submitted with the hook token, its `requestId` as the agent's JSON-RPC id, passed unchanged, the accepted prompt's `turnId`, this conversation and generation, and the agent's options as offered. The runner doesn't post `permission_prompt` itself for a created ask, and each read waits at most 25 s. The chosen option, allow or deny, reaches the agent once, and the turn then reports as usual. OpenRig's `permitted`, at submit or at a read, reaches the agent the same way, and a read that carries the person's own words (`said`) still gives the agent the read's option. A refusal is read by its code, never its status: one section 7's table marks "permits" (`duplicate_request`, `activity_grant_pending`, `activity_grant_revoked`), a read that gets no answer, and a status or read state the section doesn't list (500, `deferred`) each give the agent its `allow_once` option. A refusal saying the request isn't the current occupant's (`stale_generation` at submit, `activity_grant_stale` on a read), and a `refused` (`said_no`), stale or withdrawn read, give the agent `cancelled`, and the turn then ends as usual, with nothing held. With no answer, the request stays pending with no idle report after the turn's accepted prompt, and a pasted message isn't an answer. `/abort` withdraws it with `turn_cancelled`, and an answer after that isn't delivered. A turn's end or the agent's exit while it waits withdraws it with `turn_ended` or `exited`. Once the ask is withdrawn no person is still waiting, so the turn's end is reported as usual. An ask the runner permitted after a read got no answer, or an unlisted one, may still be open, so it's withdrawn at the turn's end too |
|
||||
| 4c | 7 | **Advisory.** A read's `said`, the words of a person who answered in words, is queued to the agent as a prompt, after the turn and after any prompt already waiting. Section 7 lets a runner drop them and still keep the contract, so this result is shown as met or not met and never counts as a pass or a fail, or toward the exit status |
|
||||
| 5 | 4 | Every post carries `OPENRIG_OCCUPANT_GENERATION` |
|
||||
| 6 | 8 | A resume asks the agent to reopen `OPENRIG_RESUME_REF` in the launch's working folder, and records that conversation. The agent's own not-found becomes `error: "missing"`. Any other failure is neither missing nor resumed. No case starts a new conversation |
|
||||
| 7 | 4 | An agent that dies mid-turn gets `Stop/<preset>_exited`, and no idle report for that turn. Posts are taken in the runner's own order, as OpenRig orders them (`occurredAt` when it's a time string, otherwise the post's arrival, then `sequence` when both are positive whole numbers), so an idle report from before the turn began doesn't count, however late it arrives |
|
||||
| 7b | 4 | A failed `session/prompt` posts `Notification/runtime_error`, not a permission request |
|
||||
| 7c | 9 | **Advisory.** Closing the pane ends the agent process. Section 9 marks this duty as proposed, and lets a runner list in `limits` what may keep running, so this result is shown as met or not met and never counts as a pass or a fail, or toward the exit status. The fake agent outlives a hangup here, so the runner has to end it |
|
||||
| 8 | 7 | An unknown request from the agent gets JSON-RPC `-32601`, and the next prompt still goes through |
|
||||
| 9 | 9 | `OPENRIG_FIRST_INPUT`'s exact bytes, newlines and spaces included, are the first prompt |
|
||||
|
||||
## What it doesn't check
|
||||
|
||||
- **How OpenRig reads your posts:** working, idle, needs input, unknown after an exit, a send refused while a
|
||||
permission waits, and an earlier generation not counting. That's OpenRig's side of the contract, and its own tests
|
||||
cover it.
|
||||
- **Your real agent:** a passing check isn't a run with your agent. Section 10's real check, with your agent under a
|
||||
runner preset, is still the useful evidence. Passing this is evidence for the versions you ran, not certification.
|
||||
- **The model handoff:** `OPENRIG_MODEL` is mapped to your harness's own setting, with no wire this check can see.
|
||||
- **A resume for a preset without `resume`:** OpenRig doesn't pass `OPENRIG_RESUME_REF` to it.
|
||||
- **Restarting the agent after a failed handshake:** the contract doesn't rule on that.
|
||||
- **The chosen person's side of an ask:** who may answer, the Feed and Slack are OpenRig's side, and its own tests
|
||||
cover them. Routing an ask to a seat is a later change.
|
||||
@@ -1,818 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// OpenRig runner conformance check: point it at your runner and see which promises of the runner contract
|
||||
// (docs/reference/harness-runner-contract.md) it keeps. It needs Node and tmux, and no OpenRig daemon:
|
||||
// - your runner starts in a private tmux server, with the environment a runner-preset launch gives it;
|
||||
// - the fake ACP agent beside this file stands in for your agent (your runner starts it where it would start the real one);
|
||||
// - a small local endpoint stands in for OpenRig's activity route, and records what your runner posts;
|
||||
// - input arrives the way `rig send` delivers it: one bracketed paste with raw newlines, then Enter.
|
||||
//
|
||||
// node check-runner.mjs --runner '<command>' [--name <preset name>] [--resume] [--first-input]
|
||||
// [--cases 1,2,4] [--wait-ms 8000] [--pass-env NAME] [--json] [--keep]
|
||||
//
|
||||
// `{agent}` in the command is replaced with the fake agent's path, an executable that takes any arguments your runner
|
||||
// adds. For example: --runner 'my-runner --agent-command {agent}'. --resume and --first-input say your preset declares
|
||||
// `resume: true` and `first_input: true`; without them those cases are skipped, since OpenRig wouldn't pass that input.
|
||||
// The runner gets a clean environment: PATH, HOME, USER, SHELL, LANG and TMPDIR, the OpenRig variables, and any
|
||||
// --pass-env names. Exit status: 0 when nothing fails, 1 when a case fails, 2 for a usage or setup problem.
|
||||
//
|
||||
// It checks the runner's side only. How OpenRig reads those posts (working, idle, needs input) is OpenRig's own tests'
|
||||
// job, and one real run with your agent is still the useful check (section 10 of the contract). Passing this is
|
||||
// evidence for the versions you ran, not certification.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const usage = "usage: node check-runner.mjs --runner '<command with {agent}>' [--name <preset>] [--resume] [--first-input] [--cases 1,2] [--wait-ms 8000] [--pass-env NAME] [--json] [--keep]";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const opts = { name: "runner", cases: undefined, passEnv: [], resume: false, firstInput: false, json: false, keep: false, waitMs: 8000 };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const flag = argv[i];
|
||||
const value = () => { if (i + 1 >= argv.length) throw new Error(`${flag} needs a value`); return argv[++i]; };
|
||||
if (flag === "--runner") opts.runner = value();
|
||||
else if (flag === "--name") opts.name = value();
|
||||
else if (flag === "--cases") opts.cases = value().split(",").map((c) => c.trim()).filter(Boolean);
|
||||
else if (flag === "--pass-env") opts.passEnv.push(value());
|
||||
else if (flag === "--wait-ms") opts.waitMs = Number(value());
|
||||
else if (flag === "--resume") opts.resume = true;
|
||||
else if (flag === "--first-input") opts.firstInput = true;
|
||||
else if (flag === "--json") opts.json = true;
|
||||
else if (flag === "--keep") opts.keep = true;
|
||||
else throw new Error(`unknown option ${flag}`);
|
||||
}
|
||||
if (!opts.runner) throw new Error("--runner is required");
|
||||
if (!opts.runner.includes("{agent}")) throw new Error("--runner must contain {agent}, where your runner takes its agent command");
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(opts.name)) throw new Error("--name must be a preset name (letters, digits, '.', '_' or '-')");
|
||||
if (!(opts.waitMs > 0)) throw new Error("--wait-ms must be a positive number");
|
||||
return opts;
|
||||
}
|
||||
|
||||
let opts;
|
||||
try { opts = parseArgs(process.argv.slice(2)); } catch (error) { console.error(`${error.message}\n${usage}`); process.exit(2); }
|
||||
if (spawnSync("tmux", ["-V"]).status !== 0) { console.error("tmux is required: the check drives your runner in a tmux pane, as OpenRig does."); process.exit(2); }
|
||||
|
||||
const socket = `openrig-runner-check-${process.pid}`;
|
||||
const tmux = (...args) => spawnSync("tmux", ["-L", socket, ...args], { encoding: "utf8" });
|
||||
const sh = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
// Settling times and per-step bounds grow with --wait-ms on a slow machine; they never shrink below these values. The
|
||||
// fake agent's own delays and holds grow by the same factor, so each settle keeps its place against what it measures.
|
||||
const scale = (ms) => Math.round(ms * Math.max(1, opts.waitMs / 8000));
|
||||
async function waitFor(read, ms = opts.waitMs) {
|
||||
const deadline = Date.now() + ms;
|
||||
for (;;) {
|
||||
const value = read();
|
||||
if (value) return value;
|
||||
if (Date.now() > deadline) return undefined;
|
||||
await sleep(50);
|
||||
}
|
||||
}
|
||||
|
||||
// The stand-in for OpenRig's activity route and, for a launch that scripts one, its answered permission routes (section 7).
|
||||
// Each launch has its own token; a request without one is refused, as OpenRig does.
|
||||
const posts = [];
|
||||
const tokens = new Map();
|
||||
const OPTION_ID = /^[A-Za-z0-9_-]{1,40}$/;
|
||||
const ANSWERABLE = new Set(["allow_once", "allow_always", "reject_once", "reject_always"]);
|
||||
const text = (value) => typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
// A JSON-RPC id or prompt number as the runner passed it: a number reads as its decimal string, so 7 and "7" match.
|
||||
const idText = (value) => typeof value === "number" && Number.isFinite(value) ? String(value) : text(value);
|
||||
const server = createServer((req, res) => {
|
||||
let raw = "";
|
||||
req.on("data", (chunk) => { raw += chunk; });
|
||||
req.on("end", () => {
|
||||
const token = (req.headers.authorization ?? "").replace(/^Bearer /, "");
|
||||
let body;
|
||||
try { body = raw ? JSON.parse(raw) : undefined; } catch { body = undefined; }
|
||||
const url = new URL(req.url, "http://stand-in");
|
||||
posts.push({ at: Date.now(), method: req.method, path: url.pathname, query: Object.fromEntries(url.searchParams), token, body, raw });
|
||||
const send = (status, payload) => res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(payload));
|
||||
const h = tokens.get(token);
|
||||
if (req.method === "POST" && url.pathname === "/api/activity/hooks") {
|
||||
if (!h) return send(401, { ok: false });
|
||||
return body ? send(200, { ok: true }) : send(400, { ok: false, code: "invalid_json" });
|
||||
}
|
||||
const ask = url.pathname.match(/^\/api\/activity\/permission-requests(?:\/([^/]+)(\/withdraw)?)?$/);
|
||||
if (!ask) return send(404, { ok: false, code: "not_found" });
|
||||
if (!h) return send(401, { ok: false });
|
||||
// Without a scripted ask, the stand-in is an OpenRig that predates the answered path: 404, so the runner permits.
|
||||
if (!h.asks) return send(404, { ok: false, code: "not_found" });
|
||||
// A scripted `drop` closes the connection unanswered: an OpenRig the runner can't reach.
|
||||
const drop = req.method === "POST" && !ask[1] ? h.asks.script.submit?.drop : req.method === "GET" && h.asks.script.read?.drop;
|
||||
if (drop) return void res.socket.destroy();
|
||||
if (req.method === "POST" && !ask[1]) return submitAsk(h, body, raw, send);
|
||||
const entry = h.asks.byId.get(decodeURIComponent(ask[1] ?? ""));
|
||||
if (!entry) return send(404, { ok: false, code: "not_found" });
|
||||
if (req.method === "GET" && h.asks.script.read) return send(h.asks.script.read.status, { ok: false, code: h.asks.script.read.code });
|
||||
if (req.method === "POST" && ask[2]) {
|
||||
if (!["turn_cancelled", "turn_ended", "exited"].includes(body?.reason)) return send(400, { ok: false, code: "invalid_permission_request" });
|
||||
entry.withdrawn = body.reason;
|
||||
entry.wake();
|
||||
return send(200, { ok: true, ...askState(h, entry) });
|
||||
}
|
||||
if (req.method === "GET" && !ask[2]) {
|
||||
const waitMs = Math.min(Number(url.searchParams.get("waitSeconds") ?? 0), 25) * 1000;
|
||||
const reply = () => send(200, { ok: true, ...askState(h, entry) });
|
||||
if (askState(h, entry).state !== "pending" || !(waitMs > 0)) return reply();
|
||||
const timer = setTimeout(() => { entry.waiters.delete(reply); reply(); }, waitMs);
|
||||
entry.waiters.add(() => { clearTimeout(timer); reply(); });
|
||||
return undefined;
|
||||
}
|
||||
return send(404, { ok: false, code: "not_found" });
|
||||
});
|
||||
});
|
||||
|
||||
// What the scripted ask reads as now: an answer once its time has come, `withdrawn` after a withdraw, otherwise pending. A
|
||||
// `late` answer is what the stand-in reports after a withdraw, so a runner that keeps reading and delivers it is caught.
|
||||
function askState(h, entry) {
|
||||
const script = h.asks.script;
|
||||
if (entry.withdrawn) return script.late ?? { state: "withdrawn" };
|
||||
if (script.answer && Date.now() >= entry.created + (script.answer.afterMs ?? 0)) {
|
||||
const { state, optionId, reason, said } = script.answer;
|
||||
return { state, ...(optionId ? { optionId } : {}), ...(reason ? { reason } : {}), ...(said ? { said } : {}) };
|
||||
}
|
||||
return { state: "pending" };
|
||||
}
|
||||
|
||||
// Submit, checked as OpenRig checks it: the seat and generation, nativeRef as a string, turnId and requestId as a string
|
||||
// or a number. When no one can be asked, OpenRig permits with the agent's allow_once, else allow_always: here when the
|
||||
// script says so, or for options a person can't be shown (2-4 with distinct ids). With no allow option it refuses.
|
||||
function submitAsk(h, body, raw, send) {
|
||||
const script = h.asks.script;
|
||||
const problems = [];
|
||||
if (!body) return send(400, { ok: false, code: "invalid_json" });
|
||||
if (body.sessionName !== h.session) return send(404, { ok: false, code: "session_not_found" });
|
||||
if (!text(body.nativeRef)) problems.push(`nativeRef must be a non-empty string (got ${JSON.stringify(body.nativeRef)})`);
|
||||
for (const key of ["turnId", "requestId"]) if (!idText(body[key])) problems.push(`${key} must be a non-empty string or a number (got ${JSON.stringify(body[key])})`);
|
||||
const options = Array.isArray(body.options) ? body.options : null;
|
||||
if (!options) problems.push("options must be an array");
|
||||
else if (options.some((o) => !text(o?.optionId) || !text(o?.name) || !text(o?.kind))) problems.push("each option needs optionId, name and kind");
|
||||
h.asks.submitted.push({ body, problems });
|
||||
if (problems.length) return send(400, { ok: false, code: "invalid_permission_request", error: problems.join("; ") });
|
||||
if (body.generation !== h.generation) return send(409, { ok: false, code: "stale_generation" });
|
||||
if (script.submit?.status) return send(script.submit.status, { ok: false, code: script.submit.code });
|
||||
const allow = options.find((o) => o.kind === "allow_once") ?? options.find((o) => o.kind === "allow_always");
|
||||
const permit = (reason) => allow ? send(200, { ok: true, state: "permitted", optionId: allow.optionId, reason })
|
||||
: send(422, { ok: false, code: "options_unsupported" });
|
||||
if (script.submit?.permitted) return permit("no one can be asked");
|
||||
if (options.length < 2 || options.length > 4 || new Set(options.map((o) => o.optionId)).size !== options.length
|
||||
|| options.some((o) => !OPTION_ID.test(o.optionId) || !ANSWERABLE.has(o.kind))) return permit("these options can't be shown to a person");
|
||||
const requestId = idText(body.requestId), turnId = idText(body.turnId);
|
||||
const key = JSON.stringify([body.generation, text(body.nativeRef), requestId]);
|
||||
const earlier = h.asks.byKey.get(key);
|
||||
if (earlier) return send(200, { ok: true, askId: earlier.askId, created: false });
|
||||
if ([...h.asks.byKey.values()].some((e) => e.requestId === requestId && e.turnId !== turnId)) return send(409, { ok: false, code: "duplicate_request" });
|
||||
const entry = { askId: `ask-${h.label}-${h.asks.byKey.size + 1}`, requestId, turnId, created: Date.now(), waiters: new Set() };
|
||||
entry.wake = () => { for (const w of [...entry.waiters]) w(); entry.waiters.clear(); };
|
||||
if (script.answer) setTimeout(entry.wake, script.answer.afterMs ?? 0);
|
||||
h.asks.byKey.set(key, entry);
|
||||
h.asks.byId.set(entry.askId, entry);
|
||||
return send(200, { ok: true, askId: entry.askId, created: true });
|
||||
}
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
|
||||
const launches = [];
|
||||
async function launch(label, { scenario = {}, env = {}, asks } = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), `runner-check-${label}-`));
|
||||
const cwd = join(dir, "work");
|
||||
mkdirSync(cwd);
|
||||
mkdirSync(join(dir, "home"));
|
||||
mkdirSync(join(dir, "launch"));
|
||||
const id = randomBytes(4).toString("hex");
|
||||
const h = {
|
||||
label, dir, cwd, token: randomBytes(16).toString("hex"), session: `check-${label}@runner-check`, nodeId: `node-${label}-${id}`,
|
||||
generation: `gen-${id}`, attempt: `attempt-${id}`, recordPath: join(dir, "launch", "record.json"), agentLog: join(dir, "agent.jsonl"),
|
||||
target: `check-${label}`,
|
||||
};
|
||||
if (asks) h.asks = { script: asks, byKey: new Map(), byId: new Map(), submitted: [] };
|
||||
tokens.set(h.token, h);
|
||||
writeFileSync(join(dir, "scenario.json"), JSON.stringify(scenario));
|
||||
const agent = join(dir, "agent");
|
||||
writeFileSync(agent, `#!/bin/sh\nexec ${sh(process.execPath)} ${sh(join(here, "fake-acp-agent.mjs"))} --scenario ${sh(join(dir, "scenario.json"))} --log ${sh(h.agentLog)} "$@"\n`);
|
||||
chmodSync(agent, 0o755);
|
||||
const vars = {
|
||||
PATH: process.env.PATH, HOME: process.env.HOME, USER: process.env.USER, SHELL: process.env.SHELL, LANG: process.env.LANG,
|
||||
TMPDIR: process.env.TMPDIR, TERM: "xterm-256color",
|
||||
...Object.fromEntries(opts.passEnv.map((name) => [name, process.env[name]])),
|
||||
OPENRIG_HOME: join(dir, "home"), OPENRIG_URL: base, OPENRIG_ACTIVITY_HOOK_TOKEN: h.token, OPENRIG_SESSION_NAME: h.session,
|
||||
OPENRIG_NODE_ID: h.nodeId, OPENRIG_RUNTIME: opts.name, OPENRIG_OCCUPANT_GENERATION: h.generation,
|
||||
OPENRIG_LAUNCH_RESULT: h.recordPath, OPENRIG_LAUNCH_ATTEMPT: h.attempt, ...env,
|
||||
};
|
||||
const assignments = Object.entries(vars).filter(([, value]) => value !== undefined).map(([key, value]) => `${key}=${sh(value)}`).join(" ");
|
||||
const command = opts.runner.replaceAll("{agent}", sh(agent));
|
||||
writeFileSync(join(dir, "launch.sh"), `#!/bin/sh\ncd ${sh(cwd)} || exit 1\nexec /usr/bin/env -i ${assignments} /bin/sh -c ${sh(command)}\n`);
|
||||
chmodSync(join(dir, "launch.sh"), 0o755);
|
||||
const started = tmux("new-session", "-d", "-s", h.target, "-x", "200", "-y", "50", join(dir, "launch.sh"));
|
||||
if (started.status !== 0) throw new Error(`tmux couldn't start the runner: ${started.stderr.trim()}`);
|
||||
Object.assign(h, {
|
||||
log: () => !existsSync(h.agentLog) ? [] : readFileSync(h.agentLog, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line)),
|
||||
received: (method) => h.log().filter((e) => e.dir === "in" && e.msg.method === method),
|
||||
sent: (method) => h.log().filter((e) => e.dir === "out" && e.msg.method === method),
|
||||
answerTo: (method) => { const ask = h.sent(method)[0]; return ask && h.log().find((e) => e.dir === "in" && e.msg.id === ask.msg.id && !e.msg.method); },
|
||||
replyTo: (entry) => h.log().find((e) => e.dir === "out" && e.msg.id === entry.msg.id && !e.msg.method),
|
||||
events: (event) => h.log().filter((e) => e.dir === "event" && e.event === event),
|
||||
prompts: () => h.received("session/prompt").map((e) => (e.msg.params?.prompt ?? []).filter((b) => b.type === "text").map((b) => b.text).join("")),
|
||||
posts: () => posts.filter((p) => p.path === "/api/activity/hooks" && (p.token === h.token || p.body?.sessionName === h.session)),
|
||||
permissionCalls: (method, suffix = "") => posts.filter((p) => p.token === h.token && p.method === method
|
||||
&& p.path.startsWith("/api/activity/permission-requests") && p.path.endsWith(suffix)),
|
||||
hooks: (event, subtype) => h.posts().filter((p) => p.body?.hookEvent === event && (subtype === undefined || p.body?.subtype === subtype)),
|
||||
record: () => { if (!existsSync(h.recordPath)) return undefined; try { return JSON.parse(readFileSync(h.recordPath, "utf8")); } catch { return { unreadable: true }; } },
|
||||
paste: (text) => {
|
||||
const file = join(dir, `paste-${randomBytes(3).toString("hex")}`);
|
||||
writeFileSync(file, text);
|
||||
const buffer = `b-${randomBytes(3).toString("hex")}`;
|
||||
tmux("load-buffer", "-b", buffer, file);
|
||||
tmux("paste-buffer", "-t", h.target, "-b", buffer, "-d", "-r", "-p");
|
||||
tmux("send-keys", "-t", h.target, "Enter");
|
||||
},
|
||||
keys: (...keys) => tmux("send-keys", "-t", h.target, ...keys),
|
||||
stop: () => tmux("kill-session", "-t", h.target),
|
||||
screen: () => tmux("capture-pane", "-p", "-t", h.target).stdout ?? "",
|
||||
});
|
||||
launches.push(h);
|
||||
return h;
|
||||
}
|
||||
|
||||
// An ordinary send arrives inside an envelope, so a line in it that reads /abort is still just text.
|
||||
const message = (h, body) => `From: checker@runner-check\nTo: ${h.session}\n---\n${body}`;
|
||||
const nonExitStops = (h, after = 0) => h.hooks("Stop").filter((p) => p.at >= after && !String(p.body?.subtype ?? "").endsWith("_exited"));
|
||||
// Whether post `a` comes before post `b` in the runner's own order, as OpenRig orders them: by `occurredAt` when it's a
|
||||
// non-blank string that reads as a time, otherwise by its arrival; then by `sequence`, when both carry a positive whole number.
|
||||
const postedAt = (p) => {
|
||||
const value = p?.body?.occurredAt;
|
||||
const t = typeof value === "string" && value.trim() ? Date.parse(value) : NaN;
|
||||
return Number.isFinite(t) ? t : p?.at ?? 0;
|
||||
};
|
||||
const sequenceOf = (p) => { const value = p?.body?.sequence; return Number.isSafeInteger(value) && value > 0 ? value : null; };
|
||||
const precedes = (a, b) => postedAt(a) !== postedAt(b) ? postedAt(a) < postedAt(b)
|
||||
: sequenceOf(a) !== null && sequenceOf(b) !== null && sequenceOf(a) < sequenceOf(b);
|
||||
// Non-exit Stops the runner posted after `post` in that order. An idle report from before the turn began (a runner may report
|
||||
// idle at startup) is never one made during it, however late its POST arrives.
|
||||
const stopsAfter = (h, post) => nonExitStops(h).filter((p) => !post || !precedes(p, post));
|
||||
const ready = (h, ms) => waitFor(() => h.record()?.nativeRef && h.record(), ms);
|
||||
|
||||
function result(id, title, { proposed = false } = {}) {
|
||||
const r = { id, title, status: "pass", checks: [], ...(proposed ? { proposed: true } : {}) };
|
||||
r.check = (name, ok, detail) => { r.checks.push({ name, ok: Boolean(ok), ...(ok ? {} : { detail }) }); if (!ok) r.status = "fail"; return ok; };
|
||||
r.skip = (reason) => { r.status = "skip"; r.reason = reason; return r; };
|
||||
return r;
|
||||
}
|
||||
|
||||
const cases = {
|
||||
async "1"() {
|
||||
const r = result("1", "Handshake: the launch record comes only after the conversation is open");
|
||||
const h = await launch("1", { scenario: { newDelayMs: scale(2000), sessionId: "fake-session-1" } });
|
||||
const asked = await waitFor(() => h.received("session/new")[0]);
|
||||
if (!r.check("the runner opens a conversation with session/new", asked, "no session/new arrived")) return r;
|
||||
// Until the agent's answer is logged, there must be no record. The agent logs each message before it sends it, so a
|
||||
// record read before a log that still holds no answer was written before the answer went out. Reading against the
|
||||
// agent's log, not after a fixed settle, keeps a busy machine from reading a correct runner's record as early.
|
||||
let early;
|
||||
await waitFor(() => {
|
||||
const record = h.record();
|
||||
if (h.replyTo(asked)) return true;
|
||||
if (record) early = record;
|
||||
return early;
|
||||
}, scale(2000) + opts.waitMs);
|
||||
r.check("no launch record while session/new is still unanswered", !early, `a record was written early: ${JSON.stringify(early)}`);
|
||||
const record = await ready(h);
|
||||
r.check("a launch record arrives after session/new is answered", record, `no record with a nativeRef at ${h.recordPath}`);
|
||||
if (!record) return r;
|
||||
const answered = h.replyTo(asked);
|
||||
r.check("the record is written after the answer", answered && statSync(h.recordPath).mtimeMs >= answered.at - 5, "the record's time precedes the answer");
|
||||
r.check("format 1, this attempt, this preset", record.format === 1 && record.attempt === h.attempt && record.harness === opts.name,
|
||||
`format=${record.format} attempt=${record.attempt} harness=${record.harness}`);
|
||||
r.check("nativeRef is the agent's conversation id", record.nativeRef === "fake-session-1", `nativeRef=${record.nativeRef}`);
|
||||
r.check("producer names the runner's versions", typeof record.producer === "string" && record.producer.trim(), "no producer");
|
||||
return r;
|
||||
},
|
||||
|
||||
async "1b"() {
|
||||
const r = result("1b", "A failed handshake writes no launch record and claims no turn");
|
||||
for (const [label, scenario] of [["initialize error", { initialize: "error" }], ["initialize exit", { initialize: "exit" }],
|
||||
["session/new error", { newSession: "error" }], ["session/new exit", { newSession: "exit" }]]) {
|
||||
const h = await launch(`1b-${label.replace(/[^a-z]+/g, "-")}`, { scenario });
|
||||
await waitFor(() => h.events("exit")[0] || h.log().some((e) => e.dir === "out" && e.msg.error), opts.waitMs);
|
||||
await sleep(scale(1500));
|
||||
const record = h.record();
|
||||
r.check(`${label}: no conversation in a launch record`, !record?.nativeRef, `record: ${JSON.stringify(record)}`);
|
||||
r.check(`${label}: no accepted prompt or idle report`, h.hooks("UserPromptSubmit").length === 0 && nonExitStops(h).length === 0,
|
||||
`posts: ${h.posts().map((p) => `${p.body?.hookEvent}/${p.body?.subtype}`).join(", ")}`);
|
||||
}
|
||||
return r;
|
||||
},
|
||||
|
||||
async "2"() {
|
||||
const r = result("2", "Input framing, queued messages and cancel");
|
||||
const h = await launch("2", { scenario: { turns: [{}, { holdMs: scale(15000) }, { holdMs: scale(15000), ignoreCancel: true, endAfterCancelMs: scale(300) }, {}] } });
|
||||
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
|
||||
const block = message(h, "first line\n/abort\nthird line");
|
||||
h.paste(block);
|
||||
await waitFor(() => h.prompts().length >= 1);
|
||||
await waitFor(() => nonExitStops(h).length >= 1, scale(3000));
|
||||
await sleep(scale(500));
|
||||
r.check("one pasted block is exactly one session/prompt", h.prompts().length === 1, `got ${h.prompts().length} prompts`);
|
||||
r.check("the prompt holds the whole block, byte for byte", h.prompts()[0] === block, `got ${JSON.stringify(h.prompts()[0])}`);
|
||||
|
||||
h.paste(message(h, "please hold"));
|
||||
if (!r.check("the next block starts the next turn", await waitFor(() => h.prompts().length >= 2), "no second prompt")) return r;
|
||||
// A block that arrives while the agent works, with a line reading /abort, is still one message for later.
|
||||
const queued = message(h, "sent while the agent works\n/abort\nstill part of the message");
|
||||
h.paste(queued);
|
||||
await sleep(scale(1200));
|
||||
r.check("a block sent mid-turn waits for the next turn", h.prompts().length === 2, `got ${h.prompts().length} prompts during the turn`);
|
||||
r.check("a line reading /abort inside a block isn't control", h.received("session/cancel").length === 0, "the runner sent session/cancel");
|
||||
|
||||
const before = Date.now();
|
||||
h.paste("/abort");
|
||||
const cancel = await waitFor(() => h.received("session/cancel")[0], scale(3000));
|
||||
r.check("a block that is exactly /abort sends session/cancel", cancel, "no session/cancel");
|
||||
const stopped = await waitFor(() => nonExitStops(h, before)[0], scale(3000));
|
||||
r.check("the cancelled turn reports Stop/cancelled", stopped?.body?.subtype === "cancelled", `got ${stopped ? `Stop/${stopped.body.subtype}` : "no Stop"}`);
|
||||
await sleep(scale(300));
|
||||
r.check("one /abort sends one session/cancel", h.received("session/cancel").length === 1, `got ${h.received("session/cancel").length}`);
|
||||
r.check("the waiting block becomes the next prompt", await waitFor(() => h.prompts()[2] === queued, scale(4000)), `prompts: ${JSON.stringify(h.prompts().slice(2))}`);
|
||||
|
||||
// The agent keeps this turn open until the cancel arrives and then ends it end_turn, so a runner that handles Ctrl-C
|
||||
// late on a busy machine still has a turn to cancel. Only its delivery is timed, by the per-step bound.
|
||||
const interrupted = Date.now();
|
||||
await sleep(scale(200));
|
||||
h.keys("C-c");
|
||||
r.check("Ctrl-C outside a paste sends session/cancel", await waitFor(() => h.received("session/cancel").length >= 2), "no second session/cancel");
|
||||
const ended = await waitFor(() => nonExitStops(h, interrupted)[0], scale(6000));
|
||||
r.check("cancelled is reported only on the agent's own stop reason", ended?.body?.subtype === "end_turn",
|
||||
`the agent ended the turn end_turn; the runner reported ${ended ? `Stop/${ended.body.subtype}` : "no Stop"}`);
|
||||
return r;
|
||||
},
|
||||
|
||||
async "3"() {
|
||||
const r = result("3", "Activity: turn boundaries, posted in order with the seat's identity");
|
||||
const h = await launch("3");
|
||||
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
|
||||
h.paste(message(h, "a short turn"));
|
||||
const accepted = await waitFor(() => h.hooks("UserPromptSubmit", "accepted")[0]);
|
||||
r.check("UserPromptSubmit/accepted when the prompt is given", accepted, `posts: ${h.posts().map((p) => p.body?.hookEvent).join(", ")}`);
|
||||
r.check("the accepted post names a turnId", accepted && accepted.body.turnId !== undefined && accepted.body.turnId !== "", "no turnId");
|
||||
const end = await waitFor(() => h.hooks("Stop", "end_turn")[0]);
|
||||
r.check("Stop/end_turn when the turn ends", end, "no Stop/end_turn");
|
||||
r.check("accepted comes before the turn's end", accepted && end && accepted.at <= end.at, "out of order");
|
||||
const all = h.posts();
|
||||
const missing = all.flatMap((p) => ["sessionName", "nodeId", "runtime", "generation", "sequence", "occurredAt", "hookEvent", "subtype"]
|
||||
.filter((k) => p.body?.[k] === undefined).map((k) => `${p.body?.hookEvent ?? "?"} lacks ${k}`));
|
||||
r.check("every post carries the contract's fields", missing.length === 0, missing.join("; "));
|
||||
r.check("every post uses the hook token", all.every((p) => p.token === h.token), "a post without the bearer token");
|
||||
r.check("every post names this seat and preset",
|
||||
all.every((p) => p.body?.sessionName === h.session && p.body?.nodeId === h.nodeId && p.body?.runtime === opts.name),
|
||||
all.map((p) => `${p.body?.sessionName}/${p.body?.nodeId}/${p.body?.runtime}`).join(", "));
|
||||
const seqs = all.map((p) => Number(p.body?.sequence));
|
||||
r.check("sequence rises with each post", seqs.every((s, i) => Number.isFinite(s) && (i === 0 || s > seqs[i - 1])), `sequences: ${seqs.join(", ")}`);
|
||||
const beats = all.filter((p) => p.body?.livenessSeconds !== undefined);
|
||||
r.check("livenessSeconds, when given, is a whole number from 1 to 3600",
|
||||
beats.every((p) => Number.isInteger(p.body.livenessSeconds) && p.body.livenessSeconds >= 1 && p.body.livenessSeconds <= 3600),
|
||||
beats.map((p) => p.body.livenessSeconds).join(", "));
|
||||
return r;
|
||||
},
|
||||
|
||||
// The optional working beat (section 4). When a runner posts active/liveness in a quiet turn, each beat names the open
|
||||
// turn it belongs to, keeps the post order, comes often enough for its own lifetime, stops at the turn's end, and never
|
||||
// runs while a person is asked; after an answer, active/permission_resolved comes before the next beat. Order is read
|
||||
// from the runner's own sequence and times, so a slow delivery never reads as a broken runner.
|
||||
async "3b"() {
|
||||
const r = result("3b", "Working beat (optional): beats name their open turn, keep order, and stop at its end and while a person is asked");
|
||||
const quiet = scale(22000);
|
||||
const seqOf = (p) => Number(p?.body?.sequence);
|
||||
const at = (p) => Date.parse(p.body.occurredAt);
|
||||
const h = await launch("3b", { scenario: { turns: [{ silent: true, holdMs: quiet }, {}] } });
|
||||
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
|
||||
h.paste(message(h, "a long, quiet turn"));
|
||||
const accepted = await waitFor(() => h.hooks("UserPromptSubmit", "accepted")[0]);
|
||||
const end = await waitFor(() => h.hooks("Stop", "end_turn")[0], quiet + opts.waitMs);
|
||||
await sleep(scale(11000)); // room for a beat a runner wrongly sends after the Stop
|
||||
const beats = h.hooks("active", "liveness");
|
||||
if (!beats.length) return r.skip("the runner sends no working beat in a quiet turn; the beat is optional (section 4)");
|
||||
r.check("each beat names the turn it belongs to", accepted && beats.every((b) => b.body.turnId === accepted.body.turnId),
|
||||
`turnIds: ${beats.map((b) => b.body.turnId).join(", ")}`);
|
||||
// In the runner's own time order, not delivery's: a post delivered late still has to fit where the runner sent it.
|
||||
const byTime = (a, b) => at(a) - at(b) || seqOf(a) - seqOf(b);
|
||||
const timed = h.posts().slice().sort(byTime);
|
||||
r.check("sequence rises with the runner's own time across every post, beats included",
|
||||
timed.every((p, i) => Number.isFinite(seqOf(p)) && Number.isFinite(at(p)) && (i === 0 || seqOf(p) > seqOf(timed[i - 1]))),
|
||||
`sequences in occurredAt order: ${timed.map(seqOf).join(", ")}`);
|
||||
const life = (b) => (Number.isFinite(b.body.livenessSeconds) ? b.body.livenessSeconds : 30);
|
||||
const inTurn = beats.filter((b) => !end || seqOf(b) < seqOf(end)).sort(byTime);
|
||||
const gaps = inTurn.map((b, i) => at(b) - at(i === 0 ? accepted : inTurn[i - 1]));
|
||||
r.check("beats come more often than every livenessSeconds / 2", inTurn.every((b, i) => gaps[i] <= life(b) * 500 + 1000),
|
||||
`gaps by the runner's own times: ${gaps.join(", ")} ms`);
|
||||
r.check("no beat after the turn's Stop", end && beats.every((b) => seqOf(b) < seqOf(end)),
|
||||
`beats after Stop/end_turn: ${beats.filter((b) => end && seqOf(b) > seqOf(end)).length}`);
|
||||
|
||||
// A person is asked first, and answers after a while; the agent then carries on, quietly, in the same turn.
|
||||
const p = await launch("3b-asked", { scenario: { turns: [{ permission: true, silent: true, holdMs: quiet }, {}] },
|
||||
asks: { answer: { afterMs: scale(12000), state: "answered", optionId: "allow-once" } } });
|
||||
if (!r.check("asked: the runner opens its conversation", await ready(p), "no launch record")) return r;
|
||||
p.paste(message(p, "edit the notes"));
|
||||
const submitted = await waitFor(() => p.permissionCalls("POST").find((c) => c.path === "/api/activity/permission-requests"), scale(5000));
|
||||
if (!r.check("asked: the request is submitted", submitted, "no submit")) return r;
|
||||
await waitFor(() => p.hooks("Stop", "end_turn")[0], scale(12000) + quiet + opts.waitMs);
|
||||
const resolvedPost = p.hooks("active", "permission_resolved")[0];
|
||||
const before = p.hooks("active", "liveness").filter((b) => at(b) > submitted.at && (!resolvedPost || seqOf(b) < seqOf(resolvedPost)));
|
||||
r.check("asked: no beat while the ask waits on a person", before.length === 0, `${before.length} beats while it waited`);
|
||||
r.check("asked: after the answer, active/permission_resolved names the turn and the ask, before any beat",
|
||||
resolvedPost && resolvedPost.body.askId && resolvedPost.body.turnId === p.hooks("UserPromptSubmit", "accepted")[0]?.body.turnId,
|
||||
resolvedPost ? `permission_resolved: ${JSON.stringify({ turnId: resolvedPost.body.turnId, askId: resolvedPost.body.askId })}` : "no permission_resolved");
|
||||
|
||||
// OpenRig permits at submit when no one can be asked: no ask opens, so nothing waits and the beats go on.
|
||||
const q = await launch("3b-permitted", { scenario: { turns: [{ permission: true, silent: true, holdMs: quiet }, {}] },
|
||||
asks: { submit: { permitted: true } } });
|
||||
if (!r.check("permitted at submit: the runner opens its conversation", await ready(q), "no launch record")) return r;
|
||||
q.paste(message(q, "edit the notes"));
|
||||
const qEnd = await waitFor(() => q.hooks("Stop", "end_turn")[0], quiet + opts.waitMs);
|
||||
const qBeats = q.hooks("active", "liveness").filter((b) => !qEnd || seqOf(b) < seqOf(qEnd));
|
||||
r.check("permitted at submit: the quiet turn's beats go on, with no ask to resolve", qBeats.length > 0, "no beat in the quiet turn after the permit");
|
||||
return r;
|
||||
},
|
||||
|
||||
// Section 7's default is permit: when no one can be asked, the agent gets its own allow option at once, allow_once before
|
||||
// allow_always. Only the seat's opt-in refusal (denied_by_seat), or a request with no allow option, is refused at once,
|
||||
// shown, and held as needs input until the next prompt, even when the turn is cancelled.
|
||||
async "4"() {
|
||||
const r = result("4", "Permissions: permitted when no one can be asked; refused and held only on denied_by_seat or no allow option");
|
||||
const option = (optionId, kind) => ({ optionId, name: optionId, kind });
|
||||
const withAlways = [option("allow-once", "allow_once"), option("allow-always", "allow_always"), option("reject-once", "reject_once")];
|
||||
const rejectOnly = [option("reject-once", "reject_once"), option("reject-always", "reject_always")];
|
||||
const cells = [
|
||||
// label, the stand-in's answered path (none: an OpenRig without it, which answers 404), the options, permitted, and
|
||||
// whether /abort cancels the turn after the refusal
|
||||
["404", undefined, withAlways, true],
|
||||
["unreachable", { submit: { drop: true } }, withAlways, true],
|
||||
["denied_by_seat", { submit: { status: 409, code: "denied_by_seat" } }, withAlways, false],
|
||||
["denied_by_seat, then /abort", { submit: { status: 409, code: "denied_by_seat" } }, withAlways, false, true],
|
||||
["no allow option", undefined, rejectOnly, false],
|
||||
];
|
||||
await Promise.all(cells.map(async ([label, asks, options, permitted, abort]) => {
|
||||
const turns = [{ permission: { options }, holdMs: scale(abort ? 4000 : 1500) }, {}];
|
||||
const h = await launch(`4-${label.replace(/[^a-z0-9]+/g, "-")}`, { scenario: { turns }, asks });
|
||||
if (!r.check(`${label}: the runner opens its conversation`, await ready(h), "no launch record")) return;
|
||||
h.paste(message(h, "edit the notes"));
|
||||
const asked = await waitFor(() => h.sent("session/request_permission")[0]);
|
||||
if (!r.check(`${label}: the agent's permission request is sent`, asked, "the turn never reached the request")) return;
|
||||
// A runner may take a moment to find OpenRig unreachable; everything else is answered at once.
|
||||
const answer = await waitFor(() => h.answerTo("session/request_permission"), scale(label === "unreachable" ? 10000 : 3000));
|
||||
if (!r.check(`${label}: the request is answered, not left pending`, answer, "no answer")) return;
|
||||
if (label !== "unreachable") r.check(`${label}: it's answered at once (within 2 s)`, answer.at - asked.at <= 2000, `${answer.at - asked.at} ms`);
|
||||
const got = answer.msg.result?.outcome;
|
||||
if (permitted) {
|
||||
r.check(`${label}: the agent gets its allow_once option`, got?.outcome === "selected" && got.optionId === "allow-once",
|
||||
`answer: ${JSON.stringify(answer.msg.result ?? answer.msg.error)}`);
|
||||
r.check(`${label}: the turn's end is reported as usual`, await waitFor(() => h.hooks("Stop", "end_turn")[0], scale(4000)),
|
||||
"no Stop/end_turn after the permit");
|
||||
r.check(`${label}: nothing is held as needs input`, h.hooks("Notification", "permission_prompt").length === 0, "the runner posted permission_prompt");
|
||||
return;
|
||||
}
|
||||
r.check(`${label}: the answer is a refusal, never an allow option`, got?.outcome === "cancelled",
|
||||
`answer: ${JSON.stringify(answer.msg.result ?? answer.msg.error)}`);
|
||||
const shown = await waitFor(() => h.hooks("Notification", "permission_prompt")[0], scale(3000));
|
||||
r.check(`${label}: Notification/permission_prompt is posted`, shown, "no permission_prompt post");
|
||||
if (label === "denied_by_seat") r.check(`${label}: what was asked is shown in the pane`, await waitFor(() => h.screen().includes("Edit notes.txt"), scale(3000)),
|
||||
"the pane never showed the tool call's title, Edit notes.txt");
|
||||
// A cancelled turn is still a turn's end: the refusal stays held until the next accepted prompt.
|
||||
if (abort) {
|
||||
h.paste("/abort");
|
||||
r.check(`${label}: /abort sends session/cancel`, await waitFor(() => h.received("session/cancel")[0], scale(3000)), "no session/cancel");
|
||||
}
|
||||
await waitFor(() => h.log().some((e) => e.dir === "out" && e.msg.result?.stopReason), scale(abort ? 6000 : 4000));
|
||||
await sleep(scale(800));
|
||||
r.check(`${label}: no idle Stop or beat while holding that attention`, shown && nonExitStops(h, shown.at).length === 0,
|
||||
`posted ${nonExitStops(h, shown?.at ?? 0).map((p) => `Stop/${p.body.subtype}`).join(", ")}`);
|
||||
const next = Date.now();
|
||||
h.paste(message(h, "carry on"));
|
||||
r.check(`${label}: the next prompt is accepted as usual, which clears it`,
|
||||
await waitFor(() => h.hooks("UserPromptSubmit", "accepted").some((p) => p.at >= next)), "no accepted post for the next prompt");
|
||||
}));
|
||||
return r;
|
||||
},
|
||||
|
||||
// Section 7's answered path: the runner submits the live request, delivers the option that comes back (the person's
|
||||
// answer, or OpenRig's permit) once while that request is live, and withdraws it when the turn or the agent ends.
|
||||
async "4b"() {
|
||||
const r = result("4b", "Answered permissions: the person's answer or OpenRig's permit reaches the live request, and nothing else does");
|
||||
const options = ["allow-once", "reject-once"];
|
||||
const submits = (h) => h.permissionCalls("POST").filter((p) => p.path === "/api/activity/permission-requests");
|
||||
const withdraws = (h) => h.permissionCalls("POST", "/withdraw");
|
||||
const answers = (h) => { const asked = h.sent("session/request_permission")[0];
|
||||
return asked ? h.log().filter((e) => e.dir === "in" && e.msg.id === asked.msg.id && !e.msg.method) : []; };
|
||||
const outcome = (h) => answers(h)[0]?.msg.result?.outcome;
|
||||
const open = async (label, asks, turns = [{ permission: true }, {}]) => {
|
||||
const h = await launch(`4b-${label}`, { scenario: { turns }, asks });
|
||||
if (!await ready(h)) { r.check(`${label}: the runner opens its conversation`, false, "no launch record"); return undefined; }
|
||||
h.paste(message(h, "edit the notes"));
|
||||
await waitFor(() => h.sent("session/request_permission")[0]);
|
||||
return h;
|
||||
};
|
||||
|
||||
// An answer from the chosen person: the offered option, once, and the turn then reports as usual.
|
||||
for (const [label, optionId] of [["allow", "allow-once"], ["deny", "reject-once"]]) {
|
||||
const h = await open(label, { answer: { afterMs: 400, state: "answered", optionId } });
|
||||
if (!h) continue;
|
||||
const submitted = await waitFor(() => submits(h)[0], scale(3000));
|
||||
if (!r.check(`${label}: the request is submitted to /api/activity/permission-requests`, submitted, "no submit")) continue;
|
||||
const asked = h.sent("session/request_permission")[0];
|
||||
// The submit isn't one of the runner's ordered activity posts, so it can arrive before the accepted prompt does.
|
||||
const accepted = await waitFor(() => h.hooks("UserPromptSubmit", "accepted")[0], scale(3000));
|
||||
const sent = submitted.body ?? {};
|
||||
if (label === "allow") {
|
||||
r.check("the submit passes OpenRig's checks", h.asks.submitted[0]?.problems.length === 0, h.asks.submitted[0]?.problems.join("; "));
|
||||
r.check("it uses the hook token", submitted.token === h.token, "no bearer token");
|
||||
r.check("requestId is the agent's JSON-RPC id", idText(sent.requestId) === String(asked?.msg.id), `requestId=${JSON.stringify(sent.requestId)}`);
|
||||
r.check("turnId is the accepted prompt's", accepted && idText(sent.turnId) === String(accepted.body.turnId), `turnId=${JSON.stringify(sent.turnId)}`);
|
||||
r.check("nativeRef and generation name this conversation and occupant", sent.nativeRef === h.record()?.nativeRef && sent.generation === h.generation,
|
||||
`nativeRef=${sent.nativeRef} generation=${sent.generation}`);
|
||||
r.check("the agent's options are passed on as offered", JSON.stringify((sent.options ?? []).map((o) => o.optionId)) === JSON.stringify(options),
|
||||
JSON.stringify(sent.options));
|
||||
}
|
||||
const delivered = await waitFor(() => outcome(h), scale(4000));
|
||||
r.check(`${label}: the agent gets the chosen option`, delivered?.outcome === "selected" && delivered.optionId === optionId, `got ${JSON.stringify(delivered)}`);
|
||||
await waitFor(() => h.hooks("Stop", "end_turn")[0], scale(3000));
|
||||
await sleep(scale(300));
|
||||
r.check(`${label}: it's delivered once`, answers(h).length === 1, `${answers(h).length} answers`);
|
||||
if (label === "allow") {
|
||||
r.check("OpenRig records the prompt; the runner doesn't post permission_prompt itself", h.hooks("Notification", "permission_prompt").length === 0,
|
||||
"the runner posted permission_prompt for a created ask");
|
||||
const reads = h.permissionCalls("GET");
|
||||
// Section 7 bounds the wait at 25 s and no lower; OpenRig's read takes 0 to 25 or none.
|
||||
r.check("each read waits at most 25 s", reads.length > 0 && reads.every((p) => p.query.waitSeconds === undefined || Number(p.query.waitSeconds) <= 25),
|
||||
reads.map((p) => p.query.waitSeconds ?? "(none)").join(", "));
|
||||
r.check("after the answer, the turn's end is reported as usual", h.hooks("Stop", "end_turn").length > 0, "no Stop/end_turn after delivery");
|
||||
}
|
||||
}
|
||||
|
||||
// OpenRig's permit, at submit or at a read, is delivered like an answer. So is the runner's own permit when OpenRig
|
||||
// can't give an answer: a refusal section 7's table marks "permits" (two of them 409s, like stale_generation, so the
|
||||
// code decides, never the status; a revoked grant can still be the live occupant's, since one is reissued after a lost
|
||||
// response), a read that gets no answer, or a status, code or read state the section doesn't list. A refusal saying
|
||||
// the request isn't the current occupant's, and a refused, stale or withdrawn read, can't take an answer: the agent
|
||||
// gets cancelled, and nothing is held.
|
||||
const outcomes = [
|
||||
["permitted at submit", { submit: { permitted: true } }, "allow-once"],
|
||||
["permitted at read", { answer: { afterMs: 300, state: "permitted", optionId: "allow-once" } }, "allow-once"],
|
||||
// A person's own words come back beside the outcome, and never change the option delivered.
|
||||
["permitted at read, with the person's words", { answer: { afterMs: 300, state: "permitted", optionId: "allow-once", said: "only the notes file" } }, "allow-once"],
|
||||
["duplicate_request", { submit: { status: 409, code: "duplicate_request" } }, "allow-once"],
|
||||
["activity_grant_pending", { submit: { status: 409, code: "activity_grant_pending" } }, "allow-once"],
|
||||
["activity_grant_revoked", { submit: { status: 401, code: "activity_grant_revoked" } }, "allow-once"],
|
||||
["unreachable read", { read: { drop: true } }, "allow-once"],
|
||||
["unlisted read status (500)", { read: { status: 500, code: "internal_error" } }, "allow-once"],
|
||||
["unlisted read state (deferred)", { answer: { afterMs: 300, state: "deferred" } }, "allow-once"],
|
||||
["stale_generation", { submit: { status: 409, code: "stale_generation" } }, null],
|
||||
["activity_grant_stale read", { read: { status: 409, code: "activity_grant_stale" } }, null],
|
||||
["refused read (said_no)", { answer: { afterMs: 300, state: "refused", reason: "said_no" } }, null],
|
||||
["stale ask", { answer: { afterMs: 300, state: "stale" } }, null],
|
||||
["withdrawn ask", { answer: { afterMs: 300, state: "withdrawn" } }, null],
|
||||
];
|
||||
await Promise.all(outcomes.map(async ([label, asks, optionId]) => {
|
||||
const h = await open(label.replace(/[^a-z0-9]+/g, "-"), asks, [{ permission: true, holdMs: scale(800) }, {}]);
|
||||
if (!h) return;
|
||||
// Section 7 sets no time limit for what follows a read, so the check waits its ordinary per-step bound (--wait-ms),
|
||||
// and longer when the read got no answer, which a runner may retry a few times before it permits.
|
||||
const got = await waitFor(() => outcome(h), label === "unreachable read" ? scale(10000) : opts.waitMs);
|
||||
if (!optionId) {
|
||||
r.check(`${label}: the agent gets cancelled`, got?.outcome === "cancelled", `got ${JSON.stringify(got)}`);
|
||||
r.check(`${label}: nothing is held`, await waitFor(() => h.hooks("Stop", "end_turn")[0], scale(3000))
|
||||
&& !h.hooks("Notification", "permission_prompt").length, "no Stop/end_turn after the cancel, or the runner posted permission_prompt");
|
||||
return;
|
||||
}
|
||||
r.check(`${label}: the agent gets its allow_once option`, got?.outcome === "selected" && got.optionId === optionId, `got ${JSON.stringify(got)}`);
|
||||
await waitFor(() => h.hooks("Stop", "end_turn")[0], scale(3000));
|
||||
await sleep(scale(300));
|
||||
r.check(`${label}: it's delivered once`, answers(h).length === 1, `${answers(h).length} answers`);
|
||||
r.check(`${label}: the turn's end is reported as usual`, h.hooks("Stop", "end_turn").length > 0, "no Stop/end_turn after the permit");
|
||||
if (label === "permitted at submit") r.check(`${label}: there's no ask to read`, h.permissionCalls("GET").length === 0,
|
||||
`${h.permissionCalls("GET").length} reads`);
|
||||
// A read that got no answer leaves OpenRig's ask open, so it must not stay in the person's Feed after the turn.
|
||||
if (label === "unreachable read" || label.startsWith("unlisted read")) r.check(`${label}: the ask is still withdrawn at the turn's end`,
|
||||
await waitFor(() => withdraws(h).some((w) => w.body?.reason === "turn_ended"), scale(3000)), `withdraws: ${JSON.stringify(withdraws(h).map((w) => w.body))}`);
|
||||
}));
|
||||
|
||||
// No answer: the request stays pending and visible, a pasted message isn't an answer, and /abort withdraws it, after
|
||||
// which a late answer isn't delivered.
|
||||
{
|
||||
const h = await open("pending", { late: { state: "answered", optionId: "allow-once" } });
|
||||
if (h) {
|
||||
await sleep(scale(1500));
|
||||
r.check("pending: the runner keeps reading the ask", h.permissionCalls("GET").length >= 1, "no read of the ask");
|
||||
r.check("pending: nothing is delivered while no one has answered", answers(h).length === 0, `got ${JSON.stringify(outcome(h))}`);
|
||||
const turn = h.hooks("UserPromptSubmit", "accepted").at(-1);
|
||||
r.check("pending: no idle Stop while it waits", stopsAfter(h, turn).length === 0, stopsAfter(h, turn).map((p) => p.body.subtype).join(", "));
|
||||
h.paste(message(h, "allow it"));
|
||||
await sleep(scale(800));
|
||||
r.check("pending: a pasted message isn't an answer", answers(h).length === 0, `got ${JSON.stringify(outcome(h))}`);
|
||||
const abortedAt = Date.now();
|
||||
h.paste("/abort");
|
||||
const withdrawn = await waitFor(() => withdraws(h)[0], scale(3000));
|
||||
r.check("pending: /abort withdraws the ask with turn_cancelled", withdrawn?.body?.reason === "turn_cancelled", `withdraw: ${JSON.stringify(withdrawn?.body)}`);
|
||||
r.check("pending: the agent's request is answered cancelled", (await waitFor(() => outcome(h), scale(3000)))?.outcome === "cancelled",
|
||||
`got ${JSON.stringify(outcome(h))}`);
|
||||
// Once the ask is withdrawn no person is still waiting, so the cancelled turn's own end is reported as usual (the
|
||||
// pasted message above runs as the next turn, so only that turn's Stop/cancelled counts). The contract doesn't
|
||||
// order the Stop and the withdraw, so the Stop is any after /abort, not only one after the withdraw arrived.
|
||||
r.check("pending: after /abort, the turn's end is reported, holding nothing",
|
||||
await waitFor(() => h.hooks("Stop", "cancelled").some((p) => p.at >= abortedAt), scale(3000)), "no Stop/cancelled after /abort");
|
||||
await sleep(scale(1200));
|
||||
r.check("pending: an answer after the withdraw isn't delivered", answers(h).every((a) => a.msg.result?.outcome?.outcome !== "selected"),
|
||||
`answers: ${JSON.stringify(answers(h).map((a) => a.msg.result))}`);
|
||||
}
|
||||
}
|
||||
// The turn ending, or the agent exiting, while the ask waits also withdraws it.
|
||||
for (const [label, permission, reason] of [["turn end", { waitMs: scale(1500) }, "turn_ended"], ["agent exit", { exitAfterMs: scale(1500) }, "exited"]]) {
|
||||
const h = await open(label.replace(/[^a-z]+/g, "-"), {}, [{ permission }, {}]);
|
||||
if (!h) continue;
|
||||
const withdrawn = await waitFor(() => withdraws(h)[0], scale(4000));
|
||||
r.check(`${label}: the ask is withdrawn with ${reason}`, withdrawn?.body?.reason === reason, `withdraw: ${JSON.stringify(withdrawn?.body)}`);
|
||||
// The turn's own end: a Stop with the agent's own stop reason, after the agent answered that turn's session/prompt,
|
||||
// on either side of the withdraw (the contract doesn't order them). A Stop posted before the turn ended doesn't count.
|
||||
if (reason === "turn_ended") {
|
||||
const turnEnd = await waitFor(() => {
|
||||
const asked = h.sent("session/request_permission")[0];
|
||||
const prompt = asked && h.received("session/prompt").filter((p) => p.at <= asked.at).at(-1);
|
||||
return prompt && h.replyTo(prompt);
|
||||
}, scale(4000));
|
||||
const stopReason = turnEnd?.msg.result?.stopReason;
|
||||
r.check(`${label}: nothing is held once the ask is withdrawn`,
|
||||
stopReason && await waitFor(() => h.hooks("Stop", stopReason).some((p) => p.at >= turnEnd.at), scale(3000)),
|
||||
`no Stop/${stopReason ?? "(no turn end)"} after the agent ended the turn`);
|
||||
}
|
||||
}
|
||||
return r;
|
||||
},
|
||||
|
||||
// Section 7: a person who answers in their own words gets them passed on. OpenRig carries them as the read's `said`, and
|
||||
// a runner queues them for the agent like any other prompt, after the turn, never as the answer. A runner that drops
|
||||
// them still keeps the contract, so the result is advisory.
|
||||
async "4c"() {
|
||||
const r = result("4c", "The person's words: a read's said is queued to the agent as a prompt (advisory)", { proposed: true });
|
||||
const said = "Only the notes file, please. Leave the rest alone.";
|
||||
const h = await launch("4c", { scenario: { turns: [{ permission: true }, {}] },
|
||||
asks: { answer: { afterMs: 300, state: "permitted", optionId: "allow-once", said } } });
|
||||
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
|
||||
h.paste(message(h, "edit the notes"));
|
||||
if (!r.check("the first turn ends", await waitFor(() => h.hooks("Stop", "end_turn")[0], scale(6000)), "no Stop/end_turn")) return r;
|
||||
const later = await waitFor(() => h.prompts().slice(1).find((prompt) => prompt?.includes(said)), scale(4000));
|
||||
r.check("the person's words are queued to the agent as a prompt, after the turn", later, `prompts: ${JSON.stringify(h.prompts())}`);
|
||||
return r;
|
||||
},
|
||||
|
||||
async "5"() {
|
||||
const r = result("5", "Generation: every post carries the launch's occupant generation");
|
||||
const h = await launch("5");
|
||||
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
|
||||
h.paste(message(h, "a short turn"));
|
||||
await waitFor(() => h.hooks("Stop", "end_turn")[0]);
|
||||
const all = h.posts();
|
||||
r.check("the runner posted something", all.length > 0, "no posts");
|
||||
r.check("generation is OPENRIG_OCCUPANT_GENERATION on every post", all.every((p) => p.body?.generation === h.generation),
|
||||
all.map((p) => p.body?.generation).join(", "));
|
||||
return r;
|
||||
},
|
||||
|
||||
async "6"() {
|
||||
const r = result("6", "Resume: the same conversation, or an honest missing or unconfirmed");
|
||||
if (!opts.resume) return r.skip("the preset doesn't declare resume (--resume): OpenRig passes OPENRIG_RESUME_REF only to one that does");
|
||||
const reopenCalls = (h) => [...h.received("session/load"), ...h.received("session/resume")];
|
||||
{
|
||||
const h = await launch("6-same", { scenario: { knownSessions: ["fake-old-1"] }, env: { OPENRIG_RESUME_REF: "fake-old-1" } });
|
||||
const record = await ready(h);
|
||||
const call = reopenCalls(h)[0];
|
||||
r.check("same: the agent is asked to reopen OPENRIG_RESUME_REF", call?.msg.params?.sessionId === "fake-old-1",
|
||||
call ? `asked for ${call.msg.params?.sessionId}` : "no session/load or session/resume");
|
||||
r.check("same: in the launch's working folder", call && realpathSync(String(call.msg.params?.cwd ?? "")) === realpathSync(h.cwd),
|
||||
`cwd=${call?.msg.params?.cwd}`);
|
||||
r.check("same: the record reports that conversation", record?.nativeRef === "fake-old-1", `record: ${JSON.stringify(h.record())}`);
|
||||
r.check("same: no new conversation starts", h.received("session/new").length === 0, "session/new was sent");
|
||||
}
|
||||
{
|
||||
const h = await launch("6-missing", { scenario: { reopenError: "missing" }, env: { OPENRIG_RESUME_REF: "fake-gone" } });
|
||||
const record = await waitFor(() => h.record());
|
||||
r.check("missing: the agent's own not-found is reported as error: missing", record?.error === "missing" && !record.nativeRef,
|
||||
`record: ${JSON.stringify(record)}`);
|
||||
await sleep(scale(800));
|
||||
r.check("missing: no new conversation starts", h.received("session/new").length === 0, "session/new was sent");
|
||||
}
|
||||
{
|
||||
const h = await launch("6-other", { scenario: { reopenError: "error" }, env: { OPENRIG_RESUME_REF: "fake-elsewhere" } });
|
||||
await waitFor(() => reopenCalls(h)[0] && h.log().some((e) => e.dir === "out" && e.msg.error));
|
||||
await sleep(scale(2500));
|
||||
const record = h.record();
|
||||
r.check("other failure: not reported missing or resumed", !record || (record.error && record.error !== "missing" && !record.nativeRef),
|
||||
`record: ${JSON.stringify(record)}`);
|
||||
r.check("other failure: no new conversation starts", h.received("session/new").length === 0, "session/new was sent");
|
||||
}
|
||||
return r;
|
||||
},
|
||||
|
||||
async "7"() {
|
||||
const r = result("7", "Death mid-turn: Stop/<preset>_exited, never an idle report");
|
||||
const h = await launch("7", { scenario: { turns: [{ exit: true }] } });
|
||||
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
|
||||
h.paste(message(h, "this turn kills the agent"));
|
||||
const died = await waitFor(() => h.events("exit")[0]);
|
||||
if (!r.check("the agent exits mid-turn", died, "the agent didn't exit")) return r;
|
||||
const exited = await waitFor(() => h.hooks("Stop", `${opts.name}_exited`)[0], scale(4000));
|
||||
r.check(`Stop/${opts.name}_exited is posted`, exited, `posts: ${h.posts().map((p) => `${p.body?.hookEvent}/${p.body?.subtype}`).join(", ")}`);
|
||||
// Any idle report for the turn the agent died in counts, before or after the exit; one from before the turn doesn't.
|
||||
const turn = h.hooks("UserPromptSubmit", "accepted")[0];
|
||||
const idle = turn ? stopsAfter(h, turn) : nonExitStops(h, died.at);
|
||||
r.check("no idle report for the turn it died in", idle.length === 0, idle.map((p) => p.body.subtype).join(", "));
|
||||
return r;
|
||||
},
|
||||
|
||||
async "7b"() {
|
||||
const r = result("7b", "Errors: a failed prompt is a runtime error, not a permission request");
|
||||
const h = await launch("7b", { scenario: { turns: [{ error: true }, {}] } });
|
||||
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
|
||||
h.paste(message(h, "this prompt fails"));
|
||||
const posted = await waitFor(() => h.hooks("Notification", "runtime_error")[0]);
|
||||
r.check("a failed session/prompt posts Notification/runtime_error", posted,
|
||||
`posts: ${h.posts().map((p) => `${p.body?.hookEvent}/${p.body?.subtype}`).join(", ")}`);
|
||||
r.check("it isn't reported as a permission request", h.hooks("Notification", "permission_prompt").length === 0, "permission_prompt posted");
|
||||
return r;
|
||||
},
|
||||
|
||||
// Section 9 marks this duty "(Proposed)", and a runner may list in `limits` what keeps running, so the result is reported
|
||||
// on its own: never a pass or a fail, and never the exit status.
|
||||
async "7c"() {
|
||||
const r = result("7c", "Shutdown: closing the pane ends the agent (proposed in section 9; advisory)", { proposed: true });
|
||||
const h = await launch("7c", { scenario: { stubborn: true } });
|
||||
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
|
||||
const pid = h.events("started")[0]?.pid;
|
||||
h.stop();
|
||||
const gone = await waitFor(() => { try { process.kill(pid, 0); return false; } catch { return true; } }, scale(5000));
|
||||
r.check("closing the pane ends the agent process", gone, `agent pid ${pid} still running ${scale(5000) / 1000} s later; list it in limits if that's expected`);
|
||||
if (!gone) try { process.kill(pid, "SIGKILL"); } catch { /* already gone */ }
|
||||
return r;
|
||||
},
|
||||
|
||||
async "8"() {
|
||||
const r = result("8", "Unknown requests from the agent get -32601, and the runner carries on");
|
||||
const h = await launch("8", { scenario: { unknownRequest: true } });
|
||||
const answer = await waitFor(() => h.answerTo("fake/unknown_method"));
|
||||
r.check("the unknown request is answered -32601", answer?.msg.error?.code === -32601, `answer: ${JSON.stringify(answer?.msg ?? null)}`);
|
||||
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
|
||||
h.paste(message(h, "still working?"));
|
||||
r.check("the next prompt still goes through", await waitFor(() => h.prompts().length === 1), "no prompt after the unknown request");
|
||||
return r;
|
||||
},
|
||||
|
||||
async "9"() {
|
||||
const r = result("9", "First input: OPENRIG_FIRST_INPUT's exact bytes are the first prompt");
|
||||
if (!opts.firstInput) return r.skip("the preset doesn't declare first_input (--first-input): OpenRig passes no OPENRIG_FIRST_INPUT");
|
||||
const dir = mkdtempSync(join(tmpdir(), "runner-check-first-"));
|
||||
const file = join(dir, "first-input");
|
||||
const text = "Read the plan first.\n\n Then say ready.\n";
|
||||
writeFileSync(file, text, { mode: 0o600 });
|
||||
const h = await launch("9", { env: { OPENRIG_FIRST_INPUT: file } });
|
||||
const first = await waitFor(() => h.prompts()[0]);
|
||||
r.check("the first prompt is the file's bytes, newlines and spaces included", first === text, `got ${JSON.stringify(first)}`);
|
||||
if (!opts.keep) rmSync(dir, { recursive: true, force: true });
|
||||
return r;
|
||||
},
|
||||
};
|
||||
|
||||
const order = ["1", "1b", "2", "3", "3b", "4", "4b", "4c", "5", "6", "7", "7b", "7c", "8", "9"];
|
||||
const selected = opts.cases ?? order;
|
||||
const unknown = selected.filter((id) => !order.includes(id));
|
||||
if (unknown.length) { console.error(`unknown case ${unknown.join(", ")}; cases are ${order.join(", ")}`); process.exit(2); }
|
||||
let results;
|
||||
try {
|
||||
results = await Promise.all(order.filter((id) => selected.includes(id)).map(async (id) => {
|
||||
try { return await cases[id](); } catch (error) { return { id, title: id, status: "fail", checks: [{ name: "the case ran", ok: false, detail: error.message }] }; }
|
||||
}));
|
||||
} finally {
|
||||
tmux("kill-server");
|
||||
server.close();
|
||||
// A runner or agent can still be writing its last lines as its pane closes, so cleanup waits a moment and never fails the report.
|
||||
await sleep(scale(300));
|
||||
if (!opts.keep) for (const h of launches) try { rmSync(h.dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch { /* left in the temp folder */ }
|
||||
}
|
||||
|
||||
// A proposed duty's result is advisory: it says whether the runner met it, outside pass, fail and the exit status.
|
||||
for (const r of results) if (r.proposed && r.status !== "skip") { r.met = r.status === "pass"; r.status = "advisory"; }
|
||||
const summary = Object.fromEntries(["pass", "fail", "skip", "advisory"].map((s) => [s, results.filter((r) => r.status === s).length]));
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ check: "openrig-runner-conformance", format: 1, runner: opts.runner, name: opts.name, results, summary }, null, 2));
|
||||
} else {
|
||||
console.log(`OpenRig runner conformance check (docs/reference/harness-runner-contract.md)\nrunner: ${opts.runner}\npreset: ${opts.name}\n`);
|
||||
for (const r of results) {
|
||||
const label = r.status === "advisory" ? "NOTE" : r.status.toUpperCase();
|
||||
const outcome = r.status === "advisory" ? (r.met ? ": met" : ": not met") : r.reason ? `: ${r.reason}` : "";
|
||||
console.log(`${label.padEnd(5)} ${r.id.padEnd(3)} ${r.title}${outcome}`);
|
||||
for (const c of r.checks.filter((c) => !c.ok)) console.log(` x ${c.name}: ${c.detail}`);
|
||||
}
|
||||
console.log(`\n${summary.pass} pass, ${summary.fail} fail, ${summary.skip} skipped, ${summary.advisory} advisory (not counted)${opts.keep ? `; launch folders kept under ${tmpdir()}` : ""}`);
|
||||
}
|
||||
process.exit(summary.fail ? 1 : 0);
|
||||
@@ -1,150 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// A scriptable fake ACP agent for the runner conformance check (check-runner.mjs). It speaks just enough of ACP v1 over
|
||||
// stdio, newline-delimited JSON-RPC, to exercise a runner without a model, credentials or the network. It was written
|
||||
// after the fake agent in bpamiri/dsh-seat (Apache-2.0), which showed the approach.
|
||||
//
|
||||
// node fake-acp-agent.mjs --scenario <file.json> --log <file.jsonl> [any arguments the runner adds are ignored]
|
||||
//
|
||||
// The scenario says how to answer; the log records every message in and out, so the check can compare them.
|
||||
// sessionId the id `session/new` returns (default "fake-session-1")
|
||||
// initialize "error" or "exit" to fail the handshake there
|
||||
// newSession "error" or "exit" to fail `session/new`
|
||||
// newDelayMs wait this long before answering `session/new`
|
||||
// knownSessions ids `session/load` and `session/resume` reopen
|
||||
// reopenError "missing" (the agent's own "no such session") or "error", for an id it doesn't know
|
||||
// unknownRequest send the runner a request with a method it can't know, once, after `initialize`
|
||||
// stubborn keep running after a hangup or after input closes; only SIGTERM or SIGKILL ends it
|
||||
// turns one entry per `session/prompt`, in order (the last one repeats):
|
||||
// holdMs keep the turn open this long; `session/cancel` ends it early as "cancelled"
|
||||
// silent send no message chunk for this turn, so a held turn is entirely quiet (a long tool or model call)
|
||||
// ignoreCancel on `session/cancel`, keep the turn open and end it with stopReason (default end_turn) anyway, when
|
||||
// holdMs runs out or, with endAfterCancelMs, that long after the first cancel if that's sooner
|
||||
// permission ask `session/request_permission` first, then carry on whatever the answer. As an object:
|
||||
// options the options offered (default: allow-once, allow_once; reject-once, reject_once)
|
||||
// waitMs stop waiting for the answer after this long, and end the turn
|
||||
// exitAfterMs exit with status 3 this long after asking, while the request waits
|
||||
// A `session/cancel` while the request waits ends the turn "cancelled" once it's answered.
|
||||
// error answer the prompt with a JSON-RPC error
|
||||
// exit exit with status 3 instead of answering
|
||||
// stopReason the stop reason to return (default "end_turn")
|
||||
import { appendFileSync, readFileSync } from "node:fs";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
const arg = (name) => { const i = process.argv.indexOf(name); return i > 0 ? process.argv[i + 1] : undefined; };
|
||||
const scenarioFile = arg("--scenario") ?? process.env.RUNNER_CHECK_SCENARIO;
|
||||
const logFile = arg("--log") ?? process.env.RUNNER_CHECK_AGENT_LOG;
|
||||
const scenario = scenarioFile ? JSON.parse(readFileSync(scenarioFile, "utf8")) : {};
|
||||
|
||||
const log = (entry) => { if (logFile) appendFileSync(logFile, `${JSON.stringify({ at: Date.now(), ...entry })}\n`); };
|
||||
const send = (msg) => { log({ dir: "out", msg }); process.stdout.write(`${JSON.stringify(msg)}\n`); };
|
||||
const reply = (id, result) => send({ jsonrpc: "2.0", id, result });
|
||||
const fail = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
|
||||
const exit = (status, why) => { log({ dir: "event", event: "exit", status, why }); process.exit(status); };
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
log({ dir: "event", event: "started", pid: process.pid, cwd: process.cwd(), argv: process.argv.slice(2) });
|
||||
// Ctrl-C is the runner's to handle, so the agent never takes it as a signal. A stubborn agent also outlives a hangup and
|
||||
// its input closing, as some real agents do: only the runner ending it (SIGTERM or SIGKILL) stops it.
|
||||
process.on("SIGINT", () => log({ dir: "event", event: "signal", signal: "SIGINT" }));
|
||||
process.on("SIGHUP", () => scenario.stubborn ? log({ dir: "event", event: "signal", signal: "SIGHUP" }) : exit(0, "SIGHUP"));
|
||||
process.on("SIGTERM", () => exit(0, "SIGTERM"));
|
||||
|
||||
let sessionId;
|
||||
let turn = 0;
|
||||
let nextId = 1;
|
||||
const answers = new Map();
|
||||
let cancelTurn = null;
|
||||
|
||||
// Numbers, as JSON-RPC agents commonly use. A runner passes one on unchanged, and OpenRig reads it as its decimal string.
|
||||
function ask(method, params) {
|
||||
const id = 1000 + nextId++;
|
||||
send({ jsonrpc: "2.0", id, method, params });
|
||||
return new Promise((resolve) => answers.set(id, resolve));
|
||||
}
|
||||
|
||||
async function prompt(id, params) {
|
||||
const spec = (scenario.turns ?? [])[Math.min(turn, (scenario.turns?.length ?? 1) - 1)] ?? {};
|
||||
turn += 1;
|
||||
if (spec.exit) exit(3, `turn ${turn}`);
|
||||
if (spec.error) { fail(id, -32603, "fake agent: this turn fails"); return; }
|
||||
if (!spec.silent) send({ jsonrpc: "2.0", method: "session/update",
|
||||
params: { sessionId: params.sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: `turn ${turn}` } } } });
|
||||
let stopReason = spec.stopReason ?? "end_turn";
|
||||
if (spec.permission) {
|
||||
const p = typeof spec.permission === "object" ? spec.permission : {};
|
||||
const asked = ask("session/request_permission", {
|
||||
sessionId: params.sessionId,
|
||||
toolCall: { toolCallId: `call-${turn}`, title: "Edit notes.txt", kind: "edit" },
|
||||
options: p.options ?? [
|
||||
{ optionId: "allow-once", name: "Allow once", kind: "allow_once" },
|
||||
{ optionId: "reject-once", name: "Reject", kind: "reject_once" },
|
||||
],
|
||||
});
|
||||
if (p.exitAfterMs) setTimeout(() => exit(3, "while a permission request waits"), p.exitAfterMs);
|
||||
let cancelled = false;
|
||||
cancelTurn = () => { cancelled = true; };
|
||||
const answer = await (p.waitMs ? Promise.race([asked, sleep(p.waitMs).then(() => null)]) : asked);
|
||||
cancelTurn = null;
|
||||
if (!answer) log({ dir: "event", event: "permission-wait-ended" });
|
||||
if (cancelled) { reply(id, { stopReason: "cancelled" }); return; }
|
||||
}
|
||||
if (spec.holdMs) {
|
||||
const cancelled = await new Promise((resolve) => {
|
||||
const timer = setTimeout(() => { cancelTurn = null; resolve(false); }, spec.holdMs);
|
||||
cancelTurn = () => {
|
||||
if (!spec.ignoreCancel) { clearTimeout(timer); cancelTurn = null; resolve(true); return; }
|
||||
if (spec.endAfterCancelMs === undefined) return;
|
||||
cancelTurn = null;
|
||||
setTimeout(() => { clearTimeout(timer); resolve(false); }, spec.endAfterCancelMs);
|
||||
};
|
||||
});
|
||||
if (cancelled) stopReason = "cancelled";
|
||||
}
|
||||
reply(id, { stopReason });
|
||||
}
|
||||
|
||||
async function reopen(id, params) {
|
||||
if ((scenario.knownSessions ?? []).includes(params.sessionId)) { sessionId = params.sessionId; reply(id, null); return; }
|
||||
if (scenario.reopenError === "missing") fail(id, -32002, `Session not found: ${params.sessionId}`);
|
||||
else fail(id, -32603, "fake agent: reopen failed");
|
||||
}
|
||||
|
||||
async function handle(msg) {
|
||||
if (msg.id !== undefined && msg.method === undefined) { answers.get(msg.id)?.(msg); answers.delete(msg.id); return; }
|
||||
switch (msg.method) {
|
||||
case "initialize":
|
||||
if (scenario.initialize === "exit") exit(2, "initialize");
|
||||
if (scenario.initialize === "error") { fail(msg.id, -32603, "fake agent: initialize fails"); return; }
|
||||
reply(msg.id, { protocolVersion: 1, agentCapabilities: { loadSession: true }, authMethods: [] });
|
||||
if (scenario.unknownRequest) await ask("fake/unknown_method", { note: "a method no runner knows" });
|
||||
return;
|
||||
case "session/new":
|
||||
if (scenario.newDelayMs) await sleep(scenario.newDelayMs);
|
||||
if (scenario.newSession === "exit") exit(2, "session/new");
|
||||
if (scenario.newSession === "error") { fail(msg.id, -32603, "fake agent: session/new fails"); return; }
|
||||
sessionId = scenario.sessionId ?? "fake-session-1";
|
||||
reply(msg.id, { sessionId });
|
||||
return;
|
||||
case "session/load":
|
||||
case "session/resume":
|
||||
await reopen(msg.id, msg.params ?? {});
|
||||
return;
|
||||
case "session/prompt":
|
||||
await prompt(msg.id, msg.params ?? {});
|
||||
return;
|
||||
case "session/cancel":
|
||||
cancelTurn?.();
|
||||
return;
|
||||
default:
|
||||
if (msg.id !== undefined) fail(msg.id, -32601, `Method not found: ${msg.method}`);
|
||||
}
|
||||
}
|
||||
|
||||
const lines = createInterface({ input: process.stdin });
|
||||
lines.on("line", (line) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(line); } catch { log({ dir: "event", event: "garbage", line }); return; }
|
||||
log({ dir: "in", msg });
|
||||
void handle(msg);
|
||||
});
|
||||
lines.on("close", () => { if (scenario.stubborn) { log({ dir: "event", event: "stdin-closed" }); setInterval(() => {}, 60_000); } else exit(0, "stdin closed"); });
|
||||
@@ -144,23 +144,6 @@ 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`.
|
||||
|
||||
A slice's frontmatter may also carry `status:`. `GET /api/slices` reports the
|
||||
bucket each word falls in as the slice's `status`, and its
|
||||
`?filter=all|active|done|blocked` filters on that bucket:
|
||||
|
||||
| `status:` word | Bucket |
|
||||
| --- | --- |
|
||||
| `active`, `in-flight`, `ratified` | `active` |
|
||||
| `draft`, `draft-pending-orch-ratification`, `placeholder` | `draft` |
|
||||
| `done`, `shipped`, `promoted`, `closed` | `done` |
|
||||
| `blocked`, `parked-with-evidence` | `blocked` |
|
||||
|
||||
Words are matched ignoring case. Any other word is matched by what it contains:
|
||||
`done`, `ship` or `close` makes it `done`; `block` or `park`, `blocked`; `draft`
|
||||
or `pending`, `draft`. A word that matches none of these counts as `active`, so a
|
||||
slice marked `complete` counts as `active`. A slice with no `status:` counts as
|
||||
`draft`.
|
||||
|
||||
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
|
||||
@@ -182,12 +165,6 @@ 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
|
||||
@@ -195,13 +172,9 @@ missions whose `mission.yaml` carries `metadata.status: active` or
|
||||
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. A mission can also carry
|
||||
`metadata.status: complete`, which only `POST /api/missions/:missionId/complete`
|
||||
writes (no `rig` verb does); a `complete` mission is outside the rollup too.
|
||||
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.
|
||||
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.
|
||||
|
||||
The three sections project into the UI's one review structure: a vertical
|
||||
stack of **INTENT → PLAN → DELIVERED**. A slice missing a section still
|
||||
|
||||
+10
-144
@@ -1,109 +1,23 @@
|
||||
# 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.
|
||||
The existing typing guard supports three modes. Before draft-aware mode,
|
||||
automatic input either used the ordinary delivery path or was held unconditionally.
|
||||
Draft-aware adds an opt-in check for unfinished input without a second control or
|
||||
daemon-owned retry scheduler. 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`, `urgent: true` on `POST /api/transport/send`, 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
|
||||
|
||||
### Latest permission observation
|
||||
|
||||
`rig seat status <seat>` can show the latest observed harness permission request,
|
||||
with a fixed tool descriptor, source time and actor category. This is one latest
|
||||
observation across actors, not an aggregate of pending prompts. `request_observed`
|
||||
leaves the displayed actor's pending state unknown. `completed` applies only to
|
||||
that actor's later tool batch; other actors' pending state is still unknown. An
|
||||
unavailable observation proves neither a permission stop nor the absence of a
|
||||
waiting native prompt. With no observation, human status omits this section;
|
||||
JSON keeps the explicit unavailable state. The JSON field is `permission_stop`,
|
||||
and its `pending` value has the same observation-only scope.
|
||||
|
||||
An observation may also carry Claude's **suggested rules** as `suggested_rules`.
|
||||
These are read-only evidence, not applied or approved, not guaranteed effective,
|
||||
not the exact command, and not a complete list of dialog options. The supported
|
||||
subset of [Claude's permission update schema](https://code.claude.com/docs/en/hooks#permission-update-entries)
|
||||
is `addRules`, `replaceRules` and `removeRules`, preserving type, ordered rules
|
||||
(`toolName`, optional `ruleContent`), behavior and destination exactly. Strings
|
||||
are neither trimmed nor broadened; absent `ruleContent` remains distinct from
|
||||
an empty string. Mode/directory updates and malformed entries are withheld.
|
||||
|
||||
The envelope distinguishes a missing native field from a present empty array;
|
||||
`unsupported` and `size_cut` mark incomplete evidence. An older relay without
|
||||
this field, or a failed formatter, is unavailable rather than proof of native
|
||||
absence. Bounds are eight entries, sixteen rules per entry, 128 UTF-16 code
|
||||
units per tool name, 2,048 per rule-content string, and 16,384 UTF-8 bytes of
|
||||
serialized evidence. Oversized whole entries are dropped with `size_cut`,
|
||||
never shortened into different plausible rules. Both producer and ingress
|
||||
validate these bounds independently. Terminal JSON escapes controls and
|
||||
nonprinting format/separator characters while preserving decoded string values;
|
||||
its representation can expand, so the byte bound applies to stored/transported
|
||||
evidence, not the entire formatted status output.
|
||||
|
||||
**Suggested rule strings can contain sensitive values.** The relay sends only
|
||||
this bounded rule subset alongside the fixed descriptor. Rule content can itself
|
||||
contain sensitive command-like text; raw `tool_input`, arguments outside that
|
||||
suggestion, and provider reasons are not forwarded. It posts to `/api/activity/hooks` at the
|
||||
configured `OPENRIG_URL` (or host/port), or the discovered endpoint in
|
||||
`OPENRIG_HOME/activity-endpoint.json`. Both `GET /api/seat/status/:seatRef` and
|
||||
`GET /api/seat/status/node/:nodeId` can return the evidence. The daemon adds no
|
||||
event, log, queue or telemetry persistence for it; a caller can still capture
|
||||
status output in a transcript or file. Evidence shares the observation's
|
||||
five-minute lifetime and existing seat/actor limits, is discarded lazily on the
|
||||
next observation/status read after expiry, and is lost on restart. No rule,
|
||||
permission/configuration write, hook decision, edited input, prompt submission
|
||||
or new attention/delivery behavior is produced.
|
||||
|
||||
Actor attribution is a convention derived from the [Claude hook schema](https://code.claude.com/docs/en/hooks#common-input-fields): an omitted or null `agent_id`
|
||||
is treated as main, a valid ID identifies a subagent or in-process teammate, and
|
||||
an invalid ID is dropped rather than inferred as main. The fixtures are synthetic;
|
||||
no live PermissionRequest capture qualifies that convention on an installed native
|
||||
version. A hook denial without a source-bound guard receipt has unknown origin.
|
||||
This diagnostic adds no guard collector, permission decision or attention policy.
|
||||
|
||||
The new PermissionRequest diagnostic is optional for activity-hook readiness.
|
||||
Every preexisting required event, relay asset and explicit hook-disable check
|
||||
still applies. An older installed plugin at the same manifest version retains
|
||||
its bytes and may not send this event: plugin-only delivery needs a future normal
|
||||
maintainer release refresh, and managed delivery needs normal reprojection. A
|
||||
manifest or green readiness check does not prove live hook execution. Raw tool
|
||||
arguments and provider reason text are withheld; diagnostic evidence is bounded,
|
||||
memory-only and expires after five minutes.
|
||||
|
||||
### Who may intervene
|
||||
|
||||
`rig seat status <seat>` shows the seat's terminal interaction account alongside
|
||||
@@ -174,18 +88,8 @@ their existing behaviour.
|
||||
|
||||
### Terminal delivery
|
||||
|
||||
`rig seat set-typing-guard` works from an ordinary terminal as well as from a
|
||||
seat's shell. The daemon takes the recorded actor from transport identity only:
|
||||
|
||||
- From a managed seat's shell (`OPENRIG_SESSION_NAME` set), the preference and
|
||||
its audit entry name that seat.
|
||||
- From a terminal with no seat identity, the change applies the same way, and
|
||||
the preference and its audit entry record the actor as
|
||||
`unattributed (no seat identity)`.
|
||||
|
||||
Neither case takes an actor from the request body or from the target seat's name.
|
||||
`rig seat retire-held-message` still records the seat that retired a message, so
|
||||
run it from a seat's shell.
|
||||
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 \
|
||||
@@ -196,7 +100,7 @@ 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
|
||||
Use exactly one of `--mode off|draft-aware|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
|
||||
@@ -204,21 +108,8 @@ to finish. The API may return HTTP 202 with `pending: true`; wait for
|
||||
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 and the saved attempt's `outboxIds`. No automatic
|
||||
retry is scheduled. The reasons are:
|
||||
|
||||
- `typing_guard_enabled`: the seat's mode holds this send (`hold`, or a level that
|
||||
holds this kind of message).
|
||||
- `runtime_switch_in_progress`: the seat is switching runtime.
|
||||
- In `draft-aware` mode, a refusal before any input was written:
|
||||
`draft_input_busy` (the input holds unsubmitted text), `draft_input_unknown` (the
|
||||
input can't be read), `draft_input_changed` (the input or its target changed during
|
||||
the delivery), `draft_wake_superseded`, `target_needs_input`,
|
||||
`wait_for_idle_timeout`, `target_activity_unknown`, `target_runtime_unverified`,
|
||||
`target_runtime_not_running`, `target_runtime_conflict`, `invalid_wait_for_idle`,
|
||||
`invalid_dangerously_interact`, `dangerously_interact_requires_reason`,
|
||||
`prompt_override_audit_unavailable`, `transport_unavailable`, `session_missing` or
|
||||
`tmux_unavailable`. Each explicit draft-aware request is a new attempt, including
|
||||
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.
|
||||
|
||||
@@ -236,26 +127,6 @@ 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.
|
||||
|
||||
A seat holds one message per sender, so one sender can't fill its held quota and
|
||||
drop everyone else's messages. A sender's newest held message retires its earlier
|
||||
ones at that seat into itself (`retired_by: outbox@system`, reason `coalesced into
|
||||
<id>`), in every mode, `draft-aware` included. The newest names them: `rig seat
|
||||
held-messages` shows `coalesced: { count, outboxIds }` on it, and each coalesced
|
||||
record stays readable with `--id`. Held messages are never replayed, so this changes
|
||||
no delivery. A `draft-aware` attempt takes its sender's place only once it settles
|
||||
as held; one that is then delivered leaves the held messages as they are. A queue
|
||||
wake to a `draft-aware` seat counts its sender's held message there as room, by the
|
||||
same rule. At a full quota, a sender with nothing held there is still refused, and
|
||||
nothing from another sender is touched. A committed wake is never refused, and the
|
||||
sender's next held message coalesces it like any other. A message that settles as
|
||||
held late, behind a newer held send, folds into that send's newest part and leaves
|
||||
its other parts held. An urgent message coalesced under a routine one passes its
|
||||
marking to the newest. Never coalesced: the other parts of the same send, a
|
||||
watchdog's reminders (one per job), and an unattributed sender.
|
||||
|
||||
A backlog held before this change clears at that sender's next held message to the
|
||||
seat, or at once with `rig seat retire-held-message`.
|
||||
|
||||
## Input evidence and outcomes
|
||||
|
||||
The cursor-aware reader lives in `session-transport.ts` and shares the composer
|
||||
@@ -336,9 +207,7 @@ 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}`. The `X-OpenRig-Session` header, when
|
||||
present, is the recorded actor; without it the change is recorded as
|
||||
`unattributed (no seat identity)`. Status and held-message routes are
|
||||
`{mode, reason}` or `{enabled, reason}`. Status and held-message routes are
|
||||
unchanged; no RigSpec field is required.
|
||||
|
||||
## Verification scope
|
||||
@@ -361,9 +230,6 @@ current parked-row eligibility and stop checks still apply. This changes message
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
@@ -58,15 +58,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
|
||||
|
||||
@@ -99,24 +90,8 @@ So after installing, compare the granted scopes Slack shows for the app with all
|
||||
`rig slack manifest --json` lists.
|
||||
|
||||
The app subscribes to messages in public channels it is a member of (`message.channels`), to
|
||||
mentions of the app (`app_mention`), and to emoji reactions (`reaction_added`). The manifest does
|
||||
not request direct-message or private-channel access.
|
||||
|
||||
### Using a private channel
|
||||
|
||||
The connector doesn't check whether a channel is public or private, so a private channel can be
|
||||
the default channel or a mapped one once the app can read it. OpenRig doesn't test this setup. In
|
||||
the app's settings:
|
||||
|
||||
1. Under **OAuth & Permissions**, add the bot scopes `groups:history` (read messages in private
|
||||
channels the app is in) and `groups:read` (read a private channel's details; `rig slack verify`
|
||||
needs it to confirm the app is a member).
|
||||
2. Under **Event Subscriptions**, subscribe to the bot event `message.groups`.
|
||||
3. Reinstall the app to the workspace, then invite it to the private channel.
|
||||
4. To have `rig slack verify` require the new scopes too, run
|
||||
`rig slack setup --required-scopes chat:write,channels:history,channels:read,groups:history,groups:read`.
|
||||
|
||||
Direct messages are not supported.
|
||||
mentions of the app (`app_mention`), and to emoji reactions (`reaction_added`). It does not request
|
||||
direct-message or private-channel access.
|
||||
|
||||
A reaction added to an ask reaches the agent that asked, as a note naming who reacted and with
|
||||
which emoji; the agent decides what it means. Every message OpenRig posted for the ask counts:
|
||||
@@ -125,14 +100,6 @@ reaction on any other message, including a person's reply in the thread, is igno
|
||||
`reaction_added` event: add both under **OAuth & Permissions** and **Event Subscriptions**, then
|
||||
reinstall the app to the workspace.
|
||||
|
||||
To have reactions ignored instead, run `rig slack setup --reactions off`. The agent that asked
|
||||
never hears of a reaction, including one still waiting to be retried, and a person answers an ask by
|
||||
typing a reply in its thread. Slack still sends the event and the manifest keeps it, so switching
|
||||
back with `--reactions route` needs no change to the app. Like any saved change, the setting reaches
|
||||
a running connector when it next rewires, which a daemon restart does (`rig daemon stop`, then
|
||||
`rig daemon start`). See [One channel per rig or seat](#one-channel-per-rig-or-seat-optional) for
|
||||
why disabling and re-enabling the connector isn't the same.
|
||||
|
||||
The manifest also turns on **Interactivity**, so the human can answer a decision's structured
|
||||
questions by clicking a button (`rig queue create --human-questions-file`). In Socket Mode the
|
||||
clicks arrive over the same socket, so no request URL is needed. An app created from an older
|
||||
@@ -182,9 +149,6 @@ rig slack channel-map list
|
||||
rig slack channel-map remove pr@my-rig
|
||||
```
|
||||
|
||||
A client can offer the channels to choose from with `GET /api/gateway/slack/channels`, which lists the public
|
||||
channels the bot token can see, with their ids ([Writing a daemon client](daemon-http-clients.md#route-details)).
|
||||
|
||||
- **Which channel an item posts to.** The item's seat is matched exactly first (`pr@my-rig`),
|
||||
then its rig (`my-rig`). Anything unmapped posts to the default channel, so a config
|
||||
without a map behaves exactly as before. Aggregate delivery digests have no single seat and
|
||||
@@ -218,15 +182,7 @@ channels the bot token can see, with their ids ([Writing a daemon client](daemon
|
||||
- **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 and every mapped channel, except a channel relayed to another machine.
|
||||
- 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
|
||||
@@ -234,15 +190,13 @@ app is in all of them. A saved change reaches a running connector when it next r
|
||||
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
|
||||
didn't finish still posts: an alert whose row is still open, a decision-resolved notice, or a digest. 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
|
||||
connector reads the file, and `rig slack status` names it. A private channel can be a target once
|
||||
the app has the private-channel scopes ([Using a private channel](#using-a-private-channel)).
|
||||
Direct messages are not supported targets.
|
||||
connector reads the file, and `rig slack status` names it. Private channels and direct messages
|
||||
are not supported targets.
|
||||
|
||||
## Named bots (optional)
|
||||
|
||||
@@ -304,7 +258,7 @@ reaches a running connector when it next rewires, which a daemon restart does (`
|
||||
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.
|
||||
open, a decision-resolved notice, or a digest.
|
||||
|
||||
## One app across machines (optional)
|
||||
|
||||
@@ -349,8 +303,9 @@ decisions, and posts its own replies.
|
||||
- **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; `rig slack status` shows local in-flight attempts separately from retained records, but zero does not prove global quiescence.
|
||||
- **Who answered:** every post starts `From <seat> on <machine>`, using the machine's host id (set
|
||||
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 ends `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.
|
||||
@@ -385,36 +340,6 @@ Quiet hours hold a person's routine asks while they're on, and post them once th
|
||||
|
||||
## Reconnect recovery and status
|
||||
|
||||
When a posted ask is answered, closed, canceled or replaced elsewhere, the connector edits its
|
||||
original primary message with the status, time, actor and closure note. Buttons become option
|
||||
text and the answer hint disappears; the remaining content and reconciliation marker stay as
|
||||
posted. Supplemental messages are unchanged. This adds no post or reaction and needs no agent
|
||||
turn. Separate resolution notices retain their existing behavior.
|
||||
|
||||
A deliberately reopened direct ask says **Open again**; its options remain as text. If it has
|
||||
no committed answer, it tells the person to reply in its thread. Otherwise it says **Earlier
|
||||
answer retained**: reopening does not accept a second answer. Earlier replaced parks remain
|
||||
historical; **Asked again** does not claim that the later ask has been posted. Edits reconcile
|
||||
the current state again after awaited Slack calls, so an obsolete close is not recorded as the
|
||||
current result after a reopen.
|
||||
|
||||
Edits follow committed queue changes, with a startup catch-up for posted asks on retained queue
|
||||
rows. Each primary costs one exact-message read and one `chat.update`. The serial edit worker
|
||||
uses the recovery budget helpers (four calls, 100 entries, 15 seconds) and waits for the next
|
||||
budget window when exhausted. Slack's per-method `Retry-After` is retained across restarts.
|
||||
Only an acknowledged edit gets a success receipt. `rig slack status` shows queued reconciliation
|
||||
and the last edit failure. Transient failures retry with increasing waits, capped at an hour,
|
||||
behind other queued work. Permanent Slack failures (such as missing scope, a deleted original,
|
||||
an archived channel or an uneditable message) remain unfinished and are retried only on the next
|
||||
connector start. They do not keep consuming edit capacity. These edit calls have their own
|
||||
budget, separate from missed-message scanning below.
|
||||
|
||||
The baseline already includes public-channel history access and `chat:write`. Private-channel
|
||||
reads may require `groups:history`, which the baseline does not grant; a refused read never
|
||||
counts as an edit. An unavailable/deleted original cannot be replaced by a new post. Startup
|
||||
catch-up cannot edit rows already removed by queue retention or posts without their original
|
||||
notification receipt and channel stamp.
|
||||
|
||||
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
|
||||
|
||||
@@ -115,67 +115,3 @@ successful empty history. The CLI preserves the error body in JSON mode and exit
|
||||
1 for request errors, 2 for unavailable reads. Existing SSE and usage APIs retain
|
||||
their behavior. Collector deployment, exporters and durable store epochs are
|
||||
separate work.
|
||||
|
||||
## Independent exporter mapping and limits
|
||||
|
||||
OpenRig's telemetry guarantee ends at the finite HTTP/CLI read envelope above. Any
|
||||
projection of those finite reads (or of cached rig/node inventory) into
|
||||
OpenTelemetry logs, custom metrics, or spans is defined and owned by an external
|
||||
consumer or exporter—not guaranteed by OpenRig's core API.
|
||||
|
||||
The external OTLP exporter at `smota/openrig-telemetry` applies the following
|
||||
exporter-specific mapping choices to OpenRig's source facts (other consumers may
|
||||
make different choices):
|
||||
|
||||
- **Recorded queue-state intervals (not execution time)**: In OpenRig, queue
|
||||
transition rows record state changes (`pending`, `in-progress`, `blocked`, and
|
||||
departure states), not agent execution duration or model latency; the first
|
||||
observation of an item has no prior boundary; repeated same-state rows remain
|
||||
distinct source records; and any page carrying an ID-range or withheld-record gap
|
||||
(`before_retained_floor`, `retained_sequence_gap`, `conflicting_transition_id`,
|
||||
`record_byte_limit`, `historical_transitions_not_read`, truncated gap details, or
|
||||
a boot/watermark discontinuity) cannot prove what happened in the missing range.
|
||||
In `smota/openrig-telemetry`, the exporter pairs an entering state with a later
|
||||
departure state to measure only the recorded interval between two observed rows
|
||||
with valid, ordered UTC timestamps, suppresses or drops open intervals across gap
|
||||
boundaries rather than inventing durations, and reports cumulative transition
|
||||
counts only for rows observed from its checkpoint (including historical rows when
|
||||
started with `start=retained`), never lifetime daemon totals.
|
||||
- **No current-owner, seat, tenure, or native-session join**: In OpenRig's finite
|
||||
API, the `queue-transitions` stream does not join transition rows to current seat
|
||||
owners, mutable node or rig names, occupant tenures (`tenures`), or native
|
||||
harness sessions, and it never selects queue bodies or transition notes; each
|
||||
transition row does include `qitemId`, `actorSession`, `closureTarget`, and
|
||||
`originalTimestamp`. In its exported OTLP output, `smota/openrig-telemetry`
|
||||
chooses to emit derived queue spans as root/internal observations without
|
||||
invented parent/child trace hierarchy, cross-stream causal links, or `gen_ai.*`
|
||||
model/token/cost attributes; places `qitemId` only on opt-in log/span attributes
|
||||
rather than metric dimensions; and omits the source fields `actorSession`,
|
||||
`closureTarget`, and raw `originalTimestamp` from exported OTLP logs, metrics,
|
||||
and spans.
|
||||
- **Sampling unknowns on cached seat inventory**: OpenRig's cached rig and seat
|
||||
inventory endpoints (`GET /api/rigs`, `GET /api/rigs?archived=only`,
|
||||
`GET /api/rigs/:rigId/nodes`) return point-in-time daemon projections
|
||||
(`activityState.display`, `sessionStatus`, `arbitratedNeedsInput`, and
|
||||
`activityState.lastSwap`), not a continuous event stream or independent OS
|
||||
process probe; intermediate activity changes or multiple occupant handovers
|
||||
between polls are not observable from those snapshots, and initial
|
||||
observation has no backfilled history. In OpenRig source,
|
||||
`arbitratedNeedsInput` is a tri-state boolean (`true`, `false`, or `null`):
|
||||
`true` means the seat needs input, `false` means it was observed clear of
|
||||
needing input, and `null` means no evidence exists either way (it does not
|
||||
indicate `false`). Separately, external consumers or metric mappings must
|
||||
choose how to interpret or project this tri-state value into their own
|
||||
metrics or counters. `smota/openrig-telemetry` polls these snapshots as
|
||||
point-in-time observations and relies on downstream metric storage to enforce
|
||||
freshness/staleness TTL when polling stops or a seat departs.
|
||||
|
||||
### External OTLP exporter repository and version compatibility
|
||||
|
||||
An independent, standalone OTLP/HTTP exporter is maintained outside OpenRig core at:
|
||||
|
||||
- **Repository**: <https://github.com/smota/openrig-telemetry>
|
||||
|
||||
Its repository documents installation, configuration, and supported OpenRig versions; OpenRig does not test or guarantee the exporter.
|
||||
|
||||
OpenRig's finite telemetry reads are versioned (`schemaVersion: 1`), while cached rig and node inventory endpoints are operational projections rather than a versioned telemetry contract.
|
||||
@@ -30,58 +30,16 @@ 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.
|
||||
An explicit session resolves under the daemon user's Herdr session directory;
|
||||
it takes precedence over that daemon's default socket override. 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
|
||||
@@ -116,43 +74,6 @@ 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 }`.
|
||||
@@ -167,335 +88,3 @@ 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: {kind:"qitem", id, 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.
|
||||
|
||||
Live following supports **Linux and macOS with Herdr protocol 22**. Admission
|
||||
checks the live protocol and selected socket, then confirms each pane's IDs,
|
||||
focus state and foreground attachment. A shell startup `cd` does not change
|
||||
pane identity; filesystem aliases of the same existing path are accepted.
|
||||
If a creation response is lost and shell startup changes the directory, Core
|
||||
may be unable to attribute that pane. It reports uncertainty and does not
|
||||
repeat the creation.
|
||||
The detected Herdr version remains visible in provider status. The native
|
||||
qualification version is 0.9.5; a matching release label alone is not proof of
|
||||
compatibility, and future protocol numbers are not assumed compatible.
|
||||
|
||||
If a new view's platform or protocol cannot support following, Core opens a
|
||||
labelled **snapshot** instead and disables automatic following. It will not
|
||||
track later owner changes. Incomplete membership prevents that snapshot from
|
||||
opening; the result explains why. A failed endpoint/identity read or an existing
|
||||
canvas stays unconfirmed, preserving it instead of creating a fallback view.
|
||||
Other providers and 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 a supported Linux or macOS Herdr 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. Each viewer's
|
||||
private `attach.sh` is kept for provider restarts. `--no-follow` preserves the
|
||||
workspace and its scripts; stopping Herdr is not proof that its panes are gone.
|
||||
Core removes only an exact, unedited owned script after confirming its viewer
|
||||
is absent. Old views that are never reconciled again can therefore accumulate
|
||||
scripts; there is no cleanup based only on an unreachable provider.
|
||||
|
||||
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.
|
||||
|
||||
A writable tile attaches with `tmux attach -f ignore-size` when its session
|
||||
already has a client that sizes it, so the tile doesn't fight that client over
|
||||
the agent window's dimensions. When the session has no sizing client, as with a
|
||||
freshly launched seat, the tile attaches without the flag and sizes the window
|
||||
itself: tmux applies ignore-size across the whole server, so a session viewed
|
||||
only by ignore-size clients would otherwise keep its 80x24 creation size while
|
||||
any other session has a sizing client. Read-only clients keep `-r` (which also
|
||||
implies ignore-size), and the printed manual attach commands keep
|
||||
`-f ignore-size`. This is **not resize isolation**: opening a tile can change
|
||||
the agent's terminal size. Observers of a Herdr terminal do not themselves resize it;
|
||||
a controlling browser can. No cross-version resize guarantee is implied.
|
||||
|
||||
To type into a seat without changing its size, send the text through OpenRig
|
||||
(`rig send`, or `POST /api/transport/send`): it is written to the pane with tmux
|
||||
buffers and keys, and no client attaches. There is no supported way to attach a
|
||||
writable client that is sure never to resize the agent's window.
|
||||
|
||||
|
||||
## Following a slice or mission's current work
|
||||
|
||||
Use `rig terminal open slice:<name-or-id> --follow --provider herdr` to keep
|
||||
one workspace following the current destination owners of that slice's active
|
||||
queue rows. `pending`, `in-progress` and `blocked` rows count. A member can own
|
||||
several rows; `task.members[].obligations` preserves every row and state.
|
||||
|
||||
A slice resolves within the daemon's configured slice index, by exact directory
|
||||
name or unique frontmatter `id`. Tags must name it exactly: `rig queue create
|
||||
--slice <name-or-id> ...` associates a row. Handoff inherits those tags unless
|
||||
replaced; merely linking an untagged child does not associate it. A mission-only
|
||||
tag does not put a row in every slice. No project is inferred from a tag or the
|
||||
caller's working directory. A bare name or ID claimed by multiple slices is
|
||||
`scope_ambiguous`; no first-match scope is selected. Selecting a unique key can
|
||||
still follow a slice whose other alias is reused: a row carrying that colliding
|
||||
alias must have exactly one `mission:` tag naming exactly one indexed claimant.
|
||||
Rows placed in another mission are excluded. Missing, contradictory or otherwise
|
||||
unplaceable mission/alias tags make the snapshot incomplete, retaining previous
|
||||
viewers. A unique alias on its own retains exact matching. Flat legacy slices
|
||||
without an indexed parent mission cannot disambiguate a colliding alias.
|
||||
|
||||
Mission views use `mission:<directory-id>`, from the same configured index.
|
||||
A mission is known when its directory contains `slices/` (even empty) or a
|
||||
readable `mission.yaml` declaration. Its directory ID is its exact identity;
|
||||
mission frontmatter is not an alias. Membership is the union of active rows
|
||||
with that exact `mission:` tag and rows associated with any physically indexed
|
||||
child slice by its exact name or unique ID. Each row counts once even when it
|
||||
has both tags. Flat legacy slices without that parent association are not
|
||||
inferred into a mission. A child's historical status or composition activation
|
||||
flag does not remove its current obligations from the union. Duplicate mission
|
||||
IDs are ambiguous. Shared child names/IDs are omitted from bare slice matching;
|
||||
rows with one indexed claimant mission are placed through their mission tag.
|
||||
A direct mission tag takes precedence, including work tagged to both missions.
|
||||
Without that direct tag, a shared-key row with missing, contradictory or
|
||||
non-claimant mission tags is named in `issues` and makes the snapshot incomplete;
|
||||
known owners remain.
|
||||
The launcher reads this same terminal preview and submits its `expectedPlan`
|
||||
when opening, rather than using the review-agent roster.
|
||||
|
||||
Slice and mission keys are case-sensitive and can include Unicode, plus signs and internal
|
||||
spaces. They cannot be empty or contain slash, backslash, control characters,
|
||||
or leading/trailing whitespace in the scope reader. The service trims the whole
|
||||
selector before composition, so a trailing selector space is accepted by
|
||||
preview/ensure with the same plan; ensure rejects a leading selector space.
|
||||
Prefer the exact canonical selector in every call. Pass it in JSON or a quoted
|
||||
CLI argument; encode it once as a GET query value. Do not double-decode it.
|
||||
|
||||
Slice previews and open results use `task: {kind:"slice", id, name, complete,
|
||||
issues, members}`. `id` is the unique frontmatter id, or the directory name when
|
||||
no id is present. There is no synthetic `qitemId`. Preserve the preview's `view`
|
||||
for the ensure request. All successful previews, including qitem previews,
|
||||
include `capabilities: {ensureViewer:boolean}`. True means that the resolved
|
||||
view/provider implements ensure; the existing member, completeness, owner and
|
||||
`expectedPlan` checks still apply. A missing capability/task on an older daemon
|
||||
is unsupported scoped membership, not an empty team.
|
||||
|
||||
Mission results use the same shape with `task.kind:"mission"`; `task.id` and
|
||||
`task.name` are the directory ID, with no synthetic `qitemId`. Ordinary mission
|
||||
preview/open uses this current-obligation membership too: past participants
|
||||
without active obligations are excluded, while unavailable current owners
|
||||
remain named. Follow, preview and ensure share the same authority.
|
||||
|
||||
New slice and mission workspaces default to four automatic tiles; `--max-tiles` overrides
|
||||
that. Qitem views retain their eight-tile default. The full membership and
|
||||
obligation list remains available when tiles exceed capacity. Pins, drafts,
|
||||
unknown input and focus retention still protect departed viewers. Closing a
|
||||
viewer never stops its agent. Opening a tile can change the agent's dimensions.
|
||||
|
||||
A known empty slice or mission remains enabled in the same workspace; later associated
|
||||
work can join it. An incomplete queue read (`task.complete:false` plus `issues`)
|
||||
retains previous viewers. A missing scope is `scope_not_found`, duplicate scope
|
||||
identity is `scope_ambiguous`, unreadable/capped index data is `scope_unavailable`,
|
||||
and an unavailable resolver is `scope_unsupported`. None is authoritative empty.
|
||||
Ensure of an incomplete snapshot returns `viewer_unconfirmed`; changed plans
|
||||
return `preview_changed` before provisioning.
|
||||
|
||||
Ordinary ownership changes follow queue/session/node events. Node and directory
|
||||
changes under the primary configured index use the existing filesystem watcher,
|
||||
with a coalesced scope invalidation event. There is no repeating membership
|
||||
poll. Structural edits under additional legacy roots, or a filesystem where
|
||||
watching is unavailable, require `rig`'s existing slice refresh endpoint
|
||||
`POST /api/slices/refresh` or reopening the view. Missing or unreadable sources
|
||||
keep prior viewers; they never certify that the work disappeared.
|
||||
|
||||
|
||||
Scope identity reads are bounded to 4,096 directory entries, 16 KiB per selected
|
||||
node-header read, and 8 MiB of source bytes per index generation. The byte budget
|
||||
counts the bounded read, including text after short frontmatter. An index that
|
||||
exceeds these limits is too large for this read; use a smaller configured index
|
||||
or a qitem view. This is separate from unreadable or malformed sources.
|
||||
Queue membership reads are bounded to 50,000 active rows plus one lookahead,
|
||||
16 KiB of encoded tags per row and 8 MiB of aggregate encoded tags. They remain
|
||||
fresh on each render. Identities are reused within a scope-source generation
|
||||
only while the primary-root watcher is live and existing additional roots are
|
||||
covered by that root. A source event or explicit refresh invalidates them;
|
||||
errors discard the cache. Unavailable watching or an existing unwatched legacy
|
||||
root uses fresh bounded identity reads instead. Missing legacy roots are checked
|
||||
on each call so an appearing root cannot reuse an older cached identity set.
|
||||
|
||||
Error bodies remain authoritative. `scope_unsupported` is HTTP 400,
|
||||
`scope_not_found` is 404, and `scope_ambiguous`/`scope_unavailable` are 409.
|
||||
An incomplete queue snapshot is a successful preview with `task.complete:false`;
|
||||
ensure returns `viewer_unconfirmed` without a provider effect. Unsupported
|
||||
ensure view kinds now return `view_required` (400), where they previously returned
|
||||
200 with an error body. Unsupported follow view kinds
|
||||
retain `follow_view_required` in a 200 `ok:false` body, as before. A changed
|
||||
preview remains `preview_changed` (409). During automatic refresh a missing or
|
||||
ambiguous scope retains the existing tiles and names the source problem in the
|
||||
explanation strip; refresh or reopen it under the current unambiguous key.
|
||||
|
||||
|
||||
## Reading or sending to an expected native conversation
|
||||
|
||||
An application that selects an original Claude Code or Codex conversation can opt
|
||||
into an expected-identity check. The identity is the exact tuple `nodeId`, canonical
|
||||
`sessionName`, `runtime` (`claude-code` or `codex`) and native `nativeId`. A registry
|
||||
resume token by itself is not a current-process identity observation.
|
||||
|
||||
Read `GET /api/sessions/:sessionName/generation-record-expected` with query fields
|
||||
`expectedNodeId`, `expectedRuntime`, `expectedNativeId` and optional `sinceBytes`.
|
||||
It resolves the verified current occupant and native process, opens that native
|
||||
record, checks its identity, and returns identity and bounded suffix bytes from
|
||||
the same opened file. Its existing byte cursor and `generationId` semantics match
|
||||
`generation-record`. Success adds `identityContract:1` and `checkedIdentity`, the
|
||||
exact expected tuple. Check both before displaying the suffix as that conversation.
|
||||
|
||||
Send `POST /api/transport/send-expected` with the ordinary send body and a required
|
||||
`expectedIdentity` object containing those four fields. The check runs again after
|
||||
buffer preparation immediately before paste, and before Enter after any wait.
|
||||
A retained input stores the constraint in its outbox receipt; committed dispatch
|
||||
and reuse of its delivery ID cannot select a different original conversation or
|
||||
omit the stored constraint. Delivery IDs still do not deduplicate explicit input.
|
||||
Success adds `identityContract:1` and `checkedIdentity`. Retained is not sent;
|
||||
`checkedIdentity` is an observation at an input boundary, not model consumption.
|
||||
If replacement is observed after paste, the result reports `inputPhase:"written"`
|
||||
and `sent:true`, with Enter refused. Inspect that input before deciding what to do.
|
||||
|
||||
Malformed expectations return 400 (`invalid_expected_identity`). A different
|
||||
observed identity returns 409 (`expected_identity_mismatch`); a missing or
|
||||
unverified identity returns 409 (`expected_identity_unavailable`). Conflicting
|
||||
reuse of a delivery ID returns 409 (`delivery_identity_conflict`). No conditional
|
||||
operation falls back to the replacement. Use these dedicated paths when detecting
|
||||
support: older daemons return 404 instead of silently ignoring an unknown field.
|
||||
Ordinary reads and sends without an expectation or an ID bound by an earlier
|
||||
conditional send keep their existing behavior. Reusing a bound ID also carries
|
||||
its stored constraint when the caller omits the expectation.
|
||||
|
||||
While a runtime switch is preparing or confirmed, a conditional send returns
|
||||
409 `expected_identity_unavailable` with `sent:false` and `inputPhase:"not_reached"`.
|
||||
It retains neither the input nor the expectation. Retry the conditional endpoint
|
||||
with the original `expectedIdentity`; an ordinary send is not an identity-bound
|
||||
retry unless its ID already has a stored constraint. Ordinary sends during the
|
||||
switch keep their existing retained response.
|
||||
|
||||
This is a finite process/record observation followed by a terminal write; tmux has
|
||||
no atomic native-conversation compare-and-write operation. A native conversation
|
||||
change after the final observation remains a race. The check does not turn a
|
||||
terminal attachment into a conversation pin: existing terminal viewers follow
|
||||
managed pane/session bindings and may display their replacement. Applications
|
||||
must keep that attachment limit separate from checked record reads and sends.
|
||||
@@ -1,214 +0,0 @@
|
||||
# Choosing the tmux server for an OpenRig instance
|
||||
|
||||
`terminal.tmux_server` controls where the daemon creates and reads managed seats.
|
||||
It accepts `inherit` (the default) or `isolated`. The environment override is
|
||||
`OPENRIG_TMUX_SERVER`.
|
||||
|
||||
To keep managed seats out of your personal `tmux ls`, set this before starting a
|
||||
new instance, or before a deliberate restart of an existing daemon:
|
||||
|
||||
```sh
|
||||
rig config set terminal.tmux_server isolated
|
||||
```
|
||||
|
||||
The setting is read once at daemon startup. Changing it does not move a running
|
||||
seat. Inspect the effective choice with:
|
||||
|
||||
```sh
|
||||
rig terminal status --json
|
||||
```
|
||||
|
||||
The `tmux` object reports `requestedMode` (read at the last startup), effective
|
||||
`mode`, `socketPath`, `serverArgs`, `migrationPending`, and `blockingSessions`.
|
||||
The settings browser shows the configured value; terminal status shows what the
|
||||
running daemon actually uses.
|
||||
|
||||
## Server identity and configuration
|
||||
|
||||
In `inherit` mode, a new instance uses the launching shell's `TMUX` socket, or the
|
||||
default socket under `TMUX_TMPDIR` (otherwise `/tmp`) when outside tmux. Native
|
||||
commands retain tmux's existing environment-based selection. Printed attachments
|
||||
and terminal metadata name a non-default server so a new terminal can reach it.
|
||||
The default mode does not require a server record or an additional instance
|
||||
identity check to start the daemon or use existing attachment paths.
|
||||
|
||||
In `isolated` mode, the socket is named `openrig-<hash>` under tmux's per-user
|
||||
directory. The hash identifies the canonical `OPENRIG_HOME`: different homes get
|
||||
different servers, and symlink aliases of one home share a server. Choose a short
|
||||
`TMUX_TMPDIR` before the first isolated start if your normal temporary path would
|
||||
exceed the platform's Unix socket path limit.
|
||||
|
||||
When isolation is requested or active, the daemon records the effective socket in
|
||||
`OPENRIG_HOME/tmux-server.json` using an atomic, owner-only write. Keep this file
|
||||
and the durable `tmux-server.initialized` marker with the instance state. A later shell
|
||||
with different `TMUX` or `TMUX_TMPDIR` does not redirect an established isolated
|
||||
instance. An invalid or unreadable isolation record is reported rather than overwritten.
|
||||
If the record disappears while isolation is selected, startup and `destroy --all` stop before
|
||||
touching sessions. Restore the record from an instance backup, or inspect the
|
||||
running daemon's `rig terminal status --json` to recover its effective selection.
|
||||
An ordinary inherited instance may record its socket for a later migration, but
|
||||
missing, invalid or unwritable records do not prevent its normal startup.
|
||||
The record's `pinned` flag distinguishes a fixed isolated or migration socket
|
||||
from an advisory inherited record. Returning to ordinary inheritance commits
|
||||
`pinned: false` and removes the active marker; an interrupted update must not
|
||||
leave startup using one server and offline cleanup selecting another. A valid
|
||||
unpinned record takes precedence over a leftover marker. Legacy inherited records
|
||||
and the old `1` marker do not make ordinary inheritance depend on that file.
|
||||
|
||||
A newly started server loads tmux's normal system and user configuration,
|
||||
including `~/.tmux.conf`; OpenRig does not substitute an empty configuration.
|
||||
Existing OpenRig session options, such as `terminal.status_bar`, still apply.
|
||||
An existing server does not reread its configuration merely because the daemon
|
||||
restarts. Settings and sessions in a separate personal server are left alone.
|
||||
|
||||
## Existing teams and deferred migration
|
||||
|
||||
At startup, the daemon checks the previously selected server before switching.
|
||||
If registered sessions still exist there, it keeps that server and reports a
|
||||
pending migration. Explicit `OPENRIG_HOME` ownership markers distinguish another
|
||||
instance's namesakes; unmarked legacy sessions are retained conservatively. If
|
||||
the old server cannot be inspected, the daemon also keeps it and reports why.
|
||||
|
||||
Stop the listed sessions deliberately, then restart the daemon again to apply the
|
||||
new selection. Merely editing the setting or waiting for the last seat to stop
|
||||
does not switch a running daemon. The same rule applies when returning to
|
||||
`inherit` from isolation. A normal inherited start continues to follow its launching
|
||||
environment, as before. No
|
||||
live pane is moved, restarted or killed by server selection. Old viewing windows
|
||||
can be closed separately after their conversations have stopped.
|
||||
|
||||
Bring a stopped team back with `rig up <name> --existing`. Its normal restoration
|
||||
rules still apply: a seat without usable provider history requires an explicit
|
||||
fresh-start choice before it launches.
|
||||
|
||||
On the first upgrade from a version without a socket record, the old-server
|
||||
candidate comes from the launching environment. If live seats use a non-default
|
||||
server, start the upgraded daemon from that original server to establish its
|
||||
record, or stop those seats before changing servers. Earlier versions did not
|
||||
persist enough information to find an arbitrary old socket from a different shell.
|
||||
|
||||
## Moving an existing team onto tmux control mode
|
||||
|
||||
`OPENRIG_TMUX_CONTROL=1` in the daemon's environment sends its routine tmux commands
|
||||
through one long-lived control-mode client instead of a new tmux client each time,
|
||||
which saves a process start per command on a busy host. It takes effect only on a
|
||||
tmux server at 3.7c or later. The version of the server that is running decides,
|
||||
not the tmux binary on `PATH`, and a server keeps the version it started with until
|
||||
it exits. So installing a newer tmux changes nothing for a server that is already up.
|
||||
On an older server the daemon uses a spawned client for every command. It says so
|
||||
once in its log (`[tmux] OPENRIG_TMUX_CONTROL=1 is set, but this daemon's tmux server
|
||||
runs tmux 3.4 …`), and `rig doctor` reports it as `tmux_control_mode`.
|
||||
|
||||
To move a team that runs on an older server:
|
||||
|
||||
1. Install tmux 3.7c or later where the daemon finds it first on its `PATH`. For a
|
||||
daemon run as a service, that's the service's environment.
|
||||
2. Choose the server the team will use. `isolated` gives this OpenRig home a server of
|
||||
its own, started by the daemon with that tmux, and leaves your own tmux sessions
|
||||
alone: `rig config set terminal.tmux_server isolated`. In `inherit` mode the team
|
||||
shares your default server, which runs the new version only after every session
|
||||
on it, yours included, has ended.
|
||||
3. Stop each rig deliberately with `rig down <rig>`. Its seats' sessions end, and a
|
||||
turn in progress stops. Queue rows, rigs and seats stay as they are.
|
||||
4. Restart the daemon with `OPENRIG_TMUX_CONTROL=1` in its environment.
|
||||
`rig terminal status --json` should show `migrationPending: false`. If it's still
|
||||
`true`, `blockingSessions` names the sessions still on the old server.
|
||||
5. Bring each rig back with `rig up <rig> --existing`. Each seat resumes its
|
||||
conversation where its harness allows, under the restoration rules above.
|
||||
6. Check: the daemon's log says `[tmux] Control mode is on`, and `rig doctor` passes
|
||||
`tmux_control_mode`.
|
||||
|
||||
## Attachments, discovery and cleanup
|
||||
|
||||
`rig capture`, managed launch/stop/recovery, terminal previews and desktop viewers
|
||||
use the effective server. `rig ps --nodes --json` includes `tmuxServerArgs` and a
|
||||
matching `tmuxAttachCommand` for bound tmux seats. `rig tui --shared` uses that
|
||||
metadata. To attach a terminal directly, use the canonical session name:
|
||||
|
||||
```sh
|
||||
rig terminal attach '<session>'
|
||||
rig terminal attach --read-only -- '<session>'
|
||||
```
|
||||
|
||||
These commands attach on the daemon's machine. They do not create a seat or
|
||||
switch somebody else's tmux client. A local CLI pointed at an HTTP remote daemon
|
||||
cannot attach a same-named local session. For an effectively isolated server,
|
||||
terminal attachment, shared TUI and desktop windows also compare the daemon's
|
||||
instance identity with the local durable identity before using local terminal
|
||||
tools. A loopback URL through a port forward does not bypass this isolation check;
|
||||
use the matching `OPENRIG_HOME` on the daemon's machine. Inherited servers retain
|
||||
the existing local-host checks without requiring a matching durable identity.
|
||||
SSH view tiles follow the viewing instance's effective mode. In `inherit`, they
|
||||
use remote tmux through `ssh -t`, including `-r` for read-only views, and keep the
|
||||
bounded, identity-checked reattachment when a seat's session is replaced. The
|
||||
remote host needs only tmux and a POSIX shell, not OpenRig. Copyable recovery
|
||||
commands remain plain `ssh -t ... tmux attach`. In `isolated`, tiles run
|
||||
`rig terminal attach` on the remote host,
|
||||
using that host's local instance and socket; both the remote CLI and daemon must
|
||||
support this command. The remote shell must select the right `OPENRIG_HOME` when
|
||||
using a custom home. Local socket paths are never sent to it, and an isolated
|
||||
attachment failure does not fall back to a same-named default-server session.
|
||||
An inherited view does not discover an isolated remote instance's socket; use
|
||||
the remote instance's `rig terminal attach` directly for that mixed setup.
|
||||
|
||||
Discovery and adoption see the selected server. Existing sessions on another
|
||||
server are not imported automatically. `rig attach --self` reports its current
|
||||
socket and, for an isolated instance, rejects a different server before binding
|
||||
or adding a node; an older
|
||||
CLI without socket metadata must be updated to self-attach to an isolated
|
||||
instance. A shell outside tmux can still attach as `external_cli`.
|
||||
|
||||
`rig destroy --all` reads the recorded effective server before stopping the daemon
|
||||
or removing its state when isolation or a deferred migration pins it. Ordinary
|
||||
`inherit` cleanup uses the invoking environment's normal tmux selection, as before;
|
||||
an old advisory socket is not used to redirect it. Cleanup kills only registered
|
||||
session names on the selected server.
|
||||
|
||||
Dedicated selection requires POSIX tmux. On Windows, psmux retains its existing
|
||||
server behavior; requesting `isolated` produces a status note and a pending choice.
|
||||
|
||||
## Two instances on one machine
|
||||
|
||||
Isolation selects only a tmux server. Give each daemon its own OpenRig home,
|
||||
database and port as well. For example, from a directory where you want to keep
|
||||
the two instances:
|
||||
|
||||
```sh
|
||||
mkdir -p .openrig-a .openrig-b
|
||||
env OPENRIG_HOME="$PWD/.openrig-a" OPENRIG_DB="$PWD/.openrig-a/rig.db" \
|
||||
OPENRIG_PORT=5311 OPENRIG_TMUX_SERVER=isolated rig daemon start
|
||||
env OPENRIG_HOME="$PWD/.openrig-b" OPENRIG_DB="$PWD/.openrig-b/rig.db" \
|
||||
OPENRIG_PORT=5312 OPENRIG_TMUX_SERVER=isolated rig daemon start
|
||||
```
|
||||
|
||||
Use the same instance-specific environment for every subsequent command, including
|
||||
`rig up`, `rig terminal status --json`, attachment and `rig daemon stop`. Do not
|
||||
reuse a database between these homes. Other inherited `OPENRIG_*` variables still
|
||||
apply; start from a shell without selectors for another running instance.
|
||||
|
||||
The operating-system home, provider credentials and provider conversation history
|
||||
remain shared unless configured separately. This setting does not isolate the
|
||||
filesystem or network. Both inherited and isolated servers support the opt-in
|
||||
`OPENRIG_TMUX_CONTROL=1` execution path; its control client connects to the same
|
||||
server as the daemon's other tmux commands. On servers older than tmux 3.7c, or
|
||||
whose version cannot be recognized as a fixed release, OpenRig uses spawned
|
||||
clients instead: starting a control client while another session is created can
|
||||
crash those servers (#2304). This avoids creating OpenRig's own control client;
|
||||
it cannot protect the server from other tools' control clients.
|
||||
|
||||
On a large fleet, the daemon's two seat-activity pollers (the activity sampler and the
|
||||
structural pane cache) are a large share of its tmux traffic: each runs once a second.
|
||||
The setting `daemon.seat_poll_interval_ms` slows both down, from the 1000 ms default up to
|
||||
2000 ms. Set it with `rig config set daemon.seat_poll_interval_ms 2000` (saved in
|
||||
`config.json`; `rig config` shows it with its source) or with `OPENRIG_SEAT_POLL_INTERVAL_MS`,
|
||||
which overrides the saved value. It is startup-only: the daemon reads it once when it starts,
|
||||
so a change takes a daemon restart. `rig config set` refuses a value outside 1000..2000; an
|
||||
environment value outside it is dropped with a warning and the daemon keeps the saved value or
|
||||
the default.
|
||||
A slower cadence makes activity and idle changes show up later, and a Codex permission prompt
|
||||
can also show up as much as one interval later. Keep the default if a structural sweep of your
|
||||
tmux server can take more than about 2 s (about 3 s at the default): past that, a sweep that
|
||||
runs over skips the next tick, the Codex working-frame proof expires, and a working Codex seat
|
||||
can briefly read idle. At 2000 ms the remaining 1 s margin is also what a lagging event loop
|
||||
uses up. The two settings compose: `OPENRIG_TMUX_CONTROL=1` makes each tmux command cheaper,
|
||||
and this one makes there be fewer of them.
|
||||
+112
-181
@@ -1,7 +1,7 @@
|
||||
# What changed in OpenRig 0.7.1
|
||||
# What changed in OpenRig 0.7.0
|
||||
|
||||
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.7.0. It lists only what behaves differently from
|
||||
0.6.14 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
|
||||
@@ -9,203 +9,134 @@ after an upgrade it can still hold the previous version's note.
|
||||
|
||||
## After the upgrade
|
||||
|
||||
- **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.
|
||||
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Six migrations run on the first start (116, 118,
|
||||
119, 121, 122 and 123). Two settings are added, `terminal.history_limit` (default 10000) and
|
||||
`terminal.inherit_tmux_environment` (default false), and `provider.subscription_moves` is removed.
|
||||
- **The bundled openrig-core plugin goes from 0.1.15 to 0.1.17.** Startup replaces installed copies of managed plugins and
|
||||
marked global skills. It first copies every file it replaces under `$OPENRIG_HOME/backups/managed-refresh/<id>/`,
|
||||
whether or not anyone edited it, and logs `[plugin-vendor] preserved previous contents of <file> at <copy> before
|
||||
managed refresh` for each one. A stock upgrade from 0.6.14 copies about 22 files (about 250 KB), and a later start with
|
||||
no new version copies none. The copies matter only for files someone changed: the copy keeps the bytes, but the change
|
||||
may no longer be active after the refresh. Keep the log line, because it names the original file and the copy's folder
|
||||
doesn't. If a copy fails, that plugin or skill isn't refreshed, its files are kept, and the log says so. Copies aren't
|
||||
cleaned up.
|
||||
- **If you gave new agents accounts or API keys through tmux's global environment** (`tmux set-environment -g …`), new
|
||||
agents no longer inherit it (below). Put the values in the seat's `env`, pass `rig up --env KEY=VALUE`, or keep the old
|
||||
behaviour with `rig config set terminal.inherit_tmux_environment true`. Running agents aren't affected.
|
||||
- **If you write queue rows to another machine by its self-id** (`member@rig@<self-id>`), run `rig host doctor <alias>`
|
||||
once per host: bindings learned before 0.7.0 have no address.
|
||||
|
||||
## New things you can do
|
||||
|
||||
- **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.
|
||||
- **Hold a person's routine asks while they're away:** `rig gateway human quiet set <id> --daily HH:MM-HH:MM` or
|
||||
`--until <time>` (`--tz`), and `end`, `clear` or `show`. A held ask posts once, without a mention, when quiet ends.
|
||||
Asks tagged `escalation` or `wake-escalation` are not held.
|
||||
- **Apply a person's mode** ("asleep", "out of office"): `rig gateway human mode save <id> <name>` (with
|
||||
`--availability`, `--expected-back`, `--decision-room "<their words>"`, `--exclude`, `--quiet`), then `apply <id>
|
||||
<name> [--until …]`, `current`, `end` and `account`. Never tell the person "back to normal" while the account shows an
|
||||
effect `kept`, `pending` or `failed`.
|
||||
- **Decide an ask inside a person's decision room:** check `rig gateway human mode room <id>`, then `rig gateway human
|
||||
mode decide <qitemId> --application <id> --reason "…" --reversible|--irreversible`. It's recorded as delegated, never
|
||||
as their answer, and only the agent that asked can decide. Permission prompts are refused (`permission-prompt`).
|
||||
- **Pass a rig's name instead of its id** to `archive`, `unarchive`, `release`, `shrink`, `expand`, `snapshot`,
|
||||
`export` and `restore --rig`. A name two rigs share is refused and the matching ids are named: with `--json`,
|
||||
`archive`, `unarchive`, `release`, `shrink`, `expand` and `restore status --rig` return `rig_ambiguous`.
|
||||
- **Stop a managed compaction's follow-up after `/compact` was sent:** `rig compact --cancel` now also disarms the
|
||||
restore and audit prompts that would follow. It sends no input and can't undo the `/compact`.
|
||||
- **See your work candidates:** `rig queue whoami` adds `currentWorkChoice` when current work isn't settled.
|
||||
- **See why a wake didn't land:** `rig queue show --json` adds `lastNudgeInputBox`.
|
||||
- **See each seat's configured effort:** the TUI agent table's `CFG EFF` column, and `effort` and `effortSource` in
|
||||
`rig ps --nodes --full --json`. It's the configured level, not what the model ran.
|
||||
|
||||
## Behaviour that changed
|
||||
|
||||
- **Old history-only events can be pruned, if you choose to.** `retention.events_days` (default `0`, which keeps every
|
||||
event) turns on a pass in the retention sweep (at daemon start and daily) that deletes `view.changed`,
|
||||
`snapshot.created`, `watchdog.evaluation_fired` and the `queue.created`, `queue.updated`, `queue.claimed`,
|
||||
`queue.unclaimed` and `queue.handed_off` events older than that many days; 14 is a reasonable value
|
||||
(`rig config set retention.events_days 14`). Every other type is kept at any age, because restore, startup proof and
|
||||
the seat readers fold from them, and the newest event is always kept. What you give up: pruned events no longer
|
||||
replay from `/api/events` (an older cursor or a large `?replay`), no longer appear in `rig telemetry events` (which
|
||||
reports the gap) or in older log and feed entries, and a later change of the setting can't bring them back. A new
|
||||
value is read at the next sweep: the daily one, or the next daemon start. The first sweep after turning it on works
|
||||
through the old history in short steps while the daemon keeps answering. `retention.enabled false` stops every
|
||||
retention pass from the next daemon start.
|
||||
- **The database file does not shrink by itself.** Pruned rows free pages that new events reuse, which slows the file's
|
||||
growth; other tables still grow. To give the space back to the disk: find the file with `rig config get db.path`, stop the daemon
|
||||
(`rig daemon stop`), copy the file aside, run `sqlite3 <that file> "VACUUM;"` (it needs free disk about the size of
|
||||
the file and can take a few minutes), then `rig daemon start`. Never vacuum the file of a running daemon.
|
||||
- **With pruning on, `rig telemetry events` reports the pruned ranges.** A page that spans pruned events inside the
|
||||
retained window has a `retained_sequence_gap` for each missing seq range and `coverage.status` `partial` (it used to be
|
||||
`retained_window`); events pruned from the start of the log show up as `before_retained_floor`.
|
||||
- **New agents start from a clean environment, not tmux's global one.** A new seat or handover successor gets:
|
||||
- the launch environment: `PATH`, `HOME`, `USER`, `LOGNAME`, `CODEX_HOME`, `OPENRIG_HOME`, `OPENRIG_URL`,
|
||||
`OPENRIG_PORT`, `OPENRIG_HOST`, the seat's own identity values (such as `OPENRIG_SESSION_NAME`) and the allowed
|
||||
provider sign-in variables. Not `OPENRIG_DB` or `TMUX_TMPDIR` (see Known gaps);
|
||||
- the daemon's `LANG`, `LC_ALL`, `LC_CTYPE`, `TZ`, `TMPDIR`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_CACHE_HOME` and
|
||||
`CLAUDE_CONFIG_DIR`;
|
||||
- the values named in tmux's `update-environment`;
|
||||
- its own `env`.
|
||||
|
||||
- **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.
|
||||
Shell startup still runs. `rig setup` warns when it sees a custom tmux configuration.
|
||||
- **After a reboot, the daemon restores a kernel whose seats are all detached** (and still bound to their sessions), in
|
||||
the background, as `rig up kernel --existing` would. `OPENRIG_NO_KERNEL=1` still skips it, and `rig start --json` reports `kernelRestore`.
|
||||
- **A seat missing a required startup file no longer blocks its rig's restore:** `rig up <rig> --existing` holds that
|
||||
seat and restores the others (`partially_restored`).
|
||||
- **Seats added to a running rig** with `rig expand`, `rig grow` or add-member get its culture file and rig-level
|
||||
startup, for rigs created on 0.7.0 or later. Older rigs get no backfill.
|
||||
- **A busy daemon gets the real request.** The CLI refuses only when the daemon is stopped or stale, the connection is
|
||||
refused or blocked, or the daemon answering belongs to another OpenRig home. A slow daemon is no longer read as down.
|
||||
- **A new seat's first startup message** says it's OpenRig's identity context, not a task. A seat whose startup sends
|
||||
nothing else is asked to run `rig whoami --json` and say it's ready for an assignment.
|
||||
- **A new Claude seat's first startup text** waits briefly (about five seconds) for Claude's input box before it's
|
||||
sent.
|
||||
- **Codex seats that select openrig-core get the plugin installed into Codex.** While Codex hooks are on
|
||||
(`runtime.codex.hooks_enabled`, default on), OpenRig installs it with Codex's own marketplace and plugin commands at
|
||||
daemon start and at a fresh launch, unless the person's Codex configuration disables it.
|
||||
- **A first install only** (no earlier install or plugin cache): OpenRig checks the installed hook files against its own
|
||||
copy, then trusts the plugin's six shipped hooks by their exact definitions, so the seat starts without Codex's hook
|
||||
review.
|
||||
The file check happens only when trust is granted: a later edit to the cached hook scripts keeps that trust, as with any hook trusted in Codex.
|
||||
- **An existing install keeps the person's choices.** A plugin or hook they disabled stays disabled. One they left
|
||||
untrusted stays under Codex's review: Codex records nothing for "continue without trusting", so OpenRig doesn't read a
|
||||
missing trust record as permission.
|
||||
- **What it adds:** the four managed inline hooks stay the only activity reporters; the plugin adds refocus and its
|
||||
skills.
|
||||
- **Watchdog delivery is recorded as it happened:** `rig watchdog status` outcomes are `sent`, `refused`, `failed`,
|
||||
`skipped` or `terminal` (0.6.14 had `sent`, `skipped` or `terminal`). Every reminder carries "Delivery may repeat after
|
||||
an unconfirmed attempt." under its header line, and two refusals in a row at one seat's prompt raise that seat's prompt
|
||||
alert. A park wake shows the continuation its park recorded (`Recorded continuation: …`), so record one when you park.
|
||||
- **Slack:** a thread reply answers a parked question only if it was written after the question posted, and a message
|
||||
Slack rejects as `invalid_blocks` isn't posted again.
|
||||
- **Claude Code team agents are asked for permission less often.** Housekeeping commands such as `archive`, `attach`,
|
||||
`bind`, `release`, `reconcile-session`, `config set` and `seat continue` no longer prompt. `seat restart` and `policy
|
||||
permissions apply` now do.
|
||||
- **`rig up --plan` waits as long as an apply** (120 seconds). An unanswered plan exits 1 and is safe to run again.
|
||||
- **`rig setup` keeps 10,000 rows of tmux history**, down from 50,000 (`terminal.history_limit`, 2000 to 1000000).
|
||||
|
||||
## Output that changed shape
|
||||
|
||||
- **`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.
|
||||
- **`rig gateway human list`** shows each person's availability (`available`, `focus`, `away` or `off`) where it showed
|
||||
`away` or `available`, plus a `quiet:` line; JSON adds `availability` and `quiet`.
|
||||
- **`rig attach`** prints `Sender:` (and `Tmux:` when the names differ), and `rig whoami` adds `identity.senderAddress`.
|
||||
A clashing member name is refused with `address_conflict`.
|
||||
- **Send results** carry `managedCompaction`, and the CLI prints a `Notice:` line.
|
||||
- **`rig ps --rig <unknown>`** exits 1, and `--host H --rig X` filters by rig.
|
||||
|
||||
## 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.
|
||||
- **Don't use `rig provider moves`:** it's gone with its setting. Report a subscription move as a queue item to whoever
|
||||
asked.
|
||||
- **Don't steer a new seat's account or keys through tmux globals.** Use the seat's `env` or `rig up --env`.
|
||||
- **Don't close a row `done` with `--closure-reason blocked_on`.** It's refused with `invalid_closure_reason`; use `rig
|
||||
queue block <id> --on <child>`.
|
||||
- **Don't treat "daemon did not respond" as down.** Check the effect before any retry or restart.
|
||||
- **Don't look up a rig id first** for the commands above; pass the name.
|
||||
- **Don't restart the daemon to stop a managed compaction's follow-up prompts.** Use `rig compact --cancel`.
|
||||
- **Don't run `rig up kernel --existing` by hand after a reboot** before checking `rig status`.
|
||||
- **Don't reuse a `deliveryId` to deduplicate a send,** and don't pass `--reason` or `--actor` to `rig slack verify`.
|
||||
|
||||
## Known gaps in 0.7.1
|
||||
## Known gaps in 0.7.0
|
||||
|
||||
- **A new seat doesn't get the daemon's `OPENRIG_DB` or `TMUX_TMPDIR`.** A tool in the seat that reads `OPENRIG_DB`
|
||||
directly can fall back to the default database. If you rely on either, pass it in the seat's `env` or with `rig up
|
||||
--env`.
|
||||
- **A launch that warns "Scoped tmux environment unavailable; retaining the previous environment"** didn't give that seat
|
||||
the clean environment, and the seat may lack OpenRig's variables. Check `env` in the seat before relying on them.
|
||||
- **A Slack channel-mapping change needs a daemon restart** (`rig daemon stop`, then `rig daemon start`). Don't use `rig
|
||||
slack disable` then `enable` for it: enabling marks any alerts waiting to post to Slack as already seen, so open
|
||||
exchanges miss them.
|
||||
|
||||
- **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.
|
||||
- **The TUI's terminal button refuses inside a Herdr pane reached over SSH.** Start the TUI with `env -u SSH_CONNECTION
|
||||
-u SSH_CLIENT -u SSH_TTY rig tui`, or run `rig terminal open '<view>' --provider herdr`.
|
||||
- **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`.
|
||||
- **Ubuntu 22.04's stock tmux 3.2a can crash its whole server** when OpenRig checks a pane that has just closed. Avoid
|
||||
tmux 3.2a for OpenRig (`tmux -V`).
|
||||
- **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`.
|
||||
+33
-8
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
Generated
+91
-324
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.0",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
@@ -473,13 +473,11 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -491,13 +489,11 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -509,13 +505,11 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -527,13 +521,11 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -545,13 +537,11 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -563,13 +553,11 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -581,13 +569,11 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -599,13 +585,11 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -617,13 +601,11 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -635,13 +617,11 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -653,13 +633,11 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -671,13 +649,11 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -689,13 +665,11 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -707,13 +681,11 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -725,13 +697,11 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -743,13 +713,11 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -761,13 +729,11 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -779,13 +745,11 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -797,13 +761,11 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -815,13 +777,11 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -833,13 +793,11 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -851,13 +809,11 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -869,13 +825,11 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -887,13 +841,11 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -905,13 +857,11 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -923,13 +873,11 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1085,60 +1033,13 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/client": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.4.0.tgz",
|
||||
"integrity": "sha512-LgrD2OphHuG12vfKFKz6SOnq7bKHQmhGJIAYdD+MZBn2npHEyry6uD8zvktodtl5CQM85MO0anUyD9JwmUmxww==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/core": "2.4.0",
|
||||
"cross-spawn": "^7.0.5",
|
||||
"eventsource": "^3.0.2",
|
||||
"eventsource-parser": "^3.0.8",
|
||||
"jose": "^6.1.3",
|
||||
"pkce-challenge": "^5.0.0",
|
||||
"zod": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/core": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.4.0.tgz",
|
||||
"integrity": "sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"zod": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/node": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/node/-/node-2.1.2.tgz",
|
||||
"integrity": "sha512-IIcIqBpr6NCQQs5DgJTgvbSOAw+3iaR/uquqg+e0nXCdudfjPg/80x0TseUnQx14AUAtE4fsJCEpJKPyb6WPVA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9 || ^2.1.3",
|
||||
"hono": "^4.11.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@modelcontextprotocol/server": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.32.2",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.2.tgz",
|
||||
"integrity": "sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw==",
|
||||
"version": "1.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz",
|
||||
"integrity": "sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9 || ^2.0.5",
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"content-type": "^1.0.5",
|
||||
@@ -1172,19 +1073,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/server": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.4.0.tgz",
|
||||
"integrity": "sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/core": "2.4.0",
|
||||
"zod": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -3120,20 +3008,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
|
||||
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^2.1.0",
|
||||
"content-type": "^1.0.5",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.1",
|
||||
"iconv-lite": "^0.7.3",
|
||||
"http-errors": "^2.0.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.16.1",
|
||||
"raw-body": "^3.0.2",
|
||||
"type-is": "^2.1.0"
|
||||
"qs": "^6.14.1",
|
||||
"raw-body": "^3.0.1",
|
||||
"type-is": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -3143,23 +3031,10 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/content-type": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/iconv-lite": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@@ -3394,9 +3269,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
|
||||
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -3799,9 +3674,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
@@ -3899,9 +3774,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource-parser": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
|
||||
"integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
|
||||
"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -3918,15 +3793,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz",
|
||||
"integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==",
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.3.0",
|
||||
"content-disposition": "^2.0.1",
|
||||
"content-type": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
"content-disposition": "^1.0.0",
|
||||
"content-type": "^1.0.5",
|
||||
"cookie": "^0.7.1",
|
||||
"cookie-signature": "^1.2.1",
|
||||
"debug": "^4.4.0",
|
||||
@@ -3942,14 +3817,14 @@
|
||||
"on-finished": "^2.4.1",
|
||||
"once": "^1.4.0",
|
||||
"parseurl": "^1.3.3",
|
||||
"proxy-addr": "^2.0.8",
|
||||
"qs": "^6.16.0",
|
||||
"proxy-addr": "^2.0.7",
|
||||
"qs": "^6.14.0",
|
||||
"range-parser": "^1.2.1",
|
||||
"router": "^2.2.0",
|
||||
"send": "^1.1.0",
|
||||
"serve-static": "^2.2.0",
|
||||
"statuses": "^2.0.1",
|
||||
"type-is": "^2.1.0",
|
||||
"type-is": "^2.0.1",
|
||||
"vary": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3961,13 +3836,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.7.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz",
|
||||
"integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==",
|
||||
"version": "8.3.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
|
||||
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"ip-address": "^10.2.0"
|
||||
"ip-address": "10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -3979,19 +3853,6 @@
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/content-type": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -4260,13 +4121,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/graphlib": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
|
||||
@@ -4400,9 +4254,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.7.5",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz",
|
||||
"integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==",
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -4692,16 +4546,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
|
||||
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
@@ -4842,32 +4692,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
|
||||
"integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"content-type": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator/node_modules/content-type": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
@@ -4932,19 +4762,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
|
||||
"integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
@@ -4994,9 +4820,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz",
|
||||
"integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -5237,22 +5063,10 @@
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proper-lockfile": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
||||
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"retry": "^0.12.0",
|
||||
"signal-exit": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
||||
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
@@ -5260,10 +5074,6 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
@@ -5277,13 +5087,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.16.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
|
||||
"integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==",
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
@@ -5313,16 +5122,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
||||
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
@@ -5341,9 +5146,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body/node_modules/iconv-lite": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@@ -5550,16 +5355,6 @@
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/retry": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
@@ -5790,14 +5585,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4",
|
||||
"side-channel-list": "^1.0.1",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
@@ -5809,13 +5604,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4"
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -5868,13 +5663,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/smol-toml": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz",
|
||||
@@ -6270,34 +6058,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"content-type": "^2.0.0",
|
||||
"content-type": "^1.0.5",
|
||||
"media-typer": "^1.1.0",
|
||||
"mime-types": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is/node_modules/content-type": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
@@ -7260,12 +7031,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zod-to-json-schema": {
|
||||
"version": "3.25.2",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
|
||||
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
|
||||
"version": "3.25.1",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
|
||||
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.28 || ^4"
|
||||
"zod": "^3.25 || ^4"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
@@ -7298,15 +7069,13 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hono/node-ws": "^1.3.1",
|
||||
"@modelcontextprotocol/node": "^2.1.2",
|
||||
"@modelcontextprotocol/sdk": "^1.32.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",
|
||||
@@ -7325,7 +7094,6 @@
|
||||
"rig": "dist/bin-wrapper.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modelcontextprotocol/client": "^2.4.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
@@ -7336,7 +7104,7 @@
|
||||
},
|
||||
"packages/daemon": {
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
@@ -7353,7 +7121,6 @@
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/tar": "^6.1.13",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
@@ -7379,7 +7146,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -19,7 +19,6 @@
|
||||
"typecheck:prep": "npm run build -w packages/daemon",
|
||||
"lint": "npm run typecheck:prep && tsc --noEmit --project packages/daemon/tsconfig.json && tsc --noEmit --project packages/ui/tsconfig.json && tsc --noEmit --project packages/cli/tsconfig.json && tsc --noEmit --project packages/tui/tsconfig.json",
|
||||
"build:package": "bash scripts/build-package.sh",
|
||||
"pack:release": "node scripts/pack-release.mjs",
|
||||
"test:tui-package": "node scripts/test-tui-package.mjs",
|
||||
"mirror-skills": "node scripts/mirror-skills.mjs",
|
||||
"mirror-skills:check": "node scripts/mirror-skills.mjs --check",
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2026 Mike Schwarz
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,36 +0,0 @@
|
||||
# @openrig/acp
|
||||
|
||||
OpenRig as an agent in apps that speak the [Agent Client Protocol](https://agentclientprotocol.com) (ACP), such as
|
||||
Zed. Your prompt goes to your OpenRig team as a queue row. The app shows who has the work
|
||||
and their notes as it moves, any question the team asks you as a choice, and the team's result as the reply.
|
||||
|
||||
This package has one command, `openrig-acp`. It starts `rig acp serve` from the `@openrig/cli` version it depends on,
|
||||
and warns on stderr if it finds another, so ACP apps and the ACP registry can launch it with `npx`.
|
||||
|
||||
## What you need
|
||||
|
||||
- OpenRig on this computer, with its daemon running (`rig daemon start`) and a team.
|
||||
- The seat that receives your team's new work, set once with `rig config set acp.to <member@rig>`, or passed as
|
||||
`--to <member@rig>`.
|
||||
- You, as a person OpenRig knows: register with
|
||||
`rig gateway human add <id> --display-name "<your name>" --delivery-class <A|B|C|D>` (the only registered person
|
||||
is the default; with several, choose one with `rig gateway default <id>`), or pass `--as <address>`. The team's
|
||||
questions reach an `<id>@external` address only once it's registered.
|
||||
|
||||
The app's sign-in step, "OpenRig on this machine", checks that the daemon answers and that it can tell who you are.
|
||||
This agent talks to the same OpenRig daemon as `rig` on this computer: the local one, unless `OPENRIG_URL` names another.
|
||||
|
||||
## In an app, without the registry
|
||||
|
||||
Add a custom agent whose command is `npx` with the arguments `@openrig/acp@<version>`, plus `--to <member@rig>` if
|
||||
you haven't set `acp.to`. In Zed's `settings.json`:
|
||||
|
||||
```json
|
||||
"agent_servers": {
|
||||
"OpenRig": { "type": "custom", "command": "npx", "args": ["@openrig/acp@<version>", "--to", "lead@my-team"] }
|
||||
}
|
||||
```
|
||||
|
||||
If `rig` is already installed, `rig acp serve` is the same agent without this package.
|
||||
|
||||
Guide: `rig context get reference/acp-agents.md#openrig-as-an-agent-in-acp-apps`.
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// OpenRig as an agent in ACP apps. The ACP registry starts an agent with `npx <package>@<version>`, and npx needs a
|
||||
// package with one executable: @openrig/cli has two. This one starts `rig acp serve` from the @openrig/cli this package
|
||||
// depends on, as Node resolves it (never a `rig` on the PATH), with the app's stdin and stdout, arguments, environment
|
||||
// and working directory, and exits as it exits. Only ACP messages may reach stdout, so anything this file says goes to
|
||||
// stderr.
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
let rig;
|
||||
try {
|
||||
const manifestPath = createRequire(import.meta.url).resolve("@openrig/cli/package.json");
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||||
// Module resolution can reach a different @openrig/cli further up when this package's own copy is missing. That copy
|
||||
// most likely still works, and one that doesn't fails visibly, so say which version runs and run it.
|
||||
const wanted = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).dependencies["@openrig/cli"];
|
||||
if (manifest.version !== wanted) {
|
||||
console.error(`openrig-acp: found @openrig/cli ${manifest.version} at ${dirname(manifestPath)}, not the ${wanted} this package pins; running it anyway. Reinstall @openrig/acp to get ${wanted}.`);
|
||||
}
|
||||
rig = join(dirname(manifestPath), manifest.bin.rig);
|
||||
} catch (error) {
|
||||
console.error(`openrig-acp: @openrig/cli isn't installed beside this package (${error.message}). Reinstall @openrig/acp.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const child = spawn(process.execPath, [rig, "acp", "serve", ...process.argv.slice(2)], { stdio: "inherit" });
|
||||
const forward = (signal) => child.kill(signal);
|
||||
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, forward);
|
||||
child.on("error", (error) => {
|
||||
console.error(`openrig-acp: couldn't start rig acp serve: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
// End the same way the server did, so the app sees the signal rather than an exit code.
|
||||
process.removeListener(signal, forward);
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"name": "@openrig/acp",
|
||||
"version": "0.7.1",
|
||||
"description": "OpenRig as an agent in ACP apps: starts rig acp serve from the @openrig/cli it pins.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"openrig-acp": "bin/openrig-acp.js"
|
||||
},
|
||||
"files": [
|
||||
"bin",
|
||||
"LICENSE",
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openrig/cli": "0.7.1"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"author": {
|
||||
"name": "mvschwarz",
|
||||
"url": "https://esoteric.run"
|
||||
},
|
||||
"homepage": "https://openrig.dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/mvschwarz/openrig",
|
||||
"directory": "packages/acp"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
"agents",
|
||||
"acp",
|
||||
"agent-client-protocol",
|
||||
"openrig"
|
||||
]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"id": "openrig",
|
||||
"name": "OpenRig",
|
||||
"version": "0.7.1",
|
||||
"description": "Send work to your OpenRig team of agents and follow it through to the result",
|
||||
"repository": "https://github.com/mvschwarz/openrig",
|
||||
"website": "https://openrig.dev",
|
||||
"authors": [
|
||||
"mvschwarz"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"license_url": "https://github.com/mvschwarz/openrig/blob/main/LICENSE",
|
||||
"distribution": {
|
||||
"npx": {
|
||||
"package": "@openrig/[email protected]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="8 -754 596 808" fill="currentColor"><g transform="scale(1,-1)"><path d="M306.0 -14.0Q426.0 -14.0 495.0 59.5Q564.0 133.0 564.0 272.0V428.0Q564.0 563.0 495.0 638.5Q426.0 714.0 306.0 714.0Q186.0 714.0 117.0 638.5Q83.32444763183594 601.652099609375 66.08428192138672 550.631591796875L195.21009826660156 523.18505859375Q207.8548583984375 554.14111328125 233.0 573.0Q261.0 594.0 306.0 594.0Q351.0 594.0 378.5 573.0Q406.0 552.0 419.0 516.0Q432.0 480.0 432.0 436.0V264.0Q432.0 194.0 405.0 150.0Q378.0 106.0 306.0 106.0Q234.0 106.0 207.0 150.0Q180.0 194.0 180.0 264.0V436.0Q180.0 461.6716613769531 184.25511169433594 484.6199951171875L55.7750358581543 511.9292907714844Q48.0 473.3169250488281 48.0 428.0V272.0Q48.0 133.0 117.0 59.5Q186.0 -14.0 306.0 -14.0ZM138.0 176.0C138.0 162.7451629638672 127.25483703613281 152.0 114.0 152.0C100.74516296386719 152.0 90.0 162.7451629638672 90.0 176.0C90.0 189.2548370361328 100.74516296386719 200.0 114.0 200.0C127.25483703613281 200.0 138.0 189.2548370361328 138.0 176.0Z"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.0",
|
||||
"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": [
|
||||
@@ -47,9 +47,7 @@
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hono/node-ws": "^1.3.1",
|
||||
"@modelcontextprotocol/node": "^2.1.2",
|
||||
"@modelcontextprotocol/sdk": "^1.32.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",
|
||||
@@ -64,7 +62,6 @@
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modelcontextprotocol/client": "^2.4.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
|
||||
@@ -1,540 +0,0 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { JsonRpcPeer } from "./json-rpc-stdio.js";
|
||||
|
||||
// OpenRig as an ACP agent (rig acp serve): an ACP app's prompt becomes a queue row to the team's receiver, the row's
|
||||
// transitions and handoffs come back as progress, and its closing resolution is the reply. A question the team asks the
|
||||
// caller while it works comes to the app as a permission request, and the choice goes back through the person's answer
|
||||
// path. The queue is the only work store: a session is the rows tagged with its id, so it can be listed and reopened.
|
||||
// A turn's end_turn says only that the turn ended: the reply says whether the team closed the item, and a cancel stops
|
||||
// this process waiting, never the team.
|
||||
|
||||
export const ACP_PROTOCOL_VERSION = 1;
|
||||
export const AUTH_METHOD_ID = "openrig-local";
|
||||
export const SESSION_TAG = "acp-session:";
|
||||
export const CWD_TAG = "acp-cwd:";
|
||||
/** Where the caller's prompt ends in a row's body; the rest is the note rig acp serve adds for the team. */
|
||||
const FOOTER = "\n\n---\nSent by ";
|
||||
|
||||
/** One queue row as this adapter reads it. */
|
||||
export interface TeamRow {
|
||||
qitemId: string;
|
||||
state: string;
|
||||
destinationSession: string;
|
||||
handedOffTo: string | null;
|
||||
closureReason: string | null;
|
||||
closureTarget: string | null;
|
||||
resolution: string | null;
|
||||
/** Who closed the row, from its closing transition (a note written after the close doesn't change it); null while open. */
|
||||
closer: string | null;
|
||||
}
|
||||
|
||||
export interface TeamTransition {
|
||||
transitionId: number;
|
||||
ts: string;
|
||||
state: string;
|
||||
transitionNote: string | null;
|
||||
actorSession: string;
|
||||
}
|
||||
|
||||
/** A row to the caller that asks for a decision, with the choices it offers (none: words only). */
|
||||
export interface TeamAsk {
|
||||
qitemId: string;
|
||||
sourceSession: string;
|
||||
state: string;
|
||||
tsCreated: string;
|
||||
summary: string | null;
|
||||
body: string;
|
||||
tags: string[];
|
||||
questions: Array<{ id: string; question: string; options: Array<{ id: string; label: string; recommended?: boolean }> }>;
|
||||
/** What's recorded so far, by question id: the chosen option's id, or the words of a typed reply. */
|
||||
answers: Record<string, string>;
|
||||
}
|
||||
|
||||
/** A row the caller sent through rig acp serve. */
|
||||
export interface SentRow {
|
||||
qitemId: string;
|
||||
tsCreated: string;
|
||||
tsUpdated: string;
|
||||
summary: string | null;
|
||||
body: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/** The daemon answered a create and refused it, so nothing was written. Any other failure of a create leaves it unknown. */
|
||||
export class TeamRefused extends Error {}
|
||||
|
||||
/** The queue verbs the adapter uses, acting as the caller. */
|
||||
export interface TeamQueue {
|
||||
/** Create the row under the id the adapter chose, so a create whose answer is lost can be read back by that id. Throws
|
||||
* TeamRefused only for a refusal the daemon answered with. */
|
||||
create(row: { qitemId: string; destination: string; body: string; summary: string; tags: string[] }): Promise<void>;
|
||||
show(qitemId: string): Promise<TeamRow>;
|
||||
/** The row, or null when this machine has no such row; throws when it can't tell. */
|
||||
find(qitemId: string): Promise<TeamRow | null>;
|
||||
transitions(qitemId: string): Promise<TeamTransition[]>;
|
||||
/** The row a handoff of `qitemId` created on this machine, if any. */
|
||||
successor(qitemId: string): Promise<string | undefined>;
|
||||
/** Open asks addressed to `person`. */
|
||||
openAsks(person: string): Promise<TeamAsk[]>;
|
||||
/** Every ask to `person` tagged for one chat, open or settled, to replay with it. */
|
||||
sessionAsks(person: string, sessionId: string): Promise<TeamAsk[]>;
|
||||
/** Record the caller's choice on an ask, through the person's answer path; a refusal comes back with its reason. */
|
||||
answer(qitemId: string, questionId: string, optionId: string): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
/** Rows the caller sent through rig acp serve. */
|
||||
sent(caller: string): Promise<SentRow[]>;
|
||||
}
|
||||
|
||||
/** What a session works through: the queue as the caller, and the seat that receives the caller's tasks (when set). */
|
||||
export interface TeamConnection {
|
||||
queue: TeamQueue;
|
||||
caller: string;
|
||||
receiver: string | undefined;
|
||||
/** Something about this connection the person should know, said once in each session before its first prompt's work. */
|
||||
notice?: string;
|
||||
}
|
||||
|
||||
export interface AcpAgentOptions {
|
||||
/** Reaches the daemon (`rig`'s: the local one, or OPENRIG_URL's) and resolves the caller; returns guidance when it can't.
|
||||
* Called lazily, never at initialize. */
|
||||
connect: () => Promise<TeamConnection | { error: string }>;
|
||||
version: string;
|
||||
pollMs: number;
|
||||
log?: (line: string) => void;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
/** Settles when the app's connection ends: open turns stop following their work (nothing could carry a reply). */
|
||||
closed?: Promise<void>;
|
||||
}
|
||||
|
||||
const ACTIVE_STATES = new Set(["pending", "in-progress", "blocked"]);
|
||||
/** How long a cancel waits for the app to settle questions it was showing (ACP has the client answer them cancelled). */
|
||||
const QUESTION_SETTLE_MS = 2000;
|
||||
const AUTH_REQUIRED = -32000;
|
||||
const INVALID_PARAMS = -32602;
|
||||
|
||||
class AcpError extends Error {
|
||||
constructor(readonly code: number, message: string, readonly data?: unknown) { super(message); }
|
||||
}
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
cwd: string;
|
||||
/** The first row each prompt created, oldest first, with the last row its work reached. */
|
||||
sent: Array<{ root: string; tail: string }>;
|
||||
turn?: { cancel: () => void };
|
||||
noticed?: boolean;
|
||||
}
|
||||
|
||||
type ContentBlock = { type: string; text?: string; uri?: string; name?: string; resource?: { uri?: string; text?: string } };
|
||||
|
||||
/** The questions one turn has put to the app. Once the turn has ended, nothing from them reaches the app or the queue. */
|
||||
interface TurnQuestions {
|
||||
finished: boolean;
|
||||
/** The person cancelled the turn: no further question opens, though one the app is showing still settles. */
|
||||
cancelled: boolean;
|
||||
/** Questions the app is still showing, by tool call id, each with how the chat names it. */
|
||||
outstanding: Map<string, string>;
|
||||
tasks: Set<Promise<void>>;
|
||||
}
|
||||
type Update = Record<string, unknown>;
|
||||
|
||||
/** The prompt as plain text for the row's body: text as written, links and embedded text resources named. */
|
||||
export function promptText(prompt: ContentBlock[]): string {
|
||||
const parts: string[] = [];
|
||||
for (const block of prompt) {
|
||||
if (block.type === "text" && block.text) parts.push(block.text);
|
||||
else if (block.type === "resource_link" && block.uri) parts.push(`[${block.name ?? "link"}](${block.uri})`);
|
||||
else if (block.type === "resource" && block.resource?.uri) {
|
||||
parts.push(block.resource.text !== undefined ? `${block.resource.uri}:\n\`\`\`\n${block.resource.text}\n\`\`\`` : block.resource.uri);
|
||||
}
|
||||
}
|
||||
return parts.join("\n\n").trim();
|
||||
}
|
||||
|
||||
/** A queue row id in the form the CLI mints (`qitem-<UTC yyyymmddhhmmss>-<16 hex>`). */
|
||||
export const newQitemId = () => `qitem-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(8).toString("hex")}`;
|
||||
|
||||
function oneLine(text: string, max: number): string {
|
||||
const line = text.split("\n").find((l) => l.trim())?.trim() ?? "";
|
||||
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
|
||||
}
|
||||
|
||||
/** The person's own words in a prompt, for the row's summary and the session's title. An app that wraps them in its
|
||||
* own instructions marks them (T3 Code sends several blocks of harness text, then `<user_request>…</user_request>`);
|
||||
* the row's body keeps everything the app sent. Only a prompt with one opening and one closing mark is read this
|
||||
* way: with more, an example could be taken for the request, so the prompt stands as sent. */
|
||||
function personsWords(text: string): string {
|
||||
if (text.split("<user_request>").length !== 2 || text.split("</user_request>").length !== 2) return text;
|
||||
const marked = /<user_request>([\s\S]*?)<\/user_request>/.exec(text)?.[1];
|
||||
return marked?.trim() ? marked : text;
|
||||
}
|
||||
|
||||
const toolStatus = (state: string) =>
|
||||
state === "pending" ? "pending" : ACTIVE_STATES.has(state) ? "in_progress" : state === "done" || state === "handed-off" ? "completed" : "failed";
|
||||
|
||||
const transitionLine = (t: TeamTransition) =>
|
||||
`${t.ts.slice(11, 19)}Z ${t.actorSession} · ${t.state}${t.transitionNote ? `: ${t.transitionNote}` : ""}`;
|
||||
|
||||
const textContent = (text: string) => [{ type: "content", content: { type: "text", text } }];
|
||||
|
||||
/** Where one prompt's work stands after a read of its rows. */
|
||||
type ChainState = { kind: "open"; tail: TeamRow } | { kind: "ended"; tail: TeamRow; reply: string };
|
||||
|
||||
/** Follows one prompt's work through its rows: each row is a step, and its new notes are emitted as they appear. */
|
||||
class ChainFollower {
|
||||
private readonly chain: string[];
|
||||
private readonly seen = new Map<string, { lines: string[]; lastId: number; status: string; title: string }>();
|
||||
/** The seats the work has reached; a question from one of them during the turn belongs to it. */
|
||||
readonly seats = new Set<string>();
|
||||
|
||||
constructor(private readonly queue: TeamQueue, private readonly emit: (update: Update) => void, readonly root: string) {
|
||||
this.chain = [root];
|
||||
}
|
||||
|
||||
async read(): Promise<ChainState> {
|
||||
for (;;) {
|
||||
let tail!: TeamRow;
|
||||
for (const qitemId of this.chain) {
|
||||
const row = await this.queue.show(qitemId);
|
||||
const transitions = await this.queue.transitions(qitemId);
|
||||
tail = row;
|
||||
this.seats.add(row.destinationSession);
|
||||
const status = toolStatus(row.state);
|
||||
let entry = this.seen.get(qitemId);
|
||||
if (!entry) {
|
||||
const title = qitemId === this.root ? `Sent to ${row.destinationSession} (${qitemId})` : `Handed to ${row.destinationSession} (${qitemId})`;
|
||||
entry = { lines: [], lastId: 0, status, title };
|
||||
this.seen.set(qitemId, entry);
|
||||
this.emit({ sessionUpdate: "tool_call", toolCallId: qitemId, kind: "other", status, title, rawInput: { qitemId, destination: row.destinationSession } });
|
||||
}
|
||||
const fresh = transitions.filter((t) => t.transitionId > entry!.lastId);
|
||||
if (fresh.length > 0 || status !== entry.status) {
|
||||
for (const t of fresh) { entry.lines.push(transitionLine(t)); entry.lastId = Math.max(entry.lastId, t.transitionId); }
|
||||
entry.status = status;
|
||||
// An app that shows a step collapsed still shows its title, so the latest note leads it: a narrow panel cuts the end.
|
||||
const noted = fresh.filter((t) => t.transitionNote).at(-1);
|
||||
this.emit({
|
||||
sessionUpdate: "tool_call_update", toolCallId: qitemId, status,
|
||||
...(noted ? { title: `${noted.actorSession}: ${oneLine(noted.transitionNote!, 100)} · ${entry.title}` } : {}),
|
||||
...(entry.lines.length > 0 ? { content: textContent(entry.lines.join("\n")) } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Both handoff forms pass the work on: `handoff` leaves the row handed-off, `handoff-and-complete` closes it done
|
||||
// with closure reason handed_off_to. Either way the answer is the successor's, not this row's.
|
||||
if (tail.state === "handed-off" || (tail.state === "done" && tail.closureReason === "handed_off_to")) {
|
||||
const next = await this.queue.successor(tail.qitemId);
|
||||
if (next) { this.chain.push(next); continue; }
|
||||
return { kind: "ended", tail, reply: `${tail.qitemId} was handed to ${tail.handedOffTo ?? tail.closureTarget ?? "another seat"}, whose row isn't on this machine, so this app can't follow it further. Ask your team for its result.` };
|
||||
}
|
||||
if (ACTIVE_STATES.has(tail.state)) return { kind: "open", tail };
|
||||
// A done row's closure reason says how it ended (canceled is a done close too); the ordinary one goes unsaid.
|
||||
const reason = tail.closureReason && tail.closureReason !== "no-follow-on" ? ` (${tail.closureReason})` : "";
|
||||
const how = tail.state === "done" ? `done${reason}` : `as ${tail.state}${reason}`;
|
||||
return { kind: "ended", tail, reply: `${tail.closer ?? tail.destinationSession} closed ${tail.qitemId} ${how}.\n\n${tail.resolution?.trim() || "(No resolution was written.)"}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function serveAcpAgent(peer: JsonRpcPeer, options: AcpAgentOptions): void {
|
||||
const sessions = new Map<string, Session>();
|
||||
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
||||
const log = options.log ?? (() => {});
|
||||
let clientName = "an ACP app";
|
||||
/** The app asks forms (ACP `elicitation/create`, mode form), so a question's choices go to it as a form, each by its
|
||||
* label. A permission request shows them only in apps that list each option: T3 Code shows a fixed Approve, which
|
||||
* sends the first. */
|
||||
let asksForms = false;
|
||||
let connection: TeamConnection | undefined;
|
||||
void options.closed?.then(() => { for (const session of sessions.values()) session.turn?.cancel(); });
|
||||
|
||||
const connected = async (): Promise<TeamConnection> => {
|
||||
if (connection) return connection;
|
||||
const result = await options.connect();
|
||||
if ("error" in result) throw new AcpError(AUTH_REQUIRED, result.error);
|
||||
connection = result;
|
||||
return result;
|
||||
};
|
||||
|
||||
const update = (sessionId: string, body: Update) => peer.notify("session/update", { sessionId, update: body });
|
||||
const say = (sessionId: string, text: string) => update(sessionId, { sessionUpdate: "agent_message_chunk", content: { type: "text", text } });
|
||||
/** A question the app showed ends unanswered: its card says so, and so does the chat, which every app shows, since an
|
||||
* app need not show a card's later text. */
|
||||
const unanswered = (sessionId: string, toolCallId: string, named: string, why: string) => {
|
||||
update(sessionId, { sessionUpdate: "tool_call_update", toolCallId, content: textContent(`${why} The question is still open in the OpenRig TUI and Slack.`) });
|
||||
say(sessionId, `\n\n${why} ${named} is still open in the OpenRig TUI and Slack.\n\n`);
|
||||
};
|
||||
const newSessionId = () => `acps-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(4).toString("hex")}`;
|
||||
|
||||
/** Each of the caller's prompts in a session, oldest first, by the rows tagged with its id. */
|
||||
const sessionRoots = async (team: TeamConnection, sessionId: string) =>
|
||||
(await team.queue.sent(team.caller)).filter((row) => row.tags.includes(`${SESSION_TAG}${sessionId}`))
|
||||
.sort((a, b) => a.tsCreated.localeCompare(b.tsCreated));
|
||||
|
||||
/** Put one ask's open questions to the person, as a form where the app asks forms and otherwise as permission
|
||||
* requests, one at a time, and record each choice. A reply that arrives after the turn ended answers nothing here:
|
||||
* the question stays open in the TUI and Slack. */
|
||||
const askPerson = async (team: TeamConnection, sessionId: string, ask: TeamAsk, turn: TurnQuestions) => {
|
||||
const emit = (body: Update) => { if (!turn.finished) update(sessionId, body); };
|
||||
if (ask.questions.length === 0) {
|
||||
emit({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: `\n\n${ask.sourceSession} asks you: ${ask.summary ?? oneLine(ask.body, 200)} (${ask.qitemId}). It wants an answer in words, which this app can't send: answer it in the OpenRig TUI or Slack.\n\n` } });
|
||||
return;
|
||||
}
|
||||
for (const q of ask.questions.filter((question) => !(question.id in ask.answers))) {
|
||||
if (turn.finished || turn.cancelled) return;
|
||||
const toolCallId = `${ask.qitemId}#${q.id}`;
|
||||
emit({ sessionUpdate: "tool_call", toolCallId, kind: "other", status: "pending", title: `${ask.sourceSession} asks: ${q.question}`, content: textContent(ask.body) });
|
||||
const label = (o: (typeof q.options)[number]) => (o.recommended ? `${o.label} (recommended)` : o.label);
|
||||
const named = `${ask.sourceSession}'s question "${q.question}" (${ask.qitemId})`;
|
||||
let chosenId: string | undefined;
|
||||
turn.outstanding.set(toolCallId, named);
|
||||
try {
|
||||
if (asksForms) {
|
||||
// Each choice is offered as its label, the one form every app shows as choices: T3 Code 0.1.7 builds them only
|
||||
// from `enum`, a value serving as both label and answer, and reads `oneOf` as free text. Labels that repeat
|
||||
// carry their option's id, so a value names one choice; the answer is mapped back by that exact value.
|
||||
const shown = q.options.map(label);
|
||||
const values = new Set(shown).size === shown.length ? shown : q.options.map((o, i) => `${shown[i]} (${o.id})`);
|
||||
const result = (await peer.request("elicitation/create", {
|
||||
sessionId, toolCallId, mode: "form", message: `${ask.sourceSession} asks: ${q.question}`,
|
||||
requestedSchema: { type: "object", required: [q.id], properties: { [q.id]: { type: "string", title: q.question, enum: values } } },
|
||||
})) as { action?: string; content?: Record<string, unknown> | null } | undefined;
|
||||
const value = result?.action === "accept" ? result.content?.[q.id] : undefined;
|
||||
chosenId = typeof value === "string" ? q.options[values.indexOf(value)]?.id : undefined;
|
||||
} else {
|
||||
const result = (await peer.request("session/request_permission", {
|
||||
sessionId,
|
||||
toolCall: { toolCallId, title: `${ask.sourceSession} asks: ${q.question}`, kind: "other", status: "pending" },
|
||||
options: q.options.map((o) => ({ optionId: o.id, name: label(o), kind: "allow_once" })),
|
||||
})) as { outcome?: { outcome?: string; optionId?: string } } | undefined;
|
||||
chosenId = result?.outcome?.outcome === "selected" ? result.outcome.optionId : undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!turn.finished) unanswered(sessionId, toolCallId, named, `This app couldn't show the question (${(error as Error).message}).`);
|
||||
return;
|
||||
} finally {
|
||||
turn.outstanding.delete(toolCallId);
|
||||
}
|
||||
if (turn.finished) return;
|
||||
// Only one of the question's own options is recorded: the app's reply is checked again here.
|
||||
const chosen = q.options.find((o) => o.id === chosenId);
|
||||
if (!chosen) {
|
||||
unanswered(sessionId, toolCallId, named, "Not answered here.");
|
||||
return;
|
||||
}
|
||||
const recorded = await team.queue.answer(ask.qitemId, q.id, chosen.id).catch((error: Error) => ({ ok: false as const, reason: error.message }));
|
||||
emit(recorded.ok
|
||||
? { sessionUpdate: "tool_call_update", toolCallId, status: "completed", content: textContent(`You answered: ${chosen.label}. ${ask.sourceSession} gets it as your answer.`) }
|
||||
: { sessionUpdate: "tool_call_update", toolCallId, status: "failed", content: textContent(`Your choice (${chosen.label}) wasn't recorded: ${recorded.reason}`) });
|
||||
log(`session ${sessionId}: ${recorded.ok ? "answered" : "couldn't answer"} ${ask.qitemId} ${q.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
peer.onRequest("initialize", async (params) => {
|
||||
const p = (params ?? {}) as { clientInfo?: { name?: string; title?: string }; clientCapabilities?: { elicitation?: { form?: unknown } | null } };
|
||||
clientName = p.clientInfo?.title ?? p.clientInfo?.name ?? clientName;
|
||||
// ACP counts a mode as supported only when its field is present and not null.
|
||||
asksForms = p.clientCapabilities?.elicitation?.form != null;
|
||||
return {
|
||||
protocolVersion: ACP_PROTOCOL_VERSION,
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
promptCapabilities: { image: false, audio: false, embeddedContext: true },
|
||||
mcpCapabilities: { http: false, sse: false },
|
||||
sessionCapabilities: { list: {} },
|
||||
},
|
||||
authMethods: [{
|
||||
id: AUTH_METHOD_ID,
|
||||
name: "OpenRig on this machine",
|
||||
description: "Sends your work to your OpenRig team, as you, through the same OpenRig daemon as `rig` on this computer: the local one (start it with `rig daemon start`), unless OPENRIG_URL names another.",
|
||||
}],
|
||||
agentInfo: { name: "openrig", title: "OpenRig", version: options.version },
|
||||
};
|
||||
});
|
||||
|
||||
peer.onRequest("authenticate", async (params) => {
|
||||
const methodId = (params as { methodId?: unknown } | undefined)?.methodId;
|
||||
if (methodId !== AUTH_METHOD_ID) throw new AcpError(INVALID_PARAMS, `Unknown auth method ${String(methodId)}; this agent offers ${AUTH_METHOD_ID}`);
|
||||
const team = await connected();
|
||||
log(`authenticated as ${team.caller}${team.receiver ? `, sending to ${team.receiver}` : ", with no receiver set"}`);
|
||||
return {};
|
||||
});
|
||||
|
||||
peer.onRequest("session/new", async (params) => {
|
||||
await connected();
|
||||
const id = newSessionId();
|
||||
sessions.set(id, { id, cwd: String((params as { cwd?: unknown } | undefined)?.cwd ?? ""), sent: [] });
|
||||
return { sessionId: id };
|
||||
});
|
||||
|
||||
peer.onRequest("session/list", async (params) => {
|
||||
const team = await connected();
|
||||
const cwd = (params as { cwd?: unknown } | undefined)?.cwd;
|
||||
const byId = new Map<string, { sessionId: string; cwd: string; title: string; updatedAt: string }>();
|
||||
for (const row of (await team.queue.sent(team.caller)).sort((a, b) => a.tsCreated.localeCompare(b.tsCreated))) {
|
||||
const sessionId = row.tags.find((t) => t.startsWith(SESSION_TAG))?.slice(SESSION_TAG.length);
|
||||
if (!sessionId) continue;
|
||||
const known = byId.get(sessionId);
|
||||
if (known) { if (row.tsUpdated > known.updatedAt) known.updatedAt = row.tsUpdated; continue; }
|
||||
byId.set(sessionId, {
|
||||
sessionId, cwd: row.tags.find((t) => t.startsWith(CWD_TAG))?.slice(CWD_TAG.length) ?? "",
|
||||
title: oneLine(personsWords(row.body.split(FOOTER)[0] ?? ""), 100), updatedAt: row.tsUpdated,
|
||||
});
|
||||
}
|
||||
const listed = [...byId.values()].filter((s) => typeof cwd !== "string" || s.cwd === cwd).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
return { sessions: listed };
|
||||
});
|
||||
|
||||
peer.onRequest("session/load", async (params) => {
|
||||
const p = (params ?? {}) as { sessionId?: string; cwd?: string };
|
||||
const team = await connected();
|
||||
const sessionId = String(p.sessionId ?? "");
|
||||
const roots = await sessionRoots(team, sessionId);
|
||||
if (roots.length === 0) throw new AcpError(INVALID_PARAMS, `No session ${sessionId} from ${team.caller} on this machine`);
|
||||
const session: Session = { id: sessionId, cwd: String(p.cwd ?? ""), sent: [] };
|
||||
const asks = (await team.queue.sessionAsks(team.caller, sessionId)).sort((a, b) => a.tsCreated.localeCompare(b.tsCreated));
|
||||
// Replay: each prompt as the caller wrote it, then its work as it stands now, the questions the team asked during
|
||||
// it with what's recorded, and the reply. Nothing is asked again here; an open question comes up in the next turn.
|
||||
for (const [i, row] of roots.entries()) {
|
||||
update(sessionId, { sessionUpdate: "user_message_chunk", content: { type: "text", text: row.body.split(FOOTER)[0] ?? "" } });
|
||||
const follower = new ChainFollower(team.queue, (u) => update(sessionId, u), row.qitemId);
|
||||
const state = await follower.read();
|
||||
session.sent.push({ root: row.qitemId, tail: state.tail.qitemId });
|
||||
const next = roots[i + 1]?.tsCreated;
|
||||
for (const ask of asks.filter((a) => (i === 0 || a.tsCreated >= row.tsCreated) && (next === undefined || a.tsCreated < next))) {
|
||||
for (const q of ask.questions) {
|
||||
const answer = ask.answers[q.id];
|
||||
const outcome = answer !== undefined ? `You answered: ${q.options.find((o) => o.id === answer)?.label ?? answer}.`
|
||||
: ACTIVE_STATES.has(ask.state) ? "Not answered yet. It's still open in the OpenRig TUI and Slack." : `Not answered: ${ask.qitemId} was closed (${ask.state}).`;
|
||||
update(sessionId, {
|
||||
sessionUpdate: "tool_call", toolCallId: `${ask.qitemId}#${q.id}`, kind: "other", status: answer === undefined && ACTIVE_STATES.has(ask.state) ? "pending" : "completed",
|
||||
title: `${ask.sourceSession} asks: ${q.question}`, content: [...textContent(ask.body), ...textContent(outcome)],
|
||||
});
|
||||
}
|
||||
}
|
||||
say(sessionId, state.kind === "ended" ? state.reply : `${state.tail.qitemId} is still ${state.tail.state} with ${state.tail.destinationSession}.`);
|
||||
}
|
||||
sessions.set(sessionId, session);
|
||||
return {};
|
||||
});
|
||||
|
||||
peer.onNotification("session/cancel", (params) => {
|
||||
const sessionId = (params as { sessionId?: string } | undefined)?.sessionId;
|
||||
if (sessionId) sessions.get(sessionId)?.turn?.cancel();
|
||||
});
|
||||
|
||||
peer.onRequest("session/prompt", async (params) => {
|
||||
const p = (params ?? {}) as { sessionId?: string; prompt?: ContentBlock[] };
|
||||
const session = p.sessionId ? sessions.get(p.sessionId) : undefined;
|
||||
if (!session) throw new AcpError(INVALID_PARAMS, `No session ${String(p.sessionId)} in this agent; start one with session/new or session/load`);
|
||||
if (session.turn) throw new AcpError(INVALID_PARAMS, "This session already has a turn running; wait for it or cancel it first");
|
||||
const team = await connected();
|
||||
const text = promptText(Array.isArray(p.prompt) ? p.prompt : []);
|
||||
if (!text) {
|
||||
say(session.id, "Nothing to send: the prompt had no text.");
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
if (!team.receiver) {
|
||||
say(session.id, "This OpenRig agent has no team receiver, so nothing was sent. Choose the seat that takes your team's new work with `rig config set acp.to <member@rig>`, or add `--to <member@rig>` to the agent's command in your app's settings.");
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
if (team.notice && !session.noticed) {
|
||||
session.noticed = true;
|
||||
say(session.id, `${team.notice}\n\n`);
|
||||
}
|
||||
|
||||
let wake: () => void = () => {};
|
||||
const questions: TurnQuestions = { finished: false, cancelled: false, outstanding: new Map(), tasks: new Set() };
|
||||
session.turn = { cancel: () => { questions.cancelled = true; wake(); } };
|
||||
let root: string | undefined;
|
||||
try {
|
||||
// Each earlier item as it stands; one whose create was never confirmed may not exist, or can't be read: say which.
|
||||
const earlier = await Promise.all(session.sent.map(async (s) => {
|
||||
const row = await team.queue.find(s.tail).catch(() => undefined);
|
||||
return { ...s, row, state: row === undefined ? "state unknown" : row === null ? "not found here" : row.state };
|
||||
}));
|
||||
const open = earlier.filter((s) => s.row && ACTIVE_STATES.has(s.row.state));
|
||||
if (open.length > 0) say(session.id, `${open.map((s) => `${s.row!.qitemId} is still ${s.row!.state} with ${s.row!.destinationSession}`).join("; ")}. Sending this as a follow-up.\n\n`);
|
||||
const lineage = earlier.length === 0 ? "" : `\nFollows up on ${earlier.map((s) => `${s.root} (${s.tail === s.root ? "" : `reached ${s.tail}, `}${s.state})`).join(", ")}.`;
|
||||
const body = `${text}${FOOTER}${team.caller} from ${clientName} through rig acp serve (session ${session.id}).${lineage}\n` +
|
||||
`The app shows this item's notes and handoffs as progress, and its closing resolution as the answer: close it with a resolution written for ${team.caller}. ` +
|
||||
`A question with choices you ask ${team.caller} while this is open, tagged for this chat, appears in the app as a choice: ` +
|
||||
`rig queue create --destination ${team.caller} --human-questions-file … --tags ${SESSION_TAG}${session.id}.`;
|
||||
const turnStarted = new Date().toISOString();
|
||||
const qitemId = newQitemId();
|
||||
try {
|
||||
await team.queue.create({
|
||||
qitemId, destination: team.receiver, body, summary: `ACP: ${oneLine(personsWords(text), 100)}`,
|
||||
tags: ["acp-serve", `${SESSION_TAG}${session.id}`, ...(session.cwd ? [`${CWD_TAG}${session.cwd}`] : [])],
|
||||
});
|
||||
} catch (error) {
|
||||
const reason = (error as Error).message;
|
||||
// A refusal the daemon answered with wrote nothing, so it can be said plainly.
|
||||
if (error instanceof TeamRefused) throw new AcpError(-32603, `The team's queue refused the task: ${reason}`);
|
||||
// No answer: the request may still land after any readback, so only a row that reads back settles it.
|
||||
const landed = await team.queue.find(qitemId).catch(() => undefined);
|
||||
if (!landed) {
|
||||
session.sent.push({ root: qitemId, tail: qitemId });
|
||||
say(session.id, `Sending ${qitemId} to ${team.receiver} got no answer (${reason}), and ${landed === null ? "it wasn't found on the immediate readback" : "it couldn't be read back"}: delivery is unconfirmed, so it may still reach the team. A new prompt in this session names it, and \`rig queue show ${qitemId}\` shows it if it landed.`);
|
||||
throw new AcpError(-32603, `Couldn't confirm ${qitemId}: ${reason}`);
|
||||
}
|
||||
log(`session ${session.id}: ${qitemId}'s create got no answer, but it reads back`);
|
||||
}
|
||||
root = qitemId;
|
||||
const sent = { root, tail: root };
|
||||
session.sent.push(sent);
|
||||
log(`session ${session.id}: sent ${root} to ${team.receiver}`);
|
||||
|
||||
// ponytail: polls the daemon for the chain's rows, their transitions and the caller's open asks; the
|
||||
// daemon's event stream is the upgrade path if a poll per open turn ever costs too much.
|
||||
const follower = new ChainFollower(team.queue, (u) => update(session.id, u), root);
|
||||
const seen = new Set<string>();
|
||||
/** Close this turn's questions: any the app still shows are named as open elsewhere, then nothing more comes from them. */
|
||||
const finishQuestions = (why: string) => {
|
||||
for (const [toolCallId, named] of questions.outstanding) unanswered(session.id, toolCallId, named, why);
|
||||
questions.finished = true;
|
||||
};
|
||||
for (;;) {
|
||||
const state = await follower.read();
|
||||
sent.tail = state.tail.qitemId;
|
||||
if (state.kind === "ended") {
|
||||
finishQuestions("The work closed before this was answered here.");
|
||||
say(session.id, state.reply);
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
const asks = await team.queue.openAsks(team.caller);
|
||||
// After a cancel, even one that landed during that read, no new question goes to the person.
|
||||
for (const ask of questions.cancelled ? [] : asks) {
|
||||
if (seen.has(ask.qitemId)) continue;
|
||||
// An ask tagged for a chat belongs to that chat alone. An untagged one from a seat this work reached might be
|
||||
// another chat's, so it isn't asked here; it's named, with where to answer it.
|
||||
const chats = ask.tags.filter((tag) => tag.startsWith(SESSION_TAG));
|
||||
if (chats.includes(`${SESSION_TAG}${session.id}`)) {
|
||||
seen.add(ask.qitemId);
|
||||
const task: Promise<void> = askPerson(team, session.id, ask, questions)
|
||||
.catch((error: Error) => log(`session ${session.id}: asking about ${ask.qitemId} failed: ${error.message}`))
|
||||
.finally(() => questions.tasks.delete(task));
|
||||
questions.tasks.add(task);
|
||||
} else if (chats.length === 0 && follower.seats.has(ask.sourceSession) && ask.tsCreated >= turnStarted) {
|
||||
seen.add(ask.qitemId);
|
||||
say(session.id, `\n\n${ask.sourceSession}, which this work reached, has a question for you that isn't tagged for this chat (${ask.qitemId}: ${ask.summary ?? oneLine(ask.body, 120)}). Answer it in the OpenRig TUI or Slack.\n\n`);
|
||||
}
|
||||
}
|
||||
if (questions.cancelled) {
|
||||
// ACP has the app answer the questions it was showing as cancelled; let those answers land before the turn ends.
|
||||
if (questions.tasks.size > 0) await Promise.race([Promise.allSettled([...questions.tasks]), sleep(QUESTION_SETTLE_MS)]);
|
||||
finishQuestions("Not answered here: the turn was cancelled.");
|
||||
say(session.id, `Stopped waiting. ${state.tail.qitemId} is still ${state.tail.state} with ${state.tail.destinationSession}: cancelling here doesn't stop the team's work. A new prompt in this session goes to the team as a follow-up and shows where this stands.`);
|
||||
return { stopReason: "cancelled" };
|
||||
}
|
||||
await Promise.race([sleep(options.pollMs), new Promise<void>((resolve) => { wake = resolve; })]);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AcpError || !root) throw error;
|
||||
say(session.id, `Lost track of ${root}: ${(error as Error).message}. The item stays with the team; its state is unknown from here.`);
|
||||
throw new AcpError(-32603, `Couldn't follow ${root}: ${(error as Error).message}`);
|
||||
} finally {
|
||||
questions.finished = true;
|
||||
session.turn = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { appendFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { JsonRpcRequestError, JsonRpcTransportClosed, StdioJsonRpcPeer, type JsonRpcErrorObject } from "./json-rpc-stdio.js";
|
||||
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
|
||||
@@ -48,22 +48,10 @@ export interface AcpRunOptions {
|
||||
detachRequested?: Promise<void>;
|
||||
/** How long to wait for the agent to exit after its stdin closes (default 5 s). */
|
||||
exitWaitMs?: number;
|
||||
/** A harness's own evidence that a `session/load` error means that session doesn't exist. Without it, or when it
|
||||
* doesn't match, the load error reads `failed` with the agent's code and message: no code means missing by itself. */
|
||||
missingSession?: (error: JsonRpcErrorObject, sessionId: string) => boolean;
|
||||
/** Called once with what the stop found: the agent's exit and whether any process carrying the run's marker remains. */
|
||||
onSettled?: (report: string) => void;
|
||||
}
|
||||
|
||||
export type AcpOutcomeKind = "completed" | "stopped" | "cancelled" | "failed" | "unknown" | "missing";
|
||||
/** What the agent itself reported; OpenRig measures none of it. */
|
||||
export interface AcpUsage {
|
||||
/** The prompt response's `usage` (an unstable ACP field), as the agent counted it. */
|
||||
tokens?: { total?: number; input?: number; output?: number; thought?: number; cachedRead?: number; cachedWrite?: number };
|
||||
/** The last `usage_update` cost: the agent's own cumulative figure for the whole session, in its unit. */
|
||||
cost?: { amount: number; currency: string };
|
||||
}
|
||||
export interface AcpOutcome { kind: AcpOutcomeKind; summary: string; text: string; sessionId?: string; usage?: AcpUsage }
|
||||
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]"];
|
||||
@@ -99,7 +87,6 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
|
||||
let sessionId: string | undefined;
|
||||
let text = "";
|
||||
let usage: { used?: number; size?: number; cost?: unknown } | undefined;
|
||||
const reported: AcpUsage = {};
|
||||
let toolCalls = 0;
|
||||
let lastTool = "";
|
||||
let lastProgressNote = Date.now();
|
||||
@@ -117,8 +104,6 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
|
||||
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 };
|
||||
const cost = update.cost as { amount?: unknown; currency?: unknown } | null | undefined;
|
||||
if (typeof cost?.amount === "number" && typeof cost.currency === "string") reported.cost = { amount: cost.amount, currency: cost.currency };
|
||||
}
|
||||
if (Date.now() - lastProgressNote >= 60_000) {
|
||||
lastProgressNote = Date.now();
|
||||
@@ -168,11 +153,10 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
|
||||
|
||||
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;
|
||||
const withUsage: AcpOutcome = { ...outcome, usage: { ...reported } };
|
||||
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.\n${describeUsage(withUsage.usage)}`,
|
||||
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 withUsage;
|
||||
return outcome;
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -182,12 +166,7 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
|
||||
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: "" });
|
||||
try {
|
||||
await peer.request("session/load", { sessionId: options.loadSession, cwd: options.cwd, mcpServers: [] });
|
||||
} catch (error) {
|
||||
if (!(error instanceof JsonRpcRequestError) || !options.missingSession?.(error.error, options.loadSession)) throw error;
|
||||
return await record({ kind: "missing", text: "", summary: `session ${options.loadSession} doesn't exist for this agent (session/load returned ${describeError(error.error)}); no new session was started` });
|
||||
}
|
||||
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;
|
||||
@@ -200,29 +179,20 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
|
||||
if (turnOver || cancelRequestedAt) return;
|
||||
cancelRequestedAt = new Date().toISOString();
|
||||
peer.notify("session/cancel", { sessionId });
|
||||
try {
|
||||
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` });
|
||||
} catch (error) {
|
||||
console.error(`[rig acp] could not record the cancel request: ${String(error)}`);
|
||||
}
|
||||
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; usage?: Record<string, unknown> };
|
||||
const response = await Promise.race([peer.request("session/prompt", { sessionId, prompt: [{ type: "text", text: row.body }] }), detached]) as { stopReason?: string };
|
||||
turnOver = true;
|
||||
const tokens = response.usage;
|
||||
if (tokens && typeof tokens === "object") {
|
||||
const count = (key: string) => (typeof tokens[key] === "number" ? tokens[key] as number : undefined);
|
||||
reported.tokens = { total: count("totalTokens"), input: count("inputTokens"), output: count("outputTokens"), thought: count("thoughtTokens"), cachedRead: count("cachedReadTokens"), cachedWrite: count("cachedWriteTokens") };
|
||||
}
|
||||
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 ${describeError(error.error)}` });
|
||||
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" });
|
||||
@@ -238,9 +208,7 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
|
||||
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>`;
|
||||
const report = `agent ${exit === "still-running" ? "still running after stdin closed" : `exited (code ${exit.code ?? "none"}, signal ${exit.signal ?? "none"})`}; ${settled}`;
|
||||
options.onSettled?.(report);
|
||||
await queue.update(row.qitemId, { note: `ACP run ${options.runId} detached: ${report}` }).catch(() => {});
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -259,22 +227,6 @@ export function classify(stopReason: string | undefined, text: string, usage: {
|
||||
return { kind: "completed", sessionId, text, summary: "the agent ended its turn (end_turn)" };
|
||||
}
|
||||
|
||||
const describeError = (error: JsonRpcErrorObject) => `${error.code} ${error.message}${error.data === undefined ? "" : ` ${JSON.stringify(error.data)}`}`;
|
||||
|
||||
/** Usage and cost as the agent reported them, with where each came from; a figure it didn't report reads unknown. */
|
||||
export function describeUsage(usage: AcpUsage | undefined): string {
|
||||
const t = usage?.tokens;
|
||||
const parts = t ? [["in", t.input], ["out", t.output], ["thought", t.thought], ["cache read", t.cachedRead], ["cache write", t.cachedWrite]]
|
||||
.filter(([, n]) => n !== undefined).map(([label, n]) => `${n} ${label}`) : [];
|
||||
const tokens = t?.total !== undefined || parts.length > 0
|
||||
? `${t?.total !== undefined ? `${t.total} tokens` : "tokens"}${parts.length ? ` (${parts.join(", ")})` : ""}, from the agent's prompt response`
|
||||
: "tokens unknown (the agent's prompt response reported none)";
|
||||
const cost = usage?.cost
|
||||
? `cost ${usage.cost.amount} ${usage.cost.currency}, the agent's own cumulative figure for the session (usage_update)`
|
||||
: "cost unknown (the agent reported none)";
|
||||
return `Usage as the agent reported it: ${tokens}; ${cost}.`;
|
||||
}
|
||||
|
||||
/** 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();
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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 OpenRig needs on either
|
||||
// side (a few outgoing requests, incoming requests and notifications): StdioJsonRpcPeer is the client end of an agent
|
||||
// process OpenRig launched, and JsonRpcPeer over this process's own stdin and stdout is the agent end (rig acp serve).
|
||||
// The official SDK would add a dependency for a handful of methods, so swap to it only if the method set grows.
|
||||
// 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;
|
||||
@@ -34,18 +33,27 @@ 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;
|
||||
|
||||
/** One JSON-RPC connection over a pair of streams: incoming lines on `input`, outgoing packets written to `output`. */
|
||||
export class JsonRpcPeer {
|
||||
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;
|
||||
protected readonly lines: ReturnType<typeof createInterface>;
|
||||
private readonly lines: ReturnType<typeof createInterface>;
|
||||
readonly exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
|
||||
|
||||
constructor(input: NodeJS.ReadableStream, private readonly output: NodeJS.WritableStream, private readonly record: PacketRecorder = () => {}) {
|
||||
this.lines = createInterface({ input });
|
||||
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> {
|
||||
@@ -69,18 +77,25 @@ export class JsonRpcPeer {
|
||||
this.notificationHandlers.set(method, handler);
|
||||
}
|
||||
|
||||
/** The connection is gone: nothing more is sent, and every request still waiting rejects as closed. */
|
||||
protected close(): void {
|
||||
this.ended = true;
|
||||
for (const [id, request] of this.pending) {
|
||||
this.pending.delete(id);
|
||||
request.reject(new JsonRpcTransportClosed(request.method));
|
||||
}
|
||||
/** 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.output.write(`${JSON.stringify(packet)}\n`);
|
||||
this.child.stdin.write(`${JSON.stringify(packet)}\n`);
|
||||
}
|
||||
|
||||
private receive(line: string): void {
|
||||
@@ -112,7 +127,7 @@ export class JsonRpcPeer {
|
||||
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: `Method not found: ${method}` } });
|
||||
this.send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Client does not handle ${method}` } });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -120,48 +135,7 @@ export class JsonRpcPeer {
|
||||
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;
|
||||
const data = (error as { data?: unknown }).data;
|
||||
if (!this.ended) this.send({ jsonrpc: "2.0", id, error: { code, message: (error as Error).message, ...(data !== undefined ? { data } : {}) } });
|
||||
if (!this.ended) this.send({ jsonrpc: "2.0", id, error: { code, message: (error as Error).message } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The client end of an ACP agent process OpenRig launched. */
|
||||
export class StdioJsonRpcPeer extends JsonRpcPeer {
|
||||
readonly exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
|
||||
|
||||
constructor(private readonly child: ChildProcessWithoutNullStreams, record: PacketRecorder = () => {}) {
|
||||
super(child.stdout, child.stdin, record);
|
||||
this.exited = new Promise((resolve) => child.once("close", (code, signal) => {
|
||||
this.close();
|
||||
resolve({ code, signal });
|
||||
}));
|
||||
child.stdin.on("error", () => { /* EPIPE after the agent exits; pending requests reject on close */ });
|
||||
}
|
||||
|
||||
/** 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();
|
||||
}
|
||||
}
|
||||
|
||||
/** The agent end over this process's stdin and stdout (or any stream pair): `closed` settles when the input ends. */
|
||||
export class StreamJsonRpcPeer extends JsonRpcPeer {
|
||||
readonly closed: Promise<void>;
|
||||
|
||||
constructor(input: NodeJS.ReadableStream, output: NodeJS.WritableStream, record: PacketRecorder = () => {}) {
|
||||
super(input, output, record);
|
||||
this.closed = new Promise((resolve) => this.lines.once("close", () => { this.close(); resolve(); }));
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,9 @@ 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 (`rig exec` names its connector's own). Linux reads /proc;
|
||||
* elsewhere `ps e` shows own environments. */
|
||||
export async function findRunProcesses(runId: string, markerName = RUN_MARKER): Promise<RunProcess[]> {
|
||||
const marker = `${markerName}=${runId}`;
|
||||
/** 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")) {
|
||||
|
||||
@@ -62,16 +62,3 @@ export function writeTextAtomically(path: string, content: string, label = "file
|
||||
if (owned) try { unlinkSync(temporary); } catch { /* Renamed or already removed. */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a file, failing with EEXIST rather than replacing anything already at the path, even a dangling link. */
|
||||
export function writeNewText(path: string, content: string): void {
|
||||
const fd = openSync(path, "wx", 0o666);
|
||||
try {
|
||||
writeFileSync(fd, content, "utf-8");
|
||||
} catch (error) {
|
||||
try { unlinkSync(path); } catch { /* Report the write failure. */ }
|
||||
throw error;
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,7 @@ 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.
|
||||
// A piped bare invocation only prints usage; cold-cache writes can otherwise delay its exit.
|
||||
if (process.argv.length > 2 || (process.stdin.isTTY && process.stdout.isTTY)) module.enableCompileCache?.();
|
||||
module.enableCompileCache?.();
|
||||
|
||||
export function resolveBinEntry(invokedPath = process.argv[1], moduleUrl = import.meta.url): string {
|
||||
const wrapperPath = invokedPath ? realpathSync(invokedPath) : realpathSync(fileURLToPath(moduleUrl));
|
||||
|
||||
+25
-101
@@ -3,9 +3,8 @@ import { assessLocalDaemonOwner, ForeignLocalDaemonError, type LocalDaemonOwner
|
||||
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";
|
||||
|
||||
export function terminalAuthHeaders(): Record<string, string> {
|
||||
const token = resolveTerminalToken();
|
||||
@@ -15,9 +14,6 @@ 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
|
||||
@@ -35,13 +31,7 @@ export function senderIdentityHeaders(originSelfHostId?: string, sessionName?: s
|
||||
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. */
|
||||
@@ -147,15 +137,9 @@ 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,7 +161,6 @@ 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. */
|
||||
@@ -186,100 +169,43 @@ export class DaemonClient {
|
||||
private identity?: Promise<Record<string, string>>;
|
||||
private originUnknown = false;
|
||||
private readonly sessionName?: string;
|
||||
private readonly principal?: DaemonClientOptions["principal"];
|
||||
|
||||
/** Isolated terminal tools require this home's durable identity, even on loopback. */
|
||||
async isLocalInstance(): Promise<boolean> {
|
||||
if (this.remoteTarget) return false;
|
||||
const origin = (await import("./local-origin.js")).readLocalOrigin();
|
||||
return !!origin && await this.matchesInstanceIdentity(origin);
|
||||
}
|
||||
|
||||
private async matchesInstanceIdentity(origin: string): Promise<boolean> {
|
||||
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)); }),
|
||||
]);
|
||||
return target === origin;
|
||||
} catch { return false; }
|
||||
finally { clearTimeout(timer); controller.abort(); }
|
||||
}
|
||||
|
||||
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 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 };
|
||||
if (!session || session.split("@").length >= 3) return localHeaders;
|
||||
const directTarget = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
|
||||
if (!this.remoteTarget && !directTarget) return localHeaders;
|
||||
// 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 (!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, this.sessionName);
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -296,11 +222,9 @@ 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. */
|
||||
@@ -385,7 +309,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,
|
||||
{
|
||||
|
||||
@@ -4,20 +4,16 @@ 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 { daemonMayAnswer, daemonStatusGuard, getDaemonStatus, getDaemonUrl, statusGuardMessage } from "../daemon-lifecycle.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 { serveAcpAgent, SESSION_TAG, TeamRefused, type TeamAsk, type TeamConnection, type TeamQueue } from "../acp/agent-server.js";
|
||||
import { StreamJsonRpcPeer } from "../acp/json-rpc-stdio.js";
|
||||
import { CLI_VERSION } from "../version.js";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
export const runsDir = () => join(getOpenRigHome(), "acp", "runs");
|
||||
export const newId = (prefix: string, bytes: number) => `${prefix}-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(bytes).toString("hex")}`;
|
||||
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 }
|
||||
|
||||
@@ -48,127 +44,6 @@ export function daemonAcpQueue(client: DaemonClient): AcpQueue {
|
||||
};
|
||||
}
|
||||
|
||||
/** rig acp serve's queue verbs over the daemon API, acting as the caller the client was built for. */
|
||||
export function daemonTeamQueue(client: DaemonClient): TeamQueue {
|
||||
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;
|
||||
};
|
||||
const face = (data: Record<string, unknown>) => {
|
||||
const row = (data.item ?? data) as Record<string, unknown>;
|
||||
const text = (value: unknown) => (typeof value === "string" ? value : null);
|
||||
return {
|
||||
qitemId: String(row.qitemId), state: String(row.state), destinationSession: String(row.destinationSession),
|
||||
handedOffTo: text(row.handedOffTo), closureReason: text(row.closureReason), closureTarget: text(row.closureTarget), resolution: text(row.resolution),
|
||||
closer: text((row.closingTransition as { actorSession?: unknown } | null | undefined)?.actorSession),
|
||||
};
|
||||
};
|
||||
const list = async (query: string, what: string): Promise<Array<Record<string, unknown>>> => {
|
||||
const data = ok(await client.get<unknown>(`/api/queue/list?${query}`), what);
|
||||
return (Array.isArray(data) ? data : ((data as { items?: unknown[] }).items ?? [])) as Array<Record<string, unknown>>;
|
||||
};
|
||||
/** The rows that ask the person for a decision (questions with choices, or a decision in words). */
|
||||
const asks = (rows: Array<Record<string, unknown>>): TeamAsk[] =>
|
||||
rows.filter((row) => row.humanIntent !== "update" && (Array.isArray(row.humanQuestions) || row.humanIntent === "decision")).map((row) => ({
|
||||
qitemId: String(row.qitemId), sourceSession: String(row.sourceSession), state: String(row.state), tsCreated: String(row.tsCreated),
|
||||
summary: typeof row.summary === "string" ? row.summary : null, body: typeof row.body === "string" ? row.body : "",
|
||||
tags: Array.isArray(row.tags) ? (row.tags as string[]) : [],
|
||||
questions: Array.isArray(row.humanQuestions) ? (row.humanQuestions as TeamAsk["questions"]) : [],
|
||||
// A click records the option's id; a typed reply records its words.
|
||||
answers: Object.fromEntries(Object.entries((row.humanAnswers as Record<string, unknown> | null) ?? {})
|
||||
.map(([id, value]) => [id, typeof value === "string" ? value : String((value as { text?: unknown } | null)?.text ?? "")])),
|
||||
}));
|
||||
return {
|
||||
async create(row) {
|
||||
const response = await client.post("/api/queue/create", { qitemId: row.qitemId, destinationSession: row.destination, body: row.body, summary: row.summary, tags: row.tags });
|
||||
// A 4xx is the daemon's answered refusal, with nothing written; a 5xx leaves the write unknown, like no answer.
|
||||
if (response.status >= 400 && response.status < 500) throw new TeamRefused(`HTTP ${response.status}: ${JSON.stringify(response.data)}`);
|
||||
ok(response, `queue create for ${row.destination}`);
|
||||
},
|
||||
async show(qitemId) {
|
||||
return face(ok(await client.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`), `queue show ${qitemId}`));
|
||||
},
|
||||
async find(qitemId) {
|
||||
const response = await client.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`);
|
||||
if (response.status === 404) return null;
|
||||
return face(ok(response, `queue show ${qitemId}`));
|
||||
},
|
||||
async transitions(qitemId) {
|
||||
const data = ok(await client.get<Array<Record<string, unknown>>>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`), `queue transitions ${qitemId}`);
|
||||
return data.map((t) => ({
|
||||
transitionId: Number(t.transitionId), ts: String(t.ts), state: String(t.state),
|
||||
transitionNote: typeof t.transitionNote === "string" ? t.transitionNote : null, actorSession: String(t.actorSession),
|
||||
}));
|
||||
},
|
||||
async successor(qitemId) {
|
||||
return (await list(`handedOffFrom=${encodeURIComponent(qitemId)}&limit=1`, `queue successor of ${qitemId}`))[0]?.qitemId as string | undefined;
|
||||
},
|
||||
async openAsks(person) {
|
||||
return asks(await list(`destinationSession=${encodeURIComponent(person)}&state=pending&limit=500`, `open asks to ${person}`));
|
||||
},
|
||||
async sessionAsks(person, sessionId) {
|
||||
const rows = await list(`destinationSession=${encodeURIComponent(person)}&limit=2000`, `rows to ${person}`);
|
||||
return asks(rows.filter((row) => Array.isArray(row.tags) && (row.tags as string[]).includes(`${SESSION_TAG}${sessionId}`)));
|
||||
},
|
||||
async answer(qitemId, questionId, optionId) {
|
||||
// The person's answer path, as a click in Slack records one: the daemon takes the actor from this client's caller.
|
||||
const bearer = process.env.OPENRIG_AUTH_BEARER_TOKEN;
|
||||
const response = await client.post<Record<string, unknown>>("/api/mission-control/action", { verb: "answer", qitemId, questionId, optionId },
|
||||
bearer ? { headers: { Authorization: `Bearer ${bearer}` } } : undefined);
|
||||
if (response.status < 400) return { ok: true };
|
||||
const data = (response.data ?? {}) as { message?: unknown; error?: unknown };
|
||||
return { ok: false, reason: String(data.message ?? data.error ?? `HTTP ${response.status}`) };
|
||||
},
|
||||
async sent(caller) {
|
||||
const rows = await list(`sourceSession=${encodeURIComponent(caller)}&limit=2000`, `rows from ${caller}`);
|
||||
return rows.filter((row) => Array.isArray(row.tags) && (row.tags as string[]).includes("acp-serve")).map((row) => ({
|
||||
qitemId: String(row.qitemId), tsCreated: String(row.tsCreated), tsUpdated: String(row.tsUpdated),
|
||||
summary: typeof row.summary === "string" ? row.summary : null, body: typeof row.body === "string" ? row.body : "", tags: row.tags as string[],
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The seat that takes this agent's tasks: --to, else this machine's acp.to setting (an ACP registry launch has no args).
|
||||
* A setting that can't be read comes back as the reason, for the app's sign-in step to show. */
|
||||
export function acpReceiver(to: string | undefined, store: Pick<ConfigStore, "get"> = new ConfigStore()): { receiver?: string } | { error: string } {
|
||||
if (to?.trim()) return { receiver: to.trim() };
|
||||
try {
|
||||
return { receiver: String(store.get("acp.to") ?? "").trim() || undefined };
|
||||
} catch (error) {
|
||||
return { error: `${(error as Error).message} The agent reads acp.to from it; adding --to <member@rig> to the agent's command skips it.` };
|
||||
}
|
||||
}
|
||||
|
||||
/** Why the team's questions can't reach `address`, when it's a person this machine's OpenRig doesn't know. Says nothing
|
||||
* for a seat's address, a registered person, or a registry that can't be read (unknown isn't unregistered). */
|
||||
export async function unregisteredPersonNotice(address: string, home?: string): Promise<string | undefined> {
|
||||
const suffix = "@external";
|
||||
if (!address.endsWith(suffix)) return undefined;
|
||||
try {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const people = registry.projectHumans(home);
|
||||
if (!people.ok || people.entities.some((person) => person.address === address)) return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const id = address.slice(0, -suffix.length);
|
||||
return `Your team can't ask you questions here yet: ${address} isn't a registered person on this machine, so OpenRig refuses a question addressed to it. Your work is still sent. To be asked, register yourself with \`rig gateway human add ${id} --display-name "<your name>" --delivery-class <A|B|C|D>\`.`;
|
||||
}
|
||||
|
||||
/** The person this machine's OpenRig names as its default (`<id>@external`), or why there isn't one. */
|
||||
async function defaultPersonAddress(): Promise<{ address: string } | { reason: string }> {
|
||||
try {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const people = registry.projectHumans();
|
||||
if (!people.ok) return { reason: people.error };
|
||||
const chosen = registry.resolveDefaultPerson(people.entities);
|
||||
return chosen.person ? { address: chosen.person.address } : { reason: chosen.source === "none" ? chosen.reason : "no default person" };
|
||||
} catch (error) {
|
||||
return { reason: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
function readRun(runId: string): RunFile | undefined {
|
||||
try { return JSON.parse(readFileSync(join(runsDir(), `${runId}.json`), "utf8")) as RunFile; } catch { return undefined; }
|
||||
}
|
||||
@@ -187,7 +62,7 @@ function liveConnector(run: RunFile): boolean {
|
||||
}
|
||||
|
||||
export function acpCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("acp").description("ACP (Agent Client Protocol): hand a queue row's task to an ACP agent, or serve OpenRig as an agent to ACP apps");
|
||||
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();
|
||||
@@ -219,7 +94,7 @@ Ctrl-C (or rig acp cancel <runId>) asks the agent to cancel the turn; a second C
|
||||
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
|
||||
rig acp run qitem-20261023-abc --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 }) => {
|
||||
@@ -260,62 +135,6 @@ Guide: rig context get reference/acp-agents.md`)
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("serve")
|
||||
.description("Serve OpenRig as an ACP agent on stdio, for an ACP app (Zed and others) to launch: its prompts go to your team as queue rows")
|
||||
.option("--to <member@rig>", "The seat that receives your tasks: your team's entry point (default: rig config get acp.to)")
|
||||
.option("--as <address>", "Who the tasks come from (default: OPENRIG_SESSION_NAME, else this machine's default person)")
|
||||
.option("--poll-seconds <n>", "How often to read the work's progress while a turn is open", "2")
|
||||
.addHelpText("after", `
|
||||
An ACP app launches this command and talks to it on stdin and stdout. Each prompt becomes a queue row from you to
|
||||
--to. The app shows the row's notes and handoffs as progress, and the closing resolution as the reply. A later
|
||||
prompt in the same session goes to --to as a follow-up that names the earlier items. A question the working seats
|
||||
ask you meanwhile comes to the app as a choice, recorded as your answer. The app can list and reopen your sessions.
|
||||
|
||||
The turn ends when the team closes the item, or when you cancel. Cancelling stops this process waiting; the team's
|
||||
work carries on. A turn that ended doesn't mean the work was accepted: the reply says how the item was closed.
|
||||
|
||||
Without --to, the receiver is the acp.to setting (rig config set acp.to <member@rig>), which is how an app that
|
||||
starts the agent from the ACP registry, with no arguments, reaches your team.
|
||||
|
||||
It uses the same OpenRig daemon as rig: the one on this computer, started with rig daemon start, unless OPENRIG_URL
|
||||
names another. The app's sign-in step ("OpenRig on this machine") checks that the daemon answers and that you
|
||||
resolve to an address: --as, OPENRIG_SESSION_NAME, or the default person: the only one registered
|
||||
(rig gateway human add <id> ...), or with several, the one chosen with rig gateway default <id>.
|
||||
|
||||
In Zed's settings.json:
|
||||
"agent_servers": { "OpenRig": { "type": "custom", "command": "rig", "args": ["acp", "serve", "--to", "lead@my-team"] } }
|
||||
|
||||
Guide: rig context get reference/acp-agents.md#openrig-as-an-agent-in-acp-apps`)
|
||||
.action(async (opts: { to?: string; as?: string; pollSeconds: string }) => {
|
||||
// Only ACP messages may reach stdout: anything a helper prints goes to stderr instead.
|
||||
console.log = console.error;
|
||||
console.info = console.error;
|
||||
process.stdout.on("error", () => { /* EPIPE once the app has gone; the input closing ends the server */ });
|
||||
const connectTeam = async (): Promise<TeamConnection | { error: string }> => {
|
||||
const status = await getDaemonStatus(getDeps().lifecycleDeps);
|
||||
if (!daemonMayAnswer(status)) {
|
||||
const err = statusGuardMessage(status);
|
||||
return { error: `${err.fact} ${err.action}` };
|
||||
}
|
||||
let caller = opts.as?.trim() || self()?.trim();
|
||||
if (!caller) {
|
||||
const person = await defaultPersonAddress();
|
||||
if ("reason" in person) return { error: `OpenRig doesn't know who you are on this machine (${person.reason}). Register yourself with rig gateway human add <id> --display-name "<your name>" --delivery-class <A|B|C|D> (the only registered person is the default; with several, choose one with rig gateway default <id>), or add --as <address> to the agent's command.` };
|
||||
caller = person.address;
|
||||
}
|
||||
const target = acpReceiver(opts.to);
|
||||
if ("error" in target) return target;
|
||||
return { queue: daemonTeamQueue(new DaemonClient(getDaemonUrl(status), { sessionName: caller })), caller, receiver: target.receiver, notice: await unregisteredPersonNotice(caller) };
|
||||
};
|
||||
const peer = new StreamJsonRpcPeer(process.stdin, process.stdout);
|
||||
serveAcpAgent(peer, {
|
||||
connect: connectTeam, version: CLI_VERSION, pollMs: Math.max(1, Number(opts.pollSeconds) || 2) * 1000,
|
||||
log: (line) => console.error(`[rig acp serve] ${line}`), closed: peer.closed,
|
||||
});
|
||||
await peer.closed;
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("cancel <runId>")
|
||||
.description("Ask a running rig acp run to cancel its agent's turn (the same as Ctrl-C there)")
|
||||
|
||||
@@ -45,7 +45,7 @@ export function agentCommand(depsOverride?: AgentDeps): Command {
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
|
||||
const res = await client.postText<{ valid?: boolean; errors?: string[]; advisories?: string[] }>("/api/agents/validate", yaml);
|
||||
const res = await client.postText<{ valid?: boolean; errors?: string[] }>("/api/agents/validate", yaml);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
@@ -65,8 +65,6 @@ export function agentCommand(depsOverride?: AgentDeps): Command {
|
||||
}
|
||||
|
||||
const data = res.data;
|
||||
// Advisories are printed whether or not the spec is valid; they never make it invalid.
|
||||
for (const advisory of data.advisories ?? []) console.error(`⚠ agent spec advisory: ${advisory}`);
|
||||
if (data.valid) {
|
||||
const name = yamlScalar(yaml, "name") ?? "unknown";
|
||||
const version = yamlScalar(yaml, "version") ?? "unknown";
|
||||
|
||||
@@ -11,31 +11,26 @@ function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
const defaultTmuxExec: TmuxExecFn = (cmd: string) => execSync(cmd, { encoding: "utf-8" }).replace(/\r?\n$/, "");
|
||||
|
||||
// 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+) (.+)$/;
|
||||
const defaultTmuxExec: TmuxExecFn = (cmd: string) => execSync(cmd, { encoding: "utf-8" }).trim();
|
||||
|
||||
export function resolveAttachContext(tmuxExec: TmuxExecFn = defaultTmuxExec):
|
||||
| { attachmentType: "external_cli" }
|
||||
| { attachmentType: "tmux"; tmuxSession: string; tmuxWindow?: string; tmuxPane: string; tmuxSocketPath?: string } {
|
||||
| { attachmentType: "tmux"; tmuxSession: string; tmuxWindow?: string; tmuxPane: string } {
|
||||
const tmuxPane = process.env["TMUX_PANE"];
|
||||
if (!tmuxPane) {
|
||||
return { attachmentType: "external_cli" };
|
||||
}
|
||||
|
||||
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;
|
||||
// Read the socket separately: tmux replaces control-character delimiters,
|
||||
// and session names and socket paths may both contain literal spaces.
|
||||
const tmuxSocketPath = tmuxExec(`tmux display-message -p -t ${shellQuote(resolvedPane!)} '#{socket_path}'`).replace(/\r?\n$/, "");
|
||||
return { attachmentType: "tmux", tmuxSession: tmuxSession!, tmuxWindow, tmuxPane: resolvedPane!,
|
||||
...(tmuxSocketPath ? { tmuxSocketPath } : {}) };
|
||||
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.
|
||||
@@ -62,7 +57,7 @@ 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 address outside tmux (new bare names gain @rig)")
|
||||
.option("--display-name <name>", "Display label inside tmux; external session name outside tmux")
|
||||
.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")
|
||||
.action(async (opts: {
|
||||
@@ -124,7 +119,6 @@ export function attachCommand(depsOverride?: StatusDeps): Command {
|
||||
body["tmuxSession"] = attachContext.tmuxSession;
|
||||
if (attachContext.tmuxWindow) body["tmuxWindow"] = attachContext.tmuxWindow;
|
||||
body["tmuxPane"] = attachContext.tmuxPane;
|
||||
if (attachContext.tmuxSocketPath) body["tmuxSocketPath"] = attachContext.tmuxSocketPath;
|
||||
}
|
||||
if (opts.displayName) body["displayName"] = opts.displayName;
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ export function authCommand(depsOverride?: AuthCommandDeps): Command {
|
||||
});
|
||||
|
||||
withHome(withRuntime(auth.command("switch <profile>")))
|
||||
.description("Restore saved credential bytes (sign-in is unverified).")
|
||||
.description("Activate a saved profile.")
|
||||
.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
|
||||
@@ -121,7 +121,6 @@ cross the connection.`)
|
||||
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}`);
|
||||
});
|
||||
|
||||
|
||||
@@ -222,16 +222,12 @@ async function runCrossHostBroadcast(
|
||||
* local route does, so per-target honesty prints identically. A partial
|
||||
* fan-out sets a non-zero exit either way. */
|
||||
function renderBroadcastResults(data: Record<string, unknown>): void {
|
||||
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; inputPhase?: string; sent?: boolean; managedCompaction?: { notice?: unknown } }>) ?? [];
|
||||
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; managedCompaction?: { notice?: unknown } }>) ?? [];
|
||||
for (const r of results) {
|
||||
if (r.ok) {
|
||||
console.log(`${r.sessionName}: sent`);
|
||||
} else if (r.inputPhase === "attempted" || r.inputPhase === "written" || r.sent === true) {
|
||||
// The daemon records this recipient's receipt as indeterminate (#2507): its text or Enter may have reached it.
|
||||
console.log(`${r.sessionName ? `${r.sessionName}: ` : ""}UNCONFIRMED — ${r.error ?? "unknown error"}; the text may have reached it, so check with rig capture before resending`);
|
||||
} else {
|
||||
// A target that resolved to no sessions has no name to report.
|
||||
console.log(`${r.sessionName ? `${r.sessionName}: ` : ""}FAILED — ${r.error ?? "unknown error"}`);
|
||||
console.log(`${r.sessionName}: FAILED — ${r.error ?? "unknown error"}`);
|
||||
}
|
||||
if (typeof r.managedCompaction?.notice === "string") console.log(`Notice: ${r.managedCompaction.notice}`);
|
||||
}
|
||||
|
||||
@@ -26,8 +26,6 @@ interface CaptureOpts {
|
||||
host?: string;
|
||||
json?: boolean;
|
||||
historyPlusPane?: boolean;
|
||||
readable?: boolean;
|
||||
size?: string;
|
||||
/** --lines was given on the command line (not the "20" default). */
|
||||
linesGiven?: boolean;
|
||||
/** Set when --lines N was given (and --history-plus-pane wasn't): the output is cut to the last N lines. */
|
||||
@@ -46,30 +44,6 @@ function exactLineCount(opts: CaptureOpts, linesFromCli: boolean): number | unde
|
||||
return n > 0 ? n : undefined;
|
||||
}
|
||||
|
||||
/** How long a readable capture request may take: the daemon's 8 s for readable reads (CAPTURE_READABLE_BUDGET_MS, shared
|
||||
* across a fan-out), its 1 s for input-box reads, and the client's usual 5 s for the rest. */
|
||||
export const READABLE_REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** `--readable` reads at 120x40; `--size WxH` at that size. Null when neither was given; an error for a bad --size. */
|
||||
/** What happened to the window after a readable read, as far as OpenRig knows. */
|
||||
export function readableRelease(release: string | undefined): string {
|
||||
switch (release) {
|
||||
case "given-back": return "its window was then given back its size and its own window-size";
|
||||
case "kept-by-another-holder": return "a web terminal opened on it meanwhile and holds its window now; it gives the window back when it closes";
|
||||
case "failed": return "giving its window back failed; a later readable capture or web terminal visit gives it back";
|
||||
default: return "its window's record had changed, so OpenRig left the window as it found it";
|
||||
}
|
||||
}
|
||||
|
||||
export function readableSize(opts: Pick<CaptureOpts, "readable" | "size">): { width: number; height: number } | null | { error: string } {
|
||||
if (opts.size === undefined) return opts.readable ? { width: 120, height: 40 } : null;
|
||||
const match = /^(\d{1,4})x(\d{1,4})$/.exec(opts.size);
|
||||
const width = match ? Number(match[1]) : 0;
|
||||
const height = match ? Number(match[2]) : 0;
|
||||
if (!match || width < 1 || height < 1 || width > 1000 || height > 1000) return { error: `--size wants WIDTHxHEIGHT, each from 1 to 1000 (got ${JSON.stringify(opts.size)})` };
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* The last `n` lines of a capture. The blank rows of the visible pane below its content are dropped first: on a
|
||||
* short shell pane the bottom rows are empty, and the last N rows would otherwise be all blank.
|
||||
@@ -115,17 +89,6 @@ function renderCapture(data: Record<string, unknown>): void {
|
||||
if (box && typeof box.hint === "string" && box.status !== "empty" && box.status !== "no-input-box") {
|
||||
console.error(`[rig capture: ${r["sessionName"]}: input box: ${box.hint}]`);
|
||||
}
|
||||
// A prompt in a pane too small to show it: say so, with the resize command, rather than leave a reader guessing.
|
||||
const pane = r["promptPane"] as { hint?: string } | undefined;
|
||||
if (pane && typeof pane.hint === "string") console.error(`[rig capture: ${r["sessionName"]}: ${pane.hint}]`);
|
||||
// --readable: the size it was read at, or why the window was left as it was.
|
||||
const readable = r["readable"] as { resized?: boolean; width?: number; height?: number; reason?: string; release?: string; sizeBack?: string } | undefined;
|
||||
if (readable?.resized === true) {
|
||||
const after = readable.release === "given-back" && readable.sizeBack
|
||||
? `its own window-size was given back, but ${readable.sizeBack}`
|
||||
: readableRelease(readable.release);
|
||||
console.error(`[rig capture: ${r["sessionName"]}: read at ${readable.width}x${readable.height}; ${after}]`);
|
||||
} else if (readable?.resized === false) console.error(`[rig capture: ${r["sessionName"]}: not resized for reading: ${readable.reason}]`);
|
||||
};
|
||||
|
||||
// Multi-target result
|
||||
@@ -164,8 +127,6 @@ export function captureCommand(depsOverride?: CaptureDeps): Command {
|
||||
.option("--pod <name>", "Capture all sessions in a pod")
|
||||
.option("--lines <n>", "Show the last N lines of the pane (without it: 20 history lines plus the whole visible pane)", "20")
|
||||
.option("--history-plus-pane", "With --lines N, show N history lines plus the whole visible pane, as before --lines was exact")
|
||||
.option("--readable", "Read a seat whose pane is smaller than 120x40 at that size, then give its window back")
|
||||
.option("--size <WxH>", "With or without --readable: read at this size instead (for example 160x50)")
|
||||
.option("--host <id>", "Capture on a remote host declared in ~/.openrig/hosts.yaml (ssh hosts shell out; http hosts go CLI-direct to the remote daemon)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
@@ -173,7 +134,6 @@ Examples:
|
||||
rig capture dev-impl@my-rig
|
||||
rig capture dev-impl@my-rig --lines 50
|
||||
rig capture dev-impl@my-rig --lines 5 --history-plus-pane
|
||||
rig capture dev-impl@my-rig --readable
|
||||
rig capture --rig my-rig
|
||||
rig capture --pod dev --rig my-rig
|
||||
rig capture --rig my-rig --json
|
||||
@@ -186,17 +146,6 @@ Supported notes:
|
||||
A suggestion isn't the person's intent, a draft or a staged command. Ambiguous evidence says
|
||||
unknown. --json has it as inputBox: status, confidence, the cursor and the input and
|
||||
suggestion regions on the visible screen (zero-based rows and columns), evidence and hint.
|
||||
- When the captured screen is at a prompt and the seat's pane is smaller than 80x24, a
|
||||
"[rig capture: ...: pane WxH is smaller than 80x24, ...]" note on stderr says the prompt may be
|
||||
cut off and suggests --readable. --json has it as promptPane: width, height and hint.
|
||||
- --readable (or --size WxH) reads a seat whose pane is smaller than that: it resizes the seat's
|
||||
window, waits for the seat to redraw, captures, then puts the window back, both its size and
|
||||
its own window-size setting, so Herdr, tmux attach and other clients size it as before. The
|
||||
seat redraws twice, and anyone watching it sees the resize briefly. A window that's already big
|
||||
enough is read as it is, and so is one whose size is pinned (window-size manual, as while the
|
||||
web terminal shows it), one whose sizing can't be read, and one with several panes. With --rig
|
||||
or --pod, small seats are read at that size for up to 8 seconds in all, and the rest as they
|
||||
are. A stderr note says which; --json has it as readable.
|
||||
- --lines N shows at most the last N lines, counted up from the last non-blank line. When lines
|
||||
above them are left out, a "[rig capture: ...]" note on stderr says how many; --json reports
|
||||
lines (returned), requestedLines and omittedLines. Trailing rows that are entirely whitespace
|
||||
@@ -229,12 +178,6 @@ Supported notes:
|
||||
const deps = getDeps();
|
||||
opts.linesGiven = cmd.getOptionValueSource("lines") === "cli";
|
||||
opts.exactLines = exactLineCount(opts, opts.linesGiven);
|
||||
const size = readableSize(opts);
|
||||
if (size && "error" in size) {
|
||||
console.error(size.error);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// OPR.0.4.6.MH4 §4 — `agent@rig@host` target sugar (session operand
|
||||
// only; --rig/--pod values are names, never sugar-parsed). Suffix must
|
||||
@@ -265,18 +208,14 @@ Supported notes:
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const lines = parseInt(opts.lines ?? "20", 10);
|
||||
// The capture default is 20: a missing or non-positive count reads as 20, never 0 or negative.
|
||||
const requestedLines = Number.isNaN(lines) || lines < 1 ? 20 : lines;
|
||||
|
||||
const body: Record<string, unknown> = { lines: requestedLines };
|
||||
const body: Record<string, unknown> = { lines: isNaN(lines) ? 20 : lines };
|
||||
if (opts.rig) body.rig = opts.rig;
|
||||
if (opts.pod) body.pod = opts.pod;
|
||||
if (session) body.session = session;
|
||||
body.inputBox = true; // the input box note; a daemon without it ignores the field
|
||||
if (size) body.readable = size; // a daemon without it ignores the field and captures as before
|
||||
|
||||
const res = await client.post<Record<string, unknown>>("/api/transport/capture", body,
|
||||
{ headers: terminalAuthHeaders(), ...(size ? { timeoutMs: READABLE_REQUEST_TIMEOUT_MS } : {}) });
|
||||
const res = await client.post<Record<string, unknown>>("/api/transport/capture", body, { headers: terminalAuthHeaders() });
|
||||
if (res.status < 400) res.data = withExactLines(res.data, opts.exactLines);
|
||||
|
||||
if (opts.json) {
|
||||
@@ -336,16 +275,8 @@ async function runCrossHostCapture(
|
||||
if (opts.rig) argv.push("--rig", opts.rig);
|
||||
if (opts.pod) argv.push("--pod", opts.pod);
|
||||
// Only an explicit --lines is forwarded: the remote's own default stays the default.
|
||||
// A value that parses as a positive count goes over as given (the remote decides);
|
||||
// only a missing, zero, negative or non-numeric count sends the documented default.
|
||||
if (opts.linesGiven || opts.historyPlusPane) {
|
||||
const given = opts.lines ?? "20";
|
||||
const parsed = parseInt(given, 10);
|
||||
argv.push("--lines", Number.isNaN(parsed) || parsed < 1 ? "20" : given);
|
||||
}
|
||||
if (opts.linesGiven || opts.historyPlusPane) argv.push("--lines", opts.lines ?? "20");
|
||||
if (opts.historyPlusPane) argv.push("--history-plus-pane");
|
||||
if (opts.readable) argv.push("--readable");
|
||||
if (opts.size !== undefined) argv.push("--size", opts.size);
|
||||
if (opts.json) argv.push("--json");
|
||||
|
||||
const result = await runner(host, argv);
|
||||
@@ -383,17 +314,13 @@ async function runHttpHostCapture(
|
||||
hint?: string,
|
||||
): Promise<void> {
|
||||
const lines = parseInt(opts.lines ?? "20", 10);
|
||||
const requestedLines = Number.isNaN(lines) || lines < 1 ? 20 : lines;
|
||||
const body: Record<string, unknown> = { lines: requestedLines };
|
||||
const body: Record<string, unknown> = { lines: isNaN(lines) ? 20 : lines };
|
||||
if (opts.rig) body.rig = opts.rig;
|
||||
if (opts.pod) body.pod = opts.pod;
|
||||
if (session) body.session = session;
|
||||
body.inputBox = true;
|
||||
const size = readableSize(opts);
|
||||
if (size && !("error" in size)) body.readable = size;
|
||||
|
||||
const result = await runRemoteHttpOp(host.id, "POST", "/api/transport/capture", body, deps,
|
||||
body.readable ? { timeoutMs: READABLE_REQUEST_TIMEOUT_MS } : {});
|
||||
const result = await runRemoteHttpOp(host.id, "POST", "/api/transport/capture", body, deps, {});
|
||||
if (result.ok) result.data = withExactLines(result.data, opts.exactLines);
|
||||
|
||||
if (opts.json) {
|
||||
|
||||
Loaded 100 of 1474 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user