mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-10 03:31:40 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c99450f322 | ||
|
|
7e55c19a53 | ||
|
|
99aaada64c | ||
|
|
ac496208b0 | ||
|
|
b3e03ed75b | ||
|
|
a1cd9ebf3b | ||
|
|
2fec738282 | ||
|
|
5380ca1070 | ||
|
|
a892e73df8 | ||
|
|
f3fad85e96 |
No files matched your search
@@ -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,40 +76,6 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npm run test:repo
|
||||
|
||||
linux-seat-environment:
|
||||
if: ${{ github.event_name != 'push' }}
|
||||
# This integration test reads /proc/<pid>/environ and is skipped on macOS.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- name: Install the real terminal and staging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes tmux rsync
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- name: Check seat environments without ambient credentials or external network
|
||||
run: |
|
||||
set -euo pipefail
|
||||
suite_root=$(mktemp -d "$RUNNER_TEMP/opr-seat-env.XXXXXX")
|
||||
mkdir -p "$suite_root/home"
|
||||
# Only loopback exists in this namespace. Drop back to the runner user
|
||||
# before the CLI, private daemon and private tmux server are started.
|
||||
sudo unshare --net -- /bin/bash -s -- "$(id -un)" "$suite_root" "$PATH" <<'SUITE'
|
||||
set -euo pipefail
|
||||
ip link set lo up
|
||||
exec runuser -u "$1" -- env -i HOME="$2/home" PATH="$3" TMPDIR="$2" TMUX_TMPDIR="$2" \
|
||||
CI=true TERM=xterm-256color \
|
||||
npm run test -w packages/daemon -- test/seat-env-parent-session.integration.test.ts --maxWorkers=1 --reporter=verbose
|
||||
SUITE
|
||||
|
||||
package-tests:
|
||||
if: ${{ github.event_name != 'push' }}
|
||||
# macOS, the platform OpenRig runs on: several daemon and cli tests read macOS ps columns,
|
||||
|
||||
+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
|
||||
|
||||
-2239
File diff suppressed because it is too large.
Load diff
+1
-3
@@ -42,9 +42,7 @@ repository, and we're happy to link to it.
|
||||
|
||||
Node `^22 || ^24`, a working `tmux` and `rsync` are required. macOS includes `rsync`; many Linux
|
||||
installs don't (`sudo apt-get install rsync` on Debian or Ubuntu). `npm test` and `npm run mirror-skills`
|
||||
use it. Use the npm that comes with your Node, which is npm 10 or newer (`npm --version` shows which you
|
||||
have). A separately packaged npm 9, such as Debian or Ubuntu's `npm` package, fails a fresh `npm ci` with
|
||||
`ENOENT` on `chmod` of `node_modules/@openrig/tui/dist/main.js`. Then:
|
||||
use it. Then:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mvschwarz/openrig.git
|
||||
|
||||
@@ -26,9 +26,9 @@ use:
|
||||
|
||||
```sh
|
||||
# Claude Code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/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.4/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,11 +68,11 @@ 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.4` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
|
||||
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.4/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.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 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.
|
||||
@@ -226,10 +226,9 @@ user's home; changing `OPENRIG_HOME` alone does not isolate provider configurati
|
||||
installation. Selected settings resources can still replace overlapping keys.
|
||||
The collector script lives under `.openrig/`, and the activity-hook relay under
|
||||
the instance's `state/claude-activity-hooks/`. Selected settings/MCP resources can also
|
||||
change that settings file and `.mcp.json`. The shared settings resource enables
|
||||
Exa/Context7 MCP entries and leaves existing `permissions.defaultMode` and permission
|
||||
rules unchanged. Managed launch flags select the session mode. Selected MCP resources
|
||||
configure those external services. Built-in bootstrap
|
||||
change that settings file and `.mcp.json`. The shared settings resource sets
|
||||
`permissions.defaultMode` to `acceptEdits` and enables Exa/Context7 MCP entries;
|
||||
selected MCP resources configure those external services. Built-in bootstrap
|
||||
no longer writes a command allowlist to `~/.claude/settings.json` or removes
|
||||
older allowances. `CLAUDE_CONFIG_DIR` does not relocate the project-local writes.
|
||||
To keep your own status line alongside the collector, see
|
||||
|
||||
@@ -6,12 +6,6 @@ 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.4
|
||||
|
||||
[0.7.4 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.4): a first, experimental step toward every
|
||||
assistant you use working on one team. See its release notes for what shipped, the experimental and security notes for
|
||||
hosted members, upgrade instructions, contributor credits and the remaining testing gaps.
|
||||
|
||||
## How to help
|
||||
|
||||
1. Pick an item and comment on its issue to say you're working on it and coordinate with others. A maintainer can assign
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -88,39 +88,6 @@ Readiness has its own attention codes (`runtime-adapter.ts:74–87`):
|
||||
When `checkReady` reports one, the readiness wait returns at once and startup
|
||||
ends `attention_required`.
|
||||
|
||||
### Evidence for a failed Claude resume
|
||||
|
||||
Previously, a failed Claude launch recorded its error without the probe code or
|
||||
the captured input's identity, and a refused strict full-restore attention clear
|
||||
had no corresponding refusal event. Probe-backed Claude launch failures now
|
||||
carry an optional `probe` in `HarnessLaunchResult` and `node.startup_failed`.
|
||||
Strict full-restore refusals emit `seat.attention_clear_refused`; a
|
||||
`pane_not_usable` refusal includes that probe and the restore attempt ID.
|
||||
A missing strict reconciler emits `strict_reconciler_unavailable` without
|
||||
inventing an attempt ID or probe evidence, and still refuses the clear.
|
||||
|
||||
`native-resume-probe-evidence.ts` records the status, code, a closed-set login
|
||||
refusal and row kind when matched, and the exact capture's line count, byte
|
||||
count and SHA-256. Its new fields contain no captured pane text or free-form
|
||||
probe detail. Existing attention evidence and recovery decisions keep their
|
||||
previous behavior. MCP approval detection still takes precedence over the
|
||||
login-refusal check.
|
||||
|
||||
Pane dimensions are optional, observed after the capture. Their read has a
|
||||
250 ms deadline so a hung `listPanes` cannot indefinitely delay the failure
|
||||
result or its event. On timeout, error, ambiguous pane identity or invalid
|
||||
dimensions, the unavailable dimensions are `null`; late results are ignored.
|
||||
The deadline bounds this diagnostic wait, not the underlying tmux command.
|
||||
|
||||
`native-resume-probe-evidence.test.ts` covers unavailable and late geometry;
|
||||
`startup-orchestrator.test.ts` and `restore-orchestrator.test.ts` exercise the
|
||||
real adapter/orchestrator and SQLite event paths with available, failing and
|
||||
never-settling geometry reads. `seat-attention-reconciler.test.ts` checks that
|
||||
a missing strict reconciler records its refusal without changing restore
|
||||
history or sending input. These use synthetic terminal captures, not a
|
||||
live Claude session. The stub scenario runtime cannot draw Claude's native
|
||||
login-refusal UI, and its scenario runner does not bind the restore action.
|
||||
|
||||
## 2. The runtime adapters
|
||||
|
||||
**5** classes implement `RuntimeAdapter`
|
||||
@@ -238,12 +205,8 @@ resume launches (`codex-runtime-adapter.ts:378`, `codex-resume.ts:103`).
|
||||
`<cwd>/CLAUDE.md` by default (`:194`, `:501`; default at
|
||||
`domain/managed-blocks.ts:17`, and the rig may select `CLAUDE.local.md`
|
||||
instead with `managed_blocks: { claude-code: <file> }`,
|
||||
`managed-blocks.ts:15`); `skill_install` and skill resources → the seat's
|
||||
skill root, `binding.seatSkillsRoot`, which is the `skills/` folder of the
|
||||
seat's generated plugin in its own storage
|
||||
(`domain/startup-orchestrator.ts:353–369`), or
|
||||
`<cwd>/.claude/skills/<name>/` for a seat without one (`:215–216`,
|
||||
`:849`); subagents → `.claude/agents`, plugins →
|
||||
`managed-blocks.ts:15`); `skill_install` → `<cwd>/.claude/skills/<name>/`
|
||||
(`:200`, `:582`); subagents → `.claude/agents`, plugins →
|
||||
`.claude/plugins/<id>` (only plugins that apply to Claude), runtime
|
||||
resources → `.claude/extensions/<id>` (`:584–586`); settings fragments
|
||||
merged into `.claude/settings.local.json` (`:594`) and MCP fragments into
|
||||
@@ -281,45 +244,14 @@ resume launches (`codex-runtime-adapter.ts:378`, `codex-resume.ts:103`).
|
||||
`no_conversation_found` (`:429–434`) and with `recovery:
|
||||
"attention_required"` plus pane evidence on an `attention_required` probe
|
||||
(`:444–450`) — the adapter never relaunches on its own.
|
||||
- **Fresh in-place handover readiness.** `assessFreshInPlaceProbe` qualifies an
|
||||
otherwise ready history probe only for the matching fresh launch, binding and
|
||||
launch generation. The current pane must show a prompt, and `assessManagedProbe`
|
||||
must confirm the new launch's exact session token in the same foreground process
|
||||
across two observations, with the pane and launch still current. It reuses the
|
||||
existing managed identity proof, including its process fingerprint. A process
|
||||
label alone, old scrollback, an unavailable screen, an unreadable process list
|
||||
or an unidentified Claude process cannot admit the handover. Those cases stay
|
||||
`awaiting_runtime`; a positive identity conflict and visible approval or login
|
||||
gates keep their specific results. The handover waits within its existing
|
||||
readiness budget before sending the context packet.
|
||||
|
||||
The fresh path requires the last `❯` prompt to sit inside Claude's complete
|
||||
input frame: its top rule directly above the prompt and a bottom rule below.
|
||||
A shell's echoed launch command cannot borrow an earlier header or input
|
||||
frame, even when the matching Claude process is already running. A header
|
||||
and prompt alone, or a partially drawn frame, remain pending. This may delay
|
||||
readiness until the full frame is visible; other launch paths keep their
|
||||
existing screen predicates.
|
||||
|
||||
Previously, unavailable observations and ambiguous headerless prompts could
|
||||
retain the old ready result. `claude-fresh-in-place-scope.test.ts` now checks
|
||||
missing observations, unknown or changing process identity, retained UI above
|
||||
direct and script launch commands, partial frames, recovery and normal prompt
|
||||
controls. `claude-handover-readiness.test.ts` exercises the real handover
|
||||
service with migrated SQLite, modeled pane/process observations and one synthetic
|
||||
clock. Readiness and the later packet-submission receipt remain separate checks;
|
||||
these tests do not run a live Claude account.
|
||||
|
||||
### CodexRuntimeAdapter (`codex-runtime-adapter.ts:65`)
|
||||
|
||||
- `readonly runtime = "codex"` (`:66`).
|
||||
- **Projects** to `.agents/` targets: `guidance_merge` → `<cwd>/AGENTS.md`
|
||||
(`:309`, `:576`); `skill_install` and skill resources → the seat's skill
|
||||
root, `binding.seatSkillsRoot`, which is the `skills/` folder beside the
|
||||
seat's catalog in its own storage
|
||||
(`domain/startup-orchestrator.ts:360–369`), or
|
||||
`<cwd>/.agents/skills/<name>/` for a seat without one (`:563–564`,
|
||||
`:1060`); plugins go to `.codex/plugins/<id>` (`:1063`).
|
||||
(`:309`, `:576`); `skill_install` → `<cwd>/.agents/skills/<name>/` (`:315`);
|
||||
skills resolve under `.agents/skills/<id>` (`:664`); plugins go to
|
||||
`.codex/plugins/<id>` (`:667`).
|
||||
- **Launches/resumes**: fresh = `codex[ --no-daemon]<posture>[<network>] -C
|
||||
<cwd> …` (`:442`), then capture a fresh thread id (`:460`); resume is built
|
||||
by `buildCodexResumeCore` (`:441`) as `codex<daemon><posture> resume
|
||||
@@ -384,20 +316,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`).
|
||||
|
||||
## 3. Resume honesty
|
||||
|
||||
The daemon does not assume a harness resumed just because the launch command
|
||||
|
||||
@@ -119,37 +119,6 @@ them imports Hono: **0**
|
||||
- `startup-resolver.ts` — additive startup layering.
|
||||
- `projection-planner.ts` — runtime resource projection planning.
|
||||
|
||||
### Profile resolution during launch
|
||||
|
||||
Preflight already resolves profiles in `profile-resolution-worker.ts`. Previously,
|
||||
the pod-aware apply path, materialized-member launch and first-start retry called
|
||||
the synchronous resolver again on the daemon's request thread. A fallback skill
|
||||
scan or managed-catalog inspection could therefore delay every other request even
|
||||
after preflight had finished ([#1653](https://github.com/mvschwarz/openrig/issues/1653)).
|
||||
Those three launch paths now await the same resolver in a worker before projecting
|
||||
resources or starting the member. Configuration precedence and launch order still
|
||||
come from the existing resolver and instantiator.
|
||||
|
||||
An apply operation reuses one lazy worker across its members and terminates it on
|
||||
success or failure. Unlike preflight, launch supplies no `scanScope`: each member
|
||||
reads current files, including changes made by an earlier member's startup.
|
||||
Standalone member launches and first-start retries use a short-lived worker for
|
||||
their resolution. This adds worker startup and context-copy costs; it does not
|
||||
reuse the preflight result or cache configuration between requests. The helper's
|
||||
existing synchronous fallback for a deleted process working directory remains.
|
||||
|
||||
Worker errors become configuration-failure results. Apply can then follow the
|
||||
existing all-members-failed cleanup, including service rollback and restoration
|
||||
of replaced generations. Materialized launches report a failed member, and
|
||||
first-start retries refuse before writing retained state.
|
||||
|
||||
`profile-resolution-worker.test.ts` holds a real fallback scan open with a FIFO,
|
||||
requires an HTTP health response while that FIFO is still blocked, then checks
|
||||
that the selected skill reaches projection. It covers all three launch paths and
|
||||
a skill edit between members. These fixtures use SQLite and the real instantiator
|
||||
with a simulated runtime; they do not establish native provider behavior or
|
||||
responsiveness during other synchronous parts of startup.
|
||||
|
||||
## 4. Startup orchestration (the spec-startup contract)
|
||||
|
||||
`StartupOrchestrator.startNode` (`startup-orchestrator.ts:172`, a thin wrapper
|
||||
|
||||
@@ -68,10 +68,9 @@ system-level invariants.
|
||||
14. Restore outcome states are a fixed set (`RestoreNodeResult.status`,
|
||||
`types.ts:475`): `resumed`, `rebuilt`, `fresh-primed`,
|
||||
`awaiting-decision`, `attention_required`, `failed`, plus
|
||||
`operator_recovered` (set only by later reconciliation), `fresh`
|
||||
(retained only for the legacy continuity-restoring skip path) and
|
||||
`left_attached` (a seat joined with `rig attach` as an external CLI, which
|
||||
restore leaves as it is). `rebuilt` = new process assembled from artifacts.
|
||||
`operator_recovered` (set only by later reconciliation) and `fresh`
|
||||
(retained only for the legacy continuity-restoring skip path). `rebuilt` =
|
||||
new process assembled from artifacts.
|
||||
15. Restore honesty: a failed resume stops loudly as `awaiting-decision`, with
|
||||
the blank session rolled back and no session running
|
||||
(`restore-orchestrator.ts:1055`). No automatic fresh fallback. Fresh launch
|
||||
|
||||
@@ -146,21 +146,9 @@ that gateway side too. For the pane side, read:
|
||||
`indeterminate:…` reads `claimed` (`presentedNudgeResult`). Unclaiming shows
|
||||
the stored result again.
|
||||
- `claimedAt`: when the destination claimed the row. Closing doesn't clear it.
|
||||
- `pickup`: the derived pickup receipt (`unclaimed`, `working`, `needs-input`,
|
||||
- `pickup`: the derived pickup receipt (`unclaimed`, `working`,
|
||||
`stalled-after-claim`, `parked`, `terminal`), computed at read time.
|
||||
|
||||
A claimed item whose owner's activity reports outstanding input reads
|
||||
`needs-input`, with the reason in `pickup.evidence`. That covers an item in
|
||||
progress and a `pending` item that kept its claim, after an update back to
|
||||
`pending` or a reopen. A Codex permission request the daemon couldn't confirm on
|
||||
screen reads `owner may need input (not verified: permission_request_unverified)`.
|
||||
The input signal can outlast the prompt it reported, so it never pauses
|
||||
recovery. When the claim would otherwise read `stalled-after-claim`, the receipt
|
||||
keeps that evidence in `pickup.stall`, and the stuck sweep's finding, its
|
||||
backstop and manual dispositions behave exactly as for `stalled-after-claim`.
|
||||
Queue reads, the pickup view and the execution view share this receipt
|
||||
(`queue-pickup.ts`). Unclaimed, blocked and terminal items keep their receipts.
|
||||
|
||||
A claim isn't proof that the body was read or that the terminal render was
|
||||
verified, and closing a task isn't a new delivery receipt: a claimed task that
|
||||
is later closed still reads `claimed` when its stored result was unconfirmed
|
||||
@@ -178,60 +166,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
|
||||
@@ -483,9 +417,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
|
||||
|
||||
@@ -56,7 +56,7 @@ six runtime keys — `claude-code`, `codex`, `pi`, `omp`, `stub`, `terminal`
|
||||
### The stack
|
||||
|
||||
```text
|
||||
CLI (87 top-level commands) / TUI / web UI / MCP (24 tools)
|
||||
CLI (87 top-level commands) / TUI / web UI / MCP (23 tools)
|
||||
|
|
||||
v
|
||||
Hono daemon routes (69 app.route() mounts + direct handlers for /healthz,
|
||||
|
||||
@@ -37,9 +37,8 @@ All in `packages/daemon/src/domain/types.ts`. The spec/projection types live in
|
||||
`resumed`, `rebuilt`, `fresh`, `fresh-primed`, `awaiting-decision`,
|
||||
`failed`, `attention_required`, `operator_recovered`, `n-a`. The restore
|
||||
result itself, `RestoreNodeResult.status` (`types.ts:475`), has the same
|
||||
values without `n-a`, plus `left_attached`: **9**
|
||||
values without `n-a`: **8**
|
||||
(`grep -E '^ status: "resumed"' packages/daemon/src/domain/types.ts | grep -o '"[^"]*"' | wc -l`).
|
||||
`left_attached` isn't a `NodeRestoreOutcome`; the inventory projects it as `n-a`.
|
||||
Where `restore-orchestrator.ts` sets each one:
|
||||
- `resumed` — `baseStatus = "resumed"` after a legacy resume (`:1030`); the
|
||||
status is returned only by `finishJoinedResume` (defined `:1320`, called
|
||||
@@ -62,10 +61,6 @@ All in `packages/daemon/src/domain/types.ts`. The spec/projection types live in
|
||||
`:1292`, `:1300`).
|
||||
- `operator_recovered` — only from later reconciliation,
|
||||
`reconcileNodeRuntimeTruth` (`:1590`, event at `:1727`).
|
||||
- `left_attached` — a seat joined with `rig attach` as an external CLI,
|
||||
returned first in `restoreNodeWithCompensation`: nothing is launched and
|
||||
its binding and claim stay as they were. It doesn't count against the
|
||||
rollup, and its forecast in `restore-plan-preview.ts` is `leave-attached`.
|
||||
- **RestoreRigResult** (`types.ts:444`) — the rig-level rollup, **4** values
|
||||
(`grep '^export type RestoreRigResult' packages/daemon/src/domain/types.ts | grep -o '"[^"]*"' | wc -l`):
|
||||
`fully_restored`, `partially_restored`, `failed`, `not_attempted`.
|
||||
@@ -73,26 +68,10 @@ All in `packages/daemon/src/domain/types.ts`. The spec/projection types live in
|
||||
first three; `not_attempted` is set when pre-restore validation fails
|
||||
(`:281`).
|
||||
- **SnapshotData** (`types.ts:351`) — the serialized snapshot payload. It has
|
||||
**8** optional fields, kept optional so older snapshots still load
|
||||
**7** optional fields, kept optional so older snapshots still load
|
||||
(`sed -n '/^export interface SnapshotData/,/^}/p' packages/daemon/src/domain/types.ts | grep -c '?:'`):
|
||||
`activeSessionIdByNode?`, `activeOccupantsByNode?`, `periodicResumeSourcesByNode?`,
|
||||
`topologyRoster?`, `pods?`, `continuityStates?`, `nodeStartupContext?`, `envReceipt?`.
|
||||
Periodic snapshots retain a down agent's whole authoritative resume-source
|
||||
session in `periodicResumeSourcesByNode`, without marking it running. A named
|
||||
single-agent launch using automatic periodic selection can resume that exact
|
||||
carried source; an explicit snapshot or whole-rig restore still asks about an
|
||||
absent agent with resumable history. Tokenless and non-resume-policy agents
|
||||
keep their existing fresh path. Only the node's newest session with a token
|
||||
and `resume_if_possible` policy is carried; a current fresh-successor relation
|
||||
must agree. Named launch rechecks the live newest session, resume policy and
|
||||
fresh-successor relation under its lifecycle lease, before changing any seat
|
||||
state; a source retired after capture stops and asks without launching.
|
||||
Idle-shell carry advice uses automatic selection again, not an
|
||||
explicit snapshot that disables carry. The selected source is shared by preflight and post-launch
|
||||
restore; explicit reviewed-history selection keeps its existing checks.
|
||||
Ranking, periodic cadence and `--fresh` are unchanged. Retention is best-effort
|
||||
if the earlier-source lookup fails: capture still records current crash
|
||||
insurance and logs that the resume sources were unavailable.
|
||||
`activeSessionIdByNode?`, `activeOccupantsByNode?`, `topologyRoster?`,
|
||||
`pods?`, `continuityStates?`, `nodeStartupContext?`, `envReceipt?`.
|
||||
- **NodeStartupSnapshot** (`types.ts:344`) — persisted restore replay input:
|
||||
classification-free projection entries, resolved startup files, startup
|
||||
actions, runtime.
|
||||
@@ -405,20 +384,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
|
||||
|
||||
@@ -6,7 +6,7 @@ topics: [coordination, observability]
|
||||
domains: [engineering-advisor, operating-advisor, orchestrator, human-operator]
|
||||
applies-when: |
|
||||
Need to know how the daemon-backed Mission Control surface works — the
|
||||
seven views, the nine write verbs, the action audit table, the
|
||||
seven views, the eight write verbs, the action audit table, the
|
||||
bearer-token middleware, or how queue observability maps to the
|
||||
coordination primitive's sources.
|
||||
siblings: [coordination-primitive.md, workflow-runtime.md, ../ui/project-and-for-you.md]
|
||||
@@ -20,7 +20,7 @@ last-updated: 2026-10-08
|
||||
Mission Control is the daemon's queue-observability and operator-action
|
||||
surface, mounted at `/api/mission-control` over the coordination primitive's
|
||||
`queue_items` and `stream_items` (see `coordination-primitive.md`). It has
|
||||
seven views, nine verbs, a recent-ships cap of 10
|
||||
seven views, eight verbs, a recent-ships cap of 10
|
||||
(`mission-control-read-layer.ts:107`), and a daemon-backed action audit
|
||||
table. In the web UI the old `/mission-control` page now redirects to
|
||||
`/for-you`, and the For You feed carries the verb actions (§6).
|
||||
@@ -85,14 +85,14 @@ There is no filesystem fallback. When no `StreamStore` is wired,
|
||||
> `rig` or `rig tui` is called the "mission-control TUI" in source
|
||||
> (`packages/tui/README.md:1`), but it does not call these routes.
|
||||
|
||||
## 2. The nine verbs (the write contract)
|
||||
## 2. The eight verbs (the write contract)
|
||||
|
||||
`MISSION_CONTROL_VERBS` (`mission-control-action-log.ts:14–29`) is the verb
|
||||
`MISSION_CONTROL_VERBS` (`mission-control-action-log.ts:14–26`) is the verb
|
||||
list. The verbs execute through `MissionControlWriteContract.act`
|
||||
(`mission-control-write-contract.ts:131`); the closure mapping is
|
||||
`verbToClosure` (`:509`).
|
||||
|
||||
Verbs: **9** — `sed -n '/^export const MISSION_CONTROL_VERBS = \[/,/\] as const;/p' packages/daemon/src/domain/mission-control/mission-control-action-log.ts | grep -c '^ "'`
|
||||
Verbs: **8** — `sed -n '/^export const MISSION_CONTROL_VERBS = \[/,/\] as const;/p' packages/daemon/src/domain/mission-control/mission-control-action-log.ts | grep -c '^ "'`
|
||||
|
||||
| Verb | Effect |
|
||||
|---|---|
|
||||
@@ -103,8 +103,7 @@ Verbs: **9** — `sed -n '/^export const MISSION_CONTROL_VERBS = \[/,/\] as cons
|
||||
| `hold` | `state="blocked"`, `closure_reason="blocked_on"` |
|
||||
| `drop` | `state="done"`, `closure_reason="canceled"` |
|
||||
| `handoff` | same closure and new destination item as `route`; the 4-step shape (see below) |
|
||||
| `resolve` | for a `blocked` item parked on a human seat or a well-formed typed gate (`external:`, `fold:`, `auth:`): moves it to `in-progress` with required decision/satisfaction evidence in the transition note; typed waits are recorded as blocker resolutions, not human decisions. Qitem dependencies, malformed gates and non-blocked items are refused; no closure and no new item (`:369`, `:386`, `:410–411`). After commit it sends a best-effort nudge with the decision text to the item's owner unless `notify: false` (`:450–467`); the CLI wrapper is `rig queue resolve <qitemId> --decision <text>` (`packages/cli/src/commands/queue.ts:828`) |
|
||||
| `answer` | only for a `pending` ask addressed to the acting person: a choice (`questionId` + `optionId`) through `QueueRepository.recordHumanAnswer`, or their words (`decision`) through `resolveDirectHumanReply`, each with its existing meaning (a partial choice set stays pending; words fill the first unanswered question). Once answered, one reply row goes to the asking seat (`human-answer` tag, `replyTo` the ask, an id derived from the ask) and then the ask closes once with `human-decision-resolved`, the order Slack's answer continuation uses. With neither a choice nor words, a complete question set whose return didn't finish is sent again with exactly its recorded answers. A closed ask answers 409 `qitem_already_terminal` and records nothing; a refused answer is 400 `answer_not_recorded`; 409 `answer_not_closed` means the ask stayed open: its return failed, it changed before its close, or a retry came in other words than the reply already returned (that retry isn't sent). The TUI's Feed sends it |
|
||||
| `resolve` | only for a `blocked` item parked on a human seat: moves it to `in-progress` with the required decision text as the transition note; no closure and no new item (`:369`, `:386`, `:410–411`). After commit it sends a best-effort nudge with the decision text to the item's owner unless `notify: false` (`:450–467`); the CLI wrapper is `rig queue resolve <qitemId> --decision <text>` (`packages/cli/src/commands/queue.ts:828`) |
|
||||
|
||||
`annotate` requires `annotation`, `hold` and `drop` require `reason`, `route`
|
||||
and `handoff` require `destinationSession`, and `resolve` requires a non-empty
|
||||
@@ -114,11 +113,11 @@ 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
|
||||
Each verb is one atomic daemon transaction: the queue mutation via
|
||||
`QueueRepository.updateWithinTransaction()` (`domain/queue-repository.ts:2371`,
|
||||
which keeps the hot-potato closure validation — see
|
||||
`coordination-primitive.md` §3), an audit row in `mission_control_actions`
|
||||
|
||||
@@ -123,15 +123,11 @@ is refused; the other content checks are advisory.
|
||||
|
||||
`packages/daemon/src/domain/skill-catalog.ts` resolves a seat's skill loadout from the
|
||||
catalog at `skills.root` (`catalog.yaml`, schema `openrig.skill-catalog/v1`) and the system,
|
||||
topology and project selections. For a working directory it projects skills into
|
||||
`<cwd>/.claude/skills` or `<cwd>/.agents/skills` and tracks what it owns in
|
||||
`<cwd>/.openrig/skill-loadouts/<runtime>.json`. For a Claude or Codex team seat it takes a
|
||||
`location` instead: the seat's own storage outside the project, with its `skill-loadout.json`
|
||||
there, and for Claude the `openrig:<id>` names its generated plugin gives. Each projection
|
||||
reports `current`, `missing`, `shadowed`, `stale` or `conflicting`.
|
||||
`rig skill loadout --runtime <runtime>` inspects the composed projection for a working
|
||||
directory, `rig skill loadout --seat <session|self>` does the same for one seat's own skills,
|
||||
and both reconcile only with `--apply`; `rig skill audit` is a separate
|
||||
topology and project selections. It projects skills into `<cwd>/.claude/skills` or
|
||||
`<cwd>/.agents/skills` and tracks what it owns in
|
||||
`<cwd>/.openrig/skill-loadouts/<runtime>.json`. Each projection reports `current`, `missing`,
|
||||
`shadowed`, `stale` or `conflicting`. `rig skill loadout --runtime <runtime>` inspects the
|
||||
composed projection and reconciles it only with `--apply`; `rig skill audit` is a separate
|
||||
read-only provenance and freshness audit. Launch applies the selected loadout per seat.
|
||||
|
||||
For a Claude Code or Codex seat, the profile resolver also adds each selected plugin's skills to
|
||||
@@ -284,9 +280,7 @@ with. Claude Code before 2.1.290 ignores the key, Claude's managed settings take
|
||||
2. The agent writes `RESTORE-MAP.md.tmp` with its file-edit tool, completion marker last.
|
||||
Later ticks return `preparation_pending` until that exact map is complete. The map path is under the seat's working directory
|
||||
(`.openrig/compaction/preparation/<session>/<attempt>/`, with a `.gitignore` of `*`) when
|
||||
that is writable, otherwise under `compaction/preparation/` in the OpenRig home. An explicit
|
||||
`policies.claude_compaction.preparation_root` instead places maps at `<root>/<session>/<attempt>/`
|
||||
for both manual and automatic attempts; failure to prepare that root stops the attempt without fallback.
|
||||
that is writable, otherwise under `compaction/preparation/` in the OpenRig home.
|
||||
3. The daemon atomically renames the completed temp file to `RESTORE-MAP.md` in its existing
|
||||
final send checks, after rechecking the attempt and occupant. An already-complete final map
|
||||
remains compatible. Only then is `/compact` sent, with the final map path appended to the
|
||||
|
||||
@@ -33,12 +33,12 @@ blanket-replaced: `rigged` → `rig` is a per-occurrence question, not a global
|
||||
substitution.
|
||||
|
||||
**Axis 1 — MCP tool names are `rig_*`.** `packages/cli/src/mcp-server.ts`
|
||||
registers **24** tools, all named `rig_*`
|
||||
registers **23** tools, all named `rig_*`
|
||||
(`grep -c 'server.tool(' packages/cli/src/mcp-server.ts`), including
|
||||
`rig_rig_nodes` (`mcp-server.ts:290`), `rig_send` (`:307`),
|
||||
`rig_capture` (`:341`), `rig_chatroom_send` (`:366`) and `rig_chatroom_watch`
|
||||
(`:402`), the five queue tools `rig_queue_create`, `_show`, `_list`, `_claim`
|
||||
and `_update`, and `rig_telemetry_events`. No `rigged_*` name other than the tmux keys below remains in product
|
||||
(`:402`), and the five queue tools `rig_queue_create`, `_show`, `_list`, `_claim`
|
||||
and `_update`. No `rigged_*` name other than the tmux keys below remains in product
|
||||
source: **0** hits
|
||||
(`git grep -n 'rigged_' -- ':(glob)packages/*/src/**' | grep -v -c '@rigged_'`).
|
||||
|
||||
@@ -277,21 +277,21 @@ Routes: `packages/daemon/src/routes/{transport,transcripts,ask,chat,whoami}.ts`
|
||||
after Enter must show the box empty, with the pane process
|
||||
(`#{pane_pid}`) and the seat's binding unchanged.
|
||||
|
||||
These readings stay internal to the send; the result carries only the
|
||||
verdict (`verified`, `outcome`). An empty box reads `queued` instead of
|
||||
`empty` where the frames say the message may wait behind a turn: Codex's
|
||||
pending-input rows or a row under its running-turn status, or a running
|
||||
turn in the frame read before Enter (Codex's `• Working` row; for Claude
|
||||
Code, `esc to interrupt` or a live status row above the box, including
|
||||
above a box of several rows). The verdict uses these readings: verified
|
||||
needs `empty`, so for a seat that was idle before the paste they are the
|
||||
only check that rejects a message queued behind a turn that started after
|
||||
the pre-paste capture. An audited prompt answer (step 4) reads no box: with
|
||||
the setting on it stays unconfirmed, whatever the default matcher would
|
||||
say.
|
||||
The readings decide the verification verdict, not whether Enter is sent:
|
||||
the seat is read from the capture `--verify` already takes, and none of
|
||||
the extra reads decides whether Enter is sent. Each has
|
||||
The result's `inputBox: { seat, before, after, sameProcess }` says which
|
||||
reading failed. An empty box reads `queued` instead of `empty` where the
|
||||
frames say the message may wait behind a turn: Codex's pending-input rows
|
||||
or a row under its running-turn status, or a running turn in the frame
|
||||
read before Enter (Codex's `• Working` row; for Claude Code,
|
||||
`esc to interrupt` or a live status row above the box, including above a
|
||||
box of several rows). The verdict uses these readings: verified needs
|
||||
`empty`, so for a seat that was idle before the paste they are the only
|
||||
check that rejects a message queued behind a turn that started after the
|
||||
pre-paste capture. An audited prompt answer (step 4) reads no box: with
|
||||
the setting on its `inputBox` reads `unchecked` for `seat`, `before` and
|
||||
`after`, `sameProcess` is `false` (it stays a boolean), and the send reads
|
||||
unconfirmed, whatever the default matcher would say.
|
||||
It reports only: the seat is read from the capture `--verify` already
|
||||
takes, and none of the extra reads decides whether Enter is sent. Each has
|
||||
a 500 ms deadline, which is a budget, not a guarantee: the pre-Enter read
|
||||
comes after the existing 200 ms wait and can add about 500 ms before
|
||||
Enter, and the post-Enter process read can add about another 500 ms
|
||||
@@ -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.
|
||||
+45
-144
@@ -9,23 +9,23 @@ applies-when: |
|
||||
the source that implements a command. Use command help for invocation details.
|
||||
siblings: [README.md, codemap.md]
|
||||
prerequisite-reads: [../reference/help.md]
|
||||
last-verified-against-source: 34fdcb80543903b054a982cc48bb302ae6871682
|
||||
last-updated: 2026-10-26
|
||||
last-verified-against-source: 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.
|
||||
These are source counts, not a claim about a deployed release.
|
||||
|
||||
## Reading this reference
|
||||
|
||||
@@ -143,7 +143,7 @@ Root: `rig`; declared option: `-V, --version`.
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig daemon` | — | — |
|
||||
| `rig daemon start` | — | `--port <port>`<br>`--host <host>`<br>`--db <path>`<br>`--allow-db-conflict`<br>`--no-kernel`<br>`--wait-for-kernel`<br>`--wait-for-kernel-ms <ms>` |
|
||||
| `rig daemon start` | — | `--port <port>`<br>`--host <host>`<br>`--db <path>`<br>`--no-kernel`<br>`--wait-for-kernel`<br>`--wait-for-kernel-ms <ms>` |
|
||||
| `rig daemon stop` | — | — |
|
||||
| `rig daemon status` | — | — |
|
||||
| `rig daemon logs` | — | `--follow` |
|
||||
@@ -188,13 +188,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 quiet` | — | — |
|
||||
| `rig gateway human quiet set <entityId>` | — | `--daily <window>`<br>`--until <time>`<br>`--tz <zone>`<br>`--actor <actor>`<br>`--json` |
|
||||
| `rig gateway human quiet end <entityId>` | — | `--actor <actor>`<br>`--json` |
|
||||
| `rig gateway human quiet clear <entityId>` | — | `--actor <actor>`<br>`--json` |
|
||||
| `rig gateway human quiet show <entityId>` | — | `--json` |
|
||||
| `rig gateway human show <entityId>` | — | `--json` |
|
||||
| `rig gateway human set <entityId> <field> <value>` | — | `--reason <reason>`<br>`--actor <actor>` |
|
||||
| `rig gateway human mode` | — | — |
|
||||
| `rig gateway human mode save <entityId> <name>` | — | `--availability <value>`<br>`--expected-back <when>`<br>`--decision-room <words>`<br>`--exclude <item...>`<br>`--quiet <rule>`<br>`--tz <zone>`<br>`--account <where>`<br>`--actor <actor>`<br>`--json` |
|
||||
| `rig gateway human mode saved <entityId> [name]` | — | `--json` |
|
||||
@@ -205,27 +202,7 @@ 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
|
||||
|
||||
@@ -311,7 +288,7 @@ the sections that were not read are `null`.
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig bundle` | — | — |
|
||||
| `rig bundle create <spec>` | — | `-o, --output <path>` **required**<br>`--name <name>`<br>`--bundle-version <ver>`<br>`--include-login-files`<br>`--include-packages <refs...>`<br>`--rig-root <root>`<br>`--context-pack <dir>` (repeatable)<br>`--project-dir <dir>`<br>`--preset <name>`<br>`--seat <member=runtime>` (repeatable)<br>`--notes <text>`<br>`--min-daemon-version <ver>`<br>`--min-cli-version <ver>`<br>`--allow-drift`<br>`--json` |
|
||||
| `rig bundle create <spec>` | — | `-o, --output <path>` **required**<br>`--name <name>`<br>`--bundle-version <ver>`<br>`--include-packages <refs...>`<br>`--rig-root <root>`<br>`--context-pack <dir>` (repeatable)<br>`--project-dir <dir>`<br>`--preset <name>`<br>`--seat <member=runtime>` (repeatable)<br>`--notes <text>`<br>`--min-daemon-version <ver>`<br>`--min-cli-version <ver>`<br>`--allow-drift`<br>`--json` |
|
||||
| `rig bundle configurations <spec>` | — | `--json` |
|
||||
| `rig bundle inspect <path>` | — | `--preset <name>`<br>`--seat <member=runtime>` (repeatable)<br>`--json` |
|
||||
| `rig bundle install <path>` | — | `--preset <name>`<br>`--seat <member=runtime>` (repeatable)<br>`--non-interruptive`<br>`--no-non-interruptive`<br>`--plan`<br>`--yes`<br>`--target <root>`<br>`--cwd <path>`<br>`--skip-version-check`<br>`--force`<br>`--json` |
|
||||
@@ -322,13 +299,13 @@ the sections that were not read are `null`.
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig up <source>` | — | `--non-interruptive`<br>`--no-non-interruptive`<br>`--plan`<br>`--yes`<br>`--cwd <path>`<br>`--env <KEY=VALUE>` (repeatable)<br>`--name <rig>`<br>`--target <root>`<br>`--preset <name>`<br>`--seat <member=runtime>` (repeatable)<br>`--existing`<br>`--fresh <seats...>`<br>`--json`<br>`--host <id>` |
|
||||
| `rig up <source>` | — | `--non-interruptive`<br>`--no-non-interruptive`<br>`--plan`<br>`--yes`<br>`--cwd <path>`<br>`--env <KEY=VALUE>` (repeatable)<br>`--target <root>`<br>`--preset <name>`<br>`--seat <member=runtime>` (repeatable)<br>`--existing`<br>`--fresh <seats...>`<br>`--json`<br>`--host <id>` |
|
||||
|
||||
### down
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig down <rig>` | — | `--delete`<br>`--force`<br>`--snapshot`<br>`--yes`<br>`--json`<br>`--host <id>` |
|
||||
| `rig down <rig>` | — | `--delete`<br>`--force`<br>`--snapshot`<br>`--json`<br>`--host <id>` |
|
||||
|
||||
### archive
|
||||
|
||||
@@ -354,31 +331,12 @@ the sections that were not read are `null`.
|
||||
| `rig host list` | `ls` | `--json` |
|
||||
| `rig host doctor <id>` | — | `--posture <profile>`<br>`--public-addr <ip>`<br>`--json` |
|
||||
|
||||
Registration makes a host visible in topology; remote FEED attention remains opt-in.
|
||||
The human-readable success output of `host add` and `host pair` now includes the FEED
|
||||
next step alongside the existing `host doctor` suggestion. For an HTTP entry named
|
||||
`laptop`, run this on the local instance to include its attention items:
|
||||
|
||||
```sh
|
||||
rig config set feed.subscriptions.laptop.enabled true
|
||||
```
|
||||
|
||||
The key uses the registry id, including any dots, and defaults to `false`. Registration
|
||||
does not change that setting. SSH entries instead explain that FEED needs an HTTP entry;
|
||||
ids reserved by feed settings (such as `progress`) get a different-id suggestion rather
|
||||
than an invalid command. `--json` keeps its single JSON result without these hints.
|
||||
The host CLI tests execute the advertised command against an isolated config and cover
|
||||
registration, reserved names and JSON output; pairing responses are simulated, so they
|
||||
do not establish connectivity to a live peer.
|
||||
|
||||
### ps
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig ps` | — | `--json`<br>`--no-cleanup`<br>`--resources`<br>`--nodes`<br>`--full`<br>`--verbose`<br>`--limit <n>`<br>`--fields <list>`<br>`--summary`<br>`--filter <key=value>`<br>`--active`<br>`--running`<br>`-A, --all-rigs`<br>`--rig <name>`<br>`--session <name>`<br>`--include-archived`<br>`--host <id>`<br>`--all-hosts`<br>`--hosts <ids>` |
|
||||
|
||||
- `--nodes --json` — each seat carries the daemon's served `needsAttention` flag; the CLI doesn't re-derive it, and it is absent when an older daemon doesn't serve it. `arbitratedNeedsInput` and `needsInputEvidence` (`{ rung, observedAt }` or null) come with `--full`, or by name through `--fields`.
|
||||
|
||||
### roster
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
@@ -393,20 +351,7 @@ do not establish connectivity to a live peer.
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig mcp` | — | — |
|
||||
| `rig mcp serve` | — | `--port <port>`<br>`--http`<br>`--bind <host:port>`<br>`--grant-service <url>`<br>`--grant-service-header-file <path>`<br>`--oauth-resource <url>`<br>`--oauth-issuer <url>` |
|
||||
| `rig mcp token` | — | — |
|
||||
| `rig mcp token add <name>` | — | `--seat <session>` **required**<br>`--rig <name...>`<br>`--root <name...>`<br>`--workspace`<br>`--owner` |
|
||||
| `rig mcp token list` | — | `--json` |
|
||||
| `rig mcp token revoke <name>` | — | — |
|
||||
|
||||
### acp
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig acp` | — | — |
|
||||
| `rig acp run <qitemId> <agent...>` | — | `--cwd <dir>`<br>`--permissions-to <session>`<br>`--load-session <id>`<br>`--poll-seconds <n>` |
|
||||
| `rig acp cancel <runId>` | — | — |
|
||||
| `rig acp stop <runId> [stopCommand...]` | — | — |
|
||||
| `rig mcp serve` | — | `--port <port>` |
|
||||
|
||||
### agent
|
||||
|
||||
@@ -435,7 +380,7 @@ do not establish connectivity to a live peer.
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig send [session] [text]` | — | `--to <sessions>` (repeatable)<br>`--pod <name>`<br>`--rig <name>`<br>`--verify`<br>`--force`<br>`--wait-for-idle <seconds>`<br>`--raw`<br>`--dangerously-interact`<br>`--type-without-checking`<br>`--reason <text>`<br>`--urgent`<br>`--host <id>`<br>`--from <session>`<br>`--context <ref>`<br>`--json` |
|
||||
| `rig send [session] [text]` | — | `--to <sessions>` (repeatable)<br>`--pod <name>`<br>`--rig <name>`<br>`--verify`<br>`--force`<br>`--wait-for-idle <seconds>`<br>`--raw`<br>`--dangerously-interact`<br>`--reason <text>`<br>`--host <id>`<br>`--from <session>`<br>`--context <ref>`<br>`--json` |
|
||||
|
||||
### stream
|
||||
|
||||
@@ -447,29 +392,24 @@ do not establish connectivity to a live peer.
|
||||
| `rig stream watch` | — | `--json` |
|
||||
| `rig stream show <streamItemId>` | — | `--json` |
|
||||
| `rig stream archive <streamItemId>` | — | `--json` |
|
||||
| `rig stream subscribe <readerSession>` | — | `--tags <tags>`<br>`--type <hintType>`<br>`--actor <session>`<br>`--json` |
|
||||
| `rig stream unsubscribe <readerSession>` | — | `--json` |
|
||||
| `rig stream subscriptions` | — | `--json` |
|
||||
| `rig stream read` | — | `--limit <n>`<br>`--reader <session>`<br>`--json` |
|
||||
| `rig stream act <streamItemId>` | — | `--work <qitemId>`<br>`--decline <reason>`<br>`--duplicate <id>`<br>`--reader <session>`<br>`--json` |
|
||||
|
||||
### queue
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig queue` | — | — |
|
||||
| `rig queue create` | — | `--source <session>`<br>`--destination <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--body-context <ref>`<br>`--mission <id>`<br>`--slice <id>`<br>`--gate <role>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--expires-at <iso>`<br>`--id <qitemId>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--human-intent <intent>`<br>`--alert`<br>`--human-detail-file <path>`<br>`--reply-to <qitemId>`<br>`--human-questions-file <path>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--verify`<br>`--full`<br>`--json` |
|
||||
| `rig queue create` | — | `--source <session>`<br>`--destination <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--body-context <ref>`<br>`--mission <id>`<br>`--slice <id>`<br>`--gate <role>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--expires-at <iso>`<br>`--id <qitemId>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--human-intent <intent>`<br>`--human-detail-file <path>`<br>`--reply-to <qitemId>`<br>`--human-questions-file <path>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--verify`<br>`--full`<br>`--json` |
|
||||
| `rig queue claim <qitemId>` | — | `--destination <session>`<br>`--full`<br>`--json` |
|
||||
| `rig queue unclaim <qitemId>` | — | `--destination <session>`<br>`--reason <text>`<br>`--full`<br>`--json` |
|
||||
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--full`<br>`--json` |
|
||||
| `rig queue block <qitemId>` | — | `--on <blocker>` **required**<br>`--actor <session>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--continuation <text>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--full`<br>`--json` |
|
||||
| `rig queue 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 handoff <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--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>`--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` |
|
||||
| `rig queue transitions <qitemId>` | — | `--json`<br>`--full` |
|
||||
| `rig queue transitions <qitemId>` | — | `--json` |
|
||||
| `rig queue list` | — | `-a, --all`<br>`-A, --all-rigs`<br>`--full`<br>`--owned`<br>`--mine`<br>`-o <format>`<br>`--destination <session>`<br>`--source <session>`<br>`--state <state>`<br>`--target-repo <name>`<br>`--limit <n>`<br>`--json` |
|
||||
| `rig queue overdue` | — | `--rig <name>`<br>`-A, --all-rigs`<br>`--full`<br>`--limit <n>`<br>`--json` |
|
||||
| `rig queue undelivered` | — | `--rig <name>`<br>`-A, --all-rigs`<br>`--full`<br>`--limit <n>`<br>`--json` |
|
||||
@@ -491,11 +431,10 @@ do not establish connectivity to a live peer.
|
||||
| `rig slack verify` | — | `--json` |
|
||||
| `rig slack channel-map` | — | — |
|
||||
| `rig slack channel-map list` | — | `--json` |
|
||||
| `rig slack channel-map set <match> <channel>` | — | `--inbound <seat>`<br>`--reason <reason>`<br>`--actor <actor>` |
|
||||
| `rig slack channel-map set <match> <channel>` | — | `--reason <reason>`<br>`--actor <actor>` |
|
||||
| `rig slack channel-map remove <match>` | — | `--reason <reason>`<br>`--actor <actor>` |
|
||||
| `rig slack enable` | — | `--reason <reason>`<br>`--actor <actor>` |
|
||||
| `rig slack disable` | — | `--reason <reason>` **required**<br>`--actor <actor>` |
|
||||
| `rig slack post-open-asks` | — | `--reason <reason>`<br>`--wait <seconds>`<br>`--actor <actor>`<br>`--json` |
|
||||
| `rig slack outbound` | — | `--json` |
|
||||
| `rig slack inbound` | — | — |
|
||||
|
||||
@@ -509,10 +448,8 @@ do not establish connectivity to a live peer.
|
||||
| `rig project experimental disable` | — | `--config <file>` **required**<br>`--max-requests <n>`<br>`--timeout-ms <n>`<br>`--json` |
|
||||
| `rig project experimental status` | — | `--config <file>` **required**<br>`--max-requests <n>`<br>`--timeout-ms <n>`<br>`--json` |
|
||||
| `rig project experimental capture` | — | `--config <file>` **required**<br>`--input <file>` **required**<br>`--output <file>` **required**<br>`--json` |
|
||||
| `rig project decisions` | — | — |
|
||||
| `rig project decisions status` | — | `--json` |
|
||||
| `rig project candidates` | — | `--project <id>` **required**<br>`--taxonomy <file>` **required**<br>`--classifier-version <version>` **required**<br>`--evidence-epoch <epoch>` **required**<br>`--decisions <file>`<br>`--experiment <file>`<br>`--decide`<br>`--limit <count>`<br>`--json` |
|
||||
| `rig project wake` | — | `--project <id>` **required**<br>`--taxonomy <file>` **required**<br>`--classifier-version <version>` **required**<br>`--evidence-epoch <epoch>` **required**<br>`--decisions <file>`<br>`--experiment <file>`<br>`--decide`<br>`--limit <count>`<br>`--json` |
|
||||
| `rig project candidates` | — | `--project <id>` **required**<br>`--taxonomy <file>` **required**<br>`--classifier-version <version>` **required**<br>`--evidence-epoch <epoch>` **required**<br>`--decisions <file>`<br>`--experiment <file>`<br>`--limit <count>`<br>`--json` |
|
||||
| `rig project wake` | — | `--project <id>` **required**<br>`--taxonomy <file>` **required**<br>`--classifier-version <version>` **required**<br>`--evidence-epoch <epoch>` **required**<br>`--decisions <file>`<br>`--experiment <file>`<br>`--limit <count>`<br>`--json` |
|
||||
| `rig project shadow-status` | — | — |
|
||||
| `rig project shadow-drain` | — | `--actor <name>` |
|
||||
| `rig project shadow-stop` | — | `--actor <name>` |
|
||||
@@ -538,24 +475,20 @@ do not establish connectivity to a live peer.
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig terminal` | — | — |
|
||||
| `rig terminal open <view>` | — | `--provider <name>`<br>`--session <name>`<br>`--window`<br>`--expected-plan <id>`<br>`--json` |
|
||||
| `rig terminal open <view>` | — | `--provider <name>`<br>`--window`<br>`--expected-plan <id>`<br>`--json` |
|
||||
| `rig terminal views` | — | `--json` |
|
||||
| `rig terminal status` | — | `--provider <name>`<br>`--session <name>`<br>`--json` |
|
||||
|
||||
See [named terminal sessions](../reference/terminal-sessions.md) to select a
|
||||
Herdr session per request, including session-bound previews and window reuse.
|
||||
| `rig terminal status` | — | `--provider <name>`<br>`--json` |
|
||||
|
||||
### watchdog
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig watchdog` | — | — |
|
||||
| `rig watchdog register` | — | `--spec <path>`<br>`--policy <policy>` **required**<br>`--target-session <session>` **required**<br>`--interval-seconds <n>` **required**<br>`--registered-by <session>` **required**<br>`--threshold-bytes <n>`<br>`--threshold-mb <n>`<br>`--threshold-tokens <n>`<br>`--threshold-percent <n>`<br>`--watched-file <path>`<br>`--requires-job <jobId>`<br>`--message <text>`<br>`--active-wake-interval-seconds <n>`<br>`--scan-interval-seconds <n>`<br>`--json` |
|
||||
| `rig watchdog register` | — | `--spec <path>`<br>`--policy <policy>` **required**<br>`--target-session <session>` **required**<br>`--interval-seconds <n>` **required**<br>`--registered-by <session>` **required**<br>`--threshold-bytes <n>`<br>`--threshold-mb <n>`<br>`--watched-file <path>`<br>`--requires-job <jobId>`<br>`--message <text>`<br>`--active-wake-interval-seconds <n>`<br>`--scan-interval-seconds <n>`<br>`--json` |
|
||||
| `rig watchdog list` | — | `-a, --all`<br>`--full`<br>`--limit <n>`<br>`--json` |
|
||||
| `rig watchdog show <jobId>` | — | `--json` |
|
||||
| `rig watchdog status <jobId>` | — | `--json` |
|
||||
| `rig watchdog stop <jobId>` | — | `--reason <text>`<br>`--json` |
|
||||
| `rig watchdog update <jobId>` | — | `--interval-seconds <n>`<br>`--message <text>`<br>`--spec <path>`<br>`--json` |
|
||||
|
||||
### workflow
|
||||
|
||||
@@ -568,7 +501,7 @@ Herdr session per request, including session-bound previews and window reuse.
|
||||
| `rig workflow operation <key>` | — | `--json` |
|
||||
| `rig workflow instantiate-lifecycle <missionPath>` | — | `--operation-key <key>` **required**<br>`--root-objective <text>` **required**<br>`--created-by <session>` **required**<br>`--entry-owner <session>`<br>`--rig <name>`<br>`--json` |
|
||||
| `rig workflow instantiate <specPath>` | — | `--root-objective <text>` **required**<br>`--created-by <session>` **required**<br>`--entry-owner <session>`<br>`--rig <name>`<br>`--project <id>`<br>`--mission <id>`<br>`--slice <id>`<br>`--tags <tags>`<br>`--json` |
|
||||
| `rig workflow project` | — | `--instance <id>` **required**<br>`--current-packet <qitem-id>` **required**<br>`--exit <kind>` **required**<br>`--actor-session <session>` (only when the caller's session can't be derived)<br>`--result-note <text>`<br>`--evidence-ref <ref>`<br>`--wait-for-proof <scope>`<br>`--blocked-on <ref>`<br>`--next-owner <session>`<br>`--acceptance-candidate <identity>`<br>`--acceptance-verdict <verdict>`<br>`--acceptance-evidence-ref <path>`<br>`--json` |
|
||||
| `rig workflow project` | — | `--instance <id>` **required**<br>`--current-packet <qitem-id>` **required**<br>`--exit <kind>` **required**<br>`--actor-session <session>` **required**<br>`--result-note <text>`<br>`--evidence-ref <ref>`<br>`--wait-for-proof <scope>`<br>`--blocked-on <ref>`<br>`--next-owner <session>`<br>`--acceptance-candidate <identity>`<br>`--acceptance-verdict <verdict>`<br>`--acceptance-evidence-ref <path>`<br>`--json` |
|
||||
| `rig workflow list` | — | `--status <s>`<br>`--json` |
|
||||
| `rig workflow specs` | — | `--json` |
|
||||
| `rig workflow guidance <instanceId>` | — | `--packet <id>`<br>`--component <id>`<br>`--full`<br>`--json` |
|
||||
@@ -577,16 +510,16 @@ Herdr session per request, including session-bound previews and window reuse.
|
||||
| `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 watch <instanceId>` | — | `--json` |
|
||||
| `rig workflow route <instanceId>` | — | `--to <session>` **required**<br>`--actor-session <session>` (only when the caller's session can't be derived)<br>`--packet <qitem-id>`<br>`--reason <text>`<br>`--json` |
|
||||
| `rig workflow resume <instanceId>` | — | `--actor-session <session>` (only when the caller's session can't be derived)<br>`--occurrence <failed-qitem-id>`<br>`--decision <text>`<br>`--json` |
|
||||
| `rig workflow abort <instanceId>` | — | `--reason <text>` **required**<br>`--actor-session <session>` (only when the caller's session can't be derived)<br>`--json` |
|
||||
| `rig workflow route <instanceId>` | — | `--to <session>` **required**<br>`--actor-session <session>` **required**<br>`--packet <qitem-id>`<br>`--reason <text>`<br>`--json` |
|
||||
| `rig workflow resume <instanceId>` | — | `--actor-session <session>` **required**<br>`--occurrence <failed-qitem-id>`<br>`--decision <text>`<br>`--json` |
|
||||
| `rig workflow abort <instanceId>` | — | `--reason <text>` **required**<br>`--actor-session <session>` **required**<br>`--json` |
|
||||
| `rig workflow status` | — | `--json` |
|
||||
|
||||
### capture
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig capture [session]` | — | `--rig <name>`<br>`--pod <name>`<br>`--lines <n>`<br>`--history-plus-pane`<br>`--readable`<br>`--size <WxH>`<br>`--host <id>`<br>`--json` |
|
||||
| `rig capture [session]` | — | `--rig <name>`<br>`--pod <name>`<br>`--lines <n>`<br>`--host <id>`<br>`--json` |
|
||||
|
||||
### broadcast
|
||||
|
||||
@@ -672,7 +605,7 @@ Only Claude and Codex tenures can be woken.
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig skill` | — | — |
|
||||
| `rig skill loadout` | — | `--runtime <runtime>` (folder mode; **required** there)<br>`--seat <session>` (seat mode; `self` is this seat)<br>`--wait-for-idle <ms>`<br>`--resend`<br>`--cwd <path>`<br>`--project-root <path>`<br>`--topology <ids>`<br>`--apply`<br>`--json` |
|
||||
| `rig skill loadout` | — | `--runtime <runtime>` **required**<br>`--cwd <path>`<br>`--project-root <path>`<br>`--topology <ids>`<br>`--apply`<br>`--json` |
|
||||
| `rig skill audit` | — | `--json` |
|
||||
|
||||
### agent-image
|
||||
@@ -690,18 +623,11 @@ Only Claude and Codex tenures can be woken.
|
||||
| `rig agent-image prune` | — | `--dry-run`<br>`--force`<br>`--json` |
|
||||
| `rig agent-image sync` | — | `--json` |
|
||||
|
||||
### community-harness
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig community-harness` | — | — |
|
||||
| `rig community-harness list` | — | `--json` |
|
||||
|
||||
### fork
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig fork <source-session>` | — | `--rig <rig>` **required**<br>`--pod <pod-namespace>` **required**<br>`--member <member-id>` **required**<br>`--keep-image`<br>`--image-name <name>`<br>`--image-version <version>`<br>`--rig-root <path>`<br>`--json` |
|
||||
| `rig fork <source-session>` | — | `--rig <rig-id>` **required**<br>`--pod <pod-namespace>` **required**<br>`--member <member-id>` **required**<br>`--keep-image`<br>`--image-name <name>`<br>`--image-version <version>`<br>`--rig-root <path>`<br>`--json` |
|
||||
|
||||
### workspace
|
||||
|
||||
@@ -720,7 +646,7 @@ Only Claude and Codex tenures can be woken.
|
||||
| `rig mode show` | — | `--json` |
|
||||
| `rig mode effective` | — | `--rig <id>`<br>`--project <id>`<br>`--mission <id>`<br>`--workstream <id>`<br>`--qitem <id>`<br>`--json` |
|
||||
| `rig mode cite` | — | `--rig <id>`<br>`--project <id>`<br>`--mission <id>`<br>`--workstream <id>`<br>`--qitem <id>` |
|
||||
| `rig mode unset <scope> [qualifier]` | — | `--bearer <token>`<br>`--category <category>`<br>`--json` |
|
||||
| `rig mode unset <scope> [qualifier]` | — | `--bearer <token>`<br>`--json` |
|
||||
| `rig mode defaults` | — | `--json` |
|
||||
|
||||
### policy
|
||||
@@ -742,7 +668,7 @@ Only Claude and Codex tenures can be woken.
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig whoami` | — | `--node-id <id>`<br>`--session <name>`<br>`--host <id>`<br>`--all-hosts`<br>`--hosts <ids>`<br>`--json`<br>`--full`<br>`--verbose`<br>`--all-peers` |
|
||||
| `rig whoami` | — | `--node-id <id>`<br>`--session <name>`<br>`--host <id>`<br>`--all-hosts`<br>`--hosts <ids>`<br>`--json`<br>`--full`<br>`--verbose` |
|
||||
|
||||
### config
|
||||
|
||||
@@ -774,10 +700,10 @@ Only Claude and Codex tenures can be woken.
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig auth` | — | — |
|
||||
| `rig auth status` | — | `--runtime <runtime>`<br>`--codex-home <dir>`<br>`--host <id>` |
|
||||
| `rig auth list` | — | `--runtime <runtime>`<br>`--codex-home <dir>`<br>`--host <id>` |
|
||||
| `rig auth status` | — | `--runtime <runtime>` |
|
||||
| `rig auth list` | — | `--runtime <runtime>` |
|
||||
| `rig auth save <profile>` | — | `--runtime <runtime>` |
|
||||
| `rig auth switch <profile>` | — | `--runtime <runtime>`<br>`--codex-home <dir>`<br>`--host <id>` |
|
||||
| `rig auth switch <profile>` | — | `--runtime <runtime>` |
|
||||
| `rig auth validate <profile>` | — | `--runtime <runtime>` |
|
||||
| `rig auth seats` | — | — |
|
||||
| `rig auth seats list` | — | `--runtime <runtime>` |
|
||||
@@ -790,14 +716,12 @@ Only Claude and Codex tenures can be woken.
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig provider` | — | — |
|
||||
| `rig provider status` | — | `--json`<br>`--refresh`<br>`--provider <p>`<br>`--claude-token-file <path>`<br>`--credential-label <label>`<br>`--all-hosts`<br>`--hosts <ids>` |
|
||||
| `rig provider status` | — | `--json`<br>`--refresh`<br>`--provider <p>` |
|
||||
| `rig provider accounts` | — | `--json`<br>`--provider <p>`<br>`--account <a>` |
|
||||
| `rig provider bindings` | — | `--json`<br>`--provider <p>`<br>`--account <a>` |
|
||||
| `rig provider signals` | — | `--json`<br>`--provider <p>`<br>`--account <a>` |
|
||||
| `rig provider precheck` | — | `--seat <s>` **required**<br>`--to-account <a>`<br>`--token-file <path>`<br>`--stored-login`<br>`--json` |
|
||||
| `rig provider switch` | — | `--seat <s>` **required**<br>`--to-account <a>`<br>`--token-file <path>`<br>`--stored-login`<br>`--force-unsafe`<br>`--json` |
|
||||
| `rig provider moves` | — | `--seat <seat>`<br>`--limit <n>`<br>`--json` |
|
||||
| `rig provider moves report` | — | `--file <path>` **required**<br>`--to <seat>` **required**<br>`--follows <qitemId>`<br>`--evidence-ref <ref>`<br>`--json` |
|
||||
| `rig provider precheck` | — | `--seat <s>` **required**<br>`--to-account <a>` **required**<br>`--json` |
|
||||
| `rig provider switch` | — | `--seat <s>` **required**<br>`--to-account <a>` **required**<br>`--force-unsafe`<br>`--json` |
|
||||
|
||||
### usage
|
||||
|
||||
@@ -805,7 +729,6 @@ Only Claude and Codex tenures can be woken.
|
||||
|---|---|---|
|
||||
| `rig usage` | — | — |
|
||||
| `rig usage top` | — | `--window <duration>`<br>`--top <n>`<br>`--json` |
|
||||
| `rig usage cost` | — | `--seat <session>`<br>`--json` |
|
||||
| `rig usage series` | — | `--seat <session>`<br>`--lane <lane>`<br>`--since <iso>`<br>`--until <iso>`<br>`--limit <n>`<br>`--json` |
|
||||
|
||||
### telemetry
|
||||
@@ -862,20 +785,6 @@ Only Claude and Codex tenures can be woken.
|
||||
|---|---|---|
|
||||
| `rig grow <rig-id> <members...>` | — | `--pod <pod>`<br>`--new-pod <pod>`<br>`--runtime <runtime>`<br>`--cwd <path>`<br>`--json` |
|
||||
|
||||
### edge
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig edge` | — | — |
|
||||
| `rig edge add <rig> [from] [to]` | — | `--kind <kind>`<br>`--plan`<br>`--json` |
|
||||
| `rig edge remove <rig> <edge-id>` | — | `--plan`<br>`--json` |
|
||||
|
||||
`add` requires `from`, `to` and `--kind`, and is idempotent: a repeat reports the
|
||||
existing edge and writes nothing. `remove` deletes the edge with that ID; a repeat changes nothing and
|
||||
returns `edge_not_found` (exit 1). See
|
||||
[changing edges on an existing rig](../reference/rig-spec.md#changing-edges-on-an-existing-rig)
|
||||
for input and receipt examples, rollback, and snapshot behavior.
|
||||
|
||||
### reconcile-session
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
@@ -931,7 +840,7 @@ for input and receipt examples, rollback, and snapshot behavior.
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig setup` | — | `--dry-run`<br>`--json`<br>`--full`<br>`--no-herdr`<br>`--ghostty`<br>`--no-ghostty`<br>`--providers <list>`<br>`--policy <name>`<br>`--spec <path>`<br>`--router-line <answer>` |
|
||||
| `rig setup` | — | `--dry-run`<br>`--json`<br>`--full`<br>`--no-herdr`<br>`--ghostty`<br>`--no-ghostty`<br>`--providers <list>`<br>`--policy <name>`<br>`--spec <path>` |
|
||||
|
||||
### restore-check
|
||||
|
||||
@@ -954,8 +863,6 @@ for input and receipt examples, rollback, and snapshot behavior.
|
||||
|---|---|---|
|
||||
| `rig compact-plan` | — | `--json`<br>`--rig <name>`<br>`--refresh`<br>`--threshold-tokens <n>`<br>`--threshold-percent <0-100>` |
|
||||
|
||||
- `--threshold-tokens` and `--threshold-percent` set the plan's own candidate thresholds (the percentage applies when a seat's context window size is missing). They are not the automatic compaction setting: the output labels them as the plan's own and shows `policies.claude_compaction` (`enabled`, `threshold_percent`) separately, for comparison only, or says it couldn't be read.
|
||||
|
||||
### compact
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
@@ -973,7 +880,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` |
|
||||
@@ -981,15 +887,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 effort <seat>` | — | `--json` |
|
||||
| `rig seat set-permissions <seat>` | — | `--mode <mode>` **required**<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
|
||||
| `rig seat set-model <seat>` | — | `--model <id>` **required**<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
|
||||
| `rig seat set-runtime <seat>` | — | `--runtime <runtime>`<br>`--reason <text>`<br>`--model <id>`<br>`--effort <level>`<br>`--codex-config-profile <name>`<br>`--permission-mode <mode>`<br>`--confirm-timeout <seconds>`<br>`--dry-run`<br>`--confirm <switch-id>`<br>`--note <text>`<br>`--recover`<br>`--json` |
|
||||
| `rig seat set-cwd <seat>` | — | `--cwd <directory>` **required**<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
|
||||
| `rig seat launch <seat>` | — | `--fresh` **required**<br>`--reason <text>`<br>`--plan`<br>`--stop`<br>`--operator <address>`<br>`--json` |
|
||||
| `rig seat restart <seat>` | — | `--keep-conversation` **required**<br>`--reason <text>`<br>`--plan`<br>`--verify`<br>`--expect-account <text>`<br>`--operator <address>`<br>`--host <id>`<br>`--json` |
|
||||
| `rig seat launch <seat>` | — | `--fresh` **required**<br>`--reason <text>` **required**<br>`--stop`<br>`--operator <address>`<br>`--json` |
|
||||
| `rig seat continue <seat>` | — | `--json` |
|
||||
| `rig seat stop <seat>` | — | `--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
|
||||
| `rig seat clean <seat>` | — | `--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
|
||||
@@ -1072,6 +973,6 @@ off, draft-aware and hold modes, immediate refusal results and held-message insp
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig proof` | — | `--workspace <path>` |
|
||||
| `rig proof show [scope]` | — | `--project <id>`<br>`--json` |
|
||||
| `rig proof judge <scope-item>` | — | `--project <id>`<br>`--verdict <verdict>` **required**<br>`--reason <text>` **required**<br>`--evidence <ref>` (repeatable)<br>`--subject <kind:ref>`<br>`--comparison <ref>`<br>`--revision <revision>`<br>`--operation-id <id>`<br>`--replace`<br>`--json` |
|
||||
| `rig proof show [scope]` | — | `--json` |
|
||||
| `rig proof judge <scope-item>` | — | `--verdict <verdict>` **required**<br>`--reason <text>` **required**<br>`--evidence <ref>` (repeatable)<br>`--subject <kind:ref>`<br>`--comparison <ref>`<br>`--revision <revision>`<br>`--operation-id <id>`<br>`--replace`<br>`--json` |
|
||||
| `rig proof add <slice-path>` | — | `--mission <name>`<br>`--artifact-type <type>` **required**<br>`--verdict <verdict>` **required**<br>`--candidate-sha <sha>` **required**<br>`--money-evidence <line>` **required**<br>`--slice-id <dot-id>`<br>`--file <path>`<br>`--body <text>`<br>`--name <filename>`<br>`--replace`<br>`--evidences <refs>`<br>`--self-check <text>`<br>`--media <refs>`<br>`--json` |
|
||||
@@ -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` |
|
||||
@@ -190,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:
|
||||
|
||||
@@ -205,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
|
||||
@@ -278,8 +258,8 @@ 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. |
|
||||
| Skill projection | The stub projects skills into `<cwd>/.openrig/stub/skills/`. Claude Code projects a team seat's skills into the seat's generated plugin in OpenRig's state, and into `<cwd>/.claude/skills/` only for a seat without one. | A stub green says nothing about provider-specific projection (the class behind #159). |
|
||||
| Step-time restore, compaction, mutation, policy | `restore`, `emit`, `mutate` and `policy` parse, but the runner throws `UnboundActionError` when it reaches them. Behaviours run only from a per-seat launch script (`env.stub_scripts`). A seat `restart` step maps to `rig launch`, which is not native resume or fork. | Step-time compaction and restore, and native resume and fork, are not covered. |
|
||||
| Skill projection | The stub projects skills into `<cwd>/.openrig/stub/skills/`. Claude Code projects into `<cwd>/.claude/skills/`. | A stub green says nothing about provider-specific projection (the class behind #159). |
|
||||
| Model and provider | No model, no tokens. Stub scripts refuse `usage_limit` because it only exists on real runtimes. | Model behaviour, provider usage limits and real context usage need live evals or a real runtime. |
|
||||
| Platform | CI's scenario job runs in a Linux container. | macOS process behaviour is covered by the macOS `package-tests` jobs, not by scenarios. |
|
||||
|
||||
@@ -299,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 |
|
||||
|---|---|---|
|
||||
@@ -318,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
|
||||
@@ -347,19 +324,6 @@ above remain pending: pane rendering does not prove input consumption, durable
|
||||
stream replay does not prove live subscription, and this test does not exercise
|
||||
native providers, restore or policy changes.
|
||||
|
||||
`packages/daemon/test/rig-edge-cli.integration.test.ts` uses the same private
|
||||
scenario daemon and tmux helpers for edge repair. It removes a stub seat, re-adds
|
||||
its logical ID with the terminal runtime, then restores its cross-pod edges with one
|
||||
`rig edge add` per edge. It checks repeat IDs and edge counts, refusal without a
|
||||
write, unchanged sibling node/binding identities and pane PIDs, export,
|
||||
snapshot capture and exact rollback. It models the cross-runtime replacement
|
||||
workaround without invoking Claude Code or Codex. The YAML runner has no bound
|
||||
edge/remove/add verbs, so this journey drives the real CLI directly:
|
||||
|
||||
```sh
|
||||
npm test -w @openrig/daemon -- test/rig-edge-cli.integration.test.ts
|
||||
```
|
||||
|
||||
## If your change touches an artery, add or extend a scenario (or say why not)
|
||||
|
||||
[arteries.md](arteries.md) lists the behaviours that everything else depends on. If your
|
||||
@@ -464,12 +428,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:
|
||||
|
||||
@@ -592,6 +552,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
|
||||
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
# Handing work between OpenRig and A2A agents
|
||||
|
||||
An OpenRig seat can give a queue row's task to an outside A2A agent. In the other direction, an A2A server can
|
||||
front a seat's queue and return its answer as an artifact. These are compositions of the
|
||||
[official Python SDK](https://github.com/a2aproject/a2a-python) and existing `rig queue` commands, not a
|
||||
`rig a2a` command or a daemon endpoint. The scripts below are examples you run and own.
|
||||
|
||||
OpenRig does not configure the outside agent's permissions, model or credentials. Choose a peer and task content
|
||||
you are authorized to share. The peer's result is its report, not acceptance of the work by your team.
|
||||
|
||||
## Before either recipe
|
||||
|
||||
Use a working OpenRig installation and an existing seat. `rig whoami --json` identifies your seat;
|
||||
`rig ps --nodes --rig <rig>` shows the receiving seat's canonical session name. If you need a team first, use
|
||||
[getting started](getting-started.md). Keep the daemon and provider environment you already use.
|
||||
|
||||
In a scratch folder, make a Python environment (Python 3.10 or newer, with `venv` and `pip` available):
|
||||
|
||||
```sh
|
||||
python3 -m venv .venv
|
||||
.venv/bin/python -m pip install 'a2a-sdk[http-server]==1.1.0' httpx uvicorn
|
||||
```
|
||||
|
||||
The examples pin the SDK version used in the pipe trials; they are not a recommendation to downgrade another
|
||||
application. The official SDK 1.1.0 paths were exercised in both directions. Other 1.x versions were not tested.
|
||||
Recheck the API and cancellation behavior when changing versions.
|
||||
|
||||
Pydantic AI 2.60.0 with FastA2A 2.1.1 did not interoperate with these SDK 1.1.0 recipes in the tested setup:
|
||||
FastA2A's inbound typed client returned `result={}` even though the SDK front, with 0.3 compatibility enabled,
|
||||
sent task, artifact and completed records in its raw SSE stream. Outbound, the official SDK raised a protobuf
|
||||
parse error at the FastA2A task's `status.timestamp`; the full wire timestamp was not retained. When mixing
|
||||
frameworks, compare the raw stream's task IDs, artifact and final status with the typed client's output.
|
||||
An HTTP 200 or an empty typed result is not proof that the task succeeded. Retain the evidence before retrying.
|
||||
|
||||
Save this helper as `queue_io.py`:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
async def rig(*args):
|
||||
child = await asyncio.create_subprocess_exec(
|
||||
'rig', *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||||
out, err = await child.communicate()
|
||||
if child.returncode:
|
||||
raise RuntimeError(f'rig {args[:2]} exited {child.returncode}: {err.decode()} {out.decode()}')
|
||||
return json.loads(out)
|
||||
```
|
||||
|
||||
It inherits the caller's OpenRig environment. Run queue operations in the intended seat's environment; set
|
||||
`OPENRIG_URL` only when deliberately selecting another daemon. A timed-out write is uncertain: read its row and
|
||||
transitions before trying again. Do not silently send the task a second time.
|
||||
|
||||
## Outbound: a queue task goes to an A2A peer
|
||||
|
||||
You need the peer's base URL and a row assigned to your seat. For a no-model peer, the official
|
||||
[Hello World sample](https://github.com/a2aproject/a2a-samples/tree/6603ba3f2c31a7ef33e70b9d8b5b5f8be42ac9a3/samples/python/agents/helloworld)
|
||||
returns an echo artifact. Fetch that revision:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/a2aproject/a2a-samples.git
|
||||
git -C a2a-samples checkout --detach 6603ba3f2c31a7ef33e70b9d8b5b5f8be42ac9a3
|
||||
```
|
||||
|
||||
Start the peer yourself in a separate terminal, following `a2a-samples/samples/python/agents/helloworld/README.md`.
|
||||
Its default URL is `http://127.0.0.1:9999`, used below; it is available only while you run the sample.
|
||||
The sample does not implement the input-required and cancellation controls described later. Use a peer that
|
||||
supports those operations when testing them.
|
||||
|
||||
Save as `a2a_client.py`. This client also works without an OpenRig row, for the inbound recipe below.
|
||||
Streaming reads have no deadline so quiet worker waits keep their stream; connection, write and pool waits are bounded to 10 seconds.
|
||||
Stop the local wait with Ctrl-C or a caller-owned overall deadline; this does not confirm cancellation, so keep the task/context IDs and cancel through the peer when it is reachable.
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
import httpx
|
||||
from a2a.client import A2ACardResolver, ClientConfig, create_client
|
||||
from a2a.helpers import new_text_message
|
||||
from a2a.types import Role, SendMessageRequest
|
||||
from google.protobuf.json_format import MessageToDict
|
||||
from queue_io import rig
|
||||
|
||||
async def main():
|
||||
row_id = os.environ.get('A2A_ROW')
|
||||
text = os.environ.get('A2A_TEXT')
|
||||
if row_id:
|
||||
row = await rig('queue', 'show', row_id, '--full', '--json')
|
||||
identity = await rig('whoami', '--json')
|
||||
if row['destinationSession'] != identity['identity']['sessionName']:
|
||||
raise RuntimeError('Choose a row assigned to this seat.')
|
||||
if row['state'] == 'pending':
|
||||
await rig('queue', 'claim', row_id, '--json')
|
||||
row = await rig('queue', 'show', row_id, '--full', '--json')
|
||||
if row['state'] != 'in-progress':
|
||||
raise RuntimeError('The row is not in progress; inspect it before sending.')
|
||||
if text is None:
|
||||
text = row['body']
|
||||
if text is None:
|
||||
raise RuntimeError('Set A2A_TEXT or A2A_ROW.')
|
||||
message = new_text_message(text, role=Role.ROLE_USER)
|
||||
if os.environ.get('A2A_CONTEXT'):
|
||||
message.context_id = os.environ['A2A_CONTEXT']
|
||||
if os.environ.get('A2A_TASK'):
|
||||
message.task_id = os.environ['A2A_TASK']
|
||||
log_path = Path(f'a2a-{uuid.uuid4()}.jsonl').resolve()
|
||||
state, bound = 'UNKNOWN', False
|
||||
with log_path.open('x') as log:
|
||||
async with httpx.AsyncClient() as http:
|
||||
card = await A2ACardResolver(httpx_client=http, base_url=os.environ['A2A_URL']).get_agent_card()
|
||||
log.write(json.dumps({'card': MessageToDict(card), 'message': MessageToDict(message)}) + '\n')
|
||||
log.flush()
|
||||
client = await create_client(agent=card, client_config=ClientConfig(
|
||||
streaming=True, httpx_client=httpx.AsyncClient(timeout=httpx.Timeout(10.0, read=None))))
|
||||
try:
|
||||
async for chunk in client.send_message(SendMessageRequest(message=message)):
|
||||
event = MessageToDict(chunk) # SDK 1.1.0 yields StreamResponse, not a tuple.
|
||||
log.write(json.dumps(event) + '\n')
|
||||
log.flush()
|
||||
print(json.dumps(event), flush=True)
|
||||
task = event.get('task')
|
||||
if task and row_id and not bound:
|
||||
await rig('queue', 'update', row_id, '--note',
|
||||
f"A2A peer={card.name} task={task['id']} context={task['contextId']} log={log_path}", '--json')
|
||||
bound = True
|
||||
status = (task or event.get('statusUpdate') or {}).get('status', {})
|
||||
state = status.get('state', state)
|
||||
finally:
|
||||
await client.close()
|
||||
if row_id:
|
||||
await rig('queue', 'update', row_id, '--note', f'A2A reported {state}; full result/events: {log_path}', '--json')
|
||||
print(f'Reported state: {state}; saved events: {log_path}')
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
When `A2A_TEXT` is unset, the row body becomes the peer's message verbatim. Write the body as the peer's input,
|
||||
or set `A2A_TEXT` to the exact text you want to send. Run it from your seat, replacing the row and peer URL:
|
||||
|
||||
```sh
|
||||
export A2A_URL=http://127.0.0.1:9999
|
||||
export A2A_ROW=<your-row-id>
|
||||
.venv/bin/python a2a_client.py
|
||||
```
|
||||
|
||||
Read the saved `artifactUpdate` parts and final status. Put the useful result on the **same** row, using a note
|
||||
while work continues or a resolution when you deliberately close it. The script leaves the row owned and open:
|
||||
|
||||
```sh
|
||||
rig queue update "$A2A_ROW" --note 'A2A result: <answer, artifact reference, and any limits>' --json
|
||||
# Only when your work on the row is actually finished:
|
||||
rig queue update "$A2A_ROW" --state done --closure-reason no-follow-on --resolution '<your assessed outcome>' --json
|
||||
```
|
||||
|
||||
If the stream fails, retain its exception and log. The peer may still be working; inspect the known task before
|
||||
resending. When the task asks for input, set `A2A_TASK` and `A2A_CONTEXT` to its recorded IDs and `A2A_TEXT` to the
|
||||
answer, then run the client again. For a new follow-up, unset `A2A_TASK` and keep `A2A_CONTEXT`: a completed task
|
||||
is not reopened. Unset `A2A_TEXT` when the next outbound task should again use its row body.
|
||||
|
||||
## Inbound: an A2A client reaches an OpenRig seat
|
||||
|
||||
Run the following server in a custodian seat's environment, with `A2A_SEAT` set to the existing worker seat's
|
||||
canonical session name. The worker is a separate participant: it watches its queue, claims the row, does the work
|
||||
and writes a resolution. A deterministic shell worker is enough to test this path; a model is optional.
|
||||
|
||||
Save as `a2a_front.py` beside `queue_io.py`:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import uvicorn
|
||||
from starlette.applications import Starlette
|
||||
from a2a.helpers import get_message_text, new_task_from_user_message, new_text_message, new_text_part
|
||||
from a2a.server.agent_execution import AgentExecutor
|
||||
from a2a.server.request_handlers import DefaultRequestHandler
|
||||
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
|
||||
from a2a.server.tasks import InMemoryTaskStore, TaskUpdater
|
||||
from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill
|
||||
from queue_io import rig
|
||||
|
||||
seat = os.environ['A2A_SEAT']
|
||||
rows, canceling, published = {}, set(), {}
|
||||
|
||||
class Front(AgentExecutor):
|
||||
async def execute(self, context, events):
|
||||
task = context.current_task or new_task_from_user_message(context.message)
|
||||
if not context.current_task:
|
||||
await events.enqueue_event(task)
|
||||
update = TaskUpdater(events, task.id, task.context_id)
|
||||
published[task.id] = asyncio.Event()
|
||||
try:
|
||||
text = get_message_text(context.message)
|
||||
if task.id not in rows:
|
||||
row_id = 'qitem-a2a-' + task.id
|
||||
body = json.dumps({'taskId': task.id, 'contextId': task.context_id, 'request': text})
|
||||
await rig('queue', 'create', '--id', row_id, '--destination', seat, '--body', body,
|
||||
'--summary', 'Inbound A2A task', '--no-nudge', '--full', '--json')
|
||||
rows[task.id] = row_id
|
||||
else:
|
||||
row_id = rows[task.id]
|
||||
await rig('queue', 'update', row_id, '--note', 'A2A answer: ' + text, '--json')
|
||||
working = False
|
||||
while True:
|
||||
row = await rig('queue', 'show', row_id, '--full', '--json')
|
||||
if task.id in canceling:
|
||||
await asyncio.Future() # cancel() owns the terminal event.
|
||||
if row['state'] == 'done':
|
||||
if not row.get('resolution'):
|
||||
await update.failed(new_text_message('Row closed without a result resolution.'))
|
||||
else:
|
||||
await update.add_artifact([new_text_part(row['resolution'])], name='seat-result')
|
||||
await update.complete(new_text_message('Seat closed row ' + row_id))
|
||||
return
|
||||
if row['state'] in ('failed', 'denied', 'handed-off'):
|
||||
await update.failed(new_text_message(row.get('resolution') or ('Row ' + row['state'])))
|
||||
return
|
||||
if row['state'] == 'canceled':
|
||||
await update.cancel(new_text_message('Queue row canceled.'))
|
||||
return
|
||||
if row['state'] == 'in-progress' and not working:
|
||||
await update.start_work(new_text_message('Seat claimed ' + row_id))
|
||||
working = True
|
||||
history = await rig('queue', 'transitions', row_id, '--full', '--json')
|
||||
notes = [t.get('transitionNote') or '' for t in history]
|
||||
conversation = [n for n in notes if n.startswith(('A2A question: ', 'A2A answer: '))]
|
||||
if conversation and conversation[-1].startswith('A2A question: '):
|
||||
await update.requires_input(new_text_message(conversation[-1][14:]))
|
||||
return
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
if task.id in canceling:
|
||||
await published[task.id].wait() # See SDK 1.1.0 cancellation note below.
|
||||
raise
|
||||
except Exception as error:
|
||||
await update.failed(new_text_message(str(error)))
|
||||
|
||||
async def cancel(self, context, events):
|
||||
canceling.add(context.task_id)
|
||||
try:
|
||||
row_id = rows[context.task_id]
|
||||
await rig('queue', 'update', row_id, '--state', 'canceled', '--resolution',
|
||||
'A2A client requested cancellation; worker stop not yet confirmed.', '--json')
|
||||
await TaskUpdater(events, context.task_id, context.context_id).cancel(
|
||||
new_text_message('Queue cancellation committed; worker stop is a separate observation.'))
|
||||
finally:
|
||||
published[context.task_id].set()
|
||||
|
||||
card = AgentCard(name='OpenRig queue: ' + seat, version='example-1',
|
||||
description='A worker seat returns its queue resolution.',
|
||||
default_input_modes=['text/plain'], default_output_modes=['text/plain'],
|
||||
capabilities=AgentCapabilities(streaming=True),
|
||||
supported_interfaces=[AgentInterface(protocol_binding='JSONRPC', url='http://127.0.0.1:19092', protocol_version='1.0')],
|
||||
skills=[AgentSkill(id='queue-work', name='Queue work', description='Work assigned to ' + seat, tags=['queue'])])
|
||||
handler = DefaultRequestHandler(agent_executor=Front(), task_store=InMemoryTaskStore(), agent_card=card)
|
||||
app = Starlette(routes=[*create_agent_card_routes(card), *create_jsonrpc_routes(handler, '/')])
|
||||
uvicorn.run(app, host='127.0.0.1', port=19092)
|
||||
```
|
||||
|
||||
Start the front, then use a second terminal for the outside client:
|
||||
|
||||
```sh
|
||||
# Custodian terminal:
|
||||
export A2A_SEAT=<worker-session-from-rig-ps>
|
||||
.venv/bin/python a2a_front.py
|
||||
|
||||
# Client terminal, in the folder with the example scripts and venv:
|
||||
unset A2A_ROW A2A_TASK A2A_CONTEXT
|
||||
export A2A_URL=http://127.0.0.1:19092
|
||||
export A2A_TEXT='Return exactly these words: "hello from the receiving seat".'
|
||||
.venv/bin/python a2a_client.py
|
||||
```
|
||||
|
||||
The client waits while the worker does its part. In the **worker's own environment**, inspect and claim the row:
|
||||
|
||||
```sh
|
||||
rig queue list --owned --full --json
|
||||
export ROW=<the-new-qitem-a2a-id>
|
||||
rig queue show "$ROW" --full --json
|
||||
rig queue claim "$ROW" --json
|
||||
rig queue update "$ROW" --state done --closure-reason no-follow-on --resolution 'hello from the receiving seat' --json
|
||||
```
|
||||
|
||||
The client should receive that exact resolution as an artifact, followed by completed. Compare the artifact with
|
||||
`rig queue show "$ROW" --full --json`, including the task/context IDs in the row body. The sample uses `--no-nudge`:
|
||||
the worker must be watching its queue; merely starting the front does not start or wake an agent.
|
||||
To wake a model worker after the row appears, run `rig send <worker-session> 'Check your queue and process the new A2A task.'`
|
||||
from the custodian's environment, or remove `--no-nudge` from the front's queue-create call to request the normal queue wake.
|
||||
|
||||
For input-required, the worker instead records `--note 'A2A question: <question>'` and leaves the row in progress.
|
||||
The client reruns with the same `A2A_TASK`, `A2A_CONTEXT` and its answer as `A2A_TEXT`. The worker reads the
|
||||
`A2A answer: ` transition with `rig queue transitions "$ROW" --full --json`, then finishes that row. These prefixes
|
||||
are this example's small convention, not built-in OpenRig fields. A new follow-up omits the completed task ID and
|
||||
keeps the context ID; it produces a new row.
|
||||
|
||||
## Cancel and read the outcome honestly
|
||||
|
||||
For a protocol-1.0 JSON-RPC peer, a direct control request includes `A2A-Version: 1.0`. For example, after the task
|
||||
is bound and working (substitute its ID; use `GetTask` with the same parameters to inspect it):
|
||||
|
||||
```sh
|
||||
curl -sS "$A2A_URL" -H 'Content-Type: application/json' -H 'A2A-Version: 1.0' \
|
||||
--data '{"jsonrpc":"2.0","id":"cancel-1","method":"CancelTask","params":{"id":"<task-id>"}}'
|
||||
```
|
||||
|
||||
Read the JSON body and task state, not just HTTP status. A pipe trial received HTTP 200 carrying JSON-RPC
|
||||
`-32009 VERSION_NOT_SUPPORTED` when the version header was omitted. That was an error, not a completed task.
|
||||
The SDK handles versioned calls; raw requests must follow the peer's advertised interface and
|
||||
[protocol version rules](https://a2a-protocol.org/v1.0.0/specification/).
|
||||
|
||||
SDK 1.1.0's default V2 handler cancels the running producer **before** awaiting the executor's `cancel` callback.
|
||||
If that callback awaits a queue write, the producer can close the event channel first. The inbound trial then
|
||||
returned stale working state even though the row was canceled. The event in the example keeps that producer
|
||||
alive until the callback publishes its outcome. This is specific to the exercised ordering; see the
|
||||
[pinned SDK implementation](https://github.com/a2aproject/a2a-python/blob/v1.1.0/src/a2a/server/agent_execution/active_task.py).
|
||||
Test it again when changing SDK versions, including an awaited external write rather than an immediate callback.
|
||||
|
||||
Keep these facts separate:
|
||||
|
||||
| A2A observation | What it means for the queue or worker |
|
||||
|---|---|
|
||||
| Submitted | The A2A front accepted a task; a row may not yet be created. |
|
||||
| Working | This front observed the receiving seat's claim. |
|
||||
| Input-required | A question needs answering; this example leaves the row in-progress. |
|
||||
| Completed + artifact | The worker's stored result was returned; your assessment still belongs to you. |
|
||||
| Canceled | This inbound front committed queue cancellation; an outside peer's cancellation says nothing about your outbound row. Verify separately that the worker noticed and stopped. |
|
||||
| Failed / JSON-RPC error | Retain the actual error and any row effect; do not report success. |
|
||||
| Disconnect / timeout | The last known state remains all you know. Inspect before retrying. |
|
||||
|
||||
There is no one-to-one A2A/queue state mapping. The example does not follow handoffs to another row, so it reports
|
||||
that limit instead of pretending the successor's work completed. Stopping the front only detaches clients; it does
|
||||
not cancel its queue rows or stop their workers. Cancel or hand off outstanding work deliberately before stopping
|
||||
your server. Acknowledged cancellation does not establish that a model provider stopped spending.
|
||||
|
||||
## What was exercised, and what remains open
|
||||
|
||||
The initial trials used an OpenRig 0.7.0 candidate and SDK 1.1.0 on Linux. Outbound discovery, streamed output and
|
||||
artifacts used the official Hello World sample. Deterministic peers covered input-required, cancellation, failure
|
||||
and concurrency. Inbound used a private daemon and a separate no-model queue worker: five artifacts matched the
|
||||
stored resolutions, two tasks overlapped without crossing IDs, and completed-task reuse left the old result
|
||||
unchanged. The successful cancellation check also observed the worker stop, beyond receiving the protocol reply.
|
||||
|
||||
A later inbound check used an official SDK 1.1.0 client and front, an OpenRig queue row, and Pi 1.4.0 running
|
||||
`openai/gpt-4o-mini`. One marker answer reached the row's resolution and the client's completed artifact. A
|
||||
custodian performed the queue writes; model tools were disabled. That run used a 120-second streaming read
|
||||
timeout and prompt RPC event draining, changed together. It did not separately test the unbounded read used above.
|
||||
|
||||
These results do not establish model quality or provider billing. Other framework versions, model tool use,
|
||||
public deployment and authentication, stream reconnect, durable bridge restart, and cancellation before row
|
||||
binding were not exercised. The inbound example holds A2A tasks and their row mapping in memory: keep it running for the conversation;
|
||||
after a crash, inspect retained OpenRig rows before any replacement run. It binds loopback and supplies no public
|
||||
authentication layer. Use it on your own machine or a trusted private path, not as an unauthenticated public gateway.
|
||||
@@ -1,77 +0,0 @@
|
||||
# Handing work to an ACP agent
|
||||
|
||||
`rig acp run` gives one queue row's task to an agent that speaks the
|
||||
[Agent Client Protocol](https://agentclientprotocol.com) (ACP), such as OpenClaw's `openclaw acp`, and records what
|
||||
happened on that same row. The seat that runs it is the row's custodian; the agent is recorded as the executor.
|
||||
|
||||
```sh
|
||||
rig acp run <qitemId> --cwd <work folder> -- <the agent's ACP command> [args...]
|
||||
```
|
||||
|
||||
The row's notes get, in order:
|
||||
- **the binding:** the agent's name and version, the ACP protocol version, whether it can load sessions, and the session;
|
||||
- **progress,** about once a minute (tool calls so far, output so far); the full transcript is in the run's log file, named on the row;
|
||||
- **each permission request and its answer** (below);
|
||||
- **the outcome:** `completed`, `stopped` (for example `max_tokens`), `cancelled`, `failed` or `unknown`, with the
|
||||
agent's output;
|
||||
- **what happened to the agent's processes** after the run ended.
|
||||
|
||||
The outcome is the agent's report, not a judgment of the work. The row stays open and yours: read the result, then
|
||||
close it or hand it on with your own resolution, as you would any other.
|
||||
|
||||
## When the agent asks permission
|
||||
|
||||
OpenRig holds no permission policy of its own. The agent's own settings decide what it asks, and an owner decides
|
||||
the answer. Each request becomes a queue row to `--permissions-to` (this seat by default), listing the action, its
|
||||
target, the options the agent offered and the run, session and request it belongs to. Answer on that row with the
|
||||
option's id:
|
||||
|
||||
```sh
|
||||
rig queue update <permission row> --state done --closure-reason no-follow-on --resolution allow-once
|
||||
```
|
||||
|
||||
Answer on the permission row itself; don't hand it off, because the run waits on that row. The answer goes back to
|
||||
that one request, once. The turn waits for it; there's no deadline. An answer that isn't one
|
||||
of the offered options is noted and the question is asked again. If the turn is cancelled first, the permission row
|
||||
is closed as stale and nothing is sent. Whoever answers can follow their own written policy; that choice is theirs.
|
||||
|
||||
## Cancel, detach and stop
|
||||
|
||||
These are different things, and the row keeps them apart:
|
||||
- **Cancel** (Ctrl-C, or `rig acp cancel <runId>` from another terminal) asks the agent to stop its turn. The row
|
||||
records when it was requested and when the agent acknowledged it. Whether the model provider stopped work, and what
|
||||
it cost, stays **unknown** unless you check the provider's own usage.
|
||||
- **Detach** (a second Ctrl-C, or the run ending normally) closes the agent's connection. That doesn't stop
|
||||
the agent's runtime.
|
||||
- **Stop:** when the run ends, it reports whether any process carrying the run's environment marker remains:
|
||||
`settled` (none), `incomplete` (with their process ids) or `unknown` (the process table couldn't be read). Only
|
||||
processes that inherited the marker are seen, so one the agent started with a cleared environment, or through a
|
||||
service manager, isn't. It never touches a process it didn't launch. To stop what's left, use the runtime's
|
||||
own stop command, through `rig acp stop <runId> -- <that command>` so the result lands on the row.
|
||||
|
||||
A gateway the agent connects to but didn't start, such as OpenClaw's Gateway, isn't a process of the run. Leave a
|
||||
shared one running; stop your own with its own command.
|
||||
|
||||
## Reading the outcome
|
||||
|
||||
- `completed`: the agent ended its turn with output.
|
||||
- `unknown`: the turn ended, but nothing shows the work happened. That covers an `end_turn` with no output and no or
|
||||
zero usage (an agent can end a turn this way after its provider refused the request), an `end_turn` whose text
|
||||
reports an interruption (OpenClaw's `[OpenClaw interruption]`), and a connection that closed before the turn ended.
|
||||
Check the agent's own log before you treat it as done.
|
||||
- `failed`: the agent returned an error. The row keeps its original code, message and data.
|
||||
- `cancelled`, `stopped`: as above.
|
||||
|
||||
## Notes for specific agents
|
||||
|
||||
- **OpenClaw:** `openclaw acp` is a bridge to a running OpenClaw Gateway, so start the Gateway first, then run, for
|
||||
example, `rig acp run <row> -- openclaw acp --url ws://127.0.0.1:18789 --token-file <file>`. Its permission requests
|
||||
come from the Gateway's exec approvals.
|
||||
- **Continuing a session:** `--load-session <id>` continues an earlier session when the agent advertises loading. If it
|
||||
doesn't, or the load fails, the run reports that and starts nothing new.
|
||||
|
||||
## Limits
|
||||
|
||||
- One run is one turn for one row.
|
||||
- The client offers the agent no file-system or terminal methods; the agent works in its own environment.
|
||||
- Spend is whatever the agent's provider charges; OpenRig doesn't measure it.
|
||||
@@ -177,7 +177,6 @@ defaults:
|
||||
| `runtime` | string | no | — | Default runtime for this agent. Precedence: the rig spec member's `runtime`, then the profile's `preferences.runtime`, then this, then `claude-code`. |
|
||||
| `model` | string | no | — | Default model, with the same precedence. |
|
||||
| `effort` | string | no | — | Default reasoning effort, with the same precedence. Claude gets `--effort`, Codex `-c model_reasoning_effort=…`, Pi `--thinking <level>` (Pi's levels: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`; passed as written). A blank or non-string value is ignored with an advisory. |
|
||||
| `advisor` | string | no | — | Default Claude Advisor model, with the same precedence. Passed as the session-scoped `--advisor` flag, independently of the primary model; never written to shared Claude settings. Blank or non-string values are ignored with an advisory. Other runtimes ignore it. |
|
||||
| `lifecycle` | Lifecycle | no | see below | Lifecycle behavior defaults. |
|
||||
|
||||
### Lifecycle Defaults
|
||||
@@ -260,11 +259,10 @@ profiles:
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `summary` | string | no | — | Profile description. Spec review surfaces read a profile `description` instead. |
|
||||
| `preferences` | object | no | — | Runtime/model/effort/Advisor preferences for this profile. |
|
||||
| `preferences` | object | no | — | Runtime/model/effort preferences for this profile. |
|
||||
| `preferences.runtime` | string | no | — | Preferred runtime. |
|
||||
| `preferences.model` | string | no | — | Preferred model. |
|
||||
| `preferences.effort` | string | no | — | Preferred reasoning effort. |
|
||||
| `preferences.advisor` | string | no | — | Preferred Claude Advisor model; overrides `defaults.advisor` and can be overridden by the member. |
|
||||
| `uses` | Uses | no | all empty | Selects which declared resources are active for this profile. |
|
||||
| `startup` | StartupBlock | no | — | Profile-level startup files and actions. Merged with agent-level startup via layering. |
|
||||
| `lifecycle` | Lifecycle | no | — | Profile-level lifecycle overrides. `restore_policy` can only narrow (`resume_if_possible` → `relaunch_fresh` → `checkpoint_only`); `compaction_strategy` and `mechanic` take the most specific value (defaults, then profile, then member). |
|
||||
@@ -290,7 +288,7 @@ The `uses` categories are: `skills`, `guidance`, `subagents`, `plugins`, `runtim
|
||||
empty list, is refused: hooks ship inside plugins.
|
||||
|
||||
**Skills resolve more widely than other resources.** For Claude Code and Codex seats, a skill ID that no declared
|
||||
resource provides is looked up at launch in the project's runtime skill folder in the seat's cwd (`<cwd>/.claude/skills` or
|
||||
resource provides is looked up at launch in the seat's runtime skill folder (`<cwd>/.claude/skills` or
|
||||
`<cwd>/.agents/skills`), then the `skills/` folder beside the RigSpec, then the home skill folder, then the managed
|
||||
skills root. Pi looks only in the `skills/` folder beside the RigSpec. OMP does no filesystem discovery, so its
|
||||
`uses.skills` references need declared resources. A discovered skill's ID is its `SKILL.md` frontmatter `name`, and a declared resource wins over a discovered skill with the
|
||||
@@ -370,10 +368,10 @@ version.
|
||||
|
||||
| Category | Fields | Description |
|
||||
|----------|--------|-------------|
|
||||
| `skills` | `id`, `path` | Skill directories containing a SKILL.md. Projected by the runtime adapter. Claude and Codex seats get them in the seat's own storage, `<OpenRig home>/state/seats/<rig id>/<logical seat>/<runtime>/`, outside the project: Claude to `plugin/skills/<id>` in the seat's generated plugin, where they show as `openrig:<id>`, and Codex to `skills/<id>` beside the seat's skill catalog. A Claude or Codex seat without that storage gets `<cwd>/.claude/skills/<id>` or `<cwd>/.agents/skills/<id>`. Pi and OMP project to the seat's own agent directory under OpenRig's state. |
|
||||
| `skills` | `id`, `path` | Skill directories containing a SKILL.md. Projected by the runtime adapter: Claude to `<cwd>/.claude/skills/<id>`, Codex to `<cwd>/.agents/skills/<id>`, Pi and OMP to the seat's own agent directory under OpenRig's state. |
|
||||
| `guidance` | `id`, `path`, `target`*, `merge`* | Guidance files, merged as a managed block (see "Guidance Resources"). |
|
||||
| `subagents` | `id`, `path` | Subagent definitions. Claude copies them to `<cwd>/.claude/agents/`, Codex to `<cwd>/.agents/`; Pi and OMP don't project them. |
|
||||
| `plugins` | `id`, `source`, `plugin_type`* | Local plugin directories; selected with `uses.plugins`. `source.kind` must be `local`. `plugin_type` is `claude`, `codex` or `auto` (default): Claude projects a plugin to `<cwd>/.claude/plugins/<id>` when it is `claude`, or `auto` with a `.claude-plugin/plugin.json`; Codex projects to `<cwd>/.codex/plugins/<id>` when it is `codex`, or `auto` with a `.codex-plugin/plugin.json`. Pi and OMP don't project plugins. Neither Claude Code nor Codex reads skills from that plugin folder, so when a seat is instantiated a selected plugin's skills are also projected into the seat's skill folder under their plain names: the seat's own storage (see `skills`), or `.claude/skills/<name>` or `.agents/skills/<name>` for a seat without it. A folder already there with the same name, ignoring letter case, that OpenRig doesn't own is left alone; unless it already holds the same bytes, launch warns `plugin_skill_kept`. A skill the seat already selects by that name (from its profile, the System World or the project) keeps that source, an edited OpenRig-owned copy is kept with `plugin_skill_kept`, a retained plugin copy whose remembered source can no longer be projected is also kept with `plugin_skill_kept` (a seat selecting that plugin refreshes it at its next launch), and if the plugin skills can't be projected the seat launches without them and warns `plugin_skills_not_projected`. Hooks ship inside plugins. |
|
||||
| `plugins` | `id`, `source`, `plugin_type`* | Local plugin directories; selected with `uses.plugins`. `source.kind` must be `local`. `plugin_type` is `claude`, `codex` or `auto` (default): Claude projects a plugin to `<cwd>/.claude/plugins/<id>` when it is `claude`, or `auto` with a `.claude-plugin/plugin.json`; Codex projects to `<cwd>/.codex/plugins/<id>` when it is `codex`, or `auto` with a `.codex-plugin/plugin.json`. Pi and OMP don't project plugins. Neither Claude Code nor Codex reads skills from that plugin folder, so when a seat is instantiated a selected plugin's skills are also projected into the seat's skill folder (`.claude/skills/<name>` or `.agents/skills/<name>`) under their plain names. A folder already there with the same name, ignoring letter case, that OpenRig doesn't own is left alone; unless it already holds the same bytes, launch warns `plugin_skill_kept`. A skill the seat already selects by that name (from its profile, the System World or the project) keeps that source, an edited OpenRig-owned copy is kept with `plugin_skill_kept`, a retained plugin copy whose remembered source can no longer be projected is also kept with `plugin_skill_kept` (a seat selecting that plugin refreshes it at its next launch), and if the plugin skills can't be projected the seat launches without them and warns `plugin_skills_not_projected`. Hooks ship inside plugins. |
|
||||
| `runtime_resources` | `id`, `path`, `runtime`, `type` | Runtime-specific resources. `runtime` and `type` are required. An entry whose `runtime` isn't the seat's runtime is skipped. Pi and OMP project none. |
|
||||
|
||||
*Fields marked with `*` are optional.
|
||||
@@ -461,7 +459,7 @@ restore behavior, and the distinction between readiness and verified proof.
|
||||
|
||||
| Hint | When Delivered | Mechanism |
|
||||
|------|---------------|-----------|
|
||||
| `auto` | Depends on the file | A seat's own role (a file marked `orientation: role`, or at a `guidance/role.md` path) is `send_text`, so pod-mates never overwrite each other's role in the shared file. A seat launched before this rule keeps the hint saved at its launch, which restore (`rig up <rig>`), `rig seat launch --fresh` and handover replay, until the rig is instantiated from its spec again: `rig down <rig>`, then `rig up <spec file>`, which starts a new generation with fresh conversations. Otherwise a path ending in `SKILL.md`, or content beginning with `# SKILL`, is `skill_install`; a `.md` file is `guidance_merge`; and any other file is `send_text` |
|
||||
| `auto` | Depends on the file | A path ending in `SKILL.md`, or content beginning with `# SKILL`, is `skill_install`; otherwise a `.md` file is `guidance_merge`, and any other file is `send_text` |
|
||||
| `guidance_merge` | Before harness boot | Merged into CLAUDE.md/AGENTS.md as managed block |
|
||||
| `skill_install` | Before harness boot | Installed to runtime skill directory |
|
||||
| `send_text` | After harness is ready | Sent as text to agent terminal via tmux |
|
||||
|
||||
@@ -244,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
|
||||
@@ -330,12 +321,6 @@ not the destination's file contents.)
|
||||
- `rig seat launch <seat> --fresh --stop --reason <text>`, which starts a blank conversation; or
|
||||
- `rig handover <seat> --source rebuild --reason <text>`, which carries durable context.
|
||||
|
||||
For Codex rebuilds, OpenRig also carries a complete supported native permission policy read from the live
|
||||
predecessor's own conversation and process, without adding fresh-launch grants. A later recorded seat permission
|
||||
or policy choice, or a differing declared successor profile, takes precedence. Missing, stale or unsupported observations keep the existing launch
|
||||
behaviour and are reported as unverified in the handover result; requested modes and profiles alone don't prove
|
||||
preservation. The result reports what was emitted, not native successor equivalence.
|
||||
|
||||
**Adopt it promptly.** The saved directory doesn't move the running process, but background work can use it before
|
||||
adoption. Saved configuration isn't the live process's directory, and these examples aren't the only consumers. For a
|
||||
running Claude seat:
|
||||
@@ -364,37 +349,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: a managed guidance block missing from the guidance file is put back before the harness starts.
|
||||
|
||||
Use this to avoid re-sending context that a fresh conversation doesn't need twice. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both, so a seat that comes back fresh gets it again.
|
||||
|
||||
---
|
||||
|
||||
## What a seat is set to, and what it's running
|
||||
|
||||
A seat's settings can disagree with its running agent: what's saved for the next launch, what the running process
|
||||
actually uses, and what its conversation has already read are three separate things. OpenRig reports each one
|
||||
separately. Without live evidence it says unknown (or not verified), never the saved value. A saved change reaches the
|
||||
running seat only through the action in the last column.
|
||||
|
||||
| What | Saved for the next launch | What the running seat has | How the running seat gets it |
|
||||
|---|---|---|---|
|
||||
| Model | `rig seat set-model` | `rig ps --nodes` shows the configured model beside the last observed one, or `UNKNOWN (<reason>)` | The next managed resume or successor launch |
|
||||
| Reasoning effort | `rig seat set-effort` | `rig seat effort <seat>`: requested, saved, sent at launch, and what the session recorded, which is not verified until it takes a turn | Its next managed continuation; nothing restarts |
|
||||
| Permissions | `rig seat set-permissions` (`--mode`, `--rules`, or for Codex `--codex-permissions` or `--codex-profile`), shown by `rig seat status` | What it launched with; a status read doesn't prove the running process enforces the selection | Its next managed launch or resume ([per-seat permission mode](getting-started.md#per-seat-permission-mode)) |
|
||||
| Working directory | `rig seat set-cwd`, shown by `rig seat status` as **Working directory for future launches** | The directory it started in | `rig seat launch --fresh --stop` (a blank conversation) or `rig handover --source rebuild` (durable context); [details](#change-an-existing-seats-working-directory) |
|
||||
| Startup files and skills | The spec's startup files, skills and actions | What its conversation read when it started; editing a file afterwards doesn't reach it | A fresh launch or a handover delivers them again; for the running seat, send or walk the change (`rig walk <seat> --through <files>`) |
|
||||
| Tools (MCP servers, plugins) | Selected fragments, merged into the harness's own config (`.mcp.json` for Claude, `config.toml` for Codex) | Unknown to OpenRig: registered isn't active, Claude asks before it uses a new server, and no OpenRig command reports which tools a running harness loaded | Check in the harness itself (Claude Code's `/mcp`); a running session may need a restart to pick up newly written config ([runtime config disclosure](#runtime-config-disclosure)) |
|
||||
|
||||
Per harness: Claude Code's and Codex's permission choices are in the setup reference
|
||||
([Claude Code](getting-started.md#claude-code-a-different-launch-flag),
|
||||
[Codex](getting-started.md#codex-select-sandbox-and-approvals-together)), Codex homes are
|
||||
[below](#separate-codex-homes-for-separate-installations), community harnesses' limits are in
|
||||
[their presets](community-harness-presets.md), and moving a live seat to the other runtime is in the
|
||||
[setup reference](getting-started.md#move-a-seat-to-the-other-runtime).
|
||||
Use this to avoid re-sending context that the agent already has from its resumed conversation. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both.
|
||||
|
||||
---
|
||||
|
||||
@@ -434,11 +392,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,46 +517,6 @@ remain shared.
|
||||
|
||||
**Terminal seats** (`builtin:terminal`) get no agent or profile layers and no startup proof.
|
||||
|
||||
### Claude compaction preparation directory
|
||||
|
||||
Managed Claude compaction asks the seat to write a restore map before `/compact`. By default,
|
||||
OpenRig uses `<cwd>/.openrig/compaction/preparation/`, falling back to its instance home when
|
||||
that directory cannot be prepared. A filesystem write check cannot establish whether Claude's
|
||||
file-edit permissions allow the seat to write there.
|
||||
|
||||
If the launch workspace is denied by Claude's permissions, select an absolute preparation root:
|
||||
|
||||
```sh
|
||||
rig config set policies.claude_compaction.preparation_root /absolute/path/to/restore-maps
|
||||
```
|
||||
|
||||
Grant the seat file-edit access to that root in its Claude configuration. OpenRig does not change
|
||||
Claude permissions; choose a directory outside any matching deny rule. The corresponding environment
|
||||
variable is `OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT`.
|
||||
|
||||
New manual and automatic attempts use `<root>/<session>/<attempt-id>/RESTORE-MAP.md`; an in-flight
|
||||
attempt keeps its original path through the post-compaction restore request. The root gets an
|
||||
exclusive `.gitignore` containing `*`, and the daemon publishes the marked `.tmp` file with an
|
||||
atomic rename. Use a dedicated root: a conflicting
|
||||
ignore file or an unwritable configured directory stops preparation with
|
||||
`preparation_directory_unavailable`, visible in `rig compact <session> --state`, without choosing
|
||||
another location. Fix the directory and explicitly retry. `--skip-map` still skips the map once.
|
||||
|
||||
Reset with `rig config reset policies.claude_compaction.preparation_root` to restore the default.
|
||||
The setting applies to new attempts without restarting the daemon.
|
||||
|
||||
The managed `/compact` command keeps its 600-character budget. When the selected map path or
|
||||
the attempt's compaction instruction does not fit, the existing restore request carries it;
|
||||
it never substitutes a newer preparation root or instruction. An explicitly selected root's
|
||||
published map is also named in that request when the path fit in `/compact`. The default root
|
||||
keeps the existing command/restore handoff. Under `--skip-map`, restore still recognizes an
|
||||
optional map completed at that attempt's path, or says that no map was required.
|
||||
|
||||
The compaction preparation and command-submission tests cover manual and automatic attempts,
|
||||
long paths and instructions, settings changed during preparation, directory failures and
|
||||
late maps after cancellation or expiry. They use synthetic delivery and filesystem fixtures;
|
||||
they do not prove a live Claude session's permission choices or compaction behavior.
|
||||
|
||||
### Codex compaction and sizing
|
||||
|
||||
OpenRig's managed compaction is for Claude Code seats only, by design. That covers `rig compact`, the
|
||||
|
||||
@@ -41,31 +41,6 @@ state. Attention/alarm values likewise stay in their own machinery (`attentionCo
|
||||
Both herdr and omnigent converged on this exclusion, with warning comments on the
|
||||
collision in both codebases.
|
||||
|
||||
The legacy `agentActivity` field distinguishes a typed provider interruption from
|
||||
a request for a person: fresh Codex `at_limit` and Claude `StopFailure/rate_limit`
|
||||
events carry `state: unknown` and `reason: usage_limit`, never a `needs_input`
|
||||
hook. A permission prompt can independently supply human-input evidence. See
|
||||
[Claude usage-limit detection](claude-status-line.md#claude-usage-limit-detection)
|
||||
for the separate provider signal and queue timer.
|
||||
|
||||
### Per-seat attention in the node inventory
|
||||
|
||||
`rig ps --nodes` carries the daemon's `needsAttention` result, using the same predicate
|
||||
as the rig's `attentionCount`. It also serves `arbitratedNeedsInput`: `true` for observed
|
||||
input, `false` for an observed clear, and `null` when no authoritative needs-input source
|
||||
answered. `needsInputEvidence` retains that source's rung and observation time, or `null`.
|
||||
A zero prompt count alone is not an observed clear. Absent arbitration evidence leaves
|
||||
the existing live-hook fallback in effect; lifecycle, startup, held and error signals
|
||||
can independently raise attention. A false attention flag means no known trigger, not
|
||||
proof that an unobserved input box is clear.
|
||||
|
||||
The compact human table's ATTN column renders `yes`, `no`, or `?` when the flag is
|
||||
unavailable. Compact JSON retains `needsAttention`; use `--full` or
|
||||
`--fields needsAttention,arbitratedNeedsInput,needsInputEvidence` with `--json` for the
|
||||
provenance. The TUI uses the served flag for attention glyphs and totals, and shows the
|
||||
needs-input evidence in seat detail, including a hook-only prompt. With an older daemon,
|
||||
attention stays unknown while its existing prompt, startup and error hints remain visible.
|
||||
|
||||
## Derived diagnoses — computed at read time, never stored
|
||||
|
||||
- **PARKED** — the disease: `(activity = idle-at-prompt OR needs-input pending) ×
|
||||
@@ -102,7 +77,7 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
|
||||
- **r3 — Claude `sessions/<pid>.json` self-report** (busy/shell/idle/waiting, since
|
||||
v2.1.139): standing, above hooks for working/idle. Undocumented internal — unreadable
|
||||
means fall down the ladder, never error.
|
||||
- **r2 — lifecycle hooks** (Claude SessionStart/UserPromptSubmit/PostToolBatch/Stop/StopFailure/Notification;
|
||||
- **r2 — lifecycle hooks** (Claude SessionStart/UserPromptSubmit/Stop/Notification;
|
||||
Codex SessionStart/UserPromptSubmit/Stop/PermissionRequest): standing for Claude; Codex's
|
||||
hook rung starts at trial and is promoted after 50 agreeing observations over at least an
|
||||
hour. Exactly-once turn boundaries; SubagentStop filtered (it
|
||||
@@ -131,33 +106,7 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
|
||||
without `agent_end`) leaves the seat working up to the 15 s window, then unknown, never idle. After a
|
||||
daemon restart, a runner seat takes its last recorded boundary from the hook store, for its
|
||||
current session and occupant generation only, so an idle seat still reads idle before it
|
||||
posts again, provided that report is fresh: a pi runner's idle boundary states an interval
|
||||
(below) and lapses to unknown once it is older than two of them.
|
||||
A pi runner also posts an idle `Stop` (`idle_heartbeat`) every three minutes while it waits, and states that
|
||||
interval as `livenessSeconds` on every `Stop`: a newly ready runner reads idle at once, and an idle boundary that
|
||||
carries an interval holds for two of them, then lapses to unknown, so a runner killed without saying so stops reading
|
||||
idle. Silence never clears a pending attention prompt, and a runner that states no interval (omp, an older pi
|
||||
runner) keeps the hold-until-the-next-boundary behaviour.
|
||||
|
||||
Claude permission notifications raise needs-input immediately. A visible permission can clear
|
||||
after it has been observed and then disappears. A notification whose dialog was never observed
|
||||
can also clear when Claude reports `PostToolBatch`: every tool in that actor's batch has resolved,
|
||||
so its earlier permission requests are no longer pending. The relay carries the native actor
|
||||
identity; a subagent or in-process teammate finishing its batch cannot clear the main session's
|
||||
request, and overlapping actors remain separate. An individual `PostToolUse` is insufficient
|
||||
because another tool in the batch may still be waiting for permission.
|
||||
|
||||
Batch completion must come from the current occupant and have a later source timestamp than
|
||||
the matching notification. Missing actor attribution from an older relay, equal or older
|
||||
timestamps, and another actor's completion retain attention. An unrecognized or missing screen
|
||||
does not expire attention by age. `Stop` and `UserPromptSubmit` remain the turn-boundary fallback.
|
||||
The oracle remembers each actor's latest completed batch for the current occupant, so a strictly
|
||||
earlier notification arriving late cannot restore the mark, even if the completion arrived first.
|
||||
A notification with an equal or later timestamp remains eligible; swapping or retiring the
|
||||
occupant discards its remembered completions.
|
||||
Completing a batch changes the needs-input signal without inferring that the seat is idle or
|
||||
overwriting a later elicitation. The hook contract is documented in
|
||||
[Claude's hooks reference](https://code.claude.com/docs/en/hooks#posttoolbatch).
|
||||
posts again.
|
||||
|
||||
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
|
||||
@@ -183,8 +132,7 @@ LIFECYCLE); see the
|
||||
- **Attention codes:** `trust_gate`, `hook_trust_gate`, `update_gate`, `login_required`,
|
||||
`mcp_gate`, `bypass_consent_gate`, `codex_auth_refusal`, `codex_client_incompatible`.
|
||||
- **Restore outcome:** `resumed`, `fresh-primed`, `awaiting-decision`, `attention_required`,
|
||||
`failed` (plus `rebuilt`, `operator_recovered` and the legacy `fresh`). A seat joined with
|
||||
`rig attach` as an external CLI reads `left_attached`: restore leaves it as it is.
|
||||
`failed` (plus `rebuilt`, `operator_recovered` and the legacy `fresh`).
|
||||
|
||||
## Rejected oracles — dated receipts (2026-08-26), so nobody re-derives them expensively
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# Checked seat stop (HTTP)
|
||||
|
||||
Read `GET /api/seat/stop-precondition/:seatRef` immediately before an operator-confirmed stop. A successful response has this shape:
|
||||
|
||||
```json
|
||||
{"contract":"seat-stop.v1","expected":{"nodeId":"<node ID>","sessionId":"<latest session ID or null>"}}
|
||||
```
|
||||
|
||||
Pass `expected` unchanged in the body of the existing `POST /api/seat/stop/:seatRef`, alongside `reason` and optional `operator`. The contract marker lets clients detect support. `expected` is optional; a request without it retains the existing stop behavior. A malformed explicit value returns HTTP 400. A null session ID can describe an empty seat, but stopping it returns `no_session`.
|
||||
|
||||
Checked stop requires the delivery guard. It refuses a changed node, latest session, binding or occupant before a terminal effect with HTTP 409 `selection_changed`. If the selection changes after client detach, the response reports `partialEffect: true` and no kill is sent. A kill failure or a failure to save stop state after a kill reports `stop_uncertain`; inspect the seat before retrying. Success retains the usual `seat`, `sessionName` and `sessionId` fields.
|
||||
@@ -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.
|
||||
@@ -1,13 +0,0 @@
|
||||
# Install as $OPENRIG_HOME/community-harnesses/cursor.yaml, with openrig-cursor-launch on the seat's PATH.
|
||||
format: 1
|
||||
name: cursor
|
||||
command: openrig-cursor-launch
|
||||
maintainer: Your name or team
|
||||
limits:
|
||||
- resume reads a chat as missing unless its store is in the seat's Cursor config folder, under the seat's working directory
|
||||
- no guidance, skills or startup files are placed
|
||||
- needs-input and approval prompts are read from the screen only
|
||||
launch_result: true
|
||||
resume: true
|
||||
startup_assisted: false
|
||||
first_input: true
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/bin/sh
|
||||
# A reference launcher for Cursor's CLI as an OpenRig community harness. Copy it, own it, and change it: OpenRig
|
||||
# doesn't ship or run it. It follows docs/reference/community-harness-presets.md:
|
||||
# - a new launch: `cursor-agent create-chat` prints a new chat id; the launcher reports it, then reopens that chat;
|
||||
# - a resume (OPENRIG_RESUME_REF set): it reopens the chat it was asked for and reports that id, or, when Cursor holds
|
||||
# no such chat, reports it missing and starts nothing.
|
||||
# Cursor's chat-id facts come from the probe in OpenRig issue #352, and the chat store's location from Cursor's own CLI
|
||||
# (2026.10.22).
|
||||
set -eu
|
||||
|
||||
producer="openrig-cursor-launch 2"
|
||||
cursor="${OPENRIG_CURSOR_AGENT:-cursor-agent}"
|
||||
|
||||
# Each seat gets its own Cursor config folder unless CURSOR_CONFIG_DIR is already set. Cursor keeps cli-config.json
|
||||
# (`--model`, approval settings) and the chats there, so a seat doesn't rewrite the person's own ~/.cursor. Cursor's
|
||||
# login isn't in it: Cursor keeps that in ~/.config/cursor/auth.json on Linux and in the Keychain on macOS.
|
||||
CURSOR_CONFIG_DIR="${CURSOR_CONFIG_DIR:-${OPENRIG_HOME:-$HOME/.openrig}/state/cursor/${OPENRIG_NODE_ID:-${OPENRIG_SESSION_NAME:-default}}}"
|
||||
export CURSOR_CONFIG_DIR
|
||||
mkdir -p "$CURSOR_CONFIG_DIR"
|
||||
|
||||
# Write this attempt's record, all at once, where OpenRig asked for it (nothing when it didn't ask).
|
||||
report() {
|
||||
[ -n "${OPENRIG_LAUNCH_RESULT:-}" ] || return 0
|
||||
tmp="$OPENRIG_LAUNCH_RESULT.$$.tmp"
|
||||
printf '%s\n' "$1" > "$tmp"
|
||||
mv "$tmp" "$OPENRIG_LAUNCH_RESULT"
|
||||
}
|
||||
plain_id() { case "$1" in ''|*[!A-Za-z0-9._:-]*) return 1 ;; *) return 0 ;; esac; }
|
||||
# Where Cursor keeps a chat: <config folder>/chats/<md5 of the working directory>/<chat id>/store.db. Cursor hashes
|
||||
# its process's working directory, which is the physical path, so this hashes `pwd -P`.
|
||||
chat_store() {
|
||||
if command -v md5sum >/dev/null 2>&1; then sum=$(pwd -P | tr -d '\n' | md5sum | cut -d' ' -f1)
|
||||
else sum=$(pwd -P | tr -d '\n' | md5 -q); fi
|
||||
printf '%s/chats/%s/%s/store.db' "$CURSOR_CONFIG_DIR" "$sum" "$1"
|
||||
}
|
||||
|
||||
# Preserve the file's exact bytes, including trailing newlines. A read error stops before the harness starts.
|
||||
if [ -n "${OPENRIG_FIRST_INPUT:-}" ]; then
|
||||
first_input=$(cat "$OPENRIG_FIRST_INPUT" && printf '.')
|
||||
first_input=${first_input%.}
|
||||
set -- "$@" "$first_input"
|
||||
fi
|
||||
|
||||
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
|
||||
id="$OPENRIG_RESUME_REF"
|
||||
plain_id "$id" || { echo "openrig-cursor-launch: refusing an unusual chat id" >&2; exit 2; }
|
||||
# Cursor's `--resume` with an id it doesn't hold exits 0 on a new, empty chat, so check first. Gone: say so, start nothing.
|
||||
if [ ! -f "$(chat_store "$id")" ]; then
|
||||
report "{\"format\":1,\"attempt\":\"${OPENRIG_LAUNCH_ATTEMPT:-}\",\"harness\":\"cursor\",\"error\":\"missing\",\"producer\":\"$producer\"}"
|
||||
echo "openrig-cursor-launch: Cursor has no chat $id in $CURSOR_CONFIG_DIR for $(pwd -P); starting nothing" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# create-chat prints the id but doesn't exit (see #352): read the first line, then stop it.
|
||||
out=$(mktemp)
|
||||
"$cursor" create-chat > "$out" 2>/dev/null &
|
||||
pid=$!
|
||||
i=0
|
||||
while [ "$i" -lt 100 ] && [ ! -s "$out" ]; do sleep 0.1; i=$((i + 1)); done
|
||||
kill "$pid" 2>/dev/null || true
|
||||
id=$(head -n 1 "$out" | tr -d '\r\n\t ')
|
||||
rm -f "$out"
|
||||
# No usable id: report nothing, so OpenRig shows the identity as unavailable, and start Cursor on its own chat.
|
||||
plain_id "$id" || exec "$cursor" --trust "$@"
|
||||
fi
|
||||
|
||||
report "{\"format\":1,\"attempt\":\"${OPENRIG_LAUNCH_ATTEMPT:-}\",\"harness\":\"cursor\",\"nativeRef\":\"$id\",\"producer\":\"$producer\"}"
|
||||
exec "$cursor" --resume "$id" --trust "$@"
|
||||
@@ -1,19 +0,0 @@
|
||||
# Merge this into each Hermes seat's config.yaml, in that seat's Hermes home ($OPENRIG_HOME/state/hermes/<node id> with
|
||||
# the example launcher), before the seat's first start.
|
||||
#
|
||||
# Hermes runs every tool command in its gateway (one per Hermes home, so one per seat with the example launcher), with
|
||||
# the gateway's environment. For each session it swaps
|
||||
# in the client's own values only for its toolchain names and the names listed here. Without this list, `rig` inside
|
||||
# Hermes runs as whichever seat started the gateway, or as no seat. With it, each seat runs `rig` as itself.
|
||||
# These are the seat's own OpenRig variables; none is a secret, and Hermes refuses credential-like names anyway.
|
||||
terminal:
|
||||
client_env_passthrough:
|
||||
- OPENRIG_SESSION_NAME
|
||||
- OPENRIG_NODE_ID
|
||||
- OPENRIG_HOME
|
||||
- OPENRIG_URL
|
||||
- OPENRIG_HOST
|
||||
- OPENRIG_PORT
|
||||
- OPENRIG_DB
|
||||
|
||||
# Your own settings go beside it (the guide says which): the model and provider, and agent.max_turns if you want a cap.
|
||||
@@ -1,12 +0,0 @@
|
||||
# Install as $OPENRIG_HOME/community-harnesses/hermes.yaml, with openrig-hermes-launch on the seat's PATH.
|
||||
format: 1
|
||||
name: hermes
|
||||
command: openrig-hermes-launch # no model, provider or --max-turns here: they go in the seat's Hermes config.yaml
|
||||
maintainer: Your name or team
|
||||
limits:
|
||||
- resume not declared until reopening a named session, and a missing one, have been checked with a real Hermes
|
||||
- closing a seat's pane detaches it; Hermes finishes any accepted turn; whether its gateway exits when idle is unchecked
|
||||
- creation settings (model, provider, max turns) come from the seat's Hermes config.yaml, not from flags
|
||||
- no guidance, skills or startup files are placed
|
||||
- needs-input and approval prompts are read from the screen only
|
||||
names_conversation: true
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/bin/sh
|
||||
# A reference launcher for Hermes Agent's CLI as an OpenRig community harness. Copy it, own it, and change it: OpenRig
|
||||
# doesn't ship or run it. It follows docs/reference/community-harness-presets.md (`names_conversation`):
|
||||
# - a new launch (OPENRIG_CONVERSATION set): Hermes creates the session titled with that name;
|
||||
# - a resume (OPENRIG_RESUME_REF set): Hermes reopens that session, and never creates one. A name that matches nothing
|
||||
# ends with Hermes's own "No session found" error, so a gone conversation isn't replaced by a new one.
|
||||
# Put the model, provider and max turns in the seat's Hermes config.yaml, not on the command line. Hermes v0.22.4's CLI
|
||||
# creates a `-c <name> --create-if-missing` session with no launch flags, then reopens it and refuses any creation flag
|
||||
# that differs, so such flags fail even on a new name. Other arguments go only to a new session, never to a resume.
|
||||
set -eu
|
||||
|
||||
hermes="${OPENRIG_HERMES:-hermes}"
|
||||
|
||||
# Each seat gets its own Hermes home, so seats don't share sessions or a gateway, and a seat's stop concerns only its own.
|
||||
# Its settings (model, provider, agent.max_turns) come from that home's config.yaml, and its key from the environment
|
||||
# (an OpenRouter key, say), not from the person's ~/.hermes.
|
||||
HERMES_HOME="${OPENRIG_HOME:-$HOME/.openrig}/state/hermes/${OPENRIG_NODE_ID:-${OPENRIG_SESSION_NAME:-default}}"
|
||||
export HERMES_HOME
|
||||
mkdir -p "$HERMES_HOME"
|
||||
|
||||
# Hermes's tools see its shared gateway's environment except for the names a profile passes through, so a seat whose
|
||||
# config.yaml doesn't pass OPENRIG_SESSION_NAME would run `rig` as another seat, or as none (see config.yaml.example).
|
||||
if ! grep -qs OPENRIG_SESSION_NAME "$HERMES_HOME/config.yaml"; then
|
||||
echo "openrig-hermes-launch: $HERMES_HOME/config.yaml doesn't pass OPENRIG_SESSION_NAME to Hermes's tools, so rig run from Hermes won't act as this seat; see config.yaml.example" >&2
|
||||
fi
|
||||
|
||||
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
|
||||
exec "$hermes" chat -c "$OPENRIG_RESUME_REF"
|
||||
fi
|
||||
if [ -n "${OPENRIG_CONVERSATION:-}" ]; then
|
||||
exec "$hermes" chat -c "$OPENRIG_CONVERSATION" --create-if-missing "$@"
|
||||
fi
|
||||
echo "openrig-hermes-launch: OpenRig passed no conversation name; is names_conversation set in the preset?" >&2
|
||||
exit 2
|
||||
@@ -1,15 +0,0 @@
|
||||
# The Gateway must already run. Configure its route and authentication in this seat's OPENCLAW_CONFIG_PATH.
|
||||
format: 1
|
||||
name: openclaw-gateway
|
||||
command: openrig-openclaw-launch --gateway
|
||||
maintainer: Your name or team
|
||||
limits:
|
||||
- connects to an existing Gateway; this launcher never starts or stops that service
|
||||
- stopping the TUI closes its client; an accepted turn may keep running on the Gateway
|
||||
- uses only agent main; automatic session resets must stay off
|
||||
- typing /new changes the native key while the seat keeps the name OpenRig gave it
|
||||
- no guidance, skills or startup files are placed
|
||||
- needs-input and approval prompts are read from the screen only
|
||||
names_conversation: true
|
||||
resume: true
|
||||
first_input: true
|
||||
@@ -1,16 +0,0 @@
|
||||
# Install as $OPENRIG_HOME/community-harnesses/openclaw.yaml, with openrig-openclaw-launch on the seat's PATH.
|
||||
format: 1
|
||||
name: openclaw
|
||||
command: openrig-openclaw-launch
|
||||
maintainer: Your name or team
|
||||
limits:
|
||||
- runs locally in the pane (openclaw tui --local), using only agent main, with no Gateway; Gateway-attached use isn't covered
|
||||
- Finish the current local turn before stopping; an interrupted local turn may retain native recovery work and refuse the next request. This launcher does not repair or retry it.
|
||||
- local resume may run a native database integrity check; check that the TUI has finished startup before sending work
|
||||
- automatic session resets (session.reset) must stay off, or a key can start a new conversation
|
||||
- typing /new in the pane moves the chat to a fresh session key; the seat still holds the name OpenRig gave it
|
||||
- no guidance, skills or startup files are placed
|
||||
- needs-input and approval prompts are read from the screen only
|
||||
names_conversation: true
|
||||
resume: true
|
||||
first_input: true
|
||||
@@ -1,70 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Reference launcher for OpenClaw 2026.10.6 (fe06181), not installed by OpenRig.
|
||||
# Each seat owns its client state and uses the main agent. OpenRig's opaque name maps to its full native key.
|
||||
# --gateway selects an existing Gateway configured for this client; the default is an embedded local agent.
|
||||
# Resume checks that exact key at its owner; an unavailable lookup is not a missing conversation.
|
||||
set -eu
|
||||
|
||||
openclaw="${OPENRIG_OPENCLAW:-openclaw}"
|
||||
mode=local
|
||||
if [ "${1:-}" = --gateway ]; then mode=gateway; shift; fi
|
||||
OPENCLAW_STATE_DIR="${OPENRIG_HOME:-$HOME/.openrig}/state/openclaw/${OPENRIG_NODE_ID:-${OPENRIG_SESSION_NAME:-default}}"
|
||||
export OPENCLAW_STATE_DIR
|
||||
mkdir -p "$OPENCLAW_STATE_DIR"
|
||||
|
||||
name="${OPENRIG_RESUME_REF:-${OPENRIG_CONVERSATION:-}}"
|
||||
if [ -z "$name" ]; then
|
||||
echo "openrig-openclaw-launch: OpenRig passed no conversation name; is names_conversation set in the preset?" >&2
|
||||
exit 2
|
||||
fi
|
||||
case "$name" in
|
||||
agent:main:*) key="$name" ;;
|
||||
agent:*) echo "openrig-openclaw-launch: this example supports only the main OpenClaw agent" >&2; exit 2 ;;
|
||||
*) key="agent:main:$name" ;;
|
||||
esac
|
||||
|
||||
# Preserve the file's exact bytes, including trailing newlines. A read error stops before the harness starts.
|
||||
if [ -n "${OPENRIG_FIRST_INPUT:-}" ]; then
|
||||
first_input=$(cat "$OPENRIG_FIRST_INPUT" && printf '.')
|
||||
first_input=${first_input%.}
|
||||
set -- "$@" --message "$first_input"
|
||||
fi
|
||||
|
||||
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
|
||||
if [ "$mode" = gateway ]; then
|
||||
params=$(KEY="$key" node -e 'process.stdout.write(JSON.stringify({ key: process.env.KEY, agentId: "main" }))')
|
||||
if listing=$("$openclaw" gateway call sessions.describe --params "$params" --json); then listed=true; else listed=false; fi
|
||||
else
|
||||
if listing=$("$openclaw" sessions --agent main --json --limit all); then listed=true; else listed=false; fi
|
||||
fi
|
||||
if [ "$listed" = false ]; then
|
||||
echo "openrig-openclaw-launch: this seat's OpenClaw sessions couldn't be listed; not starting" >&2
|
||||
exit 4
|
||||
fi
|
||||
if printf '%s' "$listing" | KEY="$key" MODE="$mode" node -e '
|
||||
let raw = ""; process.stdin.on("data", (chunk) => { raw += chunk; });
|
||||
process.stdin.on("end", () => {
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
if (process.env.MODE === "gateway") {
|
||||
if (data.session === null) process.exit(3);
|
||||
process.exit(data.session?.key === process.env.KEY ? 0 : 4);
|
||||
}
|
||||
const { sessions } = data;
|
||||
if (!Array.isArray(sessions)) process.exit(4);
|
||||
process.exit(sessions.some((s) => s?.agentId === "main" && s.key === process.env.KEY) ? 0 : 3);
|
||||
} catch { process.exit(4); }
|
||||
});'; then
|
||||
:
|
||||
else
|
||||
result=$?
|
||||
if [ "$result" -eq 3 ]; then
|
||||
echo "openrig-openclaw-launch: this seat's OpenClaw sessions don't list \"$key\"; not starting a new one under that key" >&2
|
||||
else
|
||||
echo "openrig-openclaw-launch: this seat's OpenClaw sessions couldn't be listed; not starting" >&2
|
||||
fi
|
||||
exit "$result"
|
||||
fi
|
||||
fi
|
||||
if [ "$mode" = gateway ]; then exec "$openclaw" tui --session "$key" "$@"; fi
|
||||
exec "$openclaw" tui --local --session "$key" "$@"
|
||||
@@ -1,100 +0,0 @@
|
||||
#!/bin/sh
|
||||
# A reference launcher for Pi's own terminal UI (interactive `pi`) as an OpenRig community harness. Copy it, own it, and
|
||||
# change it: OpenRig doesn't ship or run it. It's written for Pi 1.4.0 from its source and not yet checked with a real Pi.
|
||||
# It follows docs/reference/community-harness-presets.md (`names_conversation`, `first_input`):
|
||||
# - a new launch (OPENRIG_CONVERSATION set): Pi creates the session whose id is that name, in the seat's own session
|
||||
# folder, and takes the first input as its first prompt;
|
||||
# - a resume (OPENRIG_RESUME_REF set): the launcher opens the one session file whose id is that name, and starts nothing
|
||||
# when there isn't exactly one. Pi's --session-id creates a new session for an id it doesn't find, and --session with
|
||||
# an id can open another session whose id starts the same way, so neither is used to resume.
|
||||
set -eu
|
||||
|
||||
pi="${OPENRIG_PI:-pi}"
|
||||
state="${OPENRIG_HOME:-$HOME/.openrig}/state/pi-tui/${OPENRIG_NODE_ID:-${OPENRIG_SESSION_NAME:-default}}"
|
||||
sessions="$state/sessions"
|
||||
|
||||
# Each seat gets its own Pi agent folder (settings, logins, skills) unless PI_CODING_AGENT_DIR is already set, so seats
|
||||
# don't share settings and a person's ~/.pi/agent isn't touched. Give the seat its provider key in its environment, or its
|
||||
# login and settings in this folder, before its first start.
|
||||
PI_CODING_AGENT_DIR="${PI_CODING_AGENT_DIR:-$state/agent}"
|
||||
# Pi expands a leading ~ in this folder; do the same, so the skills land where Pi looks.
|
||||
case "$PI_CODING_AGENT_DIR" in
|
||||
"~") PI_CODING_AGENT_DIR="$HOME" ;;
|
||||
"~/"*) PI_CODING_AGENT_DIR="$HOME/${PI_CODING_AGENT_DIR#"~/"}" ;;
|
||||
esac
|
||||
export PI_CODING_AGENT_DIR
|
||||
mkdir -p "$PI_CODING_AGENT_DIR/skills" "$sessions"
|
||||
|
||||
# Copy the installed OpenRig's coordination skills in at each launch, so an upgrade reaches the seat. Pi lists the skills
|
||||
# in <agent folder>/skills/ by itself; the seat's first input should still name queue-handoff (see the recipe).
|
||||
skills_from="${OPENRIG_PI_SKILLS_FROM:-${OPENRIG_HOME:-$HOME/.openrig}/plugins/openrig-core/skills}"
|
||||
set -f
|
||||
for skill in ${OPENRIG_PI_SKILLS:-queue-handoff}; do
|
||||
case "$skill" in
|
||||
*[!a-z0-9-]*|-*) echo "openrig-pi-tui-launch: skipping '$skill': not a skill name" >&2; continue ;;
|
||||
esac
|
||||
if [ -d "$skills_from/$skill" ]; then
|
||||
rm -rf "$PI_CODING_AGENT_DIR/skills/$skill"
|
||||
cp -Rp "$skills_from/$skill" "$PI_CODING_AGENT_DIR/skills/"
|
||||
else
|
||||
echo "openrig-pi-tui-launch: no $skill skill in $skills_from; starting without it" >&2
|
||||
fi
|
||||
done
|
||||
set +f
|
||||
|
||||
# Pi's session ids: letters, digits, '.', '_' and '-', starting and ending with a letter or digit.
|
||||
valid_id() {
|
||||
case "$1" in
|
||||
"" | *[!A-Za-z0-9._-]* | [!A-Za-z0-9]* | *[!A-Za-z0-9]) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The first input goes over as Pi's first prompt, byte for byte, after `--`. OpenRig's starts with its identity-context
|
||||
# line; Pi would read one starting with @ as a file even after `--`, and may run one starting with / as a command.
|
||||
set --
|
||||
if [ -n "${OPENRIG_FIRST_INPUT:-}" ]; then
|
||||
first_input=$(cat "$OPENRIG_FIRST_INPUT" && printf '.')
|
||||
first_input="${first_input%.}"
|
||||
if [ -n "$first_input" ]; then set -- -- "$first_input"; fi
|
||||
fi
|
||||
|
||||
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
|
||||
id="$OPENRIG_RESUME_REF"
|
||||
if ! valid_id "$id"; then
|
||||
echo "openrig-pi-tui-launch: '$id' isn't a Pi session id; starting nothing" >&2
|
||||
exit 2
|
||||
fi
|
||||
# Pi names a session file <time>_<id>.jsonl, but the file's first line, its header, is what says which session it is:
|
||||
# its top-level type and id, read as JSON as Pi reads it. Pi writes the file only once the conversation has a message, so
|
||||
# a session that never got one can't be reopened. Pi runs on Node, so node reads the header.
|
||||
node="${OPENRIG_PI_NODE:-node}"
|
||||
if ! command -v "$node" >/dev/null 2>&1; then
|
||||
echo "openrig-pi-tui-launch: $node isn't on PATH to read Pi's session headers; starting nothing" >&2
|
||||
exit 1
|
||||
fi
|
||||
found="" count=0
|
||||
for file in "$sessions"/*_"$id".jsonl; do
|
||||
[ -s "$file" ] || continue
|
||||
if head -n 1 "$file" | "$node" -e '
|
||||
let text = ""; process.stdin.on("data", c => { text += c; }).on("end", () => {
|
||||
try { const h = JSON.parse(text); process.exit(h && h.type === "session" && h.id === process.argv[1] ? 0 : 1); }
|
||||
catch { process.exit(1); }
|
||||
});' "$id"; then
|
||||
found="$file" count=$((count + 1))
|
||||
fi
|
||||
done
|
||||
if [ "$count" -ne 1 ]; then
|
||||
echo "openrig-pi-tui-launch: expected one Pi session $id in $sessions, found $count; starting nothing" >&2
|
||||
exit 1
|
||||
fi
|
||||
exec "$pi" --session-dir "$sessions" --session "$found" "$@"
|
||||
fi
|
||||
if [ -n "${OPENRIG_CONVERSATION:-}" ]; then
|
||||
if ! valid_id "$OPENRIG_CONVERSATION"; then
|
||||
echo "openrig-pi-tui-launch: '$OPENRIG_CONVERSATION' isn't a Pi session id; starting nothing" >&2
|
||||
exit 2
|
||||
fi
|
||||
exec "$pi" --session-dir "$sessions" --session-id "$OPENRIG_CONVERSATION" "$@"
|
||||
fi
|
||||
echo "openrig-pi-tui-launch: OpenRig passed no conversation name; is names_conversation set in the preset?" >&2
|
||||
exit 2
|
||||
@@ -1,15 +0,0 @@
|
||||
# Install as $OPENRIG_HOME/community-harnesses/pi-tui.yaml, with openrig-pi-tui-launch on the seat's PATH.
|
||||
# "pi" itself is the built-in managed Pi runtime, so this preset for Pi's own terminal UI takes another name.
|
||||
format: 1
|
||||
name: pi-tui
|
||||
command: openrig-pi-tui-launch # no model or provider here: they go in the seat's Pi settings or environment
|
||||
maintainer: Your name or team
|
||||
limits:
|
||||
- checked once with Pi 1.4.0 and OpenRig 0.7.4 (one run, one model); other Pi versions aren't checked
|
||||
- resume not declared until reopening a named session, and a missing one, have been checked with a real Pi
|
||||
- with the default typing guard, a wake submits a person's half-typed draft in Pi's editor together with the wake; run the seat unattended, not where a person also types
|
||||
- a send while a Pi dialog OpenRig doesn't recognize is open, such as /settings, types into the dialog
|
||||
- how Pi takes a wake that arrives while it's busy isn't shown
|
||||
- only the listed openrig-core skills are copied into the seat's Pi agent folder; no guidance or startup files are placed
|
||||
names_conversation: true
|
||||
first_input: true
|
||||
@@ -1,351 +0,0 @@
|
||||
# Community harness presets
|
||||
|
||||
A community harness is a coding harness an outside contributor maintains. You install it as one small data file, and a
|
||||
rig member runs it by naming it as its `runtime`. OpenRig doesn't test these harnesses. It shows them as community
|
||||
harnesses, and it reads their activity only from what the screen shows.
|
||||
|
||||
## What a community harness gets
|
||||
|
||||
What OpenRig does for a community-harness seat, capability by capability. **Partly** and **no** mean OpenRig shows that it doesn't know;
|
||||
it never reads an unsupported capability as idle, working, ready or verified.
|
||||
|
||||
| Capability | Supported | How it works, or what you see instead |
|
||||
|---|---|---|
|
||||
| Launch | yes | OpenRig types the preset's `command` into the seat's pane, with its variables passed through `/usr/bin/env`. The seat reads as ready once something other than a shell runs in the pane; that's a process check, not proof of which conversation it runs. With `startup_assisted`, the seat reports attention required until a person checks it. |
|
||||
| Delivery | yes | `rig send` and queue wakes paste the text into the pane and press Enter, as for any seat. A pane that's back at a shell is refused (`target_runtime_unverified`) and nothing is typed. OpenRig doesn't confirm the harness took the message; `rig send --verify` checks the pane afterwards. |
|
||||
| Identity | when declared | With `launch_result`, the conversation is shown as reported by its launcher; with `names_conversation`, as named by OpenRig. Neither is observed by OpenRig. Without either, the identity is unavailable. |
|
||||
| Resume | when declared | `resume: true` with one of the two above (see [Reopening a conversation](#reopening-a-conversation-resume-true)). Without it, a resume says `Resume is unsupported: community harness "<name>" is launch-only`, and a restore stops and asks. |
|
||||
| Activity | partly | Read from the screen only; there are no activity hooks. Work and attention the screen shows are kept, but a screen that looks idle reads `unknown`, never idle, because a quiet prompt can be an approval or a pause. |
|
||||
| Attention | partly | Read from the screen: a permission question or numbered choice that OpenRig recognizes reads as needing attention, and `rig send` won't type into it. A prompt it doesn't recognize reads `unknown`. |
|
||||
| Approval | no | Approvals stay in the harness's own pane. OpenRig relays none, so a person answers them there. |
|
||||
| Cancel | no | OpenRig has no verb that cancels a running turn. `rig seat stop` and `rig down` end the seat's tmux session; anything the harness runs outside its pane, such as a gateway, is its own, and the preset's `limits` should say what keeps running. Restarting in place and switching harness are refused. |
|
||||
| Transport | yes | The seat is a tmux pane like any other, so `rig capture`, `rig send` and `--host` work as they do for any seat. |
|
||||
|
||||
**Not placed:** guidance, skills and startup files (a launch warns that they weren't delivered), and the model: set it in the harness's own
|
||||
configuration.
|
||||
|
||||
**Check that the harness answers on its own first.** Before writing a preset, start the harness in a terminal outside OpenRig, with the
|
||||
same configuration and credentials your launcher will give it, and send it one prompt. Use the folder your launcher gives the seat, not
|
||||
the harness's default one: the example launchers keep one per seat under `$OPENRIG_HOME/state/<harness>/` (`HERMES_HOME`,
|
||||
`OPENCLAW_STATE_DIR` or `CURSOR_CONFIG_DIR`), so a check with your own defaults can pass while the seat's setup fails. If no answer comes
|
||||
back, the problem is in the harness's own setup, such as its model, provider or login, and relaunching an OpenRig seat won't fix it. Fix
|
||||
it in the harness, then connect it.
|
||||
|
||||
## The preset
|
||||
|
||||
One YAML file per harness: `$OPENRIG_HOME/community-harnesses/<name>.yaml`. `rig community-harness list` shows what's
|
||||
installed.
|
||||
|
||||
```yaml
|
||||
format: 1
|
||||
name: example-harness # must match the file name: lowercase letters, digits and hyphens
|
||||
command: example-launch # one program and its arguments, typed into the seat's pane
|
||||
maintainer: Example Maintainer
|
||||
limits: # what the maintainer says doesn't work
|
||||
- fork unsupported
|
||||
launch_result: true # optional: the launcher reports the conversation it created (below)
|
||||
# names_conversation: true # optional, instead of launch_result: OpenRig names the conversation (below)
|
||||
resume: true # optional, needs one of those two; declare it only once your harness's resume is checked
|
||||
startup_assisted: true # optional: startup submission needs a person's check (below)
|
||||
first_input: true # optional: launcher takes the first turn from OPENRIG_FIRST_INPUT (below)
|
||||
```
|
||||
|
||||
- **`command`** is a single program and its arguments. Shell syntax such as `|`, `&&`, `;`, redirections or `$(…)`
|
||||
is refused: put it in a script and name the script.
|
||||
- **Built-in runtime names are reserved.** A preset can't replace `claude-code`, `codex` or the other built-ins.
|
||||
- **Unknown fields are refused,** so a preset written for a newer OpenRig says so instead of half-working.
|
||||
- **Fork isn't supported, and resume only as described below.** Neither is placing skills, guidance or startup files. Except
|
||||
for assisted startup below, the seat is called ready while something other than a shell runs in its pane, which says
|
||||
nothing about which conversation it is.
|
||||
|
||||
### Assisted startup
|
||||
|
||||
Set `startup_assisted: true` when startup submission needs a person's check. The seat may start its first turn
|
||||
automatically, including from input passed at launch with `first_input: true`. This declaration requires a check after
|
||||
the submission attempt; it does not hold the agent back. Without `first_input`, OpenRig pastes the startup text and
|
||||
presses Enter once.
|
||||
|
||||
After attempting startup input, OpenRig reports **attention required**: check the pane and submit any text still in the
|
||||
composer. OpenRig does not resend the identity prompt. Once the seat responds, use the reported
|
||||
`rig seat clear-attention <session> --reason "Startup submission checked; seat responded"` command to acknowledge the
|
||||
check. A launch with no startup text needs no such check. Other presets keep their existing behavior.
|
||||
|
||||
## Reporting the conversation a launch created (`launch_result: true`)
|
||||
|
||||
Some harnesses create their own conversation id before they start. Cursor's `cursor-agent create-chat` is one. With
|
||||
`launch_result: true`, the harness's launcher tells OpenRig that id for each launch.
|
||||
|
||||
1. **OpenRig names one file per launch attempt.** It starts `command` with two variables:
|
||||
- `OPENRIG_LAUNCH_RESULT`, a new file path inside `$OPENRIG_HOME/state/launch-results/`;
|
||||
- `OPENRIG_LAUNCH_ATTEMPT`, this attempt's id.
|
||||
2. **The launcher writes one JSON record to that path,** then starts the harness:
|
||||
```json
|
||||
{ "format": 1, "attempt": "<OPENRIG_LAUNCH_ATTEMPT>", "harness": "cursor", "nativeRef": "<the conversation id>", "producer": "openrig-cursor-launch 1.0" }
|
||||
```
|
||||
3. **OpenRig reads that attempt's record once,** waiting up to 20 seconds for it. It records `nativeRef` on the session,
|
||||
and the seat shows its identity as **reported by its launcher, not observed**. That's what the launcher says, not
|
||||
proof that the harness used it.
|
||||
|
||||
The seat still starts, with its identity **unavailable** and the reason named, when the record:
|
||||
- wasn't written;
|
||||
- belongs to another attempt or another harness;
|
||||
- isn't JSON, isn't format 1, or is larger than 64 KiB;
|
||||
- has an unusable `nativeRef` (empty, too long, or containing spaces or control characters);
|
||||
- names no producer;
|
||||
- or is reached through a link.
|
||||
|
||||
None of these means the conversation is gone.
|
||||
|
||||
A reported id doesn't make the harness resumable on its own. Without `resume: true`, nothing resumes it: a restore
|
||||
stops and asks, as it did before (re-run it with `--fresh <seat>` only if you want a new conversation), and elsewhere a
|
||||
resume says it's unsupported, and a new conversation starts only where the caller allows a fresh start.
|
||||
|
||||
**Good to know:**
|
||||
- **The variables are passed with `/usr/bin/env`,** so `launch_result` needs a POSIX system.
|
||||
- **A launcher that never writes its record** adds up to 20 seconds to each launch, while OpenRig waits for it.
|
||||
- **Records are kept,** one small file per attempt, under `$OPENRIG_HOME/state/launch-results/<session>/`. A record
|
||||
larger than 64 KiB isn't read.
|
||||
|
||||
## Letting OpenRig name the conversation (`names_conversation: true`)
|
||||
|
||||
Some harnesses take a conversation name from the caller and create that conversation, or reopen it. Hermes's
|
||||
`hermes chat -c <name> --create-if-missing` is one. With `names_conversation: true`, OpenRig names each new conversation
|
||||
and the harness uses that name, so no record is needed.
|
||||
- **A new launch:** OpenRig starts `command` with `OPENRIG_CONVERSATION` set to a new name, `openrig-<seat>-<8 hex digits>`
|
||||
(at most 100 characters). Each launch gets its own name, so two seats never share a conversation.
|
||||
- **What the seat shows:** OpenRig records that name on the session, and the seat shows its identity as **named by
|
||||
OpenRig, not observed**. That's the name OpenRig passed, not proof that the harness used it.
|
||||
- **Not with `launch_result`:** a preset declares one or the other. Either the launcher reports the conversation, or
|
||||
OpenRig names it.
|
||||
|
||||
## Reopening a conversation (`resume: true`)
|
||||
|
||||
**A named conversation:** with `names_conversation`, a resume starts `command` with `OPENRIG_RESUME_REF` set to the saved
|
||||
name. The harness reopens it and must never create one under that name. Nothing reports back, so:
|
||||
- **readiness is process-only:** the seat reads as ready while something other than a shell runs in its pane. That
|
||||
doesn't show the named conversation exists: a harness (or a launcher's own check) that hasn't yet found it gone reads
|
||||
as ready, and the saved name stays unverified until something observes it;
|
||||
- **never creating one is the launcher's job:** a resume must not create a conversation under the saved name. When it's
|
||||
gone, the harness or launcher ends with its error and the pane is back at a shell; the seat keeps its saved name, and
|
||||
OpenRig starts nothing new, though the seat may already have read as ready;
|
||||
- **a former runtime's id** isn't passed to the harness, as below.
|
||||
|
||||
**A reported conversation:** with `launch_result`, a resume (a restore included) starts the launcher with `OPENRIG_RESUME_REF` set to the
|
||||
conversation to reopen. The launcher reports what happened in the same attempt's file:
|
||||
- **Reopened:** the usual record, with that conversation as `nativeRef`.
|
||||
- **Gone:** `{ "format": 1, "attempt": "…", "harness": "cursor", "error": "missing", "producer": "…" }`. The launcher
|
||||
starts nothing. OpenRig reports the conversation missing and starts nothing either: it never replaces it with a new one.
|
||||
|
||||
For a reported conversation, OpenRig also says so plainly when:
|
||||
- **no usable record is written:** the resume isn't confirmed. The seat needs attention and keeps the conversation it
|
||||
asked for; nothing is restarted;
|
||||
- **the record names a different conversation:** the seat needs attention and keeps the conversation it asked for.
|
||||
OpenRig doesn't switch it to the reported one;
|
||||
- **the saved conversation isn't this harness's own** (a former runtime's id, say): it isn't passed to the launcher. A restore stops and asks, and elsewhere the resume says it isn't possible;
|
||||
- **the record's folder can't be made:** the launcher couldn't be told what to reopen, so nothing starts.
|
||||
|
||||
## An example: Hermes
|
||||
|
||||
[`community-harness-examples/hermes/`](community-harness-examples/hermes/) has a preset and a small launcher for Hermes
|
||||
Agent's CLI.
|
||||
- **The launcher:** it creates the session OpenRig named (`-c <name> --create-if-missing`), and on a resume reopens it
|
||||
without `--create-if-missing`, with its own Hermes home per seat. A name that matches nothing ends with Hermes's own
|
||||
"No session found" error.
|
||||
- **Settings go in the seat's Hermes `config.yaml`, not in flags:** Hermes's CLI creates a named session with no launch
|
||||
flags and then reopens it, refusing any creation flag (model, provider, `--max-turns`) that differs. So the preset's
|
||||
command carries none, and each seat's `config.yaml`, in its Hermes home, sets them before the seat's first start:
|
||||
the model and provider, and `agent.max_turns` if you want a cap (nothing else sets it once the flag is gone).
|
||||
- **Let Hermes run `rig` as the seat:** Hermes runs every tool command in its gateway, one per Hermes home, so with
|
||||
this launcher one per seat (a two-seat run showed separate gateways, homes and sockets, and stopping one seat left
|
||||
the other's running). It runs them with that gateway's
|
||||
environment, and swaps in a session's own values only for its toolchain names and the names its profile lists in
|
||||
`terminal.client_env_passthrough`. So put the seat's OpenRig variables in that list, in each seat's `config.yaml`
|
||||
([`config.yaml.example`](community-harness-examples/hermes/config.yaml.example)). Without it, `rig` inside Hermes
|
||||
runs as whichever seat started the gateway, or as no seat; with it, each seat runs `rig` as itself. To check, ask the
|
||||
seat's Hermes to run `rig whoami --json`. The launcher warns when a seat's `config.yaml` doesn't pass
|
||||
`OPENRIG_SESSION_NAME`. This covers Hermes's CLI and TUI, whose clients pass their environment; its ACP clients pass
|
||||
none.
|
||||
- **The preset:** it declares `names_conversation` but not `resume`, until reopening a named session, and a missing one,
|
||||
have been checked with a real Hermes.
|
||||
- **Stopping:** Hermes's CLI is a client of a gateway, so closing a seat's pane detaches it. Hermes finishes any turn it
|
||||
accepted. Whether the seat's gateway then exits once idle hasn't been checked: check with `ps` that a stopped seat's
|
||||
gateway has gone before relying on it. The preset says so in its limits.
|
||||
|
||||
Copy them and maintain them as your own.
|
||||
|
||||
## An example: OpenClaw
|
||||
|
||||
[`community-harness-examples/openclaw/`](community-harness-examples/openclaw/) has a preset and a small launcher for
|
||||
OpenClaw's CLI.
|
||||
- **Choose the mode:** `openclaw.yaml` runs an embedded agent with `tui --local`. `openclaw-gateway.yaml` runs a TUI client of an existing Gateway. Configure that client's Gateway route and authentication through `OPENCLAW_CONFIG_PATH`; the launcher never starts or stops the Gateway.
|
||||
- **The conversation:** both examples use only agent `main`, mapping OpenRig's name to the full native key `agent:main:<OpenRig name>`. Each seat has its own client state folder.
|
||||
- **Its resume check:** local mode checks `sessions --agent main --json --limit all`; Gateway mode calls the selected Gateway's `sessions.describe`. Both require the exact canonical key. An unavailable lookup stops the launch; a confirmed missing key is never recreated. A client's local cache cannot authorize Gateway resume.
|
||||
- **Stopping:** a Gateway client's pane closing detaches it. The Gateway and an accepted turn can keep running; stopping that service is separate. Local mode runs its embedded agent in the pane.
|
||||
- **Unattended seats:** prefer Gateway mode within the capabilities qualified for your installed version. Local interrupted-turn recovery is not supported by these launchers.
|
||||
- **Local interruption:** Finish the current local turn before stopping; an interrupted local turn may retain native recovery work and refuse the next request. This launcher does not repair or retry it. OpenRig recognizes the recorded local “changed while starting work” error frame as needing attention: “OpenClaw turn not recovered; inspect the pane before retrying”. Use `rig capture <session>` to inspect the native conversation. An ordinary send still submits a new turn, with an advisory that the last local turn was not recovered; it is not an automatic replay of that turn. It does not clear native recovery records, repeat the request or start a fresh conversation; a successful process launch is not proof that the turn recovered.
|
||||
- **Local startup after resume:** OpenClaw may run its database integrity check during startup. A reported resumed process does not confirm readiness for the next request; check that the TUI has finished starting before sending work.
|
||||
- **Resets must stay off:** automatic session resets can create a new conversation under the same key. Typing `/new` also moves to a fresh key while the seat keeps the name OpenRig gave it.
|
||||
- **The presets:** both declare `names_conversation`, `resume` and `first_input`. The launcher passes the generated first startup text through OpenClaw's `--message` option. OpenRig labels the saved name as passed, not observed by its adapter.
|
||||
|
||||
Copy the launcher and the preset for your mode, and maintain them as your own.
|
||||
|
||||
**Stopping a community seat stops its pane.** `rig down` names those seats ("Pane stopped: …"), since a harness's own
|
||||
processes, a gateway say, may keep running after it. OpenRig doesn't stop them, and whether they survive isn't exercised
|
||||
by OpenRig's tests, so check the harness's own processes after `rig down`. Each preset's limits say what to expect.
|
||||
|
||||
**Not supported for community harnesses:** restarting a seat in place (`rig seat restart` is for Codex seats) and
|
||||
switching harness on a running seat. Each says so before anything changes.
|
||||
|
||||
## An example: Cursor
|
||||
|
||||
[`community-harness-examples/cursor/`](community-harness-examples/cursor/) has a preset and a small launcher for Cursor's
|
||||
CLI.
|
||||
- **The launcher:** it reports the chat `cursor-agent create-chat` makes, then reopens it. Each seat gets its own Cursor
|
||||
config folder unless `CURSOR_CONFIG_DIR` is already set. Cursor keeps its chats and `cli-config.json` there, and its
|
||||
login elsewhere.
|
||||
- **The preset:** it declares `launch_result` and `resume`. Cursor's `--resume` with an id it doesn't hold starts a new,
|
||||
empty chat without saying so, so on a resume the launcher first checks that the chat's store exists. When it doesn't, the
|
||||
launcher reports `error: "missing"` and starts nothing.
|
||||
- **First input at launch:** the preset declares `first_input: true` and `startup_assisted: false`. The launcher passes
|
||||
the generated startup text as Cursor's positional first prompt. This route and same-chat resume were checked with
|
||||
Cursor Agent 2026.10.22-27dcf5f. Later tool approvals use Cursor's native prompts; startup submission does not answer them.
|
||||
- **Sender names come from the sending session.** A command run from an unattached operator shell can arrive as
|
||||
`<unknown sender>`; that doesn't mean the receiving Cursor seat lost its identity.
|
||||
|
||||
Copy both and maintain them as your own.
|
||||
|
||||
## An example: Pi's terminal UI
|
||||
|
||||
[`community-harness-examples/pi-tui/`](community-harness-examples/pi-tui/) has a preset and a small launcher that run Pi's
|
||||
own terminal UI as a community harness. `pi` is the built-in managed Pi runtime, so the preset is named `pi-tui`. OpenRig
|
||||
delivers to this seat as to any community seat (the Delivery row above), not as to a plain `runtime: terminal` seat, which
|
||||
queue wakes don't reach. Checked once, on Pi 1.4.0 with OpenRig 0.7.4 and one model: a parked row's wake reached Pi in
|
||||
the same session (the same session file) and Pi resumed the work, and a handoff reached a second `pi-tui` seat, which
|
||||
picked it up unattended. The first seat's task named the commands to use, so this shows a wake reaching Pi and Pi acting
|
||||
on it, not Pi choosing by itself to park or hand work on.
|
||||
- **The launcher:** each seat gets its own Pi agent folder (`$OPENRIG_HOME/state/pi-tui/<node>/agent`, unless
|
||||
`PI_CODING_AGENT_DIR` is set) and its own session folder. At each launch it copies the installed `openrig-core` skills
|
||||
it's given (`queue-handoff` by default) into that agent folder. A new launch starts `pi --session-id <the name OpenRig
|
||||
gave>` and passes the first input as Pi's first prompt, byte for byte.
|
||||
- **Resume isn't declared.** The launcher can reopen a named session: it opens the one session file whose header has that
|
||||
id, and starts nothing when there isn't exactly one, because Pi's own `--session-id` creates a session for an id it
|
||||
doesn't find. Declare `resume: true` only after a real Pi has reopened a named session and refused a missing one.
|
||||
- **Its limits:** run a `pi-tui` seat unattended, not where a person also types into it.
|
||||
- With the seat's [typing guard](seat-delivery.md) at its default, a wake submits a person's half-typed draft in Pi's
|
||||
editor together with the wake. The guard's draft-aware mode isn't a known fix for this.
|
||||
- A send while a Pi dialog OpenRig doesn't recognize is open, such as `/settings`, types into the dialog.
|
||||
- Not shown: how Pi takes a wake that arrives while it's busy (a message sent during a busy turn was answered after
|
||||
it), and other Pi versions.
|
||||
- **Give the seat a model** before its first start: its provider key in its environment, or its login and settings in its
|
||||
agent folder.
|
||||
|
||||
Copy both and maintain them as your own.
|
||||
|
||||
The launcher is the contributor's code, in the contributor's package. OpenRig runs no code from a preset and loads no
|
||||
module into its daemon. A preset that sets `launch_result`, `names_conversation`, `startup_assisted` or `first_input` is refused by an older
|
||||
OpenRig that doesn't know the field.
|
||||
|
||||
## Pass the first startup input at launch
|
||||
|
||||
A format-1 preset may set `first_input: true` (a boolean, default false). OpenRig composes the same initial identity turn it would otherwise paste, including the first merged text file, durable-obligation guidance and any selected orientation challenge. Before launch it writes that text to a per-attempt 0600 file under its own `state/first-input/` folder and passes the path as `OPENRIG_FIRST_INPUT`. The typed launch command carries the path, not the text; an argument-based launcher still puts the text in the native harness’s argv.
|
||||
|
||||
The contributor-owned launcher reads the file and uses its harness’s supported initial-prompt route: the Cursor example passes one positional prompt; OpenClaw uses `--message`. Core knows neither flag. If no initial identity turn is due (an ordinary resume, for example), the variable is absent and the launcher starts without a first message. Later startup files and actions use their existing delivery paths; they are not folded into the launch file.
|
||||
|
||||
OpenRig records `node.startup_first_input_passed` with the path and `submission: unconfirmed`. This says the input was passed at launch, not that the native harness submitted or read it. An uncertain launch is not permission to replay it. Readiness remains process-only. `startup_assisted` is independent: when declared, startup still requires a person’s acknowledgement, including when the first input was passed at launch.
|
||||
|
||||
Presets without `first_input` and built-in runtimes keep their existing behavior. Older OpenRig versions reject this new field as unknown. The private input file remains for diagnostics; it can contain private startup context. A launcher that turns its contents into one argument is subject to the platform’s argument-size limit; the launcher must report a launch error rather than silently truncate the turn. The examples’ argument transport is covered with stand-ins; native unattended startup still requires qualification with the installed harness.
|
||||
|
||||
## Interactive Pi in a terminal seat: the coordination skills
|
||||
|
||||
This is for a seat that runs interactive `pi` from a `runtime: terminal` member's startup, or from a community preset's
|
||||
launcher. It isn't for a managed `runtime: pi` seat, which already copies its selected skill directories into the seat's own
|
||||
Pi agent directory (`$OPENRIG_HOME/state/pi/<session>/agent/skills/`), or for Workshop, whose Pi profiles carry pinned skill
|
||||
copies (openrig-world's `PI-SKILLS.md`). The [`pi-tui` example](#an-example-pis-terminal-ui) above does step 1 for its
|
||||
seats in its launcher.
|
||||
|
||||
OpenRig installs no skills for a terminal or community seat: it skips their resources and startup files, and `rig skill
|
||||
loadout` covers Claude Code and Codex only. Without the coordination skills, the agent there doesn't know the queue's habits,
|
||||
such as parking a row that waits on CI instead of leaving it in progress, or handing work on so the next row names its
|
||||
source. The seat's setup does two things.
|
||||
|
||||
1. **Make the released skills readable.** The installed OpenRig's own plugin carries them, matching the installed release:
|
||||
`rig plugin list` shows `openrig-core` and its path (usually `$OPENRIG_HOME/plugins/openrig-core`), and the skills are in
|
||||
its `skills/` folder. Copy whole skill directories, keeping their helper files and modes, into the `skills/` folder of the
|
||||
Pi agent directory that seat uses (`PI_CODING_AGENT_DIR`, or `~/.pi/agent` by default). At least `queue-handoff`:
|
||||
|
||||
```bash
|
||||
CORE="$(rig plugin list | awk '$1 == "openrig-core" {print $NF}')"
|
||||
PI_AGENT_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
|
||||
mkdir -p "$PI_AGENT_DIR/skills"
|
||||
cp -Rp "$CORE/skills/queue-handoff" "$PI_AGENT_DIR/skills/"
|
||||
```
|
||||
|
||||
Copy again after each OpenRig upgrade: a copy doesn't follow the plugin.
|
||||
2. **Point the agent at it when it starts.** Don't count on Pi finding the skill by itself; name it in the seat's first turn.
|
||||
For example:
|
||||
|
||||
> Before you work your queue, read `<Pi agent directory>/skills/queue-handoff/SKILL.md` in full: it's how this team hands
|
||||
> work on, parks it and waits. When you wait on CI or a long job, park the row with `rig queue block <id> --on
|
||||
> external:<what> --wake-after <duration> --continuation "<what resumes>"`.
|
||||
|
||||
- A community preset with `first_input: true` carries it in the seat's first input when the file is declared with
|
||||
`delivery_hint: send_text` (see "Pass the first startup input at launch" above). A `.md` file left at the `auto` hint
|
||||
counts as guidance, which a community seat doesn't take.
|
||||
- A plain terminal seat can pass it as Pi's initial message in the command its startup runs. Check `pi --help` for that
|
||||
argument in your Pi version.
|
||||
- `first_input` carries text, not files: it doesn't install the skills.
|
||||
|
||||
**In a plain terminal seat, a queue wake doesn't reach Pi.** OpenRig doesn't treat a `runtime: terminal` seat as an agent
|
||||
seat. When a parked row's wake fires, OpenRig doesn't type it into Pi, and a handoff to that seat records a failed wake
|
||||
(`failed:terminal-seat`). Parking still records the wait, but `--wake-after` won't bring Pi back. Check the seat's parked
|
||||
rows yourself (`rig queue whoami --session <seat>`, or `rig queue show <id>`), or tell Pi to continue the named row.
|
||||
|
||||
**What this shows, and what it doesn't.** A copied folder and a startup line put the text in front of the agent. They don't
|
||||
show that Pi discovered the skill by itself, or that the agent follows it. Check those separately: that the agent read the
|
||||
whole file (its tool trace), and what its rows record, such as a CI wait parked on `external:` with a wake, and a handoff
|
||||
made with `handoff-and-complete`, whose next row names it as its source.
|
||||
|
||||
## Write your own
|
||||
|
||||
Start from the example closest to your harness, and change it. First check that the harness answers a prompt on its own
|
||||
([above](#what-a-community-harness-gets)).
|
||||
|
||||
1. **Choose how the conversation is named.**
|
||||
- **Your harness creates an id you can read before it starts,** as Cursor's `create-chat` does: declare `launch_result: true`, and the
|
||||
launcher writes the record.
|
||||
- **Your harness takes a name from its caller and creates or reopens it,** as Hermes's `-c <name>` and OpenClaw's session key do: declare
|
||||
`names_conversation: true`, and the launcher passes `OPENRIG_CONVERSATION` on.
|
||||
- **Neither:** leave both out. The seat starts and works, its identity shows as unavailable, and it can't be resumed.
|
||||
2. **Write the launcher:** a small script your package installs on the seat's `PATH`. It reads OpenRig's variables, starts the harness with
|
||||
`exec`, and does nothing else.
|
||||
- `OPENRIG_CONVERSATION` with `names_conversation`, or `OPENRIG_LAUNCH_RESULT` and `OPENRIG_LAUNCH_ATTEMPT` with `launch_result`.
|
||||
- **On a resume,** `OPENRIG_RESUME_REF`: reopen exactly that conversation. Check that it exists first, and when it doesn't, end with an
|
||||
error (or report `error: "missing"`) instead of creating one.
|
||||
- **With `first_input: true`,** `OPENRIG_FIRST_INPUT`: pass the file's exact bytes through your harness's own first-prompt route.
|
||||
- **Keep each seat's harness state apart** (Cursor's config folder, Hermes's home, OpenClaw's state folder), so two seats never share a
|
||||
conversation.
|
||||
3. **Write the preset** at `$OPENRIG_HOME/community-harnesses/<name>.yaml`, and check it with `rig community-harness list`.
|
||||
- Declare `resume` only after checking, with the real harness, that a reopen works and that a missing conversation is refused.
|
||||
- List what doesn't work under `limits`.
|
||||
4. **Try it in a rig:** give a member `runtime: <name>`, then go through the checks in the promotion test below.
|
||||
5. **Publish the preset and the launcher in your own package,** and maintain them. OpenRig runs no code from a preset, so the launcher is
|
||||
yours.
|
||||
|
||||
## The promotion test
|
||||
|
||||
A community harness stays a community harness: you maintain it, and OpenRig shows it as one. **Promotion** means OpenRig takes a harness on
|
||||
as a built-in runtime that it maintains and tests itself. **Promotion is decided by the OpenRig maintainers, on evidence from OpenRig's own
|
||||
testing.** Passing these checks isn't automatic promotion, and it isn't a promise of upstream support.
|
||||
|
||||
What the maintainers look at:
|
||||
1. **Everything the preset declares works on a real install,** end to end through OpenRig:
|
||||
- a seat starts and takes its first turn (`rig up`);
|
||||
- a message arrives (`rig send`), and the reply can be read (`rig capture`);
|
||||
- the seat stops (`rig down`) without leaving work running that the person can't see, or the preset's `limits` say what keeps running;
|
||||
- with `resume`, `rig up <rig> --existing` reopens the same conversation, and a missing one is refused, never replaced.
|
||||
2. **What it doesn't support reads as not known,** never as idle, working, ready or verified.
|
||||
3. **Its `limits` are true,** and name anything a person would otherwise be surprised by.
|
||||
4. **The built-in runtimes are unchanged.**
|
||||
5. **It's maintained:** a named maintainer, and a launcher that keeps working with the harness's current release.
|
||||
|
||||
There's no score or threshold. The maintainers weigh the evidence.
|
||||
@@ -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,165 +0,0 @@
|
||||
# Gateway participants: several people, shared channels, organizations and policy
|
||||
|
||||
OpenRig's gateway can register **several people** and **shared channels**, group them into **organizations** and
|
||||
**groups**, give each readable **policy** text, and name a **default person**. Agents read the policy and apply it;
|
||||
OpenRig doesn't enforce it. This page covers the commands, what the default is for, and what upgrading and rolling back
|
||||
do.
|
||||
|
||||
## Participants
|
||||
|
||||
A participant is either:
|
||||
- **a person**, registered with `rig gateway human add` as before, with their own Slack binding; or
|
||||
- **a shared channel**: one vetted Slack channel registered as one participant, `<id>@external`, not a person per
|
||||
sender.
|
||||
|
||||
People and channels share one address space: a channel can't take a person's id.
|
||||
|
||||
```bash
|
||||
# An organization and a group inside it (one command each, create or update)
|
||||
rig gateway org set acme --name "Acme" --policy "Answer Acme's customers within a working day. Never promise a date."
|
||||
rig gateway group set support --organization acme --name "Support" --policy-file ./support-policy.md
|
||||
|
||||
# People: the second and later ones are allowed now
|
||||
rig gateway human add sarah --display-name "Sarah" \
|
||||
--binding "slack:main:file:/etc/openrig/slack.env:primary:handle=U0SARAH" --delivery-class B \
|
||||
--organization acme --group support --policy "Sarah approves refunds up to 500." --default
|
||||
|
||||
# A shared channel
|
||||
rig gateway channel add acme-customers --display-name "Acme customers" \
|
||||
--binding "slack:main:file:/etc/openrig/slack.env:C0ACME" --organization acme
|
||||
|
||||
# See everyone, with organization, groups, the default and each participant's policy
|
||||
rig gateway participants
|
||||
rig gateway participants --organization acme --json
|
||||
rig gateway human show sarah
|
||||
```
|
||||
|
||||
Change a person's placement with `rig gateway human set <id> organization|groups|policy <value>` (`unset` clears it;
|
||||
for policy, `@<path>` reads a file). Remove a channel with `rig gateway channel remove <id>`, which refuses while
|
||||
non-terminal rows are addressed to it unless you pass `--force`.
|
||||
|
||||
## Policy is context, not enforcement
|
||||
|
||||
A participant's **effective policy** is its organization's text, then each of its groups', then its own, each labelled
|
||||
with where it came from. Agents read it (`rig gateway participants --json`, `rig gateway human show --json`) and apply
|
||||
it with judgment. It doesn't grant or block anything by itself, and a participant's name never implies a policy.
|
||||
|
||||
## The default person
|
||||
|
||||
Some things need a person without naming one: an escalation of stuck work, the digest of notices, Mission Control's
|
||||
my-queue, a workflow's human step when no person is configured for it, and a pairing approval without `--human`. They
|
||||
go to:
|
||||
1. the default person, when one is chosen (`rig gateway default <id>`, or `--default` on `human add`);
|
||||
2. otherwise the only registered person, so a one-person install needs no choice;
|
||||
3. otherwise nobody, and each says so: an escalation reports `undeliverable:no-default-person`, the notice digest
|
||||
waits, and the others name the command to run.
|
||||
|
||||
`rig gateway default` shows the current default; `rig gateway default --clear` clears the choice. Choosing a default
|
||||
never changes `workspace.operator_seat_name`, which keeps its own meaning.
|
||||
|
||||
## In the TUI: whose queue, and who acts
|
||||
|
||||
The person at the terminal is the **primary user**: the default person, or, with several people and none chosen, the
|
||||
person at the terminal unnamed. Whatever the TUI shows, its acts are theirs.
|
||||
- **Opening:** with a chosen default, the TUI opens on that person's Feed. With none chosen, including a one-person
|
||||
install that never chose one, it opens on the topology, as before.
|
||||
- **Whose queue:** `whose <person>` shows one person's asks, `whose all` everyone's, and `whose default` the default
|
||||
person's. The Feed's first line says whose queue it shows and who acts.
|
||||
- **Acting on someone else's ask** (answer, approve or resolve from the TUI) records both: the primary user as the actor,
|
||||
and the person asked in the audit notes and the transition. The asking seat reads, for example, "Answered by
|
||||
mike@external, the primary user at the terminal, on sarah@external's ask". With several people and none chosen, the
|
||||
actor is `human@host`.
|
||||
- **Viewing Sarah isn't acting as Sarah.** Only Sarah, from her own channel, answers as Sarah. Without the primary
|
||||
user's authority, one registered person can't answer, approve, deny or resolve what was asked of another; that's
|
||||
refused (`answer_not_recorded`) and nothing changes.
|
||||
- With no one registered, the TUI and its acts are as before, except on a shared channel's ask or a row parked on a
|
||||
channel: the TUI shows the act as `human@host`, the person at the terminal, and it's recorded that way, never as the
|
||||
channel.
|
||||
- **A shared channel never acts.** Its messages are correspondence, so nothing it says answers, approves, denies or
|
||||
resolves an ask, and a customer's earlier message in an ask's thread is never taken as its answer. A request without
|
||||
the primary user's authority that acts as a channel, with any Mission Control verb (including drop, route, hold, hand
|
||||
off and annotate), is refused (`answer_not_recorded`) and nothing changes.
|
||||
- **Where the authority applies:** only on a request that carries no seat identity header, which is how the TUI calls
|
||||
the daemon. Seats and the CLI send that header, so their acts stay theirs. This is the existing trusted-network
|
||||
distinction, not an authentication check: a request that leaves the header out is taken as the terminal's, as any
|
||||
such request already is.
|
||||
|
||||
## Shared-channel conversations
|
||||
|
||||
A registered shared channel lets people who aren't registered, such as a customer, talk to a seat, without registering
|
||||
each of them.
|
||||
|
||||
**Setup:**
|
||||
1. Invite the OpenRig Slack app to the channel.
|
||||
2. Register the channel: `rig gateway channel add acme-customers --display-name "Acme customers" --binding
|
||||
"slack:main:file:/etc/openrig/slack.env:C0ACME" --organization acme --policy-file ./acme-customers.md`.
|
||||
3. Send its new messages to the support seat: `rig slack channel-map set support-rig C0ACME --inbound
|
||||
support@support-rig`. Without a map, they go to the inbound destination.
|
||||
|
||||
A channel works on its own: no registered person is needed for it to converse.
|
||||
|
||||
**What the seat sees:** each message lands as a row from the channel (`acme-customers@external`). Its body ends with a
|
||||
labelled block: the channel, its organization and groups, who wrote (the Slack user, marked as not a registered person),
|
||||
and the channel's effective policy. The row is tagged `participant:acme-customers`, `shared-channel` and
|
||||
`slack-sender:<user id>`. A registered person writing in the channel stays themselves; the block only says where they
|
||||
wrote.
|
||||
|
||||
The channel's policy is the labelled block OpenRig adds at the end of the row, after the message. Anything above it is
|
||||
the sender's own words, even if it looks like policy. `rig gateway participants --json` is the source.
|
||||
|
||||
**Answering:** reply into the customer's thread with an update to the channel:
|
||||
|
||||
```bash
|
||||
rig queue create --destination acme-customers@external --human-intent update --reply-to <their row's id> \
|
||||
--summary "Your invoice" --body "It's on your account page."
|
||||
```
|
||||
|
||||
A later update with the same `--reply-to` lands in the same thread. An update without `--reply-to` posts to the channel
|
||||
on its own. Posts to a channel mention nobody.
|
||||
|
||||
**What a channel can't do:** its messages are correspondence. They never answer, approve, close or resume anyone's ask.
|
||||
You can still send an ask to the channel; the queue accepts it with a warning that nothing there will settle it, so read
|
||||
the replies and close it yourself. Its questions show in the channel as a list, without buttons, and ask for a reply in
|
||||
the thread.
|
||||
|
||||
## Where it's stored
|
||||
|
||||
- **People** stay exactly as before: one file per person under `$OPENRIG_HOME/gateway/humans/`, plus the generated
|
||||
`humans.generated.yaml`. Nothing new is written into them.
|
||||
- **Everything else** (the default, organizations, groups, each participant's placement and policy, and shared
|
||||
channels) is in `$OPENRIG_HOME/gateway/participants.yaml`, written by the commands above.
|
||||
- **A problem in `participants.yaml` never disables the person registry.** People still receive and answer their asks.
|
||||
The commands above report the problem, and with several people no default is chosen until it's fixed.
|
||||
- **A person removed by hand** leaves a warning in `rig gateway participants`, not a broken file. `rig gateway human
|
||||
remove` also removes the person's placement, and the default if it was them.
|
||||
|
||||
## Upgrading
|
||||
|
||||
For a one-person install, nothing to do: it keeps working as before, that person stays the default, pending rows keep
|
||||
their addresses, and no new file is written until you use one of the commands above.
|
||||
|
||||
A TUI older than the daemon still works: it sends the actor the daemon serves, the person asked, which is still their
|
||||
own act. On a shared channel's ask the daemon serves `human@host`. An older TUI's approve there is recorded as
|
||||
`human@host`, and its answer is refused (`answer_not_recorded`), because answering someone else's ask takes the primary
|
||||
user's authority, which only a current TUI sends.
|
||||
|
||||
**If several people are already registered** (earlier versions listed hand-added people with an advisory), choose a
|
||||
default with `rig gateway default <id>` after upgrading. Until you do, escalations report
|
||||
`undeliverable:no-default-person` instead of reaching whoever's id sorted first, and the notice digest waits.
|
||||
|
||||
## Rolling back to 0.7.2 or earlier
|
||||
|
||||
Older versions don't read `participants.yaml`, and the person files carry nothing they don't understand, so:
|
||||
- **every registered person still loads**, and each can still answer their own asks from Slack;
|
||||
- **another person's reply is guarded less on 0.7.1 and 0.7.0:** there, a different person's Slack reply in the thread
|
||||
of a question parked on someone else can resume that question, and 0.7.0 can record such a reply to a pending ask as
|
||||
an answer row (it still can't close the ask);
|
||||
- **there's no default:** escalations and the notice digest go to the first person by id, Mission Control's my-queue
|
||||
has no default person, and pairing needs `--human`;
|
||||
- **the TUI** opens on the topology and records a terminal answer as the person asked, as before. Rows already
|
||||
answered by the primary user keep their notes;
|
||||
- **shared channels are unknown:** their customers' messages are refused again, as from any unregistered sender, and
|
||||
rows addressed to a channel become undeliverable on the older version;
|
||||
- **organizations, groups and policy aren't shown.**
|
||||
|
||||
Upgrading again restores all of it, because the older version never changes `participants.yaml`.
|
||||
@@ -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,14 +46,10 @@ 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.
|
||||
> - Discovery skills go in `~/.claude/skills` and `~/.agents/skills`.
|
||||
> - Each seat's `CLAUDE.md` or `AGENTS.md` gets OpenRig's managed blocks (culture, identity, onboarding). One more
|
||||
> line, pointing an agent at its seat skills, is added only on a yes: saved at setup, or set where the daemon runs
|
||||
> (`OPENRIG_SKILLS_ROUTER_LINE_ENABLED`).
|
||||
> - **Starting a team in your project:** adds managed guidance to `AGENTS.md` or `CLAUDE.md`, plus the team's skills
|
||||
> and plugins.
|
||||
> - **Permissions:** by default, OpenRig's own kernel gets broader permissions than a project team gets by default.
|
||||
@@ -191,11 +181,11 @@ 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.4/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.4/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
|
||||
@@ -233,15 +223,6 @@ writes an OpenRig-managed block (mouse on, a longer history) into `~/.tmux.conf`
|
||||
It is optional for this selected-provider path, not a requirement to fix an unused
|
||||
provider.
|
||||
|
||||
Setup also prints the one line OpenRig can add to each seat's `CLAUDE.md` or
|
||||
`AGENTS.md`, plus OpenRig's markers around it so it can be removed cleanly: the
|
||||
exact text it writes, which files it goes in, and what a no gives up. Show the person,
|
||||
ask, and save their answer with `rig setup --router-line yes` or
|
||||
`rig setup --router-line no`. No answer isn't a yes: with nothing saved and
|
||||
nothing set in the environment, the line isn't added. The answer covers only
|
||||
that line, and so does what setup says a no gives up; setup's "OpenRig may
|
||||
modify" list names the other writes.
|
||||
|
||||
The block keeps `terminal.history_limit` rows of scrollback per pane (default
|
||||
10000). To keep more, run `rig config set terminal.history_limit 50000`, then
|
||||
`rig setup` again. A running tmux server keeps its old limit until it restarts
|
||||
@@ -252,19 +233,14 @@ New managed agents and handover successors on macOS/Linux start from the daemon'
|
||||
selected launch environment and declared seat values, rather than stored tmux globals.
|
||||
Values named by tmux's `update-environment` option, including its default SSH-agent
|
||||
and display variables, come from the daemon's environment.
|
||||
Normal shell startup and configured tmux shell commands still run. OpenRig reasserts its
|
||||
selected `OPENRIG_DB` and configured `TMUX_TMPDIR` when starting the runtime after shell startup.
|
||||
If your custom setup intentionally supplies values through tmux's global environment,
|
||||
opt in with
|
||||
Normal shell startup and configured tmux shell commands still run. If your custom setup
|
||||
intentionally supplies values through tmux's global environment, opt in with
|
||||
`rig config set terminal.inherit_tmux_environment true`. Setup points out this option
|
||||
when it detects custom tmux configuration; it never enables it automatically. The setting
|
||||
applies to future launches and successors, not running agents or your personal tmux server.
|
||||
If the scoped environment cannot be prepared, launch keeps its previous environment and
|
||||
reports a warning in fresh launch results and the daemon log. For handovers, check
|
||||
that log for this warning. This is not a promise that shell startup cannot add or override values.
|
||||
On Linux, a seat also gets the daemon's `XDG_RUNTIME_DIR`, so tools whose sockets live there work in seats,
|
||||
with `DBUS_SESSION_BUS_ADDRESS=disabled:` so its D-Bus clients don't find your session bus on their own. A seat's
|
||||
own `env:` value for either name wins. See "The runtime directory, on Linux" in the rig spec reference.
|
||||
|
||||
|
||||
## Start the kernel and check its state
|
||||
@@ -331,16 +307,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
|
||||
@@ -404,14 +370,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.
|
||||
|
||||
@@ -510,11 +475,6 @@ rig terminal open saved:kernel --provider cmux --json
|
||||
```
|
||||
|
||||
Use `rig terminal status --json` to inspect provider availability and liveness.
|
||||
To keep rigs in separate Herdr sessions, select one explicitly:
|
||||
`rig terminal open future-prod-rig --provider herdr --session future-prod`.
|
||||
`--session default` selects Herdr's default session; omitting the option keeps
|
||||
the daemon's configured endpoint. See [named terminal sessions](terminal-sessions.md)
|
||||
for desktop windows, previews and session-specific status.
|
||||
For the first desktop handoff, use `--window`. Only if the window cannot open,
|
||||
`rig tui --shared` is the dashboard-only fallback, not the operator's conversation.
|
||||
|
||||
@@ -617,8 +577,8 @@ rig ps --nodes --rig starter
|
||||
|
||||
Preview the selected seats, models and resources. Plan checks resolution and
|
||||
preflight for the working directory. The kernel is already your entry point; this step starts the chosen project
|
||||
team. Read readiness for the project seats, not only daemon health. Have the person answer a named authentication, trust, hook-review or
|
||||
permission prompt before giving that seat work; a launch default, or their answer for another seat, isn't their answer to this one. When a seat stops at such a prompt, `rig up` reports
|
||||
team. Read readiness for the project seats, not only daemon health. Resolve a named authentication, trust or permission prompt before giving
|
||||
that seat work. When a seat stops at such a prompt, `rig up` reports
|
||||
`Status: partial`, prints `Startup attention (<seat>): <reason>` and exits 1;
|
||||
`rig ps --nodes` repeats it as "Startup details". The reason ends with the command
|
||||
to run once the prompt is answered: `rig seat continue <seat>`, which delivers the
|
||||
@@ -704,15 +664,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.
|
||||
@@ -762,56 +716,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.
|
||||
@@ -851,16 +755,14 @@ the lifecycle API without booting a new kernel, then stop the remaining rigs.
|
||||
| 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 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
|
||||
@@ -888,24 +790,6 @@ seats by itself: once a kernel exists, startup skips the built-in kernel boot an
|
||||
were all lost, not seats that failed. `rig status` then reads `Kernel: skipped` unless some of its seats are running
|
||||
and ready, so check `rig ps --nodes --rig kernel --full`.
|
||||
|
||||
**When every agent stops at once on Ubuntu 22.04.** Ubuntu 22.04's packaged tmux, 3.2a, can exit its whole server
|
||||
when it's asked about a pane or session that has just closed. OpenRig checks each seat about once a second, so when a
|
||||
seat's session closes, the next check can stop every agent on that tmux server, and tmux reports "server exited
|
||||
unexpectedly". This was reproduced with Ubuntu 22.04's `tmux 3.2a-4ubuntu0.3` for a closed pane, a closed session,
|
||||
and a closed pane in a window that stayed open. If `tmux -V` prints `tmux 3.2a`, put tmux 3.4 or later first on
|
||||
your `PATH`. Newer versions weren't tested against this crash; #980's reporter runs 3.5a, and none of OpenRig's own
|
||||
machines runs 3.2a. One way to get one is to build a current release from the tmux project, as that reporter did:
|
||||
|
||||
```sh
|
||||
sudo apt install build-essential pkg-config bison libevent-dev libncurses-dev
|
||||
# in the unpacked release tarball:
|
||||
./configure && make && sudo make install # installs into /usr/local/bin
|
||||
```
|
||||
|
||||
Then restart the daemon from a shell where `tmux -V` shows the new tmux. A tmux server that's already running keeps
|
||||
its version until it exits, so the change applies to servers started afterwards. If a crash already stopped your
|
||||
agents, recover as for lost tmux sessions in the table above.
|
||||
|
||||
If a snapshot is unavailable, the startup view checks the selected seat's
|
||||
retained startup source and authoritative occupant relation. It reports a
|
||||
missing or ambiguous source instead of selecting an arbitrary historical row.
|
||||
@@ -1137,10 +1021,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
|
||||
@@ -1258,31 +1138,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`.
|
||||
|
||||
@@ -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,114 +0,0 @@
|
||||
# Hosted members: a tool outside the terminal on your team
|
||||
|
||||
A hosted member is a tool that lives outside a terminal, such as an assistant, a chat bot or a cloud routine, and works as a member of a rig. You
|
||||
address work to it like any seat. It reaches the team through OpenRig's MCP server under a grant you issue and can revoke. It never gets a pane.
|
||||
|
||||
Assistants that run on your machine can reach OpenRig's MCP server locally. Many assistants live in a vendor's cloud, and those can only reach a server on the internet.
|
||||
|
||||
> **Experimental and unsupported. Off by default.** Nothing in OpenRig listens on the internet unless you set it up yourself. You serve `rig mcp serve --http`, put it behind your own TLS tunnel or proxy, and issue a token. Doing that is like opening a port in your firewall. Anyone with the URL and a valid token can act as that member within its grant. Treat the token like a password, give the member the narrowest grant, and revoke it when you're done (`rig mcp token revoke <name>`). We don't support, and take no responsibility for, an OpenRig reachable from the internet, just as with running OpenRig itself on the open internet. Use at your own risk.
|
||||
|
||||
## Set one up
|
||||
|
||||
1. **Pick its address** on an existing rig: `<member>@<rig>`, for example `grok@research`. No node is added; the queue takes rows for it.
|
||||
2. **Say what it's good at, and its limits,** in a roster: an entry in `<workspace.root>/rosters/<file>.json` with its seat, this machine's host id
|
||||
(`hostSelfId` from `rig ps --nodes -A --json`), `capabilities`, `use_when`, `why`, a `caveat` for its limits, and `"hosted": true` (see
|
||||
[Rosters](rosters.md)).
|
||||
3. **Issue its grant:**
|
||||
|
||||
```sh
|
||||
rig mcp token add grok --seat grok@research --rig research
|
||||
```
|
||||
|
||||
It prints the token once. The member acts as `grok@research`: every row it creates or changes is attributed to that seat, and it reaches only
|
||||
that rig's seats and work.
|
||||
4. **Serve the MCP server over HTTP** behind a tunnel or proxy that adds TLS: `rig mcp serve --http`. See `rig mcp serve --help` for binding and
|
||||
for how authentication works. If the proxy runs browser checks (such as Cloudflare's Browser Integrity Check), skip them for `/mcp`:
|
||||
MCP clients aren't browsers.
|
||||
5. **Add the server in the tool:** its `https://…/mcp` URL and the token as a bearer credential.
|
||||
|
||||
## How it works its rows
|
||||
|
||||
- **Hand it work** as you would any seat: `rig queue create --destination grok@research …`.
|
||||
- **The member works it** through the MCP tools:
|
||||
- `rig_queue_list` with `owned` lists its rows;
|
||||
- `rig_queue_claim` takes one;
|
||||
- `rig_queue_update` closes it done with the answer in `resolution`, or `rig_queue_handoff` hands it on to a seat in its rig.
|
||||
- **It can send work too:** `rig_queue_create` to its rig's seats.
|
||||
- **Its rows outlive the conversation.** A new conversation with the same grant reaches the same work.
|
||||
- **`rig_whoami`** answers the seat its grant names, though the member has no session.
|
||||
|
||||
## Asking a person while keeping the work
|
||||
|
||||
When a row needs a person's approval before the member can finish it, the member keeps the row and asks:
|
||||
- **The row came from a person:** the member sets it `blocked` with `blockedOn` that person, and puts the question, with a short form of
|
||||
what it will do, in the row's `summary`. The person is asked with the summary and the row; a transition note stays in the row's history
|
||||
and isn't part of the ask. A longer draft goes at an https link in `evidenceRef`, which the ask carries. The person's answer returns the
|
||||
row to the member in progress.
|
||||
- **The row came from a seat, or from a shared channel:** the member asks as a new row, to that seat, or for a channel's row to its rig's
|
||||
lead (a shared channel takes correspondence and can't answer an ask). Then it sets its own row `blocked` on the new row's id. When the
|
||||
question closes, the member's row returns to pending.
|
||||
- **Keep the question and action summary short.** A long row body continues as numbered thread replies, within the existing delivery
|
||||
limits. Use an https `evidenceRef` for a longer approval draft. The summary and framing can still be too large, so nothing promises
|
||||
unlimited delivery.
|
||||
|
||||
Either way the row reads blocked, not done, while it waits, and the member finishes that same row. Nothing promises to wake the
|
||||
member, so it checks its rows again.
|
||||
|
||||
The server tells the member this in its MCP instructions. The text is versioned, so you can check which version a tool received:
|
||||
|
||||
> Member workflow (member-workflow/v2). Your inbox is the items addressed to your seat: rig_queue_list with owned set. It's yours, not the team's queue. A wake, a ring or a message only points at it: list your inbox, then act. For each item: claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update), or hand it on (rig_queue_handoff). Listing or reading an item doesn't take it. Say what you can reach from your grant, not from the tool list: a listed tool isn't access. rig_artifact_read's answer for an item names the shared roots you may read, and none means no shared files. If the person connected you, that's basically authorized: claim the items in your inbox and do them without asking whether you may. When an item needs a person's approval before you can finish it, don't close it and don't take the action: keep the item and ask. If it came from a person (an address ending in @external that isn't a shared channel; the item's 'Shared channel:' line names one), set it blocked with blockedOn that person and, as its summary, the question with a short form of what you'll do: the person sees the summary and the item, not your note, which keeps the record. Link a longer draft as an https evidenceRef. Their answer returns the item to you in progress. Otherwise ask as a new item, to the seat it came from or, for a shared channel's item, to your rig's lead (rig_team shows it), with your question and draft. Then set yours blocked with blockedOn that new qitemId: when that item closes, yours returns to pending, to claim again. Nothing promises to wake you, so check your inbox again. Then finish the same item; don't open a new one.
|
||||
|
||||
## Ring it when work lands
|
||||
|
||||
If the tool has a trigger that starts it from outside, such as a webhook on a scheduled routine, add it to the member's roster
|
||||
entry. The entry holds secret names, never values:
|
||||
|
||||
```json
|
||||
"wake": { "webhook": { "url_ref": "GROK_WAKE_URL", "authorization_ref": "GROK_WAKE_AUTH", "secrets_env_file": "/abs/path/grok-wake.env" } }
|
||||
```
|
||||
|
||||
- **The secrets:**
|
||||
- `url_ref` names the secret holding the webhook URL.
|
||||
- `authorization_ref`, when the tool needs one, names the secret holding the `Authorization` header value.
|
||||
- Credentials in the URL itself (`https://user:pass@host/…`) are sent as Basic `Authorization` instead, unless
|
||||
`authorization_ref` is set: then its value is sent, and the URL's credentials are dropped.
|
||||
- The daemon reads each from its environment (the name, or `OPENRIG_<NAME>`), else from `secrets_env_file`, an env file kept at mode 0600.
|
||||
- **The ring:** when a row lands for the member, the daemon POSTs
|
||||
`{"source":"openrig.queue","schema_version":1,"event":"work-waiting","qitem_id":…,"destination":…,"summary":…,"from":…}`.
|
||||
- It's a pointer, not the row's body: the member reads the row under its grant.
|
||||
- A receiver should tolerate a repeated `qitem_id`. A ring accepted just before the daemon stops, before it's recorded,
|
||||
is rung again on recovery, the same at-least-once window a terminal wake has.
|
||||
- It waits up to 10 seconds and doesn't follow redirects.
|
||||
- **What the trigger should do:** start a run that lists the member's rows (`rig_queue_list` with `owned`) and claims its work.
|
||||
|
||||
## What the record says, and doesn't
|
||||
|
||||
These facts stay separate on the row:
|
||||
- **Wake:** with a trigger, the row's wake result is `wake-accepted:webhook (<url_ref>) answered <status>` when the trigger took the
|
||||
ring, or `failed:webhook …`. Accepted means the tool's trigger answered, not that the member read the row.
|
||||
- With no trigger, the result is `unroutable`, and OpenRig tells the person: the operator seat when it's a person, else whoever
|
||||
created the row.
|
||||
- It tells the person too when the member was rung but hasn't claimed the work by the stuck sweep's unclaimed age
|
||||
(`queue.stuck_sweep_unclaimed_age_minutes`, 60 by default).
|
||||
- The member sees new work when it checks: when it's rung, when its person asks it to, or on a schedule the tool runs.
|
||||
- **Pickup:** its claim, a transition by its seat. Listing or reading a row isn't pickup.
|
||||
- **Result:** the closing transition and its resolution, or the handoff.
|
||||
- **Notification:** where a person asked, Slack's posted receipt. A posted notice isn't the person's acknowledgment.
|
||||
|
||||
**On the team:** `rig_team` lists the rig's hosted members (roster entries marked hosted that aren't one of its nodes), with their capabilities and caveat, no pane, and activity `unknown`.
|
||||
OpenRig doesn't observe a hosted member between its calls, so it never reads idle. `rig roster find` shows it the same way.
|
||||
- **Presence:** each entry carries `presence`, which says what OpenRig last saw of the member. It isn't pickup, and `null` means unknown:
|
||||
- `lastCallAt`: its last authenticated call to this host's `rig mcp serve --http`, recorded at most once a minute in
|
||||
`<OPENRIG_HOME>/mcp-http-presence.json`, beside the token file;
|
||||
- `lastWake`: its latest ring and the result, among its 50 most recent rows.
|
||||
- `rig roster show` and `find` show the last call; the last ring is in `rig_team`.
|
||||
|
||||
## Revoke
|
||||
|
||||
`rig mcp token revoke grok` refuses its next request. The rows it worked, and their answers, stay.
|
||||
|
||||
## Limits today
|
||||
|
||||
- **Webhook triggers only:** OpenRig rings a webhook. Email and other triggers aren't supported yet. A member without a trigger
|
||||
isn't rung, and its person is told instead.
|
||||
- **It can't act as a person:** it works rows addressed to its seat, a person's request to it included. It can't settle an ask addressed to a person, or hand work to one.
|
||||
@@ -165,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,9 +0,0 @@
|
||||
# HTTP operation correlation
|
||||
|
||||
`GET /api/operations/capabilities` advertises `operation-correlation.v1` and the supported kinds: `rig.create`, `rig.import`, and `member.add`. A client should check this marker before relying on correlation; an older daemon may ignore an unfamiliar request header.
|
||||
|
||||
For `POST /api/rigs`, `POST /api/rigs/import`, and `POST /api/rigs/:rigId/pods/:podNamespace/members`, send a UUID in `X-OpenRig-Operation-Id`. Keep the same daemon, route targets, original request text, and behavior-changing headers for a retry. IDs are case-normalized. An ID that isn't an RFC UUID receives HTTP 400 `Invalid X-OpenRig-Operation-Id` before the route does anything else. Once a request passes the route's own validation, a different request using the same ID receives HTTP 409 `operation_id_conflict`. Matching compares the exact request text, so a change in whitespace or key order is a different request, not an equivalent one; an exact replay receives HTTP 200 with `replayed: true` and current database state. A first request retains its usual status and response fields and adds `operation`.
|
||||
|
||||
The operation contains the rig and node IDs committed by that request. Its `current` field reads the database at response time, including whether recorded resources still exist and the latest recorded session for each node. It does not inspect tmux or prove a launch finished. A reservation with no IDs can mean that execution is still underway or that it failed before creating resources. Replaying an exact request that failed before creating resources returns HTTP 200 with `operation.rigId: null` and `operation.nodeIds: []`; inspect those fields to distinguish it from a replay that recorded resources. Replays never execute the mutation again, retry a launch, or recreate deleted resources. Use a new UUID only when deliberately starting a new execution.
|
||||
|
||||
This contract stores neither the original response nor a recovery verdict. An HTTP response can be lost after a database commit. External launch effects are outside the database transaction, so the contract is correlation rather than exactly-once execution of external effects.
|
||||
@@ -108,24 +108,6 @@ ownership receipt. A later seat start with no project-world input preserves it;
|
||||
an explicit install whose `install.skills` is empty clears it. This keeps
|
||||
"project not supplied" distinct from "project deliberately selects no skills."
|
||||
|
||||
Before refreshing a skill copy, don't take `rig skill audit` as the answer. The
|
||||
audit reports declared provenance, date freshness (its `stale` is a verified date
|
||||
past 90 days or one with no evidence source) and copy hashes. It doesn't say
|
||||
whether a copy is safe to refresh, and two copies with different hashes aren't
|
||||
evidence of a deliberate fork. For a managed copy in a working folder, the
|
||||
read-only check is `rig skill loadout --runtime <claude-code|codex> --cwd
|
||||
<target> --project-root <project> --json`, without `--apply`. It reports
|
||||
`current`; `stale`, still OpenRig's last projection, so `--apply` can refresh it;
|
||||
`conflicting`; `shadowed`; or `missing`. Read the detail of `conflicting` and
|
||||
`shadowed`: `conflicting` can mean the bytes differ or that the target couldn't
|
||||
be inspected, which leaves any difference unknown, and a confirmed difference
|
||||
doesn't prove a deliberate local edit. A seat's own skills, kept outside the
|
||||
project (0.7.1 and later), are listed with where they're stored by
|
||||
`rig skill loadout --seat <session|self> --json`; that read doesn't classify
|
||||
them. A global or untracked copy has no install
|
||||
record, so its origin stays unknown: ask the skill's owner, and don't move global
|
||||
skills into a project just to get one.
|
||||
|
||||
## Work-install project selection
|
||||
|
||||
`rig context work-install` picks one project from the catalog at
|
||||
@@ -186,12 +168,6 @@ The read API adds `GET /api/scopes/projects` and optional `project` and
|
||||
detail also requires the selected mission directory. Existing reads without a
|
||||
project parameter keep their legacy contract.
|
||||
|
||||
`GET /api/scopes?counts=1` is the light list: each mission's slices with only
|
||||
their `dirName`, `id`, `displayName`, `status`, `stage` and `locks` (and
|
||||
`sourcePath` for a selected project), read from each slice's node file alone. It
|
||||
leaves out readiness and proof pairing, which are most of the work and bytes of
|
||||
the default list. `detail=1` takes precedence when both are given.
|
||||
|
||||
## UI Mapping
|
||||
|
||||
- `workspace.root` maps to the Project workspace.
|
||||
|
||||
@@ -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.4`. 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.
|
||||
@@ -193,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
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
-271
@@ -174,7 +174,7 @@ edges:
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `version` | string | yes | — | Use `"0.2"`. Any non-empty string validates; a spec is treated as pod-aware because `pods` is an array. |
|
||||
| `name` | string | yes | — | Rig name. Used in session naming (`{pod}-{member}@{name}`), snapshot identification, and spec library lookup. `rig up <spec> --name <rig>` launches the spec under another name instead (see below). |
|
||||
| `name` | string | yes | — | Rig name. Used in session naming (`{pod}-{member}@{name}`), snapshot identification, and spec library lookup. |
|
||||
| `summary` | string | no | — | Human-readable description. Shown in the spec library, review surfaces and `rig specs preview` (in `rig specs show` only with `--json`). |
|
||||
| `culture_file` | string | no | — | Relative path to a rig-wide culture/constitution file. Must be a safe relative path (no `..`, no absolute). |
|
||||
| `non_interruptive` | boolean | no | — | Pod-aware rig launch-warning default. With full-bypass Claude/Codex seats, true accepts or hides supported harness warnings using launch flags. Explicit CLI choices override this field; this field overrides the machine default. It does not grant permissions, change native settings files, or bypass login. |
|
||||
@@ -188,25 +188,6 @@ edges:
|
||||
| `pods` | Pod[] | yes | — | At least one pod required. Each pod is a bounded context containing members and pod-local edges. |
|
||||
| `edges` | CrossPodEdge[] | no | `[]` | Cross-pod edges connecting members in different pods. Must use fully-qualified `pod.member` IDs. |
|
||||
|
||||
**Launching one spec under another name.** `rig up <spec> --name <rig>` creates the rig as `<rig>` instead of the spec's `name:`, so one spec can run as several rigs at once, for example `--name alpha` and `--name beta`, with seats `{pod}-{member}@alpha` and `{pod}-{member}@beta`. The spec file is not changed. The rig keeps the chosen name from then on: `rig ps`, `rig down alpha`, `rig up alpha --existing`, snapshots and restore all use it. The same checks as a launch without `--name` apply to the chosen name: a running rig of that name refuses the launch, and a stopped one is archived and replaced, as an explicit spec import does today. `--name` does not rename an existing rig, so the CLI refuses it with the name of an existing rig to restore, with a topology and with a GitHub bundle link. A library spec name works: `rig up starter --name beta` launches the library spec as `beta` even while a rig named `starter` exists. Over `--host`, pass the spec path or bundle file with `--name`. A spec that sets `services.project_name` refuses `--name`: every rig launched from it would share that Compose project, and `rig down` or a failed service boot of one would stop the others' services. Without `project_name`, each rig gets its own project. Two launches of the same spec started at the same moment can answer 409 (already in progress for this source) even under different names; start them one after the other.
|
||||
|
||||
### Starting one materialized seat
|
||||
|
||||
`rig import ./team.yaml --materialize-only` records the team without starting its harnesses.
|
||||
For a seat that has never started, use its positional logical ID:
|
||||
|
||||
```bash
|
||||
rig launch <rig-id> dev.lead --json
|
||||
```
|
||||
|
||||
This starts only that seat, preserving the rig's node IDs and the other seats. The daemon retains
|
||||
the materialized declaration and resolves its agent and startup files from the original source
|
||||
root at launch time; keep those files available. Current saved model, effort and permission
|
||||
choices take precedence over the retained declaration; launch reports the differences. A prior snapshot isn't required. Seats with
|
||||
session history continue through restore. Explicit `--snapshot-id` and `--seats` use restore too.
|
||||
`--plan` previews restore selection; it requires a snapshot and does not preflight this first start.
|
||||
Rigs materialized by older daemons lack the retained declaration; this path doesn't retrofit them.
|
||||
|
||||
### Attaching a permission policy
|
||||
|
||||
Attach a policy to a rig with `permission_policy`, either at the rig level or on a member:
|
||||
@@ -236,131 +217,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
|
||||
@@ -498,33 +354,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.
|
||||
- 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):
|
||||
|
||||
@@ -596,18 +435,13 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
|
||||
| `agent_ref` | string | yes | — | Reference to an AgentSpec. Must start with `local:` (relative) or `path:` (absolute). Exception: `builtin:terminal` for infrastructure nodes. |
|
||||
| `profile` | string | yes | — | Profile name from the referenced AgentSpec. Use `default` for the default profile. Exception: `none` for terminal nodes. |
|
||||
| `codex_config_profile` | string | no | — | Codex-only native profile passed as `-p <name>`; letters, numbers, `_`, `.`, `-`. Separate from the AgentSpec `profile`. With the normal launch mode, this replaces OpenRig's explicit workspace-write sandbox flag. A full-bypass policy instead emits danger-full-access and omits this profile argument. |
|
||||
| `codex_permissions` | mapping | no | — | Codex-only sandbox and approval envelope, emitted as explicit flags on every managed launch and resume; see [per-member Codex permissions](#per-member-codex-permissions). |
|
||||
| `runtime` | string | yes | — | Agent runtime. Current supported values: `claude-code`, `codex`, `pi`, `omp`, `terminal`, `stub`. `stub` is a deterministic test harness, not an agent: a Node runner in the seat's tmux pane goes through the normal launch, skill projection, startup-file and readiness path, follows `<cwd>/.openrig/stub/script.json` when present, and calls no model. See "What a stub can and cannot prove" in `docs/as-built/test-layers.md`. |
|
||||
| `cwd` | string | yes | — | Working directory for the agent. A relative path resolves against the rig root (the directory containing the rig spec). If that path exists as written, it is used. Otherwise a leading `~` or `~/` expands to the home directory (`~user` does not) and `$VAR` / `${VAR}` expand from the daemon host's environment; a relative result still resolves against the rig root. A reference to an unset variable stays as written, so the cwd is that literal path and works only if it exists. Use `"."` for the rig root itself. Can be overridden at launch time with `rig up --cwd`. A cwd inside the OpenRig installation fails preflight unless `--cwd` is given. Editing this declaration does not update an existing seat; use [`rig seat set-cwd`](agent-startup-guide.md#change-an-existing-seats-working-directory) to save its future launch directory. |
|
||||
| `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. |
|
||||
| `permission_policy` | string | no | — | Member-level permission policy; overrides the rig's. Not valid on terminal members. |
|
||||
| `env` | map | no | — | Launch environment for this seat, laid over the rig's `env`. Same rules as the rig-level field. Valid on terminal members too. See "Launch environment" below. |
|
||||
| `agent_file` | string | no | — | Claude members only. A Claude agent definition (a `.md` file with `name` in its frontmatter) for this seat. It's copied into the seat's own storage and launched as `--agent openrig:<name>`. A path beside the spec: no `..`, not absolute. |
|
||||
| `append_system_prompt_file` | string | no | — | Claude members only. A prompt file appended to Claude's system prompt for this seat. It's copied into the seat's own storage and passed as `--append-system-prompt-file`. A path beside the spec. |
|
||||
| `plugin_dirs` | list | no | — | Claude members only. Claude plugin folders for this seat, each copied into the seat's own storage and loaded through `CLAUDE_CODE_PLUGIN_DIRS`. Paths beside the spec. Each is copied under its folder name, so two folders with the same name are refused; an empty list is none. A missing file or folder fails that seat's launch by name. `rig bundle create` carries all three; `rig export` names a seat whose additions it doesn't carry. |
|
||||
| `role` | string | no | — | The seat's role, letters, digits, `_`, `.` and `-`. Not valid on terminal members. |
|
||||
| `restore_policy` | string | no | the AgentSpec's | Restore behavior. One of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`. The default comes from the AgentSpec's `defaults.lifecycle` (else `resume_if_possible`) as narrowed by its profile; a member value may only narrow it further, in that order. |
|
||||
| `compaction_strategy` | string | no | — | `default-compaction`, `managed-compaction`, `handover` or `apprentice-handover`; `harness_native` and `pod_continuity` are accepted as deprecated aliases, and `custom_prompt` is refused. `managed-compaction` and `apprentice-handover` create their jobs only for `claude-code` seats; see [Codex compaction and sizing](agent-startup-guide.md#codex-compaction-and-sizing). |
|
||||
@@ -616,36 +450,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 Advisor (`runtime: claude-code`)
|
||||
|
||||
Set `advisor` independently of the primary `model`:
|
||||
|
||||
```yaml
|
||||
members:
|
||||
- id: owner
|
||||
agent_ref: local:agents/implementer
|
||||
profile: default
|
||||
runtime: claude-code
|
||||
cwd: .
|
||||
model: claude-sonnet-5-5
|
||||
effort: medium
|
||||
advisor: opus
|
||||
```
|
||||
|
||||
OpenRig passes `--model claude-sonnet-5-5 --effort medium --advisor opus` to this
|
||||
Claude session. The primary model stays Sonnet. Advisor resolution uses the member's
|
||||
`advisor`, then the profile's `preferences.advisor`, then the agent's `defaults.advisor`.
|
||||
Blank candidates are skipped before applying this precedence. Materialization stores
|
||||
the resolved value, so node/graph output, exported specs and snapshots include it even
|
||||
before the first launch. Launching the original spec re-resolves its current preferences.
|
||||
The selection is supplied again on resume, restore, fresh restart and handover. A fork
|
||||
uses the resulting member's configuration and keeps its own conversation identity.
|
||||
|
||||
This requires a Claude Code version that supports `--advisor`. OpenRig never writes
|
||||
`advisorModel` to user or project settings. Members sharing a checkout can therefore
|
||||
choose different Advisors. When Advisor is unset, no flag is emitted and Claude's
|
||||
own defaults/settings apply. Other runtimes ignore this field.
|
||||
|
||||
### Pi (`runtime: pi`)
|
||||
|
||||
`runtime: pi` launches the Pi coding agent (`pi --mode rpc`) through OpenRig's RPC runner.
|
||||
@@ -653,9 +457,7 @@ own defaults/settings apply. Other runtimes ignore this field.
|
||||
- **State:** Each seat uses `$OPENRIG_HOME/state/pi/<session>/agent` as its Pi agent directory, with `sessions/` beside it, instead of your default `~/.pi/agent`. The runner sets `PI_CODING_AGENT_DIR` to that directory. `<session>` is the seat's session name, unchanged; for a pod member it is `{podId}-{memberId}@{rigName}`. `$OPENRIG_HOME` defaults to `~/.openrig`. OpenRig creates these directories at launch and does not copy anything from `~/.pi/agent`.
|
||||
- **Thinking level:** A Pi seat's effort (the choice saved with `rig seat set-effort`, else the spec's `effort`) is sent to Pi as `--thinking <level>` on every managed launch and resume: fresh launch, successor and restore. Pi accepts `off`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`; OpenRig passes the value as written and does not translate it, so Codex's `none` is not mapped to `off`. Only a value of the shape `set-effort` accepts (a letter, then up to 31 letters, digits, `-` or `_`) is sent; any other spec value, such as one with a space or one that starts with `--`, is left out, and `rig seat effort` says so. An unfamiliar level gets an advisory (a warning from `set-effort`), never a refusal, and Pi itself ignores an unknown level with a warning and keeps the level it would use without the flag: a model suffix, the resumed session's level, or its default. With no effort resolved, no flag is sent. The effort wins over a model suffix, and the model string is never changed. `rig seat effort` reports the level sent at launch and, on Pi 0.99.0 or later, the `thinkingLevel` Pi records on this launch's latest successful assistant turn (error and aborted turns are skipped); that is the session's level after Pi's own cap, not proof the provider applied it, and it is shown apart from the level sent when they differ. On older Pi it is NOT VERIFIED. Without an effort, you can still set the level as a model suffix, for example `model: openrouter/x-ai/grok-4:high`. Pi first looks for a model whose id is the whole pin, and uses it when there is one, suffix included. Only when there is none does Pi read the suffix as the level and record the model without it. Any other suffix, such as OpenRouter's `:free`, stays part of the model id. The model-divergence check reads the pin literally when the seat's `models.json` defines a model with that exact id; otherwise it accepts the model with or without the suffix. If the seat's `models.json` can't be read, a pin with a suffix stays unchecked, with the usual warning, when the recorded model matches the whole pin or the pin without its suffix. A record that matches neither is reported.
|
||||
- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi/<session>/agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file. A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`.
|
||||
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
|
||||
|
||||
- **Extension dialogs:** The RPC runner shows extension `select`, `confirm`, `input` and `editor` requests in the pane and cancels them because the managed pane cannot answer them. The seat needs attention through the end of the turn, until a new agent run starts. Review the cancelled request and send an instruction the extension can complete without a dialog. Extension `notify` text is shown without raising attention or sending a response.
|
||||
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
|
||||
|
||||
### Oh My Pi (`runtime: omp`)
|
||||
|
||||
@@ -665,7 +467,7 @@ own defaults/settings apply. Other runtimes ignore this field.
|
||||
- **Credentials:** Provision each seat separately, or put the provider's key variable in `recovery.provider_auth_env_allowlist` (for example `ANTHROPIC_API_KEY` or `MISTRAL_API_KEY`). The allowlist accepts the key variable of every provider in OpenRig's OMP provider map, from `anthropic` through `litellm`. A seat receives a key only when its `model` is written as `provider/id`, such as `anthropic/claude-sonnet-4-5`. Short names such as `opus` pass no key. A provider whose key is scoped to one endpoint also accepts that endpoint's variable: `anthropic`, `openai` and `litellm` each take their `<PROVIDER>_BASE_URL` — `ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL`, `LITELLM_BASE_URL`. The allowlist admits each variable on its own, so an endpoint named without its key is admitted too, which suits a keyless local proxy. The launch gate admits these endpoint names for OMP seats; the match against the seat's declared `provider/id` happens at the OMP child, which forwards a matching endpoint already in the runner's environment just as it forwards a key. OMP also reads the launch directory's `.env`, so use a trusted working directory.
|
||||
- **Approval posture:** The default floor is `--approval-mode always-ask`. Because the runner is headless, OMP approval requests are cancelled and the seat stays in needing-attention after the turn ends, until the next agent run starts. A `full_bypass` permission policy selects `--approval-mode yolo`.
|
||||
- **Model errors:** Errors are printed in the pane. A completed assistant failure with a numeric `errorStatus` of 408, 429, 500, 502, 503, 504, or 529 returns to idle at terminal `agent_end`, allowing an ordinary retry. Until that terminal boundary it stays in needing-attention. Intermediate `agent_end(isTerminal:false)` events do not release attention. A statusless failed retry summary retains that classification only when its attempt matches an announced retry and its final error exactly matches the latest typed transient assistant failure in that attempt. New assistant/turn boundaries invalidate the previous failure; unmatched summaries remain runtime errors. Rejected prompts, auth/configuration failures, exhausted retries without a typed transient status, and untyped errors (including text such as `529 overloaded`) retain attention until the next agent run starts. The runner does not classify diagnostic text. A transient failure does not clear a denied approval or another unresolved runtime failure in the same turn.
|
||||
- **Restore:** OMP creates its session file after the first persisted turn. A new seat with no persisted turn has no resume token, so a restore leaves it awaiting a decision. Start it fresh with `rig seat launch <seat> --fresh --reason "<why>"`, the command `rig start` and `rig up --existing` print for that seat. After that file exists, OpenRig restores that exact session file. If a full rig restore leaves an OMP seat in `attention_required` or `failed`, `rig seat clear-attention` cannot yet reconcile it to `operator_recovered`, even with `--reason`, because restore reconciliation only verifies Claude Code and Codex processes ([#41](https://github.com/mvschwarz/openrig/issues/41)). Relaunch that seat with `rig seat launch <seat> --fresh --reason "<why>"`, or restore it manually. `rig seat launch --fresh` refuses a managed seat whose pane is still running unless you add `--stop`, which ends what is running there; add it only when you mean to replace that session.
|
||||
- **Restore:** OMP creates its session file after the first persisted turn. A new seat with no persisted turn has no resume token; restoring it requires `rig up --existing <rig> --fresh <seat>`. After that file exists, OpenRig restores that exact session file. If a full rig restore leaves an OMP seat in `attention_required` or `failed`, `rig seat clear-attention` cannot yet reconcile it to `operator_recovered`, even with `--reason`, because restore reconciliation only verifies Claude Code and Codex processes ([#41](https://github.com/mvschwarz/openrig/issues/41)). Relaunch that seat with `rig up --existing <rig> --fresh <seat>`, or restore it manually.
|
||||
|
||||
### Terminal Nodes
|
||||
|
||||
@@ -681,8 +483,6 @@ All three must be present together. Any partial combination is a validation erro
|
||||
|
||||
A terminal seat no longer waits for a `--fresh` decision just because it has no resume token: on restore it relaunches fresh and re-runs its startup actions. The runtime, identity and replay prerequisites still apply. A runtime mismatch still waits for a decision, an ambiguous occupant still fails, and a missing required replay file still blocks.
|
||||
|
||||
`rig send` to a terminal node types the exact text, without the From/To envelope that agent seats get, because the shell would run the envelope lines. The sender's output says so.
|
||||
|
||||
### agent_ref Rules
|
||||
|
||||
- Must start with `local:` or `path:`
|
||||
@@ -718,11 +518,7 @@ This is human-authored (you choose the pod/member IDs) and system-validated (the
|
||||
| `spawned_by` | Source was spawned by target; the target launches first. Constrains launch order. | Child → parent in hierarchical topologies |
|
||||
| `can_observe` | Source can observe target's output. Does NOT constrain launch order. | Reviewer → implementer, monitor → worker |
|
||||
| `collaborates_with` | Peer collaboration relationship. Does NOT constrain launch order. | Co-equal peers working together |
|
||||
| `escalates_to` | Fallback queue escalation for a seat without a declared delegation parent. Does NOT constrain launch order. | Local member or same-daemon `pod-member@rig` address |
|
||||
|
||||
A named `escalates_to` target may be in another rig on the same daemon and need not exist at import time.
|
||||
See [fallback escalation across rigs](edge-types.md#fallback-escalation-across-rigs) for precedence, single-hop delivery,
|
||||
cycle detection and visible fallback outcomes.
|
||||
| `escalates_to` | Source escalates to target for decisions. Does NOT constrain launch order. | Worker → lead for escalation |
|
||||
|
||||
### Pod-Local Edges
|
||||
|
||||
@@ -757,68 +553,12 @@ edges:
|
||||
|
||||
Cross-pod edges must reference different pods. An edge where both `from` and `to` are in the same pod is a validation error — use pod-local edges instead.
|
||||
|
||||
### Changing edges on an existing rig
|
||||
|
||||
Use `rig edge add` to ensure relationships between seats that already exist. It
|
||||
accepts a rig name or ID and **fully qualified logical IDs for both same-pod and
|
||||
cross-pod edges**. This command only changes edges; it does not create pods,
|
||||
restart sessions or change seat identity.
|
||||
|
||||
```sh
|
||||
rig edge add my-rig orch.lead review.reviewer --kind delegates_to --plan --json
|
||||
rig edge add my-rig orch.lead review.reviewer --kind delegates_to --json
|
||||
```
|
||||
|
||||
Run it once per edge. It returns `added` with the new edge's ID, or `present` with
|
||||
the existing edge's ID when that `{from, to, kind}` is already there, and then
|
||||
writes nothing. Existing duplicate rows from other creation paths are retained;
|
||||
this operation does not create more copies.
|
||||
|
||||
Unknown endpoints, an invalid kind or a launch-order cycle leave the graph
|
||||
unchanged. Both endpoints must belong to the target rig. `--plan` performs the
|
||||
same validation and returns `would_add` for a new edge without writing anything.
|
||||
|
||||
Each `added` receipt includes a rollback command,
|
||||
`rig edge remove <rig-id> <edge-id>`, which removes exactly that edge in that rig.
|
||||
Removal also supports `--plan` and `--json`. Removing an edge that is already gone changes nothing
|
||||
and returns `edge_not_found` (HTTP 404, exit 1), so a repeated removal reports not found, not success.
|
||||
Rig names resolve only among unarchived rigs, and an ambiguous name requires an
|
||||
explicit ID. These commands target the local daemon, as `rig add` does.
|
||||
|
||||
A `delegates_to` edge also decides routing. A seat's stuck-row findings and recovery go to its most
|
||||
recently started parent. A person's top-level Slack message in the rig's mapped channel goes to the rig's
|
||||
representative, its one delegation root, unless that channel names an `inbound` seat; with no single root it
|
||||
goes to Slack's inbound destination. When an added or removed edge changes either, the receipt says what
|
||||
moved in `routing`:
|
||||
`orchestrator: {seat, from, to}` and `representative: {from, to}`, where `null` is none. The plain output
|
||||
prints a `routing:` line for each. A change that moves nothing has no `routing`, and `--plan` doesn't
|
||||
preview it.
|
||||
|
||||
Changes are persisted immediately and included in `rig export`. Existing snapshots
|
||||
remain unchanged, and restore reads the **selected snapshot's edges**. To restore
|
||||
the modified topology, run `rig snapshot <rig-id>` and use its returned ID with
|
||||
`rig restore <snapshot-id> --rig <rig-id>`.
|
||||
|
||||
---
|
||||
|
||||
## Startup Block
|
||||
|
||||
Startup blocks can appear at three levels: rig, pod, and member. They are merged additively via the startup layering model (see [the layering model](agent-startup-guide.md#the-layering-model)). The rig's `culture_file` joins as a required file with the `auto` hint in the culture layer, on both fresh start and restore.
|
||||
|
||||
`rig export` includes the rig's saved culture file and startup block, and now also
|
||||
writes each pod's saved startup files under that pod. The file order, delivery
|
||||
hints, required flags, orientation and `applies_on` selections are retained.
|
||||
Previously the saved pod file lists were omitted. Pods without a recorded file
|
||||
list, including older rigs, keep their existing export behavior; the exporter
|
||||
does not reconstruct declarations from a member's combined startup context.
|
||||
|
||||
File paths remain relative to the exported spec's folder. When exporting to a
|
||||
different folder, supply the referenced startup files there or import with the
|
||||
original `--rig-root`. The CLI warns once per missing required rig or pod file;
|
||||
missing optional files do not trigger that warning. Pod startup actions and
|
||||
member startup blocks remain outside this export; keep the source spec when you
|
||||
need those declarations.
|
||||
|
||||
### Files
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
@@ -827,7 +567,7 @@ need those declarations.
|
||||
| `orientation` | string | no | — | Set to `role` to identify a per-seat role file for `rig queue whoami --json` and refocus. Does not change delivery or replay startup. |
|
||||
| `delivery_hint` | string | no | `auto` | How the file is delivered. One of: `auto`, `guidance_merge`, `skill_install`, `send_text`. |
|
||||
| `required` | boolean | no | `true` | Whether startup fails if this file cannot be delivered. |
|
||||
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this file is delivered. Subset of: `fresh_start`, `restore`. An exact resume of the saved conversation delivers none, apart from putting back a missing managed guidance block. |
|
||||
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this file is delivered. Subset of: `fresh_start`, `restore`. |
|
||||
|
||||
#### Delivery Hints
|
||||
|
||||
@@ -845,8 +585,8 @@ need those declarations.
|
||||
| `type` | string | yes | — | Action type. One of: `slash_command`, `send_text`, `startup_proof`. Note: `shell` is explicitly NOT supported in v1. |
|
||||
| `value` | string | yes | — | Command/text to send, or `authenticated` / `none` for `startup_proof`. |
|
||||
| `phase` | string | no | `after_files` | When to execute text/commands. One of: `after_files` (after startup files are delivered), `after_ready` (after harness readiness check passes). Proof selection is resolved before projection regardless of phase. |
|
||||
| `idempotent` | boolean | yes | — | Whether this action is safe to run again on a restore without the earlier conversation or a retried start. **Required field.** Non-idempotent actions must NOT include `restore` in `applies_on`. |
|
||||
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this action runs. Subset of: `fresh_start`, `restore`. An exact resume of the saved conversation runs none (see the startup guide). |
|
||||
| `idempotent` | boolean | yes | — | Whether this action is safe to replay on restore. **Required field.** Non-idempotent actions must NOT include `restore` in `applies_on`. |
|
||||
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this action runs. Subset of: `fresh_start`, `restore`. |
|
||||
|
||||
### Startup proof selection
|
||||
|
||||
|
||||
@@ -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)).
|
||||
@@ -165,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
|
||||
@@ -180,9 +174,7 @@ outside the rollup, even after its slices are accepted, until someone changes
|
||||
its status to `active` (or `release-candidate` for a release that is ready to
|
||||
judge). Nothing changes the status automatically. Missions created before this
|
||||
default carry no status and are not counted either; add the line by hand to
|
||||
include them. It also writes `metadata.name`, the mission's folder name, which
|
||||
the project lifecycle compiler requires; a mission created without it refuses to
|
||||
compile with `lifecycle_field_missing` until you add that line.
|
||||
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
|
||||
|
||||
@@ -1,116 +1,22 @@
|
||||
# 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`, 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.
|
||||
apply. `--raw`, `--force` and `--dangerously-interact` do not bypass the typing guard.
|
||||
Direct human input remains available.
|
||||
|
||||
## Configure and inspect
|
||||
|
||||
### Who may intervene
|
||||
|
||||
`rig seat status <seat>` shows the seat's terminal interaction account alongside
|
||||
its native permissions and interruption level. These choices are independent.
|
||||
The presets `no-peer-interventions`, `help-within-instructions` and `full-control`
|
||||
set the peer row. Self and OpenRig retain their existing authority unless an
|
||||
explicit exception changes it. Named helpers and the delegating instruction are
|
||||
shown; OpenRig does not interpret free-text instructions as executable rules.
|
||||
|
||||
~~~sh
|
||||
rig seat set-terminal-policy reviewer@my-rig --preset help-within-instructions \
|
||||
--helper lead@my-rig --peer-model deny \
|
||||
--reason "Help with compaction; keep the review model"
|
||||
rig seat status reviewer@my-rig
|
||||
~~~
|
||||
|
||||
Each `--peer-compaction`, `--self-compaction`, `--openrig-compaction`,
|
||||
`--peer-model`, `--self-model` or `--openrig-model` exception takes `allow` or
|
||||
`deny`. Model includes reasoning effort. Exceptions apply to the whole actor
|
||||
row: `--peer-compaction allow` also allows unnamed peers. A choice with
|
||||
exceptions reads as Custom. The setter replaces the complete choice; omit
|
||||
exceptions to return to the preset's defaults. Outside a managed seat, use `--operator <address>` for
|
||||
audit attribution. This does not establish an authenticated identity.
|
||||
|
||||
Enforcement covers managed compaction and saved model/effort changes only.
|
||||
The latter take effect at the next managed continuation, not in the current
|
||||
process. Other interventions remain guidance-only. Managed compaction applies
|
||||
to Claude seats; it is not offered for Codex or Pi. A policy change during
|
||||
preparation is checked before further input. It does not undo a submitted
|
||||
`/compact` or abandon an already-owed restore/audit.
|
||||
|
||||
Existing seats retain current behaviour on upgrade. To choose a default for
|
||||
newly added seats in a rig, add `--rig-default` to the setter above: existing
|
||||
seats keep their own copied choices. OpenRig's standing managed-compaction
|
||||
authority stays active unless its exception is deliberately changed.
|
||||
|
||||
Caller attribution is cooperative. An unattributed caller retains current
|
||||
behaviour rather than being labelled the person or a peer. The existing
|
||||
`rig send --dangerously-interact --reason` path remains available; this policy
|
||||
adds no restriction there. Its existing-input check applies independently of the
|
||||
typing mode. Plain tmux is not prevented.
|
||||
|
||||
### Deliberate interaction and existing input
|
||||
|
||||
A managed `rig send <seat> "<answer>" --dangerously-interact --reason "<why>" can
|
||||
drive a recognized native menu as before. Otherwise, with the delivery guard
|
||||
attached, it reads the visible composer under the seat's lease immediately before
|
||||
pasting, after buffer preparation. Empty input proceeds. A recognized dim
|
||||
suggestion is displayed separately and counts as empty; typed or pasted text
|
||||
(author unknown) stops the send with `override_input_present`. An unreadable input
|
||||
stops with `override_input_unknown`, never an empty-input claim. Both return single-send HTTP
|
||||
409, `sent: false`, and caller-only `existingInput` evidence (visible rows, suggestion
|
||||
rows, cursor/dimensions and target binding). A collapsed paste shows its label,
|
||||
not the hidden contents. OpenRig neither holds nor retries this stopped override.
|
||||
|
||||
Inspect the shown input in the native terminal, deliberately clear the prompt with
|
||||
native/tmux controls, then issue the send again. Manual clearing runs outside
|
||||
OpenRig's lease and observation checks and is not recorded. For a deliberate
|
||||
unchecked write, add `--type-without-checking` to that same command. It skips the
|
||||
read and does **not** clear input: the text may join or submit what is already there.
|
||||
The result names that choice. It requires `--dangerously-interact` and `--reason`.
|
||||
|
||||
Observed input is returned only to the caller, never saved in an outbox, held
|
||||
message, capture-observer record or override audit. The audit keeps classifications,
|
||||
counts and the choice, without input contents. The lease serializes OpenRig writes;
|
||||
human typing can still race the capture and paste. Ordinary sends and wakes keep
|
||||
their existing behaviour.
|
||||
|
||||
### Terminal delivery
|
||||
|
||||
Run from a seat shell with `OPENRIG_SESSION_NAME` set; the daemon derives the
|
||||
audit actor from transport identity.
|
||||
|
||||
@@ -123,7 +29,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
|
||||
@@ -253,9 +159,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
|
||||
|
||||
@@ -146,9 +137,7 @@ part of OpenRig.
|
||||
|
||||
## One channel per rig or seat (optional)
|
||||
|
||||
Every ask to a person posts to Slack by default: that's their Slack subscription's `every-ask` preset. A person who wants
|
||||
Slack for urgent asks only sets `rig gateway human set <id> slack-subscription urgent-only`: then only an ask marked
|
||||
`--alert` posts, and the rest wait on Steering and the TUI. An ask marked `--alert` stands out either way (an interrupt with a mention). By default every post goes to the one channel from `rig slack setup --channel`.
|
||||
By default every human-bound item posts to the one channel from `rig slack setup --channel`.
|
||||
To follow each rig separately, post a rig's items, or a single seat's, to its own channel:
|
||||
|
||||
```bash
|
||||
@@ -191,14 +180,6 @@ rig slack channel-map remove pr@my-rig
|
||||
- **Answers come back to where the person asked.** A person's new message is the root of its
|
||||
answer: when the seat it reached replies with `--reply-to` that message's row, the answer and
|
||||
later updates land in the person's thread, in the channel they used.
|
||||
- **One conversation, whoever writes.** Messages routed to the same continuing agent
|
||||
conversation can use that conversation's earlier context, whoever sent them. A queue row
|
||||
records who sent a message and which seat owns it; it doesn't start a separate agent
|
||||
conversation. If people need separate conversations, route them to separate seats
|
||||
(`--inbound` names the seat a channel's new messages reach). Separate channels help only when
|
||||
their routing also keeps the conversations separate: new messages in a seat's own channel
|
||||
still go to the inbound destination unless its entry names `--inbound`. A conversation shared
|
||||
on purpose is fine.
|
||||
- Missed-message recovery (below) covers the default channel only.
|
||||
|
||||
Invite the app to every mapped channel (`/invite @OpenRig`). `rig slack verify` checks
|
||||
@@ -207,8 +188,7 @@ 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" }`
|
||||
@@ -276,7 +256,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)
|
||||
|
||||
@@ -323,7 +303,7 @@ decisions, and posts its own replies.
|
||||
removed. An enabled connector retries them; disabling it pauses retry without deleting them.
|
||||
Let relays finish before removing a forward entry. A zero dead-letter backlog alone does not
|
||||
prove they have drained: it does not count relay HTTP requests still in flight.
|
||||
- **Who answered:** every post starts `From <seat> on <machine>`, using the machine's host id (set
|
||||
- **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.
|
||||
|
||||
@@ -102,34 +102,6 @@ transport or inventory. Failed runs preserve already written output; inspect
|
||||
`unavailable`, `remaining`, call count and stop reason rather than treating a file
|
||||
as proof of success.
|
||||
|
||||
From 0.7.2, `rig project wake --experiment` records its attempts under
|
||||
`<classifier-version>+experiment`. `rig stream show` labels its classifications
|
||||
`experiment`, not intake's, and its abstentions don't keep an item from the typed
|
||||
decisions path below. It still creates no work.
|
||||
|
||||
## Typed decisions in the intake turn (`--decide`)
|
||||
|
||||
With `decisions.enabled` and the `stream` consumer configured (`rig project decisions
|
||||
status`), `rig project wake --decide` takes the same options as a wake and asks a typed
|
||||
decision for each eligible item, within the run's call budget. An accepted answer is
|
||||
written with the item's classification and becomes owned work (a created or joined queue
|
||||
row) or a recorded no-action or duplicate; `rig stream show <id>` shows what became of it.
|
||||
Everything else stays with the intake seat: abstentions, errors, a deferral's reason, and
|
||||
any item that intake or an earlier version already abstained on (an experiment's
|
||||
abstentions excepted). The manual path (`rig project classify`, or a wake with
|
||||
`--decisions`) is unchanged and remains the fallback.
|
||||
|
||||
**Turning decisions off, or changing them mid-run:**
|
||||
- Off means no new provider calls. A run rechecks the settings before every send and halts
|
||||
with `settings_changed`; an answer that arrives after a change is `stale` and isn't applied.
|
||||
- Nothing already committed is discarded. Classifications, dispositions and work are kept,
|
||||
and a disposition accepted before the change but not yet applied is still applied:
|
||||
`rig project wake --decide` with decisions off asks nothing, takes the classifier lease
|
||||
and applies those effects (`recovered` in its output).
|
||||
- One classifier lease serves every path; an effect is applied only by the lease holder.
|
||||
- Old unclassified items are bounded by stream retention (`retention.stream_days`,
|
||||
`retention.stream_max_items`), which keeps items any of these records name.
|
||||
|
||||
## One bounded wake
|
||||
|
||||
From the selected occupant, read candidates using the configured project ID:
|
||||
|
||||
@@ -21,9 +21,6 @@ An empty match set can still have more source records: check `page.hasMore` and
|
||||
`page.nextCursor`. The cursor binds the stream and filters; retain the same filter
|
||||
options when continuing. Do not use `--start` with `--cursor`.
|
||||
|
||||
`rig mcp serve` offers the events read as the `rig_telemetry_events` tool, with the
|
||||
same parameters and envelope; its `rigId` also takes a rig name.
|
||||
|
||||
## HTTP and envelope
|
||||
|
||||
These reads use the daemon's existing browser/access boundary:
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
# Choose a Herdr session for a terminal view
|
||||
|
||||
Use `--session <name>` to open a view in a named Herdr session on the daemon's
|
||||
host. For example, keep development and production rigs in separate sessions:
|
||||
|
||||
```sh
|
||||
rig terminal open future-rig --provider herdr --session default --json
|
||||
rig terminal open future-prod-rig --provider herdr --session future-prod --json
|
||||
rig terminal status --provider herdr --session future-prod --json
|
||||
```
|
||||
|
||||
The explicit provider form places the view in the existing Herdr session
|
||||
without opening a desktop window. Use Herdr's own session management to prepare
|
||||
the session. An unavailable selected session returns `herdr_unavailable`; it
|
||||
does not redirect the view to a different session.
|
||||
|
||||
The `session` field in an open result confirms the requested selection. Status
|
||||
also names the session and returns its resolved control socket in `status.launch`.
|
||||
Session names are a single non-empty name, such as `future-prod`, rather than a
|
||||
socket path. `--session default` explicitly selects Herdr's default session.
|
||||
|
||||
## Default selection and scope
|
||||
|
||||
When Herdr is selected with no `--session`, the daemon uses the endpoint selected when it started:
|
||||
its `HERDR_SOCKET_PATH`, then its `HERDR_SESSION`, then the default endpoint.
|
||||
Setting either variable only on a later `rig terminal open` command does not
|
||||
change that daemon configuration. Pass `--session` for a per-command selection.
|
||||
|
||||
With neither a provider nor a session, the daemon's open and preview APIs try
|
||||
a live Herdr first, then a live cmux. An explicit session always selects Herdr
|
||||
and skips that fallback, including when the daemon's default Herdr endpoint is down.
|
||||
|
||||
Session paths use the daemon's `XDG_CONFIG_HOME` when set, or `HOME/.config`
|
||||
when unset. Relative paths (including an empty value) resolve from the daemon's
|
||||
startup directory, matching Herdr's configuration selection. Named sessions live under
|
||||
`herdr/sessions/<name>/herdr.sock`; `default` uses `herdr/herdr.sock` directly.
|
||||
These settings are captured when the daemon starts.
|
||||
|
||||
If the requested name equals the daemon's `HERDR_SESSION`, it keeps that
|
||||
session's explicit `HERDR_SOCKET_PATH`. A different requested name uses the
|
||||
configuration-tree path, without reusing the override or inferring a session
|
||||
from an arbitrary socket path. With an explicit socket but no configured
|
||||
session name, omit `--session` to keep that exact endpoint. A missing selected
|
||||
socket never falls back to another session. Viewer and follow operations carry
|
||||
that exact socket to the CLI through `HERDR_SOCKET_PATH`, without an explicit
|
||||
CLI `--session` that would override it. They check that the CLI reports the
|
||||
selected socket before changing its panes. Daemon-side CLI calls retain the
|
||||
startup environment; desktop follow uses the socket confirmed by the daemon.
|
||||
|
||||
The selection belongs to the request. Saved views still describe membership
|
||||
and layout, so pass `--session` again when reopening the view in that session.
|
||||
|
||||
The existing view rules apply: saved views, rig and pod views, read-only mission
|
||||
and slice views, pagination, absent seats and partial results all use the same
|
||||
composer. Selecting a session changes where those tiles are placed. It does
|
||||
not relaunch the seats' tmux sessions or move queue ownership.
|
||||
|
||||
## Open results
|
||||
|
||||
For Herdr, a successful layout request alone does not count its seats as opened.
|
||||
OpenRig checks the returned tab's pane list. A missing tab identity or an
|
||||
unreadable, empty or unattributable listing puts the affected seats in
|
||||
`degraded`, with `code: "pane_unconfirmed"`, instead of `opened`. The CLI and
|
||||
TUI name those seats as unconfirmed. Inspect the terminal before retrying:
|
||||
the layout may have taken effect even though its panes could not be confirmed.
|
||||
Confirmed seats on other pages still count; with none confirmed, the command
|
||||
returns a nonzero exit status. Reusing an existing workspace keeps its separate
|
||||
reuse result. This pane check does not prove desktop visibility or create a
|
||||
verified viewer mapping.
|
||||
|
||||
## Replacing a seat's session
|
||||
|
||||
New tiles keep their place when a seat's tmux session is replaced. They wait up
|
||||
to 30 seconds for the same session name and managed seat identity to return,
|
||||
then reattach with the original read-only setting. This uses the shared tile
|
||||
command for Herdr, cmux, Rex and plain tmux, including SSH tiles. In-place
|
||||
handover and restart keep the existing attachment.
|
||||
|
||||
A deliberate detach closes the tile after about two seconds. A different seat
|
||||
under the old name is never followed. If no replacement returns in time, or a
|
||||
session has no managed identity, reopen the rig view with
|
||||
`rig terminal open rig:<name> --window` (or your explicit provider). Views
|
||||
opened before this change must be closed and reopened to get this behavior.
|
||||
|
||||
## Desktop windows and previews
|
||||
|
||||
```sh
|
||||
rig terminal open future-prod-rig --session future-prod --window --json
|
||||
```
|
||||
|
||||
The window launcher, readiness checks, workspace inventory, reuse and layout
|
||||
application use the selected endpoint. When launched inside Herdr, the current
|
||||
Herdr endpoint must match that selection to reuse the current terminal. To
|
||||
place a view in another existing session from such a caller, use the explicit
|
||||
`--provider herdr` form without `--window`.
|
||||
|
||||
An endpoint-mismatch error also gives a command for the caller's own session,
|
||||
such as `rig terminal open future-rig --session work`. Previously it only
|
||||
explained how to place the view in the daemon's selected session. The caller's
|
||||
name comes from the same resolved socket path used by the endpoint check:
|
||||
`~/.config/herdr/sessions/<name>/herdr.sock`, or `default` for
|
||||
`~/.config/herdr/herdr.sock`. An explicit `HERDR_SOCKET_PATH` takes precedence
|
||||
over `HERDR_SESSION` for both the check and the suggestion. Other custom socket
|
||||
paths keep the existing guidance without guessing a session name, even when
|
||||
`HERDR_SESSION` is set. Reporting the mismatch opens nothing.
|
||||
|
||||
Previews include the selected session in their fingerprint. A preview from one
|
||||
session cannot authorize an open in another; refresh the preview after changing
|
||||
the selection. The CLI asks the daemon to confirm named-session support before
|
||||
requesting an open, so an older daemon cannot silently ignore the option and
|
||||
write into its default session. An unconfirmed reply after an open is reported
|
||||
as an unknown outcome; inspect the selected session before retrying.
|
||||
|
||||
`--session` applies to Herdr. A named session does not fall back to plain tmux
|
||||
when Herdr is absent, and combining a session with cmux is rejected. Existing
|
||||
workspaces are kept during reuse checks. Any suggested cleanup command for a
|
||||
stale named-session workspace includes the selected socket.
|
||||
|
||||
## Rex
|
||||
|
||||
```sh
|
||||
rig terminal open future-rig --provider rex --json
|
||||
rig terminal open future-rig --window --json
|
||||
rig terminal status --provider rex --json
|
||||
```
|
||||
|
||||
`--provider rex` opens the view as a new session on the daemon's Rex server,
|
||||
one window per page of the view, without switching any app to it. A tile whose
|
||||
attach fails within ten seconds stays open showing the error, and the result
|
||||
lists that seat as degraded when the failure shows up in the check made about
|
||||
two seconds after opening. OpenRig picks
|
||||
Rex only when it is named, or for `--window` (or no provider) when the command
|
||||
runs inside a Rex block, including in a tmux started there. Everywhere else the
|
||||
defaults above are unchanged, and the daemon's open and preview APIs never pick
|
||||
Rex for a request that names no provider.
|
||||
|
||||
From inside Rex, the window form first checks that the daemon uses the same Rex
|
||||
server as the calling terminal. When they differ, nothing is opened. After the
|
||||
open it asks the Rex app showing the caller's session to switch to the new one,
|
||||
choosing the app whose window holds the caller's terminal when several show it.
|
||||
When no app can be chosen, or the app refuses the switch, the result names the
|
||||
session to select by hand. A Rex app accepts the switch only with Remote Control
|
||||
turned on in its Settings, under Rex Server.
|
||||
|
||||
The daemon finds `rex` in `$REX_BIN_DIR`, then on `PATH`, then inside the Rex
|
||||
app on macOS, and uses its own `REX_SERVER` when set. It never starts a Rex
|
||||
server. An open view is not reused: opening it again adds a numbered session,
|
||||
such as `future-rig (2)`. `--session` applies to Herdr only. An unreachable Rex
|
||||
server returns `rex_unavailable` and a failed session create returns
|
||||
`rex_session_failed`, both as HTTP 200 results with `ok: false`. When Rex
|
||||
doesn't confirm a session create or a page, for example because its reply
|
||||
timed out, OpenRig closes nothing: the result says `rex_outcome_unknown`, or
|
||||
adds a note when other pages opened, and names the session to check in Rex
|
||||
before retrying. `rig terminal status` gives Rex about a second to answer.
|
||||
|
||||
## HTTP API
|
||||
|
||||
- `POST /api/terminal/open`: `{ view, provider?: "herdr", session?: string, expectedPlan?: string }`.
|
||||
- `GET /api/terminal/preview?view=<view>&provider=herdr&session=<name>`.
|
||||
- `GET /api/terminal/status?provider=herdr&session=<name>`.
|
||||
- The alias `POST /api/rigs/:rigId/terminal/open` accepts the same session and
|
||||
preview binding, with the rig determining the view.
|
||||
|
||||
Malformed names and unsupported provider/session combinations return HTTP 400
|
||||
before socket calls. A changed preview returns HTTP 409. An unavailable Herdr
|
||||
session keeps the normal HTTP 200 provider result with `ok: false`, its selected
|
||||
`session`, an error code and direct-attach guidance.
|
||||
The `kernel_seats_unavailable` result also keeps the selected session, so the CLI
|
||||
reports that known failure instead of replacing it with an unconfirmed-session error.
|
||||
|
||||
## Task terminal views
|
||||
|
||||
`rig terminal open qitem:<id> --provider herdr --json` opens a derived, read-only
|
||||
view of the current owners of a local queue episode. The episode includes the
|
||||
requested row and explicitly linked descendants; only pending, in-progress and
|
||||
blocked obligations contribute owners. It does not include historical senders,
|
||||
closed predecessors, parent episodes or siblings. Membership is not input
|
||||
permission.
|
||||
|
||||
The existing daemon endpoints accept the same view:
|
||||
|
||||
```text
|
||||
GET /api/terminal/preview?view=qitem:<id>&provider=herdr
|
||||
POST /api/terminal/open {"view":"qitem:<id>","provider":"herdr","expectedPlan":"<preview planId>"}
|
||||
```
|
||||
|
||||
Preview and open include `task: {qitemId, name, complete, issues, members}`. Each member
|
||||
names its structured `{hostId, nodeId}` key (null when unresolved), seat,
|
||||
contributing `obligations`, `availability` and `viewerStatus`. Stopped, missing,
|
||||
external and unresolved remote owners remain visible. Remote queues are not
|
||||
crawled and no remote tmux socket is guessed. A bounded or damaged lineage is
|
||||
explicitly incomplete; it is never silently presented as the whole team.
|
||||
|
||||
The local lineage read has work limits: 50,000 scanned rows (plus one overflow
|
||||
sentinel), 8 MiB of encoded lineage, 100,000 links, and 16 KiB per chain record.
|
||||
It reads one snapshot from the requested task forward, then probes earlier active
|
||||
obligations using the queue's existing state index and follows references by exact
|
||||
ID, including closed intermediaries. A child may arrive before its referenced root.
|
||||
All these reads share the work limits; unrelated older closed history is not scanned.
|
||||
Only the requested episode is expanded, up to 256 rows. Caller-supplied task IDs
|
||||
are supported.
|
||||
Hitting a work limit preserves known members and reports incomplete membership;
|
||||
it does not claim that an owner outside the scanned history is absent.
|
||||
|
||||
Preview and open also include `viewDescriptor: {kind, name}`: `following` for
|
||||
derived views and `fixed` for a saved layout. A task uses its row summary as
|
||||
its name, falling back to the qitem ID. The existing preview `view` string is
|
||||
unchanged. A following view derives membership when requested; it does not
|
||||
mean an open workspace updates itself.
|
||||
|
||||
Preview is read-only. A member without owned viewer provenance is
|
||||
`not-provisioned`; the ensure operation below can establish a reusable provider
|
||||
mapping. An explicit open retains the provider's existing workspace behavior;
|
||||
it does not continuously reconcile the view or change an agent's lifecycle.
|
||||
|
||||
## Following one task
|
||||
|
||||
`rig terminal open qitem:<id> --follow` keeps one workspace following the
|
||||
task's current obligation owners. A joining member gets a verified local
|
||||
viewer tile without taking focus. A departing member loses only its viewer,
|
||||
after a grace period; the agent and its conversation keep running. The
|
||||
explanation strip names joins, departures, unavailable terminals and limits.
|
||||
The first workspace on an empty provider may become focused.
|
||||
|
||||
This increment supports the qualified **Linux Herdr 0.9.5/protocol 22**
|
||||
endpoint. Other platforms/providers and other selector kinds retain ordinary
|
||||
one-shot open. Use `--provider herdr --json` to operate without opening a
|
||||
desktop window, and `--session <name>` to select that endpoint. Opening a tile
|
||||
can change the agent's terminal size; `ignore-size` does not guarantee isolation.
|
||||
|
||||
```bash
|
||||
rig terminal open qitem:example --follow --provider herdr --json
|
||||
rig terminal open qitem:example --follow --provider herdr --pin reviewer@team
|
||||
rig terminal open qitem:example --follow --provider herdr --max-tiles 12
|
||||
rig terminal open qitem:example --follow --provider herdr --suppress reviewer@team --suppression-ms 60000
|
||||
rig terminal open qitem:example --no-follow
|
||||
```
|
||||
|
||||
`--unpin` and `--unsuppress` reverse those presentation choices. The default
|
||||
cap is eight tiles; explicitly raising `--max-tiles` overrides it. Pins can
|
||||
exceed the cap rather than being evicted. A departed tile retained for typed or
|
||||
unconfirmed input does not consume a slot for joining members. Disabling removes
|
||||
the saved following selector and keeps the existing layout intact.
|
||||
|
||||
The default departure grace is four seconds (`--grace-ms`). A focused leaver
|
||||
gets another twelve seconds for reading (`--focus-retention-ms`); before its
|
||||
tile closes, focus moves to a verified surviving member, or to the explanation
|
||||
strip when none remains. Typed or unconfirmed input keeps the tile, labeled
|
||||
as no longer participating. Explicit suppression also retains a typed draft.
|
||||
Pi, terminal and community harness input may be unreadable to the composer
|
||||
inspector: those tiles remain while input is unconfirmed, even after unpinning.
|
||||
Each input-retained departure gets one delayed recheck after thirty seconds.
|
||||
After that, queue or node/session lifecycle events, or an explicit repeat of
|
||||
the follow command, can reconsider it. Activity hooks do not wake following,
|
||||
and input is not repeatedly polled. Draft text is never saved.
|
||||
|
||||
Following consumes Core events and one-shot retention/suppression deadlines,
|
||||
not repeated CLI membership queries. Complete empty membership drains the
|
||||
tiles and preserves the workspace. Once no retained tiles remain, following
|
||||
stops and the selector releases its active slot; new work does not automatically
|
||||
restart it. Run `--follow` again to resume the same workspace. An incomplete source, failed provider read
|
||||
or changed pane/terminal generation retains the previous view and reports
|
||||
the uncertainty. It never authorizes a blind workspace rebuild or close.
|
||||
`rig terminal views --json` and `rig terminal status --json` include the saved
|
||||
active selectors and latest following status. Active selectors and owned viewer provenance
|
||||
survive daemon restarts; task membership is always re-derived.
|
||||
|
||||
The owner-authorized HTTP operation is `POST /api/terminal/follow` with
|
||||
`{view:"qitem:<id>",provider:"herdr",session?,enabled:true,maxTiles?,pin?,unpin?,
|
||||
suppress?,unsuppress?,suppressionMs?,graceMs?,focusRetentionMs?,expectedPlan?}`.
|
||||
Seat controls are arrays of names from the current/retained view. It uses the
|
||||
existing terminal bearer boundary, like ensure-viewer. The response includes
|
||||
the stable `workspaceId`, normal open partitions, `viewDescriptor`, and
|
||||
`following:{id,enabled,state,maxTiles,tiles,events,overflow,nextAt?}`. Presentation
|
||||
provenance grants no membership or input authority. This is a local native
|
||||
viewer operation; browser input and remote-host provisioning are separate.
|
||||
|
||||
## Ensuring an owner viewer
|
||||
|
||||
The owner-facing HTTP operation `POST /api/terminal/viewer/ensure` accepts
|
||||
`{view:"qitem:<id>", member:{hostId,nodeId}, provider:"herdr", session,
|
||||
expectedPlan}`. Use a current preview's member key and plan. This explicit
|
||||
operation uses the daemon's existing terminal bearer and browser boundary;
|
||||
a bridge must retain its signed-in owner, Origin and expiry controls. It does
|
||||
not grant authority based on task membership or accept a caller terminal ID.
|
||||
|
||||
On the qualified Linux Herdr 0.9.5/protocol 22 endpoint, it creates one viewer
|
||||
or reuses an owned, verified `{workspaceId,paneId,terminalId,readOnly}` mapping.
|
||||
A repeated/concurrent request does not create another viewer. A lost create
|
||||
reply is reconciled before starting anything else; a lost run reply is checked
|
||||
against the actual foreground process, never blindly repeated. Unknown or
|
||||
changed terminal identity remains unconfirmed. The operation does not relayout,
|
||||
close departed viewers, take over control, focus existing panes or launch/stop
|
||||
agents. A first workspace on an otherwise empty Herdr server may become focused.
|
||||
|
||||
Preview remains passive and names `not-provisioned`, `verified` or `unconfirmed`
|
||||
viewer status. Studio can ensure a new member, add its browser frame, and retain
|
||||
a pinned leaver labeled as no longer participating. The provider mapping is
|
||||
small presentation provenance, separate from queue membership. Herdr display
|
||||
labels are not that provenance. Other providers/platforms remain unsupported
|
||||
for ensure-viewer until qualified; ordinary open retains its existing behavior.
|
||||
|
||||
Writable tile clients use `tmux attach -f ignore-size`; read-only clients keep
|
||||
`-r` (which also implies ignore-size). This can avoid contributing to sizing
|
||||
when other clients establish the agent window's dimensions. It is **not resize
|
||||
isolation**: opening a tile can change the agent's terminal size when it is the
|
||||
only sizing client. Observers of a Herdr terminal do not themselves resize it;
|
||||
a controlling browser can. No cross-version resize guarantee is implied.
|
||||
@@ -29,7 +29,7 @@ What a seat runs has its own words:
|
||||
| --- | --- | --- |
|
||||
| Runtime | What a seat runs: a harness, a terminal, or the stub (a scripted stand-in used in tests). It's the rig spec's `runtime` field and the TUI's runtime column | `claude-code`, `codex`, `terminal` |
|
||||
| Harness | The agent software around a model | Claude Code, Codex, Pi |
|
||||
| Community harness | A harness an outside contributor maintains. It's installed as a preset under `$OPENRIG_HOME/community-harnesses/` ([format](community-harness-presets.md)), listed by `rig community-harness list`, and OpenRig doesn't test it | A preset's name in a member's `runtime` |
|
||||
| Community harness | A harness an outside contributor maintains. It's installed as a preset under `$OPENRIG_HOME/community-harnesses/`, listed by `rig community-harness list`, and OpenRig doesn't test it | A preset's name in a member's `runtime` |
|
||||
| Terminal | A seat with no agent: a shell for infrastructure processes | `runtime: terminal` |
|
||||
|
||||
## How the software teams grow
|
||||
|
||||
+113
-163
@@ -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,184 +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
|
||||
|
||||
- **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.
|
||||
- **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`.
|
||||
|
||||
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
+92
-373
File diff suppressed because it is too large.
Load diff
+2
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -28,7 +28,6 @@
|
||||
"gate": "node scripts/gate-lane.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22 || ^24",
|
||||
"npm": ">=10"
|
||||
"node": "^22 || ^24"
|
||||
}
|
||||
}
|
||||
@@ -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,8 +47,7 @@
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hono/node-ws": "^1.3.1",
|
||||
"@modelcontextprotocol/node": "^2.1.2",
|
||||
"@modelcontextprotocol/server": "^2.4.0",
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"ajv": "^8.18.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
@@ -63,8 +62,6 @@
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modelcontextprotocol/client": "^2.4.0",
|
||||
"@modelcontextprotocol/sdk": "^1.32.2",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { appendFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { JsonRpcRequestError, JsonRpcTransportClosed, StdioJsonRpcPeer } from "./json-rpc-stdio.js";
|
||||
import { findRunProcesses, RUN_MARKER } from "./run-processes.js";
|
||||
|
||||
// `rig acp run`: one queue row's task to one ACP agent turn, with the outcome back on that row. OpenRig carries the
|
||||
// agent's permission requests to an owning seat and returns that seat's answer; it holds no permission policy of its
|
||||
// own. The connector runs as the row's custodian seat and names the agent as executor; it never claims as the agent.
|
||||
|
||||
export interface AcpQueueRow {
|
||||
qitemId: string;
|
||||
state: string;
|
||||
destinationSession: string;
|
||||
body: string;
|
||||
resolution?: string | null;
|
||||
tags?: string[] | null;
|
||||
}
|
||||
|
||||
/** The queue verbs the connector uses, as the calling seat. `rig acp` wires these to the daemon's queue API. */
|
||||
export interface AcpQueue {
|
||||
show(qitemId: string): Promise<AcpQueueRow>;
|
||||
claim(qitemId: string): Promise<void>;
|
||||
/** A note, optionally with a state change. A note-only update stores no resolution (the queue keeps one only on close). */
|
||||
update(qitemId: string, change: { note: string; state?: string }): Promise<void>;
|
||||
create(row: { destination: string; body: string; summary: string; tags: string[] }): Promise<string>;
|
||||
}
|
||||
|
||||
export interface AcpRunOptions {
|
||||
qitemId: string;
|
||||
/** The agent's program and arguments, e.g. ["hermes", "acp"]. No shell. */
|
||||
agent: string[];
|
||||
cwd: string;
|
||||
/** The calling seat, which must own the row. */
|
||||
self: string;
|
||||
/** Who answers permission requests (default: the calling seat). */
|
||||
permissionsTo?: string;
|
||||
/** Load this earlier session instead of starting one; refused rather than replaced when it can't load. */
|
||||
loadSession?: string;
|
||||
runId: string;
|
||||
logPath: string;
|
||||
pollMs?: number;
|
||||
/** Live progress for the terminal running the connector. */
|
||||
progress?: (line: string) => void;
|
||||
/** Resolves when the custodian asks to cancel the turn (Ctrl-C or `rig acp cancel`). */
|
||||
cancelRequested?: Promise<void>;
|
||||
/** Resolves when the custodian detaches without waiting (a second Ctrl-C): stdin closes and the turn reads unknown. */
|
||||
detachRequested?: Promise<void>;
|
||||
/** How long to wait for the agent to exit after its stdin closes (default 5 s). */
|
||||
exitWaitMs?: number;
|
||||
}
|
||||
|
||||
export type AcpOutcomeKind = "completed" | "stopped" | "cancelled" | "failed" | "unknown";
|
||||
export interface AcpOutcome { kind: AcpOutcomeKind; summary: string; text: string; sessionId?: string }
|
||||
|
||||
/** Agent text that reports a failed turn while still ending it `end_turn` (OpenClaw #178057; its #44294 is open). */
|
||||
export const INTERRUPTION_MARKERS = ["[OpenClaw interruption]"];
|
||||
const FINAL_STATES = new Set(["done", "canceled", "handed-off", "failed", "denied"]);
|
||||
const NOTE_TEXT_LIMIT = 6000;
|
||||
/** The command a permission row's owner runs; `done` needs a closure reason. */
|
||||
export const answerCommand = (row: string) => `rig queue update ${row} --state done --closure-reason no-follow-on --resolution <optionId>`;
|
||||
|
||||
class DetachedBeforeTurnEnd extends Error {}
|
||||
|
||||
interface PermissionOption { optionId: string; name?: string; kind?: string }
|
||||
|
||||
export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions): Promise<AcpOutcome> {
|
||||
const progress = options.progress ?? (() => {});
|
||||
const pollMs = options.pollMs ?? 2000;
|
||||
const permissionsTo = options.permissionsTo ?? options.self;
|
||||
let row = await queue.show(options.qitemId);
|
||||
if (row.destinationSession !== options.self) {
|
||||
throw new Error(`${options.qitemId} belongs to ${row.destinationSession}; run rig acp from that seat, or hand the row to ${options.self} first`);
|
||||
}
|
||||
if (row.state === "pending") { await queue.claim(options.qitemId); row = await queue.show(options.qitemId); }
|
||||
if (row.state !== "in-progress") throw new Error(`${options.qitemId} is ${row.state}; rig acp runs an in-progress or pending row`);
|
||||
|
||||
mkdirSync(dirname(options.logPath), { recursive: true });
|
||||
const log = (entry: Record<string, unknown>) => appendFileSync(options.logPath, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`);
|
||||
const child = spawn(options.agent[0]!, options.agent.slice(1), {
|
||||
cwd: options.cwd, stdio: "pipe", env: { ...process.env, [RUN_MARKER]: options.runId },
|
||||
});
|
||||
child.stderr.on("data", (data: Buffer) => log({ direction: "stderr", text: data.toString() }));
|
||||
const spawned = new Promise<void>((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
|
||||
const peer = new StdioJsonRpcPeer(child, (direction, packet) => log({ direction, packet }));
|
||||
|
||||
let sessionId: string | undefined;
|
||||
let text = "";
|
||||
let usage: { used?: number; size?: number; cost?: unknown } | undefined;
|
||||
let toolCalls = 0;
|
||||
let lastTool = "";
|
||||
let lastProgressNote = Date.now();
|
||||
let turnOver = false;
|
||||
let cancelRequestedAt: string | undefined;
|
||||
|
||||
peer.onNotification("session/update", (params) => {
|
||||
const { sessionId: from, update } = params as { sessionId?: string; update?: Record<string, unknown> };
|
||||
if (!update || from !== sessionId) return; // another session's events never reach this row
|
||||
if (update.sessionUpdate === "agent_message_chunk") {
|
||||
const content = update.content as { type?: string; text?: string } | undefined;
|
||||
if (content?.type === "text" && content.text) { text += content.text; progress(content.text); }
|
||||
} else if (update.sessionUpdate === "tool_call") {
|
||||
toolCalls += 1; lastTool = `${update.title ?? "tool"} (${update.kind ?? "other"})`;
|
||||
progress(`\n[tool] ${lastTool}\n`);
|
||||
} else if (update.sessionUpdate === "usage_update") {
|
||||
usage = { used: update.used as number, size: update.size as number, cost: update.cost };
|
||||
}
|
||||
if (Date.now() - lastProgressNote >= 60_000) {
|
||||
lastProgressNote = Date.now();
|
||||
void queue.update(row.qitemId, { note: `ACP run ${options.runId} progress: ${toolCalls} tool calls (latest ${lastTool || "none"}), ${text.length} characters of output so far` }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
peer.onRequest("session/request_permission", async (params, requestId) => {
|
||||
const request = params as { sessionId?: string; toolCall?: Record<string, unknown>; options?: PermissionOption[] };
|
||||
if (request.sessionId !== sessionId) throw Object.assign(new Error(`no turn of session ${request.sessionId} is running here`), { code: -32602 });
|
||||
const offered = request.options ?? [];
|
||||
const toolCall = request.toolCall ?? {};
|
||||
for (;;) {
|
||||
if (turnOver || cancelRequestedAt) return { outcome: { outcome: "cancelled" } };
|
||||
const askId = await queue.create({
|
||||
destination: permissionsTo,
|
||||
summary: `Permission for ${row.qitemId}: ${String(toolCall.title ?? toolCall.toolCallId ?? "a tool call")} (${String(toolCall.kind ?? "other")})`,
|
||||
tags: ["acp-permission", ...(row.tags ?? []).filter((tag) => /^(project|mission|slice):/.test(tag))],
|
||||
body: permissionBody(row.qitemId, options.runId, sessionId!, requestId, toolCall, offered, options.logPath),
|
||||
});
|
||||
await queue.update(row.qitemId, { note: `ACP request ${requestId} (session ${sessionId}): permission asked of ${permissionsTo} as ${askId}; the turn waits for that answer` });
|
||||
progress(`\n[permission] asked ${permissionsTo} as ${askId}\n`);
|
||||
for (;;) {
|
||||
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||
if (turnOver || cancelRequestedAt) {
|
||||
await queue.update(askId, { state: "canceled", note: `Stale: the turn of ${row.qitemId} was cancelled before an answer; any answer here is not sent` }).catch(() => {});
|
||||
return { outcome: { outcome: "cancelled" } };
|
||||
}
|
||||
const ask = await queue.show(askId);
|
||||
// The answer point: a cancel that landed during the read above still wins, and nothing is awaited between here and the reply.
|
||||
if (turnOver || cancelRequestedAt) {
|
||||
const stale = `Stale: the turn of ${row.qitemId} was cancelled; ${FINAL_STATES.has(ask.state) ? "this answer was" : "any answer here is"} not sent`;
|
||||
await queue.update(askId, FINAL_STATES.has(ask.state) ? { note: stale } : { state: "canceled", note: stale }).catch(() => {});
|
||||
return { outcome: { outcome: "cancelled" } };
|
||||
}
|
||||
if (!FINAL_STATES.has(ask.state)) continue;
|
||||
const chosen = chooseOption(offered, ask.resolution);
|
||||
if (chosen) {
|
||||
void queue.update(row.qitemId, { note: `ACP request ${requestId}: ${permissionsTo} answered ${chosen.optionId} on ${askId}; sent to the agent once` }).catch(() => {});
|
||||
return { outcome: { outcome: "selected", optionId: chosen.optionId } };
|
||||
}
|
||||
await queue.update(row.qitemId, { note: `ACP request ${requestId}: ${askId} closed ${ask.state} without an offered option (${JSON.stringify(ask.resolution ?? null)}); asking again` });
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const record = async (outcome: AcpOutcome): Promise<AcpOutcome> => {
|
||||
const shown = outcome.text.length > NOTE_TEXT_LIMIT ? `${outcome.text.slice(0, NOTE_TEXT_LIMIT)}\n… (truncated; the full transcript is in ${options.logPath})` : outcome.text;
|
||||
await queue.update(row.qitemId, {
|
||||
note: `ACP run ${options.runId} outcome ${outcome.kind}: ${outcome.summary}${shown ? `\n\n${shown}` : ""}\nThis is the agent's report, not a judgment of the work; the row stays open for you to judge and close with your own resolution.`,
|
||||
});
|
||||
return outcome;
|
||||
};
|
||||
|
||||
try {
|
||||
await spawned;
|
||||
const init = await peer.request("initialize", { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: "openrig", version: "1" } }) as
|
||||
{ protocolVersion?: number; agentCapabilities?: { loadSession?: boolean }; agentInfo?: { name?: string; version?: string } };
|
||||
const loads = init.agentCapabilities?.loadSession === true;
|
||||
if (options.loadSession) {
|
||||
if (!loads) return await record({ kind: "failed", summary: `the agent doesn't advertise loadSession, so session ${options.loadSession} wasn't loaded and no new session was started`, text: "" });
|
||||
await peer.request("session/load", { sessionId: options.loadSession, cwd: options.cwd, mcpServers: [] });
|
||||
sessionId = options.loadSession;
|
||||
} else {
|
||||
sessionId = (await peer.request("session/new", { cwd: options.cwd, mcpServers: [] }) as { sessionId: string }).sessionId;
|
||||
}
|
||||
const agent = `${init.agentInfo?.name ?? options.agent[0]} ${init.agentInfo?.version ?? "(no version reported)"}`;
|
||||
await queue.update(row.qitemId, {
|
||||
note: `ACP run ${options.runId}: executor ${agent} (protocol ${init.protocolVersion ?? "?"}, loadSession ${loads ? "advertised" : "not advertised"}), session ${sessionId} ${options.loadSession ? "loaded" : "new"}, custodian ${options.self}; transcript ${options.logPath}`,
|
||||
});
|
||||
void options.cancelRequested?.then(async () => {
|
||||
if (turnOver || cancelRequestedAt) return;
|
||||
cancelRequestedAt = new Date().toISOString();
|
||||
peer.notify("session/cancel", { sessionId });
|
||||
await queue.update(row.qitemId, { note: `ACP run ${options.runId}: cancel requested at ${cancelRequestedAt}; upstream inference and spend are unknown unless the provider shows them` });
|
||||
});
|
||||
// A detach settles the turn here even when the agent keeps its stdout open after stdin closes.
|
||||
const detached = new Promise<never>((_, reject) => {
|
||||
void options.detachRequested?.then(() => { void peer.end(0); reject(new DetachedBeforeTurnEnd()); });
|
||||
});
|
||||
detached.catch(() => {});
|
||||
const response = await Promise.race([peer.request("session/prompt", { sessionId, prompt: [{ type: "text", text: row.body }] }), detached]) as { stopReason?: string };
|
||||
turnOver = true;
|
||||
return await record(classify(response.stopReason, text, usage, cancelRequestedAt, sessionId));
|
||||
} catch (error) {
|
||||
turnOver = true;
|
||||
if (error instanceof JsonRpcRequestError) {
|
||||
return await record({ kind: "failed", sessionId, text, summary: `${error.method} returned error ${error.error.code} ${error.error.message}${error.error.data === undefined ? "" : ` ${JSON.stringify(error.error.data)}`}` });
|
||||
}
|
||||
if (error instanceof DetachedBeforeTurnEnd) {
|
||||
return await record({ kind: "unknown", sessionId, text, summary: "detached before the turn ended; the agent may still be working" });
|
||||
}
|
||||
if (error instanceof JsonRpcTransportClosed) {
|
||||
return await record({ kind: "unknown", sessionId, text, summary: "the agent's connection closed before the turn ended" });
|
||||
}
|
||||
return await record({ kind: "failed", sessionId, text, summary: `the agent couldn't be run: ${(error as Error).message}` });
|
||||
} finally {
|
||||
turnOver = true;
|
||||
const exit = await peer.end(options.exitWaitMs ?? 5000);
|
||||
const left = await findRunProcesses(options.runId).catch(() => undefined);
|
||||
const settled = left === undefined ? "unknown (the process table couldn't be read)"
|
||||
: left.length === 0 ? "settled: no process carrying this run's marker remains"
|
||||
: `incomplete: ${left.length} process(es) carrying this run's marker still run (${left.map((p) => p.pid).join(", ")}); stop them with the runtime's own stop, or rig acp stop ${options.runId} -- <its stop command>`;
|
||||
await queue.update(row.qitemId, { note: `ACP run ${options.runId} detached: agent ${exit === "still-running" ? "still running after stdin closed" : `exited (code ${exit.code ?? "none"}, signal ${exit.signal ?? "none"})`}; ${settled}` }).catch(() => {});
|
||||
peer.release(); // a still-running agent keeps running, but holds nothing that keeps this process alive
|
||||
}
|
||||
}
|
||||
|
||||
/** The stop reason alone doesn't say the work happened: a silent `end_turn` is unknown, not done. */
|
||||
export function classify(stopReason: string | undefined, text: string, usage: { used?: number } | undefined, cancelRequestedAt: string | undefined, sessionId?: string): AcpOutcome {
|
||||
if (stopReason === "cancelled") {
|
||||
return { kind: "cancelled", sessionId, text, summary: `cancel ${cancelRequestedAt ? `requested at ${cancelRequestedAt}, ` : ""}acknowledged by the agent at ${new Date().toISOString()} (stopReason cancelled); upstream spend is unknown` };
|
||||
}
|
||||
if (stopReason !== "end_turn") return { kind: "stopped", sessionId, text, summary: `the agent stopped with ${stopReason ?? "no stop reason"}` };
|
||||
const interruption = INTERRUPTION_MARKERS.find((marker) => text.includes(marker));
|
||||
if (interruption) return { kind: "unknown", sessionId, text, summary: `the agent ended its turn but reported an interruption (${interruption}); not treated as done` };
|
||||
if (!text.trim() && !(usage?.used && usage.used > 0)) {
|
||||
return { kind: "unknown", sessionId, text, summary: `the agent ended its turn with no output and ${usage ? "zero" : "no"} usage reported; not treated as done` };
|
||||
}
|
||||
return { kind: "completed", sessionId, text, summary: "the agent ended its turn (end_turn)" };
|
||||
}
|
||||
|
||||
/** An owner answers with the offered option's id; its kind (allow_once …) works when only one option has it. */
|
||||
export function chooseOption(offered: PermissionOption[], answer: string | null | undefined): PermissionOption | undefined {
|
||||
const value = answer?.trim();
|
||||
if (!value) return undefined;
|
||||
const byId = offered.find((option) => option.optionId === value);
|
||||
if (byId) return byId;
|
||||
const byKind = offered.filter((option) => option.kind === value);
|
||||
return byKind.length === 1 ? byKind[0] : undefined;
|
||||
}
|
||||
|
||||
function permissionBody(qitemId: string, runId: string, sessionId: string, requestId: string | number, toolCall: Record<string, unknown>, offered: PermissionOption[], logPath: string): string {
|
||||
const input = toolCall.rawInput === undefined ? undefined : JSON.stringify(toolCall.rawInput);
|
||||
const where = Array.isArray(toolCall.locations) ? (toolCall.locations as Array<{ path?: string }>).map((l) => l.path).filter(Boolean).join(", ") : "";
|
||||
return [
|
||||
`# The ACP agent working on ${qitemId} asks permission`,
|
||||
"",
|
||||
`- **Action:** ${String(toolCall.title ?? "(no title)")} (kind ${String(toolCall.kind ?? "other")}, tool call ${String(toolCall.toolCallId ?? "?")})`,
|
||||
where ? `- **Target:** ${where}` : undefined,
|
||||
input === undefined ? undefined : input.length > 2000 ? `- **Input:** \`${input.slice(0, 2000)}\` … (truncated; the full input is in ${logPath})` : `- **Input:** \`${input}\``,
|
||||
`- **Asked by:** ACP run ${runId}, session ${sessionId}, request ${requestId}`,
|
||||
"",
|
||||
"**Options the agent offered:**",
|
||||
...offered.map((option) => `- \`${option.optionId}\`: ${option.name ?? ""}${option.kind ? ` (${option.kind})` : ""}`),
|
||||
"",
|
||||
`Answer on this row with \`${answerCommand("<this row>")}\`; don't hand it off.`,
|
||||
"The agent's own settings and policy decide what it asks; your answer goes back to this one request, once. The turn waits until you answer.",
|
||||
].filter((line) => line !== undefined).join("\n");
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
// ACP v1 is JSON-RPC 2.0 over newline-delimited JSON on the agent's stdio. This peer covers what an OpenRig client
|
||||
// needs (a few outgoing requests, incoming requests and notifications); the official SDK would add a dependency for
|
||||
// a handful of methods, so swap to it only if the method set grows.
|
||||
|
||||
export interface JsonRpcErrorObject {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
/** An error the agent returned, with its original code, message and data kept for the record. */
|
||||
export class JsonRpcRequestError extends Error {
|
||||
constructor(readonly method: string, readonly error: JsonRpcErrorObject) {
|
||||
super(`${method} failed: ${error.code} ${error.message}`);
|
||||
this.name = "JsonRpcRequestError";
|
||||
}
|
||||
}
|
||||
|
||||
/** The connection ended before the agent answered; the request's effect is unknown. */
|
||||
export class JsonRpcTransportClosed extends Error {
|
||||
constructor(readonly method: string) {
|
||||
super(`${method}: the agent's connection closed before it answered`);
|
||||
this.name = "JsonRpcTransportClosed";
|
||||
}
|
||||
}
|
||||
|
||||
export type JsonRpcId = number | string;
|
||||
export type IncomingRequestHandler = (params: unknown, id: JsonRpcId) => Promise<unknown>;
|
||||
export type NotificationHandler = (params: unknown) => void;
|
||||
/** Every packet in both directions, in order; lines that aren't JSON arrive as `in-text`. */
|
||||
export type PacketRecorder = (direction: "out" | "in" | "in-text", packet: unknown) => void;
|
||||
|
||||
export class StdioJsonRpcPeer {
|
||||
private nextId = 0;
|
||||
private readonly pending = new Map<JsonRpcId, { method: string; resolve: (value: unknown) => void; reject: (error: Error) => void }>();
|
||||
private readonly requestHandlers = new Map<string, IncomingRequestHandler>();
|
||||
private readonly notificationHandlers = new Map<string, NotificationHandler>();
|
||||
private ended = false;
|
||||
private readonly lines: ReturnType<typeof createInterface>;
|
||||
readonly exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
|
||||
|
||||
constructor(private readonly child: ChildProcessWithoutNullStreams, private readonly record: PacketRecorder = () => {}) {
|
||||
this.lines = createInterface({ input: child.stdout });
|
||||
this.lines.on("line", (line) => this.receive(line));
|
||||
this.exited = new Promise((resolve) => child.once("close", (code, signal) => {
|
||||
this.ended = true;
|
||||
for (const [id, request] of this.pending) {
|
||||
this.pending.delete(id);
|
||||
request.reject(new JsonRpcTransportClosed(request.method));
|
||||
}
|
||||
resolve({ code, signal });
|
||||
}));
|
||||
child.stdin.on("error", () => { /* EPIPE after the agent exits; pending requests reject on close */ });
|
||||
}
|
||||
|
||||
request(method: string, params: unknown): Promise<unknown> {
|
||||
if (this.ended) return Promise.reject(new JsonRpcTransportClosed(method));
|
||||
const id = ++this.nextId;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { method, resolve, reject });
|
||||
this.send({ jsonrpc: "2.0", id, method, params });
|
||||
});
|
||||
}
|
||||
|
||||
notify(method: string, params: unknown): void {
|
||||
if (!this.ended) this.send({ jsonrpc: "2.0", method, params });
|
||||
}
|
||||
|
||||
onRequest(method: string, handler: IncomingRequestHandler): void {
|
||||
this.requestHandlers.set(method, handler);
|
||||
}
|
||||
|
||||
onNotification(method: string, handler: NotificationHandler): void {
|
||||
this.notificationHandlers.set(method, handler);
|
||||
}
|
||||
|
||||
/** Close the agent's stdin (detach) and wait for the process to exit, up to `timeoutMs`. */
|
||||
async end(timeoutMs: number): Promise<{ code: number | null; signal: NodeJS.Signals | null } | "still-running"> {
|
||||
this.child.stdin.end();
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<"still-running">((resolve) => { timer = setTimeout(() => resolve("still-running"), timeoutMs); });
|
||||
try { return await Promise.race([this.exited, timeout]); } finally { clearTimeout(timer); }
|
||||
}
|
||||
|
||||
/** Drop this side's hold on the agent (its pipes and process handle) without signalling it, so a detached agent that
|
||||
* keeps running doesn't keep the caller's process alive. */
|
||||
release(): void {
|
||||
this.lines.close();
|
||||
for (const stream of [this.child.stdin, this.child.stdout, this.child.stderr]) stream.destroy();
|
||||
this.child.unref();
|
||||
}
|
||||
|
||||
private send(packet: Record<string, unknown>): void {
|
||||
this.record("out", packet);
|
||||
this.child.stdin.write(`${JSON.stringify(packet)}\n`);
|
||||
}
|
||||
|
||||
private receive(line: string): void {
|
||||
if (!line.trim()) return;
|
||||
let packet: Record<string, unknown>;
|
||||
try {
|
||||
const parsed = JSON.parse(line) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
|
||||
packet = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
this.record("in-text", line);
|
||||
return;
|
||||
}
|
||||
this.record("in", packet);
|
||||
const { id, method } = packet;
|
||||
if (typeof method === "string" && (typeof id === "number" || typeof id === "string")) {
|
||||
void this.answer(method, id, packet.params);
|
||||
} else if (typeof method === "string") {
|
||||
this.notificationHandlers.get(method)?.(packet.params);
|
||||
} else if (typeof id === "number" || typeof id === "string") {
|
||||
const request = this.pending.get(id);
|
||||
if (!request) return; // a reply to nothing we asked; already recorded
|
||||
this.pending.delete(id);
|
||||
if (packet.error && typeof packet.error === "object") request.reject(new JsonRpcRequestError(request.method, packet.error as JsonRpcErrorObject));
|
||||
else request.resolve(packet.result);
|
||||
}
|
||||
}
|
||||
|
||||
private async answer(method: string, id: JsonRpcId, params: unknown): Promise<void> {
|
||||
const handler = this.requestHandlers.get(method);
|
||||
if (!handler) {
|
||||
this.send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Client does not handle ${method}` } });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await handler(params, id);
|
||||
if (!this.ended) this.send({ jsonrpc: "2.0", id, result });
|
||||
} catch (error) {
|
||||
const code = typeof (error as { code?: unknown }).code === "number" ? (error as { code: number }).code : -32603;
|
||||
if (!this.ended) this.send({ jsonrpc: "2.0", id, error: { code, message: (error as Error).message } });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
/** Set in the agent's environment by `rig acp run`; every process it launches inherits it, even one that detaches
|
||||
* (PPID 1), which is how a run finds what it started without touching anything it didn't. */
|
||||
export const RUN_MARKER = "OPENRIG_ACP_RUN";
|
||||
|
||||
/** Only the pid: on macOS the listing carries the environment, which may hold secrets, so nothing else leaves here. */
|
||||
export interface RunProcess { pid: number }
|
||||
|
||||
/** The caller's processes carrying this run's marker. Linux reads /proc; elsewhere `ps e` shows own environments. */
|
||||
export async function findRunProcesses(runId: string): Promise<RunProcess[]> {
|
||||
const marker = `${RUN_MARKER}=${runId}`;
|
||||
if (process.platform === "linux") {
|
||||
const found: RunProcess[] = [];
|
||||
for (const entry of await readdir("/proc")) {
|
||||
if (!/^\d+$/.test(entry)) continue;
|
||||
try {
|
||||
const environ = await readFile(`/proc/${entry}/environ`, "utf8");
|
||||
if (!environ.split("\0").includes(marker)) continue;
|
||||
found.push({ pid: Number(entry) });
|
||||
} catch { /* exited, or not ours to read */ }
|
||||
}
|
||||
return found;
|
||||
}
|
||||
const { stdout } = await promisify(execFile)("ps", ["axeww", "-o", "pid=,command="], { maxBuffer: 32 * 1024 * 1024 });
|
||||
return stdout.split("\n").flatMap((line) => {
|
||||
const match = /^\s*(\d+)\s+(.*)$/.exec(line);
|
||||
if (!match || !match[2]!.split(/\s+/).includes(marker)) return [];
|
||||
return [{ pid: Number(match[1]) }];
|
||||
});
|
||||
}
|
||||
+31
-94
@@ -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,33 +14,23 @@ export function terminalAuthHeaders(): Record<string, string> {
|
||||
/** The header carrying the caller's seat identity, derived ONCE at the transport chokepoint. */
|
||||
export const SENDER_IDENTITY_HEADER = "X-OpenRig-Session";
|
||||
|
||||
/** The caller's occupant generation (its seat env), read only by a runtime switch's own checks. */
|
||||
export const OCCUPANT_GENERATION_HEADER = "X-OpenRig-Occupant-Generation";
|
||||
|
||||
/**
|
||||
* P18 sender-provenance — derive the caller's identity from the seat ENV (never a request-body
|
||||
* claim). Routes that record authorship into the channel of record read ONLY this header, so a
|
||||
* buggy/stale caller cannot write false history by fat-fingering or stale-copying a body field.
|
||||
* Absent env ⇒ no header ⇒ the daemon DELIVERS and labels the write `claimed:v1` (P18 deliver-and-label;
|
||||
* a write with no actor at all is 400 actor_required — parameter completeness, not a refusal). One
|
||||
* chokepoint: `DaemonClient.fetch` stamps this on every request; callers never set it by hand. A client built for one
|
||||
* authenticated caller (`sessionName`) carries that caller's seat instead of the process env.
|
||||
* chokepoint: `DaemonClient.fetch` stamps this on every request; callers never set it by hand.
|
||||
*/
|
||||
export function senderIdentityHeaders(originSelfHostId?: string, sessionName?: string): Record<string, string> {
|
||||
const session = (sessionName ?? readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME"))?.trim();
|
||||
export function senderIdentityHeaders(originSelfHostId?: string): Record<string, string> {
|
||||
const session = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")?.trim();
|
||||
if (!session) return {};
|
||||
// Preserve qualified senders; append only a locally derived instance id.
|
||||
const value =
|
||||
originSelfHostId && originSelfHostId.length > 0 && session.split("@").length < 3
|
||||
? `${session}@${originSelfHostId}`
|
||||
: session;
|
||||
// Slice 16: the caller's own occupant generation from its seat env, so the two processes of one seat during a
|
||||
// runtime switch can be told apart. Only a client acting for its own process carries it.
|
||||
const generation = sessionName === undefined ? process.env.OPENRIG_OCCUPANT_GENERATION?.trim() : undefined;
|
||||
return {
|
||||
[SENDER_IDENTITY_HEADER]: value,
|
||||
...(generation && /^[0-9A-Za-z-]{1,64}$/.test(generation) ? { [OCCUPANT_GENERATION_HEADER]: generation } : {}),
|
||||
};
|
||||
return { [SENDER_IDENTITY_HEADER]: value };
|
||||
}
|
||||
|
||||
/** Construct registered remote clients with the origin derived from this instance's durable store. */
|
||||
@@ -122,7 +111,6 @@ export class DaemonResponseError extends Error {
|
||||
export interface DaemonResponse<T = unknown> {
|
||||
status: number;
|
||||
data: T;
|
||||
headers?: Headers;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,15 +135,7 @@ 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 +157,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. */
|
||||
@@ -185,78 +164,43 @@ export class DaemonClient {
|
||||
remoteTarget = false;
|
||||
private identity?: Promise<Record<string, string>>;
|
||||
private originUnknown = false;
|
||||
private readonly sessionName?: string;
|
||||
private readonly principal?: DaemonClientOptions["principal"];
|
||||
|
||||
private async identityHeaders(): Promise<Record<string, string>> {
|
||||
const { headers, local } = await this.senderHeaders();
|
||||
// Only beside a seat this client was built for, and only to this host's own daemon: the endpoint itself must answer
|
||||
// as this host's instance, whichever way it was chosen (OPENRIG_URL, or an explicit URL such as rig mcp serve --port,
|
||||
// which can be a forwarded port). The daemon records it only on a direct, attributed request.
|
||||
if (!this.principal || this.sessionName === undefined || !local || !(await this.endpointIsThisHost())) return headers;
|
||||
// A header value can't carry characters beyond Latin-1 (fetch throws), and a grant name may hold any: everything past
|
||||
// ASCII travels as JSON's own \u escapes, which the daemon's JSON.parse reads back.
|
||||
const principal = JSON.stringify(this.principal).replace(/[\u0080-\uffff]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
||||
return { ...headers, "X-OpenRig-Principal": principal };
|
||||
}
|
||||
|
||||
/** Whether this client's endpoint answers /healthz with the id this host's own daemon minted. */
|
||||
private async endpointIsThisHost(): Promise<boolean> {
|
||||
if (this.remoteTarget) return false;
|
||||
// Imported here, not at the top: it loads better-sqlite3 and yaml.
|
||||
const origin = this.originSelfHostId ?? (await import("./local-origin.js")).readLocalOrigin();
|
||||
return !!origin && (await this.endpointSelfHostId()) === origin;
|
||||
}
|
||||
|
||||
/** The endpoint's own instance id from /healthz, or undefined when it doesn't answer within a second. */
|
||||
private async endpointSelfHostId(): Promise<unknown> {
|
||||
const controller = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
(async () => {
|
||||
const res = await this.fetchImpl(`${this.baseUrl.replace(/\/+$/, "")}/healthz`, { signal: controller.signal });
|
||||
return res.ok ? (await res.json() as { selfHostId?: unknown }).selfHostId : undefined;
|
||||
})(),
|
||||
new Promise<undefined>(resolve => { timer = setTimeout(() => resolve(undefined), Math.min(this.timeoutMs, 1000)); }),
|
||||
]);
|
||||
} catch { return undefined; }
|
||||
finally { clearTimeout(timer); controller.abort(); }
|
||||
}
|
||||
|
||||
/** The sender headers, and whether the endpoint is this host's own daemon: no direct endpoint is configured, or the
|
||||
* endpoint answered with this host's own instance id. */
|
||||
private async senderHeaders(): Promise<{ headers: Record<string, string>; local: boolean }> {
|
||||
const localHeaders = senderIdentityHeaders(undefined, this.sessionName);
|
||||
const localHeaders = senderIdentityHeaders();
|
||||
const session = localHeaders[SENDER_IDENTITY_HEADER];
|
||||
if (!session || session.split("@").length >= 3) return { headers: localHeaders, local: false };
|
||||
const directTarget = readOpenRigUrl();
|
||||
if (!this.remoteTarget && !directTarget) return { headers: localHeaders, local: true };
|
||||
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);
|
||||
}
|
||||
|
||||
constructor(baseUrl?: string, options?: DaemonClientOptions) {
|
||||
if (baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
} else {
|
||||
const envUrl = readOpenRigUrl();
|
||||
const envUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
|
||||
if (envUrl) {
|
||||
this.baseUrl = envUrl;
|
||||
} else {
|
||||
@@ -273,11 +217,8 @@ 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. */
|
||||
@@ -362,7 +303,7 @@ export class DaemonClient {
|
||||
let responseStatus: number | undefined;
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
timeoutMs >= FETCH_HEADERS_TIMEOUT_MS ? this.longFetchImpl : this.fetchImpl,
|
||||
this.fetchImpl,
|
||||
`${this.baseUrl}${path}`,
|
||||
init,
|
||||
{
|
||||
@@ -416,11 +357,7 @@ export class DaemonClient {
|
||||
|
||||
private async requestText(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<DaemonResponse<string>> {
|
||||
let data = "";
|
||||
let headers: Headers | undefined;
|
||||
const res = await this.fetch(path, init, options, async (response) => {
|
||||
headers = response.headers;
|
||||
data = await response.text();
|
||||
});
|
||||
return { status: res.status, data, headers };
|
||||
const res = await this.fetch(path, init, options, async (response) => { data = await response.text(); });
|
||||
return { status: res.status, data };
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { DaemonClient, type DaemonResponse } from "../client.js";
|
||||
import { daemonStatusGuard, getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { processIdentity } from "../local-daemon-owner.js";
|
||||
import { getOpenRigHome, readOpenRigEnv } from "../openrig-compat.js";
|
||||
import { answerCommand, runAcpAssignment, type AcpQueue } from "../acp/connector.js";
|
||||
import { findRunProcesses } from "../acp/run-processes.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
const runsDir = () => join(getOpenRigHome(), "acp", "runs");
|
||||
const newId = (prefix: string, bytes: number) => `${prefix}-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(bytes).toString("hex")}`;
|
||||
|
||||
interface RunFile { runId: string; qitemId: string; pid: number; processStart?: string; agent: string[]; startedAt: string; endedAt?: string }
|
||||
|
||||
/** The connector's queue verbs over the daemon API, acting as the calling seat (the client stamps its session). */
|
||||
export function daemonAcpQueue(client: DaemonClient): AcpQueue {
|
||||
const ok = <T>(response: DaemonResponse<T>, what: string): T => {
|
||||
if (response.status >= 400) throw new Error(`${what} returned HTTP ${response.status}: ${JSON.stringify(response.data)}`);
|
||||
return response.data;
|
||||
};
|
||||
return {
|
||||
async show(qitemId) {
|
||||
const data = ok(await client.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`), `queue show ${qitemId}`);
|
||||
const row = (data.item ?? data) as Record<string, unknown>;
|
||||
return {
|
||||
qitemId: String(row.qitemId), state: String(row.state), destinationSession: String(row.destinationSession),
|
||||
body: typeof row.body === "string" ? row.body : "", resolution: (row.resolution as string | null) ?? null, tags: (row.tags as string[] | null) ?? null,
|
||||
};
|
||||
},
|
||||
async claim(qitemId) { ok(await client.post(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {}), `queue claim ${qitemId}`); },
|
||||
async update(qitemId, change) {
|
||||
ok(await client.post(`/api/queue/${encodeURIComponent(qitemId)}/update`, { transitionNote: change.note, state: change.state }), `queue update ${qitemId}`);
|
||||
},
|
||||
async create(row) {
|
||||
const qitemId = newId("qitem", 8);
|
||||
ok(await client.post("/api/queue/create", { qitemId, destinationSession: row.destination, body: row.body, summary: row.summary, tags: row.tags }), `queue create for ${row.destination}`);
|
||||
return qitemId;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readRun(runId: string): RunFile | undefined {
|
||||
try { return JSON.parse(readFileSync(join(runsDir(), `${runId}.json`), "utf8")) as RunFile; } catch { return undefined; }
|
||||
}
|
||||
|
||||
/** A process's start time, the discriminator the CLI already uses for daemon ownership (pids get reused). `ps` from PATH,
|
||||
* as the other process readers use it: the setuid /bin/ps can't run in a sandbox (macOS CI runs a plain copy). */
|
||||
export function processStart(pid: number): string | undefined {
|
||||
try {
|
||||
return processIdentity(execFileSync("ps", ["-o", "uid=", "-o", "lstart=", "-p", String(pid)], { encoding: "utf8", env: { ...process.env, LC_ALL: "C" } }))?.start;
|
||||
} catch { return undefined; }
|
||||
}
|
||||
|
||||
/** The run is live only while it hasn't ended and its pid is still the process that started it. */
|
||||
function liveConnector(run: RunFile): boolean {
|
||||
return !run.endedAt && run.processStart !== undefined && processStart(run.pid) === run.processStart;
|
||||
}
|
||||
|
||||
export function acpCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("acp").description("Hand a queue row's task to an ACP (Agent Client Protocol) agent and keep its outcome on the row");
|
||||
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
const connect = async (): Promise<DaemonClient | undefined> => {
|
||||
const deps = getDeps();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
return daemonStatusGuard(status) ? deps.clientFactory(getDaemonUrl(status)) : undefined;
|
||||
};
|
||||
const self = () => readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
|
||||
|
||||
cmd
|
||||
.command("run <qitemId> <agent...>")
|
||||
.usage("<qitemId> [options] -- <agent program> [args...]")
|
||||
.description("Send one row's task to an ACP agent as a single turn, and record its outcome on that row")
|
||||
.option("--cwd <dir>", "The agent session's working directory", process.cwd())
|
||||
.option("--permissions-to <session>", "The seat that answers the agent's permission requests (default: this seat)")
|
||||
.option("--load-session <id>", "Continue an earlier session; if the agent can't load it, nothing new is started")
|
||||
.option("--poll-seconds <n>", "How often to look for a permission answer", "2")
|
||||
.addHelpText("after", `
|
||||
Runs as this seat, the row's custodian; the agent is recorded as executor. The row's notes get the binding
|
||||
(agent, session), progress, each permission request and its answer, and the outcome: completed, stopped,
|
||||
cancelled, failed or unknown. The outcome is the agent's report, not a judgment of the work: the row stays open
|
||||
for you to judge, then close or hand on with your own resolution.
|
||||
|
||||
A permission request becomes a row to --permissions-to, listing the options the agent offered. Answer it with
|
||||
${answerCommand("<that row>")}
|
||||
on that row; don't hand it off. OpenRig holds no policy of its own: the agent's settings decide what it
|
||||
asks, and the owner decides the answer.
|
||||
|
||||
Ctrl-C (or rig acp cancel <runId>) asks the agent to cancel the turn; a second Ctrl-C detaches without waiting.
|
||||
Ending the run closes the agent's stdin and reports whether any process carrying the run's marker remains.
|
||||
|
||||
Example:
|
||||
rig acp run <qitem-id> --cwd ~/work/app -- openclaw acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token
|
||||
|
||||
Guide: rig context get reference/acp-agents.md`)
|
||||
.action(async (qitemId: string, agent: string[], opts: { cwd: string; permissionsTo?: string; loadSession?: string; pollSeconds: string }) => {
|
||||
const seat = self();
|
||||
if (!seat) { console.error("rig acp run acts as the seat that owns the row, but OPENRIG_SESSION_NAME isn't set"); process.exitCode = 1; return; }
|
||||
const client = await connect();
|
||||
if (!client) return;
|
||||
const runId = newId("acp", 4);
|
||||
mkdirSync(runsDir(), { recursive: true });
|
||||
const runFile: RunFile = { runId, qitemId, pid: process.pid, processStart: processStart(process.pid), agent, startedAt: new Date().toISOString() };
|
||||
const writeRun = (run: RunFile) => writeFileSync(join(runsDir(), `${runId}.json`), `${JSON.stringify(run)}\n`);
|
||||
writeRun(runFile);
|
||||
let cancel!: () => void;
|
||||
let detach!: () => void;
|
||||
const cancelRequested = new Promise<void>((done) => { cancel = done; });
|
||||
const detachRequested = new Promise<void>((done) => { detach = done; });
|
||||
let interrupts = 0;
|
||||
const onInterrupt = () => {
|
||||
interrupts += 1;
|
||||
if (interrupts === 1) { console.error("\n[rig acp] cancel requested; Ctrl-C again to detach without waiting"); cancel(); } else detach();
|
||||
};
|
||||
process.on("SIGINT", onInterrupt);
|
||||
console.error(`[rig acp] run ${runId} for ${qitemId}; cancel with Ctrl-C or rig acp cancel ${runId}`);
|
||||
try {
|
||||
const outcome = await runAcpAssignment(daemonAcpQueue(client), {
|
||||
qitemId, agent, cwd: resolve(opts.cwd), self: seat, permissionsTo: opts.permissionsTo, loadSession: opts.loadSession,
|
||||
runId, logPath: join(runsDir(), `${runId}.jsonl`), pollMs: Math.max(1, Number(opts.pollSeconds) || 2) * 1000,
|
||||
progress: (text) => process.stdout.write(text), cancelRequested, detachRequested,
|
||||
});
|
||||
console.log(`\n[rig acp] ${outcome.kind}: ${outcome.summary}`);
|
||||
if (outcome.kind !== "completed") process.exitCode = 2;
|
||||
} catch (error) {
|
||||
console.error(`rig acp run: ${(error as Error).message}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
process.removeListener("SIGINT", onInterrupt);
|
||||
writeRun({ ...runFile, endedAt: new Date().toISOString() });
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("cancel <runId>")
|
||||
.description("Ask a running rig acp run to cancel its agent's turn (the same as Ctrl-C there)")
|
||||
.action((runId: string) => {
|
||||
const run = readRun(runId);
|
||||
if (!run || !liveConnector(run)) { console.error(`No running rig acp run ${runId}`); process.exitCode = 1; return; }
|
||||
process.kill(run.pid, "SIGINT");
|
||||
console.log(`Cancel requested for ${runId}; its row ${run.qitemId} records what the agent acknowledges`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("stop <runId> [stopCommand...]")
|
||||
.usage("<runId> [-- <the runtime's own stop command> [args...]]")
|
||||
.description("Run the agent runtime's own stop command, if given, then report whether every process the run launched has exited")
|
||||
.addHelpText("after", `
|
||||
rig acp never kills a process it didn't launch. A gateway the agent connects to but didn't start (OpenClaw's, or a
|
||||
shared one) is left alone; stop it with its own command, if it's yours to stop. The result goes on the run's row.`)
|
||||
.action(async (runId: string, stopCommand: string[]) => {
|
||||
const run = readRun(runId);
|
||||
if (!run) { console.error(`No rig acp run ${runId} on this machine`); process.exitCode = 1; return; }
|
||||
if (liveConnector(run)) { console.error(`${runId} is still running; cancel it or detach first`); process.exitCode = 1; return; }
|
||||
let ran = "no stop command was given";
|
||||
if (stopCommand.length > 0) {
|
||||
const result = spawnSync(stopCommand[0]!, stopCommand.slice(1), { stdio: "inherit" });
|
||||
ran = `${stopCommand.join(" ")} exited ${result.error ? `with ${result.error.message}` : result.status ?? result.signal}`;
|
||||
}
|
||||
const left = await findRunProcesses(runId).catch(() => undefined);
|
||||
const report = left === undefined ? "unknown: the process table couldn't be read"
|
||||
: left.length === 0 ? `settled: no process from ${runId} remains`
|
||||
: `incomplete: ${left.length} process(es) from ${runId} still run (${left.map((p) => p.pid).join(", ")}); they were not stopped`;
|
||||
console.log(`${ran}; ${report}`);
|
||||
if (left?.length !== 0) process.exitCode = 2;
|
||||
const client = self() ? await connect() : undefined;
|
||||
await client?.post(`/api/queue/${encodeURIComponent(run.qitemId)}/update`, { transitionNote: `ACP run ${runId} stop: ${ran}; ${report}` }).catch(() => {});
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -13,11 +13,6 @@ function shellQuote(value: string): string {
|
||||
|
||||
const defaultTmuxExec: TmuxExecFn = (cmd: string) => execSync(cmd, { encoding: "utf-8" }).trim();
|
||||
|
||||
// tmux writes a control character in its output as "_", so a newline between fields can't separate them, and a
|
||||
// session name may hold spaces or underscores. Ask for the two fixed-shape ids first; the rest of the line is the name.
|
||||
const TMUX_CONTEXT_FORMAT = "#{pane_id} #{window_id} #{session_name}";
|
||||
const TMUX_CONTEXT_LINE = /^(%\d+) (@\d+) (.+)$/;
|
||||
|
||||
export function resolveAttachContext(tmuxExec: TmuxExecFn = defaultTmuxExec):
|
||||
| { attachmentType: "external_cli" }
|
||||
| { attachmentType: "tmux"; tmuxSession: string; tmuxWindow?: string; tmuxPane: string } {
|
||||
@@ -27,11 +22,15 @@ export function resolveAttachContext(tmuxExec: TmuxExecFn = defaultTmuxExec):
|
||||
}
|
||||
|
||||
try {
|
||||
const output = tmuxExec(`tmux display-message -p -t ${shellQuote(tmuxPane)} ${shellQuote(TMUX_CONTEXT_FORMAT)}`);
|
||||
const match = TMUX_CONTEXT_LINE.exec(output.trim());
|
||||
if (match) {
|
||||
const [, resolvedPane, tmuxWindow, tmuxSession] = match;
|
||||
return { attachmentType: "tmux", tmuxSession: tmuxSession!, tmuxWindow, tmuxPane: resolvedPane! };
|
||||
const output = tmuxExec(`tmux display-message -p -t ${JSON.stringify(tmuxPane)} "#{session_name}\n#{window_id}\n#{pane_id}"`);
|
||||
const [tmuxSession, tmuxWindow, resolvedPane] = output.split("\n").map((part) => part.trim());
|
||||
if (tmuxSession && resolvedPane) {
|
||||
return {
|
||||
attachmentType: "tmux",
|
||||
tmuxSession,
|
||||
tmuxWindow: tmuxWindow || undefined,
|
||||
tmuxPane: resolvedPane,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall back to external_cli if tmux metadata cannot be resolved.
|
||||
@@ -60,7 +59,7 @@ export function attachCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--cwd <path>", "Working directory to record", process.cwd())
|
||||
.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")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (opts: {
|
||||
self?: boolean;
|
||||
rig: string;
|
||||
|
||||
@@ -17,8 +17,6 @@ import {
|
||||
SEAT_REGISTRY_DISCLAIMER,
|
||||
type CodexAuthPaths,
|
||||
} from "../lib/codex-auth.js";
|
||||
import { localSelfHostId, originFor, runOnSshHost, type RunOnHostDeps } from "../lib/run-on-host.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
|
||||
export interface AuthCommandDeps {
|
||||
/** env source (default process.env) — tests point CODEX_HOME at a fixture. */
|
||||
@@ -27,8 +25,6 @@ export interface AuthCommandDeps {
|
||||
loginStatus?: (codexHome: string) => "logged_in" | "not_logged_in" | "unavailable";
|
||||
/** clock for seats set updated_ts (default new Date().toISOString()). */
|
||||
now?: () => string;
|
||||
/** `--host`: the host registry and the SSH runner (tests inject both). */
|
||||
host?: RunOnHostDeps;
|
||||
}
|
||||
|
||||
function fail(reason: string): void {
|
||||
@@ -48,34 +44,18 @@ function ensureRuntime(runtime: string): boolean {
|
||||
|
||||
export function authCommand(depsOverride?: AuthCommandDeps): Command {
|
||||
const env = depsOverride?.env ?? process.env;
|
||||
const paths = (codexHome?: string): CodexAuthPaths => resolveCodexHome(codexHome ? { ...env, CODEX_HOME: codexHome } : env);
|
||||
const paths = (): CodexAuthPaths => resolveCodexHome(env);
|
||||
const withRuntime = (c: Command): Command => c.option("--runtime <runtime>", "Runtime axis (MVP: codex)", "codex");
|
||||
// Slice 18: the commands that move a Codex home act on a named home, here or on another registered machine.
|
||||
const withHome = (c: Command): Command => c
|
||||
.option("--codex-home <dir>", "The Codex home to act on (default: CODEX_HOME, else ~/.codex)")
|
||||
.option("--host <id>", "Run it on this registered machine instead, over its SSH entry, as this seat");
|
||||
type HomeOpts = { runtime: string; codexHome?: string; host?: string };
|
||||
const onHost = async (verb: string, args: string[], opts: HomeOpts) => {
|
||||
const seat = (env.OPENRIG_SESSION_NAME ?? env.RIGGED_SESSION_NAME)?.trim() || undefined;
|
||||
const origin = await originFor(seat, depsOverride?.host?.selfHostId ?? (() => localSelfHostId(realDeps())));
|
||||
const home = opts.codexHome ? ["--codex-home", opts.codexHome] : [];
|
||||
await runOnSshHost(opts.host!, `auth ${verb}`, ["auth", verb, ...args, "--runtime", opts.runtime, ...home], {
|
||||
origin,
|
||||
// A switch changes that home; status and list only read.
|
||||
...(verb === "switch" ? { changes: { check: `rig auth status --host ${opts.host} --codex-home ${opts.codexHome} (a switch copies the same saved profile again if repeated)` } } : {}),
|
||||
}, depsOverride?.host);
|
||||
};
|
||||
|
||||
const auth = new Command("auth").description(
|
||||
"Manage agent auth profiles (CLI-local; runtime via --runtime). Tokens are never printed, logged, or stored.",
|
||||
);
|
||||
|
||||
withHome(withRuntime(auth.command("status")))
|
||||
withRuntime(auth.command("status"))
|
||||
.description("Auth-file presence + login state (no secrets).")
|
||||
.action(async (opts: HomeOpts) => {
|
||||
.action((opts: { runtime: string }) => {
|
||||
if (!ensureRuntime(opts.runtime)) return;
|
||||
if (opts.host) return onHost("status", [], opts);
|
||||
const r = authStatus(paths(opts.codexHome), { loginStatus: depsOverride?.loginStatus });
|
||||
const r = authStatus(paths(), { loginStatus: depsOverride?.loginStatus });
|
||||
console.log(`codex_home: ${r.codexHome}`);
|
||||
console.log(`codex_home_present: ${r.codexHomePresent ? "yes" : "no"}`);
|
||||
console.log(`active_auth_present: ${r.activeAuthPresent ? "yes" : "no"}`);
|
||||
@@ -85,12 +65,11 @@ export function authCommand(depsOverride?: AuthCommandDeps): Command {
|
||||
console.log(`login_status: ${r.loginStatus}`);
|
||||
});
|
||||
|
||||
withHome(withRuntime(auth.command("list")))
|
||||
withRuntime(auth.command("list"))
|
||||
.description("List saved profiles by name.")
|
||||
.action(async (opts: HomeOpts) => {
|
||||
.action((opts: { runtime: string }) => {
|
||||
if (!ensureRuntime(opts.runtime)) return;
|
||||
if (opts.host) return onHost("list", [], opts);
|
||||
for (const name of authList(paths(opts.codexHome))) console.log(name);
|
||||
for (const name of authList(paths())) console.log(name);
|
||||
});
|
||||
|
||||
withRuntime(auth.command("save <profile>"))
|
||||
@@ -104,24 +83,15 @@ export function authCommand(depsOverride?: AuthCommandDeps): Command {
|
||||
console.log(`profile_mode: ${r.mode}`);
|
||||
});
|
||||
|
||||
withHome(withRuntime(auth.command("switch <profile>")))
|
||||
.description("Restore saved credential bytes (sign-in is unverified).")
|
||||
.addHelpText("after", `
|
||||
With --host, the switch runs on that machine, over its SSH entry, and copies a profile already saved there; name the
|
||||
home with --codex-home (rig auth status --host <id> shows the home it would use). Only the command and its output
|
||||
cross the connection.`)
|
||||
.action(async (profile: string, opts: HomeOpts) => {
|
||||
withRuntime(auth.command("switch <profile>"))
|
||||
.description("Activate a saved profile.")
|
||||
.action((profile: string, opts: { runtime: string }) => {
|
||||
if (!ensureRuntime(opts.runtime)) return;
|
||||
if (opts.host && !opts.codexHome) {
|
||||
return fail(`switch --host needs --codex-home: name the home on ${opts.host} (rig auth status --host ${opts.host} shows the home it would use)`);
|
||||
}
|
||||
if (opts.host) return onHost("switch", [profile], opts);
|
||||
const r = authSwitch(paths(opts.codexHome), profile);
|
||||
const r = authSwitch(paths(), profile);
|
||||
if (!r.ok) return fail(r.reason);
|
||||
console.log(`activated_profile: ${r.name}`);
|
||||
console.log(`active_auth: ${r.activePath}`);
|
||||
console.log(`active_mode: ${r.mode}`);
|
||||
console.log("note: Restored saved credential bytes only; sign-in is unverified. Saved credentials may expire, be revoked, or be superseded by refresh elsewhere. Restart the affected seat and use the existing account verification; fresh sign-in may be required.");
|
||||
console.log(`note: ${r.note}`);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -4,24 +4,7 @@ import { readOpenRigEnv } from "../openrig-compat.js";
|
||||
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
/** tmux's arguments for the session that runs `rig chatroom watch`. The watcher goes to tmux as separate arguments, so
|
||||
* tmux runs it without a shell and the rig name arrives as one argument; `--` keeps a name that starts with `-` from
|
||||
* reading as an option. tmux reads the `-s` value as a format, where `##` is one `#` and a run of `#` before `[` is a
|
||||
* style it keeps as written, so every other `#` is doubled and the session is `chatroom@<rig>`. Some tmux versions
|
||||
* still change a session name (3.5a writes `_` for `:` and `.`, and escapes a backslash or a control character such
|
||||
* as a newline or tab), so `-P -F` prints the name tmux used. */
|
||||
export function chatroomWatchTmuxArgs(rig: string): string[] {
|
||||
const session = `chatroom@${rig}`.replace(/#+(\[)?/g, (run, style?: string) => style ? run : run + run);
|
||||
return ["new-session", "-d", "-P", "-F", "#{session_name}", "-s", tmuxArg(session),
|
||||
"rig", "chatroom", "watch", "--", tmuxArg(rig)];
|
||||
}
|
||||
|
||||
/** tmux ends a command at an argument that ends in `;`, unless a `\` comes before it, so a final `;` gets that `\`. */
|
||||
function tmuxArg(value: string): string {
|
||||
return value.endsWith(";") ? `${value.slice(0, -1)}\\;` : value;
|
||||
}
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
interface RigSummary {
|
||||
id: string;
|
||||
@@ -70,7 +53,6 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
|
||||
// chatroom send <rig> "message" [--sender <name>]
|
||||
cmd
|
||||
.command("send")
|
||||
.description("Post a message to a rig's chat room; the sender is this seat")
|
||||
.argument("<rig>", "Rig name")
|
||||
.argument("<message>", "Message to send")
|
||||
.option("--sender <name>", "(deprecated, ignored) the sender is derived from the seat env (X-OpenRig-Session); P21 made the chat route derive it from the transport header")
|
||||
@@ -109,7 +91,6 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
|
||||
// chatroom history <rig> [--topic <name>] [--limit N] [--json]
|
||||
cmd
|
||||
.command("history")
|
||||
.description("Print a rig's chat messages and topic markers, with filters")
|
||||
.argument("<rig>", "Rig name")
|
||||
.option("--topic <name>", "Filter by topic")
|
||||
.option("--after <id>", "Messages after this message ID")
|
||||
@@ -173,7 +154,6 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
|
||||
// chatroom watch <rig> [--tmux]
|
||||
cmd
|
||||
.command("watch")
|
||||
.description("Print a rig's chat messages as they arrive; --tmux runs it in its own tmux session")
|
||||
.argument("<rig>", "Rig name")
|
||||
.option("--tmux", "Run watch in a dedicated tmux session")
|
||||
.action(async (rig: string, opts: { tmux?: boolean }) => {
|
||||
@@ -192,11 +172,9 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
|
||||
if (opts.tmux) {
|
||||
const sessionName = `chatroom@${rig}`;
|
||||
try {
|
||||
// The name tmux used, which differs from sessionName when tmux changed it (a `.` in a rig name, for example).
|
||||
const created = execFileSync("tmux", chatroomWatchTmuxArgs(rig), { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
|
||||
.trim() || sessionName;
|
||||
console.log(`Started watch in tmux session: ${created}`);
|
||||
console.log(`Attach with: tmux attach -t ${created}`);
|
||||
execSync(`tmux new-session -d -s ${JSON.stringify(sessionName)} "rig chatroom watch ${JSON.stringify(rig)}"`, { stdio: "ignore" });
|
||||
console.log(`Started watch in tmux session: ${sessionName}`);
|
||||
console.log(`Attach with: tmux attach -t ${sessionName}`);
|
||||
} catch {
|
||||
console.error(`Failed to create tmux session '${sessionName}'. It may already exist.`);
|
||||
process.exitCode = 1;
|
||||
@@ -257,7 +235,6 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
|
||||
// chatroom topic <rig> <topic-name> [--body "text"]
|
||||
cmd
|
||||
.command("topic")
|
||||
.description("Start a new topic in a rig's chat room")
|
||||
.argument("<rig>", "Rig name")
|
||||
.argument("<topic-name>", "Topic name")
|
||||
.option("--body <text>", "Optional body text")
|
||||
@@ -293,7 +270,6 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
|
||||
// chatroom wait <rig>
|
||||
cmd
|
||||
.command("wait")
|
||||
.description("Wait for the next matching message in a rig's chat room; exits 1 when --timeout passes")
|
||||
.argument("<rig>", "Rig name")
|
||||
.option("--after <id>", "Only messages after this ID")
|
||||
.option("--topic <name>", "Filter by topic")
|
||||
@@ -406,7 +382,6 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
|
||||
// chatroom clear <rig>
|
||||
cmd
|
||||
.command("clear")
|
||||
.description("Delete every message in a rig's chat room")
|
||||
.argument("<rig>", "Rig name")
|
||||
.action(async (rig: string) => {
|
||||
const client = await getClient();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Command } from "commander";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
|
||||
import { daemonMayAnswer } from "../daemon-reachability.js";
|
||||
@@ -8,8 +7,6 @@ import { realDeps } from "./daemon.js";
|
||||
export interface CompactPlanDeps {
|
||||
lifecycleDeps: LifecycleDeps;
|
||||
clientFactory: (url: string) => DaemonClient;
|
||||
/** Config file that holds the automatic compaction setting; defaults to the rig config. */
|
||||
configPath?: string;
|
||||
}
|
||||
|
||||
interface RigEntry {
|
||||
@@ -56,12 +53,6 @@ interface CompactPlanThresholds {
|
||||
thresholdPercent: number;
|
||||
}
|
||||
|
||||
interface AutomaticCompactionSetting {
|
||||
enabled: boolean | null;
|
||||
thresholdPercent: number | null;
|
||||
unavailableReason: string | null;
|
||||
}
|
||||
|
||||
interface CompactPlanSeatPolicy {
|
||||
oneSeatAtATime: true;
|
||||
autoCompactAllowed: false;
|
||||
@@ -120,16 +111,8 @@ interface CompactPlanResult {
|
||||
mode: "read_only_plan";
|
||||
defaultThresholdTokens: number;
|
||||
defaultThresholdPercent: number;
|
||||
/** The plan's own token threshold; not the automatic compaction setting. */
|
||||
thresholdTokens: number;
|
||||
/** The plan's own fallback percentage; not the automatic compaction setting. */
|
||||
thresholdPercent: number;
|
||||
/** Whether `policies.claude_compaction.enabled` is on; null when the config could not be read. */
|
||||
automaticCompactionEnabled: boolean | null;
|
||||
/** `policies.claude_compaction.threshold_percent`, shown only for comparison; null when the config could not be read. */
|
||||
automaticCompactionThresholdPercent: number | null;
|
||||
/** Why the automatic compaction setting is unknown; null when it was read. */
|
||||
automaticCompactionUnavailableReason: string | null;
|
||||
oneSeatAtATime: true;
|
||||
autoCompactAllowed: false;
|
||||
explicitAuthorizationRequired: true;
|
||||
@@ -320,12 +303,7 @@ function analyzeNode(node: NodeEntry, thresholds: CompactPlanThresholds): Candid
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlan(
|
||||
nodes: NodeEntry[],
|
||||
thresholds: CompactPlanThresholds,
|
||||
incompleteRigs: IncompleteRig[],
|
||||
automaticCompaction: AutomaticCompactionSetting,
|
||||
): CompactPlanResult {
|
||||
function buildPlan(nodes: NodeEntry[], thresholds = defaultThresholds(), incompleteRigs: IncompleteRig[] = []): CompactPlanResult {
|
||||
const candidates: CandidateEntry[] = [];
|
||||
const blocked: BlockedEntry[] = [];
|
||||
const skipped: SkippedEntry[] = [];
|
||||
@@ -359,9 +337,6 @@ function buildPlan(
|
||||
defaultThresholdPercent: PERCENT_FALLBACK_THRESHOLD,
|
||||
thresholdTokens: thresholds.thresholdTokens,
|
||||
thresholdPercent: thresholds.thresholdPercent,
|
||||
automaticCompactionEnabled: automaticCompaction.enabled,
|
||||
automaticCompactionThresholdPercent: automaticCompaction.thresholdPercent,
|
||||
automaticCompactionUnavailableReason: automaticCompaction.unavailableReason,
|
||||
oneSeatAtATime: true,
|
||||
autoCompactAllowed: false,
|
||||
explicitAuthorizationRequired: true,
|
||||
@@ -375,27 +350,13 @@ function buildPlan(
|
||||
};
|
||||
}
|
||||
|
||||
function automaticCompactionSummary(policy: CompactPlanResult["policy"]): string {
|
||||
const enabled = policy.automaticCompactionEnabled;
|
||||
const thresholdPercent = policy.automaticCompactionThresholdPercent;
|
||||
if (enabled == null || thresholdPercent == null) {
|
||||
const reason = policy.automaticCompactionUnavailableReason;
|
||||
return `Automatic compaction: couldn't be read${reason ? `: ${reason}` : ""}; run \`rig config\` to check it.`;
|
||||
}
|
||||
if (enabled) {
|
||||
return `Automatic compaction: on, at ${thresholdPercent}% (policies.claude_compaction.threshold_percent).`;
|
||||
}
|
||||
return `Automatic compaction: off (its threshold setting policies.claude_compaction.threshold_percent is ${thresholdPercent}%).`;
|
||||
}
|
||||
|
||||
function printHuman(plan: CompactPlanResult): void {
|
||||
console.log("READ-ONLY PLAN - does not compact");
|
||||
if (plan.incompleteRigs.length > 0) {
|
||||
console.error(`Warning: partial plan; incomplete rigs: ${plan.incompleteRigs.map(({ rig, status }) => `${rig} (${typeof status === "number" ? `HTTP ${status}` : status})`).join(", ")}`);
|
||||
}
|
||||
console.log("Policy: read_only_plan; one-seat-at-a-time marshal triage; autoCompactAllowed=false; explicit authorization required.");
|
||||
console.log(`Plan thresholds (this plan's own, not the automatic compaction setting): ${plan.policy.thresholdTokens} estimated tokens; ${plan.policy.thresholdPercent}% when context window size is missing.`);
|
||||
console.log(automaticCompactionSummary(plan.policy));
|
||||
console.log(`Thresholds: ${plan.policy.thresholdTokens} estimated tokens; ${plan.policy.thresholdPercent}% when context window size is missing.`);
|
||||
console.log(`Summary: ${plan.summary.candidateCount} candidates | ${plan.summary.blockedCount} blocked | ${plan.summary.skippedCount} skipped`);
|
||||
console.log();
|
||||
|
||||
@@ -458,24 +419,6 @@ Examples:
|
||||
return;
|
||||
}
|
||||
|
||||
let automaticCompaction: AutomaticCompactionSetting;
|
||||
try {
|
||||
const config = new ConfigStore(deps.configPath).resolve();
|
||||
automaticCompaction = {
|
||||
enabled: config.policies.claudeCompaction.enabled,
|
||||
thresholdPercent: config.policies.claudeCompaction.thresholdPercent,
|
||||
unavailableReason: null,
|
||||
};
|
||||
} catch (err) {
|
||||
// The plan is still useful without the setting; report it as unknown rather than
|
||||
// showing a default the person may not have configured, and keep the plan.
|
||||
automaticCompaction = {
|
||||
enabled: null,
|
||||
thresholdPercent: null,
|
||||
unavailableReason: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (!daemonMayAnswer(status)) {
|
||||
console.error("Daemon is not running. Start it with: rig daemon start");
|
||||
@@ -546,7 +489,7 @@ Examples:
|
||||
}
|
||||
}
|
||||
|
||||
const plan = buildPlan(allNodes, thresholds.value, incompleteRigs, automaticCompaction);
|
||||
const plan = buildPlan(allNodes, thresholds.value, incompleteRigs);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(plan, null, 2));
|
||||
} else {
|
||||
|
||||
@@ -101,7 +101,7 @@ already submitted. Raw provider /compact and normal messages are unchanged.`);
|
||||
const stage = res.data["stage"] as string | undefined;
|
||||
const compact = res.data["compact"] as string | undefined;
|
||||
console.log(`Manual compaction for ${session}: ${compact ?? "submitted, unconfirmed"}${stage ? ` (stage: ${stage})` : ""}`);
|
||||
console.log("The restore + read-depth audit prompts follow automatically as the seat drains below threshold, and a daemon restart picks them up where they stopped (rig compact --state shows one too old to resume).");
|
||||
console.log("The restore + read-depth audit prompts follow automatically as the seat drains below threshold, unless the daemon restarts first.");
|
||||
});
|
||||
|
||||
return cmd;
|
||||
|
||||
@@ -11,7 +11,7 @@ function summarizeSettings(store: ConfigStore): Record<ValidKey, ResolvedSetting
|
||||
}
|
||||
|
||||
// These settings remain boot-only; capture interval/line settings reload live.
|
||||
const BOOT_ONLY_KEYS = ["daemon.port", "daemon.host", "daemon.db_conflict", "db.path"];
|
||||
const BOOT_ONLY_KEYS = ["daemon.port", "daemon.host", "db.path"];
|
||||
const BOOT_ONLY_PREFIXES = ["transcripts.enabled", "transcripts.path"];
|
||||
|
||||
function isBootOnlyKey(key: string): boolean {
|
||||
@@ -70,7 +70,6 @@ Keys:
|
||||
context.root addressable context library for 'rig context add' (default $OPENRIG_HOME/context)
|
||||
context.system_world default System World, replacement manifest path, or disabled
|
||||
skills.root versioned skill catalog (default $OPENRIG_HOME/skills)
|
||||
skills.router_line.enabled the person's yes to the seat-skills line (one line plus OpenRig's markers) in AGENTS.md / CLAUDE.md (default false)
|
||||
onboarding.default_pack.enabled deliver the two-part fresh-seat mental-model pack (default on)
|
||||
health.context_pressure.* context-pressure warning/critical percentages (defaults 95/99)
|
||||
files.allowlist name:/abs/path,name:/abs/path
|
||||
@@ -114,15 +113,10 @@ Keys:
|
||||
output.whoami_peers with output.compact on, how many peers rig whoami --json lists, edge peers first; --all-peers lists all (default 8)
|
||||
bundle.services_enabled Service-backed bundle packing, preview and target installs (default off)
|
||||
provider.usage_windows Codex and Claude usage windows in rig provider status (Codex read on request; Claude from its seats' status lines) (default off)
|
||||
provider.selected the providers chosen at rig setup --providers (claude, codex or claude,codex); kernel boot uses only these (default empty: every signed-in provider)
|
||||
usage.pi_cost_facts Pi seats report model-priced cost from their persisted session entries to rig usage cost (default off)
|
||||
usage.pi_openrouter_charge with usage.pi_cost_facts on, Pi seats look up OpenRouter's actual charge for their requests, shown beside the estimate (default off; changes take effect when a Pi seat next launches or resumes; earlier outcomes stay visible)
|
||||
transport.verify_input_box with --verify, a send to a Claude Code or Codex seat (also queue wakes) reads verified only when the seat was proven idle before the paste and its input box showed the exact text before Enter and was empty after it, on the same pane process; a busy or unknown seat, and anything else, reads unconfirmed, not failed. Reporting only (default off)
|
||||
transport.local_user_guard on a Mac, the CLI refuses a local daemon whose reported user, checked with ps, isn't yours; an explicit OPENRIG_URL skips the check. A mitigation for reaching the wrong daemon, not authentication (default off)
|
||||
decisions.enabled typed decisions (OpenRouter + Jev) for the consumers in decisions.consumers, with OPENROUTER_API_KEY from the
|
||||
rig process that runs them; off makes no provider calls (default off). rig project decisions status shows the state
|
||||
decisions.consumers comma-separated consumers that may call (supported: stream; default none). An unknown name makes no calls
|
||||
decisions.template_dir absolute folder of <name>.yaml templates that replace the shipped ones (default empty: shipped templates only)
|
||||
|
||||
Precedence: CLI flag > environment variable > config file > default`)
|
||||
.action((opts: { json?: boolean; withSource?: boolean }) => {
|
||||
|
||||
@@ -113,8 +113,13 @@ interface PreviewWire {
|
||||
missingFiles: Array<{ path: string; role: string }>;
|
||||
}
|
||||
|
||||
/** A write the OS refused for this process: a sandboxed seat's writable roots don't include the topology tree. */
|
||||
const WRITE_REFUSED_CODES = new Set(["EPERM", "EACCES", "EROFS"]);
|
||||
const SAFE_REF_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
|
||||
function assertSafeTopologySegment(kind: "rig" | "seat", value: string): void {
|
||||
if (value === "." || value === ".." || !SAFE_REF_SEGMENT.test(value)) {
|
||||
throw new Error(`unsafe ${kind} segment '${value}' — topology addresses require one bounded path segment`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertLocalGitClient(client: DaemonClient): void {
|
||||
if (!["127.0.0.1", "localhost", "[::1]"].includes(new URL(client.baseUrl).hostname)) {
|
||||
@@ -676,7 +681,6 @@ Examples:
|
||||
// library verb — nothing here sends to a seat. Naming rig/seat (or mission)
|
||||
// is the caller's explicit grant of read access to that directory subtree.
|
||||
cmd.command("profile")
|
||||
.description("Compose a context pack's atoms into the profile for one --situation and print it")
|
||||
.argument("<name-or-ref>", "Context pack name or path-like ref (its manifest must declare atoms)")
|
||||
.requiredOption("--situation <situation>", "fresh | handover | post-compaction")
|
||||
// r1 4d obs 2: default from the seat's own environment — a codex seat that
|
||||
@@ -778,8 +782,7 @@ Examples:
|
||||
// trace: the seat dir resolves from topology.root CONFIG; the write flows
|
||||
// through the ONE store (supersession + addressability gate); advisory
|
||||
// contract findings ride stderr while the write still lands — the boundary
|
||||
// is never blocked on prose shape. A process that may not write the topology
|
||||
// tree (a sandboxed seat) hands the same recap to the daemon, which owns it.
|
||||
// is never blocked on prose shape.
|
||||
cmd.command("recap-write")
|
||||
.description("Write this seat's authored RECAP (decisions-with-rationale) at the handover boundary; supersedes the previous recap into the seat's chain")
|
||||
.requiredOption("--rig <rig>", "Rig name (the rigs/<rig> altitude)")
|
||||
@@ -787,33 +790,29 @@ Examples:
|
||||
.requiredOption("--file <path>", "Markdown file containing the recap content")
|
||||
.action(async (opts: { rig: string; seat: string; file: string }) => {
|
||||
try {
|
||||
const { writeSeatRecapAt, validateRecapContract } = await import("@openrig/daemon/seat-recap-store");
|
||||
const { writeSeatRecap, validateRecapContract, listRecapChain } = await import("@openrig/daemon/seat-recap-store");
|
||||
const content = readFileSync(opts.file, "utf-8");
|
||||
const topologyRoot = String(new ConfigStore().resolveWithSource("topology.root").value);
|
||||
const store = new ConfigStore();
|
||||
const topologyRoot = String(store.resolveWithSource("topology.root").value);
|
||||
assertSafeTopologySegment("rig", opts.rig);
|
||||
assertSafeTopologySegment("seat", opts.seat);
|
||||
const rigDir = join(topologyRoot, "rigs", opts.rig);
|
||||
if (!existsSync(rigDir)) {
|
||||
throw new Error(`rig directory ${rigDir} does not exist — check --rig against the topology tree (topology.root=${topologyRoot}).`);
|
||||
}
|
||||
const seatDir = join(topologyRoot, "rigs", opts.rig, "seats", opts.seat);
|
||||
assertDestinationNamespaceContained(join(topologyRoot, "rigs"), `${opts.rig}/seats/${opts.seat}/RECAP.md`);
|
||||
mkdirSync(seatDir, { recursive: true });
|
||||
for (const f of validateRecapContract(content)) {
|
||||
console.error(f.kind === "no-decisions-section"
|
||||
? "ADVISORY no-decisions-section: the authoring contract asks for decisions WITH rationale — conclusions alone are the lossy handoff shape."
|
||||
: `ADVISORY nonstandard-unverified-marker (line ${f.line}): use the canonical 'UNVERIFIED:' form so uncertain facts stay findable.`);
|
||||
}
|
||||
let written: { path: string; superseded: number; daemonUrl?: string };
|
||||
try {
|
||||
written = writeSeatRecapAt({ topologyRoot, rig: opts.rig, seat: opts.seat, content });
|
||||
} catch (err) {
|
||||
if (!WRITE_REFUSED_CODES.has((err as NodeJS.ErrnoException).code ?? "")) throw err;
|
||||
// The daemon owns the topology tree (it primes RECAP.md at the next rebuild): it writes the same recap through
|
||||
// the same store, so a sandboxed seat saves its recap without a wider writable root. Said first, so a daemon
|
||||
// that can't be reached is reported with its reason.
|
||||
console.error(`Note: this process can't write the topology tree (${(err as Error).message}), so the daemon writes the recap instead.`);
|
||||
const client = await getClient();
|
||||
const res = await client.post<{ path?: string; superseded?: number; message?: string }>("/api/context-packs/seat-recap", { rig: opts.rig, seat: opts.seat, content });
|
||||
if (res.status !== 200 || typeof res.data.path !== "string") throw new Error(res.data.message ?? `Daemon returned HTTP ${res.status}`);
|
||||
written = { path: res.data.path, superseded: res.data.superseded ?? 0, daemonUrl: client.baseUrl };
|
||||
}
|
||||
console.log(`Recap written: ${written.path}${written.superseded > 0 ? ` (${written.superseded} superseded predecessor${written.superseded === 1 ? "" : "s"} retained)` : ""}`
|
||||
+ (written.daemonUrl ? `, by the daemon at ${written.daemonUrl}` : ""));
|
||||
writeSeatRecap({ seatDir, content });
|
||||
const chain = listRecapChain(seatDir);
|
||||
console.log(`Recap written: ${join(seatDir, "RECAP.md")}${chain.length > 0 ? ` (${chain.length} superseded predecessor${chain.length === 1 ? "" : "s"} retained)` : ""}`);
|
||||
} catch (err) {
|
||||
// A daemon write that timed out says its outcome is unknown, so it's checked before a retry supersedes it again.
|
||||
if (!renderCaughtTransportError(err)) console.error((err as Error).message);
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Command } from "commander";
|
||||
import { copyFileSync, lstatSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { getOpenRigHome, readOpenRigUrl } from "../openrig-compat.js";
|
||||
import { getOpenRigHome } from "../openrig-compat.js";
|
||||
import { join } from "node:path";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { formatDaemonHostForUrl } from "../client.js";
|
||||
@@ -54,14 +54,6 @@ function existsOrThrow(path: string): boolean {
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
|
||||
}
|
||||
|
||||
/** The evidence probe's URL: OPENRIG_URL with one trailing slash trimmed, as the detector trims it, else daemon.json's
|
||||
* host and port. The shared reader leaves a query or fragment URL as written, so the trim stays here. */
|
||||
export function crashCartEvidenceUrl(openrigUrl: string | undefined, state: { host?: string; port: number } | undefined): string {
|
||||
return openrigUrl
|
||||
? `${openrigUrl.replace(/\/$/, "")}/healthz`
|
||||
: `http://${formatDaemonHostForUrl(state?.host ?? "127.0.0.1")}:${state?.port ?? 7433}/healthz`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The real verdict: LAZY-imports the narrow @openrig/daemon/crash-cart subpath at invocation (dep
|
||||
* rail 2 — rig startup + other verbs never load the daemon module) and composes the shipped detector
|
||||
@@ -71,7 +63,7 @@ export function crashCartEvidenceUrl(openrigUrl: string | undefined, state: { ho
|
||||
async function realEmit(): Promise<CrashCartEmit> {
|
||||
const cc = await import("@openrig/daemon/crash-cart");
|
||||
const home = getOpenRigHome();
|
||||
const openrigUrl = readOpenRigUrl();
|
||||
const openrigUrl = process.env.OPENRIG_URL;
|
||||
const probeClassified = (url: string) =>
|
||||
cc.probeHealthz(url, { fetch: (u, init) => fetch(u, init as RequestInit), timeoutMs: 700 });
|
||||
const detectDeps = {
|
||||
@@ -91,7 +83,9 @@ async function realEmit(): Promise<CrashCartEmit> {
|
||||
? `alive (pid ${state.pid})`
|
||||
: `dead (pid ${state.pid})`
|
||||
: "no daemon.json";
|
||||
const url = crashCartEvidenceUrl(openrigUrl, state);
|
||||
const url = openrigUrl
|
||||
? `${openrigUrl.replace(/\/$/, "")}/healthz`
|
||||
: `http://${formatDaemonHostForUrl(state?.host ?? "127.0.0.1")}:${state?.port ?? 7433}/healthz`;
|
||||
const probeResult = await probeClassified(url);
|
||||
return { pidState, probeResult, failedSignal: `healthz ${probeResult} at ${url}` };
|
||||
},
|
||||
|
||||
@@ -42,7 +42,7 @@ export function createCommand(depsOverride?: StatusDeps): Command {
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
let agentRef: string;
|
||||
try {
|
||||
agentRef = await resolveDefaultAgentRef(client, opts.runtime);
|
||||
agentRef = await resolveDefaultAgentRef(client);
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exitCode = 1;
|
||||
@@ -61,7 +61,7 @@ export function createCommand(depsOverride?: StatusDeps): Command {
|
||||
id: "lead",
|
||||
agent_ref: agentRef,
|
||||
runtime: opts.runtime,
|
||||
profile: opts.runtime === "terminal" ? "none" : "default",
|
||||
profile: "default",
|
||||
cwd,
|
||||
}],
|
||||
edges: [],
|
||||
|
||||
@@ -167,7 +167,6 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
.option("--port <port>", "Port to listen on")
|
||||
.option("--host <host>", "Host to bind on")
|
||||
.option("--db <path>", "Database path")
|
||||
.option("--allow-db-conflict", "Proceed for this start when another live daemon is confirmed using the database")
|
||||
// V0.3.1 slice 05 kernel-rig-as-default — skip the kernel auto-boot
|
||||
// path. Used by test fixtures, headless CI, and operators who want
|
||||
// a no-kernel daemon for ad-hoc topology work. The daemon proceeds
|
||||
@@ -182,7 +181,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
// weaker "daemon-ready". Default 60s; override with --wait-for-kernel-ms.
|
||||
.option("--wait-for-kernel", "After daemon binds, also wait for kernel-agent readiness (default timeout 60s)")
|
||||
.option("--wait-for-kernel-ms <ms>", "Override --wait-for-kernel timeout in milliseconds")
|
||||
.action(async (opts: { port?: string; host?: string; db?: string; allowDbConflict?: boolean; kernel?: boolean; waitForKernel?: boolean; waitForKernelMs?: string }) => {
|
||||
.action(async (opts: { port?: string; host?: string; db?: string; kernel?: boolean; waitForKernel?: boolean; waitForKernelMs?: string }) => {
|
||||
try {
|
||||
const { ConfigStore } = await import("../config-store.js");
|
||||
const { SystemPreflight, quietPreflightExec } = await import("../system-preflight.js");
|
||||
@@ -252,7 +251,6 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
// OPENRIG_NO_KERNEL env var so the daemon's kernel-boot
|
||||
// check in startup.ts honors the flag.
|
||||
skipKernelBoot: skipKernel,
|
||||
allowDbConflict: opts.allowDbConflict,
|
||||
},
|
||||
getDeps(),
|
||||
);
|
||||
@@ -264,7 +262,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
// know whether the kernel itself reached ready. Operators who
|
||||
// need a kernel-ready signal opt in here.
|
||||
if (opts.waitForKernel) {
|
||||
const { waitForKernelReady, skippedKernelMessage } = await import("../daemon-lifecycle.js");
|
||||
const { waitForKernelReady } = await import("../daemon-lifecycle.js");
|
||||
const timeoutMs = opts.waitForKernelMs && /^\d+$/.test(opts.waitForKernelMs)
|
||||
? parseInt(opts.waitForKernelMs, 10)
|
||||
: 60_000;
|
||||
@@ -272,10 +270,6 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
const result = await waitForKernelReady(baseUrl, timeoutMs);
|
||||
if (result.ok) {
|
||||
console.log(`Kernel ${result.kernelState}; variant=${result.variant ?? "(none)"}`);
|
||||
} else if (result.kernelState === "skipped") {
|
||||
// Skipped on purpose, so neither a timeout nor an auth problem: say why, and the way back.
|
||||
console.error(skippedKernelMessage(result.detail));
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
// Honest 3-part error per banked discipline.
|
||||
console.error(
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
readCmuxSocketControlModeFromText,
|
||||
resolveCmuxSettingsPath,
|
||||
} from "../cmux-config.js";
|
||||
import { buildTmuxCaptureFailure, buildTmuxControlFailure, probeTmuxCapture, probeTmuxControl } from "../tmux-health.js";
|
||||
import { buildTmuxControlFailure, probeTmuxControl } from "../tmux-health.js";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { compareSpecToLive, topologyFromRigSpec, topologyFromLiveLogicalIds } from "@openrig/daemon/spec-conformance";
|
||||
import { classifyNodeVersion } from "../node-support.js";
|
||||
@@ -31,8 +31,7 @@ export interface DoctorDeps {
|
||||
exists: (p: string) => boolean;
|
||||
baseDir: string;
|
||||
readFile: (path: string) => string | null;
|
||||
/** `timeoutMs` bounds one command; without it a command runs to completion, as before. */
|
||||
exec: (cmd: string, opts?: { timeoutMs?: number }) => string;
|
||||
exec: (cmd: string) => string;
|
||||
checkPort: (port: number, host: string) => Promise<boolean>;
|
||||
configStore: Pick<ConfigStore, "resolve">;
|
||||
platform?: NodeJS.Platform;
|
||||
@@ -129,16 +128,6 @@ export function runDoctorChecks(deps: DoctorDeps, providerChecks: DoctorCheck[]
|
||||
});
|
||||
} else if (tmuxProbe.available && tmuxProbe.version) {
|
||||
checks.push({ name: "tmux", status: "pass", message: tmuxProbe.version });
|
||||
// #980: a tmux whose server exits on `capture-pane -p` fails every seat at launch.
|
||||
const capture = probeTmuxCapture((cmd, opts) => deps.exec(cmd, opts));
|
||||
if (capture.status === "fail") {
|
||||
const failure = buildTmuxCaptureFailure(tmuxProbe.version, capture.detail);
|
||||
checks.push({ name: "tmux_capture", status: "fail", message: failure.message, reason: failure.reason, fix: failure.fix });
|
||||
} else {
|
||||
checks.push(capture.status === "pass"
|
||||
? { name: "tmux_capture", status: "pass", message: "tmux can capture a pane (checked on a throwaway server)." }
|
||||
: { name: "tmux_capture", status: "skipped", message: `Couldn't start a throwaway tmux server to check capture-pane: ${capture.detail}` });
|
||||
}
|
||||
if (platform === "darwin") {
|
||||
const mouseMode = readTmuxMouseMode(deps);
|
||||
if (mouseMode === "on") {
|
||||
@@ -395,12 +384,6 @@ function buildCmuxDaemonWarning(deps: DoctorDeps, platform: NodeJS.Platform): Do
|
||||
};
|
||||
}
|
||||
|
||||
/** The standalone doctor's command runner. A command with `timeoutMs` is bounded (the tmux capture probe passes one); others
|
||||
* run to completion, as before. */
|
||||
export function doctorExec(c: string, opts?: { timeoutMs?: number }): string {
|
||||
return execSync(c, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], ...(opts?.timeoutMs ? { timeout: opts.timeoutMs } : {}) });
|
||||
}
|
||||
|
||||
function readTmuxMouseMode(deps: DoctorDeps): "on" | "off" | null {
|
||||
try {
|
||||
const mode = deps.exec("tmux show-options -gqv mouse").trim().toLowerCase();
|
||||
@@ -444,7 +427,7 @@ export function doctorCommand(depsOverride?: DoctorDeps): Command {
|
||||
exists: existsSync,
|
||||
baseDir: import.meta.dirname,
|
||||
readFile: (p: string) => { try { return readFileSync(p, "utf-8"); } catch { return null; } },
|
||||
exec: doctorExec,
|
||||
exec: (c: string) => execSync(c, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }),
|
||||
checkPort: defaultCheckPort,
|
||||
configStore: new ConfigStore(),
|
||||
mkdirp: (dirPath: string) => mkdirSync(dirPath, { recursive: true }),
|
||||
|
||||
@@ -6,75 +6,6 @@ import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { resolveRigHandle } from "../rig-handle.js";
|
||||
import { shellQuote } from "../cross-host-executor.js";
|
||||
import { resolveIdentitySource } from "./whoami.js";
|
||||
|
||||
interface DownOptions {
|
||||
delete?: boolean;
|
||||
force?: boolean;
|
||||
snapshot?: boolean;
|
||||
yes?: boolean;
|
||||
json?: boolean;
|
||||
host?: string;
|
||||
}
|
||||
|
||||
interface DownDeps extends StatusDeps {
|
||||
promptYesNo?: (question: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface OwnRigWarning {
|
||||
fact: string;
|
||||
consequence: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
async function confirmOwnRig(
|
||||
rigId: string,
|
||||
opts: DownOptions,
|
||||
deps: DownDeps,
|
||||
readIdentity: (path: string) => Promise<unknown>,
|
||||
): Promise<OwnRigWarning | false | undefined> {
|
||||
const source = resolveIdentitySource({});
|
||||
if (!source) return;
|
||||
// A local session name can identify a different seat on the selected host.
|
||||
if (opts.host && !source.nodeId) return;
|
||||
const params = new URLSearchParams(source.nodeId ? { nodeId: source.nodeId } : { sessionName: source.sessionName! });
|
||||
params.set("compact", "1");
|
||||
let caller: { identity?: { rigId?: string; attachmentType?: string } } | undefined;
|
||||
try { caller = await readIdentity(`/api/whoami?${params}`) as typeof caller; }
|
||||
catch { return; } // An unresolved caller is not proof of membership; keep the existing teardown path.
|
||||
if (caller?.identity?.rigId !== rigId) return;
|
||||
|
||||
const warning: OwnRigWarning = {
|
||||
fact: `Your current seat belongs to rig ${rigId}.`,
|
||||
consequence: caller.identity.attachmentType === "external_cli"
|
||||
? "OpenRig doesn't stop externally attached sessions. This stops the rig's managed sessions and their work."
|
||||
: "This ends your own session and stops the rig's other managed seats and their work. OpenRig doesn't stop externally attached sessions.",
|
||||
action: ["rig", "down", shellQuote(rigId), "--yes", ...(opts.delete ? ["--delete"] : []),
|
||||
...(opts.force ? ["--force"] : []), ...(opts.snapshot ? ["--snapshot"] : []),
|
||||
...(opts.json ? ["--json"] : []), ...(opts.host ? ["--host", shellQuote(opts.host)] : [])].join(" "),
|
||||
};
|
||||
if (!opts.json) console.error(`Warning: ${warning.fact} ${warning.consequence}`);
|
||||
if (opts.yes) return warning;
|
||||
|
||||
if (deps.promptYesNo || (process.stdin.isTTY && process.stderr.isTTY)) {
|
||||
const question = `${opts.json ? `${warning.fact} ${warning.consequence}\n` : ""}Stop this rig? [y/N] `;
|
||||
let accepted: boolean;
|
||||
if (deps.promptYesNo) accepted = await deps.promptYesNo(question);
|
||||
else {
|
||||
const { createInterface } = await import("node:readline");
|
||||
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
||||
accepted = await new Promise<boolean>(resolve => {
|
||||
rl.once("close", () => resolve(false));
|
||||
rl.question(question, answer => { resolve(/^y(es)?$/i.test(answer.trim())); rl.close(); });
|
||||
});
|
||||
}
|
||||
if (accepted) return warning;
|
||||
}
|
||||
if (opts.json) console.log(JSON.stringify({ rigId, error: { code: "own_rig_confirmation_required", ...warning } }));
|
||||
else console.error(`Nothing stopped. To proceed: ${warning.action}`);
|
||||
process.exitCode = 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
interface TeardownResult {
|
||||
rigId: string;
|
||||
@@ -84,13 +15,6 @@ interface TeardownResult {
|
||||
deleteBlocked: boolean;
|
||||
alreadyStopped: boolean;
|
||||
errors: string[];
|
||||
/** Team skill files kept in seats' own storage: edited, or never recorded as OpenRig's. */
|
||||
keptSkills?: string[];
|
||||
/** Managed guidance blocks a person edited, kept in their file as `<file>: <block id>`. */
|
||||
keptGuidance?: string[];
|
||||
/** Seats joined with `rig attach` as an external CLI, which OpenRig doesn't stop. */
|
||||
leftRunning?: string[];
|
||||
paneOnly?: string[];
|
||||
}
|
||||
|
||||
interface DownAgentEntry {
|
||||
@@ -109,10 +33,8 @@ async function showAgentsBeforeDown(rigId: string, readInventory: () => Promise<
|
||||
const inventory = await readInventory();
|
||||
if (Array.isArray(inventory) && inventory.every(n => n && typeof n.nodeKind === "string"
|
||||
&& typeof n.logicalId === "string" && (n.sessionStatus === null || typeof n.sessionStatus === "string"))) {
|
||||
const live = (inventory as DownAgentEntry[]).filter(n => ["running", "idle", "unknown"].includes(n.sessionStatus ?? ""));
|
||||
const agents = live.filter(n => n.nodeKind === "agent");
|
||||
// A terminal member isn't an agent, but stopping the rig ends its session too.
|
||||
const terminals = live.filter(n => n.nodeKind !== "agent").map(n => n.canonicalSessionName || n.logicalId);
|
||||
const agents = (inventory as DownAgentEntry[]).filter(n => n.nodeKind === "agent"
|
||||
&& ["running", "idle", "unknown"].includes(n.sessionStatus ?? ""));
|
||||
facts = agents.length === 0 ? "no agent sessions reported live" : "recorded agent sessions: " + agents.map(n => {
|
||||
// A running process or an assigned queue row does not prove work in progress.
|
||||
// Prefer the daemon's arbitrated activity; older daemons expose agentActivity.
|
||||
@@ -124,7 +46,6 @@ async function showAgentsBeforeDown(rigId: string, readInventory: () => Promise<
|
||||
const work = ["working", "idle", "needs-input"].includes(activity ?? "") ? activity : "unknown";
|
||||
return `${n.canonicalSessionName || n.logicalId} (activity: ${work})`;
|
||||
}).join(", ");
|
||||
if (terminals.length > 0) facts += `; terminal sessions: ${terminals.join(", ")}`;
|
||||
}
|
||||
} catch { /* Unavailable inventory is an unknown fact, never a teardown gate. */ }
|
||||
console.error(`Before stopping ${rigId}: ${facts}.`);
|
||||
@@ -137,16 +58,14 @@ const LONG_RUNNING_TIMEOUT_MS = 45_000;
|
||||
* @param depsOverride - injectable deps for testing
|
||||
* @returns Commander command
|
||||
*/
|
||||
export function downCommand(depsOverride?: DownDeps): Command {
|
||||
export function downCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("down").description("Tear down a rig")
|
||||
.addHelpText("after", `
|
||||
rig down stops every agent in the rig: their sessions end, and any work in progress stops with them.
|
||||
Before running it, check who is working (rig ps --nodes --rig <rig>) and that the person asked for it;
|
||||
don't stop a team you didn't start unless asked. OpenRig snapshots the rig first (best-effort), and
|
||||
rig up <rig> --existing brings it back, resuming each seat's conversation where its harness allows. With
|
||||
--delete the rig record is removed too, so it can't be brought back that way.
|
||||
When your resolved seat belongs to this rig, confirm before stopping it. Without a terminal,
|
||||
the command warns and exits without stopping; re-run with --yes to confirm explicitly.`);
|
||||
--delete the rig record is removed too, so it can't be brought back that way.`);
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
@@ -154,10 +73,9 @@ the command warns and exits without stopping; re-run with --yes to confirm expli
|
||||
.option("--delete", "Delete rig record after stopping")
|
||||
.option("--force", "Kill sessions immediately")
|
||||
.option("--snapshot", "Take snapshot before teardown")
|
||||
.option("--yes", "Confirm stopping the rig your current seat belongs to")
|
||||
.option("--json", "JSON output for agents")
|
||||
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml")
|
||||
.action(async (rigHandle: string, opts: DownOptions) => {
|
||||
.action(async (rigHandle: string, opts: { delete?: boolean; force?: boolean; snapshot?: boolean; json?: boolean; host?: string }) => {
|
||||
// OPR.0.4.6.MH1 FR-2: selected-host routing — explicit --host wins;
|
||||
// else the persisted selection feeds the SHIPPED --host path; no
|
||||
// selection = today exactly.
|
||||
@@ -179,14 +97,9 @@ the command warns and exits without stopping; re-run with --yes to confirm expli
|
||||
return inventory.ok ? inventory.data : undefined;
|
||||
});
|
||||
}
|
||||
const warning = await confirmOwnRig(rigIdResult.rigId, opts, deps, async path => {
|
||||
const identity = await runRemoteHttpOp(opts.host!, "GET", path, undefined, deps, { timeoutMs: 2_000 });
|
||||
return identity.ok ? identity.data : undefined;
|
||||
});
|
||||
if (warning === false) return;
|
||||
const result = await runRemoteHttpOp(opts.host, "POST", `/api/down`, { rigId: rigIdResult.rigId, delete: opts.delete, force: opts.force, snapshot: opts.snapshot }, deps, opts);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(warning ? { ...result, warning } : result));
|
||||
console.log(JSON.stringify(result));
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
} else if (result.ok) {
|
||||
console.log(JSON.stringify(result.data, null, 2));
|
||||
@@ -253,11 +166,6 @@ the command warns and exits without stopping; re-run with --yes to confirm expli
|
||||
});
|
||||
}
|
||||
|
||||
const warning = await confirmOwnRig(rigId, opts, deps, async path => {
|
||||
const identity = await client.get<unknown>(path, { timeoutMs: 2_000 });
|
||||
return identity.status === 200 ? identity.data : undefined;
|
||||
});
|
||||
if (warning === false) return;
|
||||
const res = await client.post<TeardownResult | { error: string }>("/api/down", {
|
||||
rigId,
|
||||
delete: opts.delete ?? false,
|
||||
@@ -266,7 +174,7 @@ the command warns and exits without stopping; re-run with --yes to confirm expli
|
||||
}, { timeoutMs: LONG_RUNNING_TIMEOUT_MS });
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(warning ? { ...res.data, warning } : res.data));
|
||||
console.log(JSON.stringify(res.data));
|
||||
if (res.status >= 400) {
|
||||
process.exitCode = 2;
|
||||
} else {
|
||||
@@ -286,22 +194,6 @@ the command warns and exits without stopping; re-run with --yes to confirm expli
|
||||
}
|
||||
|
||||
const result = res.data as TeardownResult;
|
||||
// OPR.0.7.0.13: say what was kept and why, whatever else happened.
|
||||
if (result.keptSkills && result.keptSkills.length > 0) {
|
||||
console.log(`Kept ${result.keptSkills.length} team skill file(s) in the seats' own storage: edited, or not recorded as OpenRig's:`);
|
||||
for (const file of result.keptSkills) console.log(` ${file}`);
|
||||
}
|
||||
if (result.keptGuidance && result.keptGuidance.length > 0) {
|
||||
console.log(`Kept ${result.keptGuidance.length} edited OpenRig block(s) in guidance files; remove them by hand if you don't want them:`);
|
||||
for (const block of result.keptGuidance) console.log(` ${block}`);
|
||||
}
|
||||
|
||||
if (result.leftRunning && result.leftRunning.length > 0) {
|
||||
console.log(`Not stopped: ${result.leftRunning.join(", ")}. Joined with rig attach as an external CLI, which OpenRig doesn't stop or check.`);
|
||||
}
|
||||
if (result.paneOnly && result.paneOnly.length > 0) {
|
||||
console.log(`Pane stopped: ${result.paneOnly.join(", ")}. A community harness's own processes may keep running after its pane; see its preset's limits.`);
|
||||
}
|
||||
|
||||
// Exit code: errors first, then deleted, then alreadyStopped
|
||||
if (result.errors.length > 0) {
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient, DaemonResponseError, type DaemonResponse } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, daemonStatusGuard } from "../daemon-lifecycle.js";
|
||||
import { readSelectedHost } from "../host-selection.js";
|
||||
import { resolveRigHandle } from "../rig-handle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
interface EdgeEnd {
|
||||
logicalId: string;
|
||||
nodeId: string | null;
|
||||
}
|
||||
|
||||
interface EdgeSuccess {
|
||||
ok: true;
|
||||
rigId: string;
|
||||
outcome: "would_add" | "added" | "present" | "would_remove" | "removed";
|
||||
edge: { id?: string; kind: string; from: EdgeEnd; to: EdgeEnd };
|
||||
rollback?: string;
|
||||
routing?: {
|
||||
orchestrator?: { seat: string; from: string | null; to: string | null };
|
||||
representative?: { from: string | null; to: string | null };
|
||||
};
|
||||
}
|
||||
|
||||
type EdgeOpResult =
|
||||
| EdgeSuccess
|
||||
| { ok: false; code: string; message: string; candidates?: string[] };
|
||||
|
||||
const NO_EDGE_ROUTE = "This daemon has no edge route. Upgrade it to a version with `rig edge`, then retry.";
|
||||
|
||||
function report(result: EdgeOpResult, opts: { json?: boolean }): void {
|
||||
if (opts.json) console.log(JSON.stringify(result, null, 2));
|
||||
else if (!result.ok) console.error(`${result.code}: ${result.message}`);
|
||||
else {
|
||||
const { edge } = result;
|
||||
const line = `${edge.kind} ${edge.from.logicalId} -> ${edge.to.logicalId}`;
|
||||
const id = edge.id ? ` edge ${edge.id}` : "";
|
||||
switch (result.outcome) {
|
||||
case "would_add":
|
||||
console.log(`plan add ${line} in rig ${result.rigId}`);
|
||||
console.log(` from node ${edge.from.nodeId}, to ${edge.to.nodeId === null
|
||||
? `${edge.to.logicalId} (resolved when used)` : `node ${edge.to.nodeId}`}`);
|
||||
console.log(" nothing written (--plan)");
|
||||
break;
|
||||
case "added":
|
||||
console.log(`added ${line}${id}`);
|
||||
console.log(` rollback: ${result.rollback}`);
|
||||
break;
|
||||
case "present":
|
||||
console.log(`present ${line}${id} (already there; nothing written)`);
|
||||
break;
|
||||
case "would_remove":
|
||||
console.log(`plan remove ${line}${id} from rig ${result.rigId}`);
|
||||
console.log(" nothing written (--plan)");
|
||||
break;
|
||||
case "removed":
|
||||
console.log(`removed ${line}${id}`);
|
||||
break;
|
||||
}
|
||||
const { orchestrator, representative } = result.routing ?? {};
|
||||
if (orchestrator) {
|
||||
console.log(` routing: ${orchestrator.seat}'s orchestrator (its stuck-row findings and recovery) is now ${orchestrator.to ?? "none"}, was ${orchestrator.from ?? "none"}`);
|
||||
}
|
||||
if (representative) {
|
||||
console.log(` routing: the rig's representative (where a person's Slack message in the rig's mapped channel goes, unless that channel names an inbound seat) is now ${representative.to ?? "none, so such a message goes to Slack's inbound destination"}, was ${representative.from ?? "none"}`);
|
||||
}
|
||||
if (result.outcome === "added" || result.outcome === "removed") {
|
||||
console.log(` capture this topology with \`rig snapshot ${result.rigId}\`; restore uses the selected snapshot`);
|
||||
}
|
||||
}
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
export function edgeCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("edge").description("Add or remove typed edges between existing seats");
|
||||
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
const run = async (
|
||||
rigHandle: string,
|
||||
opts: { json?: boolean },
|
||||
send: (client: DaemonClient, rigId: string) => Promise<DaemonResponse<EdgeOpResult>>,
|
||||
) => {
|
||||
const deps = getDeps();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (!daemonStatusGuard(status, { json: opts.json })) return;
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const resolution = await resolveRigHandle(client, rigHandle);
|
||||
const selectedHost = readSelectedHost();
|
||||
// Match rig add: names copied from a selected remote must not silently
|
||||
// mutate a same-named rig on the local daemon. Explicit local IDs still work.
|
||||
if (selectedHost !== "local" && (resolution.kind === "ambiguous" || (resolution.kind === "resolved" && resolution.byName))) {
|
||||
report({
|
||||
ok: false, code: "remote_host_selected",
|
||||
message: `Host '${selectedHost}' is selected, but rig edge only targets the local daemon. Run it on that host or pass an explicit local rig ID (rig host select local; rig ps --json).`,
|
||||
}, opts);
|
||||
return;
|
||||
}
|
||||
if (resolution.kind === "ambiguous") {
|
||||
report({
|
||||
ok: false, code: "rig_ambiguous", candidates: resolution.ids,
|
||||
message: `'${rigHandle}' matches ${resolution.ids.length} rigs. Re-run with one of these IDs: ${resolution.ids.join(", ")}.`,
|
||||
}, opts);
|
||||
return;
|
||||
}
|
||||
if (resolution.kind === "not_found") {
|
||||
report({ ok: false, code: "rig_not_found", message: `rig '${rigHandle}' not found; list active rigs with rig ps --json, or use an archived rig's exact ID` }, opts);
|
||||
return;
|
||||
}
|
||||
const rigId = resolution.kind === "resolved" ? resolution.id : rigHandle;
|
||||
let result: EdgeOpResult;
|
||||
try {
|
||||
const response = await send(client, rigId);
|
||||
result = response.data;
|
||||
// Older daemons have used both JSON not_found and plain-text 404 pages.
|
||||
if (response.status === 404 && (!result || !("code" in result))) {
|
||||
result = { ok: false, code: "edge_route_missing", message: NO_EDGE_ROUTE };
|
||||
}
|
||||
} catch (err) {
|
||||
if (!(err instanceof DaemonResponseError) || err.status !== 404) throw err;
|
||||
result = { ok: false, code: "edge_route_missing", message: NO_EDGE_ROUTE };
|
||||
}
|
||||
report(result, opts);
|
||||
};
|
||||
|
||||
cmd
|
||||
.command("add")
|
||||
.description("Ensure one typed edge; an existing edge is unchanged")
|
||||
.argument("<rig>", "Target rig name or ID")
|
||||
.argument("[from]", "Existing source seat logical ID, such as orch.lead")
|
||||
.argument("[to]", "Target logical ID, or a same-daemon address for escalates_to")
|
||||
.option("--kind <kind>", "delegates_to, spawned_by, can_observe, collaborates_with or escalates_to")
|
||||
.option("--plan", "Validate the request and show the change without writing")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig edge add my-rig orch.lead review.reviewer --kind delegates_to
|
||||
rig edge add my-rig orch.lead review.reviewer --kind delegates_to --plan --json
|
||||
rig edge add my-rig work.lead review-advisor@coord --kind escalates_to
|
||||
|
||||
Use existing, fully qualified logical IDs in <rig> for both same-pod and
|
||||
cross-pod edges. For escalates_to only, the target may instead be a canonical
|
||||
same-daemon seat address (pod-member@rig), resolved when used; that rig or seat
|
||||
need not exist yet. Cross-host and synthetic destinations are unsupported.
|
||||
Run it once per edge. A repeat reports the existing edge and its ID, and writes
|
||||
nothing. No seats or sessions are changed.`)
|
||||
.action(async (rig: string, from: string | undefined, to: string | undefined, opts: { kind?: string; plan?: boolean; json?: boolean }) => {
|
||||
if (!from || !to || !opts.kind) {
|
||||
report({ ok: false, code: "validation_failed", message: "Supply from, to and --kind" }, opts);
|
||||
return;
|
||||
}
|
||||
const body = { from, to, kind: opts.kind, plan: opts.plan === true };
|
||||
await run(rig, opts, (client, rigId) => client.post<EdgeOpResult>(`/api/rigs/${encodeURIComponent(rigId)}/edges`, body));
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("remove")
|
||||
.description("Remove exactly one edge, by the ID that rig edge add returned")
|
||||
.argument("<rig>", "Target rig name or ID")
|
||||
.argument("<edge-id>", "Edge ID")
|
||||
.option("--plan", "Show what would change and write nothing")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action((rig: string, edgeId: string, opts: { plan?: boolean; json?: boolean }) =>
|
||||
run(rig, opts, (client, rigId) => client.delete<EdgeOpResult>(
|
||||
`/api/rigs/${encodeURIComponent(rigId)}/edges/${encodeURIComponent(edgeId)}${opts.plan ? "?plan=1" : ""}`,
|
||||
)));
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -38,7 +38,6 @@ Examples:
|
||||
// rig env status <rig>
|
||||
cmd
|
||||
.command("status")
|
||||
.description("Show the state of a rig's services")
|
||||
.argument("<rig>", "Rig name or ID")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (rig: string, opts: { json?: boolean }) => {
|
||||
@@ -95,7 +94,6 @@ Examples:
|
||||
// rig env logs <rig> [service]
|
||||
cmd
|
||||
.command("logs")
|
||||
.description("Print recent logs from a rig's services, or from one service")
|
||||
.argument("<rig>", "Rig name or ID")
|
||||
.argument("[service]", "Specific service name")
|
||||
.option("--tail <n>", "Number of lines", "100")
|
||||
@@ -128,7 +126,6 @@ Examples:
|
||||
// rig env down <rig> [--volumes]
|
||||
cmd
|
||||
.command("down")
|
||||
.description("Stop a rig's services as its down_policy says; --volumes also removes volumes")
|
||||
.argument("<rig>", "Rig name or ID")
|
||||
.option("--volumes", "Also remove volumes")
|
||||
.action(async (rig: string, opts: { volumes?: boolean }) => {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import nodePath from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { Command } from "commander";
|
||||
import { writeTextAtomically } from "../atomic-text-write.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
@@ -10,31 +8,6 @@ import { rigIdForHandle } from "../rig-handle.js";
|
||||
|
||||
export interface ExportDeps extends StatusDeps {
|
||||
writeFile: (path: string, content: string) => void;
|
||||
exists?: (path: string) => boolean;
|
||||
}
|
||||
|
||||
interface ExportedStartupBlock {
|
||||
files?: Array<{ path?: string; required?: boolean }>;
|
||||
}
|
||||
|
||||
/** Copy only the declared local culture, preserving any recipient file. */
|
||||
function copyCulture(file: string, root: string, outDir: string): void {
|
||||
if (nodePath.isAbsolute(file) || file.split(/[\\/]/).includes("..")) throw new Error("culture path must stay inside the rig folder");
|
||||
const sourceRoot = fs.realpathSync(root), source = fs.realpathSync(nodePath.resolve(root, file));
|
||||
const inside = (base: string, target: string) => target.startsWith(base.endsWith(nodePath.sep) ? base : base + nodePath.sep);
|
||||
if (!inside(sourceRoot, source) || !fs.statSync(source).isFile()) throw new Error("culture source is not a file inside the rig folder");
|
||||
const target = nodePath.resolve(outDir, file);
|
||||
if (fs.lstatSync(target, { throwIfNoEntry: false })) {
|
||||
console.warn(`Warning: kept existing culture '${target}'; check it matches the team before sharing.`);
|
||||
return;
|
||||
}
|
||||
let parent = nodePath.dirname(target);
|
||||
while (!fs.existsSync(parent)) parent = nodePath.dirname(parent);
|
||||
const outputRoot = fs.realpathSync(outDir), realParent = fs.realpathSync(parent);
|
||||
if (realParent !== outputRoot && !inside(outputRoot, realParent)) throw new Error("culture destination resolves outside the export folder");
|
||||
fs.mkdirSync(nodePath.dirname(target), { recursive: true });
|
||||
fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL);
|
||||
console.log(`Copied culture to ${target}`);
|
||||
}
|
||||
|
||||
export function exportCommand(depsOverride?: ExportDeps): Command {
|
||||
@@ -43,7 +16,6 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
writeFile: (p, content) => writeTextAtomically(p, content, "export"),
|
||||
exists: (p) => fs.existsSync(p),
|
||||
};
|
||||
|
||||
cmd
|
||||
@@ -69,86 +41,6 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
|
||||
} else {
|
||||
deps.writeFile(opts.output, res.data);
|
||||
console.log(`Exported to ${opts.output}`);
|
||||
for (const line of String(res.data).split("\n")) if (line.startsWith("# Not carried: ")) console.log(line.slice(2));
|
||||
|
||||
// Check for referenced culture_file and startup files resolving from the output directory
|
||||
const checkExists = deps.exists ?? ((p) => fs.existsSync(p));
|
||||
const outDir = nodePath.dirname(nodePath.resolve(opts.output));
|
||||
const rawRoot = res.headers?.get("X-Rig-Root");
|
||||
let recordedRoot: string | undefined;
|
||||
if (rawRoot) {
|
||||
try {
|
||||
recordedRoot = decodeURIComponent(rawRoot);
|
||||
} catch {
|
||||
recordedRoot = rawRoot;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { parse } = await import("yaml");
|
||||
const parsed = parse(res.data) as Record<string, unknown> | null;
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if (parsed["name"] === "starter") {
|
||||
console.warn("Warning: this export still installs as 'starter'. Before sharing, rename the team's name in rig.yaml; changing the bundle name alone does not rename the installed rig.");
|
||||
}
|
||||
const pods = parsed["pods"] as Array<{
|
||||
id?: string;
|
||||
startup?: ExportedStartupBlock;
|
||||
members?: Array<{ id?: string; agent_ref?: string }>;
|
||||
}> | undefined;
|
||||
for (const pod of Array.isArray(pods) ? pods : []) {
|
||||
for (const member of Array.isArray(pod.members) ? pod.members : []) {
|
||||
const local = typeof member.agent_ref === "string" && member.agent_ref.startsWith("local:") ? nodePath.normalize(member.agent_ref.slice(6)) : null;
|
||||
if (typeof member.agent_ref === "string" && (member.agent_ref.startsWith("path:") || nodePath.isAbsolute(member.agent_ref.replace(/^local:/, ""))
|
||||
|| (local !== null && (local === ".." || local.startsWith(".." + nodePath.sep))))) {
|
||||
console.warn(`Warning: ${pod.id}.${member.id} has an agent_ref outside this folder and is not portable. Copy that agent and its imports into this folder and use local: refs before sharing, or use rig bundle create with the original --rig-root to vendor them into an archive.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const culture = parsed["culture_file"] ?? parsed["cultureFile"];
|
||||
if (typeof culture === "string" && recordedRoot) {
|
||||
try {
|
||||
if (!["localhost", "127.0.0.1", "[::1]"].includes(new URL(client.baseUrl).hostname)) throw new Error("the daemon is remote; copy it from the recorded rig root on that host");
|
||||
copyCulture(culture, recordedRoot, outDir);
|
||||
} catch (error) {
|
||||
console.warn(`Warning: could not copy culture '${culture}' from '${recordedRoot}': ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
const filesToCheck: string[] = [];
|
||||
if (typeof parsed["cultureFile"] === "string") {
|
||||
filesToCheck.push(parsed["cultureFile"]);
|
||||
} else if (typeof parsed["culture_file"] === "string") {
|
||||
filesToCheck.push(parsed["culture_file"]);
|
||||
}
|
||||
|
||||
const startupBlocks = [
|
||||
parsed["startup"] as ExportedStartupBlock | undefined,
|
||||
...(Array.isArray(pods) ? pods.map((pod) => pod.startup) : []),
|
||||
];
|
||||
for (const startup of startupBlocks) {
|
||||
if (startup && Array.isArray(startup.files)) {
|
||||
for (const f of startup.files) {
|
||||
if (typeof f?.path === "string" && f.required !== false && !filesToCheck.includes(f.path)) {
|
||||
filesToCheck.push(f.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const fileRel of filesToCheck) {
|
||||
const targetPath = nodePath.resolve(outDir, fileRel);
|
||||
if (!checkExists(targetPath)) {
|
||||
const originNote = recordedRoot ? ` (came from ${recordedRoot})` : "";
|
||||
console.warn(
|
||||
`Warning: Referenced file '${fileRel}'${originNote} does not exist relative to '${outDir}'. ` +
|
||||
`A rig launched from this export fails its startup until the file is copied next to it (or the export is imported with --rig-root ${recordedRoot ?? "<folder>"}).`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If YAML parsing fails, do not block export
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import { renderCaughtTransportError } from "../cli-error.js";
|
||||
import { resolveRigHandle } from "../rig-handle.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
interface ForkResponse {
|
||||
@@ -29,16 +28,13 @@ interface ForkResponse {
|
||||
warnings?: string[];
|
||||
};
|
||||
image?: { id: string; name: string; version: string; pinned: boolean };
|
||||
/** OPR.0.7.0.9: whether the source seat's launch additions were carried into the fork, and why not if they weren't. */
|
||||
launchAdditions?: "carried" | "partial" | "not_carried";
|
||||
launchAdditionsNote?: string;
|
||||
}
|
||||
|
||||
export function forkCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("fork")
|
||||
.description("Fork a live seat's context into a new seat (composes the shipped agent-image fork path)")
|
||||
.argument("<source-session>", "Source session canonical name (e.g., dev-impl@openrig-delivery)")
|
||||
.requiredOption("--rig <rig>", "Target rig name or id")
|
||||
.requiredOption("--rig <rig-id>", "Target rig id")
|
||||
.requiredOption("--pod <pod-namespace>", "Target existing pod namespace")
|
||||
.requiredOption("--member <member-id>", "New member id for the forked successor")
|
||||
.option("--keep-image", "Also capture a durable, pinned agent image (default forks one-shot with no library growth)")
|
||||
@@ -69,18 +65,10 @@ Examples:
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (!daemonStatusGuard(status)) return;
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const resolution = await resolveRigHandle(client, opts.rig);
|
||||
if (resolution.kind === "ambiguous") {
|
||||
const msg = `'${opts.rig}' matches ${resolution.ids.length} rigs. Refusing to fork: an ambiguous name could act on the wrong rig. Re-run with the specific id: ${resolution.ids.join(", ")}`;
|
||||
if (opts.json) console.log(JSON.stringify({ ok: false, code: "rig_ambiguous", message: msg, candidates: resolution.ids }, null, 2));
|
||||
else console.error(msg);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
sourceSession,
|
||||
rigId: resolution.kind === "resolved" ? resolution.id : opts.rig,
|
||||
rigId: opts.rig,
|
||||
pod: opts.pod,
|
||||
member: opts.member,
|
||||
};
|
||||
@@ -116,9 +104,6 @@ Examples:
|
||||
console.log(`Forked ${sourceSession} into rig ${opts.rig}`);
|
||||
console.log(` Pod: ${data.result?.podNamespace ?? opts.pod}`);
|
||||
console.log(` New seat: [${icon}] ${node?.logicalId ?? opts.member}${session}${error}`);
|
||||
if (data.launchAdditions === "carried") console.log(" Launch additions: carried from the source seat");
|
||||
if (data.launchAdditions === "partial") console.log(" Launch additions: partly carried from the source seat");
|
||||
if (data.launchAdditionsNote) console.log(` Warning: ${data.launchAdditionsNote}`);
|
||||
if (data.image) {
|
||||
console.log(` Kept image: ${data.image.name} v${data.image.version} (pinned, protected from prune)`);
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Command } from "commander";
|
||||
import { resolveSenderSession, SENDER_FALLBACK } from "../sender-identity.js";
|
||||
// The human registry is home-state owned by the daemon; the verb LAZY-imports the narrow
|
||||
@@ -183,30 +182,32 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
|
||||
.option("--replace", "Explicitly replace an existing human (no silent overwrite)")
|
||||
.option("--reason <reason>", "Reason recorded with connector binding changes", "register human delivery")
|
||||
.option("--actor <actor>", "Named operator when outside a managed seat")
|
||||
.option("--organization <id>", "The person's organization (create it first with rig gateway org set)")
|
||||
.option("--group <id>", "A group the person belongs to (repeatable; create it first with rig gateway group set)", (v: string, acc: string[] = []) => { acc.push(v); return acc; })
|
||||
.option("--policy <text>", "Readable policy for agents working with this person (text, not enforced)")
|
||||
.option("--policy-file <path>", "Read the policy text from a file")
|
||||
.option("--default", "Make this person the default: the one escalations, the notice digest and other unaddressed needs go to")
|
||||
.action(async (entityId: string, opts: { displayName: string; binding: string[]; deliveryClass: string; away?: boolean; replace?: boolean; reason: string; actor?: string; organization?: string; group?: string[]; policy?: string; policyFile?: string; default?: boolean }) => {
|
||||
.action(async (entityId: string, opts: { displayName: string; binding: string[]; deliveryClass: string; away?: boolean; replace?: boolean; reason: string; actor?: string }) => {
|
||||
// LAZY import the narrow daemon surface at invocation (dep rail 2).
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const { addHumanFragment, parseBindingSpec } = registry as unknown as {
|
||||
addHumanFragment: typeof AddHumanFragment;
|
||||
parseBindingSpec: (spec: string) => { ok: true; binding: Record<string, unknown> } | { ok: false; error: string };
|
||||
};
|
||||
// Several people may be registered. Their organization, groups, policy and the default go in the participant side
|
||||
// file, never the fragment, so an older daemon still loads every person after a rollback.
|
||||
const policy = readPolicyOption(opts);
|
||||
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
|
||||
const placed = opts.organization !== undefined || opts.group !== undefined || policy.value !== undefined;
|
||||
if (placed) {
|
||||
// Check the organization and groups exist before writing anything, so a typo doesn't leave a half-placed person.
|
||||
const side = registry.readParticipantsFile();
|
||||
if (!side.ok) { console.error(`refused: ${side.error}`); process.exitCode = 1; return; }
|
||||
if (opts.organization && !side.file.organizations[opts.organization]) { console.error(`refused: no organization "${opts.organization}" — create it with rig gateway org set ${opts.organization} --name …`); process.exitCode = 1; return; }
|
||||
const unknownGroup = (opts.group ?? []).find((group) => !side.file.groups[group]);
|
||||
if (unknownGroup) { console.error(`refused: no group "${unknownGroup}" — create it with rig gateway group set ${unknownGroup} --organization … --name …`); process.exitCode = 1; return; }
|
||||
// Fix-r1 F1 (R2 blocking, A1/R5): the add VERB is the single-human boundary. A second
|
||||
// DISTINCT human is refused BEFORE any parse or write — the several-fragment state A1
|
||||
// describes as hand-authored must never be produced by the product's own surface.
|
||||
// Fail closed: an unverifiable registry refuses too (never add into an unknown state).
|
||||
const existing = registry.listHumans();
|
||||
if (!existing.ok) {
|
||||
console.error(`refused: cannot verify the single-human boundary — ${existing.error}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (existing.humans.length > 0 && !existing.humans.some((h) => h.entityId === entityId)) {
|
||||
const ids = existing.humans.map((h) => h.entityId).join(", ");
|
||||
console.error(
|
||||
`refused: a human is already configured (${ids}). OpenRig supports one configured human, so \`rig gateway human add\` manages one; ` +
|
||||
`to register someone else, remove the existing one first (\`rig gateway human remove <entityId>\`). Fragments hand-authored under ${registry.humansDir()} ` +
|
||||
`are displayed as found, with an advisory, but no command manages several.`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const bindings: Record<string, unknown>[] = [];
|
||||
for (const spec of opts.binding) {
|
||||
@@ -235,19 +236,7 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
|
||||
return { value: res, after: { digest }, effect: before.ok && registry.channelStateDigest(before.record.connectorBindings.map(({ inboundResolvable: _derived, ...binding }) => binding)) === digest ? "no-op" : "applied" };
|
||||
},
|
||||
});
|
||||
// The person is registered; their placement and the default are a second, separate write.
|
||||
const followUps: Record<string, unknown> = {};
|
||||
if (placed) {
|
||||
const res = registry.setPersonPlacement(entityId, { organization: opts.organization, groups: opts.group, policy: policy.value });
|
||||
followUps.placement = res.ok ? { path: res.path } : { error: res.error };
|
||||
}
|
||||
if (opts.default) {
|
||||
const res = registry.setDefaultPerson(entityId);
|
||||
followUps.default = res.ok ? { path: res.path } : { error: res.error };
|
||||
}
|
||||
const failed = Object.values(followUps).some((value) => "error" in (value as object));
|
||||
console.log(JSON.stringify({ ok: !failed, entityId, path: result.value.path, receipt: result.receipt, ...followUps }));
|
||||
if (failed) { console.error(`the person was registered, but part of their placement wasn't saved; fix it with rig gateway human set ${entityId} … or rig gateway default ${entityId}`); process.exitCode = 1; }
|
||||
console.log(JSON.stringify({ ok: true, entityId, path: result.value.path, receipt: result.receipt }));
|
||||
} catch (error) { console.error(`refused: ${(error as Error).message}`); process.exitCode = 1; }
|
||||
})
|
||||
.addHelpText("after", `
|
||||
@@ -270,7 +259,7 @@ Example:
|
||||
|
||||
human
|
||||
.command("list")
|
||||
.description("List the registered people, with delivery readiness and quiet hours (rig gateway participants adds organizations, groups, policy and shared channels)")
|
||||
.description("Show the configured human (OpenRig supports one; extra hand-authored fragments are listed with an advisory)")
|
||||
.option("--json", "Complete record(s) as JSON")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const { listHumans } = await import("@openrig/daemon/gateway-human-registry");
|
||||
@@ -281,7 +270,7 @@ Example:
|
||||
return { ...record, deliveryReadiness: await humanReadiness(record.entityId),
|
||||
quiet: quiet.ok ? quiet.quiet : { unknown: `quiet hours could not be read from the daemon: ${quiet.reason}` } };
|
||||
}));
|
||||
if (opts.json) { console.log(JSON.stringify({ ok: true, humans })); return; }
|
||||
if (opts.json) { console.log(JSON.stringify({ ok: true, humans, ...(res.advisory ? { advisory: res.advisory } : {}) })); return; }
|
||||
if (res.humans.length === 0) { console.log("no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"); return; }
|
||||
for (const h of humans) {
|
||||
const inbound = h.bindings.inboundResolvable ? "" : " [outbound-only]";
|
||||
@@ -289,6 +278,7 @@ Example:
|
||||
if (!h.deliveryReadiness.ready) console.log(` delivery: ${h.deliveryReadiness.reason}${h.deliveryReadiness.nextAction ? `; next: ${h.deliveryReadiness.nextAction}` : ""}`);
|
||||
console.log(` ${"unknown" in h.quiet ? `quiet: unknown (${h.quiet.unknown})` : describeQuiet(h.quiet)}`);
|
||||
}
|
||||
if (res.advisory) console.log(`advisory: ${res.advisory}`);
|
||||
});
|
||||
|
||||
const quiet = human.command("quiet").description("Quiet hours: while on, routine asks to this person are held, not posted; urgent ones (tagged escalation) still reach them. Held asks post once, each on its own, when quiet ends");
|
||||
@@ -530,25 +520,15 @@ Examples:
|
||||
.description("Show the effective record: fragment values + which defaults filled the rest, with provenance")
|
||||
.option("--json", "Complete record as JSON")
|
||||
.action(async (entityId: string, opts: { json?: boolean }) => {
|
||||
const { showHuman, participantsView } = await import("@openrig/daemon/gateway-human-registry");
|
||||
const { showHuman } = await import("@openrig/daemon/gateway-human-registry");
|
||||
const res = showHuman(entityId);
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
// The person's organization, groups, policy and whether they're the default come from the participant side file.
|
||||
const view = participantsView();
|
||||
const placement = view.ok ? view.participants.find((p) => p.id === entityId) : undefined;
|
||||
const participant = placement
|
||||
? { organization: placement.organization, groups: placement.groups, default: placement.default, policy: placement.policy }
|
||||
: { unavailable: view.ok ? "not found" : view.error };
|
||||
const sideFileError = view.ok ? view.sideFile.error : undefined;
|
||||
const r = { ...res.record, participant, ...(sideFileError ? { participantFileError: sideFileError } : {}), deliveryReadiness: await humanReadiness(entityId) };
|
||||
const r = { ...res.record, deliveryReadiness: await humanReadiness(entityId) };
|
||||
if (opts.json) { console.log(JSON.stringify({ ok: true, record: r })); return; }
|
||||
console.log(`${r.entityId} (${r.address}) — "${r.displayName}"`);
|
||||
console.log(` fragment: ${r.fragmentPath}`);
|
||||
if (placement) for (const line of describePlacement(placement)) console.log(line);
|
||||
if (sideFileError) console.log(` participant file: ${sideFileError}`);
|
||||
console.log(` delivery-class: ${r.prefs.deliveryClass.value} (${r.prefs.deliveryClass.source})`);
|
||||
console.log(` away: ${r.prefs.away.value} (${r.prefs.away.source})`);
|
||||
console.log(` slack-subscription: ${r.slackSubscription.value} (${r.slackSubscription.source})`);
|
||||
r.connectorBindings.forEach((b, i) => {
|
||||
console.log(` binding.${i}: ${b.kind}:${b.connectorRef} role=${b.role}${b.handle ? ` handle=${b.handle}` : " [outbound-only]"}`);
|
||||
});
|
||||
@@ -558,26 +538,11 @@ Examples:
|
||||
|
||||
human
|
||||
.command("set <entityId> <field> <value>")
|
||||
.description("Edit one field through the verb (same validation as add; re-projection immediate). Fields: display-name, delivery-class, away, availability (available|focus|away|off, or unset), slack-subscription (the person's Slack subscription: every-ask, the default, posts every ask to Slack too; urgent-only posts only asks created with --alert, the rest wait on Steering and the TUI; or unset), binding.<n>; and, kept in the participant side file: organization (an id, or unset), groups (comma-separated ids, or unset), policy (text, @<path> to read a file, or unset)")
|
||||
.description("Edit one field through the verb (same validation as add; re-projection immediate). Fields: display-name, delivery-class, away, availability (available|focus|away|off, or unset), binding.<n>")
|
||||
.option("--reason <reason>", "Reason recorded with connector binding changes", "update human binding")
|
||||
.option("--actor <actor>", "Named operator when outside a managed seat")
|
||||
.action(async (entityId: string, field: string, value: string, opts: { reason: string; actor?: string }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
if (field === "organization" || field === "groups" || field === "policy") {
|
||||
const clear = value === "unset";
|
||||
let input: Record<string, unknown>;
|
||||
if (field === "organization") input = { organization: clear ? null : value };
|
||||
else if (field === "groups") input = { groups: clear ? null : value.split(",").map((g) => g.trim()).filter(Boolean) };
|
||||
else {
|
||||
const policy = clear ? { ok: true as const, value: null } : value.startsWith("@") ? readPolicyOption({ policyFile: value.slice(1) }) : { ok: true as const, value };
|
||||
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
|
||||
input = { policy: policy.value };
|
||||
}
|
||||
const res = registry.setPersonPlacement(entityId, input);
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
console.log(JSON.stringify({ ok: true, entityId, field, path: res.path }));
|
||||
return;
|
||||
}
|
||||
if (field.startsWith("binding.")) {
|
||||
const before = registry.showHuman(entityId);
|
||||
if (!before.ok) { console.error(`refused: ${before.error}`); process.exitCode = 1; return; }
|
||||
@@ -634,141 +599,7 @@ Examples:
|
||||
];
|
||||
const res = removeHumanFragment(entityId, { force: !!opts.force, inflight });
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
// Their placement and, if it was them, the default go too; a later read would ignore them anyway, with a warning.
|
||||
const { removeParticipantEntry } = await import("@openrig/daemon/gateway-human-registry");
|
||||
const side = removeParticipantEntry(entityId, undefined, { kind: "person" });
|
||||
console.log(JSON.stringify({ ok: true, removed: res.removed, archivedPath: res.archivedPath, ...(res.orphanRecordPath ? { orphanRecordPath: res.orphanRecordPath } : {}),
|
||||
participantFile: side.ok ? { path: side.path } : { error: side.error } }));
|
||||
});
|
||||
|
||||
// ── Participants beyond people (0.7.3): shared channels, organizations, groups, readable policy and the default. All of
|
||||
// it lives in gateway/participants.yaml, which older daemons don't read, so a rollback still loads every person.
|
||||
|
||||
const channel = cmd.command("channel").description("Shared-channel participants: one vetted Slack channel is one participant (<id>@external), not a person per sender");
|
||||
channel
|
||||
.command("add <id>")
|
||||
.description("Register a shared channel")
|
||||
.requiredOption("--display-name <name>", "Human-readable name, e.g. \"Acme customers\"")
|
||||
.requiredOption("--binding <slack:connectorRef:secretsRef:channelId>", "The channel: connector kind (slack), connector label, vault pointer for its secrets (never the secret), and the Slack channel id")
|
||||
.option("--organization <id>", "Its organization (create it first with rig gateway org set)")
|
||||
.option("--group <id>", "A group it belongs to (repeatable)", (v: string, acc: string[] = []) => { acc.push(v); return acc; })
|
||||
.option("--policy <text>", "Readable policy for agents answering this channel (text, not enforced)")
|
||||
.option("--policy-file <path>", "Read the policy text from a file")
|
||||
.option("--replace", "Explicitly replace an existing channel (no silent overwrite)")
|
||||
.action(async (id: string, opts: { displayName: string; binding: string; organization?: string; group?: string[]; policy?: string; policyFile?: string; replace?: boolean }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const policy = readPolicyOption(opts);
|
||||
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
|
||||
const parts = opts.binding.split(":");
|
||||
if (parts.length < 4 || parts[0] !== "slack") { console.error("refused: --binding must be slack:<connectorRef>:<secretsRef>:<channelId>"); process.exitCode = 1; return; }
|
||||
const res = registry.setSharedChannel(id, {
|
||||
displayName: opts.displayName,
|
||||
channel: { connector: "slack", connectorRef: parts[1]!, secretsRef: parts.slice(2, -1).join(":"), channelId: parts[parts.length - 1]! },
|
||||
organization: opts.organization, groups: opts.group, policy: policy.value,
|
||||
}, { replace: !!opts.replace });
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
console.log(JSON.stringify({ ok: true, id, address: `${id}@${registry.ADDRESS_DOMAIN}`, path: res.path }));
|
||||
});
|
||||
channel
|
||||
.command("remove <id>")
|
||||
.description("Remove a shared channel: refuses while non-terminal rows are addressed to it (--force removes anyway)")
|
||||
.option("--force", "Remove despite KNOWN non-terminal rows")
|
||||
.action(async (id: string, opts: { force?: boolean }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const side = registry.readParticipantsFile();
|
||||
if (!side.ok) { console.error(`refused: ${side.error}`); process.exitCode = 1; return; }
|
||||
if (side.file.participants[id]?.kind !== "shared-channel") { console.error(`refused: no shared channel "${id}"`); process.exitCode = 1; return; }
|
||||
const address = `${id}@${registry.ADDRESS_DOMAIN}`;
|
||||
const rowsRes = await queueRows(address);
|
||||
if (!rowsRes.ok) { console.error(`refused: the queue could not be checked for rows addressed to ${address} (${rowsRes.error}); --force doesn't override an unchecked board`); process.exitCode = 1; return; }
|
||||
if (rowsRes.rows.length && !opts.force) {
|
||||
console.error(`refused: ${rowsRes.rows.length} non-terminal row(s) are addressed to ${address} (${rowsRes.rows.map((r) => r.id).join(", ")}); pass --force to remove anyway`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const res = registry.removeParticipantEntry(id, undefined, { kind: "shared-channel" });
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
console.log(JSON.stringify({ ok: true, removed: id, path: res.path, ...(rowsRes.rows.length ? { orphanedRows: rowsRes.rows.map((r) => r.id) } : {}) }));
|
||||
});
|
||||
|
||||
const org = cmd.command("org").description("Organizations: the outer layer of participants, with readable policy agents apply");
|
||||
org
|
||||
.command("set <id>")
|
||||
.description("Create or update an organization")
|
||||
.option("--name <name>", "Display name (required when new)")
|
||||
.option("--policy <text>", "Readable policy for agents working with this organization (text, not enforced; \"\" clears it)")
|
||||
.option("--policy-file <path>", "Read the policy text from a file")
|
||||
.action(async (id: string, opts: { name?: string; policy?: string; policyFile?: string }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const policy = readPolicyOption(opts);
|
||||
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
|
||||
const res = registry.setOrganization(id, { name: opts.name, ...(policy.value !== undefined ? { policy: policy.value || null } : {}) });
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
console.log(JSON.stringify({ ok: true, organization: id, path: res.path }));
|
||||
});
|
||||
|
||||
const group = cmd.command("group").description("Groups inside an organization, with readable policy agents apply");
|
||||
group
|
||||
.command("set <id>")
|
||||
.description("Create or update a group")
|
||||
.option("--organization <id>", "Its organization (required when new)")
|
||||
.option("--name <name>", "Display name (required when new)")
|
||||
.option("--policy <text>", "Readable policy for agents working with this group (text, not enforced; \"\" clears it)")
|
||||
.option("--policy-file <path>", "Read the policy text from a file")
|
||||
.action(async (id: string, opts: { organization?: string; name?: string; policy?: string; policyFile?: string }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const policy = readPolicyOption(opts);
|
||||
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
|
||||
const res = registry.setGroup(id, { organization: opts.organization, name: opts.name, ...(policy.value !== undefined ? { policy: policy.value || null } : {}) });
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
console.log(JSON.stringify({ ok: true, group: id, path: res.path }));
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("participants")
|
||||
.description("List people and shared channels, with organization, groups, policy and the default")
|
||||
.option("--organization <id>", "Only this organization's participants")
|
||||
.option("--json", "Complete records as JSON, including organizations and groups")
|
||||
.action(async (opts: { organization?: string; json?: boolean }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const view = registry.participantsView();
|
||||
if (!view.ok) { console.error(`refused: ${view.error}`); process.exitCode = 1; return; }
|
||||
const shown = opts.organization ? view.participants.filter((p) => p.organization?.id === opts.organization) : view.participants;
|
||||
const defaultPerson = view.default.person ? { id: view.default.person.entityId, source: view.default.source } : { id: null, reason: view.default.source === "none" ? view.default.reason : null };
|
||||
if (opts.json) {
|
||||
const side = registry.readParticipantsFile();
|
||||
console.log(JSON.stringify({ ok: true, participants: shown, default: defaultPerson,
|
||||
organizations: side.ok ? side.file.organizations : null, groups: side.ok ? side.file.groups : null,
|
||||
warnings: view.warnings, participantFile: view.sideFile }));
|
||||
return;
|
||||
}
|
||||
if (!shown.length) console.log(opts.organization ? `no participants in organization "${opts.organization}"` : "no participants yet: rig gateway human add … registers a person, rig gateway channel add … a shared channel");
|
||||
for (const p of shown) {
|
||||
console.log(`${p.id} ${p.kind} "${p.displayName}" ${p.address}${p.channel ? ` channel ${p.channel.channelId}` : ""}`);
|
||||
for (const line of describePlacement(p)) console.log(line);
|
||||
}
|
||||
console.log(defaultPerson.id ? `default: ${defaultPerson.id} (${defaultPerson.source === "chosen" ? "chosen" : "the only person"})` : `default: none — ${defaultPerson.reason}`);
|
||||
for (const warning of view.warnings) console.log(`warning: ${warning}`);
|
||||
if (view.sideFile.error) console.log(`participant file: ${view.sideFile.error}`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("default [entityId]")
|
||||
.description("Show or choose the default person: the one escalations, the notice digest, Mission Control's my-queue and pairing go to when no person is named")
|
||||
.option("--clear", "Clear the chosen default (with one person, they stay the default)")
|
||||
.action(async (entityId: string | undefined, opts: { clear?: boolean }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
if (entityId || opts.clear) {
|
||||
if (entityId && opts.clear) { console.error("refused: pass an id or --clear, not both"); process.exitCode = 1; return; }
|
||||
const res = registry.setDefaultPerson(opts.clear ? null : entityId!);
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
}
|
||||
const people = registry.projectHumans();
|
||||
if (!people.ok) { console.error(`refused: ${people.error}`); process.exitCode = 1; return; }
|
||||
const chosen = registry.resolveDefaultPerson(people.entities);
|
||||
console.log(JSON.stringify(chosen.person
|
||||
? { ok: true, default: chosen.person.entityId, address: chosen.person.address, source: chosen.source }
|
||||
: { ok: true, default: null, reason: chosen.source === "none" ? chosen.reason : null }));
|
||||
console.log(JSON.stringify({ ok: true, removed: res.removed, archivedPath: res.archivedPath, ...(res.orphanRecordPath ? { orphanRecordPath: res.orphanRecordPath } : {}) }));
|
||||
});
|
||||
|
||||
return cmd;
|
||||
@@ -791,26 +622,3 @@ function describeApplication(a: Record<string, any>): string {
|
||||
const effects = (a.effects as Array<Record<string, any>>).map((e) => ` ${e.consumer}: ${e.state}${e.detail ? ` (${e.detail})` : ""}`);
|
||||
return [head, ...(effects.length ? effects : [" no effects"])].join("\n");
|
||||
}
|
||||
|
||||
/** --policy or --policy-file (never both): the readable policy text agents read. Absent is undefined. */
|
||||
function readPolicyOption(opts: { policy?: string; policyFile?: string }): { ok: true; value: string | undefined } | { ok: false; error: string } {
|
||||
if (opts.policy !== undefined && opts.policyFile !== undefined) return { ok: false, error: "pass --policy or --policy-file, not both" };
|
||||
if (opts.policyFile === undefined) return { ok: true, value: opts.policy };
|
||||
try {
|
||||
return { ok: true, value: readFileSync(opts.policyFile, "utf8") };
|
||||
} catch (error) {
|
||||
return { ok: false, error: `--policy-file: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/** One participant's organization, groups, default and policy, in plain lines. */
|
||||
function describePlacement(p: { organization: { id: string; name: string } | null; groups: Array<{ id: string; name: string }>; default: boolean; policy: Array<{ source: string; text: string }> }): string[] {
|
||||
const lines = [
|
||||
` organization: ${p.organization ? `${p.organization.id} (${p.organization.name})` : "none"}`,
|
||||
` groups: ${p.groups.length ? p.groups.map((g) => `${g.id} (${g.name})`).join(", ") : "none"}`,
|
||||
];
|
||||
if (p.default) lines.push(" default: yes (escalations, the notice digest and other unaddressed needs go to this person)");
|
||||
if (!p.policy.length) lines.push(" policy: none");
|
||||
for (const part of p.policy) lines.push(` policy, from ${part.source}:`, ...part.text.trimEnd().split("\n").map((line) => ` ${line}`));
|
||||
return lines;
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export function growCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
let agentRef: string;
|
||||
try {
|
||||
agentRef = await resolveDefaultAgentRef(client, opts.runtime);
|
||||
agentRef = await resolveDefaultAgentRef(client);
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exitCode = 1;
|
||||
@@ -135,7 +135,7 @@ export function growCommand(depsOverride?: StatusDeps): Command {
|
||||
id: member,
|
||||
agent_ref: agentRef,
|
||||
runtime: opts.runtime,
|
||||
profile: opts.runtime === "terminal" ? "none" : "default",
|
||||
profile: "default",
|
||||
cwd,
|
||||
}));
|
||||
let nodes: GrowNode[] = [];
|
||||
|
||||
@@ -470,7 +470,7 @@ list/explain never mutate. Diagnosis mutations use explicit subcommands; automat
|
||||
.option("--json", "Summary JSON with omitted fields, original byte size and exact full command")
|
||||
.option("--full", "Complete original record; use --full --json for lossless JSON (may be large)")
|
||||
.action(async (id: string, o: { json?: boolean; full?: boolean }) => diagnosisRequest(`/${encodeURIComponent(id)}`, o));
|
||||
diagnosis.command("record <qitem-id>").description("Record an agent's disposition for one occurrence from a JSON --file").requiredOption("--file <path>", "Disposition JSON with verdict, causalStart, steering, uncertainty, evidenceRefs").option("--json")
|
||||
diagnosis.command("record <qitem-id>").requiredOption("--file <path>", "Disposition JSON with verdict, causalStart, steering, uncertainty, evidenceRefs").option("--json")
|
||||
.action(async (id: string, o: { file: string; json?: boolean }) => diagnosisRequest(`/${encodeURIComponent(id)}/disposition`, o, { value: fromFile(o.file) }));
|
||||
diagnosis.command("notify <qitem-id>").description("Explicitly request human delivery under policy and verified connector readiness").option("--json")
|
||||
.action(async (id: string, o: { json?: boolean }) => diagnosisRequest(`/${encodeURIComponent(id)}/notify`, o, {}));
|
||||
|
||||
@@ -23,7 +23,6 @@ import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executo
|
||||
import { recordHostObservation, loadHostBindings, describeBindingConflict, defaultHostBindingsPath, type HostObservationOutcome } from "../host-bindings.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { readOwnHostName, readSelectedHost } from "../host-selection.js";
|
||||
import { parseFeedHostSubscriptionKey } from "../config-store.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rig host pair — the approval wait is BOUNDED.
|
||||
@@ -72,46 +71,6 @@ export interface DoctorDeps {
|
||||
* sidecar (TOFU + loud-conflict). Optional so existing fixtures keep compiling AND unit runs never
|
||||
* write the real sidecar — absent means learning is skipped, which is the fail-open contract. */
|
||||
learnHostBinding?: (alias: string, observedHostId: string, observedUrl: string) => HostObservationOutcome;
|
||||
/** The registry entry a reply addressed `@<hostId>` resolves to, by the lookup `rig send` makes.
|
||||
* Optional like the learner: absent means doctor makes no claim about where replies go. */
|
||||
replyRoute?: (hostId: string) => HostEntry | null;
|
||||
/** The self-id learned for a registry alias, if any. */
|
||||
learnedHostId?: (alias: string) => string | null;
|
||||
}
|
||||
|
||||
/** Where `rig send` takes a target ending `@<hostId>`: registry id, then a declared hostId, then the
|
||||
* first hosts.yaml entry with that learned binding (cross-host-target.ts). Null when none matches. */
|
||||
export function replyRouteFromDisk(hostId: string): HostEntry | null {
|
||||
const loaded = loadHostRegistry();
|
||||
if (!loaded.ok) return null;
|
||||
const resolved = resolveHost(loaded.registry, hostId, loadHostBindings().bindings);
|
||||
return resolved.ok ? resolved.host : null;
|
||||
}
|
||||
|
||||
const REPLY_ROUTE_ORDER = "rig send resolves it by registry id, then a declared hostId, then the first hosts.yaml entry that learned it";
|
||||
|
||||
/** A reachable entry whose self-id another entry answers for. Unknown, not fail: doctor can't tell
|
||||
* from here whether that other entry still reaches the machine. */
|
||||
function replyRouteElsewhereRow(host: HostEntry, hostId: string, winner: HostEntry): CheckRow {
|
||||
return {
|
||||
step: "reply-route",
|
||||
status: "unknown",
|
||||
detail: `replies addressed @${hostId} go to '${winner.id}', not '${host.id}' (${REPLY_ROUTE_ORDER})`,
|
||||
fix: `run rig host doctor ${winner.id}; if it no longer reaches this machine, delete its entry from ${defaultHostRegistryPath()} (and its line in ${defaultHostBindingsPath()}, if any)`,
|
||||
};
|
||||
}
|
||||
|
||||
/** An unreachable entry that replies still resolve to: the failure a stale entry causes, named on
|
||||
* the entry itself. Null when the entry has no self-id or another entry answers for it. */
|
||||
function replyRouteThroughDeadEntryRow(host: HostEntry, deps: DoctorDeps): CheckRow | null {
|
||||
const hostId = host.hostId ?? deps.learnedHostId?.(host.id) ?? null;
|
||||
if (!hostId || deps.replyRoute?.(hostId)?.id !== host.id) return null;
|
||||
return {
|
||||
step: "reply-route",
|
||||
status: "fail",
|
||||
detail: `replies addressed @${hostId} resolve to '${host.id}', so they fail while it is unreachable`,
|
||||
fix: `if another entry reaches this machine, delete '${host.id}' from ${defaultHostRegistryPath()} (and its line in ${defaultHostBindingsPath()}, if any), then run rig host doctor on that entry; otherwise restore this entry's connectivity`,
|
||||
};
|
||||
}
|
||||
|
||||
function nestedBoolean(obj: unknown, path: readonly string[]): boolean | undefined {
|
||||
@@ -155,8 +114,6 @@ function defaultDoctorDeps(): DoctorDeps {
|
||||
sock.on("error", () => { sock.destroy(); resolve("closed"); });
|
||||
}),
|
||||
learnHostBinding: (alias, observedHostId, observedUrl) => recordHostObservation({ alias, observedHostId, observedUrl }),
|
||||
replyRoute: replyRouteFromDisk,
|
||||
learnedHostId: (alias) => loadHostBindings().bindings[alias]?.hostId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -206,8 +163,6 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
|
||||
? "SSH auth/permission gate — check the key, agent, and remote authorized_keys"
|
||||
: "verify the ssh target/alias, tailnet connectivity, and that sshd is running",
|
||||
});
|
||||
const routeRow = reach.failedStep === "ssh-unreachable" ? replyRouteThroughDeadEntryRow(host, deps) : null;
|
||||
if (routeRow) rows.push(routeRow);
|
||||
return rows; // later legs are meaningless without the shell
|
||||
}
|
||||
rows.push({ step: "transport-reachability", status: "pass", detail: `ssh to ${host.target} ok` });
|
||||
@@ -300,28 +255,20 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
|
||||
const parsed = JSON.parse(health.body) as { selfHostId?: unknown };
|
||||
if (typeof parsed.selfHostId === "string" && parsed.selfHostId.length > 0) {
|
||||
const obs = deps.learnHostBinding(host.id, parsed.selfHostId, (host as HttpHostEntry).url);
|
||||
if (obs.outcome === "conflict") {
|
||||
rows.push({
|
||||
step: "host-identity-binding",
|
||||
status: "fail",
|
||||
detail: describeBindingConflict(host.id, obs.binding),
|
||||
fix: `if the re-key is legitimate, delete the '${host.id}' entry in ${defaultHostBindingsPath()} and re-run doctor to re-learn`,
|
||||
});
|
||||
} else {
|
||||
// Ask the route rig send takes rather than assume it: an entry listed earlier in
|
||||
// hosts.yaml with the same identity keeps the replies, even when it no longer answers.
|
||||
const id = obs.binding.hostId;
|
||||
const winner = deps.replyRoute?.(id) ?? null;
|
||||
const routedHere = winner?.id === host.id;
|
||||
rows.push({
|
||||
step: "host-identity-binding",
|
||||
status: "pass",
|
||||
detail: obs.outcome === "bound"
|
||||
? `learned host identity '${id}' (first contact)${routedHere ? ` — reply hints ending @${id} now resolve to '${host.id}'` : ""}`
|
||||
: `host identity '${id}' confirmed`,
|
||||
});
|
||||
if (winner && !routedHere) rows.push(replyRouteElsewhereRow(host, id, winner));
|
||||
}
|
||||
rows.push(obs.outcome === "conflict"
|
||||
? {
|
||||
step: "host-identity-binding",
|
||||
status: "fail",
|
||||
detail: describeBindingConflict(host.id, obs.binding),
|
||||
fix: `if the re-key is legitimate, delete the '${host.id}' entry in ${defaultHostBindingsPath()} and re-run doctor to re-learn`,
|
||||
}
|
||||
: {
|
||||
step: "host-identity-binding",
|
||||
status: "pass",
|
||||
detail: obs.outcome === "bound"
|
||||
? `learned host identity '${obs.binding.hostId}' (first contact) — reply hints ending @${obs.binding.hostId} now resolve to '${host.id}'`
|
||||
: `host identity '${obs.binding.hostId}' confirmed`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// body not JSON — an older or non-daemon endpoint; learning is skipped, nothing breaks.
|
||||
@@ -329,8 +276,6 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
|
||||
}
|
||||
} catch (err) {
|
||||
rows.push({ step: "transport-reachability", status: "fail", detail: `daemon unreachable at ${(host as HttpHostEntry).url}: ${(err as Error).message}`, fix: "check tailnet connectivity and that the remote daemon is running (rig daemon start on the host)" });
|
||||
const routeRow = replyRouteThroughDeadEntryRow(host, deps);
|
||||
if (routeRow) rows.push(routeRow);
|
||||
return rows;
|
||||
}
|
||||
rows.push({
|
||||
@@ -536,21 +481,6 @@ function authPointer(h: HostEntry): string {
|
||||
return h.bearer_env ? `env:${h.bearer_env}` : h.bearer_file ? `file:${h.bearer_file}` : "—";
|
||||
}
|
||||
|
||||
function printHostNextSteps(host: HostEntry): void {
|
||||
console.log(`Verify it: rig host doctor ${host.id}`);
|
||||
if (host.transport !== "http") {
|
||||
console.log("Remote FEED requires an HTTP host entry. Pair its daemon URL with: rig host pair <url>");
|
||||
return;
|
||||
}
|
||||
const key = `feed.subscriptions.${host.id}.enabled`;
|
||||
if (!parseFeedHostSubscriptionKey(key)) {
|
||||
console.log(`For remote FEED, register this host with a different id: '${host.id}' is reserved by feed settings.`);
|
||||
return;
|
||||
}
|
||||
console.log("Remote FEED is off by default. To include this host's attention items, run locally:");
|
||||
console.log(` rig config set ${key} true`);
|
||||
}
|
||||
|
||||
export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
const cmd = new Command("host").description("Manage the multi-host registry (~/.openrig/hosts.yaml)");
|
||||
|
||||
@@ -586,7 +516,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
return;
|
||||
}
|
||||
console.log(`Added host '${res.entry.id}' (${res.entry.transport}) to ${res.path}`);
|
||||
printHostNextSteps(res.entry);
|
||||
console.log(`Verify it: rig host doctor ${res.entry.id}`);
|
||||
});
|
||||
|
||||
cmd
|
||||
@@ -856,7 +786,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
return;
|
||||
}
|
||||
console.log(`Paired. Host '${id}' registered (${targetBase}, bearer_file ${tokenPath}).`);
|
||||
printHostNextSteps(added.entry);
|
||||
console.log(`Verify it: rig host doctor ${id}`);
|
||||
});
|
||||
|
||||
cmd
|
||||
|
||||
@@ -34,7 +34,7 @@ type LaunchResponse = {
|
||||
// OPR.0.4.3.20 FR-7 — a launched-entry status that means NO session is running (the
|
||||
// operator must act). These are NOT successful launches — never print "Launched" for
|
||||
// them; exit non-zero. Mirrors the daemon's NON_RUNNING_LAUNCH_STATUSES.
|
||||
const NON_RUNNING_LAUNCH_STATUSES = new Set(["awaiting-decision", "attention_required", "failed", "left_attached"]);
|
||||
const NON_RUNNING_LAUNCH_STATUSES = new Set(["awaiting-decision", "attention_required", "failed"]);
|
||||
function launchStatusRunning(status: string): boolean {
|
||||
return !NON_RUNNING_LAUNCH_STATUSES.has(status);
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--snapshot-id <id>", "Use this exact restore-usable snapshot")
|
||||
.option("--retry-startup-from <member-file>", "Retry a stopped first start that failed during projection, using its original member fragment")
|
||||
.option("--rig-root <path>", "Original absolute source root for --retry-startup-from")
|
||||
.option("--plan", "Show restore selection and non-target effects without mutation; does not preview first start of a never-started materialized seat")
|
||||
.option("--plan", "Show subset selection and non-target effects without mutation")
|
||||
.option("--json", "JSON output")
|
||||
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml")
|
||||
.action(async (rigHandle: string, nodeRef: string | undefined, opts: { json?: boolean; holdReason?: string; seats?: string; host?: string; snapshotId?: string; plan?: boolean; retryStartupFrom?: string; rigRoot?: string }) => {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Command } from "commander";
|
||||
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps , daemonStatusGuard} from "../daemon-lifecycle.js";
|
||||
import { createMcpServer } from "../mcp-server.js";
|
||||
import { addGrant, McpHttpConfigError, mcpTokenFilePath, readGrantServiceHeader, readGrants, revokeGrant, startMcpHttpServer } from "../mcp-http.js";
|
||||
import { readOpenRigEnv } from "../openrig-compat.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
@@ -19,50 +17,19 @@ export function mcpCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
cmd
|
||||
.command("serve")
|
||||
.description("Start MCP server (stdio transport, or Streamable HTTP with --http)")
|
||||
.description("Start MCP server (stdio transport)")
|
||||
.option("--port <port>", "Daemon port override")
|
||||
.option("--http", "Serve over Streamable HTTP at /mcp instead of stdio")
|
||||
.option("--bind <host:port>", "With --http: where to listen (default 127.0.0.1:7437). Beyond loopback needs a grant (rig mcp token add)")
|
||||
.option("--grant-service <url>", "With --http: check each request's key with the grant service at this URL instead of the token file")
|
||||
.option("--grant-service-header-file <path>", "With --grant-service: a file holding one 'Name: value' header to send with each grant request")
|
||||
.option("--oauth-resource <url>", "With --grant-service and --oauth-issuer: the public https URL clients reach this server as (its path ends in /mcp), for OAuth discovery")
|
||||
.option("--oauth-issuer <url>", "With --oauth-resource: the OAuth authorization server that issues the keys --grant-service checks")
|
||||
.addHelpText("after", `
|
||||
Both MCP protocol revisions are served, over stdio and --http alike: clients on 2026-07-28 (which may start with
|
||||
server/discover) and clients that open with 2025's initialize handshake.
|
||||
|
||||
With --http and no token file, the server stays on loopback and acts as OPENRIG_SESSION_NAME, declared and not
|
||||
authenticated, like stdio. Once a grant exists (rig mcp token add), every request needs its bearer credential, loopback
|
||||
included, and each caller acts as its grant's seat within its rigs and roots. Add a grant before reaching the port
|
||||
through a tunnel or proxy: they arrive on loopback too. If the token file then goes missing or can't be read, every
|
||||
request is refused; serving unauthenticated again takes a restart with no token file. TLS comes from the tunnel or
|
||||
proxy, not from this server.
|
||||
|
||||
With --grant-service, every request needs a key the grant service issued, checked with that service on each request
|
||||
for this daemon's instance (its self id); the token file isn't read. A caller acts as the seat its grant names, which
|
||||
must already exist here (created is enough; it needn't have been launched), and reaches only what the grant names. If
|
||||
the grant service can't answer, the request is refused.
|
||||
|
||||
With --grant-service, --oauth-resource and --oauth-issuer (both or neither) add OAuth discovery (RFC 9728) for clients
|
||||
that sign in with OAuth. The resource is the public URL this server is reached as, and the issuer is the authorization
|
||||
server that issues the keys --grant-service checks. GET /.well-known/oauth-protected-resource<resource path> then answers
|
||||
with both, exactly as given, without a credential or a call to the grant service, and every 401 points there. Both must
|
||||
be https with no userinfo, query or fragment, written as a URL writes them (a lowercase host, no default port). /mcp
|
||||
stays the endpoint; a proxy in front forwards the metadata path unchanged.`)
|
||||
.action(async (opts: { port?: string; http?: boolean; bind?: string; grantService?: string; grantServiceHeaderFile?: string; oauthResource?: string; oauthIssuer?: string }) => {
|
||||
.action(async (opts: { port?: string }) => {
|
||||
const deps = getDepsF();
|
||||
|
||||
let daemonUrl: string;
|
||||
if (opts.port) {
|
||||
const daemonPort = parseInt(opts.port, 10);
|
||||
if (Number.isNaN(daemonPort)) {
|
||||
if (isNaN(daemonPort)) {
|
||||
console.error("Invalid port number");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (daemonPort < 1 || daemonPort > 65535) {
|
||||
console.error(`Warning: daemon port ${daemonPort} is outside [1, 65535]; MCP will start, but daemon tool calls may fail. Check --port.`);
|
||||
}
|
||||
daemonUrl = `http://127.0.0.1:${daemonPort}`;
|
||||
} else {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
@@ -70,134 +37,27 @@ stays the endpoint; a proxy in front forwards the metadata path unchanged.`)
|
||||
daemonUrl = getDaemonUrl(status);
|
||||
}
|
||||
|
||||
if (!opts.http && (opts.grantService || opts.grantServiceHeaderFile)) {
|
||||
console.error("--grant-service and --grant-service-header-file need --http");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.grantServiceHeaderFile && !opts.grantService) {
|
||||
console.error("--grant-service-header-file needs --grant-service");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if ((opts.oauthResource === undefined) !== (opts.oauthIssuer === undefined)) {
|
||||
console.error("--oauth-resource and --oauth-issuer go together: give both, or neither");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const oauth = opts.oauthResource !== undefined && opts.oauthIssuer !== undefined ? { resource: opts.oauthResource, issuer: opts.oauthIssuer } : undefined;
|
||||
if (oauth && !(opts.http && opts.grantService)) {
|
||||
console.error("--oauth-resource and --oauth-issuer need --http --grant-service");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.http) {
|
||||
await serveHttp(daemonUrl, opts.bind ?? "127.0.0.1:7437", opts.grantService, opts.grantServiceHeaderFile, oauth);
|
||||
return;
|
||||
}
|
||||
const client = deps.clientFactory(daemonUrl);
|
||||
const server = createMcpServer(client);
|
||||
const transport = new StdioServerTransport();
|
||||
let finish!: () => void;
|
||||
const closed = new Promise<void>((resolve) => { finish = resolve; });
|
||||
server.server.onclose = finish;
|
||||
process.stdin.once("end", finish);
|
||||
process.once("SIGINT", finish);
|
||||
process.once("SIGTERM", finish);
|
||||
// Serves the protocol revision the client opens with: 2026-07-28 per request, or 2025's initialize handshake.
|
||||
const served = serveStdio(() => createMcpServer(client), { onerror: (err) => console.error(`rig mcp serve: ${err.message}`) });
|
||||
try {
|
||||
// EOF is a normal stdio disconnect, not an unresolved top-level await.
|
||||
await server.connect(transport);
|
||||
if (process.stdin.readableEnded) finish();
|
||||
await closed;
|
||||
} finally {
|
||||
process.stdin.removeListener("end", finish);
|
||||
process.removeListener("SIGINT", finish);
|
||||
process.removeListener("SIGTERM", finish);
|
||||
await served.close();
|
||||
}
|
||||
});
|
||||
|
||||
const token = cmd.command("token").description("Grants for rig mcp serve --http: who may connect, as which seat, reaching what");
|
||||
token
|
||||
.command("add <name>")
|
||||
.description("Add a grant and print its token once; only the token's hash is stored")
|
||||
.requiredOption("--seat <session>", "The seat this caller acts as (its rows' source)")
|
||||
.option("--rig <name...>", "Rigs whose seats and work this caller may reach")
|
||||
.option("--root <name...>", "Shared file roots (OPENRIG_FILES_ALLOWLIST names) whose artifacts it may read")
|
||||
.option("--workspace", "Also read the work tree and context (rig_slices, rig_mission, rig_context_*)")
|
||||
.option("--owner", "An owner grant: every tool and object, like the local owner")
|
||||
.action(async (name: string, opts: { seat: string; rig?: string[]; root?: string[]; workspace?: boolean; owner?: boolean }) => {
|
||||
const file = mcpTokenFilePath();
|
||||
try {
|
||||
const issued = await addGrant(file, { name, seat: opts.seat, rigs: opts.rig ?? [], roots: opts.root ?? [], workspace: opts.workspace === true, owner: opts.owner === true });
|
||||
console.log(`Token for ${name}, shown once (only its hash is kept in ${file}):`);
|
||||
console.log(issued);
|
||||
console.log(`The client sends it as: Authorization: Bearer <token>. Revoke it with: rig mcp token revoke ${name}`);
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
token
|
||||
.command("list")
|
||||
.description("List grants (never their tokens or hashes)")
|
||||
.option("--json", "JSON output")
|
||||
.action((opts: { json?: boolean }) => {
|
||||
const file = mcpTokenFilePath();
|
||||
let grants;
|
||||
try { grants = readGrants(file); } catch (err) { console.error((err as Error).message); process.exitCode = 1; return; }
|
||||
const shown = (grants ?? []).map(({ tokenSha256: _hash, ...grant }) => grant);
|
||||
if (opts.json) { console.log(JSON.stringify({ configured: grants !== null, file, grants: shown }, null, 2)); return; }
|
||||
if (grants === null) { console.log(`No grants: rig mcp serve --http serves loopback only, unauthenticated (${file} doesn't exist).`); return; }
|
||||
if (shown.length === 0) console.log(`No grants left in ${file}: every HTTP request is refused until one is added.`);
|
||||
for (const grant of shown) {
|
||||
console.log(`${grant.name}: seat ${grant.seat}${grant.owner ? ", owner" : ""}; rigs ${grant.rigs.join(", ") || "(none)"}; roots ${grant.roots.join(", ") || "(none)"}${grant.workspace ? "; workspace" : ""}`);
|
||||
}
|
||||
});
|
||||
token
|
||||
.command("revoke <name>")
|
||||
.description("Remove a grant: its next request is refused. Work it already handed over stays with its owners")
|
||||
.action(async (name: string) => {
|
||||
try {
|
||||
if (!(await revokeGrant(mcpTokenFilePath(), name))) { console.error(`No grant named ${name}.`); process.exitCode = 1; return; }
|
||||
console.log(`Revoked ${name}: its next request is refused. Its rows and records stay.`);
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
async function serveHttp(daemonUrl: string, bind: string, grantService?: string, grantServiceHeaderFile?: string,
|
||||
oauth?: { resource: string; issuer: string }): Promise<void> {
|
||||
const match = /^(\[[^\]]+\]|[^:]+):(\d+)$/.exec(bind);
|
||||
if (!match) {
|
||||
console.error(`--bind takes host:port (for example 127.0.0.1:7437 or [::1]:7437), not ${bind}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const host = match[1]!.replace(/^\[|\]$/g, "");
|
||||
const tokenFile = mcpTokenFilePath();
|
||||
let served;
|
||||
try {
|
||||
const header = grantServiceHeaderFile ? readGrantServiceHeader(grantServiceHeaderFile) : undefined;
|
||||
served = await startMcpHttpServer({ daemonUrl, host, port: Number(match[2]), tokenFile,
|
||||
...(grantService ? { grantService: { url: grantService, ...(header ? { header } : {}) } } : {}), ...(oauth ? { oauth } : {}) });
|
||||
} catch (err) {
|
||||
console.error(err instanceof McpHttpConfigError ? err.message : `Can't serve on ${bind}: ${(err as Error).message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const seat = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")?.trim();
|
||||
console.error(grantService
|
||||
? `OpenRig MCP over HTTP at ${served.url}: each request needs a key from the grant service, checked with ${grantService}.`
|
||||
: served.authenticated
|
||||
? `OpenRig MCP over HTTP at ${served.url}: each request needs a bearer credential from ${tokenFile}.`
|
||||
: `OpenRig MCP over HTTP at ${served.url}: no grants, so loopback only, acting as ${seat || "no seat"}, declared and not authenticated. Add a grant (rig mcp token add) before reaching it through a tunnel or proxy.`);
|
||||
if (oauth) console.error(`OAuth discovery names ${oauth.resource}, issued by ${oauth.issuer}.`);
|
||||
await new Promise<void>((resolve) => {
|
||||
process.once("SIGINT", resolve);
|
||||
process.once("SIGTERM", resolve);
|
||||
});
|
||||
await served.close();
|
||||
}
|
||||
@@ -13,11 +13,6 @@ type SourceSnapshot = {
|
||||
sources: { project: unknown; scopes: {id: string; source: string; hash: string}[];
|
||||
roster: {session: string; nodeId: string; generation: string}[];
|
||||
recent: {streamItemId: string; body: string; evidenceRef: string}[] };
|
||||
/** Routing candidates (durable seats and their open work), versioned apart from `version`. */
|
||||
routing?: { owners: {session: string; logicalId: string; role: string | null}[]; ownersTruncated: boolean;
|
||||
work: {qitemId: string; destination: string; state: string; summary: string}[]; workTruncated: boolean } | null;
|
||||
routingVersion?: string | null;
|
||||
routingUnavailable?: string | null;
|
||||
};
|
||||
const hash = (text: string) => `sha256:${createHash("sha256").update(text).digest("hex")}`;
|
||||
function localText(file: string): string {
|
||||
@@ -36,7 +31,7 @@ function response<T>(r: {status: number; data: T}): T {
|
||||
throw Error(`${code}: ${message}`);
|
||||
}
|
||||
|
||||
export async function prepareWorker(client: Client, opts: WakeOptions, read = localText, eligibleQuery: Record<string, string> = {}) {
|
||||
export async function prepareWorker(client: Client, opts: WakeOptions, read = localText) {
|
||||
const limit = Number(opts.limit ?? 20);
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw Error("limit must be an integer in 1..100");
|
||||
const text = read(opts.taxonomy), taxonomy = parse(text);
|
||||
@@ -56,7 +51,7 @@ export async function prepareWorker(client: Client, opts: WakeOptions, read = lo
|
||||
version: hash(JSON.stringify({sourceVersion: snapshot.version, taxonomyHash: hash(text), values})), values,
|
||||
duplicateCandidates: snapshot.sources.recent.map(x => ({streamItemId: x.streamItemId, evidenceRef: x.evidenceRef})), relatedRefs: [],
|
||||
};
|
||||
const query = new URLSearchParams({classifierVersion: opts.classifierVersion, taxonomyVersion: taxonomy.version, evidenceEpoch: opts.evidenceEpoch, limit: opts.limit ?? "20", expectedOccupant: occupant.generation, ...eligibleQuery});
|
||||
const query = new URLSearchParams({classifierVersion: opts.classifierVersion, taxonomyVersion: taxonomy.version, evidenceEpoch: opts.evidenceEpoch, limit: opts.limit ?? "20", expectedOccupant: occupant.generation});
|
||||
const eligible = response(await client.get<{items: {streamItemId: string}[]; nextAfterSortKey: string | null}>(`/api/projects/eligible?${query}`));
|
||||
return {snapshot, candidates, taxonomy: {version: taxonomy.version, source: opts.taxonomy, hash: hash(text), questions: taxonomy.fields}, eligible};
|
||||
}
|
||||
@@ -65,16 +60,9 @@ export async function prepareWorker(client: Client, opts: WakeOptions, read = lo
|
||||
export async function runProjectWake(client: Client, opts: WakeOptions, read = localText, experiment?: {
|
||||
decide: (prepared: Awaited<ReturnType<typeof prepareWorker>>) => WorkerOptions["classify"];
|
||||
signal: AbortSignal; shouldStop: () => boolean; timeoutMs: number;
|
||||
/** A decisions consumer's own candidate values, and fields it adds to each classification or abstention write. */
|
||||
candidates?: (prepared: Awaited<ReturnType<typeof prepareWorker>>) => ClassificationCandidates;
|
||||
classifyBody?: (input: Record<string, unknown>) => Record<string, unknown>;
|
||||
abstainBody?: (input: Record<string, unknown>) => Record<string, unknown>;
|
||||
/** Extra eligibility filters the consumer asks the daemon for (an older daemon ignores them). */
|
||||
eligibleQuery?: Record<string, string>;
|
||||
}) {
|
||||
const prepared = await prepareWorker(client, opts, read, experiment?.eligibleQuery);
|
||||
const {snapshot, taxonomy} = prepared;
|
||||
const candidates = experiment?.candidates ? experiment.candidates(prepared) : prepared.candidates;
|
||||
const prepared = await prepareWorker(client, opts, read);
|
||||
const {snapshot, candidates, taxonomy} = prepared;
|
||||
const {session, generation} = snapshot.occupant;
|
||||
const suffix = (url: string) => `${url}${url.includes("?") ? "&" : "?"}expectedOccupant=${encodeURIComponent(generation)}`;
|
||||
const get = async <T>(url: string): Promise<T> => response(await client.get<T>(suffix(url)));
|
||||
@@ -96,13 +84,12 @@ export async function runProjectWake(client: Client, opts: WakeOptions, read = l
|
||||
heartbeat: (id, actor) => post("/api/projects/lease/heartbeat", {leaseId: id, classifierSession: actor}),
|
||||
},
|
||||
attempts: {
|
||||
eligible: input => get(`/api/projects/eligible?${new URLSearchParams([...Object.entries(input).filter(([,v]) => v !== undefined).map(([k,v]): [string, string] => [k,String(v)]),
|
||||
...Object.entries(experiment?.eligibleQuery ?? {})])}`),
|
||||
eligible: input => get(`/api/projects/eligible?${new URLSearchParams(Object.entries(input).filter(([,v]) => v !== undefined).map(([k,v]): [string, string] => [k,String(v)]))}`),
|
||||
begin: input => post("/api/projects/attempts/begin", input),
|
||||
abstain: input => post(`/api/projects/attempts/${encodeURIComponent(input.attemptId)}/abstain`, experiment?.abstainBody ? experiment.abstainBody({...input}) : input),
|
||||
abstain: input => post(`/api/projects/attempts/${encodeURIComponent(input.attemptId)}/abstain`, input),
|
||||
fail: input => post(`/api/projects/attempts/${encodeURIComponent(input.attemptId)}/fail`, input),
|
||||
},
|
||||
classifier: {classify: input => post("/api/projects/project", experiment?.classifyBody ? experiment.classifyBody({...input}) : input)},
|
||||
classifier: {classify: input => post("/api/projects/project", input)},
|
||||
stream: {getById: id => get(`/api/stream/${encodeURIComponent(id)}`)},
|
||||
classify: experiment ? experiment.decide(prepared) : async request => {
|
||||
if (snapshot.unavailable.length) return {kind: "abstain", reason: `candidate sources unavailable: ${snapshot.unavailable.join("; ")}`};
|
||||
|
||||
@@ -5,8 +5,6 @@ import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-life
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { experimentStatus, setExperiment, readBounded, JevRun, streamDecision, classifyCapture } from "./project-jev.js";
|
||||
import { DecisionRun, decisionsStatus, readDecisionsSettings, type DecisionsSettings } from "../decisions/decisions.js";
|
||||
import { recoverEffects, runDecideWake } from "../decisions/stream-consumer.js";
|
||||
import fs from "node:fs";
|
||||
|
||||
/**
|
||||
@@ -23,7 +21,6 @@ export interface ProjectDeps extends StatusDeps {
|
||||
workerRead?: (file: string) => string;
|
||||
experimentEnv?: NodeJS.ProcessEnv;
|
||||
experimentFetch?: typeof fetch;
|
||||
decisionsSettings?: () => DecisionsSettings;
|
||||
}
|
||||
|
||||
async function withClient<T>(
|
||||
@@ -55,13 +52,8 @@ export function projectCommand(depsOverride?: ProjectDeps): Command {
|
||||
};
|
||||
|
||||
const experimental = cmd.command("experimental").description("Optional Jev experiment; advisory, finite foreground runs, disabled by default");
|
||||
const experimentVerbDescriptions = {
|
||||
enable: "Turn the experiment on in its --config file, with any new limits, and print its status",
|
||||
disable: "Turn the experiment off in its --config file and print its status; later runs make no request",
|
||||
status: "Print the experiment's settings from its --config file; changes nothing",
|
||||
} as const;
|
||||
for (const verb of ["enable", "disable", "status"] as const) {
|
||||
experimental.command(verb).description(experimentVerbDescriptions[verb]).requiredOption("--config <file>", "Explicit local experiment JSON; contains no credential")
|
||||
experimental.command(verb).requiredOption("--config <file>", "Explicit local experiment JSON; contains no credential")
|
||||
.option("--max-requests <n>", "Maximum requests per foreground run, 1..20")
|
||||
.option("--timeout-ms <n>", "Per-request deadline, 1000..30000")
|
||||
.option("--json", "JSON output")
|
||||
@@ -104,16 +96,6 @@ export function projectCommand(depsOverride?: ProjectDeps): Command {
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command("decisions").description("Typed decisions (OpenRouter + Jev) for enabled consumers; off by default")
|
||||
.command("status").description("What a decision run in this process would do; makes no provider call")
|
||||
.option("--json", "JSON output")
|
||||
.action(() => {
|
||||
try {
|
||||
const deps = getDeps();
|
||||
printResult(true, decisionsStatus((deps.decisionsSettings ?? readDecisionsSettings)(), deps.experimentEnv, deps.workerRead), 200);
|
||||
} catch (error) { printResult(true, {error: "decisions_status", message: error instanceof Error ? error.message : "unavailable"}, 400); }
|
||||
});
|
||||
|
||||
for (const verb of ["candidates", "wake"] as const) {
|
||||
cmd.command(verb)
|
||||
.description(verb === "wake" ? "Run one bounded occupant wake with source-bound decisions or explicit Jev experiment; no registration" : "Read current source-bound candidates and eligible item IDs for the occupant")
|
||||
@@ -123,37 +105,9 @@ export function projectCommand(depsOverride?: ProjectDeps): Command {
|
||||
.requiredOption("--evidence-epoch <epoch>", "Owner-authorized evidence epoch; candidate changes do not retry terminal attempts")
|
||||
.option("--decisions <file>", "JSON decisions bound to candidateSetVersion and each item's bodyHash")
|
||||
.option("--experiment <file>", "Opt-in experimental Jev config; replaces --decisions for one bounded wake")
|
||||
.option("--decide", "Typed decisions for eligible stream items (decisions.enabled with consumer stream): accepted answers become owned work or a recorded disposition; everything else stays with you")
|
||||
.option("--limit <count>", "Items per bounded wake (1..100)", "20")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (opts: WakeOptions & {experiment?: string; decide?: boolean}) => {
|
||||
if (opts.decide) {
|
||||
if (verb !== "wake" || opts.decisions || opts.experiment) {
|
||||
printResult(true, {error: "decide_usage", message: "--decide is for wake only and can't be combined with --decisions or --experiment"}, 400); return;
|
||||
}
|
||||
const deps = getDeps(), limit = Number(opts.limit ?? 20);
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 100) { printResult(true, {error: "decide_usage", message: "limit must be an integer in 1..100"}, 400); return; }
|
||||
const decisions = new DecisionRun({ consumer: "stream", settings: deps.decisionsSettings, env: deps.experimentEnv, send: deps.experimentFetch,
|
||||
budget: { maxCalls: limit, deadlineMs: Math.min(600_000, limit * 10_000) } });
|
||||
if (decisions.unavailable) {
|
||||
// Nothing is asked, but already-accepted effects are still applied: a disable never strands committed work.
|
||||
let recovered: unknown = [{ outcome: "skipped", reason: "daemon unavailable; effects wait for a wake" }];
|
||||
try { await withClient(deps, async client => { recovered = await recoverEffects(client, opts.project); }); }
|
||||
catch (error) { recovered = [{ outcome: "skipped", reason: `effects not checked: ${error instanceof Error ? error.message : "daemon unavailable"}; they wait for a wake` }]; }
|
||||
printResult(true, {decisions: decisions.unavailable, status: decisionsStatus((deps.decisionsSettings ?? readDecisionsSettings)(), deps.experimentEnv, deps.workerRead), recovered}, 200); return;
|
||||
}
|
||||
process.once("SIGINT", decisions.stop); process.once("SIGTERM", decisions.stop);
|
||||
try {
|
||||
await withClient(deps, async client => {
|
||||
try {
|
||||
const result = await runDecideWake(client, opts, decisions, deps.workerRead);
|
||||
console.log(JSON.stringify(result));
|
||||
if (["unavailable", "lease_lost"].includes(result.result.state)) process.exitCode = 1;
|
||||
} catch (error) { printResult(true, {error: "worker_unavailable", message: error instanceof Error ? error.message : "unavailable", phase: "prepare", nextWakeAt: new Date(Date.now() + 60_000).toISOString()}, 409); }
|
||||
});
|
||||
} finally { process.removeListener("SIGINT", decisions.stop); process.removeListener("SIGTERM", decisions.stop); }
|
||||
return;
|
||||
}
|
||||
.action(async (opts: WakeOptions & {experiment?: string}) => {
|
||||
let run: JevRun | undefined;
|
||||
try {
|
||||
if (opts.experiment) {
|
||||
@@ -166,10 +120,7 @@ export function projectCommand(depsOverride?: ProjectDeps): Command {
|
||||
}
|
||||
await withClient(getDeps(), async client => {
|
||||
try {
|
||||
// The experiment records its own attempt identity: rig stream show labels its classifications, and the automatic
|
||||
// path doesn't treat its abstentions as intake's.
|
||||
const wakeOpts = run ? { ...opts, classifierVersion: `${opts.classifierVersion}+experiment` } : opts;
|
||||
const result = verb === "wake" ? await runProjectWake(client, wakeOpts, getDeps().workerRead, run ? {
|
||||
const result = verb === "wake" ? await runProjectWake(client, opts, getDeps().workerRead, run ? {
|
||||
decide: prepared => streamDecision(run!, prepared), signal: run.controller.signal,
|
||||
shouldStop: run.stopped, timeoutMs: run.config.timeoutMs,
|
||||
} : undefined) : await prepareWorker(client, opts, getDeps().workerRead);
|
||||
|
||||
@@ -3,39 +3,9 @@ import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Command } from "commander";
|
||||
import YAML from "yaml";
|
||||
import { inferCatalogProject, ProjectReadError } from "@openrig/daemon/project-catalog";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { findSlice, readProjectMissionsRoot, resolveMissionsRoot } from "../lib/scope/scope-fs.js";
|
||||
import { ScopeCliError, type SliceInfo } from "../lib/scope/types.js";
|
||||
|
||||
/** findSlice, then, for an absolute slice path the configured tree doesn't own, the catalogued project whose root holds it
|
||||
* (chosen as work-install chooses one for a working folder), read under that project's missions root. A tree named with
|
||||
* --workspace or OPENRIG_WORK_ROOT stands, and a retry that fails reports the original refusal. */
|
||||
function findSliceInCatalog(
|
||||
missionsRoot: string, slicePath: string, hintMission: string | null, workspace: string | undefined, warnings: string[],
|
||||
): SliceInfo {
|
||||
try {
|
||||
return findSlice(missionsRoot, slicePath, hintMission);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ScopeCliError) || !path.isAbsolute(slicePath) || workspace || process.env.OPENRIG_WORK_ROOT) throw error;
|
||||
let projectMissionsRoot: string | null = null;
|
||||
try {
|
||||
const catalogPath = String(new ConfigStore().resolveWithSource("workspace.catalog_path").value);
|
||||
const project = inferCatalogProject(catalogPath, new ProjectReadError("project_required", "no catalogued project holds the slice"),
|
||||
{ cwd: slicePath }, warnings);
|
||||
projectMissionsRoot = readProjectMissionsRoot(project.root, warnings);
|
||||
} catch {
|
||||
throw error;
|
||||
}
|
||||
if (!projectMissionsRoot || path.resolve(projectMissionsRoot) === path.resolve(missionsRoot)) throw error;
|
||||
try {
|
||||
return findSlice(projectMissionsRoot, slicePath, hintMission);
|
||||
} catch {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
import { findSlice, resolveMissionsRoot } from "../lib/scope/scope-fs.js";
|
||||
import { ScopeCliError } from "../lib/scope/types.js";
|
||||
import { selectProofContractBody, isScaffoldPlaceholderText } from "../lib/scope/scaffold-placeholder.js";
|
||||
import { parseLogicalCheckboxes } from "../lib/scope/logical-checkbox.js";
|
||||
|
||||
@@ -260,7 +230,7 @@ checkboxes do not accept an item under the selected proof policy.
|
||||
}
|
||||
const parentOpts = (command.parent?.opts() ?? {}) as { workspace?: string };
|
||||
const missionsRoot = resolveMissionsRoot({ override: parentOpts.workspace });
|
||||
const slice = findSliceInCatalog(missionsRoot, slicePath, opts.mission ?? null, parentOpts.workspace, warns);
|
||||
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
|
||||
|
||||
// Resolve the artifact body.
|
||||
let body = "";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient, DaemonTimeoutError } from "../client.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
@@ -20,44 +19,9 @@ export const USAGE_ACROSS_HOSTS_READ_TIMEOUT_MS = 20_000;
|
||||
// deadline: 50 s + 15 s + a 10 s margin = 75 s.
|
||||
export const USAGE_ACROSS_HOSTS_REFRESH_TIMEOUT_MS = 75_000;
|
||||
|
||||
type Peer = { logicalId: string; sessionName: string | null; home?: "same" | "different" | "unknown"; effect: string; step?: string };
|
||||
type SeatView = { home?: string; activity?: string; peers: Peer[] };
|
||||
type SwitchReport = { home?: string; account: string; tokenFile?: string; conversation?: string | null; loginChanged: boolean; undo: string | null; peers: Peer[];
|
||||
restart?: { recoveryCommand?: string; remainingStep?: string }; next?: string; warnings?: string[]; tokenApplied?: boolean };
|
||||
|
||||
// A switch waits as long as `rig seat restart` does: the seat's in-place restart runs inside it.
|
||||
const SWITCH_TIMEOUT_MS = 120_000;
|
||||
|
||||
function printSeat(seat: SeatView): void {
|
||||
if (seat.home) console.log(`home: ${seat.home}`);
|
||||
if (seat.activity) console.log(`seat activity: ${seat.activity}`);
|
||||
for (const peer of seat.peers) {
|
||||
const where = peer.home === "same" ? "peer on this home" : peer.home === "different" ? "seat on another home" : "seat, home unknown";
|
||||
console.log(`${where}: ${peer.sessionName ?? peer.logicalId}: ${peer.effect}`);
|
||||
if (peer.step) console.log(` then: ${peer.step}`);
|
||||
}
|
||||
}
|
||||
|
||||
type AccountOpts = { toAccount?: string; tokenFile?: string; storedLogin?: boolean };
|
||||
|
||||
/** The account a switch names: a Codex login's saved name, a Claude token's label with its file, or a Claude seat's stored
|
||||
* login. Null when neither --to-account nor --stored-login was given. */
|
||||
function targetAccount(opts: AccountOpts): { toAccount: string; tokenFile?: string | null } | null {
|
||||
if (opts.storedLogin) return { toAccount: "stored-login", tokenFile: null };
|
||||
if (!opts.toAccount) return null;
|
||||
return { toAccount: opts.toAccount, ...(opts.tokenFile !== undefined ? { tokenFile: opts.tokenFile } : {}) };
|
||||
}
|
||||
|
||||
function printClaude(claude: { label: string; tokenFile: string | null; checked: string[]; notChecked: string[]; instead?: string }): void {
|
||||
console.log(`Claude token: ${claude.tokenFile ? `"${claude.label}", read from ${claude.tokenFile} when Claude starts` : "none (the config dir's stored login)"}`);
|
||||
if (claude.instead) console.log(`instead of the stored login, the seat would use ${claude.instead}`);
|
||||
if (claude.checked.length) console.log(`checked for anything that outranks it: ${claude.checked.join("; ")}`);
|
||||
if (claude.notChecked.length) console.log(`not checked (the seat's /status shows what it uses): ${claude.notChecked.join("; ")}`);
|
||||
}
|
||||
|
||||
export function providerCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("provider").description(
|
||||
"Provider accounts and usage signals, and moving one Codex seat to another login saved in its Codex home",
|
||||
"Provider accounts, usage signals, and interruption-safe account switching",
|
||||
);
|
||||
const getDeps = (): StatusDeps =>
|
||||
depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
@@ -174,16 +138,12 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
|
||||
const c = (r["configured"] ?? {}) as Record<string, unknown>;
|
||||
const configured = c["source"] === "registry"
|
||||
? `configured: profile ${c["profileRef"]} (registry, recorded ${c["recordedAt"]}${c["profileSaved"] === false ? "; profile not saved on this host" : c["profileSaved"] === null ? "; whether the profile is saved: unknown" : ""})`
|
||||
: c["source"] === "default_login" && c["homeFrom"]
|
||||
? `configured: the seat's own home (${c["home"]}, from its ${c["homeFrom"]} env)`
|
||||
: c["source"] === "default_login"
|
||||
? `configured: default login${c["home"] ? ` (${c["home"]})` : ""}`
|
||||
: `configured: unknown (${c["reason"]})`;
|
||||
const allowance = (r["allowance"] ?? {}) as Record<string, unknown>;
|
||||
console.log(` ${r["seatSession"]} ${r["runtime"]} ${configured} · allowance not linked`);
|
||||
if (c["source"] === "default_login" && c["homeNote"]) console.log(` ${c["homeNote"]}`);
|
||||
const ignored = r["ignoredHome"] as Record<string, unknown> | undefined;
|
||||
if (ignored) console.log(` ignored: its ${ignored["from"]} env sets ${ignored["variable"]}=${ignored["value"]}, which isn't an absolute path, so it isn't used as this seat's home`);
|
||||
for (const note of (r["registryNotes"] as string[] | undefined) ?? []) console.log(` ${note}`);
|
||||
const observed = r["observed"] as Record<string, unknown> | undefined;
|
||||
const differs = (observed?.["differsFrom"] as string[] | undefined) ?? [];
|
||||
@@ -204,7 +164,7 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
|
||||
function identityLabel(id: Record<string, unknown>): string {
|
||||
// Codex: account, else the workspace account/read reported; Claude: the organisation and email auth status
|
||||
// reported at the last refresh; else unresolved. For Codex the email is a label.
|
||||
return id["source"] === "configured credential label" ? `credential ${id["credentialLabel"]} (operator label)` : id["accountId"] ? `account ${id["accountId"]}`
|
||||
return id["accountId"] ? `account ${id["accountId"]}`
|
||||
: id["workspaceId"] ? `workspace ${id["workspaceId"]} (from account/read; no usage account id)`
|
||||
: id["source"] !== "unresolved" && id["orgId"] ? `organisation ${id["orgName"] ? `${id["orgName"]} (${id["orgId"]})` : id["orgId"]}`
|
||||
: "identity unresolved";
|
||||
@@ -256,7 +216,6 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
|
||||
: id["source"] === "unresolved" ? " [from an earlier read; possibly this same login]"
|
||||
: " [from an earlier read; the latest read didn't match this row]";
|
||||
console.log(`${pad}${r["provider"]} ${labels.join(" · ")}${earlier}`);
|
||||
if (r["probe"]) console.log(`${pad} token probe: ${JSON.stringify(r["probe"])}`);
|
||||
if (r["identityNote"]) console.log(`${pad} ${r["identityNote"]}`);
|
||||
const unresolved = r["unresolved"] as Record<string, unknown> | undefined;
|
||||
if (unresolved) {
|
||||
@@ -268,7 +227,7 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
|
||||
const level = w["levelReason"] ? `${w["level"]} (${w["levelReason"]})` : String(w["level"]);
|
||||
const fresh = w["freshnessReason"] ? `${w["freshness"]}: ${w["freshnessReason"]}` : String(w["freshness"]);
|
||||
const seat = w["seatSession"] ? ` by seat ${w["seatSession"]}` : "";
|
||||
console.log(`${pad} ${windowName(w)} ${left} ${level} resets ${w["resetsAt"] ?? "unknown"} observed ${w["observedAt"]}${seat} (${fresh})${w["status"] ? ` · provider ${w["status"]}` : ""}`);
|
||||
console.log(`${pad} ${windowName(w)} ${left} ${level} resets ${w["resetsAt"] ?? "unknown"} observed ${w["observedAt"]}${seat} (${fresh})`);
|
||||
for (const d of (w["disagreeingSeats"] as Array<Record<string, unknown>> | undefined) ?? []) {
|
||||
console.log(`${pad} seat ${d["seatSession"]} reports ${Math.max(0, 100 - Number(d["usedPercent"]))}% left resets ${d["resetsAt"] ?? "unknown"} observed ${d["observedAt"]}`);
|
||||
}
|
||||
@@ -281,7 +240,7 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
|
||||
const last = r["lastRefresh"] as Record<string, unknown> | null;
|
||||
if (last?.["outcome"] === "failed") {
|
||||
// Claude's refresh reads identity only; its windows keep coming from the seats' samples.
|
||||
console.log(r["provider"] === "claude" && (r["source"] as Record<string, unknown> | undefined)?.["kind"] !== "claude_token_probe"
|
||||
console.log(r["provider"] === "claude"
|
||||
? `${pad} identity refresh failed at ${last["at"]}: ${failureText(last)}; the identity shown is from the last refresh that worked, if any`
|
||||
: `${pad} refresh failed at ${last["at"]}: ${failureText(last)}; last reading kept, marked old`);
|
||||
}
|
||||
@@ -409,22 +368,14 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--json", "Output as parseable JSON")
|
||||
.option("--refresh", "First read the Codex login's usage windows and the Claude login's identity natively (up to 35 s here, 75 s with --all-hosts/--hosts; provider.usage_windows is off by default: rig config set provider.usage_windows true)")
|
||||
.option("--provider <p>", "With --refresh, read only this provider (codex|claude)")
|
||||
.option("--claude-token-file <path>", "With --refresh --provider claude, probe this absolute token-file reference on the selected daemon (one model turn; no token value in argv)")
|
||||
.option("--credential-label <label>", "Operator label for the selected Claude token file; not provider-observed account identity")
|
||||
.option("--all-hosts", "Usage windows across every registered host, as each host already holds them (off by default: rig config set provider.usage_windows true)")
|
||||
.option("--hosts <ids>", "Like --all-hosts, for these registered hosts (comma-separated); this host is always included, so --hosts b --refresh also reads this daemon")
|
||||
.action(async (opts: { json?: boolean; refresh?: boolean; provider?: string; allHosts?: boolean; hosts?: string; claudeTokenFile?: string; credentialLabel?: string }) => {
|
||||
.action(async (opts: { json?: boolean; refresh?: boolean; provider?: string; allHosts?: boolean; hosts?: string }) => {
|
||||
if (opts.provider !== undefined && opts.provider !== "codex" && opts.provider !== "claude") {
|
||||
console.error(`--provider must be codex or claude (got ${opts.provider})`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const tokenSelected = opts.claudeTokenFile !== undefined || opts.credentialLabel !== undefined;
|
||||
if (tokenSelected && (!opts.claudeTokenFile || !opts.credentialLabel || !opts.refresh || opts.provider !== "claude" || opts.allHosts || opts.hosts !== undefined)) {
|
||||
console.error("A token probe requires --refresh --provider claude --claude-token-file <absolute path> --credential-label <label>, on one daemon (no --all-hosts/--hosts).");
|
||||
process.exitCode = 1; return;
|
||||
}
|
||||
if (tokenSelected) console.error("Claude token usage refresh spends one model turn; timeout or refusal does not prove no allowance was consumed.");
|
||||
const client = await getClient(getDeps());
|
||||
if (!client) {
|
||||
process.exitCode = 1;
|
||||
@@ -443,7 +394,7 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
|
||||
const providers = opts.provider ? [opts.provider] : ["codex", "claude"];
|
||||
const answered = await Promise.all(providers.map(async (provider) => ({
|
||||
provider,
|
||||
answer: await client.post<Record<string, unknown>>("/api/provider/usage/refresh", { provider, ...(tokenSelected ? { claudeToken: { label: opts.credentialLabel, tokenFile: opts.claudeTokenFile } } : {}) }, { timeoutMs: 60_000 }),
|
||||
answer: await client.post<Record<string, unknown>>("/api/provider/usage/refresh", { provider }, { timeoutMs: 60_000 }),
|
||||
})));
|
||||
// Bare --refresh keeps main's Codex exit: Claude is read too, but where it doesn't apply on this host
|
||||
// (no claude, no login, not a subscription login), or the daemon has no Claude refresh (400/404, an
|
||||
@@ -506,230 +457,46 @@ export function providerCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
cmd
|
||||
.command("precheck")
|
||||
.description("Whether a seat can move to a login: its in-place restart plan (nothing is stopped) and the login saved in the Codex home it runs on")
|
||||
.description("Whether switching a seat to an account is safe (never offers an unsafe switch)")
|
||||
.requiredOption("--seat <s>", "The seat session")
|
||||
.option("--to-account <a>", "Codex: the login's saved name in the seat's Codex home (rig auth list --codex-home <home>). Claude: your label for the token")
|
||||
.option("--token-file <path>", "Claude: the absolute path of a 0600 file holding the seat's own subscription token; it's read only as Claude starts")
|
||||
.option("--stored-login", "Claude: move the seat back to its config dir's stored login")
|
||||
.requiredOption("--to-account <a>", "The target account ref")
|
||||
.option("--json", "Output as parseable JSON")
|
||||
.action(async (opts: AccountOpts & { seat: string; json?: boolean }) => {
|
||||
const target = targetAccount(opts);
|
||||
if (!target) {
|
||||
console.error("Name the account: --to-account <name> (with --token-file <path> for a Claude seat), or --stored-login for a Claude seat.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
.action(async (opts: { seat: string; toAccount: string; json?: boolean }) => {
|
||||
const client = await getClient(getDeps());
|
||||
if (!client) {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const qs = new URLSearchParams({ seat: opts.seat, toAccount: target.toAccount,
|
||||
...(target.tokenFile === null ? { storedLogin: "1" } : target.tokenFile !== undefined ? { tokenFile: target.tokenFile } : {}) });
|
||||
const res = await client.get<{ safe: boolean; reasons?: string[]; message?: string; seat?: SeatView;
|
||||
claude?: Parameters<typeof printClaude>[0] }>(`/api/provider/precheck?${qs.toString()}`);
|
||||
const qs = new URLSearchParams({ seat: opts.seat, toAccount: opts.toAccount });
|
||||
const res = await client.get<{ safe: boolean; reasons?: string[] }>(`/api/provider/precheck?${qs.toString()}`);
|
||||
if (handleHttpError(res, "precheck")) return;
|
||||
if (opts.json) console.log(JSON.stringify(res.data));
|
||||
else {
|
||||
console.log(res.data.safe ? "SAFE" : `UNSAFE: ${(res.data.reasons ?? []).join(", ")}`);
|
||||
if (res.data.message) console.log(res.data.message);
|
||||
if (res.data.seat) printSeat(res.data.seat);
|
||||
if (res.data.claude) printClaude(res.data.claude);
|
||||
}
|
||||
else console.log(res.data.safe ? "SAFE" : `UNSAFE: ${(res.data.reasons ?? []).join(", ")}`);
|
||||
if (!res.data.safe) process.exitCode = 1; // let scripts gate on an unsafe verdict
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("switch")
|
||||
.description("Move one seat to another subscription login, keeping its conversation: a Codex seat to a login saved in its Codex home (Linux only), a Claude seat to its own token file")
|
||||
.description("Switch a seat to an account (precheck-gated; the daemon orchestrates the switch)")
|
||||
.requiredOption("--seat <s>", "The seat session")
|
||||
.option("--to-account <a>", "Codex: the login's saved name in the seat's Codex home (rig auth list --codex-home <home>). Claude: your label for the token")
|
||||
.option("--token-file <path>", "Claude: the absolute path of a 0600 file holding the seat's own subscription token; it's read only as Claude starts")
|
||||
.option("--stored-login", "Claude: move the seat back to its config dir's stored login")
|
||||
.option("--force-unsafe", "Kept for scripts; a seat's checks can't be overridden, since each means nothing safe to restart or no such login")
|
||||
.requiredOption("--to-account <a>", "The target account ref")
|
||||
.option("--force-unsafe", "Override a non-stranding precheck failure (still refuses to strand a live conversation)")
|
||||
.option("--json", "Output as parseable JSON")
|
||||
.addHelpText("after", `
|
||||
The daemon plans the seat's in-place restart first (rig seat restart --plan), so a refusal changes nothing. It then saves the
|
||||
home's current login as before-switch-<time>, activates the chosen one there (rig auth switch), and restarts the seat in place
|
||||
with the same conversation, pane and launch settings. If the current login can't be saved first, nothing changes. Every seat
|
||||
on that home shares its login. Peers aren't restarted: each keeps its current login only until its next token refresh, when
|
||||
Codex stops it with "signed in to another account" until it's restarted, so the report prints the rig seat restart <peer>
|
||||
--keep-conversation --reason "..." to paste for each. Signing in again there would replace the login you just switched to.
|
||||
One switch runs at a time per home; a switch that waited checks the seat again before it changes anything. The account
|
||||
isn't verified until the seat completes a tool turn: run the printed rig seat restart --keep-conversation --verify
|
||||
--expect-account. An uncertain restart is reported, never retried.
|
||||
|
||||
Codex limits:
|
||||
- Linux only: the in-place restart reads the running process from /proc, so elsewhere it refuses before anything changes.
|
||||
- Same home: the login must already be saved in the home the seat runs on; the seat doesn't move to another home.
|
||||
|
||||
A Claude seat keeps its config dir and gets its own subscription token: --to-account <your label> --token-file <absolute path
|
||||
to a 0600 file holding the token>. Only the path is kept, as the seat's reference; the launch reads the file as Claude starts,
|
||||
so the token never passes through OpenRig. First the daemon checks that the file would apply and that nothing Claude Code ranks
|
||||
above the token is set (a cloud provider, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY, an apiKeyHelper), and refuses with each one
|
||||
and its remedy if it is. When the token applies, the launch also unsets those names for Claude's process, so the pane shell's
|
||||
own exports can't outrank it. It takes a snapshot and checks that the snapshot resumes the seat's conversation. Then, holding
|
||||
the seat so no other stop, launch or switch runs on it in between, it sets the reference and relaunches the seat from that
|
||||
snapshot in its own pane: Claude ends there, the pane and its scrollback stay, and restore resumes the same conversation in it.
|
||||
Work in flight when it stopped doesn't carry over. Only a refusal before Claude was touched reads as unchanged; a relaunch that
|
||||
doesn't report a resumed conversation reads unknown, with the snapshot's recovery command, and isn't retried.
|
||||
Seats sharing the config dir keep their own login: Claude Code reads the token from that one process's environment and doesn't
|
||||
save it. That environment is Claude's, so the tools, hooks and MCP servers it starts inherit the token, and one that prints its
|
||||
environment writes it into the transcript. A token-authenticated seat can make model requests but can't use claude.ai
|
||||
connectors or Remote Control; its local MCP servers still work. --stored-login moves it back to the config dir's stored login;
|
||||
a CLAUDE_CODE_OAUTH_TOKEN the seat still receives outranks that login, and the report says which token it will use. Claude's
|
||||
/status shows token auth with no account email, so the label is yours, not an observed account.`)
|
||||
.action(async (opts: AccountOpts & { seat: string; forceUnsafe?: boolean; json?: boolean }) => {
|
||||
const target = targetAccount(opts);
|
||||
if (!target) {
|
||||
console.error("Name the account: --to-account <name> (with --token-file <path> for a Claude seat), or --stored-login for a Claude seat.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
.action(async (opts: { seat: string; toAccount: string; forceUnsafe?: boolean; json?: boolean }) => {
|
||||
const client = await getClient(getDeps());
|
||||
if (!client) {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
type SwitchResponse = { status: number; data: { outcome?: string; reasons?: string[]; message?: string; seat?: SwitchReport } };
|
||||
let res: SwitchResponse;
|
||||
const startedAt = new Date().toISOString();
|
||||
try {
|
||||
res = await client.post<SwitchResponse["data"]>("/api/provider/switch", {
|
||||
seat: opts.seat,
|
||||
...target,
|
||||
forceUnsafe: opts.forceUnsafe ?? false,
|
||||
}, { timeoutMs: SWITCH_TIMEOUT_MS });
|
||||
} catch (err) {
|
||||
if (!(err instanceof DaemonTimeoutError)) throw err;
|
||||
// The daemon may still be switching: a timeout is neither a failure nor a reason to send it again.
|
||||
res = { status: 200, data: { outcome: "unknown", reasons: ["switch_outcome_unknown"],
|
||||
message: `The CLI timed out waiting for the daemon. The switch may still be running, or queued behind another switch on the same Codex home; a queued switch hasn't changed anything yet. `
|
||||
+ `For a Codex seat, it has restarted the seat once rig seat restart ${opts.seat} --keep-conversation --plan shows a lastRestart that finished after ${startedAt}. `
|
||||
+ `Until then an eligible plan, or an older lastRestart, doesn't mean it ended, so don't run the switch again: a second one would queue behind it. `
|
||||
+ `For a Claude seat, rig seat status ${opts.seat} shows its token reference and whether it runs; a second switch would wait for this one.` } };
|
||||
}
|
||||
const res = await client.post<{ outcome?: string; reasons?: string[] }>("/api/provider/switch", {
|
||||
seat: opts.seat,
|
||||
toAccount: opts.toAccount,
|
||||
forceUnsafe: opts.forceUnsafe ?? false,
|
||||
});
|
||||
if (handleHttpError(res, "switch")) return;
|
||||
if (opts.json) console.log(JSON.stringify(res.data));
|
||||
else {
|
||||
const { outcome, reasons, message, seat } = res.data;
|
||||
console.log(reasons?.length ? `${outcome ?? "unknown"}: ${reasons.join(", ")}` : outcome ?? "unknown");
|
||||
if (message) console.log(message);
|
||||
if (seat?.home) {
|
||||
console.log(`home: ${seat.home}${seat.loginChanged ? ` (now "${seat.account}")` : ""}`);
|
||||
if (seat.undo) {
|
||||
console.log(`to restore the home's saved credential bytes: ${seat.undo}`);
|
||||
console.log("undo restores 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.");
|
||||
}
|
||||
} else if (seat) {
|
||||
console.log(`Claude token: ${seat.tokenFile ? `"${seat.account}", read from ${seat.tokenFile}` : "the config dir's stored login"}${seat.loginChanged
|
||||
? seat.tokenApplied === false ? " (reference saved; the token didn't take effect)" : " (set)" : ""}`);
|
||||
for (const warning of seat.warnings ?? []) console.log(`warning: ${warning}`);
|
||||
if (seat.conversation) console.log(`conversation: ${seat.conversation}`);
|
||||
if (seat.undo) console.log(`to switch back: ${seat.undo}`);
|
||||
}
|
||||
if (seat) {
|
||||
if (seat.restart?.recoveryCommand) console.log(`recovery: ${seat.restart.recoveryCommand}`);
|
||||
if (!seat.home && seat.restart?.remainingStep) console.log(seat.restart.remainingStep);
|
||||
printSeat({ peers: seat.peers });
|
||||
if (seat.next) console.log(`next: ${seat.next}`);
|
||||
}
|
||||
}
|
||||
if (res.data.outcome !== "rebind_in_progress" && res.data.outcome !== "succeeded") process.exitCode = 1;
|
||||
});
|
||||
|
||||
// Slice 18 — subscription move reports: what the agent that moved a Codex home reports, kept as queue items tagged
|
||||
// subscription-move. Reading them back answers "why did these seats move?"; the Fleet app reads the same route.
|
||||
type MoveEntry = {
|
||||
qitemId: string; reportedAt: string; reportedTo: string; followedBy: string[];
|
||||
report: { host: string; home: string; from: string; to: string; reason: string; follows?: string; seats: Array<{ seat: string; result: string; missing?: string }>; peers?: Array<{ seat: string; note: string }> };
|
||||
};
|
||||
const moves = cmd
|
||||
.command("moves")
|
||||
.description("Recent subscription move reports on this daemon, newest first: from and to, why, and each seat's result")
|
||||
.option("--seat <seat>", "Only reports that restarted or listed this seat, among the reports read")
|
||||
.option("--limit <n>", "How many of the newest reports to read, before --seat filters them (default 50, at most 200)")
|
||||
.option("--json", "Output as parseable JSON")
|
||||
.action(async (opts: { seat?: string; limit?: string; json?: boolean }) => {
|
||||
const client = await getClient(getDeps());
|
||||
if (!client) {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const qs = new URLSearchParams();
|
||||
if (opts.seat) qs.set("seat", opts.seat);
|
||||
if (opts.limit) qs.set("limit", opts.limit);
|
||||
const res = await client.get<{ moves: MoveEntry[]; unreadable: Array<{ qitemId: string; reason: string }>; truncated: boolean }>(
|
||||
`/api/provider/moves${qs.toString() ? `?${qs.toString()}` : ""}`,
|
||||
);
|
||||
if (handleHttpError(res, "moves")) return;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
return;
|
||||
}
|
||||
const { moves: entries, unreadable, truncated } = res.data;
|
||||
if (entries.length === 0) console.log("No subscription move reports.");
|
||||
for (const { qitemId, reportedAt, reportedTo, followedBy, report } of entries) {
|
||||
console.log(`${reportedAt} ${qitemId} ${report.host} ${report.home}: ${report.from} → ${report.to} (to ${reportedTo})`);
|
||||
console.log(` why: ${report.reason}`);
|
||||
for (const seat of report.seats) console.log(` ${seat.seat}: ${seat.result}${seat.missing ? `; missing: ${seat.missing}` : ""}`);
|
||||
for (const peer of report.peers ?? []) console.log(` ${peer.seat}: ${peer.note}`);
|
||||
if (report.follows) console.log(` follows ${report.follows}`);
|
||||
if (followedBy.length > 0) console.log(` followed by ${followedBy.join(", ")}`);
|
||||
}
|
||||
for (const item of unreadable) console.log(`unreadable: ${item.qitemId}: ${item.reason}`);
|
||||
if (truncated) console.log(`(only the newest ${opts.limit ?? 50} reports were read; older ones may exist: raise --limit)`);
|
||||
});
|
||||
moves
|
||||
.command("report")
|
||||
.description("File a report of a move you made or attempted, as a queue item to whoever asked (it moves nothing)")
|
||||
.requiredOption("--file <path>", "The report as JSON, or - for stdin")
|
||||
.requiredOption("--to <seat>", "Who gets the report: whoever asked for the move, or the person")
|
||||
.option("--follows <qitemId>", "The earlier report this one follows up (sets the report's follows)")
|
||||
.option("--evidence-ref <ref>", "The evidence a report to a person's seat names, e.g. the saved --verify output")
|
||||
.option("--json", "Output as parseable JSON")
|
||||
.addHelpText("after", `
|
||||
The report (version 1):
|
||||
{ "version": 1, "host": "<machine>", "home": "<CODEX_HOME>", "from": "<subscription label>", "to": "<subscription label>",
|
||||
"reason": "<why, in your words>",
|
||||
"policy": { "ref": "account-policy", "version": "<manifest version>" } (or { "none": true })
|
||||
"readings": [{ "subject": "<allowance>", "basis": "observed", "observedAt": "<when observed>", "summary": "<what it said>" },
|
||||
{ "subject": "<allowance>", "basis": "expected", "expectedFullSince": "<the reset>", "summary": "..." }],
|
||||
"seats": [{ "seat": "<seat>", "result": "moved",
|
||||
"accountEvidence": { "state": "matched", "detail": "<Account line>", "at": "<time>" },
|
||||
"toolTurn": { "state": "observed", "at": "<time>" } },
|
||||
{ "seat": "<seat>", "result": "unconfirmed", "missing": "<what wasn't seen>" }],
|
||||
"peers": [{ "seat": "<seat>", "note": "not restarted; account unverified" }] }
|
||||
moved needs accountEvidence matched and toolTurn observed, as rig seat restart --verify reports them; until both are
|
||||
seen, report the seat unconfirmed (or unknown) with what's missing. An expected reading (slice 01's expectation after
|
||||
a reset) stays labelled expected. Labels and references only: a value in a recognized credential format is refused.
|
||||
A refused report records nothing and changes nothing about the move: correct it and file it again; never repeat the
|
||||
switch or restart because a report was refused.`)
|
||||
.action(async (opts: { file: string; to: string; follows?: string; evidenceRef?: string; json?: boolean }, command: Command) => {
|
||||
const json = opts.json || command.parent?.opts().json;
|
||||
let report: unknown;
|
||||
try {
|
||||
report = JSON.parse(opts.file === "-" ? fs.readFileSync(0, "utf8") : fs.readFileSync(opts.file, "utf8"));
|
||||
} catch {
|
||||
console.error(`Could not read a JSON report from ${opts.file}. Nothing was recorded, and the move itself is unchanged.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.follows && report && typeof report === "object" && !Array.isArray(report)) report = { ...report, follows: opts.follows };
|
||||
const client = await getClient(getDeps());
|
||||
if (!client) {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const res = await client.post<{ qitemId?: string; errors?: string[] }>("/api/provider/moves", {
|
||||
to: opts.to, report, ...(opts.evidenceRef ? { evidenceRef: opts.evidenceRef } : {}),
|
||||
});
|
||||
if (res.status < 400 && json) console.log(JSON.stringify(res.data));
|
||||
else if (res.status < 400) console.log(`Filed ${res.data.qitemId} to ${opts.to}`);
|
||||
else {
|
||||
for (const error of res.data.errors ?? []) console.error(`- ${error}`);
|
||||
handleHttpError(res, "move report");
|
||||
}
|
||||
else console.log(res.data.outcome ?? "unknown");
|
||||
if (res.data.outcome === "failed_safely") process.exitCode = 1;
|
||||
});
|
||||
|
||||
return cmd;
|
||||
|
||||
@@ -11,7 +11,6 @@ import { loadHostRegistry, resolveHost, hostDisplayTarget, resolveRemoteBearer,
|
||||
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
|
||||
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
|
||||
import { readOpenRigEnv } from "../openrig-compat.js";
|
||||
import { formatSeatActivity as formatActivity } from "../lib/seat-activity.js";
|
||||
import type { AggregatedPayload, PerHostStatus } from "../lib/hosts/fanout-contract.js";
|
||||
|
||||
interface PsEntry {
|
||||
@@ -44,10 +43,6 @@ interface PsEntry {
|
||||
}
|
||||
|
||||
interface NodeEntry {
|
||||
/** Served daemon attention, never re-derived here. Missing on an older daemon. */
|
||||
needsAttention?: boolean;
|
||||
arbitratedNeedsInput?: boolean | null;
|
||||
needsInputEvidence?: { rung: string; observedAt: string } | null;
|
||||
rigId: string;
|
||||
rigName: string;
|
||||
logicalId: string;
|
||||
@@ -83,9 +78,7 @@ interface NodeEntry {
|
||||
needsInput: { count: number; reason: string | null };
|
||||
decidedBy: string | null;
|
||||
} | null;
|
||||
identityVerdict?: { verdict?: string; reason?: string | null; observedAt?: string | null } | null;
|
||||
/** The registry's recorded session status, kept when the served sessionStatus is corrected. */
|
||||
storedSessionStatus?: string | null;
|
||||
identityVerdict?: { reason?: string | null } | null;
|
||||
/** A community harness's seat: its label, maintainer and declared limits. */
|
||||
communityHarness?: { harness: string; maintainer: string | null; limits: string[]; identity: string; needsInput: string; presetError?: string };
|
||||
/** Slice 15 — `has-work-to-do` primitive. Derived from queue_items;
|
||||
@@ -233,13 +226,8 @@ const ALLOWED_NODE_FIELDS = new Set([
|
||||
"inProgressWorkCount",
|
||||
"blockedWorkCount",
|
||||
"agentActivity",
|
||||
"needsAttention",
|
||||
"arbitratedNeedsInput",
|
||||
"needsInputEvidence",
|
||||
"contextUsage",
|
||||
"heldReason",
|
||||
"storedSessionStatus",
|
||||
"identityVerdict",
|
||||
]);
|
||||
|
||||
interface PsCliOptions {
|
||||
@@ -621,6 +609,13 @@ function selectFanOutFields(entries: Array<Record<string, unknown>>, fields: str
|
||||
});
|
||||
}
|
||||
|
||||
function needsAttention(node: NodeEntry): boolean {
|
||||
return node.lifecycleState === "attention_required"
|
||||
|| node.startupStatus === "attention_required"
|
||||
|| node.startupStatus === "failed"
|
||||
|| node.agentActivity?.state === "needs_input";
|
||||
}
|
||||
|
||||
function hasCurrentUsageLimit(node: NodeEntry): boolean {
|
||||
const activity = node.agentActivity;
|
||||
return activity?.state === "unknown" && activity.reason === "usage_limit"
|
||||
@@ -641,17 +636,6 @@ function missingSessionGuidance(node: NodeEntry): string | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
/** A recorded-running seat whose applicable identity check could not reach tmux: its liveness is
|
||||
* unconfirmed, not known dead. Codex's mismatch verdict keeps its own attention path. */
|
||||
function livenessUnconfirmed(node: NodeEntry): boolean {
|
||||
return node.sessionStatus === "running" && !node.intentionallyStopped
|
||||
&& node.identityVerdict?.verdict === "tmux_unavailable";
|
||||
}
|
||||
|
||||
function livenessLine(seat: string, node: NodeEntry): string {
|
||||
return `Liveness (${seat}): recorded running, not confirmed: tmux was unavailable at the last observation (${node.identityVerdict?.observedAt ?? "time unknown"}).`;
|
||||
}
|
||||
|
||||
// OPR.0.4.0.34 — the compact orch field set (PRD FR-4). Carries the
|
||||
// source-available identity + state + resume-summary fields an orchestrator
|
||||
// needs at a glance, WITHOUT leaking the resume token value or resumeCommand
|
||||
@@ -707,7 +691,7 @@ export function formatDeclaredModel(model: string | null | undefined): string {
|
||||
|
||||
export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string, unknown>> {
|
||||
return nodes.map((n) => {
|
||||
const keepDiagnostics = n.needsAttention !== false;
|
||||
const attention = needsAttention(n);
|
||||
const observation = n.modelObservation == null ? null : modelObservation(n.modelObservation, n.sessionStatus === "running");
|
||||
const compact: Record<string, unknown> = {
|
||||
// identity (rigId/rigName + logicalId + session) — disambiguates under -A.
|
||||
@@ -731,8 +715,6 @@ export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string,
|
||||
modelObservation: !observation ? null : observation.status === "observed"
|
||||
? [observation.model, observation.source, observation.observedAt]
|
||||
: { status: "unknown", reason: observation.reason },
|
||||
// Provenance remains on --full / --fields; the compact read keeps the served flag.
|
||||
...(n.needsAttention === undefined ? {} : { needsAttention: n.needsAttention }),
|
||||
// lifecycle + session/startup state.
|
||||
sessionStatus: n.sessionStatus,
|
||||
startupStatus: n.startupStatus,
|
||||
@@ -740,7 +722,7 @@ export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string,
|
||||
oriented: n.oriented ?? "n-a",
|
||||
lifecycleState: n.lifecycleState,
|
||||
// Keep the current usage limit visible without classifying it as human attention.
|
||||
agentActivity: keepDiagnostics || hasCurrentUsageLimit(n)
|
||||
agentActivity: attention || hasCurrentUsageLimit(n)
|
||||
? { state: n.agentActivity?.state ?? "unknown", reason: n.agentActivity?.reason }
|
||||
: { state: n.agentActivity?.state ?? "unknown" },
|
||||
// Work counts. Compact keeps the base pending-only key plus the active
|
||||
@@ -758,17 +740,9 @@ export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string,
|
||||
lastActivity: n.agentActivity?.sampledAt ?? n.startupCompletedAt ?? null,
|
||||
};
|
||||
if (n.intentionallyStopped !== undefined) compact.intentionallyStopped = n.intentionallyStopped;
|
||||
// The served identity evidence (verdict, reason, observation time) and the registry's recorded
|
||||
// status, without the raw evidence; recorded running is not a check that the process is alive.
|
||||
if (n.storedSessionStatus !== undefined) compact.storedSessionStatus = n.storedSessionStatus;
|
||||
if (n.identityVerdict) {
|
||||
const { verdict, reason, observedAt } = n.identityVerdict;
|
||||
// Only what was served: an absent time stays absent, never filled in.
|
||||
compact.identityVerdict = { ...(verdict ? { verdict } : {}), ...(reason ? { reason } : {}), ...(observedAt ? { observedAt } : {}) };
|
||||
}
|
||||
// held/attention reason (short) when present.
|
||||
if (n.heldReason) compact.heldReason = n.heldReason;
|
||||
if (keepDiagnostics && n.latestError) {
|
||||
if (attention && n.latestError) {
|
||||
compact.latestError = n.latestError;
|
||||
}
|
||||
return compact;
|
||||
@@ -925,8 +899,7 @@ podNamespace, hostSelfId, canonicalSessionName, nodeKind, runtime, communityHarn
|
||||
intentionallyStopped, startupStatus, restoreOutcome, lifecycleState, tmuxAttachCommand,
|
||||
resumeCommand, latestError, terminalActive, hasAssignedWork,
|
||||
assignedWorkCount, pendingWorkCount, inProgressWorkCount, blockedWorkCount,
|
||||
agentActivity, needsAttention, arbitratedNeedsInput, needsInputEvidence,
|
||||
contextUsage, heldReason, storedSessionStatus, identityVerdict.
|
||||
agentActivity, contextUsage, heldReason.
|
||||
|
||||
--host runs on a remote host declared in ~/.openrig/hosts.yaml (no current-rig
|
||||
default applied; the remote host's rigs are shown).
|
||||
@@ -1355,11 +1328,12 @@ async function handleNodes(
|
||||
const humanTruncated = !opts.full && limit === null && filtered.length > HUMAN_NODE_BUDGET;
|
||||
|
||||
if (useCompact) {
|
||||
console.log(padCompactNodeRow("RIG", "SESSION", "LIFECYCLE", "ACTIVITY", "WORK", "REASON", "ATTN"));
|
||||
console.log(padCompactNodeRow("RIG", "SESSION", "LIFECYCLE", "ACTIVITY", "WORK", "REASON"));
|
||||
for (const n of humanList as NodeEntry[]) {
|
||||
const reason = missingSessionGuidance(n) ?? (n.needsAttention !== false || hasCurrentUsageLimit(n)
|
||||
const attn = needsAttention(n);
|
||||
const reason = missingSessionGuidance(n) ?? (attn || hasCurrentUsageLimit(n)
|
||||
? (n.latestError ? truncate(n.latestError, 40) : n.agentActivity?.reason ?? "—")
|
||||
: livenessUnconfirmed(n) ? "liveness unconfirmed" : "—");
|
||||
: "—");
|
||||
console.log(padCompactNodeRow(
|
||||
n.rigName,
|
||||
n.canonicalSessionName ?? "—",
|
||||
@@ -1367,7 +1341,6 @@ async function handleNodes(
|
||||
formatActivity(n),
|
||||
formatHasWork(n.hasAssignedWork, n.assignedWorkCount ?? n.pendingWorkCount),
|
||||
reason,
|
||||
n.needsAttention === undefined ? "?" : n.needsAttention ? "yes" : "no",
|
||||
));
|
||||
}
|
||||
} else {
|
||||
@@ -1394,8 +1367,7 @@ async function handleNodes(
|
||||
formatActivity(n),
|
||||
formatContextUsage(n.contextUsage),
|
||||
n.restoreOutcome,
|
||||
missingSessionGuidance(n) ?? (n.latestError ? truncate(n.latestError, 30) : n.heldReason ? `held: ${truncate(n.heldReason, 25)}`
|
||||
: livenessUnconfirmed(n) ? "liveness unconfirmed" : "—"),
|
||||
missingSessionGuidance(n) ?? (n.latestError ? truncate(n.latestError, 30) : n.heldReason ? `held: ${truncate(n.heldReason, 25)}` : "—"),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1409,9 +1381,6 @@ async function handleNodes(
|
||||
for (const n of humanList as NodeEntry[]) {
|
||||
if (n.communityHarness) console.log(formatCommunityHarnessLine(n.canonicalSessionName ?? n.logicalId, n.communityHarness));
|
||||
}
|
||||
for (const n of humanList as NodeEntry[]) {
|
||||
if (livenessUnconfirmed(n)) console.log(livenessLine(n.canonicalSessionName ?? n.logicalId, n));
|
||||
}
|
||||
if (humanTruncated) {
|
||||
const remaining = filtered.length - HUMAN_NODE_BUDGET;
|
||||
console.log(`... and ${remaining} more node${remaining === 1 ? "" : "s"} (truncated at ${HUMAN_NODE_BUDGET}).`);
|
||||
@@ -1422,6 +1391,27 @@ async function handleNodes(
|
||||
}
|
||||
}
|
||||
|
||||
function formatActivity(n: Pick<NodeEntry, "agentActivity" | "activityState" | "communityHarness">): string {
|
||||
// S19 — render from the served taxonomy state when present: needs-input as
|
||||
// count(+reason), otherwise the bridge's display value. No local arbitration.
|
||||
const tax = n.activityState;
|
||||
if (tax) {
|
||||
if (tax.needsInput.count > 0) return `needs-input x${tax.needsInput.count}`;
|
||||
// A community harness has no needs-input source in that state, so its zero isn't a clearance: a prompt the
|
||||
// screen shows is kept.
|
||||
if (n.communityHarness && n.agentActivity?.state === "needs_input") return "needs-input (screen)";
|
||||
return tax.display;
|
||||
}
|
||||
// Legacy fallback (a daemon without the S19 enrichment) — retires with the
|
||||
// pre-taxonomy surfaces per the ladder-as-migration rule.
|
||||
const activity = n.agentActivity;
|
||||
if (!activity) return "unknown";
|
||||
if (activity.state === "running") return "running";
|
||||
if (activity.state === "needs_input") return "needs_input";
|
||||
if (activity.state === "idle") return "idle";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max - 1) + "…" : s;
|
||||
}
|
||||
@@ -1453,9 +1443,6 @@ function padRigRow(cells: string[], widths: number[]): string {
|
||||
return cells.map((cell, i) => i < widths.length ? fitRigCell(cell, widths[i]!) : cell).join(RIG_COLUMN_SEPARATOR);
|
||||
}
|
||||
|
||||
// A cell filled to its width, or cut to it, still ends before the next column begins.
|
||||
const NODE_COLUMN_SEPARATOR = " ";
|
||||
|
||||
export function padNodeRow(rig: string, pod: string, member: string, session: string, runtime: string, model: string, status: string, startup: string, oriented: string, lifecycle: string, terminal: string, work: string, activity: string, ctx: string, restore: string, error: string): string {
|
||||
return [
|
||||
fitCell(rig, 30),
|
||||
@@ -1472,24 +1459,22 @@ export function padNodeRow(rig: string, pod: string, member: string, session: st
|
||||
// Slice 15 — distinct TERMINAL + WORK columns.
|
||||
fitCell(terminal, 9),
|
||||
fitCell(work, 6),
|
||||
// Wide enough for a needs-input count, as in the compact table.
|
||||
fitCell(activity, 17),
|
||||
fitCell(activity, 12),
|
||||
fitCell(ctx, 6),
|
||||
fitCell(restore, 10),
|
||||
error,
|
||||
].join(NODE_COLUMN_SEPARATOR);
|
||||
].join("");
|
||||
}
|
||||
|
||||
export function padCompactNodeRow(rig: string, session: string, lifecycle: string, activity: string, work: string, reason: string, attention = "?"): string {
|
||||
export function padCompactNodeRow(rig: string, session: string, lifecycle: string, activity: string, work: string, reason: string): string {
|
||||
return [
|
||||
fitCell(rig, 22),
|
||||
fitCell(session, 38),
|
||||
fitCell(lifecycle, 11),
|
||||
fitCell(activity, 17),
|
||||
fitCell(activity, 14),
|
||||
fitCell(work, 6),
|
||||
fitCell(attention, 4),
|
||||
reason,
|
||||
].join(NODE_COLUMN_SEPARATOR);
|
||||
].join("");
|
||||
}
|
||||
|
||||
// Slice 15 — render the terminal-active primitive honestly.
|
||||
|
||||
Loaded 100 of 1192 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user