Compare commits

..
Author SHA1 Message Date
dev60-review 90a01c499a test(cli): a neutral fixture rig name in the never-started set-runtime cell 2026-11-02 05:28:22 +00:00
dev60-review 3a783b9c3d fix(daemon): set-runtime changes a materialized seat's runtime before its first start
A seat from rig import --materialize-only that has never started has no conversation, occupant or native history for
a switch to carry, and its first start already builds the seat from its saved node row. So set-runtime now changes
what it starts as and switches nothing: the node's runtime, with the source runtime's settings cleared as a switch
clears them (or set from the given flags), the account home, the native permission choice, the member in its launch
recipe that a restore's first start reads, and a snapshot. There's no successor or switch id, and the CLI says so.

A seat with history keeps today's refusal. A never-started seat that wasn't materialized takes its runtime from its
rig spec at first start, so its refusal now says to declare the other runtime there and create the seat again.
2026-11-02 05:19:18 +00:00
dev60-review 9bd5b957e1 test: a never-started seat's runtime, as the person sees it
Red at main:
- a materialized seat saved as Claude Code, never started, can't move to Codex: set-runtime refuses with "it has no
  bound tmux pane";
- a never-started seat that wasn't materialized gets that refusal with no word on what to do;
- the CLI has no output for a change that switched nothing.

A seat with history keeps today's refusal; that cell passes on main as the control.
2026-11-02 05:19:18 +00:00
391 changed files with 1512 additions and 16508 deletions

No files matched your search

+24 -21
View File
@@ -10,14 +10,12 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
## [0.7.8]
Published 2026-11-05 as `@openrig/[email protected]`, built from the commit tagged
`v0.7.8` (`12df562c`).
**Not yet published.**
OpenRig 0.7.8 lets a person answer a runner seat's permission requests, with a
conformance check and an example runner to build against. It includes 0.7.7's
fixes plus repairs across seats, tmux, Slack and the TUI. It also includes a
reference for running a Claude worker in an NVIDIA OpenShell sandbox, but that
worker isn't usable in this release: see [the OpenShell reference](#the-openshell-reference-isnt-usable-in-078).
conformance check and an example runner to build against. It adds a maintained
way to run a Claude worker in an NVIDIA OpenShell sandbox, and includes 0.7.7's
fixes plus repairs across seats, tmux, Slack and the TUI.
### Before you upgrade
@@ -63,15 +61,20 @@ worker isn't usable in this release: see [the OpenShell reference](#the-openshel
- **A seat activity grant** lets a runner outside the host's hook setup report
activity for one seat and launch, without the install-wide hook token. ([#2295](https://github.com/mvschwarz/openrig/pull/2295))
### The OpenShell reference isn't usable in 0.7.8
### Claude workers in OpenShell sandboxes
0.7.8 includes `rig environment` (`setup`, `create`, `attach`) and a reference
for binding a seat to an NVIDIA OpenShell sandbox (`daemon/assets/openshell/README.md`).
**In our release testing the worker never became usable:** after the sandbox was
created and the native process started, `rig environment connect` failed when its
check timed out after 5 seconds, so no team work ran. Don't rely on this path in
0.7.8.
([#2296](https://github.com/mvschwarz/openrig/pull/2296), [#2300](https://github.com/mvschwarz/openrig/pull/2300), [#2378](https://github.com/mvschwarz/openrig/pull/2378))
- **`rig environment setup` installs a maintained reference** that binds a seat
to one NVIDIA OpenShell sandbox (`rig environment create` or `attach`, then
`rig up`).
- **Context and results:** the seat's guidance, skills, plugins and startup
files go into the sandbox, and verified JSON results come back through
queue work.
- **Stop and delete:** stopping the agent, stopping compute and deleting the
sandbox are separate actions.
- **Restore:** a recreated sandbox resumes the same conversation.
The steps are in `daemon/assets/openshell/README.md`.
([#2296](https://github.com/mvschwarz/openrig/pull/2296), [#2300](https://github.com/mvschwarz/openrig/pull/2300))
### Seats, delivery and the TUI
@@ -147,13 +150,14 @@ check timed out after 5 seconds, so no team work ran. Don't rely on this path in
- **The dsh example keeps one hold per seat:** "if dsh asks twice in one turn,
settling one ask releases the hold while the other may still wait on a
person."
- **The worker isn't usable** (above). The reference also requires NVIDIA
OpenShell 0.1.6, a Linux image, and an API-backed provider using
`ANTHROPIC_API_KEY`; subscription or OAuth credentials aren't supported.
- **The OpenShell reference requires:**
- NVIDIA OpenShell 0.1.6;
- a Linux image;
- an API-backed provider using `ANTHROPIC_API_KEY`. Subscription or OAuth
credentials aren't supported.
"Provider registration and image construction can incur costs." The worker's
activity reads unknown, from pane sampling, never idle.
- **The OpenShell README example uses `operator@kernel`;** the actual kernel
operator is `operator-agent@kernel`.
- **"Never grant the guest access to the daemon's main API port (normally
7433)."** The guest-only listener is what confines a guest's reach. Its grant
only authorizes requests for its own seat and launch.
@@ -170,8 +174,7 @@ stand-in follows.
## [0.7.7]
Published 2026-11-02 as `@openrig/[email protected]`, built from the commit tagged
`v0.7.7` (`f3d8bac4`). 0.7.7 is a separate reliability patch for 0.7.6. Its three
**Not yet published.** 0.7.7 is a separate reliability patch for 0.7.6. Its three
fixes are also in 0.7.8.
OpenRig 0.7.7 is 0.7.6 with three reliability fixes for managed Claude seats: an
@@ -66,7 +66,7 @@ resolve → project → deliver pre-launch files → persist replay context →
## 2. Execution and projection types
The member's optional `claude_mcp: none` is normalized as `claudeMcp` and
persisted independently in `nodes.claude_mcp` (migration 142). The
persisted independently in `nodes.claude_mcp` (migration 141). The
expansion HTTP route preserves both `claude_mcp` and `claudeMcp`, including
invalid values for the canonical validator to reject. An authored relaunch
updates the seat choice after launch eligibility and startup-source resolution;
@@ -461,13 +461,6 @@ in the current update window. Current-update coverage is independent of the
receipt window, so an unavailable current reader cannot retain closed delivery
history beyond a successful history read. Both reads leave queue state intact.
Clients that need only current FYIs can use `GET /api/queue/open-human-updates`.
It returns `{items, limit, truncated}` with open human-addressed updates, ordered
critical then urgent then routine, without requiring a delivery receipt. `limit`
defaults to 20 and accepts integers from 1 to 100. Optional `view` follows the
same person perspective as `/api/attention`; a perspective read scans at most
1000 rows and signals `truncated` when the window may omit matching items.
- `/api/stream` (`server.ts:783`) — `POST /emit` (`routes/stream.ts:56`),
`GET /list` (`:91`, including `sourceSession`, `hintDestination`, `hintTag`,
`since`, `until` and `direction` filters), `GET /watch` + `/sse` SSE
+4 -32
View File
@@ -25,7 +25,7 @@ below `rig` (including groups and the hidden `restore apply` command), and
**1,304 explicitly registered option objects**, including the root version
option. Aliases do not add command objects; short/long spellings of one
option do not add option objects. Commander-generated help is additional.
These are counts for that source snapshot, not a claim about a deployed release. The provider-switch and set-runtime rows include the later account-home options; those additions are not included in the snapshot counts. The `environment` section and the `host edit`, `host remove` and `terminal attach` rows were added later from source at `522d635c`, and aren't in the snapshot counts either. With `environment`, main registers 91 top-level commands.
These are counts for that source snapshot, not a claim about a deployed release. The provider-switch and set-runtime rows include the later account-home options; those additions are not included in the snapshot counts.
## Reading this reference
@@ -350,19 +350,10 @@ the sections that were not read are `null`.
| `rig host add` | — | `--id <id>` **required**<br>`--transport <transport>` **required**<br>`--target <target>`<br>`--user <user>`<br>`--url <url>`<br>`--bearer-env <name>`<br>`--bearer-file <path>`<br>`--notes <text>`<br>`--json` |
| `rig host select <id>` | — | `--json` |
| `rig host rename <name>` | — | `--json` |
| `rig host edit <id>` | — | `--target <target>`<br>`--user <user>`<br>`--url <url>`<br>`--notes <text>`<br>`--json` |
| `rig host remove <id>` | — | `--json` |
| `rig host pair <url>` | — | `--id <id>`<br>`--timeout <seconds>`<br>`--human <address>`<br>`--json` |
| `rig host list` | `ls` | `--json` |
| `rig host doctor <id>` | — | `--posture <profile>`<br>`--public-addr <ip>`<br>`--json` |
`rig host pair` needs the target daemon to run with `OPENRIG_AUTH_BEARER_TOKEN` set,
even when it is bound to a tailnet address and so starts without one: pairing hands
over that token. Without it, the target's `POST /api/hosts/pair-request` answers 409
`pair_target_no_bearer`; it answers 409 `pair_human_required` when no single
registered person can approve (none, or several with no default; name one with
`--human`) (`packages/daemon/src/routes/hosts.ts:167-186`).
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
@@ -386,7 +377,7 @@ do not establish connectivity to a live peer.
|---|---|---|
| `rig ps` | — | `--json`<br>`--no-cleanup`<br>`--resources`<br>`--nodes`<br>`--full`<br>`--verbose`<br>`--limit <n>`<br>`--fields <list>`<br>`--summary`<br>`--filter <key=value>`<br>`--active`<br>`--running`<br>`-A, --all-rigs`<br>`--rig <name>`<br>`--session <name>`<br>`--include-archived`<br>`--host <id>`<br>`--all-hosts`<br>`--hosts <ids>` |
- `--nodes --json` — each seat carries the daemon's served `needsAttention` flag; the CLI doesn't re-derive it, and it is absent when an older daemon doesn't serve it. `arbitratedNeedsInput` and `needsInputEvidence` (`{ rung, observedAt }` or null) come with `--full`, or by name through `--fields`. `startedWithoutRequired` lists the required startup files a handover's successor started without, and the compact read keeps it.
- `--nodes --json` — each seat carries the daemon's served `needsAttention` flag; the CLI doesn't re-derive it, and it is absent when an older daemon doesn't serve it. `arbitratedNeedsInput` and `needsInputEvidence` (`{ rung, observedAt }` or null) come with `--full`, or by name through `--fields`.
### roster
@@ -470,7 +461,7 @@ do not establish connectivity to a live peer.
| `rig queue create` | — | `--source <session>`<br>`--destination <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--body-context <ref>`<br>`--mission <id>`<br>`--slice <id>`<br>`--gate <role>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--expires-at <iso>`<br>`--id <qitemId>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--human-intent <intent>`<br>`--alert`<br>`--human-detail-file <path>`<br>`--reply-to <qitemId>`<br>`--human-questions-file <path>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--verify`<br>`--full`<br>`--json` |
| `rig queue claim <qitemId>` | — | `--destination <session>`<br>`--full`<br>`--json` |
| `rig queue unclaim <qitemId>` | — | `--destination <session>`<br>`--reason <text>`<br>`--full`<br>`--json` |
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--closure-required-at <iso>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--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` |
@@ -550,7 +541,6 @@ do not establish connectivity to a live peer.
| `rig terminal open <view>` | — | `--provider <name>`<br>`--session <name>`<br>`--window`<br>`--expected-plan <id>`<br>`--json` |
| `rig terminal views` | — | `--json` |
| `rig terminal status` | — | `--provider <name>`<br>`--session <name>`<br>`--json` |
| `rig terminal attach <session>` | — | `--read-only` |
See [named terminal sessions](../reference/terminal-sessions.md) to select a
Herdr session per request, including session-bound previews and window reuse.
@@ -638,7 +628,7 @@ Only Claude and Codex tenures can be woken.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig specs` | — | — |
| `rig specs ls` | — | `--kind <kind>`<br>`--include-builtins`<br>`--json` |
| `rig specs ls` | — | `--kind <kind>`<br>`--json` |
| `rig specs show <name-or-id>` | — | `--kind <kind>`<br>`--json` |
| `rig specs preview <name-or-id>` | — | `--kind <kind>`<br>`--json` |
| `rig specs add <path>` | — | `--json` |
@@ -707,24 +697,6 @@ Only Claude and Codex tenures can be woken.
| `rig community-harness` | — | — |
| `rig community-harness list` | — | `--json` |
### environment
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig environment` | — | — |
| `rig environment setup` | — | `--directory <path>` **required**<br>`--binding <id>`<br>`--seat <address>`<br>`--rig <name>`<br>`--mcp-url <url>`<br>`--guest-base-url <origin>`<br>`--artifact-root <path>`<br>`--json` |
| `rig environment create <binding>` | — | `--seat <address>` **required**<br>`--node <id>`<br>`--gateway <ref>` **required**<br>`--workspace <ref>` **required**<br>`--name <name>` **required**<br>`--image <digest>` **required**<br>`--provider <ref>` **required**<br>`--policy <path>` **required**<br>`--guest-base-url <origin>` **required**<br>`--operation <id>`<br>`--json` |
| `rig environment attach <binding>` | — | `--seat <address>` **required**<br>`--node <id>`<br>`--gateway <ref>` **required**<br>`--workspace <ref>` **required**<br>`--name <name>` **required**<br>`--image <digest>` **required**<br>`--provider <ref>` **required**<br>`--policy <path>` **required**<br>`--guest-base-url <origin>` **required**<br>`--operation <id>`<br>`--json`<br>`--resource-id <id>` **required**<br>`--fingerprint <value>` |
| `rig environment inspect <binding>` | — | `--live`<br>`--json` |
| `rig environment connect <binding>` | — | `--generation <id>` **required** |
| `rig environment export <binding>` | — | `--request <path>` **required**<br>`--json` |
| `rig environment checkpoint <binding>` | — | `--generation <id>` **required**<br>`--operation <id>` **required**<br>`--json` |
| `rig environment recreate <binding>` | — | `--request <path>` **required**<br>`--json` |
| `rig environment completion <binding>` | — | `--request <path>` **required**<br>`--json` |
| `rig environment effect <binding>` | — | `--effect <name>` **required**<br>`--generation <id>`<br>`--resource-id <id>`<br>`--operation <id>` **required**<br>`--engine-socket <path>`<br>`--json` |
It drives the maintained OpenShell reference; see the [OpenShell example](../reference/community-harness-examples/openshell/README.md).
### fork
| Invocation | Aliases | Declared options |
+1 -1
View File
@@ -540,7 +540,7 @@ Before you start, two honest constraints:
| registry | `host` | add/list/select/rename isolated entries that resolve to fixture endpoints. Pairing needs two container daemons. |
| coordination | `parked` | An owed item with a stopped worker is reported. A held worker with a live wake is distinguished. |
| coordination | `stream` | emit/list/show/archive an exact item. A restart and a bounded watch keep the order with no duplicates. |
| coordination | `queue` | (partial) handoff → successor owned by `--to` and source asserted closed (`handed-off`), block → parked blocked, resolve → unpark to in-progress (owner nudge suppressed), unclaim → pending with destination kept — each with exact items and destinations asserted via `expect queue` (`rig queue list --json --full --all`) in `queue-transitions-reach-exact-states.yaml`; baton restart coverage stays in `queue-baton-survives-restart.yaml`. |
| coordination | `queue` | (partial) Next: handoff, block, resolve and unclaim, each with exact items and destinations. |
| coordination | `project` | Acquire a lease, classify one item twice, read one projection. The wrong holder cannot act. |
| coordination | `view` | Register a view over known items. list/show return the exact filtered result after a mutation. |
| coordination | `watchdog` | Register one short wake, see one response, stop it, prove no further deliveries. |
+3 -115
View File
@@ -214,7 +214,7 @@ After the files, OpenRig sends a session-identity message (rig, pod, member, log
| Delivery Hint | When | How | Use For |
|---------------|------|-----|---------|
| `auto` | Depends on the file | Resolved per file: a `SKILL.md` (or content starting `# SKILL`) is `skill_install`, any other `.md` is `guidance_merge`, and any other file is `send_text` | Default — let OpenRig decide |
| `guidance_merge` | Before harness boot | Merged as a managed block into the repository `AGENTS.md` (Codex, OMP), a fresh Pi seat's own agent-directory `AGENTS.md` (see [Runtime Differences](#runtime-differences)), or `CLAUDE.md` (Claude; `CLAUDE.local.md` with the rig's `managed_blocks: { claude-code: CLAUDE.local.md }`). A fresh launch of Codex 0.164.0 or later can carry it as launch developer instructions instead (see [Codex guidance at launch](#codex-guidance-at-launch)) | Role guidance, culture, project context |
| `guidance_merge` | Before harness boot | Merged as a managed block into `AGENTS.md` (Codex, Pi) or `CLAUDE.md` (Claude; `CLAUDE.local.md` with the rig's `managed_blocks: { claude-code: CLAUDE.local.md }`) | Role guidance, culture, project context |
| `skill_install` | Before harness boot | Copied to runtime's skill directory | Skills |
| `send_text` | After harness is ready | Sent as text to agent's terminal via tmux | Boot-time grounding, identity hints, instructions to read skills |
@@ -367,15 +367,7 @@ Each startup file and action specifies when it applies:
- `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
- Default: `[fresh_start, restore]` (both)
**An exact resume delivers neither.** When a restore reopens the seat's saved conversation (`rig up <rig> --existing` with a resume token), OpenRig types no startup actions and delivers no startup files, `restore` ones included: the conversation already has them, and typing them again would add turns to it. The one exception: the guidance file's managed blocks are brought up to date before the harness starts. A missing block is put back, and a stale one is refreshed (see below).
**A seat handover does the same put-back.** Before a fresh, fork or rebuild successor starts, the handover puts the seat's missing managed guidance blocks back in its guidance file from the saved startup context. That's the rig's Claude file (`CLAUDE.md`, or `CLAUDE.local.md` when the rig uses it) for Claude, and `AGENTS.md` for Codex, Pi and omp. So a seat rebuilt into a new working directory keeps its guidance.
**A block you edited by hand is never overwritten.** OpenRig records what it last wrote in each managed block, so a launch, an exact resume, a handover or a runtime switch can refresh a block whose source file changed since. The record follows the file itself, so two links to one file share it. A block you've edited since OpenRig wrote it is left as you wrote it. The start's result names it (`guidance_block_edited`), and the seat is told in one line once it's ready, or in a handover's packet. A block OpenRig has no record of writing (any block written before these records were kept) that differs from its source works like this:
- a launch replaces it, as launches always did, and names it (`guidance_block_refreshed_unrecorded`);
- an exact resume or a handover leaves it, and names it as possibly stale (`guidance_block_may_be_stale`).
An exact resume doesn't type anything into the resumed conversation. So there, the restore result names the block (`guidanceNotes`), but the seat isn't told.
**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.
@@ -536,58 +528,6 @@ This separates provider files, not OS users or accounts. Project-local resources
still use the selected project directory; shared project and system settings
remain shared.
### Codex guidance at launch
On a fresh launch of Codex 0.164.0 or later, OpenRig passes a seat's managed guidance (OpenRig's start file and the
rig's and project's context, culture and SOP files) with the launch, as `-c developer_instructions=…`, instead of
merging it into the repository `AGENTS.md`. Neither the tracked `AGENTS.md` nor the Codex home is written for it, and
both still load as usual. The text opens: "OpenRig seat guidance for this session follows. Where it conflicts with
AGENTS.md guidance in the repository or the Codex home, follow AGENTS.md."
A launch value replaces a configured one, so this happens only when no Codex config layer OpenRig reads sets
`developer_instructions`. OpenRig reads:
- `/etc/codex/config.toml` and `/etc/codex/managed_config.toml`;
- the Codex home's `config.toml`, profiles included, and the selected profile's `<profile>.config.toml`;
- each `.codex/config.toml` from the seat's working directory up.
The Codex home it reads is the seat's own (a `CODEX_HOME` in its `env:`, or a recorded account switch), else the
daemon's `CODEX_HOME`, else `~/.codex`.
The guidance still goes to the repository `AGENTS.md`, as before, when:
- a layer OpenRig reads sets `developer_instructions`, or can't be read. A launch warning names the file;
- the launch resumes or forks a conversation. Developer instructions on a resume haven't been tested, and an exact
resume puts the guidance back in the repository `AGENTS.md`;
- the seat is restored, even when the restore starts a new conversation. A restore keeps the guidance file its launch
recorded, which for Codex is the repository `AGENTS.md`;
- a handover successor starts (fresh, fork or rebuild). Its launch carries no guidance of its own, so the handover
first puts the seat's missing blocks back in the repository `AGENTS.md` from its saved startup context;
- Codex is older than 0.164.0, or its version is unknown.
A seat's role still arrives as startup text, and the seat-skills line, after the person's yes, still goes to
`AGENTS.md`.
A launch that carries developer instructions writes no new blocks to the repository `AGENTS.md`. But blocks an earlier
handover or resume left there are refreshed from their sources, by the same rule as every other managed block. That
matters because the launch text asks Codex to follow `AGENTS.md` where the two differ.
**If your shell sets `CODEX_HOME`.** When neither the seat nor the daemon declares a `CODEX_HOME`, a seat's pane keeps
its own, and OpenRig reads `~/.codex`. A `CODEX_HOME` that only your shell startup sets (in `~/.zshrc`, for example),
with the daemon started without it, isn't seen. A `developer_instructions` set in that home is then replaced by the
launch value for the session. To have it checked, declare that same absolute path as `CODEX_HOME` in the member's or
rig's `env:` (see [Launch environment](rig-spec.md#launch-environment), "A seat's own account"). OpenRig then reads
that home at the seat's next fresh launch, and a `developer_instructions` set there keeps the repository `AGENTS.md`.
The seat keeps the same home and login.
**What it doesn't cover.** OpenRig doesn't read Codex's packaged defaults, or MDM- or enterprise-managed settings. A
`developer_instructions` set there may be replaced for the session. Legacy MDM-managed config ranks above launch flags,
so it can override OpenRig's guidance, and the seat then gets none of it.
**Not established.** The order Codex assembles developer instructions and `AGENTS.md` in, and how it settles every
conflict, aren't established; the opening sentence asks it to follow `AGENTS.md`. The native behaviour was observed on
Codex 0.164.0 alone, on fresh launches without tools.
### Runtime Differences
**Claude Code:**
@@ -618,10 +558,7 @@ Codex 0.164.0 alone, on fresh launches without tools.
- Can self-install dependencies from instructions but timer/recurring behavior is not reliably available
**Pi and OMP:**
- Read `AGENTS.md` in the working directory, added to Pi's own system prompt. Pi also reads the one in its agent
directory: on a fresh launch, a Pi seat's guidance is merged there, under OpenRig's state, not into the
repository's. When that file or its directory is a link, the repository `AGENTS.md` is used instead. A restore keeps
the file the seat launched with. OMP keeps its guidance in the repository `AGENTS.md`
- Read `AGENTS.md` in the working directory; guidance is merged there, added to Pi's own system prompt
- Selected skills are projected into the seat's own agent directory under OpenRig's state, not into a shared
workspace folder
@@ -735,29 +672,6 @@ When authoring startup content, note which instructions are runtime-specific. Fo
---
## Files OpenRig writes into a project
These are the files OpenRig writes into a seat's working directory, and what `rig down` does with each. Paths are
relative to that directory.
| Path | When OpenRig writes it | What `rig down` does |
| --- | --- | --- |
| Managed blocks in `CLAUDE.md` (or the rig's `managed_blocks` file, such as `CLAUDE.local.md`) or `AGENTS.md` | Each launch merges the seat's guidance blocks. A fresh Codex launch that takes developer instructions puts them in its launch text instead: in `AGENTS.md` it adds only the seat-skills line, after your yes, and refreshes blocks already there. A fresh Pi seat writes its own agent-directory `AGENTS.md` outside the project, except when that folder or file is a link: then its guidance stays in the repository `AGENTS.md`. | Removes OpenRig's blocks, current or legacy, and keeps everything else. When nothing else is left, it deletes the file if the file began with an OpenRig block, or held only blocks in an older format. A block-only file isn't proof that OpenRig created it. The seat-skills line is kept if you edited it. |
| `.openrig/context-collector.cjs` | Claude seats, at launch. | Removes it while its bytes are still the shipped collector's; otherwise keeps it and says so. |
| `.claude/settings.local.json` | Claude seats, at launch: OpenRig's status line and its activity hooks are merged in, separately. Your own status line is never replaced, but the activity hooks are still merged beside it. A file that doesn't parse gets neither. | Removes OpenRig's status line and hooks and keeps the file, even if `{}` is all that's left. |
| `.claude/settings.local.json` fragments and `.mcp.json` | When a profile or spec declares `claude_settings_fragment` or `claude_mcp_fragment`. | Leaves the merged content in place. |
| `.claude/agents/`, `.claude/plugins/<id>/`, `.claude/extensions/<id>/`; `.agents/<id>.yaml`, `.codex/plugins/<id>/`, `.agents/extensions/<id>/` | When the seat's projection includes subagents, plugins or runtime resources (Claude, then Codex). | Leaves them in place. |
| `.claude/skills/<id>/`, `.agents/skills/<id>/` | Only for a seat without seat storage. New Claude and Codex seats keep their skills under `$OPENRIG_HOME/state/seats/`, outside the project. | Leaves them in place. |
| The managed compaction preparation folder described in [Claude compaction preparation directory](#claude-compaction-preparation-directory), with its `.gitignore` of `*` and each attempt's `RESTORE-MAP.md` | Managed Claude compaction, unless a preparation root is configured elsewhere. | Leaves it in place. |
| `.rigged-checkpoint.md` | A restore that didn't resume a seat's native conversation (by choice or not) and had a checkpoint to hand it. | Leaves it in place. |
`rig down` leaves a file alone while another seat still uses it: a running seat of the same rig, or any seat of
another rig that isn't archived. That is decided file by file. A guidance file is kept while another seat's
guidance targets the same file. The Claude settings and collector are kept while another Claude seat works in that
directory. So another rig's Codex seat keeps `AGENTS.md` without keeping `CLAUDE.md` or the Claude settings. It
removes `.openrig/` and `.claude/` only when they are empty.
`rig down --delete` does nothing more to project files. OpenRig doesn't edit your project's own `.gitignore`.
## Patterns and Anti-Patterns
### Good Patterns
@@ -827,32 +741,6 @@ rig uses it, so it stays until then. To remove it sooner, by hand:
3. Keep a copy of the file, remove only those lines, and compare the result with
the copy: nothing else in the file should differ.
**The kernel's own folder.** A newly created default kernel works in
`<workspace.root>/kernel`, beside your projects (`<workspace.root>/projects` by
default) rather than above them. Claude reads `CLAUDE.md` from a seat's folder
and from every folder above it, so a kernel working in the workspace root put its
guidance into every Claude project seat below it. If that folder can't be made,
the new kernel works in the workspace root, as before.
A kernel that already exists keeps its folders, usually `workspace.root`.
Upgrading moves no seat, changes no restored conversation and removes no file.
To move an existing kernel yourself, if you want to:
1. Save the new folder for each kernel seat with `rig seat set-cwd <seat> --cwd
<workspace.root>/kernel --reason <text>`, using the absolute path. It creates
the folder ([details](#change-an-existing-seats-working-directory)).
2. Choose when each seat adopts it. A seat keeps its current folder until it
starts again, and the new start doesn't continue its current native
conversation: `rig seat launch <seat> --fresh --stop --reason <text>` starts a
blank one, and `rig handover <seat> --source rebuild --reason <text>` carries
durable context into a new one. Nothing does this for you.
3. Tidy the old `<workspace.root>/CLAUDE.md` (or `AGENTS.md`) by hand, once no
running rig's seat still works in that folder: a seat there still reads those
blocks, and `rig down` checks the same before it removes them. Remove only
OpenRig's managed blocks, each from its `<!-- BEGIN OpenRig MANAGED BLOCK: … -->`
line through its `END` line, as in the steps above, and keep everything else,
including your own text. Don't move or delete the whole file.
**Separate project context from role**
Don't put project documentation inside the role guidance. The role is about the agent's function; project context is about what the agent is working on. Use rig-level or pod-level startup files for project context.
+2 -23
View File
@@ -23,10 +23,7 @@ Only the activity axis is served today. The session and resumability values are
`activity-taxonomy.ts` but no surface reads them: `rig ps --nodes --full` shows the raw session
STATUS, both node views show a LIFECYCLE of `running`, `detached`, `recoverable` or `attention_required` (`run`, `det`, `rec`,
`att`), and node inventory serves `occupantLifecycle` (`active`, `retiring`, `retired`,
`context_walled`, `compacted`, `crashed`, `unknown`). Node inventory's `sessionStatus` is the
recorded session status, with two observed corrections: a running session whose pane is verified
to have exited (identity reason `pane_exited`) reads `exited`, with `startupStatus:
"attention_required"`, and a running session confirmed missing reads `detached`.
`context_walled`, `compacted`, `crashed`, `unknown`).
The axes never blend. Reachability is strict; revival optimism is a different field
(omnigent's strict-liveness split). `unknown` is a first-class honest value — the oracle
@@ -62,21 +59,6 @@ the existing live-hook fallback in effect; lifecycle, startup, held and error si
can independently raise attention. A false attention flag means no known trigger, not
proof that an unobserved input box is clear.
A handover whose successor started without a required file also raises attention. That's
a projected guidance file or a guidance startup file marked `required` still missing from
the guidance file, or, for a fresh or rebuild successor, a required `send_text` startup
file that's missing. The seat serves `startedWithoutRequired` with each file's path, the
table's reason column names each file, and the handover's own output says it's degraded.
The mark belongs to that successor's session, so the seat's next launch or handover
clears it. Time alone never does. Repairing the file in place doesn't clear it either,
because no route delivers guidance into a running conversation.
A fresh or rebuild successor's first turn opens with the seat's `send_text` startup files,
the ones a fresh start delivers, as absolute paths with the role first. A pointer asks the
successor to read the file; it isn't the pasted text a start sends. A fork successor
carries its parent's conversation and gets no list. `send_text` startup actions, such as
an `after_ready` preload, aren't run for a successor.
The compact human table's ATTN column renders `yes`, `no`, or `?` when the flag is
unavailable. Compact JSON retains `needsAttention`; use `--full` or
`--fields needsAttention,arbitratedNeedsInput,needsInputEvidence` with `--json` for the
@@ -134,10 +116,7 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
PER-HARNESS when that harness's hook rung earns trust through a measured agreement
window in production — never by fixture pass alone (symmetric admission: a fixture
pass admits a rung to TRIAL, where it is measured but not consulted; production agreement
promotes it). For Codex, this fallback also accepts a fresh working status adjacent to
the composer, or an advancing counter across two fresh captures when output separates
them. This positive evidence expires after five seconds; unrecognized, frozen, old or
pre-occupant frames add nothing. It uses the existing pane capture. Undeclared and partial-coverage rungs are identity-only, which is also where
promotes it). Undeclared and partial-coverage rungs are identity-only, which is also where
a degraded rung goes, with one declared exception: for a pi or omp seat or a community
harness, sampling is positive-only: a changing pane reads working, and a quiet one never reads idle on its
own, since the pane can be quiet mid-turn.
+6 -6
View File
@@ -1,4 +1,4 @@
# Integration author contracts — DRAFT, first published with 0.7.6
# Integration author contracts — DRAFT, published with 0.7.6
OpenRig composes agents into a team. An integration author supplies the pieces that let a new harness participate, or let an existing harness run in a new environment. This guide divides that work into two contracts:
@@ -9,16 +9,16 @@ These are draft author responsibilities. Some use implemented OpenRig interfaces
## Release and support status
This draft was published with **OpenRig 0.7.6** (tag [`v0.7.6`](https://github.com/mvschwarz/openrig/tree/v0.7.6), source `c00c6321`). Its implemented-interface baseline is commit [`b6bd4633`](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63). That base includes the runner declaration and `passes_model` changes. The published 0.7.6 adds release repairs and documentation on top of that base. **OpenRig 0.7.8** adds seat activity grants, OpenRig's side of the answered permission path and the maintained OpenShell reference driver (`rig environment`); the rows below that say 0.7.8 describe it. References below use the immutable base instead of a moving branch, except the community preset reference, which is the in-tree copy.
This draft was published with **OpenRig 0.7.6** (tag [`v0.7.6`](https://github.com/mvschwarz/openrig/tree/v0.7.6), source `c00c6321`). Its implemented-interface baseline is commit [`b6bd4633`](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63). That base includes the runner declaration and `passes_model` changes. The published 0.7.6 adds release repairs and documentation on top of that base. References below use the immutable base instead of a moving branch.
| Area | Status | What the status means |
| Area | Status at this baseline | What the status means |
|---|---|---|
| Community launcher presets, RigSpec members and AgentSpec role/startup declarations | Implemented interfaces | They compose configuration. A launcher must implement its declared effects; successful parsing does not prove delivery or native behavior. |
| `runner: true` with `launch_result: true` | Released in 0.7.6 | The declaration is retained at launch, and Core uses the runner identity/activity path. It does not supply a runner executable. |
| `passes_model: true` | Released in 0.7.6 | Passes the declared model through `OPENRIG_MODEL`. It does not observe or verify the effective native model. Cursor and Hermes examples leave it off pending native qualification. |
| The dsh/ACP input, cancellation and answered permission recipe (a person answers, and the default is permit) | OpenRig's side released in 0.7.8; runner side not qualified | OpenRig asks a person for a runner's permission requests and returns the answer. No end-to-end dsh integration is established, and the native verdict is pending. Native reopen capabilities and the tools-off case remain implementer questions. |
| The dsh/ACP input, cancellation and answered permission recipe (a person answers, and the default is permit) | Proposed author implementation | No end-to-end dsh integration is established. Native reopen capabilities and the tools-off case remain implementer questions. |
| Exact native resume via an author launcher | Existing interface; integration-specific evidence | A requested conversation must reopen exactly or fail honestly. One provider/harness result does not establish portability to another. |
| Environment preparation, projection, artifact export and owned-resource cleanup | OpenShell reference driver released in 0.7.8; generic seam proposed | The platform chapter makes the responsibilities reusable. Core ships one maintained reference driver, OpenShell 0.1.6, as `rig environment`. It doesn't provide a generic platform driver or `DeploySpec` loader. |
| Environment preparation, projection, artifact export and owned-resource cleanup | Measured in a private wrapper; generic seam proposed | The platform chapter makes the responsibilities reusable. Core does not provide a generic platform driver or `DeploySpec` loader at this baseline. |
| `HarnessSpec`, `DeploySpec`, generic lifecycle fields and wake-on-demand declarations | Proposed vocabulary or unsupported configuration | They are not new accepted YAML keys. Use existing composition and an explicit author implementation. |
| Cross-host native resume, provider-wide cancellation or permission support | Unresolved unless qualified by an integration | Unsupported and untested are distinct from observed failure. Publish the actual boundary and version. |
@@ -50,7 +50,7 @@ The queue records who owns work. It does not prove that an old provider process
## Start from an existing example
For a terminal harness, begin with the [community preset reference](community-harness-presets.md) and the pinned [Cursor](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/cursor) or [Hermes](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/hermes) directory. Their presence is source guidance, not an effective-model or complete integration guarantee.
For a terminal harness, begin with the pinned [community preset reference](https://github.com/mvschwarz/openrig/blob/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-presets.md) and the [Cursor](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/cursor) or [Hermes](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/hermes) directory. Their presence is source guidance, not an effective-model or complete integration guarantee.
For a structured runner, the [runner chapter](harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch) supplies a minimal preset, launch record and activity message. The executable named by the preset must still be implemented and installed by its author.
+1 -12
View File
@@ -8,15 +8,4 @@ Read `GET /api/seat/stop-precondition/:seatRef` immediately before an operator-c
Pass `expected` unchanged in the body of the existing `POST /api/seat/stop/:seatRef`, alongside `reason` and optional `operator`. The contract marker lets clients detect support. `expected` is optional; a request without it retains the existing stop behavior. A malformed explicit value returns HTTP 400. A null session ID can describe an empty seat, but stopping it returns `no_session`.
Checked stop requires the delivery guard. It refuses a changed node, latest session, binding or occupant before a terminal effect with HTTP 409 `selection_changed`. A kill failure or a failure to save stop state after a kill reports `stop_uncertain`; inspect the seat before retrying. Success retains the usual `seat`, `sessionName` and `sessionId` fields.
A `selection_changed` or `stop_uncertain` response from a checked stop says how far the stop got in `stopPhase`. A refusal before the stop starts carries neither `stopPhase` nor `partialEffect`, and nothing was sent. The stop detaches the seat's clients, checks the selection again, then kills the session:
| `stopPhase` | What was sent to the seat |
| --- | --- |
| `none` | Nothing. |
| `detach_attempted` | The client detach, which may not have finished. No kill. |
| `detached` | The client detach. No kill. |
| `kill_attempted` | The kill. |
`partialEffect` is `true` whenever `stopPhase` isn't `none`, so on its own it doesn't say whether the kill went out. `selection_changed` with `stopPhase: "kill_attempted"` is a change seen after the kill, when the stop state is saved: the session was killed, so re-read the seat rather than offering the stop again.
Checked stop requires the delivery guard. It refuses a changed node, latest session, binding or occupant before a terminal effect with HTTP 409 `selection_changed`. If the selection changes after client detach, the response reports `partialEffect: true` and no kill is sent. A kill failure or a failure to save stop state after a kill reports `stop_uncertain`; inspect the seat before retrying. Success retains the usual `seat`, `sessionName` and `sessionId` fields.
@@ -5,9 +5,8 @@ It runs in the seat's pane, hosts `dsh --profile acp` over ACP on stdio, and doe
section:
- **identity:** the launch record, written once the conversation is open;
- **one prompt per block:** each submitted block becomes one `session/prompt`, using bracketed paste in a TTY;
- **turn boundaries:** reported as activity hooks, with a working beat every 10 s through a long, quiet turn;
- **cancel:** `/abort` or Ctrl-C sends `session/cancel`, and a turn reads cancelled only when dsh says so. Type `/abort`
in the seat's pane: one sent with `rig send` arrives inside a From/To envelope, so it's an ordinary prompt;
- **turn boundaries:** reported as activity hooks;
- **cancel:** `/abort` or Ctrl-C sends `session/cancel`, and a turn reads cancelled only when dsh says so;
- **permissions:** each request goes to a person through OpenRig;
- **resume:** it reopens the exact conversation, or says honestly that it couldn't.
@@ -22,19 +21,13 @@ own. [bpamiri/dsh-seat](https://github.com/bpamiri/dsh-seat) is a fuller dsh run
`$OPENRIG_HOME/community-harnesses/`. Then declare a member with `runtime: dsh`.
3. **Give the seat credentials.** Each seat gets its own dsh home, at `$OPENRIG_HOME/state/dsh/<node>/home`, because dsh
rewrites its profile there on every start. Put a `.credentials.yaml` there, or put a provider key in the seat's
`env:`. To use a home you prepared yourself, set `DSH_SEAT_HOME` in the member's `env:`; `DSH_SEAT_BIN` there names
another `dsh` binary. The runner doesn't take dsh's own `DSH_HOME` from its environment, because your shell's startup
files run after the seat's `env:` and could set it. A member's `env:` can't set `OPENRIG_*` names, which OpenRig
reserves, but the runner still reads `OPENRIG_DSH_HOME` and `OPENRIG_DSH` when something exports them to it, such as
a preset `command` wrapper.
`env:`. To use a home you prepared yourself, set `OPENRIG_DSH_HOME`. `OPENRIG_DSH` names another `dsh` binary.
The runner's own defaults for dsh:
- **Uploads:** the seat home gets a `cordis.patch.yml` that turns off dsh's session-log and plugin-list uploads to
DeepSeek, and the runner starts dsh with `DSH_TELEMETRY_DISABLED=1`.
- **Permission mode:** `DSH_PERMISSION_MODE` is `workspace-write` unless you set it.
- **The hook token:** OpenRig's activity token isn't passed to dsh, whose shell tool inherits the runner's environment.
- **Its version:** the launch record names dsh's version from `dsh --version`. If that fails or takes over 5 seconds, it
names the version dsh's ACP handshake reports, marked as such: dsh 0.2.1-rc.2 reports 0.0.1 there.
## Permissions
@@ -44,11 +37,6 @@ The runner submits each request to OpenRig, which asks one person:
That person answers one of the options dsh offered, and the runner gives that answer to the same live request, once.
**When the person answers in their own words** rather than with a choice, a yes or a no, OpenRig permits by default and
passes their words back as `said`. The runner gives dsh its option as usual, shows the words in the pane, and queues
them like any other message, so dsh gets them after the current turn and after any message already waiting. The words
are never the answer.
**When no one can be asked, the request is permitted** with dsh's own allow option: `allow_once` when dsh offers it,
else `allow_always`. That's OpenRig's default. The runner does the same when OpenRig can't answer: an older OpenRig
without this path, an OpenRig it can't reach, or a refusal other than `--deny`.
@@ -65,10 +53,8 @@ OpenRig says the request isn't the seat's current occupant's (for example, after
## Before you declare resume
The preset leaves `resume` out, so a restore stops and asks, and a fresh launch starts a new conversation.
`rig seat restart --keep-conversation` is for Codex seats only. The runner reopens a conversation by the method dsh
advertises: `session/resume`, or `session/load`. When dsh advertises neither, it tries `session/resume`. It never starts
a new conversation on a resume.
The preset leaves `resume` out. The runner reopens a conversation by the method dsh advertises: `session/resume`, or
`session/load`. When dsh advertises neither, it tries `session/resume`. It never starts a new conversation on a resume.
**Telling a missing conversation from one that won't reopen.** dsh `0.2.1-rc.2` answers `-32602` both for a
conversation it doesn't have and for one already active elsewhere, so the runner never reads that error as missing.
@@ -8,7 +8,7 @@ launch_result: true
first_input: true
limits:
- written for dsh 0.2.1-rc.2 (npm @deepseek-ai/dsh) over ACP protocol 1, with Node 22 or later, and checked against a stand-in agent only
- each seat gets its own dsh home under $OPENRIG_HOME/state/dsh/<node>/home, holding a patch that turns off dsh's session-log and plugin-list uploads to DeepSeek; give it credentials (.credentials.yaml there) or a provider key in the seat's env; set DSH_SEAT_HOME in the member's env to use a home you prepared, and DSH_SEAT_BIN for another dsh binary (a member's env can't set OPENRIG_* names, and the runner doesn't read dsh's own DSH_HOME)
- each seat gets its own dsh home under $OPENRIG_HOME/state/dsh/<node>/home, holding a patch that turns off dsh's session-log and plugin-list uploads to DeepSeek; give it credentials (.credentials.yaml there) or a provider key in the seat's env
- DSH_PERMISSION_MODE defaults to workspace-write; a permission request goes to the default person or the seat's override (rig seat set-permissions-to), is permitted with dsh's allow_once (else allow_always) when no one can be asked or OpenRig can't answer, and is refused and held as needs input only after --deny
- allow_always creates a standing grant in dsh, which OpenRig doesn't record or revoke; the runner prefers allow_once, and where dsh keeps the grant and for how long isn't checked
- resume isn't declared until a real dsh has reopened a conversation and recalled it; the runner reopens by the method dsh advertises and reports missing only on dsh's own not-found error, not when dsh's session list names the conversation; dsh answers -32602 for a missing conversation (and for one active elsewhere), so a missing dsh conversation reads unconfirmed
@@ -4,12 +4,11 @@
//
// It follows docs/reference/harness-runner-contract.md: it hosts `dsh --profile acp` over ACP on stdio, writes the
// launch record once the conversation is open, turns each submitted block into one prompt, reports turn boundaries as
// activity hooks with a working beat through a long, quiet turn, cancels on /abort or Ctrl-C, and sends permission
// requests to OpenRig: a person answers, or OpenRig permits when no one can be asked, and so does the runner when OpenRig
// can't answer (refusing and holding needs input only when the seat opted in to refusal). Copy it and maintain it as your
// own.
// activity hooks, cancels on /abort or Ctrl-C, and sends permission requests to OpenRig: a person answers, or OpenRig
// permits when no one can be asked, and so does the runner when OpenRig can't answer (refusing and holding needs input
// only when the seat opted in to refusal). Copy it and maintain it as your own.
import { execFileSync, spawn } from "node:child_process";
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
@@ -20,9 +19,6 @@ const RUNNER = "openrig-dsh-runner 1";
const NAME = process.env.OPENRIG_RUNTIME || "dsh"; // the preset's name: OpenRig passes it, and checks the record against it
const LIVENESS_SECONDS = 60;
const BEAT_MS = 30_000;
// The working beat (contract section 4): every 10 s, more often than half its 30 s lifetime.
const WORK_LIVENESS_SECONDS = 30;
const WORK_BEAT_MS = 10_000;
const ACTIVE_EVERY_MS = 15_000;
const POST_TIMEOUT_MS = 2_000;
const WAIT_SECONDS = 25;
@@ -207,12 +203,10 @@ const UPLOADS_OFF = [
"",
].join("\n");
/** Each seat gets its own writable dsh home (dsh rewrites its profile there on every start). DSH_SEAT_HOME, which a rig
* spec's env: can set, or OPENRIG_DSH_HOME names one you prepared yourself, which is used as it is. Not dsh's own
* DSH_HOME: a person's shell startup files can export it, and they run after the seat's env:. */
/** Each seat gets its own writable dsh home (dsh rewrites its profile there on every start). OPENRIG_DSH_HOME names
* one you prepared yourself, which is used as it is. */
function seatHome() {
const prepared = process.env.OPENRIG_DSH_HOME || process.env.DSH_SEAT_HOME;
if (prepared) return prepared;
if (process.env.OPENRIG_DSH_HOME) return process.env.OPENRIG_DSH_HOME;
const seat = process.env.OPENRIG_NODE_ID || process.env.OPENRIG_SESSION_NAME || "default";
const home = join(openrigHome(), "state", "dsh", seat, "home");
mkdirSync(home, { recursive: true, mode: 0o700 });
@@ -221,17 +215,6 @@ function seatHome() {
return home;
}
/** dsh's version as its CLI reports it, or null when it can't say. dsh 0.2.1-rc.2's ACP handshake reports 0.0.1.
* SIGKILL at the timeout, because a synchronous call waits for the child to exit, and a child can ignore SIGTERM. */
function cliVersion(bin, env) {
try {
const out = execFileSync(bin, ["--version"], { encoding: "utf8", env, timeout: 5_000, killSignal: "SIGKILL", stdio: ["ignore", "pipe", "ignore"] });
return out.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?/)?.[0] ?? null;
} catch {
return null;
}
}
function writeLaunchRecord(fields) {
const path = process.env.OPENRIG_LAUNCH_RESULT;
if (!path) return;
@@ -267,23 +250,16 @@ async function main() {
agentEnv.DSH_PERMISSION_MODE ??= "workspace-write";
if (agentEnv.DSH_TELEMETRY_DISABLED === undefined && agentEnv.DSH_TELEMETRY_MODE === undefined) agentEnv.DSH_TELEMETRY_DISABLED = "1";
// DSH_SEAT_BIN, which a rig spec's env: can set, or OPENRIG_DSH names another dsh binary.
const bin = env.OPENRIG_DSH || env.DSH_SEAT_BIN || "dsh";
const version = cliVersion(bin, agentEnv);
const child = spawn(bin, ["--profile", "acp"], { cwd, env: agentEnv, stdio: ["pipe", "pipe", "inherit"] });
const child = spawn(env.OPENRIG_DSH || "dsh", ["--profile", "acp"], { cwd, env: agentEnv, stdio: ["pipe", "pipe", "inherit"] });
child.stdin.on("error", () => {});
const peer = new AgentPeer(child, (line) => say(`[dsh] ${line}`));
const state = { sessionId: null, turn: 0, running: false, cancelRequestedAt: null, holding: false, refused: false, queue: [], asks: new Set(), lastActive: 0, asking: 0, waiting: 0, resolvedAsk: null };
const state = { sessionId: null, turn: 0, running: false, cancelRequestedAt: null, holding: false, refused: false, queue: [], asks: new Set(), lastActive: 0 };
const inflight = new Set();
const track = (promise) => { inflight.add(promise); promise.finally(() => inflight.delete(promise)); return promise; };
let agentExited = false;
let stopping = false;
let heartbeat;
let workBeat;
let tty = false;
// The turn may read working: it runs, nothing holds needs input, no permission request is being submitted or waits for its
// permission_resolved, and dsh is alive. The beat and the agent's own reports both go by it.
const working = () => state.running && !state.holding && state.asking === 0 && !state.resolvedAsk && !agentExited;
const restoreTty = () => {
if (!tty) return;
@@ -293,7 +269,6 @@ async function main() {
};
const finish = async (code) => {
clearInterval(heartbeat);
clearInterval(workBeat);
restoreTty();
await Promise.race([Promise.allSettled([...inflight, activity.flushed()]), sleep(2_000)]);
process.exit(code);
@@ -324,7 +299,7 @@ async function main() {
peer.onNotification("session/update", (params) => {
if (params.sessionId !== state.sessionId || !params.update) return;
const update = params.update;
const reporting = working();
const reporting = state.running && !state.holding; // while needs input is held, nothing may read working
if (update.sessionUpdate === "agent_message_chunk") {
if (update.content?.type === "text") process.stdout.write(update.content.text);
if (reporting && Date.now() - state.lastActive >= ACTIVE_EVERY_MS) { state.lastActive = Date.now(); activity.post("active", "agent_message"); }
@@ -371,17 +346,10 @@ async function main() {
const allow = options.find((option) => option.kind === "allow_once") ?? options.find((option) => option.kind === "allow_always");
const permit = (why) => {
if (!allow) return refuse(`${why}, and the agent offered no allow option`);
state.holding = state.refused || state.waiting > 0; // another ask may still wait on a person
state.holding = false;
say(`[permission] permitted: ${why} (${allow.optionId})`);
return { outcome: { outcome: "selected", optionId: allow.optionId } };
};
// A person who answered in their own words: OpenRig passes them back as `said`. They join the prompt queue like any
// message, so dsh gets them after this turn and after any message already waiting. They're never the answer.
const passOn = (said) => {
if (typeof said !== "string" || !said.trim()) return;
say(`[permission] the person also wrote: ${firstLine(said)}`);
submit(`The person who answered your permission request (${toolCall.title}) also wrote:\n\n${said}`);
};
if (!askId) {
if (submitted.body?.code === "denied_by_seat") return refuse("the seat's operator chose to refuse its permission requests");
if (NOT_LIVE.includes(submitted.body?.code)) {
@@ -392,12 +360,7 @@ async function main() {
: submitted.status === 0 ? "OpenRig couldn't be reached" : `OpenRig couldn't ask anyone (${submitted.body?.code ?? submitted.status})`);
}
state.holding = true; // OpenRig recorded the seat's permission prompt when it opened the ask
state.waiting += 1;
say(`[permission] OpenRig asked a person (${askId}); waiting for their answer`);
// This ask no longer waits on a person; the seat still needs input while another one does, or after its own refusal.
const stopWaiting = () => { state.waiting -= 1; state.holding = state.refused || state.waiting > 0; };
// The agent got an answer or a permit for this ask: once nothing else waits, permission_resolved says the turn goes on.
const resolved = () => { state.resolvedAsk = { turn, askId }; };
return new Promise((resolve) => {
let done = false; // the agent has its answer
let ended = false; // OpenRig was told the request is over
@@ -408,7 +371,7 @@ async function main() {
withdraw(askId, reason);
if (done) return;
done = true;
stopWaiting(); // the turn ended the person's ask
if (!state.refused) state.holding = false; // the turn ended the person's ask: no one is still being asked
resolve(CANCELLED);
} };
state.asks.add(ask);
@@ -424,23 +387,18 @@ async function main() {
// A person's option, or OpenRig's permit when they closed it without one.
if ((answer.state === "answered" || answer.state === "permitted") && offered(answer.optionId) && live()) {
state.asks.delete(ask);
stopWaiting();
state.holding = false;
say(`[permission] ${answer.state}: ${answer.optionId}`);
resolve({ outcome: { outcome: "selected", optionId: answer.optionId } });
resolved();
passOn(answer.said);
} else if (live() && (read.status === 200 ? !ENDED.includes(answer.state) : !NOT_LIVE.includes(answer.code))) {
// OpenRig stopped answering, or answered in a way the contract doesn't list: nobody can be asked. The ask stays
// tracked, so the turn's end still withdraws it.
stopWaiting();
resolve(permit(read.status === 0 ? "OpenRig couldn't be reached" : `OpenRig couldn't answer (${answer.code ?? read.status})`));
if (!state.refused) resolved(); // permitted, not refused for want of an allow option
} else {
state.asks.delete(ask);
stopWaiting(); // a person said no, or the request is over
state.holding = false; // a person said no, or the request is over: no one is still waiting
say(`[permission] not answered (${answer.reason ?? answer.state ?? answer.code ?? read.status}); answered cancelled`);
resolve(CANCELLED);
passOn(answer.said);
}
}
})();
@@ -453,19 +411,9 @@ async function main() {
const options = Array.isArray(params.options) ? params.options : [];
say(`\n[permission] dsh asks: ${toolCall.title} (${toolCall.kind}); options ${options.map((option) => option.optionId).join(", ")}`);
if (!state.running || state.cancelRequestedAt) return CANCELLED;
state.asking += 1; // no beat while a permission request is open: it may be waiting on a person
return askOwner(requestId, toolCall, options).finally(() => { state.asking -= 1; setImmediate(settle); });
return askOwner(requestId, toolCall, options);
});
// Once nothing waits on a person or is being submitted, the seat holds no refusal of its own and dsh is alive, say the turn
// goes on. It goes out after the agent has its answer, and before the next beat.
const settle = () => {
const done = state.resolvedAsk;
if (!done || state.holding || state.asking > 0) return;
state.resolvedAsk = null;
if (state.running && state.turn === done.turn && !agentExited) activity.post("active", "permission_resolved", { turnId: String(done.turn), askId: done.askId });
};
const abort = () => {
if (!state.running) { say("[openrig-dsh] no turn is running"); return; }
if (state.cancelRequestedAt) return;
@@ -481,8 +429,6 @@ async function main() {
state.turn += 1;
state.holding = false;
state.refused = false;
state.waiting = 0;
state.resolvedAsk = null;
state.cancelRequestedAt = null;
state.lastActive = 0;
const turnId = String(state.turn);
@@ -526,8 +472,7 @@ async function main() {
return fatal(`dsh didn't complete the ACP handshake (${error.message})`, 1);
}
const capabilities = init?.agentCapabilities ?? {};
const reported = init?.agentInfo?.version ? `${init.agentInfo.version} (as its ACP handshake reports it)` : "(version not reported)";
const producer = `${RUNNER}; dsh ${version ?? reported}; ACP ${init?.protocolVersion ?? "?"}`;
const producer = `${RUNNER}; dsh ${init?.agentInfo?.version ?? "(version not reported)"}; ACP ${init?.protocolVersion ?? "?"}`;
const resumeRef = env.OPENRIG_RESUME_REF;
// Whether dsh's own session list names a conversation, following its pages: true, false when every page was read and
// none names it, or undefined when the list fails, answers in another shape, or runs past LIST_PAGES pages. An ACP
@@ -577,12 +522,6 @@ async function main() {
say("[openrig-dsh] each submitted message is one prompt; /abort or Ctrl-C cancels the running turn");
heartbeat = setInterval(() => { if (!state.running && !state.holding && !agentExited) postIdle("idle_heartbeat"); }, BEAT_MS);
// While a turn runs and nothing waits on a person: the turn is still outstanding and dsh is alive. Not progress.
workBeat = setInterval(() => {
if (working()) {
activity.post("active", "liveness", { turnId: String(state.turn), livenessSeconds: WORK_LIVENESS_SECONDS });
}
}, WORK_BEAT_MS);
for (const signal of ["SIGHUP", "SIGTERM"]) {
process.on(signal, () => {
if (state.running && !state.cancelRequestedAt) peer.notify("session/cancel", { sessionId: state.sessionId });
-203
View File
@@ -1,203 +0,0 @@
# Writing a daemon client
What a program that talks to the OpenRig daemon over HTTP needs and can't read from `rig --help`: how to find the
daemon, its health route, which bearer token each route family checks, the event streams, and some route
details. It describes the daemon as it is. It adds no promise beyond what each section says, and route shapes not
listed here can change.
## Finding the daemon
`rig status --json` is the discovery surface. Its `daemon` object gives `state` and `reachable`, and `pid` and
`port` when they are known: a stopped daemon's response has no `port`, and a running daemon found through
`OPENRIG_URL` has no `pid`. `reachable` is `true` when the daemon answered its health probe, `false` when it is
positively down, and `null` when that isn't confirmed (see `rig status` in
[the command reference](../as-built/cli-reference.md#status)). It reads the recorded state and probes; it never
starts a daemon.
The CLI itself finds the daemon in these orders:
- **Data commands** (everything that sends a request): `OPENRIG_URL` (or the legacy `RIGGED_URL`); else
`$OPENRIG_HOME/daemon.json` when it holds a valid `pid` and `port`; else the config keys `daemon.host` and
`daemon.port` (environment `OPENRIG_HOST` and `OPENRIG_PORT`), which default to `127.0.0.1:7433`.
- **Status** (`rig status`, `rig daemon status`): `OPENRIG_URL` (or `RIGGED_URL`) first, probed directly; else
the state file below.
- **Start and stop** (`rig daemon start`, `stop`): the state file, `$OPENRIG_HOME/daemon.json`, then the legacy
`~/.rigged/daemon.json`.
`daemon.json` holds `pid`, `port`, `host`, `db` and `startedAt`. `rig daemon start` writes a new daemon's record
only after its health check passes, so while a start is pending, the file holds no record of the new daemon: it
may be missing, or hold an earlier daemon's stale record. It is the CLI's own state file, not a published
contract: prefer `rig status --json`.
## `GET /healthz`
The liveness and identity read. It always returns `status: "ok"` and `pid`. Every other field is present only
in the case named:
| Field | When it is present |
| --- | --- |
| `euid` | On platforms with effective user ids. A self-report for the CLI's owner check, not authentication. |
| `databaseWarnings` | When the daemon opened its database with warnings. |
| `semver`, `commit`, `dirty`, `builtAt` | On a stamped (released) build; a development run has none. |
| `selfHostId`, `selfHostIdSource` | After the boot reconcile has set this host's id. |
| `eventLoop` | When the event-loop monitor runs: `lagMeanMs`, `lagP99Ms`, `utilization` (0 to 1), `lastTickAgeMs` and `healthy`. |
| `routeTimings` | With `eventLoop`. |
| `slowOperations` | When the slow-operation recorder runs. |
| `bind` | The bind plan: `mode` (`explicit` or `default`), `hosts` (the addresses bound), `tailscaleDetected`, and `ignoredRoutingHost` when a routing value was ignored. |
| `stuckSweep`, `wakeLadder` | The heartbeats of the stuck sweep and the wake ladder, when they run. |
## Bearer tokens
The daemon reads two tokens from its environment at start:
- **`OPENRIG_AUTH_BEARER_TOKEN`** checks Mission Control's write routes (`POST /api/mission-control/action` and
`/notifications/test`), the `/api/hosts` writes (`add`, `update`, `remove`, `pair`), and the `/api/rig-mode`
binding writes. `rig host pair` hands this token to the paired machine, so a daemon that will be paired with
needs it set (see `rig host pair` in [the command reference](../as-built/cli-reference.md)).
- **`OPENRIG_TERMINAL_BEARER_TOKEN`** checks every `/api/transport` and `/api/compaction` route, `POST
/api/terminal/follow` and `/viewer/ensure`, and every `GET /api/terminal/<name>` route, the terminal websocket
included. When the daemon is bound to an explicit address (`rig daemon start --host`) that isn't loopback or a
tailnet address, and this token isn't set, these routes check `OPENRIG_AUTH_BEARER_TOKEN` instead.
A route whose token isn't set lets every request through. Send a token as `Authorization: Bearer <token>`; the
`GET /api/terminal/<name>` routes, the websocket included, also take `?token=<token>`. The CLI sends the terminal token from `OPENRIG_TERMINAL_BEARER_TOKEN`,
or else from the file `$OPENRIG_HOME/terminal-token`, which the person writes; the daemon doesn't create it.
A wrong or missing token gets HTTP 401. The routes that check `OPENRIG_AUTH_BEARER_TOKEN`, and `/api/transport`
and `/api/compaction`, answer `{ "error": "unauthorized", "message", "what_failed", "why_it_matters", "what_to_do" }`.
The `GET /api/terminal/<name>` routes, the websocket included, answer `{ "error": "unauthorized", "hint" }`. A token
doesn't get past the browser boundary's 403 refusals, which [Browser access](browser-access.md) describes.
## Event streams
All three are server-sent events (`text/event-stream`):
- **`GET /api/events`**: the daemon's event log. `?rigId=` limits it to one rig. Each event carries `id: <seq>`.
Without `Last-Event-ID`, `?replay=<n>` first sends the newest `n` stored events (`0`: none, live events only);
`?replay=all`, no `replay`, or any other value sends the whole log first. A `Last-Event-ID` header (what
`EventSource` sends when it reconnects) sends the events after that id instead, whatever `replay` says.
Live events that arrive during the replay follow it, without duplicates.
- **`GET /api/activity/events`**: live only, no replay or ids. Each message is named after its event type:
`seat.activity_changed`, `seat.rung_health`, `proof.judged` or `proof.sources_changed`. It tells a view to read
again; it carries no derived state.
- **`GET /api/queue/sse`** (alias `/api/queue/watch`): live only, with `id: <seq>`, for `queue.created`,
`queue.handed_off`, `queue.claimed`, `queue.unclaimed`, `qitem.fallback_routed`, `qitem.closure_overdue`,
`inbox.absorbed` and `inbox.denied`.
## People, asks and updates without Slack
`GET /api/gateway/participants` returns the same JSON projection as `rig gateway participants --json`:
`{ ok, participants, default, organizations, groups, warnings, participantFile }`. Pick a person by `kind: "person"`
and use their `id` for a Feed `view`, or their `address` for the queue's `destinationSession`. The effective
`default` is `{ id, source }`, or `{ id: null, reason }`; it does not establish who is using the client.
Participant records omit connector credential pointers. Check `warnings` and `participantFile.error` for a partial
registry read; an unreadable human registry returns HTTP 503. See [Gateway participants](gateway-participants.md).
| Read | Meaning |
| --- | --- |
| `GET /api/queue/list?destinationSession=<address>&state=pending,in-progress,blocked&limit=100` | Open rows for exactly that address. `humanIntent: "update"` identifies an FYI; other values are asks. Each row has `qitemId`, `state`, `summary` and `body`. |
| `GET /api/queue/open-human-updates?view=<person-id>&limit=20` | Open human FYIs, even when no Slack delivery receipt exists. Returns `{ items, limit, truncated, perspective? }`. |
| `GET /api/queue/<qitemId>` and `/<qitemId>/transitions` | The current row and its history, including closed state and recorded outcome. Closing or acknowledging a row is different from delivering it. |
| `GET /api/queue/human-updates?view=<person-id>&limit=20` | Existing delivered-update history: explicit FYIs with a recorded Slack posting receipt, including closed rows. Items add `deliveredAt` and `deliveryReceipt`. It is not the open-update inbox. |
Both update lists accept integer `limit` values from 1 to 100 (default 20); invalid values return HTTP 400.
Open FYIs are ordered by priority, then newest first. `view=all` (also the default) shows all perspectives;
`view=default` selects the effective default person, or all when no default is available. A named view that cannot
resolve a person has `perspective.basis: "unknown"` and no items when a registry is available. With no registered
people there is no perspective filter: inspect `perspective` rather than assuming the requested view was applied.
A person's view also includes instance or unassigned items when that person is the default. These are views on a
trusted daemon, not per-person authentication or access control; existing daemon and browser access rules still apply.
Always display `truncated: true` as incomplete, even if fewer than `limit` items arrive. A person-view scan examines
at most 1,001 open updates (101 for delivered history) before filtering; older matching items may be outside that
window. These endpoints have no pagination cursor. Reading an open update never posts it to Slack, acknowledges it,
or manufactures a delivery receipt.
For example, this Node client takes a daemon URL, a registered person id and a rig name. It uses no Slack API and
makes only reads; use the daemon discovery and access rules above. In an ES module, `process.argv.slice(1)` is the
argument list for `node --input-type=module -e '<code>' <url> <person-id> <rig>`:
```js
// client-people-example
const [base, personId, rig] = process.argv.slice(1);
async function get(path) {
const response = await fetch(new URL(path, base));
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
return response.json();
}
const registry = await get("/api/gateway/participants");
const person = registry.participants.find(p => p.kind === "person" && p.id === personId);
if (!person) throw new Error("Choose a registered person id");
const rows = await get(`/api/queue/list?destinationSession=${encodeURIComponent(person.address)}&state=pending,in-progress,blocked&limit=100`);
const updates = await get(`/api/queue/open-human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
const receipts = await get(`/api/queue/human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
const rigHistory = await get(`/api/queue/list?rig=${encodeURIComponent(rig)}&limit=100`);
const messages = await get(`/api/queue/outbox/list?rig=${encodeURIComponent(rig)}&limit=100`);
console.log(JSON.stringify({ participants: registry.participants, default: registry.default,
warnings: registry.warnings, participantFile: registry.participantFile,
asks: rows.filter(row => row.humanIntent !== "update"), updates, receipts, rigHistory, messages }, null, 2));
```
The queue and outbox lists are bounded snapshots, not subscriptions. A full queue list may need a larger `limit`.
An outbox entry's `deliveryState` reports the send attempt; only `delivered` confirms delivery. A queue transition,
an open FYI, or a closed ask is not that receipt. The rig-wide reads and their limits are described below; the existing
`outbox/list?senderSession=<session>` read remains available.
## Route details
- **A rig's queue history:** `GET /api/queue/list?rig=<rig>&updatedSince=<ISO time>&sort=updated` returns the
rig's queue items (any whose source or destination seat is in the rig) updated at or after that time: open items
(`pending`, `in-progress`, `blocked`) first, then the rest by latest update. `limit` defaults to 100 and has no
upper cap. The list is cut after `limit` rows in that order, so when it comes back full, raise `limit` to read
them all. Each item's own history is `GET /api/queue/<qitemId>/transitions`. This read scans the queue table, so
read it occasionally rather than polling it. For live changes, treat `GET /api/queue/sse` events as hints to read
`queue/list?rig=` again. Not every event names both seats (`queue.claimed` and `queue.unclaimed` carry only the
destination), and not every state change is on that stream, so the events alone don't track a rig's items.
- **A rig's messages:** `GET /api/queue/outbox/list?rig=<rig>` returns the messages sent from or to the rig's
seats, newest first; `?senderSession=<session>` still returns one sender's. One of the two is required; with
both, `senderSession` is used and `rig` is ignored. `limit` defaults to 100. No event is sent when a message is delivered, so read this list to see delivery
states.
- **`GET /api/queue/recent-transitions`** returns at most 20 rows. A positive `limit` above 20 is capped at 20;
a missing, zero, negative or unparseable `limit` means 20.
- **`POST /api/transport/send`** takes `urgent: true`, the same as `rig send --urgent`. The daemon records who
marked it from the sender identity, not from the body.
- **`POST /api/scope/approve`** is what `rig scope slice approve` and `rig scope mission approve` call. The body
takes `scopeTier` (`slice` or `mission`), `scopePath`, and optionally `approvalScope` (`spec` or `delivery`, the
default), `missionsRoot`, `onBehalfOf`, `reApprove`, `reason` and `lockedArtifacts`. The approver is the
request's sender identity (`actorSession` in the body is recorded beside it, and a different value is
superseded). Success is 201 with `scopeTier`, `scopeId`, `scopePath`, `approvalScope`, `approvedBy`,
`approvedAt`, `onBehalfOf`, `actionId`, `freezeFired` and `reApproved`. Refusals include 400 for an invalid
`scopeTier`, `scopePath` or `approvalScope`, or a missing actor (`actor_required`); 503 `action_log_unavailable`;
and, from the approval itself, 404 `scope_not_found`, 409 `already_approved` or `nothing_to_reapprove`, 503
`workspace_not_configured`, 500 `audit_write_failed`, and 400 for its other refusals. An unexpected error is 500
`internal`.
- **`POST /api/proof/judge`** is what `rig proof judge` calls. The body takes `scope`, `item`, `verdict`
(`accept`, `reject` or `withdraw`), `reason`, `expectedRevision` and `expectedPrevious` (a judgment id or
`null`), and optionally `evidence`, `expectedEvidence`, `subject`, `operationId`, `replace`, `project` and
`projectRoot`. The judge is the request's sender identity. The response is `judgment`, the slice's `readiness`,
`replayed` and `notification`: 201 for a new judgment, 200 when an `operationId` replays an earlier one.
- **`POST /api/workflow/instantiate`** requires `specPath`, `rootObjective` and `createdBySession`, and takes
`entryOwnerSession`, `targetRig` and `scope` (see [Project workspace](project-workspace.md) for `scope`).
Success is 201 with `instance`, `spec`, `entryQitemId` and `entryOwnerSession`, any `advisories`, and
`scopeIgnored` when a scope wasn't applied.
- **`GET /api/provider/usage`** returns `{ hostUsage }`, this host's usage rows (`host: "local"`, a `provider`
and a `state`). A Claude row's `windows` are the seats' status-line readings, one entry per reading, each with
its `window`, `usedPercent`, `resetsAt`, `asOf` and `seatSession`. A Codex row has `windows: []`. Treating the
readings as one account rests on the deployment assumption the row's `provenance` names (one account per
host), not on a measured identity. Readings whose reset times disagree are reported as `anomalies`, and the
row's `state` can then be `explicit_unknown`. Show the row's `state`, `anomalies` and each reading's seat and
`asOf` as reported, rather than merging readings that happen to match.
- **Slack channels and people:** `GET /api/gateway/slack/channels` and `GET /api/gateway/slack/users` each return
one page from Slack, read with the daemon's configured bot token, which never leaves the daemon. Channels are
public and unarchived, as `{ id, name, isMember }`; people are `{ id, name, realName, displayName, isBot,
deleted }`, with no email. Pass `cursor` (the previous page's `nextCursor`, `null` on the last page) and
`limit` (default 100, at most 200). Inbound delivery needn't be enabled. Failures: 503 `slack_not_configured`
when no bot token resolves, which is different from an empty list; 403 `slack_missing_scope` with the scope
Slack names in `needed` (an older app may lack `users:read`); 429 `slack_rate_limited` with Slack's
`Retry-After`; otherwise 502. Each request is a live Slack call, so page through a list when someone opens a
chooser rather than polling it. Private channels and direct messages aren't listed.
- **Slices are found by folder name alone.** `GET /api/slices/:name`, `/:name/doc/*`, `/:name/proof-asset/*` and
`GET /api/review/slice/:name` take only the name; the detail route reads `?mission` only together with
`?project`. When two missions have a slice folder of the same name, the daemon keeps the first one it finds:
that one is listed and served, and the other doesn't appear in `GET /api/slices` at all. Keep slice names unique
across missions, as [Project workspace](project-workspace.md) says.
@@ -2,7 +2,7 @@
This chapter describes the placement and resource responsibilities of an integration author. The harness/runner chapter owns native protocol, model input, activity, attention, permission exchange and turn cancellation. This chapter owns where that harness runs, which files and credentials it can reach, how native state survives an environment change, and how results and resources return to their owners.
**Status:** a design contract for authors, with an existing-compatible composition and one measured private implementation. Since 0.7.8, OpenRig also ships one maintained reference driver, OpenShell 0.1.6, as `rig environment` (see the [OpenShell example](community-harness-examples/openshell/README.md)). The measured baseline used OpenRig 0.7.4, OpenShell 0.1.6 and Claude Code 2.1.310. 0.7.6 published this draft; neither it nor the reference driver implements a generic environment driver, a `DeploySpec` loader or a cross-host native-resume guarantee. Proposed operations below are not commands or accepted configuration keys. Capability support must be stated for the actual integration and version.
**Status:** a design contract for authors, with an existing-compatible composition and one measured private implementation. The measured baseline used OpenRig 0.7.4, OpenShell 0.1.6 and Claude Code 2.1.310. 0.7.6 published this draft; publishing it does not implement a generic environment driver, a `DeploySpec` loader or a cross-host native-resume guarantee. Proposed operations below are not commands or accepted configuration keys. Capability support must be stated for the actual integration and version.
## Keep the identities and responsibilities separate
+17 -29
View File
@@ -5,31 +5,15 @@ OpenRig's gateway can register **several people** and **shared channels**, group
OpenRig doesn't enforce it. This page covers the commands, what the default is for, and what upgrading and rolling back
do.
For a remote client, `GET /api/gateway/participants` returns the same participant, organization, group, default and
warning fields as `rig gateway participants --json`. Add `?organization=<id>` to filter the participant list; the default
and organization and group maps still describe the whole registry. The result names the participant file and reports
an unreadable side file without treating it as an empty one. If the human registry itself cannot be read, the route
returns `503` with `error: "human_registry_unavailable"`. The result does not include connector secret pointers.
## Participants
A participant is either:
- **a person**, registered with `rig gateway human add`, with or without a connector binding; or
- **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.
A registered person is addressed as `<id>@external`, including without Slack: for example,
`rig gateway human add alex --display-name "Alex" --delivery-class B` creates `alex@external`, reached in the Feed and Steering.
`human@host` is the unnamed person at the terminal; `human@kernel` is also accepted as a human/gate address.
These aliases (including `human-<name>@host` and `human-<name>@kernel`) are not agent sessions: reply with
`rig queue create --destination human@host --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`,
substituting the sender's address and your summary, evidence path and reply. A reply destination doesn't identify
who acted or determine who may answer an ask. Clients acting as a registered person name that person's
`actorSession`; a seat identity header takes precedence. The terminal's primary-user authority records the unnamed
`human@host` actor, not the person whose queue is being viewed.
```bash
# An organization and a group inside it (one command each, create or update)
rig gateway org set acme --name "Acme" --policy "Answer Acme's customers within a working day. Never promise a date."
@@ -75,26 +59,30 @@ never changes `workspace.operator_seat_name`, which keeps its own meaning.
## In the TUI: whose queue, and who acts
The person at the terminal is the **primary user**. The TUI records their acts as unnamed (`human@host`),
whatever person's queue it shows or who is configured as the default.
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 unnamed terminal
actor `human@host` and the person asked in the audit notes and transition. The asking seat reads, for example,
"Unattributed (human@host) answered sarah@external's ask".
- **Viewing Sarah isn't acting as Sarah.** A registered person may answer, approve, deny or resolve another person's
ask as themselves; both the actor and person asked are recorded. No connector binding is required, and viewing
someone else's queue doesn't make its viewer that person.
- **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:** primary-user authority is accepted only on a request that carries no seat identity
header, which is how the TUI calls the daemon. A headerless client can instead name a person's `actorSession` without
that authority. Seats and the CLI send the identity header, so their acts stay theirs. This is the existing
trusted-network distinction, not an authentication check.
- **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
+1 -13
View File
@@ -55,8 +55,7 @@ together in that home; it needs its own working sign-in and doesn't relocate `~/
> - Codex: hooks, workspace trust and team-selected config fragments (including MCP) in the selected
> `CODEX_HOME/config.toml`, default `~/.codex/config.toml`. These affect other Codex sessions using that home.
> - Claude Code: workspace trust in `~/.claude.json`, and activity hooks in the project's
> `.claude/settings.local.json`, plus a status line unless you already have your own. `rig down` takes the hooks
> and status line back out once no Claude seat uses that directory.
> `.claude/settings.local.json`, plus a status line unless you already have your own.
> - Discovery skills go in `~/.claude/skills` and `~/.agents/skills`.
> - Each seat's `CLAUDE.md` or `AGENTS.md` gets OpenRig's managed blocks (culture, identity, onboarding). One more
> line, pointing an agent at its seat skills, is added only on a yes: saved at setup, or set where the daemon runs
@@ -291,17 +290,6 @@ fine; if both accounts are authenticated, the kernel uses both even when your
later project uses just one. A request to use only one provider for everything
is a separate choice.
`/api/kernel/status` retains the chosen template (`rig.yaml`,
`rig-claude-only.yaml` or `rig-codex-only.yaml`) across daemon restarts and
recovery, including starts with `--no-kernel`. It reads the rig's latest rig-spec
bootstrap record, including a partial launch, so an older kernel's recorded
selection is also available. This is the original selection; the rig's live spec
describes its current configuration. The saved source must match a template under
the current installation’s built-in specs directory. A custom project path with
the same directory suffix, an old source path that can no longer be verified
(for example, after a Node version change moves the global install), or a kernel
without bootstrap history reports an unknown variant.
**Started is not ready.** Read the `Kernel:` boot state in `rig status` and the
individual seat state in `rig ps --nodes --rig kernel`. The view may open while
agents finish starting; describe that state, without claiming they are ready.
+10 -60
View File
@@ -3,20 +3,17 @@
> **Status.** This is a draft for contributors.
> - **Released in 0.7.6:** the `runner` field and OpenRig's side of declaration, identity and activity
> (sections 2-4).
> - **Released in 0.7.8:** seat activity grants (section 4), and OpenRig's side of the answered permission path
> (section 7).
> - **Runner-author behavior, not yet qualified:** the input, cancellation and permission recipe in sections 5-7, and
> the runner-side shutdown obligation in section 9. A dsh port implementing this recipe is not qualified.
> - **Proposed runner-author behavior:** the input, cancellation and permission recipe in sections 5-7, and the
> runner-side shutdown obligation in section 9. A dsh port implementing this recipe is not qualified.
> - **Existing interfaces with author obligations:** launch-result identity, exact resume, first input and model
> handoff. These interfaces do not establish that a particular runner implements them correctly.
> - **Unchanged:** plain community presets and the built-in runtimes (Claude Code, Codex, Pi, OMP, terminal).
>
> No harness has been run end to end under this contract yet, and the native verdict is pending. The first candidate
> is dsh over ACP.
> No harness has been run end to end under this contract yet. The first candidate is dsh over ACP.
>
> Release status and pinned references are in the [joined author guide](author-contracts.md). The
> [community presets reference](community-harness-presets.md) describes the implemented fields, including the runner
> preset's exception for approvals; this chapter also labels runner-author behavior that isn't qualified.
> [community presets reference](https://github.com/mvschwarz/openrig/blob/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-presets.md)
> describes the implemented fields at that base; this chapter also labels proposed author behavior.
This chapter covers **which agent runs in a seat and how OpenRig talks to it**. *Where* that agent runs (a host, a
sandbox, another machine) is the [execution platform chapter](execution-platform-contract.md). The two are separate on purpose:
@@ -107,8 +104,6 @@ Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer
|---|---|---|
| You gave the agent the prompt for a submitted message | `UserPromptSubmit` / `accepted`, `turnId` = your prompt number | working |
| The agent is working (message chunks, tool calls) | `active` / `agent_message`; `PreToolUse` / `<tool kind>` | working |
| Optional working beat, from 0.7.9: the turn is still outstanding and the agent alive, with nothing new to report (a long tool or model call) | `active` / `liveness`, with `turnId`, `sequence` and `livenessSeconds` | working while beats keep coming |
| From 0.7.9: you delivered an answer or permit to a request OpenRig had shown as needing input, and the agent continues the same turn (section 7) | `active` / `permission_resolved`, with `turnId` and `askId` | working |
| You refused a permission request (section 7) | `Notification` / `permission_prompt` | needs input |
| The turn ended | `Stop` / its stop reason (`end_turn`, `cancelled`, `max_tokens`, `refusal`) | idle. While you hold attention (section 7), don't post it: OpenRig reads any non-exit `Stop` as idle and clears needs input |
| Optional idle heartbeat | `Stop` / `idle_heartbeat`, with `livenessSeconds` | idle while beats keep coming |
@@ -119,41 +114,6 @@ Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer
idle `Stop`, the turn's end as well as each beat. OpenRig stops reading the seat idle after 2 × `livenessSeconds` without
a newer idle report. Without it, an idle report holds until your next post, even if your runner has died. Never send a
non-exit `Stop` or a beat while holding permission attention.
- **Working evidence lasts 15 seconds.** A working post (`UserPromptSubmit`, `active`, `PreToolUse`) decides the seat's
activity for 15 s. After that the seat reads from its other evidence: the screen can still show work, but it never
reads a runner seat idle, so with nothing else the seat reads unknown.
- **The working beat (from 0.7.9)** keeps a long, quiet turn reading working. It says only that this turn's prompt is
outstanding and the agent process you started is alive. It isn't progress.
- **When to beat:** only while a prompt is outstanding and the agent is alive, more often than every
`livenessSeconds` ÷ 2.
- **When to stop:** stop at once on the turn's `Stop`, on a permission request that needs a person, or on the agent's
exit, and post that boundary first.
- **`livenessSeconds`** is how long one beat counts as working. OpenRig clamps it to 5-60 s, and uses 30 s when a beat
has none. After that the beat stops counting, and the seat reads from its other evidence, as a working post's does.
- **A beat counts only when all of these hold:**
- it carries `generation`, equal to the seat's live occupant generation, and so did the `UserPromptSubmit` /
`accepted` post that opened its turn;
- its `turnId` is your latest `UserPromptSubmit` / `accepted` turn, and you haven't posted that turn's `Stop` or an
exit;
- it carries a `sequence`, greater than every earlier post's in the same generation that OpenRig counted, beats
included;
- the seat has no attention outstanding, such as a permission request waiting on a person.
So number every post with one `sequence` that rises across your whole runner process, beats included. OpenRig
orders your posts by `occurredAt`, then `sequence`. A post older than one OpenRig already counted changes nothing:
a late `Stop` for an earlier turn doesn't end the current one, and an older beat doesn't count. A post without a
valid `occurredAt` is dated when it arrives, by OpenRig's clock, so it can read older than it is when your clock
runs ahead. Give every post its `occurredAt`.
- **After you deliver an answer or permit** to a request OpenRig had shown as needing input (section 7; it created an
ask, and a permit at submit opens none), post `active` / `permission_resolved` with the turn's `turnId` and the
`askId`, before your next beat. That clears the seat's attention. So does any other working or idle post OpenRig
counts, and an operator's `rig seat clear-attention`. OpenRig doesn't match `permission_resolved` to its `askId`.
Beats are ignored until attention clears.
- **A beat that fails any of these is ignored.** The seat reads as it would without it. A beat never starts or extends
a turn, never clears attention, and never stands in for a `Stop`.
- **OpenRig keeps what it needs to judge beats in memory.** After a daemon restart, a beat already counted keeps its
lifetime, but new beats are ignored until your next accepted prompt, and the seat reads as it would without them.
- **A runner that sends no beats** behaves as before.
- **Exit:** post `Stop` / `<preset name>_exited` only when you observe the agent's process exit.
- **The screen still counts,** but only as evidence of work. On its own it never reads a runner seat idle.
@@ -176,17 +136,13 @@ Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer
A refused post records nothing, so the seat's activity stays as it was, or unknown.
**The guest's boundary is its listener, not the grant.** A guest reaches OpenRig only through a guest-only listener that
serves this route and section 7's three permission-request routes, and accepts only a grant. It must never reach the
daemon's main port. The grant scopes activity posts and permission requests, and the main port's other routes don't
check it.
- The listener is off by default. Turn it on with `OPENRIG_GUEST_ACTIVITY_PORT`, which must differ from the daemon's
main port or the daemon refuses to start.
- It binds `OPENRIG_GUEST_ACTIVITY_HOST`, default `127.0.0.1`: set it to an address the guest can reach.
serves this one route and accepts only a grant. It must never reach the daemon's main port. The grant scopes activity
posts only, and the main port's other routes don't check it.
## 5. Input: one submitted message is one prompt
Sections 5-7 specify the first dsh/ACP runner recipe; no runner has been qualified against it yet. They do not change
permission or input handling in existing built-in runtimes or require every possible runner protocol to use ACP.
Sections 5-7 specify the proposed first dsh/ACP runner recipe. They do not change permission or input handling in
existing built-in runtimes or require every possible runner protocol to use ACP.
- **Set up the pane:** put your TTY in raw mode and turn on bracketed paste (`\e[?2004h`).
- **What arrives:** `rig send` and queue reminders paste the whole message, then press Enter. Everything up to that Enter
@@ -206,8 +162,6 @@ permission or input handling in existing built-in runtimes or require every poss
- **On `/abort` or Ctrl-C:** send the agent's cancel (for ACP, the `session/cancel` notification, which has no reply),
and record when you sent it.
- A person cancels by typing `/abort` in the seat's pane. A `/abort` sent with `rig send` arrives inside a From/To
envelope, so it's an ordinary prompt.
- **Report "cancelled" only when the agent says so:** for ACP, when `session/prompt` returns `stopReason: "cancelled"`.
Then post `Stop` / `cancelled`.
- **Whether the provider stopped work or spend is unknown.** Don't claim it.
@@ -298,15 +252,11 @@ that runs without asking gains no approval step. Ordinary conversation and `rig
`refused` (with `reason`: `said_no` when the person said no and the agent offered no reject option, or
`no_allow_offered` when it was closed without a choice and the agent offered no allow option), `withdrawn` or
`stale`.
- **From 0.7.9, `said`,** with `permitted` or `refused` (`no_allow_offered`), is the person's own words, when they
answered in words that were neither a choice nor a plain yes or no. It never changes the outcome.
- **A seat grant reads and withdraws only its own launch's requests.** Once its occupant is replaced, the grant is
refused (`409 activity_grant_stale`), even during a read's wait.
3. **Deliver** `{"outcome": {"outcome": "selected", "optionId": "<optionId>"}}` to the agent once, and only while that
request is still its live one. For `refused`, `withdrawn` or `stale`, answer `cancelled` and hold no attention: no
person is still waiting. A refusal of the read itself follows the refusals below. From 0.7.9, when the read carries
`said`, queue those words for the agent as an ordinary prompt: they reach it after the current turn, behind any prompt
already waiting. They're never the answer. A runner that drops them still keeps this contract.
person is still waiting. A refusal of the read itself follows the refusals below.
4. **Withdraw** when the turn is cancelled or ends, or the agent exits:
`POST <base>/api/activity/permission-requests/<askId>/withdraw` with
`{"reason": "turn_cancelled" | "turn_ended" | "exited"}`.
-32
View File
@@ -107,38 +107,6 @@ OpenRig doesn't observe a hosted member between its calls, so it never reads idl
`rig mcp token revoke grok` refuses its next request. The rows it worked, and their answers, stay.
## Assistants connected through a grant service
With `rig mcp serve --http --grant-service <url>`, an outside service answers each connection's key with its grant
(`openrig.grant/v1`): the seat it acts as, its rigs, and the object/action pairs it may use. Each pair offers one tool,
and nothing else is offered:
| Pair | Tool |
|---|---|
| `queue`/`create`, `queue`/`show`, `queue`/`list` | `rig_queue_create`, `rig_queue_show`, `rig_queue_list` |
| `queue`/`claim`, `queue`/`update` | `rig_queue_claim`, `rig_queue_update` |
| `team`/`list` | `rig_team` |
| `artifact`/`read` | `rig_artifact_read` |
- **One seat per connected assistant:** the service gives each connection its own seat and keeps it across that
connection's reconnects. The seat must be one of the rig's nodes; a node created without a launch is enough. The caller
is exactly that seat.
- **What it reaches:**
- It hands work to its rigs' other seats, never to a person or another host's seat.
- It reads the items it sent and the items addressed to it from those seats, never the team's internal items.
- With `queue`/`claim` and `queue`/`update`, it claims and closes only items addressed to its own seat that it can read,
and waits (blocked) only on an item it names and can read. Its answers hide what reading the item hides.
- **Two assistants together:** a grant with `team`/`list` sees its rigs' seats with `rig_team`, and may address and hear from
another connected assistant's seat. A grant without it never reaches a seat in the service's caller form
(`cus-<id>-<connection>@<rig>`), so an existing connection keeps the reach it had.
- **Messages are items:** a message is an item the receiver reads and closes, and a reply is an item back to its sender.
- **What the sender can tell, from the item it reads:**
- **Queued:** the item exists and is `pending` with no claim.
- **Picked up:** the receiver's claim (`in-progress`).
- **Answered:** closed `done`, with the answer in `resolution`.
- Reading or listing an item records nothing, and nothing wakes an assistant, so an item stays queued until its receiver
claims it.
## Limits today
- **Webhook triggers only:** OpenRig rings a webhook. Email and other triggers aren't supported yet. A member without a trigger
-11
View File
@@ -110,17 +110,6 @@ limitation: use the live spec-library commands to determine where a spec is
served from, and do not infer convergence merely because the canonical
directory exists.
The spec library scans the built-in specs, `$OPENRIG_HOME/specs` and, when
present, that legacy library. At any depth below them it skips directories
named `.git`, `.worktrees`, `node_modules`, `dist`, `build`, `.turbo` and
`.next`, which usually hold build output or dependencies (`SKIP_DIRS` in
`packages/daemon/src/domain/spec-library-service.ts`). A spec inside one of
them, such as `specs/build/rig.yaml`, isn't listed, and nothing reports that it
was skipped. To list it, rename its directory and run `rig specs sync`.
`rig specs add <directory>` copies a directory under its own name, so adding a
directory named `build` doesn't help. `rig up <path-to-rig.yaml>` still launches
a spec by its path, without the library.
Config updates publish a complete replacement file atomically where directory
permissions and the filesystem allow it. On POSIX systems, replacement preserves
the existing owner, group, and read/write/execute permission bits. If creating or
-22
View File
@@ -107,28 +107,6 @@ rig queue create --destination <entityId>@external --human-intent update \
- **`posted` means the connector posted it, not that they read it.** Don't say they saw it.
- **If delivery fails or is indeterminate,** inspect that task before anything else. Never resend blindly.
## If the timer cannot reach its recipient
A `periodic-reminder` occurrence that definitely fails before input remains owed. The watchdog
retries through its existing loop, using `queue.wake_retry_interval_seconds` and
`queue.wake_retry_cap` (by default, three retries five minutes apart). A failed attempt does not
advance the reminder to its next regular cycle. A confirmed send advances the normal interval;
recovery does not replay every elapsed cycle.
`rig watchdog show <jobId> --json` includes the failed attempt, its retained outbox IDs, retry
time and next action. If input may have reached the recipient, that occurrence ends unconfirmed
and is not automatically sent again. Inspect the recipient and exact outbox record before any
manual replay. A recurring reminder keeps its next normal cycle under a new occurrence identity. A confirmed receipt survives a daemon restart, so
finishing its accounting does not type the same occurrence twice.
Exhausted retries end only the failed occurrence, with its cause and next action; a recurring
reminder keeps its next normal cycle. A permanent transport failure leaves a terminal job:
correct the destination and register a replacement reminder. A one-shot park timer records its
failed or unconfirmed wake on the parked row before it ends. A retryable failure is recorded
there while the timer stays armed. Deliberately held
input keeps its existing retained-message custody. Condition watches and parked-owner policy
behavior are unchanged. This does not replay failed occurrences recorded by older versions.
## "What are you watching for me?"
List **every active state**, not just set-aside ones. A watch being checked can be in progress, and a new one can be
+1 -2
View File
@@ -25,8 +25,7 @@ that restores nothing is not itself evidence of accidental loss.
## Choose the interface the seat actually has
Pi 1.4.0 includes an opt-in selector at
`packages/coding-agent/examples/extensions/tools.ts` in the Pi repository. Its
Pi 1.4.0 includes an opt-in selector at `examples/extensions/tools.ts`. Its
`/tools` command remembers selection per branch and needs Pi's own TUI. Use it
where that extension and native TUI are available. **Do not send `/tools` to an
agent OpenRig drives over RPC**: that interface cannot operate the selector.
+1 -2
View File
@@ -105,8 +105,7 @@ The daemon starts and boots the kernel with the coding agents you're signed in t
**What starting OpenRig changes, for the coding agents you use** (paths are the usual defaults):
- **For Codex:** hooks and trust in `~/.codex/config.toml`, when OpenRig or a Codex agent starts.
- **For Claude Code:** workspace trust in `~/.claude.json`, and activity hooks in the project's
`.claude/settings.local.json`, plus a status line unless you already have your own. `rig down` takes the hooks and
status line back out once no Claude seat uses that directory.
`.claude/settings.local.json`, plus a status line unless you already have your own.
- **For both:** small discovery skills in `~/.claude/skills` and `~/.agents/skills`.
- **Kernel access:** by default the kernel gets broader permissions than a project team gets by default. On Codex
that's full access with no approval prompts. On Claude Code, its file tools, web fetches and operational commands,
+2 -2
View File
@@ -91,10 +91,10 @@ my-rig.rigbundle.sha256 — "a1b2c3d4..." (64-char hex SHA-256)
The packer produces deterministic output:
- Files are sorted alphabetically
- A fixed mtime is used (`2026-01-01T00:00:00Z`)
- Portable mode omits owner metadata (uid, gid, user and group names) and only partly normalizes file modes: owner read and write are always set and group and other write are cleared, while execute and read bits are kept
- Portable mode normalizes uid/gid/mode
- Maximum gzip compression (level 9)
Identical staged bytes and file modes (including manifest timestamps and provenance) produce the same archive hash, so staged trees with the same contents whose files differ in execute or read bits hash differently. Use the package digest to compare packaged file content independently of file modes and manifest metadata.
Identical staged bytes (including manifest timestamps and provenance) produce the same archive hash. Use the package digest to compare packaged file content independently of manifest metadata.
---
-59
View File
@@ -755,7 +755,6 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
| `agent_file` | string | no | — | Claude members only. A Claude agent definition (a `.md` file with `name` in its frontmatter) for this seat. It's copied into the seat's own storage and launched as `--agent openrig:<name>`. A path beside the spec: no `..`, not absolute. |
| `append_system_prompt_file` | string | no | — | Claude members only. A prompt file appended to Claude's system prompt for this seat. It's copied into the seat's own storage and passed as `--append-system-prompt-file`. A path beside the spec. |
| `plugin_dirs` | list | no | — | Claude members only. Claude plugin folders for this seat, each copied into the seat's own storage and loaded through `CLAUDE_CODE_PLUGIN_DIRS`. Paths beside the spec. Each is copied under its folder name, so two folders with the same name are refused; an empty list is none. A missing file or folder fails that seat's launch by name. `rig bundle create` carries all three; `rig export` names a seat whose additions it doesn't carry. |
| `plugin_channels` | list | no | — | Claude members only. Plugin channels this agent registers for, as `plugin:<name>@<marketplace>` entries, passed as one `--channels` on every launch; see [Claude plugin channels](#claude-plugin-channels-runtime-claude-code). |
| `role` | string | no | — | The seat's role, letters, digits, `_`, `.` and `-`. Not valid on terminal members. |
| `restore_policy` | string | no | the AgentSpec's | Restore behavior. One of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`. The default comes from the AgentSpec's `defaults.lifecycle` (else `resume_if_possible`) as narrowed by its profile; a member value may only narrow it further, in that order. |
| `compaction_strategy` | string | no | — | `default-compaction`, `managed-compaction`, `handover` or `apprentice-handover`; `harness_native` and `pod_continuity` are accepted as deprecated aliases, and `custom_prompt` is refused. `managed-compaction` and `apprentice-handover` create their jobs only for `claude-code` seats; see [Codex compaction and sizing](agent-startup-guide.md#codex-compaction-and-sizing). |
@@ -811,52 +810,6 @@ OpenRig keeps the requested opt-out flags, so startup and resume remain subject
to that native refusal; it does not fall back to loading servers. Use a host
without that managed configuration, or omit the opt-out to use the managed set.
### Claude plugin channels (`runtime: claude-code`)
Set `plugin_channels` on a Claude member to register that agent for channel
notifications (messages a plugin's MCP server pushes into the session):
```yaml
members:
- id: support
agent_ref: local:agents/support
profile: default
runtime: claude-code
cwd: .
plugin_channels:
- plugin:example-channel@example-marketplace
```
OpenRig passes `--channels plugin:example-channel@example-marketplace` to this
agent's Claude on every launch, resume, fork, restore and handover. Several
entries follow one `--channels`, space-separated, and OpenRig's own session
options come after them. Claude Code 2.1.316 hides `--channels` from
`claude --help`, so it isn't listed there. An agent without the field gets no
`--channels`, and nothing is added for other agents or other runtimes; there is
no rig-wide or environment default.
Each entry is `plugin:<name>@<marketplace>`, both names made of letters, digits,
`.`, `_` and `-`, starting with a letter or digit. `server:` entries and Claude's
`--dangerously-load-development-channels` aren't supported. Combining a non-empty
channel list with `claude_mcp: none` produces a validation warning: that setting
starts Claude without MCP servers (plugin servers included), so no channel could
register. OpenRig keeps both settings and launches the member normally. Remove
`claude_mcp: none` to use the channels, or remove `plugin_channels` to keep the MCP
opt-out. OpenRig doesn't install plugins or marketplaces, or change the settings
that admit a channel: whether each channel registers is Claude's decision (the plugin installed, and
admitted by your organisation's channel settings or Claude's approved list).
Selecting a channel for one agent doesn't isolate what the plugin shares with
other agents. A token or other credential the plugin keeps in its own
configuration is the same for every agent that loads that plugin.
The selection is saved with the seat's other launch additions when the seat
starts from the spec, and starting it from a spec without the field removes it.
`rig seat launch --plan` and `rig skill loadout --seat` list it, a fork keeps it,
and `rig bundle create` and `rig export` carry it in the member; a bundle's
before-install view names each channel as a need. A running Claude process is
unaffected.
### Claude Advisor (`runtime: claude-code`)
Set `advisor` independently of the primary `model`:
@@ -1321,15 +1274,3 @@ These are the built-in specs shipped with OpenRig. Read them as worked examples.
Also shipped, in the same tree: `launch/kernel`, OpenRig's own team, which starts automatically. `first-project` is
starter's old name and still resolves to it. `workshop`, the four-agent middle team, is a rig bundle rather than a
built-in. List the built-ins with `rig specs ls --kind rig`.
To omit built-in rigs, agents and workflows from the shared spec inventory, run
`rig config set specs.builtins_visible false`. This changes the CLI, TUI and other
consumers of the library listing; user specs remain visible. Use
`rig specs ls --include-builtins` to list them deliberately, and set the value
back to `true` to restore the default. Explicit `show`, `preview`, and launch
lookups can still find hidden built-ins. When built-ins are hidden and a user
spec has the same name, the user spec wins the name lookup; use the built-in's
ID from `rig specs ls --include-builtins --json` to select it. With built-ins visible,
same-name matches remain ambiguous. This is an inventory preference, not an
access-control boundary; other endpoints and on-disk files can still expose the
built-ins.
+3 -5
View File
@@ -18,7 +18,7 @@ node check-runner.mjs --runner 'my-runner --agent-command {agent}' --name my-har
- **`--wait-ms 20000`** waits longer for each step (default 8000), for a runner whose agent takes a while to start. The check's shorter settling times, and the fake agent's own delays and holds, grow in proportion.
- **`--pass-env NAME`** passes one more variable through. Otherwise the runner gets only `PATH`, `HOME`, `USER`,
`SHELL`, `LANG` and `TMPDIR`, plus the OpenRig variables.
- **The exit status** is 0 when nothing fails, 1 when a case fails, and 2 for a usage or setup problem. An advisory result (4c, 7c) never changes it.
- **The exit status** is 0 when nothing fails, 1 when a case fails, and 2 for a usage or setup problem. An advisory result (7c) never changes it.
In an installed OpenRig, the check is at `$(npm root -g)/@openrig/cli/daemon/docs/reference/runner-conformance/`.
@@ -51,13 +51,11 @@ In an installed OpenRig, the check is at `$(npm root -g)/@openrig/cli/daemon/doc
| 1b | 3, 4 | A handshake that fails at `initialize` or `session/new` (an error or an exit) writes no conversation in a launch record, and posts no accepted prompt or idle report |
| 2 | 5, 6 | One pasted block becomes exactly one `session/prompt`, byte for byte. A line reading `/abort` inside a block isn't control. A block sent mid-turn waits for the next turn. A block that is exactly `/abort`, or Ctrl-C outside a paste, sends one `session/cancel`. `Stop/cancelled` is posted only when the agent itself returns `cancelled` |
| 3 | 4 | `UserPromptSubmit/accepted` with a `turnId`, then `Stop/end_turn`. Every post carries the contract's fields, the hook token, this seat's identity and a rising `sequence`. `livenessSeconds`, when given, is from 1 to 3600 |
| 3b | 4 | **Optional, from 0.7.9; skipped when the runner sends no beat.** In a long, quiet turn, each `active/liveness` beat names the accepted turn, `sequence` rises with the runner's own `occurredAt` across every post, beats come more often than every `livenessSeconds` ÷ 2, and none follows the turn's `Stop`. While a person is asked, no beat is posted; after the answer, `active/permission_resolved` names the turn and the ask, before any beat. A permit at submit opens no ask, so the quiet turn's beats go on |
| 4 | 7 | When no one can be asked, the request is permitted: an OpenRig without the answered path (404) at once, and one the runner can't reach, each get the agent's `allow_once` option, never its `allow_always` while `allow_once` is offered. The turn then reports as usual, with no `permission_prompt`. Only `denied_by_seat`, or a request with no allow option, is answered at once with a refusal, never an allow option. `Notification/permission_prompt` is then posted, and for `denied_by_seat` the pane shows what was asked (the tool call's title). No idle `Stop` or beat is posted while that attention is held, even when `/abort` then cancels the turn. The next prompt is accepted as usual |
| 4b | 7 | The live request is submitted with the hook token, its `requestId` as the agent's JSON-RPC id, passed unchanged, the accepted prompt's `turnId`, this conversation and generation, and the agent's options as offered. The runner doesn't post `permission_prompt` itself for a created ask, and each read waits at most 25 s. The chosen option, allow or deny, reaches the agent once, and the turn then reports as usual. OpenRig's `permitted`, at submit or at a read, reaches the agent the same way, and a read that carries the person's own words (`said`) still gives the agent the read's option. A refusal is read by its code, never its status: one section 7's table marks "permits" (`duplicate_request`, `activity_grant_pending`, `activity_grant_revoked`), a read that gets no answer, and a status or read state the section doesn't list (500, `deferred`) each give the agent its `allow_once` option. A refusal saying the request isn't the current occupant's (`stale_generation` at submit, `activity_grant_stale` on a read), and a `refused` (`said_no`), stale or withdrawn read, give the agent `cancelled`, and the turn then ends as usual, with nothing held. With no answer, the request stays pending with no idle report after the turn's accepted prompt, and a pasted message isn't an answer. `/abort` withdraws it with `turn_cancelled`, and an answer after that isn't delivered. A turn's end or the agent's exit while it waits withdraws it with `turn_ended` or `exited`. Once the ask is withdrawn no person is still waiting, so the turn's end is reported as usual. An ask the runner permitted after a read got no answer, or an unlisted one, may still be open, so it's withdrawn at the turn's end too |
| 4c | 7 | **Advisory.** A read's `said`, the words of a person who answered in words, is queued to the agent as a prompt, after the turn and after any prompt already waiting. Section 7 lets a runner drop them and still keep the contract, so this result is shown as met or not met and never counts as a pass or a fail, or toward the exit status |
| 4b | 7 | The live request is submitted with the hook token, its `requestId` as the agent's JSON-RPC id, passed unchanged, the accepted prompt's `turnId`, this conversation and generation, and the agent's options as offered. The runner doesn't post `permission_prompt` itself for a created ask, and each read waits at most 25 s. The chosen option, allow or deny, reaches the agent once, and the turn then reports as usual. OpenRig's `permitted`, at submit or at a read, reaches the agent the same way. A refusal is read by its code, never its status: one section 7's table marks "permits" (`duplicate_request`, `activity_grant_pending`, `activity_grant_revoked`), a read that gets no answer, and a status or read state the section doesn't list (500, `deferred`) each give the agent its `allow_once` option. A refusal saying the request isn't the current occupant's (`stale_generation` at submit, `activity_grant_stale` on a read), and a `refused` (`said_no`), stale or withdrawn read, give the agent `cancelled`, and the turn then ends as usual, with nothing held. With no answer, the request stays pending with no idle report, and a pasted message isn't an answer. `/abort` withdraws it with `turn_cancelled`, and an answer after that isn't delivered. A turn's end or the agent's exit while it waits withdraws it with `turn_ended` or `exited`. Once the ask is withdrawn no person is still waiting, so the turn's end is reported as usual. An ask the runner permitted after a read got no answer, or an unlisted one, may still be open, so it's withdrawn at the turn's end too |
| 5 | 4 | Every post carries `OPENRIG_OCCUPANT_GENERATION` |
| 6 | 8 | A resume asks the agent to reopen `OPENRIG_RESUME_REF` in the launch's working folder, and records that conversation. The agent's own not-found becomes `error: "missing"`. Any other failure is neither missing nor resumed. No case starts a new conversation |
| 7 | 4 | An agent that dies mid-turn gets `Stop/<preset>_exited`, and no idle report for that turn. Posts are taken in the runner's own order, as OpenRig orders them (`occurredAt` when it's a time string, otherwise the post's arrival, then `sequence` when both are positive whole numbers), so an idle report from before the turn began doesn't count, however late it arrives |
| 7 | 4 | An agent that dies mid-turn gets `Stop/<preset>_exited`, and no idle report after it |
| 7b | 4 | A failed `session/prompt` posts `Notification/runtime_error`, not a permission request |
| 7c | 9 | **Advisory.** Closing the pane ends the agent process. Section 9 marks this duty as proposed, and lets a runner list in `limits` what may keep running, so this result is shown as met or not met and never counts as a pass or a fail, or toward the exit status. The fake agent outlives a hangup here, so the runner has to end it |
| 8 | 7 | An unknown request from the agent gets JSON-RPC `-32601`, and the next prompt still goes through |
@@ -133,8 +133,8 @@ function askState(h, entry) {
const script = h.asks.script;
if (entry.withdrawn) return script.late ?? { state: "withdrawn" };
if (script.answer && Date.now() >= entry.created + (script.answer.afterMs ?? 0)) {
const { state, optionId, reason, said } = script.answer;
return { state, ...(optionId ? { optionId } : {}), ...(reason ? { reason } : {}), ...(said ? { said } : {}) };
const { state, optionId, reason } = script.answer;
return { state, ...(optionId ? { optionId } : {}), ...(reason ? { reason } : {}) };
}
return { state: "pending" };
}
@@ -242,19 +242,6 @@ async function launch(label, { scenario = {}, env = {}, asks } = {}) {
// An ordinary send arrives inside an envelope, so a line in it that reads /abort is still just text.
const message = (h, body) => `From: checker@runner-check\nTo: ${h.session}\n---\n${body}`;
const nonExitStops = (h, after = 0) => h.hooks("Stop").filter((p) => p.at >= after && !String(p.body?.subtype ?? "").endsWith("_exited"));
// Whether post `a` comes before post `b` in the runner's own order, as OpenRig orders them: by `occurredAt` when it's a
// non-blank string that reads as a time, otherwise by its arrival; then by `sequence`, when both carry a positive whole number.
const postedAt = (p) => {
const value = p?.body?.occurredAt;
const t = typeof value === "string" && value.trim() ? Date.parse(value) : NaN;
return Number.isFinite(t) ? t : p?.at ?? 0;
};
const sequenceOf = (p) => { const value = p?.body?.sequence; return Number.isSafeInteger(value) && value > 0 ? value : null; };
const precedes = (a, b) => postedAt(a) !== postedAt(b) ? postedAt(a) < postedAt(b)
: sequenceOf(a) !== null && sequenceOf(b) !== null && sequenceOf(a) < sequenceOf(b);
// Non-exit Stops the runner posted after `post` in that order. An idle report from before the turn began (a runner may report
// idle at startup) is never one made during it, however late its POST arrives.
const stopsAfter = (h, post) => nonExitStops(h).filter((p) => !post || !precedes(p, post));
const ready = (h, ms) => waitFor(() => h.record()?.nativeRef && h.record(), ms);
function result(id, title, { proposed = false } = {}) {
@@ -377,65 +364,6 @@ const cases = {
return r;
},
// The optional working beat (section 4). When a runner posts active/liveness in a quiet turn, each beat names the open
// turn it belongs to, keeps the post order, comes often enough for its own lifetime, stops at the turn's end, and never
// runs while a person is asked; after an answer, active/permission_resolved comes before the next beat. Order is read
// from the runner's own sequence and times, so a slow delivery never reads as a broken runner.
async "3b"() {
const r = result("3b", "Working beat (optional): beats name their open turn, keep order, and stop at its end and while a person is asked");
const quiet = scale(22000);
const seqOf = (p) => Number(p?.body?.sequence);
const at = (p) => Date.parse(p.body.occurredAt);
const h = await launch("3b", { scenario: { turns: [{ silent: true, holdMs: quiet }, {}] } });
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
h.paste(message(h, "a long, quiet turn"));
const accepted = await waitFor(() => h.hooks("UserPromptSubmit", "accepted")[0]);
const end = await waitFor(() => h.hooks("Stop", "end_turn")[0], quiet + opts.waitMs);
await sleep(scale(11000)); // room for a beat a runner wrongly sends after the Stop
const beats = h.hooks("active", "liveness");
if (!beats.length) return r.skip("the runner sends no working beat in a quiet turn; the beat is optional (section 4)");
r.check("each beat names the turn it belongs to", accepted && beats.every((b) => b.body.turnId === accepted.body.turnId),
`turnIds: ${beats.map((b) => b.body.turnId).join(", ")}`);
// In the runner's own time order, not delivery's: a post delivered late still has to fit where the runner sent it.
const byTime = (a, b) => at(a) - at(b) || seqOf(a) - seqOf(b);
const timed = h.posts().slice().sort(byTime);
r.check("sequence rises with the runner's own time across every post, beats included",
timed.every((p, i) => Number.isFinite(seqOf(p)) && Number.isFinite(at(p)) && (i === 0 || seqOf(p) > seqOf(timed[i - 1]))),
`sequences in occurredAt order: ${timed.map(seqOf).join(", ")}`);
const life = (b) => (Number.isFinite(b.body.livenessSeconds) ? b.body.livenessSeconds : 30);
const inTurn = beats.filter((b) => !end || seqOf(b) < seqOf(end)).sort(byTime);
const gaps = inTurn.map((b, i) => at(b) - at(i === 0 ? accepted : inTurn[i - 1]));
r.check("beats come more often than every livenessSeconds / 2", inTurn.every((b, i) => gaps[i] <= life(b) * 500 + 1000),
`gaps by the runner's own times: ${gaps.join(", ")} ms`);
r.check("no beat after the turn's Stop", end && beats.every((b) => seqOf(b) < seqOf(end)),
`beats after Stop/end_turn: ${beats.filter((b) => end && seqOf(b) > seqOf(end)).length}`);
// A person is asked first, and answers after a while; the agent then carries on, quietly, in the same turn.
const p = await launch("3b-asked", { scenario: { turns: [{ permission: true, silent: true, holdMs: quiet }, {}] },
asks: { answer: { afterMs: scale(12000), state: "answered", optionId: "allow-once" } } });
if (!r.check("asked: the runner opens its conversation", await ready(p), "no launch record")) return r;
p.paste(message(p, "edit the notes"));
const submitted = await waitFor(() => p.permissionCalls("POST").find((c) => c.path === "/api/activity/permission-requests"), scale(5000));
if (!r.check("asked: the request is submitted", submitted, "no submit")) return r;
await waitFor(() => p.hooks("Stop", "end_turn")[0], scale(12000) + quiet + opts.waitMs);
const resolvedPost = p.hooks("active", "permission_resolved")[0];
const before = p.hooks("active", "liveness").filter((b) => at(b) > submitted.at && (!resolvedPost || seqOf(b) < seqOf(resolvedPost)));
r.check("asked: no beat while the ask waits on a person", before.length === 0, `${before.length} beats while it waited`);
r.check("asked: after the answer, active/permission_resolved names the turn and the ask, before any beat",
resolvedPost && resolvedPost.body.askId && resolvedPost.body.turnId === p.hooks("UserPromptSubmit", "accepted")[0]?.body.turnId,
resolvedPost ? `permission_resolved: ${JSON.stringify({ turnId: resolvedPost.body.turnId, askId: resolvedPost.body.askId })}` : "no permission_resolved");
// OpenRig permits at submit when no one can be asked: no ask opens, so nothing waits and the beats go on.
const q = await launch("3b-permitted", { scenario: { turns: [{ permission: true, silent: true, holdMs: quiet }, {}] },
asks: { submit: { permitted: true } } });
if (!r.check("permitted at submit: the runner opens its conversation", await ready(q), "no launch record")) return r;
q.paste(message(q, "edit the notes"));
const qEnd = await waitFor(() => q.hooks("Stop", "end_turn")[0], quiet + opts.waitMs);
const qBeats = q.hooks("active", "liveness").filter((b) => !qEnd || seqOf(b) < seqOf(qEnd));
r.check("permitted at submit: the quiet turn's beats go on, with no ask to resolve", qBeats.length > 0, "no beat in the quiet turn after the permit");
return r;
},
// Section 7's default is permit: when no one can be asked, the agent gets its own allow option at once, allow_once before
// allow_always. Only the seat's opt-in refusal (denied_by_seat), or a request with no allow option, is refused at once,
// shown, and held as needs input until the next prompt, even when the turn is cancelled.
@@ -521,8 +449,7 @@ const cases = {
const submitted = await waitFor(() => submits(h)[0], scale(3000));
if (!r.check(`${label}: the request is submitted to /api/activity/permission-requests`, submitted, "no submit")) continue;
const asked = h.sent("session/request_permission")[0];
// The submit isn't one of the runner's ordered activity posts, so it can arrive before the accepted prompt does.
const accepted = await waitFor(() => h.hooks("UserPromptSubmit", "accepted")[0], scale(3000));
const accepted = h.hooks("UserPromptSubmit", "accepted")[0];
const sent = submitted.body ?? {};
if (label === "allow") {
r.check("the submit passes OpenRig's checks", h.asks.submitted[0]?.problems.length === 0, h.asks.submitted[0]?.problems.join("; "));
@@ -559,8 +486,6 @@ const cases = {
const outcomes = [
["permitted at submit", { submit: { permitted: true } }, "allow-once"],
["permitted at read", { answer: { afterMs: 300, state: "permitted", optionId: "allow-once" } }, "allow-once"],
// A person's own words come back beside the outcome, and never change the option delivered.
["permitted at read, with the person's words", { answer: { afterMs: 300, state: "permitted", optionId: "allow-once", said: "only the notes file" } }, "allow-once"],
["duplicate_request", { submit: { status: 409, code: "duplicate_request" } }, "allow-once"],
["activity_grant_pending", { submit: { status: 409, code: "activity_grant_pending" } }, "allow-once"],
["activity_grant_revoked", { submit: { status: 401, code: "activity_grant_revoked" } }, "allow-once"],
@@ -605,8 +530,7 @@ const cases = {
await sleep(scale(1500));
r.check("pending: the runner keeps reading the ask", h.permissionCalls("GET").length >= 1, "no read of the ask");
r.check("pending: nothing is delivered while no one has answered", answers(h).length === 0, `got ${JSON.stringify(outcome(h))}`);
const turn = h.hooks("UserPromptSubmit", "accepted").at(-1);
r.check("pending: no idle Stop while it waits", stopsAfter(h, turn).length === 0, stopsAfter(h, turn).map((p) => p.body.subtype).join(", "));
r.check("pending: no idle Stop while it waits", nonExitStops(h).length === 0, nonExitStops(h).map((p) => p.body.subtype).join(", "));
h.paste(message(h, "allow it"));
await sleep(scale(800));
r.check("pending: a pasted message isn't an answer", answers(h).length === 0, `got ${JSON.stringify(outcome(h))}`);
@@ -649,22 +573,6 @@ const cases = {
return r;
},
// Section 7: a person who answers in their own words gets them passed on. OpenRig carries them as the read's `said`, and
// a runner queues them for the agent like any other prompt, after the turn, never as the answer. A runner that drops
// them still keeps the contract, so the result is advisory.
async "4c"() {
const r = result("4c", "The person's words: a read's said is queued to the agent as a prompt (advisory)", { proposed: true });
const said = "Only the notes file, please. Leave the rest alone.";
const h = await launch("4c", { scenario: { turns: [{ permission: true }, {}] },
asks: { answer: { afterMs: 300, state: "permitted", optionId: "allow-once", said } } });
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
h.paste(message(h, "edit the notes"));
if (!r.check("the first turn ends", await waitFor(() => h.hooks("Stop", "end_turn")[0], scale(6000)), "no Stop/end_turn")) return r;
const later = await waitFor(() => h.prompts().slice(1).find((prompt) => prompt?.includes(said)), scale(4000));
r.check("the person's words are queued to the agent as a prompt, after the turn", later, `prompts: ${JSON.stringify(h.prompts())}`);
return r;
},
async "5"() {
const r = result("5", "Generation: every post carries the launch's occupant generation");
const h = await launch("5");
@@ -722,10 +630,7 @@ const cases = {
if (!r.check("the agent exits mid-turn", died, "the agent didn't exit")) return r;
const exited = await waitFor(() => h.hooks("Stop", `${opts.name}_exited`)[0], scale(4000));
r.check(`Stop/${opts.name}_exited is posted`, exited, `posts: ${h.posts().map((p) => `${p.body?.hookEvent}/${p.body?.subtype}`).join(", ")}`);
// Any idle report for the turn the agent died in counts, before or after the exit; one from before the turn doesn't.
const turn = h.hooks("UserPromptSubmit", "accepted")[0];
const idle = turn ? stopsAfter(h, turn) : nonExitStops(h, died.at);
r.check("no idle report for the turn it died in", idle.length === 0, idle.map((p) => p.body.subtype).join(", "));
r.check("no idle Stop after the exit", nonExitStops(h, died.at).length === 0, nonExitStops(h, died.at).map((p) => p.body.subtype).join(", "));
return r;
},
@@ -781,7 +686,7 @@ const cases = {
},
};
const order = ["1", "1b", "2", "3", "3b", "4", "4b", "4c", "5", "6", "7", "7b", "7c", "8", "9"];
const order = ["1", "1b", "2", "3", "4", "4b", "5", "6", "7", "7b", "7c", "8", "9"];
const selected = opts.cases ?? order;
const unknown = selected.filter((id) => !order.includes(id));
if (unknown.length) { console.error(`unknown case ${unknown.join(", ")}; cases are ${order.join(", ")}`); process.exit(2); }
@@ -16,7 +16,6 @@
// stubborn keep running after a hangup or after input closes; only SIGTERM or SIGKILL ends it
// turns one entry per `session/prompt`, in order (the last one repeats):
// holdMs keep the turn open this long; `session/cancel` ends it early as "cancelled"
// silent send no message chunk for this turn, so a held turn is entirely quiet (a long tool or model call)
// ignoreCancel on `session/cancel`, keep the turn open and end it with stopReason (default end_turn) anyway
// permission ask `session/request_permission` first, then carry on whatever the answer. As an object:
// options the options offered (default: allow-once, allow_once; reject-once, reject_once)
@@ -66,7 +65,7 @@ async function prompt(id, params) {
turn += 1;
if (spec.exit) exit(3, `turn ${turn}`);
if (spec.error) { fail(id, -32603, "fake agent: this turn fails"); return; }
if (!spec.silent) send({ jsonrpc: "2.0", method: "session/update",
send({ jsonrpc: "2.0", method: "session/update",
params: { sessionId: params.sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: `turn ${turn}` } } } });
let stopReason = spec.stopReason ?? "end_turn";
if (spec.permission) {
+3 -22
View File
@@ -144,23 +144,6 @@ changes either. `rig scope` reads an `established` or `canonical` slice whose
`verified:` is missing, has no source, names a tool-scaffolded placeholder, or is
older than 90 days as `provisional`.
A slice's frontmatter may also carry `status:`. `GET /api/slices` reports the
bucket each word falls in as the slice's `status`, and its
`?filter=all|active|done|blocked` filters on that bucket:
| `status:` word | Bucket |
| --- | --- |
| `active`, `in-flight`, `ratified` | `active` |
| `draft`, `draft-pending-orch-ratification`, `placeholder` | `draft` |
| `done`, `shipped`, `promoted`, `closed` | `done` |
| `blocked`, `parked-with-evidence` | `blocked` |
Words are matched ignoring case. Any other word is matched by what it contains:
`done`, `ship` or `close` makes it `done`; `block` or `park`, `blocked`; `draft`
or `pending`, `draft`. A word that matches none of these counts as `active`, so a
slice marked `complete` counts as `active`. A slice with no `status:` counts as
`draft`.
When the owning scope selects `proofPolicy.judges`, use `rig proof judge` to
record an attributed accept, reject or withdraw decision on a contract item,
and `rig proof show` to read derived readiness up through slice, mission and
@@ -195,11 +178,9 @@ missions whose `mission.yaml` carries `metadata.status: active` or
a mission does not change the project's readiness. A `planned` mission stays
outside the rollup, even after its slices are accepted, until someone changes
its status to `active` (or `release-candidate` for a release that is ready to
judge). Nothing changes the status automatically. A mission can also carry
`metadata.status: complete`, which only `POST /api/missions/:missionId/complete`
writes (no `rig` verb does); a `complete` mission is outside the rollup too.
Missions created before this default carry no status and are not counted either;
add the line by hand to include them. It also writes `metadata.name`, the mission's folder name, which
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.
+6 -31
View File
@@ -10,7 +10,7 @@ type: `openrig-urgent`, `urgent-only` and `sends-only`. Sibling seats keep their
| `off` (default) | Existing delivery and prompt checks | Existing send result |
| `draft-aware` | Paste into a recognized empty composer; check ownership again before Enter | Hold input and retain this attempt for inspection when bookkeeping is available |
| `openrig-urgent` | OpenRig's own messages (watchdogs, scheduled wakes, notices) and anything marked urgent type, never over a draft | Other agents' messages are held, the sender is told, and held messages stay readable |
| `urgent-only` | Only messages marked urgent type (`rig send --urgent`, `urgent: true` on `POST /api/transport/send`, or a queue item at priority urgent or critical), never over a draft | Everything else is held, the sender is told, and held messages stay readable |
| `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 |
@@ -111,18 +111,8 @@ their existing behaviour.
### Terminal delivery
`rig seat set-typing-guard` works from an ordinary terminal as well as from a
seat's shell. The daemon takes the recorded actor from transport identity only:
- From a managed seat's shell (`OPENRIG_SESSION_NAME` set), the preference and
its audit entry name that seat.
- From a terminal with no seat identity, the change applies the same way, and
the preference and its audit entry record the actor as
`unattributed (no seat identity)`.
Neither case takes an actor from the request body or from the target seat's name.
`rig seat retire-held-message` still records the seat that retired a message, so
run it from a seat's shell.
Run from a seat shell with `OPENRIG_SESSION_NAME` set; the daemon derives the
audit actor from transport identity.
~~~sh
rig seat set-typing-guard dev-impl@my-rig --mode draft-aware \
@@ -141,21 +131,8 @@ to finish. The API may return HTTP 202 with `pending: true`; wait for
and effective modes and retains the text `Typing guard: on (hold)` for hold.
A held send immediately returns `outcome: "retained"`, `sent: false`, its exact
reason and the saved attempt's `outboxIds`. No automatic
retry is scheduled. The reasons are:
- `typing_guard_enabled`: the seat's mode holds this send (`hold`, or a level that
holds this kind of message).
- `runtime_switch_in_progress`: the seat is switching runtime.
- In `draft-aware` mode, a refusal before any input was written:
`draft_input_busy` (the input holds unsubmitted text), `draft_input_unknown` (the
input can't be read), `draft_input_changed` (the input or its target changed during
the delivery), `draft_wake_superseded`, `target_needs_input`,
`wait_for_idle_timeout`, `target_activity_unknown`, `target_runtime_unverified`,
`target_runtime_not_running`, `target_runtime_conflict`, `invalid_wait_for_idle`,
`invalid_dangerously_interact`, `dangerously_interact_requires_reason`,
`prompt_override_audit_unavailable`, `transport_unavailable`, `session_missing` or
`tmux_unavailable`. Each explicit draft-aware request is a new attempt, including
reason (such as `draft_input_busy`) and the saved attempt's `outboxIds`. No automatic
retry is scheduled. Each explicit draft-aware request is a new attempt, including
one with a reused `deliveryId`; the result warns older callers that this field no
longer deduplicates input. The CLI does not generate a deduplication key.
@@ -253,9 +230,7 @@ refuses Enter and is not replayed. Unsupported provider layouts remain held.
Use off when that tradeoff is unsuitable.
HTTP configuration uses `POST /api/seat/set-typing-guard/:seatRef` with
`{mode, reason}` or `{enabled, reason}`. The `X-OpenRig-Session` header, when
present, is the recorded actor; without it the change is recorded as
`unattributed (no seat identity)`. Status and held-message routes are
`{mode, reason}` or `{enabled, reason}`. Status and held-message routes are
unchanged; no RigSpec field is required.
## Verification scope
+5 -55
View File
@@ -99,24 +99,8 @@ So after installing, compare the granted scopes Slack shows for the app with all
`rig slack manifest --json` lists.
The app subscribes to messages in public channels it is a member of (`message.channels`), to
mentions of the app (`app_mention`), and to emoji reactions (`reaction_added`). The manifest does
not request direct-message or private-channel access.
### Using a private channel
The connector doesn't check whether a channel is public or private, so a private channel can be
the default channel or a mapped one once the app can read it. OpenRig doesn't test this setup. In
the app's settings:
1. Under **OAuth & Permissions**, add the bot scopes `groups:history` (read messages in private
channels the app is in) and `groups:read` (read a private channel's details; `rig slack verify`
needs it to confirm the app is a member).
2. Under **Event Subscriptions**, subscribe to the bot event `message.groups`.
3. Reinstall the app to the workspace, then invite it to the private channel.
4. To have `rig slack verify` require the new scopes too, run
`rig slack setup --required-scopes chat:write,channels:history,channels:read,groups:history,groups:read`.
Direct messages are not supported.
mentions of the app (`app_mention`), and to emoji reactions (`reaction_added`). It does not request
direct-message or private-channel access.
A reaction added to an ask reaches the agent that asked, as a note naming who reacted and with
which emoji; the agent decides what it means. Every message OpenRig posted for the ask counts:
@@ -182,9 +166,6 @@ rig slack channel-map list
rig slack channel-map remove pr@my-rig
```
A client can offer the channels to choose from with `GET /api/gateway/slack/channels`, which lists the public
channels the bot token can see, with their ids ([Writing a daemon client](daemon-http-clients.md#route-details)).
- **Which channel an item posts to.** The item's seat is matched exactly first (`pr@my-rig`),
then its rig (`my-rig`). Anything unmapped posts to the default channel, so a config
without a map behaves exactly as before. Aggregate delivery digests have no single seat and
@@ -226,7 +207,7 @@ channels the bot token can see, with their ids ([Writing a daemon client](daemon
their routing also keeps the conversations separate: new messages in a seat's own channel
still go to the inbound destination unless its entry names `--inbound`. A conversation shared
on purpose is fine.
- Missed-message recovery (below) covers the default channel and every mapped channel, except a channel relayed to another machine.
- Missed-message recovery (below) covers the default channel only.
Invite the app to every mapped channel (`/invite @OpenRig`). `rig slack verify` checks
membership in the default channel and every mapped channel, and reports NOT ready until the
@@ -240,9 +221,8 @@ configuration matches the file.
The map is stored as `channelMap` in `slack-connector.json`, as a list of `{ "match", "channel" }`
entries, each with an optional `"inbound"` seat. Saving refuses any other field. A field written by a newer OpenRig is ignored when the
connector reads the file, and `rig slack status` names it. A private channel can be a target once
the app has the private-channel scopes ([Using a private channel](#using-a-private-channel)).
Direct messages are not supported targets.
connector reads the file, and `rig slack status` names it. Private channels and direct messages
are not supported targets.
## Named bots (optional)
@@ -386,36 +366,6 @@ Quiet hours hold a person's routine asks while they're on, and post them once th
## Reconnect recovery and status
When a posted ask is answered, closed, canceled or replaced elsewhere, the connector edits its
original primary message with the status, time, actor and closure note. Buttons become option
text and the answer hint disappears; the remaining content and reconciliation marker stay as
posted. Supplemental messages are unchanged. This adds no post or reaction and needs no agent
turn. Separate resolution notices retain their existing behavior.
A deliberately reopened direct ask says **Open again**; its options remain as text. If it has
no committed answer, it tells the person to reply in its thread. Otherwise it says **Earlier
answer retained**: reopening does not accept a second answer. Earlier replaced parks remain
historical; **Asked again** does not claim that the later ask has been posted. Edits reconcile
the current state again after awaited Slack calls, so an obsolete close is not recorded as the
current result after a reopen.
Edits follow committed queue changes, with a startup catch-up for posted asks on retained queue
rows. Each primary costs one exact-message read and one `chat.update`. The serial edit worker
uses the recovery budget helpers (four calls, 100 entries, 15 seconds) and waits for the next
budget window when exhausted. Slack's per-method `Retry-After` is retained across restarts.
Only an acknowledged edit gets a success receipt. `rig slack status` shows queued reconciliation
and the last edit failure. Transient failures retry with increasing waits, capped at an hour,
behind other queued work. Permanent Slack failures (such as missing scope, a deleted original,
an archived channel or an uneditable message) remain unfinished and are retried only on the next
connector start. They do not keep consuming edit capacity. These edit calls have their own
budget, separate from missed-message scanning below.
The baseline already includes public-channel history access and `chat:write`. Private-channel
reads may require `groups:history`, which the baseline does not grant; a refused read never
counts as an edit. An unavailable/deleted original cannot be replaced by a new post. Startup
catch-up cannot edit rows already removed by queue retention or posts without their original
notification receipt and channel stamp.
The daemon scans available top-level messages and replies broadcast with **Also send to
channel** in the channels this machine owns: its default channel and its channel map's, except a
channel it relays to another machine. It also scans replies in the threads it tracks in those
+5 -15
View File
@@ -300,23 +300,13 @@ small presentation provenance, separate from queue membership. Herdr display
labels are not that provenance. Other providers/platforms remain unsupported
for ensure-viewer until qualified; ordinary open retains its existing behavior.
A writable tile attaches with `tmux attach -f ignore-size` when its session
already has a client that sizes it, so the tile doesn't fight that client over
the agent window's dimensions. When the session has no sizing client, as with a
freshly launched seat, the tile attaches without the flag and sizes the window
itself: tmux applies ignore-size across the whole server, so a session viewed
only by ignore-size clients would otherwise keep its 80x24 creation size while
any other session has a sizing client. Read-only clients keep `-r` (which also
implies ignore-size), and the printed manual attach commands keep
`-f ignore-size`. This is **not resize isolation**: opening a tile can change
the agent's terminal size. Observers of a Herdr terminal do not themselves resize it;
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.
To type into a seat without changing its size, send the text through OpenRig
(`rig send`, or `POST /api/transport/send`): it is written to the pane with tmux
buffers and keys, and no client attaches. There is no supported way to attach a
writable client that is sure never to resize the agent's window.
## Following a slice or mission's current work
-13
View File
@@ -62,16 +62,3 @@ export function writeTextAtomically(path: string, content: string, label = "file
if (owned) try { unlinkSync(temporary); } catch { /* Renamed or already removed. */ }
}
}
/** Create a file, failing with EEXIST rather than replacing anything already at the path, even a dangling link. */
export function writeNewText(path: string, content: string): void {
const fd = openSync(path, "wx", 0o666);
try {
writeFileSync(fd, content, "utf-8");
} catch (error) {
try { unlinkSync(path); } catch { /* Report the write failure. */ }
throw error;
} finally {
closeSync(fd);
}
}
+1 -2
View File
@@ -227,8 +227,7 @@ function renderBroadcastResults(data: Record<string, unknown>): void {
if (r.ok) {
console.log(`${r.sessionName}: sent`);
} else {
// A target that resolved to no sessions has no name to report.
console.log(`${r.sessionName ? `${r.sessionName}: ` : ""}FAILED — ${r.error ?? "unknown error"}`);
console.log(`${r.sessionName}: FAILED — ${r.error ?? "unknown error"}`);
}
if (typeof r.managedCompaction?.notice === "string") console.log(`Notice: ${r.managedCompaction.notice}`);
}
-1
View File
@@ -69,7 +69,6 @@ Keys:
topology.root the topology tree root (instance altitude at its top; default $OPENRIG_HOME/topology)
context.root addressable context library for 'rig context add' (default $OPENRIG_HOME/context)
context.system_world default System World, replacement manifest path, or disabled
specs.builtins_visible show built-ins in spec inventory (default true)
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)
-6
View File
@@ -88,8 +88,6 @@ interface TeardownResult {
keptSkills?: string[];
/** Managed guidance blocks a person edited, kept in their file as `<file>: <block id>`. */
keptGuidance?: string[];
/** Files in a Claude seat's cwd that keep OpenRig's additions because down couldn't safely change them, as `<file>: <why>`. */
keptFiles?: string[];
/** Seats joined with `rig attach` as an external CLI, which OpenRig doesn't stop. */
leftRunning?: string[];
paneOnly?: string[];
@@ -297,10 +295,6 @@ the command warns and exits without stopping; re-run with --yes to confirm expli
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.keptFiles && result.keptFiles.length > 0) {
console.log(`Kept ${result.keptFiles.length} file(s) that still hold OpenRig's additions; check them by hand:`);
for (const file of result.keptFiles) console.log(` ${file}`);
}
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.`);
+2 -4
View File
@@ -128,10 +128,8 @@ export function environmentCommand(override?: EnvironmentCommandDeps): Command {
.action(action(async (binding: string, opts: { live?: boolean }) => report(checked(await (await client()).get<Record<string, any>>(`/api/environments/${encodeURIComponent(binding)}${opts.live ? "?live=true" : ""}`)))));
cmd.command("connect").argument("<binding>").requiredOption("--generation <id>").description("Open only the bound execution's viewer; closing it does not prove native or compute stopped")
.action(action(async (id: string, opts: { generation: string }) => {
// Launch and the guest latch already verify all projected files. Opening a viewer needs only one live
// resource lookup (the driver bounds it at 30s), not another serial validation of every startup file.
const c = await client(), b = checked(await c.get<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}?live=true`, { timeoutMs: 35_000 }));
if (b.generation !== opts.generation) throw new Error("The selected environment generation changed; no viewer was opened");
const c = await client(), b = checked(await c.get<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}?live=true`));
checked(await c.post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/validate`, { generation: opts.generation }));
const args = ["--gateway", b.placement.gateway, "--workspace", b.placement.workspace, "sandbox", "connect", b.placement.name];
await new Promise<void>((resolveExit, reject) => {
const child = spawn("openshell", args, { stdio: "inherit" });
+5 -26
View File
@@ -1,7 +1,7 @@
import nodePath from "node:path";
import fs from "node:fs";
import { Command } from "commander";
import { writeNewText, writeTextAtomically } from "../atomic-text-write.js";
import { writeTextAtomically } from "../atomic-text-write.js";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
@@ -10,8 +10,6 @@ import { rigIdForHandle } from "../rig-handle.js";
export interface ExportDeps extends StatusDeps {
writeFile: (path: string, content: string) => void;
/** Must fail with EEXIST when the path is taken. */
writeNewFile?: (path: string, content: string) => void;
exists?: (path: string) => boolean;
}
@@ -39,35 +37,18 @@ function copyCulture(file: string, root: string, outDir: string): void {
console.log(`Copied culture to ${target}`);
}
/** rig.yaml is also the name of an authored spec, and an export doesn't carry all of one (comments, a summary, startup
* it couldn't read back). So a default export takes the first free name, created so that it can't replace a file that
* appeared after the daemon was asked. Only a path the person named with -o may replace a file. */
function writeDefaultExport(write: (path: string, content: string) => void, first: string, content: string): string {
for (let attempt = 1; ; attempt++) {
const path = attempt === 1 ? first : attempt === 2 ? "rig-export.yaml" : `rig-export-${attempt - 1}.yaml`;
try {
write(path, content);
if (attempt > 1) console.warn(`${first} already exists and was left unchanged. Use -o ${first} to replace it.`);
return path;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
}
}
export function exportCommand(depsOverride?: ExportDeps): Command {
const cmd = new Command("export").description("Export a rig spec as YAML");
const getDeps = (): ExportDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
writeFile: (p, content) => writeTextAtomically(p, content, "export"),
writeNewFile: writeNewText,
exists: (p) => fs.existsSync(p),
};
cmd
.argument("<rig>", "Rig name or ID to export")
.option("-o, --output <path>", "Output file path, replacing any file there. Without it, an existing rig.yaml is kept and the export goes to an unused rig-export*.yaml", "rig.yaml")
.option("-o, --output <path>", "Output file path", "rig.yaml")
.action(async (rigId: string, opts: { output: string }) => {
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
@@ -86,10 +67,8 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
console.error(`Export failed: ${res.data}`);
process.exitCode = 1;
} else {
const written = cmd.getOptionValueSource("output") === "default"
? writeDefaultExport(deps.writeNewFile ?? deps.writeFile, opts.output, res.data)
: (deps.writeFile(opts.output, res.data), opts.output);
console.log(`Exported to ${written}`);
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));
if (line.startsWith("# Member startup not carried for ")) console.warn(`Warning: ${line.slice(2)}`);
@@ -113,7 +92,7 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
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 ${written}; changing the bundle name alone does not rename the installed rig.`);
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;
+13 -13
View File
@@ -173,9 +173,9 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
.command("add <entityId>")
.description("Add a human fragment (verb-add-only; the fragment is truth, the registry is a GENERATED projection)")
.requiredOption("--display-name <name>", "Human-readable display name")
.option(
.requiredOption(
"--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>",
"Connector binding (repeatable); leave it out for a person with no connector, who's reached in the Feed and Steering only. With bindings, role primary|secondary, EXACTLY ONE primary. connectorRef and secretsRef are config/vault labels (e.g. for Slack: slack:openrig-personal:file:<env-path>:primary:handle=<id>; tokens come from 'rig slack setup --secrets-env-file', never inline). Optional handle=<platform id> makes the binding inbound-resolvable (unique per kind); omit it for outbound-only.",
"Connector binding (repeatable); role primary|secondary, EXACTLY ONE primary. connectorRef and secretsRef are config/vault labels (e.g. for Slack: slack:openrig-personal:file:<env-path>:primary:handle=<id>; tokens come from 'rig slack setup --secrets-env-file', never inline). Optional handle=<platform id> makes the binding inbound-resolvable (unique per kind); omit it for outbound-only.",
(v: string, acc: string[] = []) => { acc.push(v); return acc; },
)
.requiredOption("--delivery-class <A|B|C|D>", "Notification loudness class (the notifications register selection)")
@@ -188,7 +188,7 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
.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; organization?: string; group?: string[]; policy?: string; policyFile?: string; default?: boolean }) => {
// 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 {
@@ -209,7 +209,7 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
if (unknownGroup) { console.error(`refused: no group "${unknownGroup}" — create it with rig gateway group set ${unknownGroup} --organization … --name …`); process.exitCode = 1; return; }
}
const bindings: Record<string, unknown>[] = [];
for (const spec of opts.binding ?? []) {
for (const spec of opts.binding) {
const b = parseBindingSpec(spec);
if (!b.ok) { console.error(`--binding: ${b.error}`); process.exitCode = 1; return; }
bindings.push(b.binding);
@@ -284,9 +284,8 @@ Example:
if (opts.json) { console.log(JSON.stringify({ ok: true, humans })); 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 || !h.bindings.primary ? "" : " [outbound-only]";
const primary = h.bindings.primary ? `primary ${h.bindings.primary.kind}:${h.bindings.primary.connectorRef}` : "no connector: Feed and Steering only";
console.log(`${h.entityId} "${h.displayName}" class=${h.deliveryClass} ${h.availability} bindings=${h.bindings.count} (${primary}) delivery=${h.deliveryReadiness.state}${inbound}`);
const inbound = h.bindings.inboundResolvable ? "" : " [outbound-only]";
console.log(`${h.entityId} "${h.displayName}" class=${h.deliveryClass} ${h.availability} bindings=${h.bindings.count} (primary ${h.bindings.primary.kind}:${h.bindings.primary.connectorRef}) delivery=${h.deliveryReadiness.state}${inbound}`);
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)}`);
}
@@ -732,16 +731,17 @@ Examples:
.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");
if (opts.json) {
const result = registry.participantsJsonView(undefined, opts.organization);
if (!result.ok) { console.error(`refused: ${result.error}`); process.exitCode = 1; return; }
console.log(JSON.stringify(result));
return;
}
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}` : ""}`);
+9 -35
View File
@@ -24,9 +24,8 @@ import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { hostname as osHostname, userInfo } from "node:os";
import { connect } from "node:net";
import { execFile } from "node:child_process";
import { getOpenRigHome } from "../openrig-compat.js";
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry, type SshHostEntry } from "../host-registry.js";
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry } from "../host-registry.js";
import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executor.js";
import { recordHostObservation, loadHostBindings, describeBindingConflict, defaultHostBindingsPath, type HostObservationOutcome } from "../host-bindings.js";
import { DaemonClient } from "../client.js";
@@ -168,35 +167,15 @@ function defaultDoctorDeps(): DoctorDeps {
};
}
/** Where ssh would connect for this host, from `ssh -G`, which reads ssh config without connecting.
* Null when ssh can't resolve the target or a ProxyJump/ProxyCommand sits in between, since no
* direct dial stands for that transport. */
export function resolveSshEndpoint(host: SshHostEntry, timeoutMs = 1500): Promise<{ hostname: string; port: number } | null> {
const args = [...(host.user ? ["-l", host.user] : []), "-G", host.target];
return new Promise((resolve) => {
execFile("ssh", args, { timeout: timeoutMs }, (error, stdout) => {
if (error) return resolve(null);
const conf = new Map(stdout.split("\n").map((line) => {
const i = line.indexOf(" ");
return [line.slice(0, i).toLowerCase(), line.slice(i + 1).trim()] as const;
}));
const proxied = ["proxyjump", "proxycommand"].some((k) => (conf.get(k) ?? "none") !== "none");
const hostname = conf.get("hostname");
const port = Number(conf.get("port"));
resolve(hostname && Number.isInteger(port) && !proxied ? { hostname, port } : null);
});
});
}
/** FR-1 doctor legs — each failing step maps to a DISTINCT actionable error:
* "SSH works but daemon is down" ≠ "daemon works but registry is wrong" ≠
* "remote rig binary missing/old". */
/** OPR.0.4.6.MH1 FR-3 — the coarse ls status word: a BOUNDED tcp dial
* against the host's transport endpoint (ssh: the host and port ssh itself
* resolves, see resolveSshEndpoint; http: the URL's host:port). Coarse by
* design (the kubectl STATUS / docker context ls precedent): reachable |
* unreachable | unknown — never a hang (hard timeout), never a guess (dial
* failure = unreachable, probe error/unparseable endpoint = unknown). */
* against the host's transport endpoint (ssh: target:22; http: the
* URL's host:port). Coarse by design (the kubectl STATUS / docker
* context ls precedent): reachable | unreachable | unknown — never a
* hang (hard timeout), never a guess (dial failure = unreachable,
* probe error/unparseable endpoint = unknown). */
export async function probeHostStatus(
host: HostEntry,
tcpProbe: DoctorDeps["tcpProbe"],
@@ -206,9 +185,8 @@ export async function probeHostStatus(
let target: string;
let port: number;
if (host.transport === "ssh") {
const endpoint = await resolveSshEndpoint(host, timeoutMs);
if (!endpoint) return "unknown";
({ hostname: target, port } = endpoint);
target = host.target;
port = 22;
} else {
const u = new URL(host.url);
target = u.hostname.replace(/^\[|\]$/g, "");
@@ -582,7 +560,6 @@ function printHostNextSteps(host: HostEntry): void {
export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
const cmd = new Command("host").description("Manage the multi-host registry (~/.openrig/hosts.yaml)");
const rewritesRegistry = "\nIt rewrites ~/.openrig/hosts.yaml from the fields it knows, so comments, formatting, unknown entry keys and other top-level keys aren't kept.";
cmd
.command("add")
@@ -596,7 +573,6 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
.option("--bearer-file <path>", "File PATH holding the bearer token — http transport (optional pointer, never a value; at most one bearer flag)")
.option("--notes <text>", "Free-form operator note")
.option("--json", "JSON output")
.addHelpText("after", rewritesRegistry)
.action((opts: { id: string; transport: string; target?: string; user?: string; url?: string; bearerEnv?: string; bearerFile?: string; notes?: string; json?: boolean }) => {
const rawEntry: Record<string, unknown> = { id: opts.id, transport: opts.transport };
if (opts.target !== undefined) rawEntry["target"] = opts.target;
@@ -727,7 +703,6 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
.option("--url <url>", "Remote daemon base URL — http transport")
.option("--notes <text>", "Free-form operator note (empty string clears the value)")
.option("--json", "JSON output")
.addHelpText("after", rewritesRegistry)
.action((id: string, opts: { target?: string; user?: string; url?: string; notes?: string; json?: boolean }) => {
const changes: Record<string, unknown> = {};
if (opts.target !== undefined) changes["target"] = opts.target;
@@ -763,7 +738,6 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
.description("Remove a host entry (deletes only the registry entry — credential files, the learned identity and the selection stay as they are)")
.argument("<id>", "A registered host id")
.option("--json", "JSON output")
.addHelpText("after", rewritesRegistry)
.action((id: string, opts: { json?: boolean }) => {
const res = removeHostEntry(id.trim());
if (!res.ok) {
@@ -783,7 +757,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
cmd
.command("pair")
.description("Pair with a remote host from one pasted address — one approval on the target, done (FR-6)")
.argument("<url>", "The target daemon's address (http[s]://host:port, or host:port); that daemon must run with OPENRIG_AUTH_BEARER_TOKEN set")
.argument("<url>", "The target daemon's address (http[s]://host:port, or host:port)")
.option("--id <id>", "Registry id for the new host (default: derived from the hostname)")
.option("--timeout <seconds>", "How long to wait for the target-side approval", "600")
.option("--human <address>", "Registered @external approval recipient on the target (required when several exist)")
+1 -1
View File
@@ -42,7 +42,7 @@ export function setExperiment(file: string, enabled: boolean, options: Partial<E
const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW, 0o600);
try {
const s = fs.fstatSync(fd);
if (!s.isFile() || s.nlink !== 1 || (process.getuid !== undefined && s.uid !== process.getuid())) throw Error("experiment config must be an owned regular file");
if (!s.isFile() || s.nlink !== 1 || s.uid !== process.getuid?.()) throw Error("experiment config must be an owned regular file");
fs.ftruncateSync(fd, 0); fs.writeFileSync(fd, JSON.stringify(config, null, 2) + "\n");
} finally { fs.closeSync(fd); }
return config;
+5 -10
View File
@@ -580,16 +580,11 @@ to a 0600 file holding the token>. Only the path is kept, as the seat's referenc
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. For a running seat, 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. The relaunch uses the seat's own effort choice (rig seat set-effort) when there is one; otherwise
it asks to keep the running effort, and uses the seat's saved effort when it can't. The report's effort line says which.
A stopped seat isn't relaunched, and no snapshot is taken: the reference is saved only when the seat's next launch would
resume its conversation, and the report prints that launch (rig launch <rig> <seat> --snapshot-id <id>, or a plain rig launch
when restore's periodic carry resumes it). Otherwise it refuses and nothing changes.
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
+2 -13
View File
@@ -99,8 +99,6 @@ interface NodeEntry {
blockedWorkCount?: number;
/** OPR.0.3.4.11 — held reason from node.held event. */
heldReason?: string | null;
/** #2456: the required startup files this occupant's handover started it without. */
startedWithoutRequired?: string[];
agentActivity?: {
state: "running" | "needs_input" | "idle" | "unknown";
reason: string;
@@ -240,7 +238,6 @@ const ALLOWED_NODE_FIELDS = new Set([
"needsInputEvidence",
"contextUsage",
"heldReason",
"startedWithoutRequired",
"storedSessionStatus",
"identityVerdict",
]);
@@ -708,11 +705,6 @@ export function formatDeclaredModel(model: string | null | undefined): string {
return m.length > 0 ? m : "not-declared";
}
/** #2456: the attention reason for an occupant started without a required startup file. */
function startedWithoutLine(files: string[]): string {
return `missing required ${files.map((file) => file.split("/").pop()).join(", ")}`;
}
export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string, unknown>> {
return nodes.map((n) => {
const keepDiagnostics = n.needsAttention !== false;
@@ -776,7 +768,6 @@ export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string,
}
// held/attention reason (short) when present.
if (n.heldReason) compact.heldReason = n.heldReason;
if (n.startedWithoutRequired?.length) compact.startedWithoutRequired = n.startedWithoutRequired;
if (keepDiagnostics && n.latestError) {
compact.latestError = n.latestError;
}
@@ -935,7 +926,7 @@ intentionallyStopped, startupStatus, restoreOutcome, lifecycleState, tmuxAttachC
resumeCommand, latestError, terminalActive, hasAssignedWork,
assignedWorkCount, pendingWorkCount, inProgressWorkCount, blockedWorkCount,
agentActivity, needsAttention, arbitratedNeedsInput, needsInputEvidence,
contextUsage, heldReason, startedWithoutRequired, storedSessionStatus, identityVerdict.
contextUsage, heldReason, storedSessionStatus, identityVerdict.
--host runs on a remote host declared in ~/.openrig/hosts.yaml (no current-rig
default applied; the remote host's rigs are shown).
@@ -1367,8 +1358,7 @@ async function handleNodes(
console.log(padCompactNodeRow("RIG", "SESSION", "LIFECYCLE", "ACTIVITY", "WORK", "REASON", "ATTN"));
for (const n of humanList as NodeEntry[]) {
const reason = missingSessionGuidance(n) ?? (n.needsAttention !== false || hasCurrentUsageLimit(n)
? (n.latestError ? truncate(n.latestError, 40) : n.startedWithoutRequired?.length ? truncate(startedWithoutLine(n.startedWithoutRequired), 40)
: n.agentActivity?.reason ?? "—")
? (n.latestError ? truncate(n.latestError, 40) : n.agentActivity?.reason ?? "—")
: livenessUnconfirmed(n) ? "liveness unconfirmed" : "—");
console.log(padCompactNodeRow(
n.rigName,
@@ -1405,7 +1395,6 @@ async function handleNodes(
formatContextUsage(n.contextUsage),
n.restoreOutcome,
missingSessionGuidance(n) ?? (n.latestError ? truncate(n.latestError, 30) : n.heldReason ? `held: ${truncate(n.heldReason, 25)}`
: n.startedWithoutRequired?.length ? truncate(startedWithoutLine(n.startedWithoutRequired), 30)
: livenessUnconfirmed(n) ? "liveness unconfirmed" : "—"),
));
}
+4 -40
View File
@@ -356,17 +356,6 @@ function printResult(json: boolean, body: unknown, status: number): void {
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
// Priority is matched exactly where it matters (the founder view, attention ranks), so "Critical" would quietly file
// the row as ordinary work. The daemon stores what it's given, so a documented value is normalized here. Other values
// are kept as given: `high`, `normal` and `background` are in real use, and the mission view ranks some of them.
const QUEUE_PRIORITIES = ["routine", "urgent", "critical"];
function queuePriorityArg(value: string): string {
const lower = value.toLowerCase();
if (QUEUE_PRIORITIES.includes(lower)) return lower;
console.error(`Note: --priority '${value}' is outside the documented choices (${QUEUE_PRIORITIES.join(", ")}); keeping it as given.`);
return value;
}
/** #2276: the daemon's checks of a new row's human-facing fields (missing summary or evidence for a person,
* notification, questions, reply-to). The queue repository raises them before the row's first write, inside the
* create's transaction. A remote host's refusal arrives as remote_queue_write_failed instead, so it isn't one of these. */
@@ -650,18 +639,6 @@ async function printQueueItemResult(
// IMPL-SPEC §2.3-2.4.
const SHOW_BODY_PREVIEW_MAX_CODEPOINTS = 512;
// A misspelled state ("in_progress") would otherwise read as an empty queue, not as a mistake: the list route matches
// names as given. Each name is checked and trimmed the way the route trims it. This warns rather than refuses, so the
// CLI's copy of the list doesn't stop it working with a newer daemon that knows more states.
const QUEUE_STATE_NAMES = ["pending", "in-progress", "done", "blocked", "failed", "denied", "canceled", "handed-off"];
function warnIfUnknownStates(value?: string): void {
const names = (value ?? "").split(",").map((name) => name.trim()).filter(Boolean);
const unknown = names.filter((name) => !QUEUE_STATE_NAMES.includes(name));
if (unknown.length === 0) return;
const effect = unknown.length === names.length ? " Nothing is filed under these names, so an empty result doesn't mean an empty queue." : "";
console.error(`Warning: unrecognized --state '${unknown.join("', '")}'. Supported states: ${QUEUE_STATE_NAMES.join(", ")}.${effect}`);
}
// Commander option parser. InvalidArgumentError (like positiveIntArg/enumArg) is what Commander
// renders as its usual "option ... argument ... is invalid" error; a plain Error would escape parse.
const WAKE_DURATION_FORMAT = "must be a positive integer with an optional s, m or h suffix (for example 90s, 15m, 168h)";
@@ -703,12 +680,6 @@ export function previewBody(
};
}
// Mark the cut inside the body so readers that discard metadata or stderr can request the complete brief.
// Counts use code points, like the preview.
export function previewCutMarker(qitemId: string, shownCodePoints: number, totalCodePoints: number): string {
return `…[cut at ${shownCodePoints} of ${totalCodePoints} chars; rig queue show ${shellQuote(qitemId)} --full]`;
}
function isRecordWithStringBody(v: unknown): v is Record<string, unknown> & { body: string } {
return (
typeof v === "object" &&
@@ -940,7 +911,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--mission <id>", "First-class mission scope; translated to a mission:<id> tag (composes with --tags)")
.option("--slice <id>", "First-class slice scope; translated to a slice:<id> tag (composes with --tags)")
.option("--gate <role>", "OPR.0.4.3.16: mark this as a gate qitem; translated to a gate:<role> tag (role e.g. guard | spec-review | pm-lead | qa | human). The idle-gate watchdog reads this predicate. Composes with --tags.")
.option("--priority <priority>", "Priority: routine | urgent | critical", queuePriorityArg, "routine")
.option("--priority <priority>", "Priority: routine | urgent | critical", "routine")
.option("--tier <tier>", "Tier (e.g. fast, routine, deep, critical) — drives SLA")
.option("--tags <tags>", "Comma-separated tags (composes with --mission and --slice)")
.option("--expires-at <iso>", "ISO timestamp at which the qitem expires")
@@ -1240,7 +1211,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--reopen", "Explicitly acknowledge a deliberate terminal-to-active repair; requires --state and --note")
.option("--closure-reason <reason>", "Required for state=done. --state canceled takes no reason, except 'superseded' (with --closure-target = the successor), which records a supersession, distinct from an abandoned cancel")
.option("--closure-target <target>", "Required for handed_off_to, escalation, and superseded; blocked_on park records use state=blocked with --blocked-on")
.option("--closure-required-at <iso>", "Accept a revised closure deadline on an in-progress item; keeps the claim and records the old/new time and accepting actor")
.option("--resolution <text>", "The outcome, stored on the row and shown by rig queue show. Needs --state done, canceled, denied, failed or handed-off, and non-blank text. Stored when the update closes an open row, after anything the row already has (a fallback line, an earlier outcome); a row that is already closed keeps what it has")
.option("--blocked-on <blocker>", "For state=blocked: the blocker — a qitem id (must exist and be live), a human seat (FR-6 park; requires summary + evidence_ref), or a typed non-qitem gate 'fold:<what>' / 'auth:<what>' / 'external:<what>'")
.option("--wake-watchdog <jobId>", "For state=blocked: attach an existing live watchdog id targeting the row owner")
@@ -1256,7 +1226,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
reopen?: boolean;
closureReason?: string;
closureTarget?: string;
closureRequiredAt?: string;
resolution?: string;
blockedOn?: string;
wakeWatchdog?: string;
@@ -1293,7 +1262,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
reopen: opts.reopen,
closureReason: opts.closureReason,
closureTarget: opts.closureTarget,
...(opts.closureRequiredAt !== undefined ? { closureRequiredAt: opts.closureRequiredAt } : {}),
resolution: opts.resolution,
blockedOn: opts.blockedOn,
wakeWatchdogId: opts.wakeWatchdog,
@@ -1417,7 +1385,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
.option("--body <text>", "New qitem body inline (use - to read from stdin; mutually exclusive with --body-file). Omit both to keep the source body.")
.option("--body-file <path>", "Read the new qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class.")
.option("--note <text>", "Transition note")
.option("--priority <priority>", "Override priority for the new qitem: routine | urgent | critical", queuePriorityArg)
.option("--priority <priority>", "Override priority for the new qitem")
.option("--tier <tier>", "Override tier for the new qitem")
.option("--tags <tags>", "Comma-separated tags for the new qitem")
.option("--gate <role>", "OPR.0.4.3.16: mark the new qitem as gate work; translated to a gate:<role> tag (e.g. guard | spec-review). The idle-gate watchdog reads this predicate. Composes with --tags.")
@@ -1521,7 +1489,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
.option("--body <text>", "New qitem body inline (use - to read from stdin; mutually exclusive with --body-file). Omit both to keep the source body.")
.option("--body-file <path>", "Read the new qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class.")
.option("--note <text>", "Transition note")
.option("--priority <priority>", "Override priority for the new qitem: routine | urgent | critical", queuePriorityArg)
.option("--priority <priority>", "Override priority for the new qitem")
.option("--tier <tier>", "Override tier for the new qitem")
.option("--tags <tags>", "Comma-separated tags for the new qitem")
.option("--gate <role>", "OPR.0.4.3.16: mark the new qitem as gate work; translated to a gate:<role> tag (e.g. guard | spec-review). The idle-gate watchdog reads this predicate. Composes with --tags.")
@@ -1684,10 +1652,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
...(closingTransition === undefined ? [] : [omittedReadField("closingTransition", closingTransition)]),
];
const view = readView(item, fullCommand, omitted);
const shownBody = bodyTruncated
? `${preview}${previewCutMarker(qitemId, Array.from(preview).length, Array.from(item.body).length)}`
: preview;
const transformed = { ...record, body: shownBody, bodyBytes, bodyTruncated, readView: view };
const transformed = { ...record, body: preview, bodyBytes, bodyTruncated, readView: view };
// Slice 15 P2: with output.compact on, the empty fields are left out and counted.
const compact = compactOutputEnabled() ? compactShow(transformed) : null;
printResult(json, compact ? compact.item : transformed, res.status);
@@ -1807,7 +1772,6 @@ Examples:
}
if (opts.destination) params.set("destinationSession", opts.destination);
if (opts.source) params.set("sourceSession", opts.source);
warnIfUnknownStates(opts.state);
if (opts.state) params.set("state", opts.state);
if (opts.targetRepo) params.set("targetRepo", opts.targetRepo);
if (opts.limit) params.set("limit", String(opts.limit));
+2 -36
View File
@@ -213,7 +213,7 @@ function buildSliceLsCommand(): Command {
const cmd = new Command("ls")
.description("List slices in a mission (or across all missions)")
.option("--mission <name>", "Restrict to a single mission")
.option("--state <state>", "Filter: active (in flight, blocked included) | shipped (delivered in place: shipped or done) | closed (in closed/, or superseded, retired or closed in place) | all", "active")
.option("--state <state>", "Filter: active | closed | shipped | all", "active")
.option("--json", "Machine-readable output")
.action(async (opts, command) => {
const out = makeStdout();
@@ -727,13 +727,12 @@ function buildMissionLsCommand(): Command {
id: m.id,
path: m.absPath,
activeSliceCount: m.activeSliceCount,
shippedSliceCount: m.shippedSliceCount,
closedSliceCount: m.closedSliceCount,
}));
emit(out, { ok: true, count: rows.length, missions: rows }, json,
rows.length === 0
? ["(no missions)"]
: rows.map((r) => `${r.name} ${r.id ?? "—"} active=${r.activeSliceCount} shipped=${r.shippedSliceCount} closed=${r.closedSliceCount}`),
: rows.map((r) => `${r.name} ${r.id ?? "—"} active=${r.activeSliceCount} closed=${r.closedSliceCount}`),
);
} catch (err) {
fail(err, json, out);
@@ -765,7 +764,6 @@ function buildMissionShowCommand(): Command {
id: mission.id,
path: mission.absPath,
activeSliceCount: mission.activeSliceCount,
shippedSliceCount: mission.shippedSliceCount,
closedSliceCount: mission.closedSliceCount,
frontmatter: mission.frontmatter,
trust,
@@ -780,7 +778,6 @@ function buildMissionShowCommand(): Command {
out.write(` id: ${mission.id ?? "—"}\n`);
out.write(formatTrustLine(trust));
out.write(` active slices: ${mission.activeSliceCount}\n`);
out.write(` shipped slices: ${mission.shippedSliceCount}\n`);
out.write(` closed slices: ${mission.closedSliceCount}\n`);
out.write(` path: ${mission.absPath}\n`);
if (readme) {
@@ -1103,7 +1100,6 @@ function buildAuditCommand(): Command {
frontmatter: {},
id: null,
activeSliceCount: 0,
shippedSliceCount: 0,
closedSliceCount: 0,
};
const graph = buildMissionDependencyGraph(auditMission);
@@ -1236,8 +1232,6 @@ function buildAuditCommand(): Command {
for (const sr of sliceResults) {
const shadow = shadowedNodeFileFinding(path.join(slicesDir, sr.name), "slice");
if (shadow) sr.result.findings.push(shadow);
const shared = sharedSliceNameFinding(missionsRoot, missionName, sr.name);
if (shared) sr.result.findings.push(shared);
}
const allFindings = [
@@ -2158,34 +2152,6 @@ function shadowedNodeFileFinding(dir: string, level: "mission" | "slice"): {
};
}
/**
* Advisory-only: another mission has a slice folder of the same name. The slice list and the routes that
* find a slice by name keep one slice per name, so the others are hidden there with no sign they exist.
* Twin of the daemon's finding of the same name.
*/
function sharedSliceNameFinding(missionsRoot: string, missionName: string, sliceName: string): {
kind: "slice_name_shared"; severity: "low"; path: string; message: string; remediation: string;
} | null {
let others: string[];
try {
others = fs.readdirSync(missionsRoot, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== missionName
&& fs.statSync(path.join(missionsRoot, e.name, "slices", sliceName), { throwIfNoEntry: false })?.isDirectory() === true)
.map((e) => e.name)
.sort();
} catch {
return null;
}
if (others.length === 0) return null;
return {
kind: "slice_name_shared",
severity: "low",
path: path.join(missionsRoot, missionName, "slices", sliceName),
message: `Mission ${others.join(", ")} also ${others.length === 1 ? "has" : "have"} a slice named "${sliceName}". The slice list (GET /api/slices) and the routes that find a slice by name keep one slice per name, so all but one of these slices are hidden there.`,
remediation: "Rename one of the slice folders so each slice name is unique across missions. Advisory only — nothing is blocked.",
};
}
export function scopeCommand(): Command {
const cmd = new Command("scope")
.description("Scope tree primitive: missions, slices, sub-slices (per conventions/scope-and-versioning)")
+16 -36
View File
@@ -159,31 +159,19 @@ interface SeatHandoverMutationResult {
currentStatus: SeatHandoverPlan["currentStatus"];
handoverAt: string;
eventSeq: number;
/** What the successor was told on taking the seat, such as its held messages. */
warnings?: string[];
/** OPR.0.5.5.5 — per-source execution record (wire mirror of the daemon shape). */
sourceOutcome?:
| { mode: "fork"; forkedFrom: string; forkId?: "captured" | "pending"; limit?: string }
| { mode: "rebuild"; primedArtifacts: Array<{ address: string; label: string }>; gaps: string[]; emptyChainReason?: string; permissions?: { state: string; detail: string } };
/** The restore packet was pasted and Enter pressed, but its submission couldn't be confirmed. */
restorePacket?: { status: "unconfirmed"; reason: string };
/** #2456: the required startup files the successor started without, where a start would have failed. */
startedWithoutRequired?: string[];
sideEffects: {
departingSessionKilled: false;
/** Absent from an older daemon, whose receipt reported both side effects as a fixed false. */
departingProcess?: "ended-graceful" | "ended-forced" | "not-touched";
startupContextDelivered: boolean;
provenanceRecordWritten: boolean;
provenanceRecordWritten: false;
};
}
const DEPARTING_PROCESS_LINES = {
"ended-graceful": "The previous occupant's process exited on TERM; its tmux session was kept, and the successor runs in the same pane.",
"ended-forced": "The previous occupant's process didn't exit on TERM and was killed; its tmux session was kept, and the successor runs in the same pane.",
"not-touched": "The previous occupant's process wasn't touched: the successor was an existing session.",
} as const;
interface SeatSwitchClientResponse {
seat_ref: string;
session: string;
@@ -327,11 +315,7 @@ function printHumanHandoverPlan(plan: SeatHandoverPlan): void {
}
function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
// #2456: a successor that started without a required startup file isn't a plain success; a start would have failed.
const withoutRequired = result.startedWithoutRequired ?? [];
console.log(`Seat handover complete${withoutRequired.length > 0 ? ", degraded" : ""}: ${result.seat.ref}`);
for (const file of withoutRequired) console.log(`The successor started without required file ${file}.`);
if (withoutRequired.length > 0) console.log("rig ps --nodes shows the seat as needing attention until it's next launched.");
console.log(`Seat handover complete: ${result.seat.ref}`);
console.log(`Rig: ${result.seat.rigName}`);
console.log(`Logical ID: ${result.seat.logicalId}`);
console.log(`Source: ${result.source.mode}${result.source.ref ? `:${result.source.ref}` : ""}`);
@@ -341,14 +325,6 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
console.log(`Current occupant: ${result.currentOccupant}`);
console.log(`Handover result: ${display(result.currentStatus.handoverResult)}`);
console.log("Seat binding and inventory provenance were updated.");
for (const warning of result.warnings ?? []) console.warn(`Warning: ${warning}`);
// An older daemon reports both side effects as a fixed false: say nothing rather than misreport them.
if (result.sideEffects.departingProcess) {
console.log(result.sideEffects.provenanceRecordWritten
? "The handover's lineage record (occupant tenure) was written."
: "The handover's lineage record (occupant tenure) was NOT written; check the daemon log, which names the first such failure since the daemon started. The seat is already bound to the successor, so don't run the handover again.");
console.log(DEPARTING_PROCESS_LINES[result.sideEffects.departingProcess]);
}
// OPR.0.5.5.5 — the per-source execution record: what actually carried context.
if (result.sourceOutcome?.mode === "fork") {
console.log(`Native fork of ${result.sourceOutcome.forkedFrom}: the successor carries the incumbent conversation from its first byte.`);
@@ -373,18 +349,18 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
// live successor agent.
console.log("The captured startup context (restore packet) was delivered to the successor.");
}
console.log("No conversation continuity was carried: the successor started a fresh conversation.");
console.log("No conversation continuity, provenance markdown, or session stop was performed.");
} else if (result.restorePacket?.status === "unconfirmed") {
// The packet was pasted and Enter pressed: the successor may already be working on it, so don't send it again.
console.log(`The captured startup context (restore packet) was sent to the successor, but its submission is unconfirmed: ${result.restorePacket.reason}.`);
console.log("Check the successor's screen before sending the context again; it may already be working on it.");
console.log("No conversation continuity was carried: the successor started a fresh conversation.");
console.log("No conversation continuity, provenance markdown, or session stop was performed.");
} else if (result.source.mode === "fork") {
console.log("No packet delivery: fork context rides the native conversation itself.");
} else {
// discovered handover: the operator-prepared successor is already live, so no
// separate context delivery is performed.
console.log("OpenRig delivered no startup context and carried no conversation: the discovered successor was already running.");
console.log("No conversation continuity, startup context delivery, provenance markdown, or session stop was performed.");
}
}
@@ -469,11 +445,6 @@ when the current input contains a draft or cannot be read. It returns the exact
reason and held message ID immediately, without automatic retry.
Off (--enabled false) permits new automatic sends.
This works from an ordinary terminal as well as from a seat's shell. From a
managed seat's shell (OPENRIG_SESSION_NAME set), the change is recorded under
that seat. From a terminal with no seat identity, it applies the same way and is
recorded as "unattributed (no seat identity)".
Between off (always interrupt) and hold (full quiet):
openrig-urgent OpenRig's own messages (watchdogs, scheduled wakes, notices) and
anything marked urgent type; other agents' messages are held.
@@ -1027,8 +998,6 @@ effort, Codex profile and native permission choice are cleared, not mapped; two
processes run until cutover; messages sent to the seat during the switch are
held, not typed, and need sending again afterwards.
Codex and Claude Code, single-pane seats only. --dry-run lists the details.
A seat that has never started has no pane to switch: declare the other runtime for it in its rig spec and bring the team
up again from that spec. That's a new generation of the seat, without this seat's edges, settings or queued work.
--clear-account-home returns to configured member/rig/daemon/native selection; it does not restore historical credentials.
It uses a fresh successor and clears an account-home token reference only after confirmation. Do not combine it with
--account-home or a new token reference. Anthropic's global active/default profile is not isolated by CLAUDE_CONFIG_DIR.
@@ -1141,6 +1110,17 @@ A seat can switch itself (its CLI may end when it retires; the daemon finishes).
console.log(`${name} already runs ${String(data["to"])}. Nothing changed.`);
return;
}
if (data["firstStart"] === true) {
const from = String(data["from"]), to = String(data["to"]);
console.log(data["dryRun"]
? `${name} has never started: its saved runtime would change from ${from} to ${to}, and its first start would use it. Nothing changes until you run it without --dry-run.`
: `${name} has never started, so nothing was switched (no switch id): its saved runtime changed from ${from} to ${to}, and its first start uses it.`);
const permission = data["permission"] as { mode: string; source: string } | undefined;
if (permission) console.log(` permissions: ${permission.source === "explicit" ? `${permission.mode} (as given)` : "the seat's member and rig policy"}`);
const dropped = (data["dropped"] as Array<{ setting: string; value: string }> | undefined) ?? [];
if (dropped.length) console.log(`Cleared from ${from}: ${dropped.map((d) => `${d.setting} ${d.value}`).join("; ")}. Its first start takes the rest from its agent spec for ${to}.`);
return;
}
const label = (source: string) => source === "explicit" ? "as given" : source === "agent_spec" ? "from the seat's agent spec" : "the runtime's default";
const settings = (data["settings"] ?? {}) as Record<string, { value: string | null; source: string }>;
if (data["dryRun"]) console.log(`Runtime switch plan for ${name}: ${String(data["from"])} -> ${String(data["to"])}. Nothing changes until you run it without --dry-run.`);
+15 -42
View File
@@ -1,4 +1,3 @@
import { isHumanSeatSessionRef } from "../session-name.js";
import { Command } from "commander";
import { resolveEffectiveHost } from "../host-selection.js";
import { DaemonClient, DaemonConnectionError, terminalAuthHeaders } from "../client.js";
@@ -91,9 +90,6 @@ export function wrapSendBody(
// renders EXACTLY AS RECEIVED — bare member@rig locally (copy-paste reply hint), and a
// cross-host caller constructs the origin triple at the forwarding boundary before wrapping.
const senderLabel = sender && sender.trim().length > 0 ? sender : SENDER_FALLBACK;
// Only reply classification uses the bare address; From and the destination retain the origin host.
const senderParts = senderLabel.split("@");
const personAddress = senderParts.length === 3 && senderParts[2] ? senderParts.slice(0, 2).join("@") : senderLabel;
const header = [`From: ${senderLabel}`, renderToLine(recipient, meta?.scope)];
if (meta?.stampISO) {
// GHOST-STAGE (g): the sender's occupant generation rides the Sent: line as a short suffix
@@ -106,9 +102,7 @@ export function wrapSendBody(
}
const reply = senderLabel.endsWith("@external")
? `↩ Reply if needed: rig queue create --destination ${senderLabel}${meta?.replyToQitem ? ` --human-intent update --reply-to ${meta.replyToQitem}` : ""} --summary "<one line>" --body "..." --verify`
: isHumanSeatSessionRef(personAddress)
? `↩ Reply if needed: rig queue create --destination ${senderLabel} --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`
: `↩ Reply: rig send ${senderLabel} "..."`;
: `↩ Reply: rig send ${senderLabel} "..."`;
return [...header, "---", body, "---", reply].join("\n");
}
@@ -571,7 +565,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
const envelope = !effect
? res.data
: effectUnresolved(effect)
? { ...res.data, verified: false, outcome: effectSubmissionUnconfirmed(effect) ? "submission-unconfirmed" : "staged-not-consumed", effectCheck: effect }
? { ...res.data, verified: false, outcome: "staged-not-consumed", effectCheck: effect }
: { ...res.data, effectCheck: effect };
console.log(JSON.stringify(envelope));
if (res.status >= 400) process.exitCode = res.status >= 500 ? 2 : 1;
@@ -638,7 +632,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
* recheck, then stops. Pure classification: no printing here.
*/
type EffectCheck =
| { checked: true; state: "staged"; remedy: "submitted-cleared" | "submitted-still-staged" | "submit-refused" | "submit-unconfirmed"; detail?: string }
| { checked: true; state: "staged"; remedy: "submitted-cleared" | "submitted-still-staged" | "submit-refused"; detail?: string }
| { checked: true; state: "no-staged-residual" }
| { checked: false; why: string };
@@ -689,26 +683,16 @@ async function classifyDeliveryEffect(
const probe = await detectStagedAtPrompt(client, session, identity);
if (probe.state === "unchecked") return { checked: false, why: probe.why };
if (probe.state === "not-staged") return { checked: true, state: "no-staged-residual" };
let enter: { status: number; data: Record<string, unknown> };
try {
enter = await client.post<Record<string, unknown>>("/api/transport/send", {
session,
submitOnly: true,
expectedStagedText: identity.expectedStagedText,
expectedStagedLineCount: identity.expectedLines,
}, transportRequestOptions(waitForIdleMs));
} catch (err) {
return { checked: true, state: "staged", remedy: "submit-unconfirmed", detail: (err as Error).message };
}
const enter = await client.post<Record<string, unknown>>("/api/transport/send", {
session,
submitOnly: true,
expectedStagedText: identity.expectedStagedText,
expectedStagedLineCount: identity.expectedLines,
}, transportRequestOptions(waitForIdleMs));
if (enter.status >= 400) {
const phase = enter.data?.["inputPhase"];
const reason = enter.data?.["reason"];
const unconfirmed = phase === "attempted" || phase === "written"
|| (phase !== "not_reached" && enter.data?.["sent"] !== false && (reason === "submit_failed" || enter.status >= 500));
return { checked: true, state: "staged", remedy: unconfirmed ? "submit-unconfirmed" : "submit-refused", detail: (enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}` };
return { checked: true, state: "staged", remedy: "submit-refused", detail: (enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}` };
}
const recheck = await detectStagedAtPrompt(client, session, identity);
if (recheck.state === "unchecked") return { checked: true, state: "staged", remedy: "submit-unconfirmed", detail: recheck.why };
return recheck.state === "staged"
? { checked: true, state: "staged", remedy: "submitted-still-staged" }
: { checked: true, state: "staged", remedy: "submitted-cleared" };
@@ -721,17 +705,13 @@ function effectHumanLines(effect: EffectCheck, session: string): string[] {
return [`Note: the pane-effect check could not run (${effect.why}); the verdict above is transport-level only.`];
}
if (effect.state === "no-staged-residual") return [];
if (effect.remedy === "submit-unconfirmed") return [
`Delivery: submission unconfirmed — the text was staged before one guarded Enter attempt, but the result could not be confirmed (${effect.detail}). Inspect with: rig capture ${session}`,
];
if (effect.remedy === "submit-refused") return [
`Delivery: unconfirmed — the guarded Enter was refused (${effect.detail}); the earlier staged observation may have changed. Inspect with: rig capture ${session}`,
];
const out = ["Delivery: staged, not consumed (checked: post-send pane capture; observed: the sent text is still at the prompt — typed, never submitted)"];
if (effect.remedy === "submitted-cleared") {
out.push("Remedy: one guarded Enter submitted — the staged text left the prompt.");
} else if (effect.remedy === "submitted-still-staged") {
out.push(`Remedy: one guarded Enter submitted, but the text is STILL at the prompt — not consumed; stopping here (one submit is the contract). Inspect with: rig capture ${session}`);
} else {
out.push(`Remedy: the single guarded Enter (submit path) was refused (${effect.detail}); stopping here — one submit is the contract. Inspect with: rig capture ${session}`);
}
return out;
}
@@ -741,10 +721,6 @@ function effectUnresolved(effect: EffectCheck | undefined): boolean {
return !!effect && effect.checked && effect.state === "staged" && effect.remedy !== "submitted-cleared";
}
function effectSubmissionUnconfirmed(effect: EffectCheck | undefined): boolean {
return !!effect && effect.checked && effect.state === "staged" && (effect.remedy === "submit-unconfirmed" || effect.remedy === "submit-refused");
}
/**
* S3 (OPR.0.5.4.6) — the staged-at-prompt detector, walk's staged-evidence
* primitive generalized to plain sends. POSITIVE evidence only: text rendered
@@ -778,7 +754,7 @@ async function detectStagedAtPrompt(
for (let i = lines.length - 1; i >= 0; i--) {
if (PROMPT_LINE_PATTERN.test(lines[i]!)) { lastPrompt = i; break; }
}
if (lastPrompt === -1) return { state: "unchecked", why: "no input prompt was visible in the capture" };
if (lastPrompt === -1) return { state: "not-staged" };
const inputRegionRaw = lines.slice(lastPrompt).join("\n").replace(PROMPT_LINE_PATTERN, "");
// IDENTITY FIRST (round-2 F2, desk-binding): the literal-residual match uses
// the daemon submit-precheck's own normalization (strip ALL whitespace,
@@ -1107,7 +1083,6 @@ async function runFanOutSend(params: {
}
const effectBySeat = new Map((effects ?? []).map((e) => [e.sessionName, e.effect]));
const unresolvedCount = (effects ?? []).filter((e) => effectUnresolved(e.effect)).length;
const unconfirmedCount = (effects ?? []).filter((e) => effectSubmissionUnconfirmed(e.effect)).length;
if (opts.json) {
// Round-2 F1: ONE verdict per recipient — a staged-unresolved recipient's
@@ -1115,7 +1090,7 @@ async function runFanOutSend(params: {
const rawResults = (res.data["results"] as Array<{ sessionName: string; ok: boolean; outcome?: string }> | undefined) ?? [];
const encodedResults = rawResults.map((r) =>
effectUnresolved(effectBySeat.get(r.sessionName))
? { ...r, ok: false, verified: false, outcome: effectSubmissionUnconfirmed(effectBySeat.get(r.sessionName)) ? "submission-unconfirmed" : "staged-not-consumed", error: effectSubmissionUnconfirmed(effectBySeat.get(r.sessionName)) ? "delivery unconfirmed; inspect the recipient after the guarded submit attempt" : "staged, not consumed (pane effect); the one guarded submit did not clear it" }
? { ...r, ok: false, verified: false, outcome: "staged-not-consumed", error: "staged, not consumed (pane effect); the one guarded submit did not clear it" }
: r
);
// Round-3 (r2 row 00fb3a68): the machine-readable AGGREGATE derives from
@@ -1158,8 +1133,6 @@ async function runFanOutSend(params: {
if (ec && ec.checked && ec.state === "staged") {
if (ec.remedy === "submitted-cleared") {
console.log(`${r.sessionName}: sent — staged at the prompt, cleared by one guarded Enter (consumed)`);
} else if (effectSubmissionUnconfirmed(ec)) {
console.log(`${r.sessionName}: ${effectHumanLines(ec, r.sessionName)[0]}`);
} else {
console.log(`${r.sessionName}: staged, not consumed — ${effectHumanLines(ec, r.sessionName).slice(-1)[0]}`);
}
@@ -1172,7 +1145,7 @@ async function runFanOutSend(params: {
}
// The delivered count never includes a staged-unresolved recipient.
const deliveredCount = Math.max(0, ((data["sent"] as number) ?? 0) - unresolvedCount);
console.log(`${deliveredCount}/${data["total"]} delivered${unresolvedCount - unconfirmedCount > 0 ? `, ${unresolvedCount - unconfirmedCount} staged-not-consumed` : ""}${unconfirmedCount > 0 ? `, ${unconfirmedCount} submission-unconfirmed` : ""}`);
console.log(`${deliveredCount}/${data["total"]} delivered${unresolvedCount > 0 ? `, ${unresolvedCount} staged-not-consumed` : ""}`);
// S2 (OPR.0.5.4.3): surface additive advisories (e.g. the unknown-sender
// sign-it notice) — an env-less operator must see it, not "sent" lines alone.
const fanoutAdvisory = data["warning"] as string | undefined;
-5
View File
@@ -251,7 +251,6 @@ export function slackCommand(deps: SlackDeps = {}): Command {
log(`Daemon: ${observation.state}${observation.reason ? ` (${observation.reason})` : ""}`);
const connector = observation.connector as { configurationDigest?: string; inboundReady?: boolean; outboundReady?: boolean;
inboundRouting?: { fallback: string; destination: string };
settlement?: { pending: number; lastFailure: string | null };
deadLetterBacklog?: number | null; deadLetterBacklogState?: string; deadLetterBacklogReason?: string;
inbound?: InboundDeliveryStatus & { state?: string; generation?: number; lastEventAt?: string };
recovery?: { state?: string; reason?: string; lastScanAt?: string; acceptedThisProcess?: number; deadLetteredThisProcess?: number;
@@ -262,10 +261,6 @@ export function slackCommand(deps: SlackDeps = {}): Command {
backoff?: Record<string, number>; incomplete?: Record<string, number> };
limits?: string[] } } | undefined;
if (connector) {
if (connector.settlement) {
log(` Ask edits: ${connector.settlement.pending} queued for reconciliation`);
if (connector.settlement.lastFailure) log(` Last ask-edit failure: ${connector.settlement.lastFailure}`);
}
if (connector.inboundRouting?.fallback === "local") log(` Configured routing fallback: local to ${connector.inboundRouting.destination} (after forwarding, thread and channel routes)`);
log(` Socket: ${connector.inbound?.state ?? "unknown"}; generation ${connector.inbound?.generation ?? "unknown"}; last event ${connector.inbound?.lastEventAt ?? "unknown"}`);
for (const line of socketDeliveryLines(connector.inbound)) log(` ${line}`);
+13 -21
View File
@@ -157,25 +157,21 @@ export async function resolveLibrarySpec(
nameOrId: string,
opts?: { kind?: string },
): Promise<LibraryEntry> {
const url = opts?.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library";
const res = await client.get<LibraryEntry[]>(url);
const filterKind = (entries: LibraryEntry[]) =>
const res = await client.get<LibraryEntry[]>(
opts?.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library",
);
const rawEntries = res.data ?? [];
const entries =
opts?.kind !== undefined && !KNOWN_SPEC_KINDS.includes(opts.kind as (typeof KNOWN_SPEC_KINDS)[number])
? entries.filter((entry) => entry.kind === opts.kind)
: entries;
const visibleEntries = filterKind(res.data ?? []);
// An exact ID always wins, including when a visible name matches a hidden ID.
const visibleId = visibleEntries.find((entry) => entry.id === nameOrId);
if (visibleId) return visibleId;
const separator = opts?.kind !== undefined ? "&" : "?";
const all = await client.get<LibraryEntry[]>(`${url}${separator}include_builtins=true`);
const entries = filterKind(all.data ?? []);
? rawEntries.filter((e) => e.kind === opts.kind)
: rawEntries;
// Try exact ID match first
const byId = entries.find((e) => e.id === nameOrId);
if (byId) return byId;
// Visible names take precedence over hidden built-ins with the same name.
const visibleNames = visibleEntries.filter((e) => e.name === nameOrId);
const byName = visibleNames.length ? visibleNames : entries.filter((e) => e.name === nameOrId);
// Try name match
const byName = entries.filter((e) => e.name === nameOrId);
if (byName.length === 1) return byName[0]!;
if (byName.length > 1) {
@@ -234,16 +230,12 @@ Examples:
cmd.command("ls")
.description("List library rigs, agents, and managed apps")
.option("--kind <kind>", "Filter by kind (rig, agent, or workflow)")
.option("--include-builtins", "Include built-ins hidden by the visibility setting")
.option("--json", "JSON output")
.action(async (opts: { kind?: string; json?: boolean; includeBuiltins?: boolean }) => {
.action(async (opts: { kind?: string; json?: boolean }) => {
try {
warnIfUnknownKind(opts.kind);
const client = await getClient();
const params = new URLSearchParams();
if (opts.kind !== undefined) params.set("kind", opts.kind);
if (opts.includeBuiltins) params.set("include_builtins", "true");
const url = `/api/specs/library${params.size ? `?${params}` : ""}`;
const url = opts.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library";
const res = await client.get<LibraryEntry[]>(url);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
const rawEntries = res.data ?? [];
+1 -1
View File
@@ -104,7 +104,7 @@ export function streamCommand(depsOverride?: StreamDeps): Command {
.description("Append a stream item")
.requiredOption("--source <session>", "Source session (e.g. dev-build@starter)")
.requiredOption("--body <text>", "Stream item body")
.option("--hint-destination <session>", "Hint at intended destination seat (the queue worker routes by this hint)")
.option("--hint-destination <session>", "Hint at intended destination seat")
.option("--hint-type <type>", "Hint type (e.g. review, handoff, idea)")
.option("--hint-urgency <urgency>", "Hint urgency (routine, urgent, critical)")
.option("--hint-tags <tags>", "Comma-separated hint tags")
@@ -11,7 +11,7 @@ interface AgentLibraryEntry {
/** Resolve the runtime's terminal seat or the shipped general-purpose agent. */
export async function resolveDefaultAgentRef(client: DaemonClient, runtime: string): Promise<string> {
if (runtime === "terminal") return "builtin:terminal";
const res = await client.get<AgentLibraryEntry[]>("/api/specs/library?kind=agent&include_builtins=true");
const res = await client.get<AgentLibraryEntry[]>("/api/specs/library?kind=agent");
const entry = res.data?.find(
(candidate) =>
candidate.kind === "agent"
+10 -26
View File
@@ -273,7 +273,7 @@ initial record is explicitly reported as unverified delivery.`)
let verdict = await pollConsumed();
if (verdict === "generation-rolled") {
failPiece(i, piece.label, "the seat's generation rolled mid-walk (a re-prime); delivery is unconfirmed. Inspect the recipient before any resend; the walk cannot continue into a different generation.");
failPiece(i, piece.label, "the seat's generation rolled mid-walk (a re-prime); the walk cannot continue into a different generation.");
return;
}
if (verdict === "timeout") {
@@ -289,39 +289,23 @@ initial record is explicitly reported as unverified delivery.`)
/\[Pasted text #\d+ \+\d+ lines\]/.test(pane)
);
if (stagedEvidence) {
let enter: { status: number; data: Record<string, unknown> };
try {
enter = await client.post<Record<string, unknown>>("/api/transport/send", {
session: seat,
submitOnly: true,
expectedStagedText: piece.content, // FULL bytes — the transport checks the rendered literal residual for contiguous containment
expectedStagedLineCount: piece.content.split("\n").length,
}, { headers: terminalAuthHeaders() });
} catch (error) {
failPiece(i, piece.label, `the guarded submit result is unconfirmed (${(error as Error).message}); inspect the recipient before any resend.`);
return;
}
const enter = await client.post<Record<string, unknown>>("/api/transport/send", {
session: seat,
submitOnly: true,
expectedStagedText: piece.content, // FULL bytes — the transport checks the rendered literal residual for contiguous containment
expectedStagedLineCount: piece.content.split("\n").length,
}, { headers: terminalAuthHeaders() });
if (enter.status >= 400) {
const phase = enter.data?.["inputPhase"], reason = enter.data?.["reason"];
const unconfirmed = phase === "attempted" || phase === "written"
|| (phase !== "not_reached" && enter.data?.["sent"] !== false && (reason === "submit_failed" || enter.status >= 500));
const detail = (enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}`;
failPiece(i, piece.label, unconfirmed
? `the guarded submit result is unconfirmed (${detail}); inspect the recipient before any resend.`
: `the single guarded submit was refused (${detail}); the earlier staged observation may have changed. Inspect the recipient before any resend.`);
failPiece(i, piece.label, `typed but not consumed; the single submit retry was refused (${(enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}`}).`);
return;
}
verdict = await pollConsumed();
if (verdict === "generation-rolled") {
failPiece(i, piece.label, "the seat's generation rolled after the guarded submit; delivery is unconfirmed. Inspect the recipient before any resend.");
return;
}
if (verdict !== "consumed") {
failPiece(i, piece.label, "no matching conversation record was observed within the verification window after the single submit retry; delivery is unconfirmed. Inspect the recipient before any resend.");
failPiece(i, piece.label, `typed and staged, but not consumed even after the single submit retry — the piece never entered the seat's conversation record. Not re-sending (one retry is the contract).`);
return;
}
} else {
failPiece(i, piece.label, `send reported ${sendOutcome === "ok" ? "success" : "a transport error"}, but no matching record or staged input was observed within the verification window. Delivery and consumption are unconfirmed; inspect the recipient before any resend.`);
failPiece(i, piece.label, `send reported ${sendOutcome === "ok" ? "success" : "a transport error"} but the piece is neither consumed in the generation record nor staged in the pane — delivery lost; consumption verification fails this walk closed.`);
return;
}
}
-26
View File
@@ -13,7 +13,6 @@ import {
// truth at ~/.openrig/config.json. Resolution stays env > file > default.
export interface RiggedConfig {
specs: { builtinsVisible: boolean };
launch: { nonInterruptive: boolean };
daemon: { port: number; host: string; dbConflict?: "warn" | "stop" };
// OPR.0.4.6.MH1 FR-1 — the persisted host-selection pointer.
@@ -230,10 +229,6 @@ export interface RiggedConfig {
wakeSwapGraceSeconds: number;
wakeHumanRung: string;
};
// Issue #1856 — stream intake setting
stream: {
intake: string;
};
}
const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
@@ -288,7 +283,6 @@ const DEFAULTS = {
topology: { root: getDefaultOpenRigPath("topology") },
// OPR.0.5.9.5 Wave B — canonical addressable context library.
context: { root: getDefaultOpenRigPath("context"), systemWorld: "default" },
specs: { builtinsVisible: true },
skills: { root: getDefaultOpenRigPath("skills"), routerLine: { enabled: false } },
onboarding: { defaultPack: { enabled: true } },
health: { contextPressure: { warningPercent: 95, criticalPercent: 99 } },
@@ -394,10 +388,6 @@ const DEFAULTS = {
consumers: "",
templateDir: "",
},
// Issue #1856 — stream intake default queue.worker; read live by the stream intake coordinator.
stream: {
intake: "queue.worker",
},
// OPR.0.4.6.FS-1 W2 — retention defaults (twin of daemon getDefaultValue).
retention: {
enabled: true,
@@ -464,7 +454,6 @@ export const VALID_KEYS = [
// OPR.0.5.9.5 Wave B — canonical context library; old key is refused.
"context.root",
"context.system_world",
"specs.builtins_visible",
"skills.root",
// OPR.0.7.0.13 (router): the person's recorded yes to OpenRig's one-line seat-skills bootstrap in AGENTS.md / CLAUDE.md.
"skills.router_line.enabled",
@@ -548,7 +537,6 @@ export const VALID_KEYS = [
// Slice 22 outcome 9 — stream retention: unreferenced items older than N days or beyond the newest N.
"retention.stream_days",
"retention.stream_max_items",
"stream.intake",
"retention.batch_size",
// S04 — pickup-receipt stall threshold; lockstep with the daemon settings-store twin.
"queue.pickup_stall_threshold_minutes",
@@ -593,7 +581,6 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"topology.root": { primary: "OPENRIG_TOPOLOGY_ROOT" },
"context.root": { primary: "OPENRIG_CONTEXT_ROOT" },
"context.system_world": { primary: "OPENRIG_CONTEXT_SYSTEM_WORLD" },
"specs.builtins_visible": { primary: "OPENRIG_SPECS_BUILTINS_VISIBLE" },
"skills.root": { primary: "OPENRIG_SKILLS_ROOT" },
"skills.router_line.enabled": { primary: "OPENRIG_SKILLS_ROUTER_LINE_ENABLED" },
"onboarding.default_pack.enabled": { primary: "OPENRIG_ONBOARDING_DEFAULT_PACK_ENABLED" },
@@ -665,7 +652,6 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"retention.watchdog_keep_per_job": { primary: "OPENRIG_RETENTION_WATCHDOG_KEEP_PER_JOB" },
"retention.stream_days": { primary: "OPENRIG_RETENTION_STREAM_DAYS" },
"retention.stream_max_items": { primary: "OPENRIG_RETENTION_STREAM_MAX_ITEMS" },
"stream.intake": { primary: "OPENRIG_STREAM_INTAKE" },
"retention.batch_size": { primary: "OPENRIG_RETENTION_BATCH_SIZE" },
"queue.pickup_stall_threshold_minutes": { primary: "OPENRIG_QUEUE_PICKUP_STALL_THRESHOLD_MINUTES" },
"queue.stuck_sweep_interval_seconds": { primary: "OPENRIG_QUEUE_STUCK_SWEEP_INTERVAL_SECONDS" },
@@ -702,7 +688,6 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"topology.root": ["topology", "root"],
"context.root": ["context", "root"],
"context.system_world": ["context", "systemWorld"],
"specs.builtins_visible": ["specs", "builtinsVisible"],
"skills.root": ["skills", "root"],
"skills.router_line.enabled": ["skills", "routerLine", "enabled"],
"onboarding.default_pack.enabled": ["onboarding", "defaultPack", "enabled"],
@@ -765,7 +750,6 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"retention.watchdog_keep_per_job": ["retention", "watchdogKeepPerJob"],
"retention.stream_days": ["retention", "streamDays"],
"retention.stream_max_items": ["retention", "streamMaxItems"],
"stream.intake": ["stream", "intake"],
"retention.batch_size": ["retention", "batchSize"],
"queue.pickup_stall_threshold_minutes": ["queue", "pickupStallThresholdMinutes"],
"queue.stuck_sweep_interval_seconds": ["queue", "stuckSweepIntervalSeconds"],
@@ -1129,12 +1113,6 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
throw new Error(`Invalid value for retention.stream_max_items: must be an integer >= 1, got "${raw}"`);
}
},
"stream.intake": (raw) => {
const val = (raw ?? "").trim();
if (val !== "off" && val !== "queue.worker" && !/^[a-zA-Z0-9\-_.]+@[a-zA-Z0-9\-_.]+$/.test(val)) {
throw new Error(`Invalid value for stream.intake: must be "queue.worker", a canonical session (<seat>@<rig>), or "off", got "${raw}"`);
}
},
"retention.batch_size": (raw, coerced) => {
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
throw new Error(`Invalid value for retention.batch_size: must be an integer >= 1, got "${raw}"`);
@@ -1281,7 +1259,6 @@ export class ConfigStore {
root: v("context.root") as string,
systemWorld: v("context.system_world") as string,
},
specs: { builtinsVisible: v("specs.builtins_visible") as boolean },
skills: {
root: v("skills.root") as string,
routerLine: { enabled: v("skills.router_line.enabled") as boolean },
@@ -1408,9 +1385,6 @@ export class ConfigStore {
wakeSwapGraceSeconds: v("queue.wake_swap_grace_seconds") as number,
wakeHumanRung: v("queue.wake_human_rung") as string,
},
stream: {
intake: v("stream.intake") as string,
},
};
}
+7 -36
View File
@@ -62,31 +62,6 @@ function bodyStartIndex(lines: string[]): number {
return 0; // unterminated frontmatter — treat as no frontmatter
}
/** Keep source indices while hiding code examples from the progress grammar. */
function progressLines(lines: string[]): string[] {
let fence: { marker: string; length: number } | null = null;
const start = bodyStartIndex(lines);
return lines.map((line, i) => {
if (i < start) return "";
const match = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
if (match) {
const marker = match[1]![0]!;
const length = match[1]!.length;
const tail = match[2]!;
if (fence) {
if (marker === fence.marker && length >= fence.length && /^[ \t\r]*$/.test(tail)) {
fence = null;
return line; // A new row may be inserted after the closing fence.
}
} else if (marker !== "`" || !tail.includes("`")) {
fence = { marker, length };
}
return "";
}
return fence ? "" : line;
});
}
export interface ProgressEditResult {
content: string;
changed: boolean;
@@ -117,35 +92,32 @@ export function addProgressRow(
const newRow = `- [${statusIndicator(opts.status)}] ${text}`;
const lines = content.split("\n");
const start = bodyStartIndex(lines);
const visible = progressLines(lines);
// Locate the target section heading.
let sectionIdx = -1;
for (let i = start; i < lines.length; i++) {
const h = visible[i]!.match(HEADING_RE);
const h = lines[i]!.match(HEADING_RE);
if (h && h[1] === "##" && h[2]!.trim() === section) { sectionIdx = i; break; }
}
if (sectionIdx === -1) {
// Create the section, appended after the last existing content.
let end = lines.length;
while (end > start && visible[end - 1]!.trim() === "") end--;
while (end > start && lines[end - 1]!.trim() === "") end--;
const head = lines.slice(0, end);
const suffix = lines.slice(end);
const rebuilt = [...head, "", `## ${section}`, "", newRow, "",
...(suffix.some(line => line.trim() !== "") ? suffix : [])].join("\n");
const rebuilt = [...head, "", `## ${section}`, "", newRow, ""].join("\n");
return { content: rebuilt, changed: true };
}
// Section block = [sectionIdx+1, nextHeadingOrEOF).
let blockEnd = lines.length;
for (let i = sectionIdx + 1; i < lines.length; i++) {
if (HEADING_RE.test(visible[i]!)) { blockEnd = i; break; }
if (HEADING_RE.test(lines[i]!)) { blockEnd = i; break; }
}
// Idempotency / conflict scan within the section.
for (let i = sectionIdx + 1; i < blockEnd; i++) {
const cb = visible[i]!.match(ROW_RE);
const cb = lines[i]!.match(ROW_RE);
if (cb && cb[3]!.trim() === text) {
const current = indicatorToStatus(cb[2]!);
if (current === opts.status) return { content, changed: false };
@@ -160,7 +132,7 @@ export function addProgressRow(
// Insert after the last non-blank line in the section block.
let insertAt = sectionIdx + 1;
for (let i = sectionIdx + 1; i < blockEnd; i++) {
if (visible[i]!.trim() !== "") insertAt = i + 1;
if (lines[i]!.trim() !== "") insertAt = i + 1;
}
lines.splice(insertAt, 0, newRow);
return { content: lines.join("\n"), changed: true };
@@ -183,11 +155,10 @@ export function setProgressRow(
const text = opts.text.trim();
const lines = content.split("\n");
const start = bodyStartIndex(lines);
const visible = progressLines(lines);
const matches: number[] = [];
for (let i = start; i < lines.length; i++) {
const cb = visible[i]!.match(ROW_RE);
const cb = lines[i]!.match(ROW_RE);
if (cb && cb[3]!.trim() === text) matches.push(i);
}
+1 -4
View File
@@ -31,10 +31,7 @@ export type FindingKind =
// SPEC.md compatibility — a node carrying BOTH authored files. Advisory by
// construction (low severity never flips the exit code): SPEC.md wins, and the
// shadowed README.md is a state to notice, never a failure to gate on.
| "shadowed_node_file"
// Another mission has a slice folder of the same name, which the slice list and the name-only routes
// hide. Advisory (low severity): renaming is the person's call.
| "slice_name_shared";
| "shadowed_node_file";
export interface AuditFinding {
kind: FindingKind;
+52 -36
View File
@@ -362,23 +362,31 @@ export function listMissions(missionsRoot: string): MissionInfo[] {
const hasReadme = readmePath !== null;
if (!hasReadme) continue;
const frontmatter = readFrontmatter(readmePath);
const slicesDir = path.join(absPath, "slices");
const closedDir = path.join(absPath, "closed");
const activeSliceCount = countSliceDirs(slicesDir);
const closedSliceCount = countSliceDirs(closedDir);
const id = pickIdFromFrontmatter(frontmatter);
out.push(withSliceCounts({ name: entry.name, absPath, readmePath, frontmatter, id }));
out.push({
name: entry.name,
absPath,
readmePath: hasReadme ? readmePath : null,
frontmatter,
id,
activeSliceCount,
closedSliceCount,
});
}
// Stable order: by mission name.
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
/** The mission with each of its slices counted once, by its standing (the same one `slice ls` filters on). */
function withSliceCounts(base: Omit<MissionInfo, "activeSliceCount" | "shippedSliceCount" | "closedSliceCount">): MissionInfo {
const mission: MissionInfo = { ...base, activeSliceCount: 0, shippedSliceCount: 0, closedSliceCount: 0 };
for (const { standing } of slicesWithStanding(mission)) {
if (standing === "active") mission.activeSliceCount++;
else if (standing === "shipped") mission.shippedSliceCount++;
else mission.closedSliceCount++;
}
return mission;
function countSliceDirs(dir: string): number {
if (!fs.existsSync(dir)) return 0;
return fs.readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory() && SLICE_DIRNAME_RE.test(e.name))
.length;
}
function pickIdFromFrontmatter(fm: Record<string, unknown>): string | null {
@@ -417,7 +425,15 @@ export function findMission(missionsRoot: string, identifier: string): MissionIn
function buildMissionInfo(missionsRoot: string, absPath: string): MissionInfo {
const readmePath = resolveNodeFile(absPath);
const frontmatter = readmePath ? readFrontmatter(readmePath) : {};
return withSliceCounts({ name: path.basename(absPath), absPath, readmePath, frontmatter, id: pickIdFromFrontmatter(frontmatter) });
return {
name: path.basename(absPath),
absPath,
readmePath,
frontmatter,
id: pickIdFromFrontmatter(frontmatter),
activeSliceCount: countSliceDirs(path.join(absPath, "slices")),
closedSliceCount: countSliceDirs(path.join(absPath, "closed")),
};
}
// ---------------------------------------------------------------------
@@ -428,35 +444,35 @@ export function listSlices(
mission: MissionInfo,
state: SliceState,
): SliceInfo[] {
return slicesWithStanding(mission)
.filter(({ standing }) => state === "all" || standing === state)
.map(({ slice }) => slice);
}
/** Where a slice stands. In closed/, it's closed. In place: shipped (or done) is delivered; a closed or superseded
* status, or a superseded or retired stage, is an exit, so closed; anything else, blocked work included, is active. */
export function sliceStanding(slice: SliceInfo, inClosedFolder: boolean): Exclude<SliceState, "all"> {
if (inClosedFolder) return "closed";
const status = (slice.status ?? "").toLowerCase();
const stage = typeof slice.frontmatter.stage === "string" ? slice.frontmatter.stage.toLowerCase() : "";
if (status === "done" || status.startsWith("shipped")) return "shipped";
if (status.startsWith("closed") || status === "superseded" || stage === "superseded" || stage === "retired") return "closed";
return "active";
}
/** Every slice in the mission (slices/ and closed/), in order, each with its standing. */
function slicesWithStanding(mission: MissionInfo): Array<{ slice: SliceInfo; standing: Exclude<SliceState, "all"> }> {
const out: Array<{ slice: SliceInfo; standing: Exclude<SliceState, "all"> }> = [];
for (const [folder, inClosedFolder] of [["slices", false], ["closed", true]] as const) {
const root = path.join(mission.absPath, folder);
const dirs: Array<{ root: string; bucket: "active" | "closed" }> = [];
if (state === "active" || state === "shipped" || state === "all") {
dirs.push({ root: path.join(mission.absPath, "slices"), bucket: "active" });
}
if (state === "closed" || state === "all") {
dirs.push({ root: path.join(mission.absPath, "closed"), bucket: "closed" });
}
const out: SliceInfo[] = [];
for (const { root } of dirs) {
if (!fs.existsSync(root)) continue;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory() || !SLICE_DIRNAME_RE.test(entry.name)) continue;
const slice = buildSliceInfo(mission, root, entry.name);
out.push({ slice, standing: sliceStanding(slice, inClosedFolder) });
if (!entry.isDirectory()) continue;
const m = SLICE_DIRNAME_RE.exec(entry.name);
if (!m) continue;
const sliceInfo = buildSliceInfo(mission, root, entry.name);
out.push(sliceInfo);
}
}
out.sort((a, b) => (a.slice.nn ?? Number.MAX_SAFE_INTEGER) - (b.slice.nn ?? Number.MAX_SAFE_INTEGER) || a.slice.name.localeCompare(b.slice.name));
out.sort((a, b) => (a.nn ?? Number.MAX_SAFE_INTEGER) - (b.nn ?? Number.MAX_SAFE_INTEGER) || a.name.localeCompare(b.name));
if (state === "active") {
// active = not in closed/, and not already delivered in-place.
return out.filter((s) => {
const st = (s.status ?? "").toLowerCase();
return st !== "done" && !st.startsWith("closed") && !st.startsWith("shipped");
});
}
if (state === "shipped") {
return out.filter((s) => (s.status ?? "").toLowerCase().startsWith("shipped"));
}
return out;
}
-3
View File
@@ -30,10 +30,7 @@ export interface MissionInfo {
/** Minted dot-ID for this mission (read from frontmatter if present,
* otherwise inferred from the folder name pattern). */
id: string | null;
/** Slices in flight (blocked included); delivered in place; and closed (closed/, or an exit in place). Each slice is
* counted once, by the standing `slice ls --state` filters on. */
activeSliceCount: number;
shippedSliceCount: number;
closedSliceCount: number;
}
+16 -51
View File
@@ -336,11 +336,10 @@ function scopeFor(access: McpAccess, seatOf: () => string | undefined): Scope {
};
}
/** The grant service names each connected assistant's seat cus-<id>-<connection>@<rig> (an existing connection's is
* cus-<id>-caller@<rig>). v1 marks no seat as a caller, so for a grant without team/list an item from a seat of this
* shape is never the team's question to it, and it can't hand a task to one. The shape only refuses; it never grants
* anything, and a grant with team/list doesn't read it. */
const CALLER_SHAPED = /^cus-[^@]+-[^@]+@[^@]+$/;
/** The grant service names an external caller's seat cus-<id>-caller@<rig>. v1 marks no seat as a caller, so an item
* from a seat of this shape is never the team's question to an external caller, and an external caller can't hand a
* task to one. The shape never grants anything. */
const CALLER_SHAPED = /^cus-.+-caller@[^@]+$/;
/** OpenRig's own tags that decide what a person asked about a row sees of its body: what Slack ingestion records about where
* the row came from and which parts of its body OpenRig wrote (the sender's channel, the attachments block, the body's
@@ -350,15 +349,11 @@ const CALLER_SHAPED = /^cus-[^@]+-[^@]+@[^@]+$/;
const OPENRIG_PROVENANCE_TAG = /^(?:founder-slack|inbound|shared-channel|gate)$|^participant:|^slack-/;
/** An external grant (openrig.grant/v1): a requester, narrower than a work grant. The grant's own seat is the
* only caller. It may hand tasks to its rigs' other seats (never a person or another host's seat, and without team/list
* never a caller-shaped seat), read the items it sent and those addressed to it from those seats (never team-internal
* items or other callers' items between themselves), change only the items addressed to its own seat (with the claim and
* update pairs), follow a handed-on item only where it came back to it, and read the artifacts of its own items under
* its named roots. */
* only caller. It may hand tasks to its rigs' team seats (never a caller-shaped seat, a person or another host's
* seat), read the items it sent and the team's questions to it (never team-internal or another caller's items), follow
* a handed-on item only where it came back to it, and read the artifacts of its own items under its named roots. */
function externalScope(access: McpAccess, seatOf: () => string | undefined): Scope {
const rigs = new Set(access.rigs);
// team/list makes the caller one of its rigs' participants: it may reach another connected assistant's seat.
const participant = access.external?.objects.team?.includes("list") === true;
const caller = (value: unknown): boolean => typeof value === "string" && value !== "" && value === seatOf();
// A seat of one of its rigs on this instance: member@rig, never a person or another host's seat.
const member = (value: unknown): boolean => {
@@ -366,20 +361,17 @@ function externalScope(access: McpAccess, seatOf: () => string | undefined): Sco
const parts = value.split("@");
return parts.length === 2 && parts[0] !== "" && parts[1] !== "external" && rigs.has(parts[1]!);
};
const team = (value: unknown): boolean => member(value) && !caller(value) && (participant || !CALLER_SHAPED.test(value as string));
const team = (value: unknown): boolean => member(value) && !caller(value) && !CALLER_SHAPED.test(value as string);
const field = (row: unknown, name: "sourceSession" | "destinationSession"): unknown => ((row ?? {}) as Record<string, unknown>)[name];
const item = (row: unknown): boolean => caller(field(row, "sourceSession"))
|| (caller(field(row, "destinationSession")) && team(field(row, "sourceSession")));
return {
session: (value) => caller(value) || team(value),
item,
// Only an item addressed to its exact seat, and one it may read, is its own to change; the daemon's claim also checks
// the claimant.
owner: (row) => caller(field(row, "destinationSession")) && item(row),
owner: () => false,
rig: (name) => typeof name === "string" && rigs.has(name),
root: (name) => typeof name === "string" && access.roots.includes(name),
// A task goes to another seat of its rigs: never to the caller itself, and without team/list never to another
// caller-shaped seat, which v1 can't tell apart.
// A task goes to the team: never to the caller itself or to another caller-shaped seat, which v1 can't tell apart.
destination: team,
successor: caller,
artifact: (row) => caller(field(row, "sourceSession")),
@@ -488,8 +480,7 @@ export type McpAccess = {
/** The tool each external object/action pair offers. A pair not listed here offers nothing. */
const EXTERNAL_TOOLS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
queue: { create: "rig_queue_create", show: "rig_queue_show", list: "rig_queue_list", claim: "rig_queue_claim", update: "rig_queue_update" },
team: { list: "rig_team" },
queue: { create: "rig_queue_create", show: "rig_queue_show", list: "rig_queue_list" },
artifact: { read: "rig_artifact_read" },
};
@@ -573,16 +564,6 @@ const SHARED_INSTRUCTIONS = [
];
const INSTRUCTIONS = [...SHARED_INSTRUCTIONS, MEMBER_WORKFLOW].join("\n");
const EXTERNAL_INSTRUCTIONS = SHARED_INSTRUCTIONS.join("\n");
/** For an external grant that names claim and update: how it works the items addressed to it, messages included. */
const EXTERNAL_INBOX_INSTRUCTIONS = [...SHARED_INSTRUCTIONS,
"Items addressed to your seat are yours: rig_queue_list with owned set lists them. For each, 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); the sender reads that answer on the same item. A message is an item too: read it, close it done, and reply, when it needs one, with a new item to its sender. Nothing promises to wake you, so check your items again.",
].join("\n");
function instructionsFor(access: McpAccess | undefined): string {
if (!access?.external) return INSTRUCTIONS;
const tools = externalTools(access.external.objects);
return tools.has("rig_queue_claim") && tools.has("rig_queue_update") ? EXTERNAL_INBOX_INSTRUCTIONS : EXTERNAL_INSTRUCTIONS;
}
/**
* Maps a DaemonResponse to MCP tool result.
@@ -635,7 +616,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
const server = new McpServer({
name: "openrig",
version: CLI_VERSION,
}, { instructions: instructionsFor(options.access) });
}, { instructions: options.access?.external ? EXTERNAL_INSTRUCTIONS : INSTRUCTIONS });
const { access } = options;
const rostersFolder = options.rostersFolder ?? (() => nodePath.join(new ConfigStore().get("workspace.root") as string, "rosters"));
const presenceFile = options.presenceFile ?? (() => mcpPresenceFilePath());
@@ -1364,10 +1345,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
try {
const denied = await itemOutOfScope(qitemId);
if (denied) return denied;
const res = await client.post(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {});
// An external caller's claimed item goes through the same projection as show and list.
const view = externalView(scope);
return mapResult(view && res.status < 400 ? { ...res, data: projectForExternal(res.data, view) } : res, undefined, { structured: true });
return mapResult(await client.post(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {}), undefined, { structured: true });
} catch (err) {
return caught(err);
}
@@ -1398,18 +1376,9 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
try {
const denied = await itemOutOfScope(qitemId, "owner");
if (denied) return denied;
// An external caller waits only on an item it names and can read: parking on a person would ask them, parking on
// another item would say whether it exists, and a park that names nothing would restore a blocker the team set.
if (access?.external && (blockedOn || state === "blocked")) {
const blocker = blockedOn?.startsWith("qitem-") ? await client.get(`/api/queue/${encodeURIComponent(blockedOn)}`) : null;
if (!blocker || blocker.status >= 400 || !scope?.item(blocker.data)) return outOfScope(`Blocker ${blockedOn ?? "(none named)"}`);
}
const res = await client.post(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
return mapResult(await client.post(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
state, closureReason, closureTarget, summary, evidenceRef, blockedOn, wakeAfterSeconds, transitionNote: note, resolution, reopen,
});
// An external caller's updated item goes through the same projection as show and list.
const view = externalView(scope);
return mapResult(view && res.status < 400 ? { ...res, data: projectForExternal(res.data, view) } : res, undefined, { structured: true });
}), undefined, { structured: true });
} catch (err) {
return caught(err);
}
@@ -1522,8 +1491,6 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
},
async ({ rigId }) => {
try {
// An external caller names one of its grant's rigs, so a lookup never answers about another workspace's rig.
if (access?.external && !scope?.rig(rigId)) return outOfScope(`Rig ${rigId}`);
const rig = await rigIdFor(client, rigId);
if ("content" in rig) return rig;
const id = encodeURIComponent(rig.id);
@@ -1544,9 +1511,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
async (seat) => {
const rows = await client.get(`/api/queue/list?${new URLSearchParams({ destinationSession: seat, compact: "1", limit: String(PRESENCE_ROWS) }).toString()}`)
.then((res) => (res.status < 400 ? res.data : null), () => null);
// An external caller sees a ring only on an item it may read, so another pair's item isn't named.
const visible = access?.external && Array.isArray(rows) ? rows.filter((row) => scope?.item(row)) : rows;
return { lastCallAt: readPresence(presenceFile())[seat]?.lastCallAt ?? null, lastWake: newestWake(visible, seat),
return { lastCallAt: readPresence(presenceFile())[seat]?.lastCallAt ?? null, lastWake: newestWake(rows, seat),
lastWakeScope: `the latest ring among its ${PRESENCE_ROWS} most recent rows` };
});
return mapResult({ status: 200, data: { ...view, hostedMembers: hosted.members, ...(hosted.note ? { hostedMembersNote: hosted.note } : {}) } }, undefined, { structured: true });
@@ -7,8 +7,6 @@ export interface SeatLaunchAdditionsView {
agentPromptReplacesDefault?: boolean;
appendSystemPromptFile?: string;
pluginDirs: string[];
/** #2044: absent from a daemon before it, and when the seat has none. */
pluginChannels?: string[];
missing: string[];
refused: string[];
}
@@ -19,7 +17,6 @@ export function seatLaunchAdditionLines(additions: SeatLaunchAdditionsView | nul
...(additions.agent ? [`agent ${additions.agent} (--agent${additions.agentPromptReplacesDefault ? "; its prompt replaces Claude's default system prompt" : ""})`] : []),
...(additions.appendSystemPromptFile ? [`prompt ${additions.appendSystemPromptFile} (--append-system-prompt-file)`] : []),
...additions.pluginDirs.map((dir) => `plugin dir ${dir} (CLAUDE_CODE_PLUGIN_DIRS)`),
...(additions.pluginChannels ?? []).map((entry) => `channel ${entry} (--channels)`),
...additions.missing.map((file) => `missing ${file}: not passed to the launch`),
...additions.refused.map((reason) => `refused ${reason}: not passed to the launch`),
];
+1 -18
View File
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import { execFileSync, spawn } from "node:child_process";
import http from "node:http";
import { Command } from "commander";
import { writeNewText, writeTextAtomically } from "../src/atomic-text-write.js";
import { writeTextAtomically } from "../src/atomic-text-write.js";
import { exportCommand } from "../src/commands/export.js";
import { STATE_FILE } from "../src/daemon-lifecycle.js";
import { allowFetchTarget, resetFetchAllowlist } from "./fetch-guard.js";
@@ -35,23 +35,6 @@ it("preserves an existing export and removes staging after a partial failed writ
expect(fs.readFileSync(target, "utf8")).toBe("name: original\n");
expect(fs.readdirSync(dir)).toEqual(["rig.yaml"]);
});
it("writeNewText creates a file but never replaces one, even a dangling link", () => {
dir = fs.mkdtempSync(join(tmpdir(), "export-atomic-"));
const target = join(dir, "rig.yaml"), link = join(dir, "link.yaml");
writeNewText(target, "name: first\n");
expect(fs.readFileSync(target, "utf8")).toBe("name: first\n");
expect(() => writeNewText(target, "name: second\n")).toThrow(expect.objectContaining({ code: "EEXIST" }));
expect(fs.readFileSync(target, "utf8")).toBe("name: first\n");
fs.symlinkSync(join(dir, "missing.yaml"), link);
expect(() => writeNewText(link, "name: second\n")).toThrow(expect.objectContaining({ code: "EEXIST" }));
expect(fs.existsSync(join(dir, "missing.yaml"))).toBe(false);
});
it("writeNewText leaves nothing behind after a failed write", () => {
dir = fs.mkdtempSync(join(tmpdir(), "export-atomic-"));
failure.active = true;
expect(() => writeNewText(join(dir, "rig.yaml"), "name: new\n")).toThrow(/no space left/);
expect(fs.readdirSync(dir)).toEqual([]);
});
it("retains export symlinks and target permissions on successful replacement", () => {
dir = fs.mkdtempSync(join(tmpdir(), "export-atomic-"));
const target = join(dir, "saved.yaml"), link = join(dir, "rig.yaml");
@@ -46,7 +46,7 @@ describe("bootstrap source paths respect the daemon target", () => {
// The responder reads relative paths from a different root, like an already-running daemon.
server = http.createServer(async (req, res) => {
res.setHeader("Content-Type", "application/json");
if (req.url === "/api/specs/library" || req.url === "/api/specs/library?include_builtins=true") {
if (req.url === "/api/specs/library") {
catalogReads++;
res.end(JSON.stringify(catalog));
return;
@@ -184,7 +184,7 @@ describe("bootstrap source paths respect the daemon target", () => {
("preserves catalog source paths (explicit URL=%s, plan=%s)", async (explicitUrl, plan) => {
if (explicitUrl) vi.stubEnv("OPENRIG_URL", daemonUrl);
await run("bootstrap", "catalog-rig", [plan ? "--plan" : "--yes"]);
expect(catalogReads).toBe(2);
expect(catalogReads).toBe(1);
expect(requests[0]?.sourceRef).toBe(catalog[0]!.sourcePath);
expect(requests[0]?.content).toBe("name: daemon\n");
});
@@ -192,7 +192,7 @@ describe("bootstrap source paths respect the daemon target", () => {
it.each([false, true])("preserves unresolved bare-name fallback (explicit URL=%s)", async (explicitUrl) => {
if (explicitUrl) vi.stubEnv("OPENRIG_URL", daemonUrl);
await run("bootstrap", "unlisted", ["--plan"]);
expect(catalogReads).toBe(2);
expect(catalogReads).toBe(1);
expect(requests[0]?.sourceRef).toBe("unlisted");
});
@@ -208,7 +208,7 @@ describe("bootstrap source paths respect the daemon target", () => {
if (explicitUrl) vi.stubEnv("OPENRIG_URL", daemonUrl);
catalog.push({ ...catalog[0]!, id: "other-id", sourcePath: path.join(callerDir, "rig.yaml") });
await run("bootstrap", "catalog-rig", ["--plan"]);
expect(catalogReads).toBe(2);
expect(catalogReads).toBe(1);
expect(requests).toEqual([]);
expect(process.exitCode).toBe(1);
});
+1 -1
View File
@@ -400,7 +400,7 @@ describe("Bootstrap CLI", () => {
let body = "";
req.on("data", (chunk: Buffer) => { body += chunk; });
req.on("end", () => {
if (url === "/api/specs/library" || url === "/api/specs/library?include_builtins=true") {
if (url === "/api/specs/library") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "lib1", name: "my-lib-rig", sourcePath: "/specs/my-lib-rig.yaml", kind: "rig", version: "0.2", sourceType: "user_file" }]));
} else if (url.includes("/api/bootstrap")) {
-1
View File
@@ -326,7 +326,6 @@ describe("Broadcast CLI", () => {
const output = logs.join("\n");
expect(output).toContain("No running sessions found");
expect(output).toContain("0/0 delivered");
expect(logs).toContainEqual(expect.stringMatching(/^FAILED — No running sessions found/));
expect(exitCode).toBe(1);
});
});
@@ -138,7 +138,6 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"topology.root",
"context.root",
"context.system_world",
"specs.builtins_visible",
"skills.root",
"skills.router_line.enabled",
"onboarding.default_pack.enabled",
@@ -212,7 +211,6 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"retention.watchdog_keep_per_job",
"retention.stream_days", // slice 22 outcome 9: stream retention
"retention.stream_max_items",
"stream.intake",
"retention.batch_size",
"queue.pickup_stall_threshold_minutes",
// S02 — standing-stuck-sweep cadence + unclaimed-obligation age.
@@ -236,16 +234,6 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
expect(() => store.set("daemon.db_conflict", "always")).toThrow(/warn or stop/);
});
it("defaults stream.intake to queue.worker and validates destinations", () => {
const store = new ConfigStore(configPath);
expect(store.get("stream.intake")).toBe("queue.worker");
store.set("stream.intake", "off");
expect(store.get("stream.intake")).toBe("off");
store.set("stream.intake", "lead@kernel");
expect(store.get("stream.intake")).toBe("lead@kernel");
expect(() => store.set("stream.intake", "invalid-bare-seat")).toThrow(/Invalid value for stream.intake/);
});
it("resolves context.root from the canonical default and OPENRIG_CONTEXT_ROOT", () => {
const store = new ConfigStore(configPath);
expect(store.resolveWithSource("context.root")).toMatchObject({
-8
View File
@@ -63,14 +63,6 @@ describe("ConfigStore", () => {
});
// Test 1
it("persists the built-in inventory preference as a validated boolean", () => {
const store = new ConfigStore(join(tmpDir, "config.json"));
expect(store.resolveOne("specs.builtins_visible").value).toBe(true);
store.set("specs.builtins_visible", "false");
expect(store.resolve().specs.builtinsVisible).toBe(false);
expect(() => store.set("specs.builtins_visible", "no")).toThrow();
});
it("resolve() returns defaults when no config file and no env", () => {
const store = new ConfigStore(join(tmpDir, "config.json"));
const config = store.resolve();
-17
View File
@@ -344,23 +344,6 @@ describe("Down CLI", () => {
expect(logs).toContain(" /work/project/CLAUDE.md: openrig-seat-skills.md");
});
it("names each file in a Claude seat's cwd that still holds OpenRig's additions, and keeps the exit code", async () => {
const kept = "/work/project/.openrig/context-collector.cjs: not the shipped collector's bytes, so it was kept";
responseOverride = {
status: 200,
body: {
rigId: "rig-1", sessionsKilled: 1, snapshotId: null, deleted: false, deleteBlocked: false, alreadyStopped: false, errors: [],
keptFiles: [kept],
},
};
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "down", "rig-1"]);
});
expect(logs).toContain("Kept 1 file(s) that still hold OpenRig's additions; check them by hand:");
expect(logs).toContain(` ${kept}`);
expect(exitCode).toBeUndefined();
});
it.each([
{ rigName: "workshop", isUniqueName: false },
{},
@@ -1,33 +0,0 @@
import { afterEach, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import { spawn } from "node:child_process";
import { environmentCommand } from "../src/commands/environment.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
vi.mock("node:child_process", async original => ({ ...await original<typeof import("node:child_process")>(), spawn: vi.fn() }));
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); process.exitCode = undefined; });
it.each(["slow-get", "slow-validate", "changed-generation"])("the viewer bounds its live lookup without the redundant validation deadline: %s", async mode => {
vi.useFakeTimers(); const generation = mode === "changed-generation" ? "changed-generation" : "generation-one";
const paths: string[] = [];
const client = new DaemonClient("http://127.0.0.1:1", { sessionName: "worker@test", ownerProbe: async () => ({ state: "unknown" }) as never,
fetchImpl: (async (url: string | URL | Request, init?: RequestInit) => {
paths.push(String(url)); const validation = init?.method === "POST";
if (validation && mode === "changed-generation") return new Response(JSON.stringify({ error: "The prepared environment belongs to another generation" }), { status: 409 });
if ((mode === "slow-get" && !validation) || (mode === "slow-validate" && validation)) {
await new Promise<void>((resolve, reject) => { const timer = setTimeout(resolve, 6000);
init?.signal?.addEventListener("abort", () => { clearTimeout(timer); reject(init.signal?.reason); }, { once: true }); });
}
return new Response(JSON.stringify({ generation, placement: { gateway: "gateway", workspace: "workspace", name: "worker" } }), { status: 200 });
}) as typeof fetch });
const lifecycleDeps: LifecycleDeps = { spawn: vi.fn(), fetch: vi.fn(async () => ({ ok: true })), kill: vi.fn(() => true),
readFile: vi.fn((path: string) => path === STATE_FILE ? JSON.stringify({ pid: 123, port: 7433, db: "test.sqlite", startedAt: "2026-04-20T00:00:00Z" }) : null),
writeFile: vi.fn(), removeFile: vi.fn(), exists: vi.fn((path: string) => path === STATE_FILE), mkdirp: vi.fn(), openForAppend: vi.fn(() => 3), isProcessAlive: vi.fn(() => true) };
vi.mocked(spawn).mockImplementation(() => { const child = new EventEmitter(); queueMicrotask(() => child.emit("exit", 0)); return child as ReturnType<typeof spawn>; });
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
const run = environmentCommand({ lifecycleDeps, clientFactory: () => client }).parseAsync(["node", "rig", "connect", "binding-one", "--generation", "generation-one"]);
await vi.advanceTimersByTimeAsync(6500); await run;
if (mode !== "changed-generation") {
expect(paths).toEqual(["http://127.0.0.1:1/api/environments/binding-one?live=true"]);
expect(errors).not.toHaveBeenCalled(); expect(spawn).toHaveBeenCalledTimes(1);
} else { expect(spawn).not.toHaveBeenCalled(); expect(errors).toHaveBeenCalledWith(expect.stringContaining("generation")); }
});
-61
View File
@@ -262,67 +262,6 @@ describe("rig export + import", () => {
expect(writeFile).toHaveBeenCalledWith("rig.yaml", expect.any(String));
});
describe("export: a default export never replaces a file", () => {
const occupied = (...taken: string[]) => vi.fn((p: string) => {
if (taken.includes(p)) throw Object.assign(new Error(`EEXIST: ${p}`), { code: "EEXIST" });
});
const run = async (deps: Partial<ExportDeps>, ...args: string[]) => {
const program = new Command();
program.addCommand(exportCommand(exportDeps(deps)));
return captureLogs(() => program.parseAsync(["node", "rig", "export", "rig-1", ...args]));
};
it("the first export writes rig.yaml", async () => {
const writeNewFile = occupied();
const logs = await run({ writeNewFile });
expect(writeNewFile.mock.calls.map((c) => c[0])).toEqual(["rig.yaml"]);
expect(logs).toContain("Exported to rig.yaml");
expect(logs.join("\n")).not.toContain("left unchanged");
});
it("an occupied rig.yaml sends the export to a sibling, and says which", async () => {
const writeFile = vi.fn();
const writeNewFile = occupied("rig.yaml");
const exitCode = process.exitCode;
const logs = await run({ writeFile, writeNewFile });
expect(writeFile).not.toHaveBeenCalled();
expect(writeNewFile).toHaveBeenLastCalledWith("rig-export.yaml", expect.any(String));
expect(logs).toContain("Exported to rig-export.yaml");
expect(logs.join("\n")).toContain("rig.yaml already exists and was left unchanged");
expect(process.exitCode).toBe(exitCode);
});
it("occupied siblings are kept too", async () => {
const writeNewFile = occupied("rig.yaml", "rig-export.yaml", "rig-export-2.yaml");
const logs = await run({ writeNewFile });
expect(writeNewFile.mock.calls.map((c) => c[0])).toEqual(["rig.yaml", "rig-export.yaml", "rig-export-2.yaml", "rig-export-3.yaml"]);
expect(logs).toContain("Exported to rig-export-3.yaml");
});
it("-o replaces the file it names, including rig.yaml", async () => {
const writeFile = vi.fn();
const writeNewFile = occupied("rig.yaml");
const logs = await run({ writeFile, writeNewFile }, "-o", "rig.yaml");
expect(writeFile).toHaveBeenCalledWith("rig.yaml", expect.any(String));
expect(writeNewFile).not.toHaveBeenCalled();
expect(logs).toContain("Exported to rig.yaml");
});
it("a real write failure stays a failure, with no success reported", async () => {
const writeNewFile = vi.fn(() => { throw Object.assign(new Error("no space"), { code: "ENOSPC" }); });
const log = vi.spyOn(console, "log").mockImplementation(() => {});
try {
const program = new Command();
program.addCommand(exportCommand(exportDeps({ writeNewFile })));
await expect(program.parseAsync(["node", "rig", "export", "rig-1"])).rejects.toThrow("no space");
expect(writeNewFile).toHaveBeenCalledTimes(1);
expect(log).not.toHaveBeenCalledWith(expect.stringContaining("Exported to"));
} finally {
log.mockRestore();
}
});
});
it("export: warns when culture_file or startup files do not resolve relative to output directory", async () => {
const writeFile = vi.fn();
const exists = vi.fn((p: string) => !p.includes("missing-culture.md") && !p.includes("missing-start.md") && !p.includes("optional-start.md"));
+1 -6
View File
@@ -484,17 +484,12 @@ describe("PUBLIC bin ownership (guard finding 1 — the wrapper is the real fron
// "PRODUCTION PATH … REAL probeFrontDoor" test earlier, with no real TUI spawned.)
it.skipIf(!existsSync(binWrapper))("piped PUBLIC bin keeps the commander usage baseline (no TUI, fast exit)", () => {
const started = Date.now();
const result = spawnSync(process.execPath, [binWrapper], {
input: "x\n",
timeout: 5000,
encoding: "utf-8",
});
expect(result.status, JSON.stringify({
elapsedMs: Date.now() - started, status: result.status, signal: result.signal,
error: result.error && { name: result.error.name, message: result.error.message, code: (result.error as NodeJS.ErrnoException).code },
stdout: result.stdout?.slice(-4096), stderr: result.stderr?.slice(-4096),
})).toBe(0);
expect(result.status).toBe(0);
expect(`${result.stderr}${result.stdout}`).toMatch(/Usage: rig/);
});
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -6,7 +6,6 @@ import { createProgram } from "../src/index.js";
// The registry module is home-state owned by the daemon; the verb (and this test) reach
// it via the narrow @openrig/daemon/gateway-human-registry subpath (the C3/crash-cart rail).
import { humansDir, loadHumanRegistry } from "@openrig/daemon/gateway-human-registry";
import { gatewayRoutes } from "../../daemon/src/routes/gateway.js";
// M1 A3 pt2 / A4b relocate — `rig gateway human add` verb integration. The verb lazy-imports
// the relocated daemon surface; these tests drive the real command end-to-end.
@@ -20,24 +19,10 @@ describe("rig gateway human add verb (post-relocate)", () => {
process.exitCode = undefined;
});
afterEach(() => {
vi.restoreAllMocks();
if (prevHome === undefined) delete process.env.OPENRIG_HOME; else process.env.OPENRIG_HOME = prevHome;
rmSync(home, { recursive: true, force: true });
});
it("participants --json and the daemon route expose the same filtered view", async () => {
const add = createProgram(); add.exitOverride();
await add.parseAsync(["node", "rig", "gateway", "human", "add", "mike", "--display-name", "Mike", "--delivery-class", "B"]);
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const command = createProgram(); command.exitOverride();
await command.parseAsync(["node", "rig", "gateway", "participants", "--organization", "missing", "--json"]);
const cli = JSON.parse(String(log.mock.calls.at(-1)?.[0]));
const response = await gatewayRoutes({ home }).request("/participants?organization=missing");
expect(response.status).toBe(200);
expect(await response.json()).toEqual(cli);
expect(cli).toMatchObject({ ok: true, participants: [], default: { id: "mike", source: "only-person" } });
});
it("is wired via createProgram (gateway human add)", () => {
const gw = createProgram().commands.find((c) => c.name() === "gateway");
expect(gw).toBeDefined();
@@ -1,51 +0,0 @@
// `rig host ls` status for an ssh host dials where ssh itself would connect (`ssh -G`), so an
// ssh-config alias or a non-22 port isn't reported unreachable. A stand-in `ssh` on PATH plays
// `ssh -G`; no real ssh runs.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { probeHostStatus } from "../src/commands/host.js";
let dir: string;
let savedPath: string | undefined;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "host-status-ssh-"));
writeFileSync(join(dir, "ssh"), `#!/bin/sh
case "$*" in
*-G*devbox*) printf 'user ops\\nhostname 10.1.2.3\\nport 2222\\n' ;;
*-G*jumpy*) printf 'hostname jumpy\\nport 22\\nproxyjump bastion\\n' ;;
*) exit 255 ;;
esac
`);
chmodSync(join(dir, "ssh"), 0o755);
savedPath = process.env["PATH"];
process.env["PATH"] = `${dir}:${savedPath}`;
});
afterEach(() => {
process.env["PATH"] = savedPath;
rmSync(dir, { recursive: true, force: true });
});
describe("rig host ls status for an ssh host", () => {
it("dials the host and port ssh resolves for an ssh-config alias", async () => {
const dials: string[] = [];
const status = await probeHostStatus({ id: "devbox", transport: "ssh", target: "devbox" }, async (target, port) => {
dials.push(`${target}:${port}`);
return "open";
});
expect(status).toBe("reachable");
expect(dials).toEqual(["10.1.2.3:2222"]);
});
it.each([["a ProxyJump host", "jumpy"], ["a target ssh can't resolve", "broken"]])(
"reads unknown for %s, since a direct dial can't stand for the transport", async (_label, target) => {
const dials: string[] = [];
const status = await probeHostStatus({ id: target, transport: "ssh", target }, async (t, port) => {
dials.push(`${t}:${port}`);
return "closed";
});
expect(status).toBe("unknown");
expect(dials).toEqual([]);
});
});
@@ -1,329 +0,0 @@
// Agent network, increment A: two assistants connected through the grant service (openrig.grant/v1), each with its own
// seat, discover each other and the team, queue work to each other, claim it and return the result on the same item,
// and come back to it without duplicates. Driven through rig mcp serve --http --grant-service and the daemon's real
// routes; the fake grant service answers each connection's key with that connection's grant.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import type { AddressInfo } from "node:net";
import { serve, type ServerType } from "@hono/node-server";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type Database from "better-sqlite3";
import { createTestApp } from "../../daemon/test/helpers/test-app.js";
import { createDb } from "../../daemon/src/db/connection.js";
import { migrate } from "../../daemon/src/db/migrate.js";
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
import { EventBus } from "../../daemon/src/domain/event-bus.js";
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
import { setSelfHostId } from "../../daemon/src/domain/hosts/fanout-contract.js";
import { startMcpHttpServer } from "../src/mcp-http.js";
const SCHEMA = "openrig.grant/v1";
const INSTANCE = "core-test-01";
const RIG = "team-a";
const LEAD = `lead@${RIG}`;
/** Two connections of one account: each its own key, grant and seat. */
const A = { key: `${"8977f2dc0000aaaa1111bbbb"}.cus_VVMAexample01.${"ab".repeat(24)}`, grant: "8977f2dc0000aaaa1111bbbb", seat: `cus-vvmaexample01-chatgpt@${RIG}` };
const B = { key: `${"8977f2dc0000aaaa2222cccc"}.cus_VVMAexample01.${"cd".repeat(24)}`, grant: "8977f2dc0000aaaa2222cccc", seat: `cus-vvmaexample01-claude@${RIG}` };
const NETWORK = { queue: ["create", "show", "list", "claim", "update"], team: ["list"], artifact: ["read"] };
const V1 = { queue: ["create", "show", "list"], artifact: ["read"] };
const active = (who: typeof A, objects: Record<string, string[]>) => ({
schema: SCHEMA, status: "active",
principal: { id: "cus_VVMAexample01", kind: "external_caller", label: "External caller …mple01" },
grant: { id: who.grant, version: 1, updated_at: "2026-11-02T05:00:00.000Z", expires_at: null, cache_seconds: 0, revocation: "per_request" },
environment: { id: RIG, core_instance: INSTANCE, studio: "https://studio.example", lead: LEAD },
seat: who.seat, rigs: [RIG], artifact_roots: [], access_class: "work", objects,
});
describe("agent network increment A: OAuth-connected assistants work with each other and the team", () => {
let db: Database.Database;
let home: string;
let repo: QueueRepository;
let daemon: ServerType;
let daemonUrl: string;
let setup: ReturnType<typeof createTestApp>;
let service: http.Server;
let serviceUrl: string;
/** What each connection's grant names, by key; a key set to "revoked" is answered revoked. */
let grants: Map<string, Record<string, string[]> | "revoked">;
const closers: Array<() => Promise<void>> = [];
const saved = { home: process.env.OPENRIG_HOME, session: process.env.OPENRIG_SESSION_NAME };
beforeEach(async () => {
db = createDb(":memory:");
migrate(db, ALL_MIGRATIONS);
home = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-network-"));
process.env.OPENRIG_HOME = home;
process.env.OPENRIG_SESSION_NAME = "env-seat@elsewhere";
repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
setup = createTestApp(db, { appDeps: { queueRepo: repo } });
daemon = await new Promise<ServerType>((resolve) => { const s = serve({ fetch: setup.app.fetch, port: 0, hostname: "127.0.0.1" }, () => resolve(s)); });
daemonUrl = `http://127.0.0.1:${(daemon.address() as AddressInfo).port}`;
setSelfHostId(INSTANCE);
grants = new Map([[A.key, NETWORK], [B.key, NETWORK]]);
service = http.createServer((req, res) => {
req.resume();
req.on("end", () => {
const key = String(req.headers.authorization ?? "").replace(/^Bearer /, "");
const who = key === A.key ? A : key === B.key ? B : null;
const objects = who ? grants.get(who.key) : undefined;
const body = !who || !objects ? { schema: SCHEMA, status: "unknown" }
: objects === "revoked" ? { schema: SCHEMA, status: "revoked" } : active(who, objects);
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(body));
});
});
await new Promise<void>((resolve) => service.listen(0, "127.0.0.1", resolve));
serviceUrl = `http://127.0.0.1:${(service.address() as AddressInfo).port}/api/connector/v1/grant`;
for (const member of ["cus-vvmaexample01-chatgpt", "cus-vvmaexample01-claude", "lead"]) seat(RIG, member);
});
afterEach(async () => {
for (const close of closers.splice(0).reverse()) await close();
await new Promise<void>((resolve) => service.close(() => resolve()));
await new Promise<void>((resolve) => daemon.close(() => resolve()));
setSelfHostId(null);
db.close();
fs.rmSync(home, { recursive: true, force: true });
if (saved.home === undefined) delete process.env.OPENRIG_HOME; else process.env.OPENRIG_HOME = saved.home;
if (saved.session === undefined) delete process.env.OPENRIG_SESSION_NAME; else process.env.OPENRIG_SESSION_NAME = saved.session;
});
function seat(rigName: string, member: string) {
const rig = setup.rigRepo.listRigs?.().find((r: { name: string }) => r.name === rigName) ?? setup.rigRepo.createRig(rigName);
const node = setup.rigRepo.addNode(rig.id, `pod.${member}`, { role: "worker" });
setup.sessionRegistry.registerSession(node.id, `${member}@${rigName}`);
}
async function listen() {
const served = await startMcpHttpServer({ daemonUrl, host: "127.0.0.1", port: 0, tokenFile: path.join(home, "mcp-http-tokens.json"),
grantService: { url: serviceUrl, timeoutMs: 1_000 } });
closers.push(() => served.close());
return served;
}
async function connect(url: string, who: typeof A) {
const client = new Client({ name: "network-test", version: "1.0.0" });
await client.connect(new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers: { Authorization: `Bearer ${who.key}` } } }));
closers.push(() => client.close());
const call = async (name: string, args: Record<string, unknown> = {}) => {
const result = await client.callTool({ name, arguments: args });
return { isError: result.isError === true, structured: result.structuredContent as Record<string, any>, text: (result.content as Array<{ text: string }>)[0]?.text ?? "" };
};
return Object.assign(call, { tools: async () => (await client.listTools()).tools.map((tool) => tool.name).sort() });
}
const rows = () => (db.prepare("SELECT COUNT(*) AS n FROM queue_items").get() as { n: number }).n;
const row = (qitemId: string) => repo.getById(qitemId)!;
const createdId = (result: { structured: Record<string, any>; text: string }) => String(result.structured?.qitemId ?? JSON.parse(result.text).qitemId);
it("offers claim, update and rig_team only to a grant that names them; a v1 grant keeps exactly today's four tools", async () => {
grants.set(B.key, V1);
const served = await listen();
expect(await (await connect(served.url, A)).tools()).toEqual(
["rig_artifact_read", "rig_queue_claim", "rig_queue_create", "rig_queue_list", "rig_queue_show", "rig_queue_update", "rig_team"]);
expect(await (await connect(served.url, B)).tools()).toEqual(["rig_artifact_read", "rig_queue_create", "rig_queue_list", "rig_queue_show"]);
});
it("A discovers B and the team, queues work to B; B claims it and returns the result on the same item, which A reads", async () => {
const served = await listen();
const a = await connect(served.url, A);
const b = await connect(served.url, B);
const team = await a("rig_team", { rigId: RIG });
expect(team.isError).toBe(false);
expect(team.text).toContain(B.seat);
expect(team.text).toContain(LEAD);
const sent = await a("rig_queue_create", { destination: B.seat, body: "Critique this plan.", tags: ["review"] });
expect(sent.isError).toBe(false);
const qitemId = createdId(sent);
expect(row(qitemId)).toMatchObject({ sourceSession: A.seat, destinationSession: B.seat });
// What A reads at each step: queued (pending, unclaimed), picked up (B's claim), answered (done with a resolution).
// B reading or listing the item changes none of it.
const inbox = await b("rig_queue_list", { owned: true });
expect(inbox.text).toContain(qitemId);
expect((await b("rig_queue_show", { qitemId })).isError).toBe(false);
expect((await a("rig_queue_show", { qitemId })).structured.item).toMatchObject({ state: "pending", claimedAt: null });
expect((await b("rig_queue_claim", { qitemId })).isError).toBe(false);
expect((await a("rig_queue_show", { qitemId })).structured.item).toMatchObject({ state: "in-progress", resolution: null });
expect((await b("rig_queue_update", { qitemId, state: "done", resolution: "Two risks; see the notes.", closureReason: "no-follow-on" })).isError).toBe(false);
expect(row(qitemId)).toMatchObject({ state: "done", resolution: "Two risks; see the notes." });
const followUp = await a("rig_queue_show", { qitemId });
expect(followUp.isError).toBe(false);
expect(followUp.text).toContain("Two risks; see the notes.");
expect(followUp.text).toContain(B.seat);
});
it("a message is an item the receiver reads and closes, and the reply is an item back, read by the first assistant", async () => {
const served = await listen();
const a = await connect(served.url, A);
const b = await connect(served.url, B);
const message = createdId(await a("rig_queue_create", { destination: B.seat, summary: "FYI: the plan changed", body: "We're shipping the smaller fix first.", tags: ["message"] }));
expect((await b("rig_queue_show", { qitemId: message })).text).toContain("We're shipping the smaller fix first.");
expect((await b("rig_queue_update", { qitemId: message, state: "done", closureReason: "no-follow-on", resolution: "Read." })).isError).toBe(false);
const reply = createdId(await b("rig_queue_create", { destination: A.seat, summary: "Re: the plan changed", body: "Agreed; I'll review the fix.", tags: ["message"] }));
expect(row(reply)).toMatchObject({ sourceSession: B.seat, destinationSession: A.seat });
expect((await a("rig_queue_list", { owned: true })).text).toContain(reply);
expect((await a("rig_queue_show", { qitemId: reply })).text).toContain("Agreed; I'll review the fix.");
});
it("B hands work on to the team and reads the team's result; A's task and B's task are separate items, each with its own result", async () => {
const served = await listen();
const b = await connect(served.url, B);
const delegated = createdId(await b("rig_queue_create", { destination: LEAD, body: "Implement the agreed change." }));
repo.update({ qitemId: delegated, actorSession: LEAD, state: "done", closureReason: "no-follow-on", resolution: "Merged as abc123." } as never);
expect((await b("rig_queue_show", { qitemId: delegated })).text).toContain("Merged as abc123.");
});
it("a create retried with the same qitemId after a lost reply is one item, and a new session with the same key finds the same work", async () => {
const served = await listen();
const a = await connect(served.url, A);
const first = createdId(await a("rig_queue_create", { destination: B.seat, body: "Once.", qitemId: "qitem-network-retry-1" }));
const before = rows();
const again = createdId(await a("rig_queue_create", { destination: B.seat, body: "Once.", qitemId: "qitem-network-retry-1" }));
expect(again).toBe(first);
expect(rows()).toBe(before);
const reentered = await connect(served.url, A);
expect((await reentered("rig_queue_list", {})).text).toContain(first);
expect(rows()).toBe(before);
});
it("two assistants calling at once each act as their own seat", async () => {
const served = await listen();
const [a, b] = await Promise.all([connect(served.url, A), connect(served.url, B)]);
const [fromA, fromB] = await Promise.all([
a("rig_queue_create", { destination: LEAD, body: "from A" }),
b("rig_queue_create", { destination: LEAD, body: "from B" }),
]);
expect(row(createdId(fromA)).sourceSession).toBe(A.seat);
expect(row(createdId(fromB)).sourceSession).toBe(B.seat);
});
it("each changes only items addressed to its own seat, stays in its workspace, and a revoked connection changes nothing", async () => {
seat("team-b", "lead");
const served = await listen();
const a = await connect(served.url, A);
const b = await connect(served.url, B);
const toB = createdId(await a("rig_queue_create", { destination: B.seat, body: "for B" }));
const toTeam = (await repo.create({ sourceSession: LEAD, destinationSession: "worker@team-a", body: "team-internal", nudge: false } as never)).qitemId;
const toA = (await repo.create({ sourceSession: LEAD, destinationSession: A.seat, body: "for A", nudge: false } as never)).qitemId;
const refused = { isError: true, structured: { code: "out_of_scope" } };
// A sent B's item and may read it; the daemon refuses its claim as someone else's.
expect((await a("rig_queue_claim", { qitemId: toB })).isError).toBe(true);
expect(await a("rig_queue_update", { qitemId: toB, state: "done", closureReason: "no-follow-on" })).toMatchObject(refused);
expect(await b("rig_queue_claim", { qitemId: toTeam })).toMatchObject(refused);
expect(await b("rig_queue_claim", { qitemId: toA })).toMatchObject(refused);
expect(await b("rig_queue_show", { qitemId: toTeam })).toMatchObject(refused);
expect(await a("rig_queue_create", { destination: "lead@team-b", body: "other workspace" })).toMatchObject(refused);
expect(await a("rig_queue_create", { destination: "someone@external", body: "a person" })).toMatchObject(refused);
expect(await a("rig_team", { rigId: "team-b" })).toMatchObject(refused);
expect(row(toB).state).toBe("pending");
// B waits only on an item it can read: never on a person (who would be asked) or on the team's own item.
expect((await b("rig_queue_claim", { qitemId: toB })).isError).toBe(false);
for (const blockedOn of ["someone@external", toTeam, "qitem-network-missing"]) {
expect((await b("rig_queue_update", { qitemId: toB, state: "blocked", blockedOn }))).toMatchObject({ isError: true, structured: { code: "out_of_scope" } });
}
expect(row(toB).state).toBe("in-progress");
const ask = createdId(await b("rig_queue_create", { destination: LEAD, body: "Which of the two plans?" }));
expect((await b("rig_queue_update", { qitemId: toB, state: "blocked", blockedOn: ask })).isError).toBe(false);
expect(row(toB)).toMatchObject({ state: "blocked", blockedOn: ask });
grants.set(B.key, "revoked");
const before = rows();
await expect(b("rig_queue_update", { qitemId: toB, state: "in-progress" })).rejects.toThrow();
expect(row(toB).state).toBe("blocked");
expect(rows()).toBe(before);
});
it("claim and update answer an assistant with what show shows: a blocker or closure target it can't read stays hidden", async () => {
const served = await listen();
const b = await connect(served.url, B);
const internal = (await repo.create({ sourceSession: LEAD, destinationSession: "worker@team-a", body: "team-internal", nudge: false } as never)).qitemId;
const mine = (await repo.create({ sourceSession: LEAD, destinationSession: B.seat, body: "for B", nudge: false } as never)).qitemId;
repo.update({ qitemId: mine, actorSession: LEAD, state: "blocked", blockedOn: internal, closureReason: "blocked_on", closureTarget: internal, transitionNote: "waiting on the team" } as never);
const shown = await b("rig_queue_show", { qitemId: mine });
const noted = await b("rig_queue_update", { qitemId: mine, note: "seen it" });
const claimed = await b("rig_queue_claim", { qitemId: mine });
for (const result of [shown, noted, claimed]) {
expect(result.isError).toBe(false);
expect(result.text).not.toContain(internal);
}
expect(row(mine).state).toBe("in-progress");
});
it("an assistant can't change an item addressed to it that it can't read: another workspace's, or another caller's under a partial grant", async () => {
grants.set(B.key, { queue: ["create", "show", "list", "claim", "update"], artifact: ["read"] });
const served = await listen();
const b = await connect(served.url, B);
const refused = { isError: true, structured: { code: "out_of_scope" } };
const foreign = (await repo.create({ sourceSession: "lead@team-b", destinationSession: B.seat, body: "from another workspace", nudge: false } as never)).qitemId;
const fromCaller = (await repo.create({ sourceSession: "cus-vvmaexample02-caller@team-a", destinationSession: B.seat, body: "from another caller", nudge: false } as never)).qitemId;
for (const qitemId of [foreign, fromCaller]) {
const before = repo.listTransitions(qitemId).length;
expect(await b("rig_queue_update", { qitemId, note: "mine?" })).toMatchObject(refused);
expect(await b("rig_queue_update", { qitemId, state: "done", closureReason: "no-follow-on" })).toMatchObject(refused);
expect(row(qitemId).state).toBe("pending");
expect(repo.listTransitions(qitemId).length).toBe(before);
}
});
it("an assistant's park names a blocker it can read: a bare re-park after a claim is refused, whether the team's blocker is open or closed", async () => {
const served = await listen();
const b = await connect(served.url, B);
for (const closedAfterClaim of [false, true]) {
const internal = (await repo.create({ sourceSession: LEAD, destinationSession: "worker@team-a", body: "team-internal", nudge: false } as never)).qitemId;
const mine = (await repo.create({ sourceSession: LEAD, destinationSession: B.seat, body: "for B", nudge: false } as never)).qitemId;
repo.update({ qitemId: mine, actorSession: LEAD, state: "blocked", blockedOn: internal, transitionNote: "waiting on the team" } as never);
expect((await b("rig_queue_claim", { qitemId: mine })).isError).toBe(false);
if (closedAfterClaim) repo.update({ qitemId: internal, actorSession: "worker@team-a", state: "done", closureReason: "no-follow-on" } as never);
const reparked = await b("rig_queue_update", { qitemId: mine, state: "blocked" });
expect(reparked).toMatchObject({ isError: true, structured: { code: "out_of_scope" } });
expect(reparked.text).not.toContain(internal);
expect(row(mine)).toMatchObject({ state: "in-progress", blockedOn: null });
}
});
it("team discovery names a hosted member's last ring only when it's on an item the assistant may read", async () => {
const workspace = path.join(home, "workspace");
const savedWorkspace = process.env.OPENRIG_WORKSPACE_ROOT;
process.env.OPENRIG_WORKSPACE_ROOT = workspace;
try {
fs.mkdirSync(path.join(workspace, "rosters"), { recursive: true });
fs.writeFileSync(path.join(workspace, "rosters", "team.json"), JSON.stringify({
version: 1, id: "team", name: "Team", purpose: "Ship the release", curator: { seat: LEAD, host: INSTANCE }, updated_at: "2026-11-02",
members: [{ seat: `grok@${RIG}`, host: INSTANCE, capabilities: ["web research"], engagement: ["delegate"], use_when: "Current sources", why: "Searches the web", hosted: true }],
}));
const served = await listen();
const a = await connect(served.url, A);
const ring = (qitemId: string, at: string) =>
db.prepare("UPDATE queue_items SET last_nudge_attempt = ?, last_nudge_result = ? WHERE qitem_id = ?").run(at, "wake-accepted:webhook answered 202", qitemId);
const fromA = (await repo.create({ sourceSession: A.seat, destinationSession: `grok@${RIG}`, body: "Find the release date.", nudge: false } as never)).qitemId;
const internal = (await repo.create({ sourceSession: LEAD, destinationSession: `grok@${RIG}`, body: "team-internal", nudge: false } as never)).qitemId;
ring(fromA, "2026-11-02T11:00:00.000Z");
ring(internal, "2026-11-02T12:00:00.000Z");
const team = await a("rig_team", { rigId: RIG });
expect(team.isError).toBe(false);
expect(team.structured.hostedMembers).toMatchObject([{ session: `grok@${RIG}`, presence: { lastWake: { qitemId: fromA, at: "2026-11-02T11:00:00.000Z" } } }]);
expect(team.text).not.toContain(internal);
} finally {
if (savedWorkspace === undefined) delete process.env.OPENRIG_WORKSPACE_ROOT; else process.env.OPENRIG_WORKSPACE_ROOT = savedWorkspace;
}
});
it("a v1 grant keeps today's rules: no task to or from another caller's seat", async () => {
grants.set(A.key, V1);
grants.set(B.key, V1);
const served = await listen();
const a = await connect(served.url, A);
const refused = { isError: true, structured: { code: "out_of_scope" } };
expect(await a("rig_queue_create", { destination: B.seat, body: "v1 can't" })).toMatchObject(refused);
const fromB = (await repo.create({ sourceSession: B.seat, destinationSession: A.seat, body: "from another caller", nudge: false } as never)).qitemId;
expect(await a("rig_queue_show", { qitemId: fromB })).toMatchObject(refused);
});
});
-30
View File
@@ -127,33 +127,3 @@ it("does not mistake a deeper subsection for the requested H2 section", () => {
expect(addProgressRow(source, { section: "Rail", text: "Release verified", status: "done" }).content)
.toBe(source + "\n## Rail\n\n- [x] Release verified\n");
});
describe("progress edits preserve fenced examples", () => {
it.each(["```md", "~~~md", "````md", " ```md"])("sets only the real row after %s", (opening) => {
const marker = opening.trimStart().startsWith("~") ? "~" : "`";
const closing = marker.repeat(5);
const example = [opening, "## Rail", "- [ ] Ship it", marker.repeat(2), "- [ ] Still an example", closing].join("\n");
const content = `# Progress\n${example}\n## Rail\n- [ ] Ship it\n`;
const result = setProgressRow(content, { text: "Ship it", status: "done" });
expect(result.content).toBe(`# Progress\n${example}\n## Rail\n- [x] Ship it\n`);
});
it("does not update a row that exists only in a code block", () => {
const content = "# Progress\n## Rail\n```md\n- [ ] Example\n```\n";
expect(() => setProgressRow(content, { text: "Example", status: "done" })).toThrow(/No progress row/);
});
it("ignores example headings and duplicates when adding a row", () => {
const example = "```md\n## Rail\n- [x] Ship it\n```";
const content = `# Progress\n${example}\n## Rail\n${example}\n\n## Notes\nKeep this.\n`;
const result = addProgressRow(content, { section: "Rail", text: "Ship it", status: "active" });
expect(result.content).toBe(`# Progress\n${example}\n## Rail\n${example}\n- [ ] Ship it\n\n## Notes\nKeep this.\n`);
});
it("inserts outside an unclosed example at the end of the section", () => {
const content = "# Progress\n## Rail\n- [ ] Existing\n\n```md\n## Example\n- [ ] Ship it\n";
const result = addProgressRow(content, { section: "Rail", text: "Ship it", status: "active" });
expect(result.content).toBe(content.replace("- [ ] Existing", "- [ ] Existing\n- [ ] Ship it"));
});
});
@@ -77,15 +77,3 @@ describe("FR-4 — verb output parses into the expected UI tree", () => {
expect(file.counts.blocked).toBe(1);
});
});
it.each(["```md\n## Rail\n- [ ] Ship it\n```\n", "```md\n## Rail\n- [ ] Ship it\n"])("adds and updates a real row without editing example %j", (example) => {
const original = `# Progress\n\n${example}`;
const added = addProgressRow(original, { section: "Rail", text: "Ship it", status: "active" }).content;
const updated = setProgressRow(added, { text: "Ship it", status: "done" }).content;
expect(updated).toContain(example);
fs.writeFileSync(path.join(root, "PROGRESS.md"), updated);
const file = indexerFor().scan().files[0]!;
expect(file.counts).toMatchObject({ total: 1, done: 1 });
expect(file.rows.filter(r => r.kind === "checkbox")).toMatchObject([{ text: "Ship it", status: "done" }]);
});
+3 -32
View File
@@ -4,7 +4,7 @@ import path from "node:path";
import os from "node:os";
import { PassThrough } from "node:stream";
import type { QueueDeps } from "../src/commands/queue.js";
import { resolveQueueBody, previewBody, previewCutMarker, waitForDeliveryOutcome } from "../src/commands/queue.js";
import { resolveQueueBody, previewBody, waitForDeliveryOutcome } from "../src/commands/queue.js";
import { createProgram } from "../src/index.js";
/**
@@ -1204,17 +1204,6 @@ describe("rig queue CLI", () => {
});
});
it("update forwards an explicitly accepted closure deadline without a state change", async () => {
const { deps, calls } = makeDeps();
const program = createProgram({ queueDeps: deps });
program.exitOverride();
await program.parseAsync(["node", "rig", "queue", "update", "qitem-estimate", "--closure-required-at", "2026-11-04T10:00:00.000Z", "--note", "Coordinator accepted the revised estimate", "--json"]);
expect(calls.find(c => c.path === "/api/queue/qitem-estimate/update")?.body).toMatchObject({
closureRequiredAt: "2026-11-04T10:00:00.000Z", transitionNote: "Coordinator accepted the revised estimate",
});
expect(calls.find(c => c.path === "/api/queue/qitem-estimate/update")?.body).toHaveProperty("state", undefined);
});
it("update forwards explicit reopen acknowledgment", async () => {
const { deps, calls } = makeDeps();
const program = createProgram({ queueDeps: deps });
@@ -1860,8 +1849,7 @@ describe("rig queue CLI", () => {
const printed = JSON.parse(logs[0]);
expect(printed.bodyTruncated).toBe(true);
expect(printed.bodyBytes).toBe(1000);
// Keep the first 512 code points, then mark the cut inside the body.
expect(printed.body).toBe(`${"A".repeat(512)}…[cut at 512 of 1000 chars; rig queue show 'qitem-1' --full]`);
expect(Array.from(printed.body).length).toBe(512);
// #1806: stdout is the record alone and parses as JSON; the marker line, with the honest total byte size, is on stderr
expect(JSON.parse(logs.join("\n"))).toMatchObject({ qitemId: "qitem-1", bodyTruncated: true });
expect(errors.join("\n")).toContain("bounded preview — complete body is 1000 bytes; full record");
@@ -1904,24 +1892,7 @@ describe("rig queue CLI", () => {
const printed = JSON.parse(logs[0]);
expect(printed.bodyTruncated).toBe(true);
expect(printed.bodyBytes).toBe(1000);
const marker = "…[cut at 512 of 1000 chars; rig queue show 'qitem-1' --full]";
expect(printed.body.endsWith(marker)).toBe(true);
expect(Array.from(printed.body.slice(0, -marker.length)).length).toBe(PREVIEW_MAX_CODEPOINTS);
});
it("show default: a multibyte body's marker counts code points, not bytes", async () => {
const full = "😀".repeat(600); // 600 code points, 2400 bytes
const { deps } = showRoute({ qitemId: "qitem-1", body: full });
const program = createProgram({ queueDeps: deps });
program.exitOverride();
await program.parseAsync(["node", "rig", "queue", "show", "qitem-1", "--json"]);
const printed = JSON.parse(logs[0]);
expect(printed.bodyBytes).toBe(2400);
expect(printed.body).toBe(`${"😀".repeat(512)}…[cut at 512 of 600 chars; rig queue show 'qitem-1' --full]`);
});
it("previewCutMarker quotes the row id for the shell", () => {
expect(previewCutMarker("qitem-it's", 512, 3983)).toBe(`…[cut at 512 of 3983 chars; rig queue show 'qitem-it'\\''s' --full]`);
expect(Array.from(printed.body).length).toBe(PREVIEW_MAX_CODEPOINTS);
});
it("show --full --json: byte-identical COMPLETE item body (compatibility contract — no preview fields)", async () => {
@@ -1,43 +0,0 @@
// The CLI twin of the daemon's slice_name_shared advisory: two missions with a slice folder of the same
// name leave one of them out of the slice list and the routes that find a slice by name. Advisory only.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { scopeCommand } from "../src/commands/scope.js";
let workspace: string;
let missionsRoot: string;
beforeEach(() => {
workspace = fs.mkdtempSync(path.join(os.tmpdir(), "cli-slice-name-audit-"));
missionsRoot = path.join(workspace, "missions");
for (const mission of ["alpha", "beta"]) {
fs.mkdirSync(path.join(missionsRoot, mission, "slices", "01-login"), { recursive: true });
fs.writeFileSync(path.join(missionsRoot, mission, "SPEC.md"), "# mission\n");
fs.writeFileSync(path.join(missionsRoot, mission, "slices", "01-login", "SPEC.md"), "# login\n");
}
});
afterEach(() => fs.rmSync(workspace, { recursive: true, force: true }));
async function audit(mission: string): Promise<{ slices: Array<{ name: string; findings: Array<{ kind: string; severity: string; message: string }> }> }> {
const buf: string[] = [];
const origWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = ((c: unknown) => { buf.push(String(c)); return true; }) as typeof process.stdout.write;
const program = new Command();
program.addCommand(scopeCommand());
program.exitOverride();
try {
await program.parseAsync(["node", "rig", "scope", "audit", "--mission", mission, "--json", "--workspace", workspace]);
} finally {
process.stdout.write = origWrite;
}
return JSON.parse(buf.join(""));
}
describe("rig scope audit — a slice name another mission also uses", () => {
it("names the other mission, as advice that blocks nothing", async () => {
const shared = (await audit("alpha")).slices.find((s) => s.name === "01-login")!.findings.find((f) => f.kind === "slice_name_shared");
expect(shared).toMatchObject({ severity: "low", message: expect.stringContaining("beta") });
});
});
-11
View File
@@ -678,17 +678,6 @@ describe("rig scope mission ls (HG-8)", () => {
const names = parsed.missions.map((m: { name: string }) => m.name).sort();
expect(names).toEqual(["backlog", "release-0.3.2"]);
});
it("counts a mission's shipped slices apart from its active ones (#2427)", async () => {
for (const nn of ["02-shipped-a", "03-shipped-b"]) {
writeFile(path.join(env.missionsRoot, "release-0.3.2", "slices", nn, "README.md"), `---\nid: OPR.0.3.2.${nn[1]}\nstatus: shipped\n---\n# ${nn}\n`);
}
const text = await run(["mission", "ls"], env.missionsRoot);
expect(text.stdout).toContain("release-0.3.2 OPR.0.3.2 active=1 shipped=2 closed=0");
const json = JSON.parse((await run(["mission", "ls", "--json"], env.missionsRoot)).stdout);
expect(json.missions.find((m: { name: string }) => m.name === "release-0.3.2"))
.toMatchObject({ activeSliceCount: 1, shippedSliceCount: 2, closedSliceCount: 0 });
});
});
describe("rig scope mission show", () => {
-38
View File
@@ -326,44 +326,6 @@ describe("listMissions + listSlices + nextSliceNN", () => {
});
});
describe("a slice's standing: in flight, delivered in place, or closed (#2427)", () => {
let root: string;
let missionsRoot: string;
beforeEach(() => {
root = mktemp();
missionsRoot = path.join(root, "missions");
writeFile(path.join(missionsRoot, "m1", "README.md"), "---\nid: OPR.9.1\n---\n# m1\n");
const slice = (dir: string, frontmatter: string) => writeFile(path.join(missionsRoot, "m1", dir, "README.md"), `---\n${frontmatter}\n---\nbody\n`);
slice("slices/01-open", "id: OPR.9.1.1\nstatus: active");
slice("slices/02-blocked", "id: OPR.9.1.2\nstatus: blocked");
slice("slices/03-shipped", "id: OPR.9.1.3\nstatus: shipped");
slice("slices/04-done", "id: OPR.9.1.4\nstatus: done");
slice("slices/05-replaced", "id: OPR.9.1.5\nstatus: active\nstage: superseded\nsuperseded-by: OPR.9.1.9");
slice("slices/06-superseded", "id: OPR.9.1.6\nstatus: superseded");
slice("closed/07-stale", "id: OPR.9.1.7\nstatus: closed-stale");
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});
it("slice ls: active is work in flight (blocked included); shipped is delivered in place; closed includes exits in place", () => {
const mission = findMission(missionsRoot, "m1");
const names = (state: "active" | "shipped" | "closed" | "all") => listSlices(mission, state).map((s) => s.name);
expect(names("active")).toEqual(["01-open", "02-blocked"]);
expect(names("shipped")).toEqual(["03-shipped", "04-done"]);
expect(names("closed")).toEqual(["05-replaced", "06-superseded", "07-stale"]);
expect(names("all")).toHaveLength(7);
});
it("mission counts give each slice one standing, the same as slice ls", () => {
const counts = { activeSliceCount: 2, shippedSliceCount: 2, closedSliceCount: 3 };
expect(listMissions(missionsRoot)[0]).toMatchObject(counts);
expect(findMission(missionsRoot, "m1")).toMatchObject(counts);
});
});
describe("findSlice + resolution variants", () => {
let root: string;
let missionsRoot: string;
@@ -481,7 +481,7 @@ describe("fresh launch preview", () => {
it("OPR.0.7.0.9: the printed plan shows the seat's launch additions, as the seat view does", async () => {
const launchAdditions = { agent: "openrig:reviewer", agentPromptReplacesDefault: true, appendSystemPromptFile: "/s/additions/system-prompt.md",
pluginDirs: ["/s/additions/plugins/review"], pluginChannels: ["plugin:example-channel@example"], missing: ["/s/gone"], refused: ["/s/linked is not inside this seat's storage"] };
pluginDirs: ["/s/additions/plugins/review"], missing: ["/s/gone"], refused: ["/s/linked is not inside this seat's storage"] };
const data = { ok: true, planOnly: true, complete: true, launchable: true, sessionName: "dev@fixture", currentOccupant: "absent",
startup: { projectionCount: 0, fileCount: 0, actionCount: 0 }, stopRequired: false, wouldStop: false, blockers: [], warnings: [], siblings: [],
launchAdditions };
@@ -491,7 +491,6 @@ describe("fresh launch preview", () => {
expect(text).toContain("agent openrig:reviewer (--agent; its prompt replaces Claude's default system prompt)");
expect(text).toContain("prompt /s/additions/system-prompt.md (--append-system-prompt-file)");
expect(text).toContain("plugin dir /s/additions/plugins/review (CLAUDE_CODE_PLUGIN_DIRS)");
expect(text).toContain("channel plugin:example-channel@example (--channels)");
expect(text).toContain("missing /s/gone: not passed to the launch");
expect(text).toContain("refused /s/linked is not inside this seat's storage: not passed to the launch");
});
@@ -189,4 +189,13 @@ describe("rig seat set-runtime", () => {
expect(errors.join("\n")).toMatch(message);
expect(exitCode).toBe(1);
});
it("a seat that has never started says nothing was switched, with no switch id, and that its first start uses the new runtime", async () => {
const { logs, exitCode } = await run({ status: 200, data: { ok: true, dryRun: false, changed: true, firstStart: true,
seat: { ref: "dev.build@team" }, from: "claude-code", to: "codex", permission: { mode: "inherit", source: "member_or_rig_policy" },
dropped: [{ setting: "model", value: "opus" }] } }, ["dev.build@team", "--runtime", "codex", "--reason", "start it on Codex"]);
expect(logs).toContain("dev.build@team has never started, so nothing was switched (no switch id): its saved runtime changed from claude-code to codex, and its first start uses it.");
expect(logs).toContain("Cleared from claude-code: model opus. Its first start takes the rest from its agent spec for codex.");
expect(logs.join("\n")).not.toMatch(/switch undefined|Queue:/);
expect(exitCode).toBeUndefined();
});
});
+7 -50
View File
@@ -260,9 +260,8 @@ const HANDOVER_RESULT = {
eventSeq: 42,
sideEffects: {
departingSessionKilled: false,
departingProcess: "not-touched",
startupContextDelivered: false,
provenanceRecordWritten: true,
provenanceRecordWritten: false,
},
};
@@ -273,9 +272,8 @@ const HANDOVER_RESULT_FRESH = {
discovery: { id: "disc-fresh", status: "claimed", tmuxSession: "dev-impl@seat-rig-h1SUCCID0", tmuxPane: "%9" },
sideEffects: {
departingSessionKilled: false,
departingProcess: "ended-graceful",
startupContextDelivered: true,
provenanceRecordWritten: true,
provenanceRecordWritten: false,
},
};
@@ -679,9 +677,8 @@ describe("rig seat status", () => {
currentStatus: { handoverResult: "complete" },
sideEffects: {
departingSessionKilled: false,
departingProcess: "not-touched",
startupContextDelivered: false,
provenanceRecordWritten: true,
provenanceRecordWritten: false,
},
});
});
@@ -718,39 +715,12 @@ describe("rig seat status", () => {
expect(output).toContain("Previous occupant: dev-impl@seat-rig");
expect(output).toContain("Current occupant: successor-session");
expect(output).toContain("Seat binding and inventory provenance were updated.");
// The receipt's side effects, as they happened: the lineage record written, the old process left alone.
expect(output).toContain("The handover's lineage record (occupant tenure) was written.");
expect(output).toContain("The previous occupant's process wasn't touched: the successor was an existing session.");
// startupContextDelivered:false — the output says nothing was delivered or carried...
expect(output).toContain("OpenRig delivered no startup context and carried no conversation: the discovered successor was already running.");
// startupContextDelivered:false — the message must include "startup context delivery" in the NOT-performed list.
expect(output).toContain("No conversation continuity, startup context delivery, provenance markdown, or session stop was performed.");
// ...and must NOT falsely claim a delivery happened.
expect(output).not.toContain("restore packet) was delivered");
});
it("handover human output says when the lineage record wasn't written, and that the old process was killed (rebuild)", async () => {
const deps = makeDeps({ status: 200, data: { ...HANDOVER_RESULT, source: { mode: "rebuild", raw: "rebuild" },
sourceOutcome: { mode: "rebuild", primedArtifacts: [], gaps: [] },
sideEffects: { departingSessionKilled: false, departingProcess: "ended-forced", startupContextDelivered: true, provenanceRecordWritten: false } } }, []);
const { logs, exitCode } = await captureLogs(async () => {
await makeCommand(deps).parseAsync(["node", "rig", "seat", "handover", "dev-impl@seat-rig", "--source", "rebuild", "--reason", "context wall"]);
});
const output = logs.join("\n");
expect(exitCode).toBeUndefined(); // the handover itself completed
expect(output).toContain("The handover's lineage record (occupant tenure) was NOT written; check the daemon log, which names the first such failure since the daemon started. The seat is already bound to the successor, so don't run the handover again.");
expect(output).toContain("The previous occupant's process didn't exit on TERM and was killed; its tmux session was kept, and the successor runs in the same pane.");
expect(output).toContain("No conversation continuity was carried: the successor started a fresh conversation.");
expect(output).not.toContain("session stop");
});
it("handover human output from an older daemon, whose receipt has no departingProcess, claims neither side effect", async () => {
const older = { departingSessionKilled: false, startupContextDelivered: false, provenanceRecordWritten: false };
const deps = makeDeps({ status: 200, data: { ...HANDOVER_RESULT, sideEffects: older } }, []);
const { logs } = await captureLogs(async () => {
await makeCommand(deps).parseAsync(["node", "rig", "seat", "handover", "dev-impl@seat-rig", "--source", "discovered:disc-1", "--reason", "mvp-proof"]);
});
expect(logs.join("\n")).not.toMatch(/lineage record|previous occupant's process/);
});
it("handover fresh human output HONESTLY reports the delivered startup context (B2)", async () => {
const deps = makeDeps({ status: 200, data: HANDOVER_RESULT_FRESH }, []);
@@ -769,7 +739,7 @@ describe("rig seat status", () => {
expect(output).toContain("Current occupant: dev-impl@seat-rig-h1SUCCID0");
// The fresh path DID deliver context — the surface must say so, not deny it.
expect(output).toContain("The captured startup context (restore packet) was delivered to the successor.");
expect(output).not.toContain("OpenRig delivered no startup context");
expect(output).not.toContain("No conversation continuity, startup context delivery,");
});
it("handover fresh human output says an unconfirmed restore packet was sent, not that nothing was delivered", async () => {
@@ -789,23 +759,10 @@ describe("rig seat status", () => {
expect(output).toContain("Seat handover complete: dev-impl@seat-rig");
expect(output).toContain(`its submission is unconfirmed: ${reason}.`);
expect(output).toContain("Check the successor's screen before sending the context again");
expect(output).not.toContain("OpenRig delivered no startup context");
expect(output).not.toContain("No conversation continuity, startup context delivery,");
expect(output).not.toContain("restore packet) was delivered");
});
it("handover human output says the successor started without a required file, not a plain complete (#2456)", async () => {
const missing = "/project/spec/culture.md";
const deps = makeDeps({ status: 200, data: { ...HANDOVER_RESULT_FRESH, startedWithoutRequired: [missing] } }, []);
const { logs, exitCode } = await captureLogs(async () => {
await makeCommand(deps).parseAsync(["node", "rig", "seat", "handover", "dev-impl@seat-rig", "--source", "fresh", "--reason", "context-wall"]);
});
const output = logs.join("\n");
expect(exitCode).toBeUndefined(); // the successor is running, so the handover itself completed
expect(logs[0]).toBe("Seat handover complete, degraded: dev-impl@seat-rig");
expect(output).toContain(`The successor started without required file ${missing}.`);
expect(output).not.toContain("Seat handover complete: dev-impl@seat-rig");
});
it("handover surfaces a loud step-named failure (successor_create_failed)", async () => {
const deps = makeDeps({
status: 500,
-41
View File
@@ -1,6 +1,5 @@
import { describe, it, expect } from "vitest";
import { wrapSendBody } from "../src/commands/send.js";
import { resolveQueueHostDestination } from "../src/commands/queue.js";
describe("wrapSendBody — pre-release CLI/daemon Item 2 (email-style envelope)", () => {
it("renders From / To / body / reply hint with both session names", () => {
@@ -11,46 +10,6 @@ describe("wrapSendBody — pre-release CLI/daemon Item 2 (email-style envelope)"
expect(out).toContain('↩ Reply: rig send driver-3@my-rig "..."');
});
it.each(["human@kernel", "human@host", "human-alice@kernel", "human-alice@host"])(
"gives %s a queue update reply with the existing human-route fields",
(sender) => {
const body = "Please check this.\nKeep the actor unchanged.";
const out = wrapSendBody(sender, "driver@rig", body, { replyToQitem: "qitem-original" });
expect(out).toContain(`From: ${sender}\nTo: driver@rig\n`);
expect(out.split("\n---\n")[1]).toBe(body);
expect(out.split("\n").at(-1)).toBe(`↩ Reply if needed: rig queue create --destination ${sender} --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`);
expect(out).not.toContain(`rig send ${sender}`);
expect(out).not.toContain("--reply-to"); // Slack-thread guidance belongs to registered external addresses.
},
);
it.each(["human@kernel@host-origin", "human-reviewer@host@host-origin", "alex@external@host-origin"])(
"a forwarded person %s gets a queue hint retaining the origin host",
(sender) => {
const body = "Original body\nkept verbatim.";
expect(resolveQueueHostDestination(sender)).toEqual({ ok: true, destination: sender.slice(0, sender.lastIndexOf("@")), hostId: "host-origin" });
const out = wrapSendBody(sender, "driver@receiver", body);
expect(out).toContain("From: " + sender + "\nTo: driver@receiver\n");
expect(out.split("\n---\n")[1]).toBe(body);
expect(out.split("\n").at(-1)).toBe("↩ Reply if needed: rig queue create --destination " + sender + ' --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify');
},
);
it("keeps host-qualified look-alike seats and malformed human triples on the ordinary hint", () => {
for (const sender of ["humanity@kernel@host-origin", "human@real-rig@host-origin", "dev-human@host@host-origin",
"human@kernel@", "human@kernel@host-origin@extra"]) {
expect(wrapSendBody(sender, "driver@receiver", "hi").split("\n").at(-1))
.toBe("↩ Reply: rig send " + sender + ' "..."');
}
});
it("keeps similarly named real seats on the ordinary reply route", () => {
for (const sender of ["human@my-rig", "humanity@kernel", "dev-human@host", "dev-human@rig@origin-host"]) {
expect(wrapSendBody(sender, "driver@rig", "hi").split("\n").at(-1))
.toBe(`↩ Reply: rig send ${sender} "..."`);
}
});
it("routes an external sender's reply through the durable human queue", () => {
const out = wrapSendBody("decision-maker@external", "driver@rig", "Decision received.");
expect(out).toContain("From: decision-maker@external");
-179
View File
@@ -285,185 +285,6 @@ describe("Send CLI", () => {
expect(output).toContain("Note: sent as exact text, without the From/To envelope, because infra-ui@term-rig is a terminal seat.");
});
it.each([
{ json: false, missingPrompt: false }, { json: true, missingPrompt: false },
{ json: false, missingPrompt: true }, { json: true, missingPrompt: true },
])("keeps the send unresolved when the post-submit capture is unusable ($json, $missingPrompt)", async ({ json, missingPrompt }) => {
let captures = 0, submits = 0;
const client = {
post: async (path: string, body: Record<string, unknown>) => {
if (path === "/api/transport/capture") {
captures++;
return captures === 1 ? { status: 200, data: { content: "❯ make test" } }
: missingPrompt ? { status: 200, data: { content: "redrawing" } }
: { status: 503, data: { error: "capture unavailable" } };
}
if (body["submitOnly"]) { submits++; return { status: 200, data: { ok: true } }; }
return { status: 200, data: { ok: true, sessionName: "infra-ui@term-rig", verified: true, outcome: "delivered", envelopeOmitted: true } };
},
} as unknown as DaemonClient;
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "infra-ui@term-rig", "make test", "--verify", ...(json ? ["--json"] : []),
]);
});
expect(submits).toBe(1);
if (json) {
const result = JSON.parse(logs.find(line => line.trim().startsWith("{")) ?? "{}");
expect(result).toMatchObject({ verified: false, outcome: "submission-unconfirmed", effectCheck: { state: "staged", remedy: "submit-unconfirmed" } });
} else {
expect(logs.join("\n")).toContain("submission unconfirmed");
expect(logs.join("\n")).not.toContain("staged text left the prompt");
}
expect(exitCode).toBe(1);
});
it.each([false, true])("reports a lost guarded Enter response as unconfirmed (json: %s)", async json => {
let captures = 0, submits = 0;
const client = {
post: async (path: string, body: Record<string, unknown>) => {
if (path === "/api/transport/capture") { captures++; return { status: 200, data: { content: "❯ make test" } }; }
if (body["submitOnly"]) { submits++; throw new Error("Enter response lost"); }
return { status: 200, data: { ok: true, sessionName: "infra-ui@term-rig", verified: true, outcome: "delivered", envelopeOmitted: true } };
},
} as unknown as DaemonClient;
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "infra-ui@term-rig", "make test", "--verify", ...(json ? ["--json"] : []),
]);
});
expect(submits).toBe(1);
expect(captures).toBe(1);
expect(exitCode).toBe(1);
if (json) {
const result = JSON.parse(logs.find(line => line.trim().startsWith("{")) ?? "{}");
expect(result).toMatchObject({ verified: false, outcome: "submission-unconfirmed", effectCheck: { remedy: "submit-unconfirmed" } });
} else expect(logs.join("\n")).toContain("submission unconfirmed");
});
it.each([false, true])("reports a failed guarded Enter after attempted input as unconfirmed (json: %s)", async json => {
let submits = 0;
const client = {
post: async (path: string, body: Record<string, unknown>) => {
if (path === "/api/transport/capture") return { status: 200, data: { content: "❯ make test" } };
if (body["submitOnly"]) { submits++; return { status: 500, data: { ok: false, reason: "submit_failed", inputPhase: "attempted", error: "submission unconfirmed" } }; }
return { status: 200, data: { ok: true, sessionName: "infra-ui@term-rig", verified: true, outcome: "delivered", envelopeOmitted: true } };
},
} as unknown as DaemonClient;
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "infra-ui@term-rig", "make test", "--verify", ...(json ? ["--json"] : []),
]);
});
expect(submits).toBe(1);
expect(exitCode).toBe(1);
if (json) {
const result = JSON.parse(logs.find(line => line.trim().startsWith("{")) ?? "{}");
expect(result).toMatchObject({ verified: false, outcome: "submission-unconfirmed", effectCheck: { remedy: "submit-unconfirmed" } });
} else expect(logs.join("\n")).toContain("submission unconfirmed");
});
it.each([
[{ reason: "submit_failed", error: "older daemon" }, "submit-unconfirmed"],
[{ reason: "staged_mismatch", sent: false, inputPhase: "not_reached", error: "changed" }, "submit-refused"],
] as const)("classifies guarded Enter failure %j as %s", async (failure, remedy) => {
const client = {
post: async (path: string, body: Record<string, unknown>) => {
if (path === "/api/transport/capture") return { status: 200, data: { content: "❯ make test" } };
if (body["submitOnly"]) return { status: 500, data: { ok: false, ...failure } };
return { status: 200, data: { ok: true, sessionName: "infra-ui@term-rig", verified: true, outcome: "delivered", envelopeOmitted: true } };
},
} as unknown as DaemonClient;
const { logs } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "infra-ui@term-rig", "make test", "--verify", "--json",
]);
});
const result = JSON.parse(logs.find(line => line.trim().startsWith("{")) ?? "{}");
expect(result.effectCheck.remedy).toBe(remedy);
expect(result.outcome).toBe("submission-unconfirmed");
});
it("does not claim the earlier staged text is still unconsumed after a refused Enter", async () => {
const client = {
post: async (path: string, body: Record<string, unknown>) => {
if (path === "/api/transport/capture") return { status: 200, data: { content: "❯ make test" } };
if (body["submitOnly"]) return { status: 409, data: { ok: false, reason: "staged_mismatch", inputPhase: "not_reached", sent: false, error: "changed" } };
return { status: 200, data: { ok: true, sessionName: "infra-ui@term-rig", verified: true, outcome: "delivered", envelopeOmitted: true } };
},
} as unknown as DaemonClient;
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "infra-ui@term-rig", "make test", "--verify",
]);
});
expect(exitCode).toBe(1);
expect(logs.join("\n")).toContain("guarded Enter was refused");
expect(logs.join("\n")).not.toContain("typed, never submitted");
});
it("reports a fan-out refusal as unconfirmed alongside a successful recipient", async () => {
let submits = 0;
const client = {
post: async (path: string, body: Record<string, unknown>) => {
if (path === "/api/transport/broadcast") return { status: 200, data: {
total: 2, sent: 2, failed: 0, results: [
{ sessionName: "seat-a@rig", ok: true, outcome: "delivered" },
{ sessionName: "seat-b@rig", ok: true, outcome: "delivered" },
],
} };
if (path === "/api/transport/capture") return { status: 200, data: {
content: body["session"] === "seat-a@rig" ? "❯ make test" : "❯ ",
} };
if (body["submitOnly"]) {
submits++;
return { status: 409, data: { ok: false, reason: "staged_mismatch", inputPhase: "not_reached", sent: false, error: "pane changed" } };
}
throw new Error("unexpected request");
},
} as unknown as DaemonClient;
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "--to", "seat-a@rig,seat-b@rig", "make test", "--verify",
]);
});
const output = logs.join("\n");
expect(output).toContain("seat-a@rig: Delivery: unconfirmed");
expect(output).toContain("guarded Enter was refused (pane changed)");
expect(output).not.toContain("staged, not consumed");
expect(output).toContain("seat-b@rig: sent");
expect(output).toContain("1/2 delivered, 1 submission-unconfirmed");
expect(submits).toBe(1);
expect(exitCode).toBe(1);
});
it("reports an unconfirmed post-submit recheck per recipient in fan-out JSON", async () => {
let captures = 0, submits = 0;
const client = {
post: async (path: string, body: Record<string, unknown>) => {
if (path === "/api/transport/broadcast") return { status: 200, data: {
total: 1, sent: 1, failed: 0, results: [{ sessionName: "infra-ui@term-rig", ok: true, verified: true, outcome: "delivered" }],
} };
if (path === "/api/transport/capture") {
captures++;
return captures === 1 ? { status: 200, data: { content: "❯ make test" } }
: { status: 503, data: { error: "capture unavailable" } };
}
if (body["submitOnly"]) { submits++; return { status: 200, data: { ok: true } }; }
throw new Error("unexpected request");
},
} as unknown as DaemonClient;
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "--to", "infra-ui@term-rig", "make test", "--verify", "--json",
]);
});
const result = JSON.parse(logs.find(line => line.trim().startsWith("{")) ?? "{}");
expect(submits).toBe(1);
expect(result).toMatchObject({ sent: 0, failed: 1, results: [{ ok: false, verified: false, outcome: "submission-unconfirmed" }] });
expect(exitCode).toBe(1);
});
it("--raw sends no exactText", async () => {
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "send", "dev-impl@my-rig", "/help", "--raw"]);
+3 -111
View File
@@ -18,8 +18,8 @@ vi.mock("../src/daemon-lifecycle.js", async () => {
};
});
function makeQueueDeps(): { deps: QueueDeps; calls: Array<{ method: string; path: string; body?: Record<string, unknown> }> } {
const calls: Array<{ method: string; path: string; body?: Record<string, unknown> }> = [];
function makeQueueDeps(): { deps: QueueDeps; calls: Array<{ method: string; path: string }> } {
const calls: Array<{ method: string; path: string }> = [];
return {
calls,
deps: {
@@ -27,7 +27,7 @@ function makeQueueDeps(): { deps: QueueDeps; calls: Array<{ method: string; path
clientFactory: () => ({
get: vi.fn(async (path: string) => { calls.push({ method: "GET", path }); return { status: 200, data: [] }; }),
getText: vi.fn(async () => ({ status: 200, data: "" })),
post: vi.fn(async (path: string, body?: Record<string, unknown>) => { calls.push({ method: "POST", path, body }); return { status: 201, data: {} }; }),
post: vi.fn(async (path: string) => { calls.push({ method: "POST", path }); return { status: 201, data: {} }; }),
delete: vi.fn(async () => ({ status: 204, data: null })),
postText: vi.fn(async () => ({ status: 200, data: "" })),
postExpectText: vi.fn(async () => ({ status: 200, data: "" })),
@@ -64,59 +64,6 @@ describe("Slice 15 — CLI contract honesty", () => {
expect(parsed.error.message).toMatch(/positive integer/);
});
const PRIORITY_VERBS: Array<[string, string[], string]> = [
["create", ["--source", "a@r", "--destination", "b@r", "--summary", "s", "--body", "x"], "/api/queue/create"],
["handoff", ["qitem-x", "--to", "b@r"], "/api/queue/qitem-x/handoff"],
["handoff-and-complete", ["qitem-x", "--to", "b@r"], "/api/queue/qitem-x/handoff-and-complete"],
];
async function sentPriority(verb: string, args: string[], path: string, flag: string[]) {
vi.stubEnv("OPENRIG_SESSION_NAME", "seat@rig"); // handoff verbs act as the calling seat
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
try {
const { deps, calls } = makeQueueDeps();
const run = await runCli(["queue", verb, ...args, ...flag, "--json"], { queueDeps: deps });
const call = calls.find((c) => c.path === path);
expect(call, JSON.stringify({ calls, run })).toBeDefined();
return { body: call!.body!, notes: stderr.mock.calls.map((c) => c.join(" ")).filter((l) => l.includes("--priority")) };
} finally {
stderr.mockRestore();
stdout.mockRestore();
vi.unstubAllEnvs();
}
}
describe.each(PRIORITY_VERBS)("queue %s --priority", (verb, args, path) => {
it.each([["Critical", "critical"], ["URGENT", "urgent"], ["Routine", "routine"], ["critical", "critical"]])(
"normalizes the documented value %s without a note", async (given, expected) => {
const { body, notes } = await sentPriority(verb, args, path, ["--priority", given]);
expect(body.priority).toBe(expected);
expect(notes).toEqual([]);
});
it.each(["high", "normal", "background", "P1-Hot"])("keeps %s as given, with one note", async (given) => {
const { body, notes } = await sentPriority(verb, args, path, ["--priority", given]);
expect(body.priority).toBe(given);
expect(notes).toHaveLength(1);
expect(notes[0]).toContain("outside the documented choices");
expect(notes[0]).not.toContain("invalid");
});
});
it("omitting --priority keeps create's routine default, without a note", async () => {
const [verb, args, path] = PRIORITY_VERBS[0]!;
const { body, notes } = await sentPriority(verb, args, path, []);
expect(body.priority).toBe("routine");
expect(notes).toEqual([]);
});
it.each(PRIORITY_VERBS.slice(1))("omitting --priority on %s sends none, so the priority is inherited", async (verb, args, path) => {
const { body, notes } = await sentPriority(verb, args, path, []);
expect(body.priority).toBeUndefined();
expect(notes).toEqual([]);
});
it("without --json, the same failure is plain text on stderr (no JSON on stdout) + nonzero exit", async () => {
const { deps } = makeQueueDeps();
const { out, err, exitCode } = await runCli(["queue", "list", "--limit", "-1"], { queueDeps: deps });
@@ -125,61 +72,6 @@ describe("Slice 15 — CLI contract honesty", () => {
expect(err).toMatch(/positive integer/);
});
it.each([
["pending, in_progress", ["in_progress"], "pending, in_progress"],
["bogus,also_bogus", ["bogus", "also_bogus"], "bogus,also_bogus"],
])("queue list --state %j warns on stderr and still runs the query as given", async (state, unknown, sent) => {
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
try {
const { deps, calls } = makeQueueDeps();
const { exitCode } = await runCli(["queue", "list", "--state", state, "--json"], { queueDeps: deps });
expect(exitCode).toBe(0);
const warning = stderr.mock.calls.map((c) => c.join(" ")).join("\n");
for (const name of unknown) expect(warning).toContain(`'${name}'`);
expect(warning).toContain("Supported states: pending, in-progress, done");
if (state.includes("pending")) expect(warning).not.toContain("empty queue");
else expect(warning).toContain("doesn't mean an empty queue");
const listCalls = calls.filter((c: { path: string }) => c.path.startsWith("/api/queue/list"));
expect(listCalls).toHaveLength(1);
expect(new URLSearchParams(listCalls[0]!.path.split("?")[1]).get("state")).toBe(sent);
expect(JSON.parse(stdout.mock.calls.map((c) => c.join(" ")).join("\n"))).toEqual([]);
} finally {
stderr.mockRestore();
stdout.mockRestore();
}
});
it.each(["pending", " pending , in-progress ", "done,handed-off,failed,denied,canceled,blocked", "pending,", " "])(
"queue list --state %j runs without a warning", async (state) => {
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
try {
const { deps, calls } = makeQueueDeps();
const { exitCode } = await runCli(["queue", "list", "--state", state, "--json"], { queueDeps: deps });
expect(exitCode).toBe(0);
expect(stderr).not.toHaveBeenCalled();
expect(calls.filter((c: { path: string }) => c.path.startsWith("/api/queue/list"))).toHaveLength(1);
} finally {
stderr.mockRestore();
stdout.mockRestore();
}
});
it("queue list without --state sends no state filter and no warning", async () => {
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
try {
const { deps, calls } = makeQueueDeps();
await runCli(["queue", "list", "--json"], { queueDeps: deps });
expect(stderr).not.toHaveBeenCalled();
expect(calls.find((c: { path: string }) => c.path.startsWith("/api/queue/list"))!.path).not.toContain("state=");
} finally {
stderr.mockRestore();
stdout.mockRestore();
}
});
it("the path is SHARED across families — a send-family error also emits JSON under --json", async () => {
const { out, exitCode } = await runCli(["send", "--json", "--no-such-flag"]);
expect(exitCode).toBe(1);
-17
View File
@@ -7,23 +7,6 @@ const clientFor = (entries: object[]) => ({ get: vi.fn(async () => ({ status: 20
afterEach(() => vi.restoreAllMocks());
describe("first-project compatibility alias", () => {
it("keeps same-name user and built-in specs ambiguous when both are visible", async () => {
const user = { ...starter, id: "user-id", sourceType: "user_file" };
await expect(resolveLibrarySpec(clientFor([starter, user]), "starter", { kind: "rig" })).rejects.toThrow("ambiguous");
});
it("finds an explicitly requested hidden built-in without shadowing a visible user spec", async () => {
const user = { ...starter, id: "user-id", sourceType: "user_file" };
const get = vi.fn(async (url: string) => ({ status: 200, data: url.includes("include_builtins=true") ? [starter, user] : [user] }));
const client = { get } as unknown as DaemonClient;
expect(await resolveLibrarySpec(client, "starter", { kind: "rig" })).toEqual(user);
expect(await resolveLibrarySpec(client, "starter-id", { kind: "rig" })).toEqual(starter);
expect(get).toHaveBeenCalledWith("/api/specs/library?kind=rig&include_builtins=true");
});
it("prefers a hidden exact ID over a visible spec with that name", async () => {
const user = { ...starter, id: "user-id", name: "starter-id", sourceType: "user_file" };
const get = vi.fn(async (url: string) => ({ status: 200, data: url.includes("include_builtins=true") ? [starter, user] : [user] }));
expect(await resolveLibrarySpec({ get } as unknown as DaemonClient, "starter-id")).toEqual(starter);
});
it("resolves the built-in starter without creating a second library entry, with a stderr notice", async () => {
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
+1 -20
View File
@@ -60,7 +60,6 @@ const RIG_REVIEW = {
describe("Specs CLI", () => {
let server: http.Server;
let port: number;
let lastLibraryUrl = "";
const libraryRequests: string[] = [];
beforeAll(async () => {
@@ -69,7 +68,6 @@ describe("Specs CLI", () => {
libraryRequests.push(rawUrl);
const url = decodeURIComponent(rawUrl);
if (url === "/api/specs/library" || url.startsWith("/api/specs/library?")) {
lastLibraryUrl = url;
res.writeHead(200, { "Content-Type": "application/json" });
const kind = new URL(`http://localhost${url}`).searchParams.get("kind");
const filtered = kind ? LIBRARY_ENTRIES.filter((e) => e.kind === kind) : LIBRARY_ENTRIES;
@@ -139,13 +137,6 @@ describe("Specs CLI", () => {
expect(parsed.length).toBe(2);
});
it("requests hidden built-ins only when explicitly asked", async () => {
await captureLogs(async () => { await makeCmd().parseAsync(["node", "rig", "specs", "ls", "--include-builtins", "--kind", "rig"]); });
const params = new URL(`http://localhost${lastLibraryUrl}`).searchParams;
expect(params.get("kind")).toBe("rig");
expect(params.get("include_builtins")).toBe("true");
});
it("specs ls --kind warns on unrecognized kind and still queries daemon", async () => {
const requestStart = libraryRequests.length;
const { stdout, stderr, exitCode } = await captureOutputs(async () => {
@@ -174,16 +165,6 @@ describe("Specs CLI", () => {
expect(libraryRequests.slice(requestStart)).toContain("/api/specs/library?kind=rig%23draft");
});
it("filters an unknown kind even when built-ins are requested", async () => {
const { stdout, stderr } = await captureOutputs(async () => {
await makeCmd().parseAsync(["node", "rig", "specs", "ls", "--kind", "rig&kind=agent", "--include-builtins", "--json"]);
});
expect(stderr.join("\n")).toContain("Warning: unrecognized --kind");
expect(JSON.parse(stdout.join("\n"))).toEqual([]);
expect(new URL(`http://localhost${libraryRequests.at(-1)}`).searchParams.get("kind")).toBe("rig&kind=agent");
expect(new URL(`http://localhost${libraryRequests.at(-1)}`).searchParams.get("include_builtins")).toBe("true");
});
it.each(["rig", "agent", "workflow"])("specs ls --kind %s runs without warning", async (kind) => {
const requestStart = libraryRequests.length;
const { stderr, exitCode } = await captureOutputs(async () => {
@@ -250,7 +231,7 @@ describe("Specs CLI", () => {
it("specs show --kind resolves same-name rig/workflow entries", async () => {
const dupServer = http.createServer((req, res) => {
const url = decodeURIComponent(req.url ?? "");
if (url.startsWith("/api/specs/library?kind=rig")) {
if (url === "/api/specs/library?kind=rig") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ ...LIBRARY_ENTRIES[0], id: "rig-same", kind: "rig", name: "conveyor" }]));
} else {
@@ -1,16 +0,0 @@
import { describe, expect, it } from "vitest";
import type { DaemonClient } from "../src/client.js";
import { resolveDefaultAgentRef } from "../src/commands/topology-default-agent.js";
describe("default agent lookup", () => {
it("finds the built-in orchestrator when inventory hides built-ins", async () => {
const client = {
get: async (url: string) => ({
data: url.includes("include_builtins=true")
? [{ kind: "agent", name: "orchestrator", sourceType: "builtin", sourcePath: "/builtin/orchestrator/agent.yaml" }]
: [],
}),
} as unknown as DaemonClient;
expect(await resolveDefaultAgentRef(client, "codex")).toBe("path:/builtin/orchestrator");
});
});
+1 -1
View File
@@ -1408,7 +1408,7 @@ describe("Up CLI", () => {
let body = "";
for await (const chunk of req) body += chunk;
const url = decodeURIComponent(req.url ?? "");
if (url.startsWith("/api/specs/library?kind=rig")) {
if (url === "/api/specs/library?kind=rig") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "rig1", kind: "rig", name: "conveyor", sourceType: "builtin", sourcePath: "/specs/rigs/conveyor/rig.yaml" }]));
} else if (url === "/api/rigs/summary") {
-64
View File
@@ -320,22 +320,6 @@ describe("rig walk — per-piece consumption verification (RED-first, mechanics-
expect(result.logs.some(l => l.includes('"consumptionVerified":true'))).toBe(succeeds);
});
it("reports a lost guarded submit response as unconfirmed without retrying", async () => {
const w: ScriptedWorld = { record: { generationId: "g1", content: "" }, pane: "", sends: [], gets: [] };
w.sendBehavior = (body) => {
if (body["text"] !== undefined) {
w.pane = `❯ ${String(body["text"])} `;
return { status: 200, data: { ok: true } };
}
return "throw";
};
const { errLogs, exitCode } = await captureLogs(() => makeCmd(consumptionDeps(w)).parseAsync(walkArgs));
expect(exitCode).toBe(1);
expect(w.sends.filter(s => s.path === "/api/transport/send" && s.body["submitOnly"] === true)).toHaveLength(1);
expect(w.sends.filter(s => s.path === "/api/transport/send" && s.body["text"] !== undefined)).toHaveLength(1);
expect(errLogs.join("\n")).toContain("unconfirmed");
});
it.each(["during-send", "between-pieces"])("generation changes %s cannot produce a verified walk", async when => {
const w: ScriptedWorld = { record: { generationId: "g1", content: "" }, pane: "", sends: [], gets: [] };
w.sendBehavior = b => {
@@ -349,7 +333,6 @@ describe("rig walk — per-piece consumption verification (RED-first, mechanics-
expect(result.exitCode).toBe(1);
expect(w.sends.filter(s => s.path === "/api/transport/send")).toHaveLength(1);
expect(result.logs.some(l => l.includes('"consumptionVerified":true'))).toBe(false);
if (when === "during-send") expect(result.errLogs.join("\n")).toMatch(/delivery is unconfirmed/i);
});
it("PIN W1 — a typed-but-never-consumed piece FAILS LOUD naming the piece; the next piece is never sent [GREEN — consumption verification]", async () => {
@@ -365,31 +348,6 @@ describe("rig walk — per-piece consumption verification (RED-first, mechanics-
expect(errLogs.join("\n")).toMatch(/consum/i); // names the failure class
});
it("keeps delivery unknown when the send times out and neither record nor pane proves its effect", async () => {
const w: ScriptedWorld = { record: { generationId: "g1", content: "" }, pane: "", sends: [], gets: [], sendBehavior: () => "throw" };
const { errLogs, exitCode } = await captureLogs(() => makeCmd(consumptionDeps(w)).parseAsync(walkArgs));
expect(exitCode).toBe(1);
expect(w.sends.filter(s => s.path === "/api/transport/send")).toHaveLength(1);
expect(errLogs.join("\n")).toMatch(/unconfirmed/i);
expect(errLogs.join("\n")).not.toMatch(/delivery lost/i);
expect(errLogs.join("\n")).toMatch(/before.*resend/i);
});
it("reports a generation change after the guarded Enter without claiming the piece was lost", async () => {
const w: ScriptedWorld = { record: { generationId: "g1", content: "" }, pane: "", sends: [], gets: [] };
w.sendBehavior = body => {
if (body["text"] !== undefined) w.pane = `❯ ${String(body["text"]).slice(0, 40)}`;
else w.record.generationId = "g2";
return { status: 200, data: { ok: true } };
};
const { errLogs, exitCode } = await captureLogs(() => makeCmd(consumptionDeps(w)).parseAsync(walkArgs));
expect(exitCode).toBe(1);
expect(w.sends.filter(s => s.path === "/api/transport/send" && s.body["submitOnly"] === true)).toHaveLength(1);
expect(w.sends.filter(s => s.path === "/api/transport/send" && s.body["text"] !== undefined)).toHaveLength(1);
expect(errLogs.join("\n")).toMatch(/generation rolled.*unconfirmed/i);
expect(errLogs.join("\n")).not.toMatch(/never entered|delivery lost/i);
});
it("PIN W2 — staged text detected -> exactly ONE submit retry (bare Enter, never a piece re-send), then consumed -> walk proceeds [GREEN — the retry path]", async () => {
const w: ScriptedWorld = { record: { generationId: "g1", content: "" }, pane: "", sends: [], gets: [] };
w.sendBehavior = (b) => {
@@ -414,28 +372,6 @@ describe("rig walk — per-piece consumption verification (RED-first, mechanics-
expect(enterRetries).toHaveLength(2); // exactly one Enter per staged piece
});
it.each([
[{ reason: "submit_failed", inputPhase: "attempted", error: "reply lost" }, "unconfirmed"],
[{ reason: "submit_failed", error: "old daemon" }, "unconfirmed"],
[{ reason: "staged_mismatch", inputPhase: "not_reached", sent: false, error: "changed" }, "refused"],
] as const)("classifies a guarded submit error %j as %s", async (failure, wording) => {
const w: ScriptedWorld = { record: { generationId: "g1", content: "" }, pane: "", sends: [], gets: [] };
w.sendBehavior = (body) => {
if (body["text"] !== undefined) {
w.pane = `❯ ${String(body["text"])} `;
return { status: 200, data: { ok: true } };
}
return { status: 500, data: { ok: false, ...failure } };
};
const { errLogs, exitCode } = await captureLogs(() => makeCmd(consumptionDeps(w)).parseAsync(walkArgs));
expect(exitCode).toBe(1);
expect(w.sends.filter(s => s.path === "/api/transport/send" && s.body["submitOnly"] === true)).toHaveLength(1);
expect(w.sends.filter(s => s.path === "/api/transport/send" && s.body["text"] !== undefined)).toHaveLength(1);
expect(errLogs.join("\n")).toContain(wording);
expect(errLogs.join("\n")).not.toMatch(/typed but not consumed/i);
if (wording === "unconfirmed") expect(errLogs.join("\n")).not.toMatch(/was refused/i);
});
it("PIN W3 — a client timeout with server-side completion reconciles BY EFFECT: no re-send, walk proceeds [GREEN — reconcile-by-effect]", async () => {
const w: ScriptedWorld = { record: { generationId: "g1", content: "" }, pane: "", sends: [], gets: [] };
w.sendBehavior = (b) => {
+2 -4
View File
@@ -6,7 +6,7 @@ The image recipe is an input recipe, not a published prebuilt image or a reprodu
## Install the reference and work connection
Run the environment commands from your managed OpenRig seat. In a plain shell, set `OPENRIG_SESSION_NAME` to the actual acting `member@rig` address, for example `export OPENRIG_SESSION_NAME=operator-agent@kernel` when operating as that member. The CLI uses this existing convention to stamp `X-OpenRig-Session`; it attributes requests within the daemon's trusted transport, and is not authentication. The work and activity credentials below are separate authorities, not replacements for this actor attribution.
Run the environment commands from your managed OpenRig seat. In a plain shell, set `OPENRIG_SESSION_NAME` to the actual acting `member@rig` address, for example `export OPENRIG_SESSION_NAME=operator@kernel` when operating as that member. The CLI uses this existing convention to stamp `X-OpenRig-Session`; it attributes requests within the daemon's trusted transport, and is not authentication. The work and activity credentials below are separate authorities, not replacements for this actor attribution.
Start an MCP HTTP listener using `rig mcp serve --http --help` and the normal member-grant file on this daemon host. Select a guest-reachable address and explicit port. Setup adds one non-owner, one-seat, one-rig work grant in that existing file and stores its bearer credential in a private managed file. It prints only the file reference. Existing files/presets with different bytes are preserved.
@@ -17,7 +17,7 @@ rig environment setup --directory ./openshell-example --binding worker-one \
--guest-base-url http://host.openshell.internal:7782 --json
```
Enable the daemon's separate guest activity listener with `OPENRIG_GUEST_ACTIVITY_PORT=7782` and a guest-reachable `OPENRIG_GUEST_ACTIVITY_HOST` in the daemon launch environment. The port must differ from the daemon's main port, or the daemon refuses to start, and the host defaults to `127.0.0.1`. It is disabled by default; this is an explicit additional listener, not a change to the main API's binding. The bind host must be an address on the daemon host that the guest can actually reach; a listener bound only to `127.0.0.1` is not reachable through a host alias that maps to a different interface. Set `OPENRIG_GUEST_ACTIVITY_HOST` to that selected host address and use its guest-visible name in `--guest-base-url`. With rootless networking, verify the host alias's route rather than assuming it reaches loopback. Bind the MCP listener to its separately selected guest-reachable address as well. The listener serves only `POST /api/activity/hooks` and the three permission-request routes (`POST /api/activity/permission-requests`, `GET /api/activity/permission-requests/<askId>` and `POST /api/activity/permission-requests/<askId>/withdraw`), using the seat/generation grant. It exposes no queue, config, event-stream or parked-seat reads and refuses the install-wide token. Use the actual guest-reachable MCP and guest-activity ports. Setup generates `policy.yaml` for those two selected host/port pairs, without blanket egress. Never grant the guest access to the daemon's main API port (normally 7433). OpenShell's REST policy applies to a host and port: the dedicated listener confines the surface, while its activity grant and the separate MCP work grant authorize their respective requests. The activity rule names `/usr/bin/node`, matching the selected plugin hook interpreter. Qualify that path in your image. The separate read-only `platform.claude.com` rule is for the pinned native startup check and carries no inference credential.
Enable the daemon's separate guest activity listener with `OPENRIG_GUEST_ACTIVITY_PORT=7782` and a guest-reachable `OPENRIG_GUEST_ACTIVITY_HOST` in the daemon launch environment. It is disabled by default; this is an explicit additional listener, not a change to the main API's binding. The bind host must be an address on the daemon host that the guest can actually reach; a listener bound only to `127.0.0.1` is not reachable through a host alias that maps to a different interface. Set `OPENRIG_GUEST_ACTIVITY_HOST` to that selected host address and use its guest-visible name in `--guest-base-url`. With rootless networking, verify the host alias's route rather than assuming it reaches loopback. Bind the MCP listener to its separately selected guest-reachable address as well. The listener serves only `POST /api/activity/hooks` and the three permission-request routes (`POST /api/activity/permission-requests`, `GET /api/activity/permission-requests/<askId>` and `POST /api/activity/permission-requests/<askId>/withdraw`), using the seat/generation grant. It exposes no queue, config, event-stream or parked-seat reads and refuses the install-wide token. Use the actual guest-reachable MCP and guest-activity ports. Setup generates `policy.yaml` for those two selected host/port pairs, without blanket egress. Never grant the guest access to the daemon's main API port (normally 7433). OpenShell's REST policy applies to a host and port: the dedicated listener confines the surface, while its activity grant and the separate MCP work grant authorize their respective requests. The activity rule names `/usr/bin/node`, matching the selected plugin hook interpreter. Qualify that path in your image. The separate read-only `platform.claude.com` rule is for the pinned native startup check and carries no inference credential.
Import `claude-code-provider.yaml` using OpenShell's provider-profile commands, then create a provider with its `ANTHROPIC_API_KEY` environment lookup. See `openshell provider --help`; keep the master credential in provider custody, never an argument or source file. `create` passes that provider reference with `--no-auto-providers`. The guest helper saves only the provider-injected reference in a private file and uses Claude's `apiKeyHelper`. It does not copy an owner authentication home.
@@ -122,8 +122,6 @@ The first source tests use fake provider transport, the actual guest helper's fi
Driver protocol 1 is documented by `driver-v1.schema.json` and the daemon's `EnvironmentDriver` interface. Core owns placement provenance, operation intent, resolved-file selection, selected task completion and receipts; the driver owns fixed OpenShell CLI transport and guest path mapping. There is no generic plugin loader or alternative task store. Projection is bounded to 4096 files, 1 MiB per file and 32 MiB total. Unsupported runtime resources/subagents refuse the selected launch explicitly; ordinary host runtimes keep their existing behavior. Cross-host migration, RAM snapshots and arbitrary native-store portability remain outside this reference.
Startup uses a private, hash-checked `control/first-input.txt` file and a bounded initial prompt pointing Claude to it. Core prepares the guest with `--prepare --first-input-required`; an older helper rejects that option before publishing the launch latch. Rebuild the maintained image with this helper when updating the worker. Startup identity, projected role/context references and applicable text actions never pass through the host viewer's input. Native ingestion remains separately observed; a passed input is not proof that Claude read it. Viewer readiness checks the recorded guest execution, and opening the viewer checks live resource identity and generation without repeating full projection validation.
## Lifecycle and restore limits
The placement name is supplied by the caller. Create refuses an already-listed name, and each binding retains its one selected name; OpenRig does not reserve names globally across bindings. Immediately before stop/delete, OpenRig checks the saved immutable identity, then the pinned vendor call addresses the name and workspace. A deliberate reuse of that name between inspection and the vendor call could affect the replacement. The pinned CLI has no expected-id condition for these calls. Later reconciliation cannot undo that mutation.
-13
View File
@@ -111,11 +111,6 @@ def descriptor(root, pending=False):
data = regular(root, file["path"])
if len(data) != file["bytes"] or digest(data) != file["sha256"]:
raise Refused("environment_projection_changed")
first_input = value.get("firstInput")
if first_input:
data = regular(root, first_input["path"])
if digest(data) != first_input["sha256"] or len(data) != first_input["bytes"]:
raise Refused("environment_projection_changed")
# P1-P3 never silently replace a requested native conversation with a fresh one.
if value.get("resumeToken"):
if value.get("nativeType") != "community:openshell-claude" or value["resumeToken"] != value["nativeId"]:
@@ -182,11 +177,6 @@ def launch(root):
argv.extend(["--effort", value["effort"]])
if value.get("permissionMode"):
argv.extend(["--permission-mode", value["permissionMode"]])
if value.get("firstInput"):
# A bounded file reference is the initial user turn; no startup bytes are typed through a host viewer.
argv.append("Read " + str(safe(root, value["firstInput"]["path"])) + " as your selected OpenRig startup input. "
"It contains your identity, role/context references and instructions. Use the authenticated OpenRig MCP tools "
"inside this guest; host-only rig CLI commands in context require their MCP equivalents.")
work = safe(root, "work")
work.mkdir(exist_ok=True)
os.chdir(work)
@@ -232,7 +222,6 @@ def main():
p.add_argument("--previous")
p.add_argument("--mode", choices=("0600", "0700"), default="0600")
p.add_argument("--prepare", action="store_true")
p.add_argument("--first-input-required", action="store_true")
p.add_argument("--identity", action="store_true")
p.add_argument("--stop-native")
p.add_argument("--generation")
@@ -266,8 +255,6 @@ def main():
return {"ok": True, "sha256": digest(data), "bytes": len(data)}
if args.prepare:
value = descriptor(root, pending=True)
if args.first_input_required and not value.get("firstInput"):
raise Refused("environment_descriptor_invalid")
encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
published = safe(root, "control/launch.json")
if published.exists():
@@ -10,4 +10,3 @@ limits:
launch_result: true
resume: true
passes_model: true
first_input: true
@@ -73,15 +73,6 @@ This is the system's truth-source for "how did the current occupant get
there." Without it, the control plane shows the current occupant but
not the legitimacy of the transition.
`rig seat handover`'s receipt reports it under `sideEffects`:
`provenanceRecordWritten` says whether this handover's lineage record (its
occupant tenure) exists, and `departingProcess` says how the old process
ended (`ended-graceful`, `ended-forced`, or `not-touched` for a discovered
successor). If `provenanceRecordWritten` is false, the seat is already bound
to the successor and only its lineage record is missing: check the daemon
log, which names the first such failure since the daemon started, and don't
run the handover again, which would replace the occupant a second time.
## State models — independent
### Occupant-creation state (per candidate)
@@ -94,7 +85,7 @@ run the handover again, which would replace the occupant a second time.
1. **Stable** — current occupant attached, no in-flight binding
2. **Binding** — handover in progress
3. **Bound** — handover succeeded; provenance record written (or missing, when the receipt says so: see failure mode 3)
3. **Bound** — handover succeeded; provenance record written
4. **Unchanged** — bind failed before completion; seat retains old occupant
A seat stays `Stable` even if multiple candidate-occupants were produced and discarded.
@@ -103,7 +94,7 @@ A seat stays `Stable` even if multiple candidate-occupants were produced and dis
1. **Candidate creation failed** — `rebuild` couldn't synthesize from artifacts; `fork` couldn't resolve `session_source`; `fresh` couldn't launch. **Action**: bind operation does not begin; seat unchanged; provenance records the failed candidate-creation step.
2. **Old occupant cannot be detached cleanly** — runtime hung, tmux locked, etc. **Action**: bind halts mid-flight; seat enters `Binding` state with explicit "halted" sub-status; operator alerted. **Do NOT auto-rollback by reattaching old-occupant if detach didn't complete cleanly.**
3. **Bind succeeded but the lineage record didn't write** — disk/db error; the receipt says `provenanceRecordWritten: false`. **Action**: the binding is committed and the successor is the occupant; only its lineage record is missing. Report it as bound with the record missing, check the daemon log (it names the first such failure since the daemon started), and don't run the handover again.
3. **Bind succeeded but provenance write failed** — disk/db error. **Action**: not durable until provenance writes; treat as `Binding` halted, not `Bound`.
4. **Old occupant disposition unfulfillable** — operator requested `advise` (keep alive as advisor) but runtime can't keep old alive. **Action**: degrade to `retire` with explicit notification, OR fail if operator passed strict-disposition flag.
5. **Concurrent handover attempts** — two operations target the same seat. **Action**: serialize by seat-id lock; second attempt refuses with clear error.
@@ -113,7 +104,7 @@ A seat stays `Stable` even if multiple candidate-occupants were produced and dis
- **Do NOT introduce successor-suffix seat names** (`lead2`/`lead3`). Stable seat identity is the architectural goal. The live address stays stable. A retired tenure is distinguished by its ledger
generation and exact history token; preserving it does not require a renamed live pane.
- **Do NOT report a handover's provenance record as written** unless its receipt says `provenanceRecordWritten: true`; a bound seat whose record is missing is reported as exactly that (failure mode 3).
- **Do NOT report `seatBindingOutcome: handed_over`** if the provenance record didn't write durably.
- **Do NOT auto-rollback a half-completed handover** by re-attaching the old occupant unless detach completed cleanly first.
## Composition and current command surface
@@ -537,9 +537,7 @@ not have to tear one down to change it, and you rarely have to start from nothin
- **A Claude member can bring 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 own storage, never the project, so one seat in a shared repository can have its own
persona, prompt or plugins. A bundle's before-install view lists them. `plugin_channels`
(`plugin:<name>@<marketplace>` entries) registers that seat alone for those plugins' channel
notifications, through Claude's `--channels`.
persona, prompt or plugins. A bundle's before-install view lists them.
- **A new Claude member can name a prepared subscription token** with
`claude_token: {label: name, file: /abs/token}` beside its `env.CLAUDE_CONFIG_DIR`; the seat stores
the file reference and reads it at launch. A missing/unreadable token uses the existing fallback
@@ -1454,6 +1452,6 @@ deliberate writing.
**`rig --help`**, then `rig <verb> --help`.
Ninety-one verbs ship, and this page named most of them once. **You will not remember which — you
Ninety verbs ship, and this page named most of them once. **You will not remember which — you
are meant to remember only that the list is long enough to be worth reading before you build
anything.** The question is never *how do I write this*; it is **does this already exist**.
@@ -98,10 +98,9 @@ node "$SKILL_DIR/scripts/backup-sqlite.mjs" \
--destination "/safe/path/openrig-before-upgrade.sqlite"
```
The helper refuses to overwrite a destination, uses the `sqlite3` CLI `.backup`
command when available (falling back to an in-process backup via `node:sqlite`
when the CLI is absent), and verifies the backup with `PRAGMA integrity_check`.
It does not stop the daemon or restore a database.
The helper refuses to overwrite a destination, uses SQLite's backup operation,
and verifies the backup with `PRAGMA integrity_check`. It does not stop the
daemon or restore a database.
### Plan or apply safe managed-plugin refreshes
@@ -4,28 +4,6 @@ import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
// Suppress experimental warning for node:sqlite to keep stderr clean for JSON errors
const originalEmitWarning = process.emitWarning;
process.emitWarning = (warning, ...args) => {
if (typeof warning === "string" && warning.includes("SQLite is an experimental feature")) {
return;
}
if (warning && typeof warning === "object" && warning.name === "ExperimentalWarning" && warning.message?.includes("SQLite")) {
return;
}
return originalEmitWarning.call(process, warning, ...args);
};
let sqliteModule;
let DatabaseSync;
try {
sqliteModule = await import("node:sqlite");
DatabaseSync = sqliteModule.DatabaseSync;
} catch {
sqliteModule = undefined;
DatabaseSync = undefined;
}
function argument(name) {
const index = process.argv.indexOf(name);
return index === -1 ? undefined : process.argv[index + 1];
@@ -55,76 +33,22 @@ if (destinationPath.includes("'") || destinationPath.includes("\n")) {
}
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
const sqliteBin = process.env.OPENRIG_SQLITE_BIN || "sqlite3";
const probe = spawnSync(sqliteBin, ["-version"], { encoding: "utf8" });
const hasCli = probe.status === 0;
let integrity;
if (hasCli) {
const backup = spawnSync(sqliteBin, [sourcePath, `.backup '${destinationPath}'`], { encoding: "utf8" });
if (backup.status !== 0) {
fs.rmSync(destinationPath, { force: true });
fail(
backup.stderr?.trim() || backup.stdout?.trim() || backup.error?.message || "sqlite3 backup failed",
`run ${sqliteBin} against the source directly, resolve locking/path/tooling errors, then choose a fresh destination`,
);
}
const check = spawnSync(sqliteBin, [destinationPath, "PRAGMA integrity_check;"], { encoding: "utf8" });
integrity = check.stdout?.trim();
if (check.status !== 0 || integrity !== "ok") {
fail(
check.stderr?.trim() || `backup integrity result was ${JSON.stringify(integrity)}`,
"preserve the failed backup for diagnosis only; do not use it for rollback",
);
}
} else if (DatabaseSync) {
try {
const srcDb = new DatabaseSync(sourcePath, { readOnly: true });
try {
if (typeof sqliteModule?.backup === "function") {
await sqliteModule.backup(srcDb, destinationPath);
} else {
srcDb.exec(`VACUUM INTO '${destinationPath}';`);
}
} finally {
srcDb.close();
}
} catch (err) {
fs.rmSync(destinationPath, { force: true });
fail(
err?.message || "sqlite backup failed via node:sqlite",
"resolve locking/path/tooling errors, then choose a fresh destination",
);
}
try {
const backupDb = new DatabaseSync(destinationPath, { readOnly: true });
try {
const rows = backupDb.prepare("PRAGMA integrity_check;").all();
integrity = rows[0]?.integrity_check;
} finally {
backupDb.close();
}
} catch (err) {
fail(
err?.message || `backup integrity result was ${JSON.stringify(integrity)}`,
"preserve the failed backup for diagnosis only; do not use it for rollback",
);
}
if (integrity !== "ok") {
fail(
`backup integrity result was ${JSON.stringify(integrity)}`,
"preserve the failed backup for diagnosis only; do not use it for rollback",
);
}
} else {
const sqlite = process.env.OPENRIG_SQLITE_BIN || "sqlite3";
const backup = spawnSync(sqlite, [sourcePath, `.backup '${destinationPath}'`], { encoding: "utf8" });
if (backup.status !== 0) {
fs.rmSync(destinationPath, { force: true });
fail(
"neither sqlite3 CLI nor node:sqlite is available to create a backup",
"install sqlite3 CLI or run on Node with node:sqlite support",
backup.stderr?.trim() || backup.stdout?.trim() || backup.error?.message || "sqlite3 backup failed",
`run ${sqlite} against the source directly, resolve locking/path/tooling errors, then choose a fresh destination`,
);
}
const check = spawnSync(sqlite, [destinationPath, "PRAGMA integrity_check;"], { encoding: "utf8" });
const integrity = check.stdout?.trim();
if (check.status !== 0 || integrity !== "ok") {
fail(
check.stderr?.trim() || `backup integrity result was ${JSON.stringify(integrity)}`,
"preserve the failed backup for diagnosis only; do not use it for rollback",
);
}
@@ -109,8 +109,7 @@ configurations, as below; do not take the pin from a cached page summary.
handoff carries, such as a trust or hook-review prompt they declined.
2. **Where it works.** If the folder is missing, ask which folder the team should
work in (usually a clone of their repository) and use its absolute path. Your
own working directory is OpenRig's: the workspace's `kernel` folder (on an older
install, the workspace itself), not their project, so never launch with
own working directory is OpenRig's workspace, not their project, so never launch with
`--cwd .` from here.
3. **Three teams, one recommendation.** Present starter, workshop and factory,
recommend one with a short reason tied to their goal, and show each graph
@@ -7,7 +7,7 @@ fleet can pick them up.
## What you do
- Inspect new stream items with `rig stream list --json`. When stream items carry `--hint-destination`, route by that hinted destination seat. Hint-destination
- Inspect new stream items with `rig stream list --json`. Hint-destination
filtering is exact, so `?` does not mean unassigned. Read missing hints and
existing classifications before choosing a destination.
- For each: decide the destination seat (which rig + which member),
@@ -19,18 +19,16 @@ import { assessNativeResumeProbe, hasClaudeDrawnInput } from "../domain/native-r
import { resumeProbeEvidence } from "../domain/native-resume-probe-evidence.js";
import { resolveReadinessTimeoutMs } from "../domain/readiness-timeout.js";
import { subtreeNamesClaudeExecutable, claudeCommandSessionToken, listNativeProcesses, observeClaudeDelivery, observeClaudePaneProcess, observeClaudePaneRuntime, observeCodexPaneProcess, verifyClaudePaneProcess, verifyCodexPaneProcess, verifyPaneIdleShell, type ClaudeLaunchedProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
import { isPerSeatRoleGuidance, perSeatRoleSkipWarning, DEFAULT_CLAUDE_MANAGED_BLOCK_FILE, type ClaudeManagedBlockFile } from "../domain/managed-blocks.js";
import { writeManagedGuidanceBlock, type ManagedBlockRecords } from "../domain/managed-block-records.js";
import { isPerSeatRoleGuidance, mergeManagedBlock, perSeatRoleSkipWarning, DEFAULT_CLAUDE_MANAGED_BLOCK_FILE, type ClaudeManagedBlockFile } from "../domain/managed-blocks.js";
import { claudeResumeLaunchEnv, claudeResumeLaunchPrefix } from "./claude-resume-launch.js";
import { shellQuote } from "./shell-quote.js";
import { validateClaudeActivityHookDelivery, claudeActivityRelayPath, parseManagedClaudeNodeCommand } from "../domain/claude-activity-hooks.js";
import { isOwnedCollectorCommand, stripOwnedRelayHooks } from "../domain/claude-owned-settings.js";
import { validateClaudeActivityHookDelivery, claudeActivityRelayPath, CLAUDE_ACTIVITY_RELAY_RELATIVE_PATH, parseManagedClaudeNodeCommand } from "../domain/claude-activity-hooks.js";
import { isFilesystemRoot } from "../domain/cwd-resolution.js";
import { observeClaudePermission } from "../domain/permission-drift.js";
import { unresolvedClaudePermissionModes } from "../domain/native-permission-selection.js";
import type { ClaudeManagedLaunch } from "../domain/claude-managed-launch.js";
import { contextUsageDirectory, providerUsageDirectory } from "../domain/telemetry-state-paths.js";
import { seatLaunchAdditionArgs, seatLaunchAdditionShellArgs } from "../domain/seat-launch-additions.js";
import { seatLaunchAdditionArgs } from "../domain/seat-launch-additions.js";
import { recordSeatSkillWrite, seatPluginSkillsLossNote, seatProjectionComplete, seatSkillWriteRefusal } from "../domain/seat-skill-storage.js";
export interface ClaudeAdapterFsOps {
@@ -100,8 +98,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
/** P20 — called after a projected file is written to a target, so the manifest
* records what we last wrote (→ operator-vs-stale discrimination). No-op by default. */
private recordProjection: (targetPath: string, content: string) => void;
/** #2456: OpenRig's record of what it last wrote in each managed guidance block (startup wires the daemon's). */
private managedBlockRecords: ManagedBlockRecords | undefined;
constructor(deps: {
tmux: TmuxAdapter;
@@ -128,9 +124,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
/** P20 — record-at-apply hook (startup wires it to the projection manifest store).
* Absent → no-op (the manifest stays empty → discrimination safe-degrades to P17). */
recordProjection?: (targetPath: string, content: string) => void;
/** #2456: with it, every guidance block this adapter writes is checked against the record and recorded; absent, the
* plain merge, as before. */
managedBlockRecords?: ManagedBlockRecords;
}) {
this.tmux = deps.tmux;
this.seatLaunchEnvironment = deps.seatLaunchEnvironment;
@@ -148,7 +141,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
this.activityRelayPath = deps.activityRelayPath ?? null;
this.claudeHooksManifestPath = deps.claudeHooksManifestPath ?? null;
this.recordProjection = deps.recordProjection ?? (() => {});
this.managedBlockRecords = deps.managedBlockRecords;
}
async listInstalled(binding: NodeBinding): Promise<InstalledResource[]> {
@@ -385,7 +377,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
const mcpArg = mcpArgs.map(arg => ` ${shellQuote(arg)}`).join("");
// OPR.0.7.0.9: the seat's own additions, after the model, effort and Advisor and before the identity options.
const additionArgs = seatLaunchAdditionArgs(binding.launchAdditions);
const additionArg = seatLaunchAdditionShellArgs(binding.launchAdditions, shellQuote); // each flag bare, each value quoted
const additionArg = additionArgs.map((arg, index) => ` ${index % 2 === 0 ? arg : shellQuote(arg)}`).join(""); // flag, value pairs
// Fork branch: `claude --resume <parent> --fork-session --session-id <fork> --name <seat>`
// (Claude Code 2.0.73 or later). The fork's own id is chosen here, like a fresh launch's,
@@ -1025,11 +1017,11 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
if (warning) warnings.push(warning);
return false;
}
// #2456: a block a person edited since OpenRig wrote it is kept and named, not counted as merged.
return writeManagedGuidanceBlock(this.fs, targetPath, blockId, content, {
records: this.managedBlockRecords, sourcePath, warnings,
mergeManagedBlock(this.fs, targetPath, blockId, content, {
warnings,
replaceBlockIds: blockId === "openrig-start.md" ? ["using-openrig.md"] : [],
});
return true;
}
private detectDeliveryHint(path: string, content: string, orientation?: string): "guidance_merge" | "skill_install" | "send_text" {
@@ -1256,7 +1248,22 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
// 1. Strip OpenRig-owned relay entries (EXACT node-command shape) from all events;
// prune emptied groups + events. User-authored hooks are untouched.
let changed = stripOwnedRelayHooks(hooks);
let changed = false;
for (const event of Object.keys(hooks)) {
const groups = Array.isArray(hooks[event]) ? (hooks[event] as unknown[]) : null;
if (!groups) continue;
const keptGroups: unknown[] = [];
for (const group of groups) {
if (!isPlainObject(group) || !Array.isArray(group["hooks"])) { keptGroups.push(group); continue; }
const groupHooks = group["hooks"] as unknown[];
const keptHooks = groupHooks.filter((h) => !isOwnedRelayCommand(hookCommand(h)));
if (keptHooks.length !== groupHooks.length) changed = true;
if (keptHooks.length === 0) continue; // prune emptied group
keptGroups.push({ ...group, hooks: keptHooks });
}
if (keptGroups.length === 0) delete hooks[event]; // prune emptied event
else hooks[event] = keptGroups;
}
// 2. Deliverable enable: copy the relay asset + upsert the owned entry for each
// PREVALIDATED relay event (derived from the canonical manifest above).
@@ -1302,6 +1309,11 @@ interface ActivityHookOutcome {
settingsUnparseable: boolean;
}
// OpenRig-owned relay path suffix. Ownership is the generated Node command whose single
// argument ends with this path — a changed prefix still matches (replace, not duplicate); a
// user command that merely contains the path (echo, or node with extra args) does NOT.
const LEGACY_OWNED_RELAY_SUFFIX = "/.openrig/hooks/scripts/activity-relay.cjs";
function managedClaudeNodeCommand(executable: string, ...args: string[]): string {
const quotedArgs = args.map(shellQuote).join(" ");
const pinned = `${shellQuote(executable)} ${quotedArgs}`;
@@ -1309,6 +1321,37 @@ function managedClaudeNodeCommand(executable: string, ...args: string[]): string
return parseManagedClaudeNodeCommand(pinned) ? pinned : `node ${quotedArgs}`;
}
function hookCommand(hook: unknown): string | undefined {
return isPlainObject(hook) && typeof hook["command"] === "string" ? (hook["command"] as string) : undefined;
}
// OpenRig-owned collectors use canonical POSIX-quoted paths. Recognise older unquoted
// three/four-token commands too, but preserve commands composed with shell operators or
// extra arguments. Old unquoted paths containing spaces cannot be identified unambiguously.
const OWNED_COLLECTOR_SUFFIX = nodePath.sep + nodePath.join(".openrig", "context-collector.cjs");
function isOwnedCollectorCommand(cmd: string): boolean {
if (/[\r\n]/.test(cmd)) return false;
const parsed = parseManagedClaudeNodeCommand(cmd);
if (parsed && (parsed.args.length === 2 || parsed.args.length === 3)) {
return parsed.args[0]!.endsWith(OWNED_COLLECTOR_SUFFIX);
}
const tokens = cmd.trim().split(/\s+/);
if ((tokens.length !== 3 && tokens.length !== 4) || tokens[0] !== "node") return false;
if (tokens.some((token) => /[;&|<>`$()'"\\]/.test(token))) return false;
return tokens[1]!.endsWith(OWNED_COLLECTOR_SUFFIX);
}
function isOwnedRelayCommand(cmd: string | undefined): boolean {
if (!cmd) return false;
const parsed = parseManagedClaudeNodeCommand(cmd);
if (!parsed || parsed.args.length !== 1) return false;
const relay = parsed.args[0]!;
return relay.endsWith("/plugins/openrig-core/hooks/scripts/activity-relay.cjs")
|| relay.endsWith(LEGACY_OWNED_RELAY_SUFFIX)
|| relay.endsWith(`/${CLAUDE_ACTIVITY_RELAY_RELATIVE_PATH}`);
}
function hashContent(content: string): string {
return createHash("sha256").update(content).digest("hex");
}
+4 -13
View File
@@ -14,7 +14,7 @@ import { verifyClaudePaneProcess, verifyPaneIdleShell, type ClaudeLaunchedProces
import { observeClaudePermission, type AppliedLaunchObservation } from "../domain/permission-drift.js";
import { unresolvedClaudePermissionModes } from "../domain/native-permission-selection.js";
import type { ClaudeManagedLaunch } from "../domain/claude-managed-launch.js";
import { seatLaunchAdditionArgs, seatLaunchAdditionShellArgs, type SeatLaunchAdditions } from "../domain/seat-launch-additions.js";
import { seatLaunchAdditionArgs, type SeatLaunchAdditions } from "../domain/seat-launch-additions.js";
export type ResumeResult =
// `launchedProcess` (Claude): the process this resume's own identity check observed (#1077).
@@ -97,7 +97,7 @@ export class ClaudeResumeAdapter {
const mcpArgs = claudeMcpArgs(claudeMcp);
const mcpArg = mcpArgs.map(arg => ` ${shellQuote(arg)}`).join("");
const additionArgs = seatLaunchAdditionArgs(launchAdditions);
const additionArg = seatLaunchAdditionShellArgs(launchAdditions, shellQuote); // each flag bare, each value quoted
const additionArg = additionArgs.map((arg, index) => ` ${index % 2 === 0 ? arg : shellQuote(arg)}`).join(""); // flag, value pairs
let managed: Awaited<ReturnType<ClaudeManagedLaunch["prepare"]>> | undefined;
if (selectedPermissionMode !== undefined) {
try {
@@ -235,8 +235,8 @@ export class ClaudeResumeAdapter {
}
// The exact --resume identity is stronger evidence than a pane command or
// a version/footer heuristic. Use it with Claude's interactive prompt visible
// (or behind a shell label, below), after the untrusted screen classifiers have had their say.
// a version/footer heuristic. Use it only with Claude's interactive prompt
// visible, and after the untrusted screen classifiers have had their say.
const mayBeWrappedComposer = finalProbe.status === "inconclusive"
|| (finalProbe.status === "failed" && finalProbe.code === "returned_to_shell");
if (/(^|\n)\s*❯/.test(finalContent) && mayBeWrappedComposer) {
@@ -273,15 +273,6 @@ export class ClaudeResumeAdapter {
message: "Claude resume failed: pane returned to shell instead of entering Claude",
};
}
// #2508: a launch subshell that didn't exec Claude (an rc alias or function, or a fallen-back environment
// correction) keeps the shell as the label. A proven Claude naming this resume beneath it is the foreground
// runtime, as a `claude` label is, before its prompt shows.
const identity = await verifyClaudePaneProcess({ target: tmuxSessionName, tmux: this.tmux,
...(this.options.listProcesses ? { listProcesses: this.options.listProcesses } : {}), expectedToken: resumeToken });
if (identity) {
return { ok: true, ...(identity.process.startedAt
? { launchedProcess: { pid: identity.process.pid, startedAt: identity.process.startedAt } } : {}) };
}
return {
ok: false,
code: "attention_required",
@@ -19,7 +19,6 @@ import type {
InstalledResource, ProjectionResult, StartupDeliveryResult, ReadinessResult,
HarnessLaunchResult,
} from "../domain/runtime-adapter.js";
import type { GuidanceChoice } from "../domain/runtime-adapter.js";
// Type-only — keeps the profile-preflight module's dynamic import lazy for
// production (no runtime import cost from this line).
import type { CodexProfileProbeResult } from "../domain/codex-profile-preflight.js";
@@ -37,8 +36,7 @@ import { assessNativeResumeProbe, buildCodexResumeCore, hasCodexUpdateHeader, ty
import { unknownDaemonSupportMessage, type CodexDaemonSupportDetector } from "../domain/codex-daemon-support.js";
import { CODEX_NETWORK_DEFAULT_OVERRIDE, codexNetworkDefaultArg, type CodexNetworkDefaultReader } from "../domain/codex-network-default.js";
import { resolveCodexGitAddDirs, type CodexGitAddDirResolver } from "../domain/codex-git-add-dirs.js";
import { isPerSeatRoleGuidance, managedBlockText, perSeatRoleSkipWarning } from "../domain/managed-blocks.js";
import { writeManagedGuidanceBlock, type ManagedBlockRecords } from "../domain/managed-block-records.js";
import { isPerSeatRoleGuidance, mergeManagedBlock, perSeatRoleSkipWarning } from "../domain/managed-blocks.js";
import { excludeNewGeneratedFiles } from "../domain/generated-file-hygiene.js";
import { codexQueueStateRoot, codexTeamWorkspaceArg, type PrepareCodexSeatFolder, type PrepareCodexTeamWorkspace } from "../domain/codex-team-workspace.js";
import { shellQuote } from "./shell-quote.js";
@@ -70,7 +68,7 @@ const codexHookTrust = createRequire(import.meta.url)("../../assets/plugins/open
};
import { listNativeProcesses, observeCodexPaneProcess, observeCodexForkProcess, verifyCodexPaneProcess, verifyPaneIdleShell, type NativeProcessRow } from "../domain/native-process-lineage.js";
import { recordSeatSkillWrite, seatPluginSkillsLossNote, seatProjectionComplete, seatSkillWriteRefusal, SEAT_SKILLS_BOOTSTRAP_BLOCK_ID } from "../domain/seat-skill-storage.js";
import { recordSeatSkillWrite, seatPluginSkillsLossNote, seatProjectionComplete, seatSkillWriteRefusal } from "../domain/seat-skill-storage.js";
import { codexPluginContentHash, planOwnedCodexContentApproval, recordOwnedCodexApproval } from "../domain/codex-owned-plugin-trust.js";
import { findTomlTable, opensTomlTable, parseTomlRoot, tomlArrayTableHeaderPath, tomlDefines, tomlTableHeaderPath } from "../domain/toml-tables.js";
@@ -198,10 +196,6 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
// so each of those runs its whole setup under this one lock, one after another.
private configLock: Promise<unknown> = Promise.resolve();
private codexSystemConfigDir: string;
/** #875: the managed guidance blocks a fresh launch's startup queued for its developer instructions, per tmux session. */
private launchInstructions = new Map<string, Map<string, string>>();
/** #2456: OpenRig's record of what it last wrote in each managed guidance block (startup wires the daemon's). */
private managedBlockRecords: ManagedBlockRecords | undefined;
/** Build the Codex launch adapter with injectable filesystem, process, and CLI seams for isolated checks. */
constructor(deps: {
@@ -245,11 +239,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
runCodexCli?: CodexCliRunner;
/** Where Codex's system and legacy managed config files live (default /etc/codex). */
codexSystemConfigDir?: string;
/** #2456: with it, every guidance block this adapter writes is checked against the record and recorded; absent, the
* plain merge, as before. */
managedBlockRecords?: ManagedBlockRecords;
}) {
this.managedBlockRecords = deps.managedBlockRecords;
this.resolveCorePluginDir = deps.resolveCorePluginDir;
this.resolveCorePluginSelected = deps.resolveCorePluginSelected;
this.runCodexCli = deps.runCodexCli ?? defaultCodexCli;
@@ -801,7 +791,6 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
}
async project(plan: ProjectionPlan, binding: NodeBinding): Promise<ProjectionResult> {
if (binding.guidanceLaunchInstructions && binding.tmuxSession) this.launchInstructions.set(binding.tmuxSession, new Map());
const projected: string[] = [];
const warnings: string[] = [];
const skipped: string[] = [];
@@ -814,7 +803,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
}
try {
const didProject = this.projectEntry(entry, binding.cwd, warnings, binding.seatSkillsRoot, binding.seatSkillsRefused, this.homeFor(binding), binding);
const didProject = this.projectEntry(entry, binding.cwd, warnings, binding.seatSkillsRoot, binding.seatSkillsRefused, this.homeFor(binding));
if (didProject) {
projected.push(entry.effectiveId);
} else {
@@ -844,7 +833,8 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
switch (hint) {
case "guidance_merge": {
const merged = this.placeGuidance(binding.cwd, binding, file.path, content, warnings);
const targetPath = nodePath.join(binding.cwd, "AGENTS.md");
const merged = this.mergeGuidance(targetPath, file.path, content, warnings);
if (!merged) continue; // rig-role skip: do not count as delivered
break;
}
@@ -959,7 +949,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
// 0.5.2-07 A2-3: the pod-aware RESUME path threads the SPEC model too (reverted before — the
// grounding map assumed codex parity with the claude adapter, but only fresh emitted -m).
? this.resumeCore(prepared, opts.resumeToken, binding)
: `codex${daemonArg}${postureArg}${networkArg} -C ${shellQuote(binding.cwd)}${gitDirArg}${seatFolderArg}${queueStateDirArg}${modelArg}${effortArg}${binding.guidanceLaunchInstructions ? this.developerInstructionsArg(binding.tmuxSession) : ""}`;
: `codex${daemonArg}${postureArg}${networkArg} -C ${shellQuote(binding.cwd)}${gitDirArg}${seatFolderArg}${queueStateDirArg}${modelArg}${effortArg}`;
const staged = await this.seatCommand(binding, cmd);
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) return { ok: false, error: "Selected history changed during Codex launch preparation; no native command was sent.", recovery: "attention_required" };
@@ -1380,14 +1370,15 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
}
private projectEntry(entry: ProjectionEntry, cwd: string, warnings: string[], seatSkillsRoot?: string, seatSkillsRefused?: string,
home?: string, binding?: NodeBinding): boolean {
home?: string): boolean {
if (entry.category === "runtime_resource" && this.applyRuntimeResource(entry, home)) {
return true;
}
if (entry.category === "guidance" && entry.mergeStrategy === "managed_block") {
const targetPath = nodePath.join(cwd, "AGENTS.md");
const content = this.fs.readFile(entry.absolutePath);
return this.placeGuidance(cwd, binding, entry.effectiveId, content, warnings, entry.absolutePath);
return this.mergeGuidance(targetPath, entry.effectiveId, content, warnings, entry.absolutePath);
}
// HG-1.3 plugin runtime applicability filter (per DESIGN.md §5.1):
@@ -1538,78 +1529,6 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
* propagate the skip signal so ProjectionResult and StartupDeliveryResult
* report honest counts.
*/
/** #875: a block goes in with the launch's developer instructions when this fresh launch chose them (guidanceFileFor),
* else into the repository AGENTS.md as before. A seat's role is skipped either way, and the seat-skills line keeps its
* AGENTS.md merge: the person's yes was for that file. */
private placeGuidance(cwd: string, binding: NodeBinding | undefined, blockId: string, content: string, warnings: string[], sourcePath?: string): boolean {
const targetPath = nodePath.join(cwd, "AGENTS.md");
const queued = binding?.guidanceLaunchInstructions && binding.tmuxSession ? this.launchInstructions.get(binding.tmuxSession) : undefined;
if (!queued || blockId === SEAT_SKILLS_BOOTSTRAP_BLOCK_ID || isPerSeatRoleGuidance(blockId, sourcePath)) {
return this.mergeGuidance(targetPath, blockId, content, warnings, sourcePath);
}
if (blockId === "openrig-start.md") queued.delete("using-openrig.md");
queued.set(blockId, content);
// #2456: a block an earlier handover or resume left in AGENTS.md is refreshed by the same rule (the launch text asks
// Codex to follow AGENTS.md where the two differ); nothing new is written there.
writeManagedGuidanceBlock(this.fs, targetPath, blockId, content, { records: this.managedBlockRecords, onlyIfPresent: true, sourcePath, warnings });
return true;
}
/** The `-c developer_instructions` argument for a fresh launch, from the blocks its startup queued (#875). The value is a
* TOML basic string (JSON's escapes, with DEL escaped too). Codex can give developer instructions more weight than AGENTS.md,
* so the text asks it to keep the person's AGENTS.md guidance first. */
private developerInstructionsArg(tmuxSession: string): string {
const blocks = this.launchInstructions.get(tmuxSession);
this.launchInstructions.delete(tmuxSession);
if (!blocks || blocks.size === 0) return "";
const text = [LAUNCH_INSTRUCTIONS_PREAMBLE, ...[...blocks].map(([id, content]) => managedBlockText(id, content))].join("\n\n");
return ` -c ${shellQuote(`developer_instructions=${JSON.stringify(text).replace(/\u007f/g, "\\u007F")}`)}`;
}
/** #875: whether a fresh launch can carry this seat's managed guidance as developer instructions instead of writing the
* repository AGENTS.md: Codex 0.164.0 or later (where `developer_instructions` is a declared key), and no visible config
* layer sets one, since the launch value would replace it. Anything else, or anything unreadable, keeps AGENTS.md. */
guidanceFileFor(binding: NodeBinding, opts?: { resuming?: boolean }): GuidanceChoice | Promise<GuidanceChoice> {
// A resume or fork (untested with developer instructions) and a startup continuation (it launches nothing) keep the
// repository path, answered at once.
if (opts?.resuming || !binding.tmuxSession) return { file: null };
return this.freshGuidanceChoice(binding);
}
private async freshGuidanceChoice(binding: NodeBinding): Promise<GuidanceChoice> {
let version: string | undefined;
try { version = (await this.resolveManagedConfigSettings?.())?.codexVersion; } catch { version = undefined; }
if (!version || !codexAcceptsDeveloperInstructions(version)) return { file: null };
const configured = this.configuredDeveloperInstructions(binding);
if (configured) return { file: null, note: `codex: ${configured}, so this seat's managed guidance stays in its repository AGENTS.md` };
return { file: null, launchInstructions: true };
}
/** The first visible Codex config layer that sets `developer_instructions` (at any depth, profiles included) or can't be
* read, as `profileRemainsReadOnly` reads the same layers. Null when none does. */
private configuredDeveloperInstructions(binding: NodeBinding): string | null {
const home = this.homeFor(binding) ?? nodePath.join(this.fs.homedir ?? os.homedir(), ".codex");
const layers = [
nodePath.join(this.codexSystemConfigDir, "config.toml"),
nodePath.join(home, "config.toml"),
...(binding.codexConfigProfile ? [nodePath.join(home, `${binding.codexConfigProfile}.config.toml`)] : []),
nodePath.join(this.codexSystemConfigDir, "managed_config.toml"),
];
for (let dir = nodePath.normalize(binding.cwd);;) {
layers.push(nodePath.join(dir, ".codex", "config.toml"));
const parent = nodePath.dirname(dir);
if (parent === dir) break;
dir = parent;
}
for (const path of new Set(layers)) {
if (!this.fs.exists(path)) continue;
let value: unknown;
try { value = parseToml(this.fs.readFile(path)); } catch { return `${path} can't be read as TOML`; }
if (holdsKey(value, "developer_instructions")) return `${path} sets developer_instructions`;
}
return null;
}
private mergeGuidance(targetPath: string, blockId: string, content: string, warnings: string[], sourcePath?: string): boolean {
// Mirrors Claude Code adapter: the `rig-role` managed block collides across
// pod-mates because the regenerator pairs (target-file × spec) without
@@ -1624,11 +1543,11 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
if (warning) warnings.push(warning);
return false;
}
// #2456: a block a person edited since OpenRig wrote it is kept and named, not counted as merged.
return writeManagedGuidanceBlock(this.fs, targetPath, blockId, content, {
records: this.managedBlockRecords, sourcePath, warnings,
mergeManagedBlock(this.fs, targetPath, blockId, content, {
warnings,
replaceBlockIds: blockId === "openrig-start.md" ? ["using-openrig.md"] : [],
});
return true;
}
private detectDeliveryHint(path: string, content: string, orientation?: string): "guidance_merge" | "skill_install" | "send_text" {
@@ -2204,21 +2123,6 @@ export function upsertCodexHookTrust(content: string, key: string, hash: string,
return field === "trusted_content_hash" ? lines.join("\n") : `${lines.join("\n").replace(/\n*$/, "\n")}`;
}
const LAUNCH_INSTRUCTIONS_PREAMBLE = "OpenRig seat guidance for this session follows. Where it conflicts with AGENTS.md guidance in the repository or the Codex home, follow AGENTS.md.";
/** Codex 0.164.0 or later, where `developer_instructions` is a declared config key (#875). */
export function codexAcceptsDeveloperInstructions(version: string): boolean {
const match = /^(\d+)\.(\d+)/.exec(version);
if (!match) return false;
return parseInt(match[1]!, 10) > 0 || parseInt(match[2]!, 10) >= 164;
}
function holdsKey(value: unknown, key: string): boolean {
if (!value || typeof value !== "object") return false;
if (Array.isArray(value)) return value.some((item) => holdsKey(item, key));
return Object.entries(value).some(([name, item]) => name === key || holdsKey(item, key));
}
export function isCodex013xOrLater(version: string): boolean {
const match = /^(\d+)\.(\d+)/.exec(version);
if (!match) return false;
Loaded 100 of 391 files, more files were not shown because too many files have changed in this diff. Show more