Compare commits

..
15 Commits
Author SHA1 Message Date
dev60-review a226b1b6cb docs(release): note the scrolled-pane stale-binding fix for 0.7.10
Add the #2644 entries to the 0.7.10 CHANGELOG section and whats-new, after
#2594's tmux mode item.
2026-11-07 07:51:52 +00:00
review50-r3 d83962a843 fix(transport): leave a pane's mode through the seat's address
A send to a seat whose pane was scrolled into copy mode tried to leave the
mode by the pane's raw tmux id. The input guard resolves a write's target to
exactly one seat, and a pane id can also match a stale binding elsewhere,
because tmux reuses pane ids after a server restart. The guard then refused
the write before typing anything, the pane stayed in its mode, and the send
was refused as pane_in_mode. Text and Enter were never affected: they are
addressed by the seat's session name.

Leave the mode through the session name too. The guard resolves the seat and
writes to its bound pane, as it does for text and Enter; the mode reads still
use the bound pane.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 3e0d7e7865)
2026-11-07 07:51:15 +00:00
dev60-review f90481bc23 docs(release): qualify plain rig in Codex seats by allow_login_shell support
The 0.7.10 notes and the capability-map line now say plain rig works however OpenRig was installed with a Codex that supports allow_login_shell, and the notes add that it hasn't yet run with a live Codex seat.
2026-11-07 06:10:10 +00:00
dev60-review cd8e32f18d docs(release): describe non-login Codex tool shells for 0.7.10
Carries #2613's 0.7.10 notes (the CHANGELOG section and whats-new) and adds one capability-map line: managed Codex seats run tool commands in a non-login shell, and launch.codex_login_shell defers to Codex's own setting at each seat's next launch.
2026-11-07 06:00:42 +00:00
dev-driver 6130b4308c test(codex): update six expectations for the non-login flag and its setting
Four daemon tests pin Codex launch commands or the resume call's shape.
They now expect -c 'allow_login_shell=false' where the launch adds it.

Two settings tests list the keys. They now include
launch.codex_login_shell, and the routes test also checks its default.

No assertion is weakened.

(cherry picked from commit 3c0d90e530)
2026-11-07 05:58:06 +00:00
dev-driver 6f0f0888c1 docs(config): name what non-login Codex tool shells can lose
The launch.codex_login_shell help and the getting-started reference now
name the trade-off. Tool commands inherit the seat's environment instead
of re-running login setup for each command. Setup that lives only in
login or rc files, including a tmux default-command that isn't a login
shell, may be missing, and so may shell functions and aliases.

Turning the option on omits OpenRig's override and defers to Codex's own
configuration. It doesn't force login shells. It applies at each seat's
next launch, and a restart in place keeps the running choice.

(cherry picked from commit 1c32da8ea1)
2026-11-07 05:58:06 +00:00
dev-driver d98f4759fe feat(config): launch.codex_login_shell keeps Codex's own login shells
Off by default, so managed Codex seats keep OpenRig's command folder on
their tool PATH. A seat's pane normally starts a login shell, so its tools
already see what login files export. A person whose panes don't (a tmux
default-command) and whose tools need setup that only their login files
make can turn it on. The rig config help says when, and what it costs.

Read at each Codex launch, by the runtime adapter and the legacy resume.

(cherry picked from commit b02bdef63a)
2026-11-07 05:58:06 +00:00
dev-driver bc793ba3ff fix(codex): keep OpenRig's command folder on managed seats' tool PATH
Codex gives each tool command the user's login-shell environment, then puts
back only its own directories. When a login file sets PATH outright, the
folder OpenRig puts first on a seat's PATH is dropped. A seat whose rig isn't
on the login PATH, such as one from a project-local install, then reports
"rig: command not found".

Managed Codex launches now pass -c allow_login_shell=false. Tool commands then
run in a non-login shell with the environment the seat's Codex was started
with, OpenRig's command folder included. Fresh, fork and resume launches add
it, including the legacy resume path.

An in-place restart keeps what the running Codex was launched with, as it
already does for --no-daemon and the network override. A seat launched
earlier picks the flag up at its next managed launch.

(cherry picked from commit 2385d48e41)
2026-11-07 05:58:06 +00:00
dev60-review 5eb917891a docs(release): a kept Herdr viewer keeps its earlier sizing while it runs 2026-11-07 04:45:43 +00:00
dev60-review 32531ae08e docs(release): 0.7.10's kept Herdr views keep working 2026-11-07 04:39:02 +00:00
Mike Schwarz c17f8a53d6 fix(terminal): keep Herdr views from earlier releases after upgrading (#2634)
Kept Herdr viewer journal entries and follow-view saved tiles store the whole tile attach argv. #2614 changed the writable tile script, and #2464 changed both scripts before it, so views kept from 0.7.5-0.7.9 failed the strict comparison after upgrading: a kept viewer read unconfirmed and a kept follow view stopped adding and closing tiles. earlierTileAttachment accepts a kept argv only when it exactly equals what 0.7.9's or 0.7.5-0.7.8's generator makes for the current session, tmux, server and recorded read-only intent; the entry then moves to the current command. A different target, a changed read-only intent, or any other script (including one sharing the old prefix) still mismatches as before. The earlier generators live in src/domain/terminal/earlier-tile-attach.ts, shared with the tests. The approach follows natefaerber's #2467. No new refusal, error code or setting.

(cherry picked from commit 6a46172df1)
2026-11-07 04:38:35 +00:00
dev60-review 3c8b72d54c docs(release): name the Herdr views kept from before 0.7.9 in 0.7.10 known limits 2026-11-07 03:02:10 +00:00
dev60-review 14d89431f9 docs(capability-map): teach what 0.7.10 added, and correct two lines it made stale
The packaged capability map takes in 0.7.10's changes, and its header marker
moves to capability-delta-v0.7.10, with the verb count at ninety-two (rig exec
is the new top-level verb).

- rig exec: a finite Claude Code or Codex turn through the harness's ACP
  adapter, run by a detached connector; the row stays the custody record, one
  run per row, status reads interrupted runs, and cancel's acknowledgement
  isn't a settled shutdown or a statement about spend. Not yet run with a real
  adapter.
- rig seat status: the latest observed permission request, with a fixed
  command descriptor that hides arguments and bounded suggested rules that can
  contain paths, patterns or sensitive values. Evidence only: it applies and
  approves nothing, lasts five minutes in memory and is lost on a restart.
- rig agent validate and launch advisories: unknown uses keys and a single
  value for uses.<category> are advisories; that value is a one-item list, so a
  member selecting an unavailable resource fails its resolution as before.
- Managed compaction: typed, unreadable or changed input still holds the
  preparation prompt and /compact; after it, a typed draft holds the step, and
  two idle polls that can't read the box send it, recorded as delivered over an
  unreadable box, so an unrecognised draft may receive the restore text.

Corrected: a send leaves every tmux mode, not one, and the OpenShell line names
0.7.10's source repairs while saying the worker hasn't run end to end. The
agent spec reference gains the single-value rule for uses. Docs only.
2026-11-06 21:48:43 +00:00
dev60-review c105b9c2f3 docs(release): describe 0.7.10 2026-11-06 20:43:28 +00:00
dev60-review 52d6dff860 chore(release): prepare 0.7.10 2026-11-06 20:42:47 +00:00
490 changed files with 2394 additions and 44931 deletions

No files matched your search

-15
View File
@@ -1,15 +0,0 @@
{
"name": "openrig-app",
"description": "The OpenRig plugin: work with your OpenRig team from this app.",
"owner": {
"name": "Mike Schwarz",
"url": "https://github.com/mvschwarz"
},
"plugins": [
{
"name": "openrig",
"source": "./packages/daemon/assets/plugins/openrig",
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns."
}
]
}
-15
View File
@@ -1,15 +0,0 @@
{
"name": "openrig-app",
"description": "The OpenRig plugin: work with your OpenRig team from this app.",
"owner": {
"name": "Mike Schwarz",
"url": "https://github.com/mvschwarz"
},
"plugins": [
{
"name": "openrig",
"source": "./packages/daemon/assets/plugins/openrig",
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns."
}
]
}
+2 -2
View File
@@ -79,7 +79,7 @@ jobs:
linux-seat-environment:
if: ${{ github.event_name != 'push' }}
# Cover /proc seat environments and Linux canonical-input limits that macOS cannot exercise.
# This integration test reads /proc/<pid>/environ and is skipped on macOS.
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
@@ -108,7 +108,7 @@ jobs:
ip link set lo up
exec runuser -u "$1" -- env -i HOME="$2/home" PATH="$3" TMPDIR="$2" TMUX_TMPDIR="$2" \
CI=true TERM=xterm-256color \
npm run test -w packages/daemon -- test/seat-env-parent-session.integration.test.ts test/pi-shell-launch-native.test.ts --maxWorkers=1 --reporter=verbose
npm run test -w packages/daemon -- test/seat-env-parent-session.integration.test.ts --maxWorkers=1 --reporter=verbose
SUITE
package-tests:
+11 -21
View File
@@ -10,9 +10,6 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
## [0.7.10]
Published 2026-11-08 as `@openrig/[email protected]`, built from the commit tagged
`v0.7.10` (`a226b1b6`).
OpenRig 0.7.10 is a patch for people running their own agent teams. After a
managed compaction, a step held because OpenRig can't read the seat's input box
now goes in after two idle polls instead of waiting with no end. Wakes and sends
@@ -21,9 +18,9 @@ queue row's task to Claude Code or Codex as one finite run that outlives the
command (not yet run with a real adapter). Installing through the rigs skill tries a user-writable npm prefix
before a project-local install, and agent spec advisories now show at launch.
There are fixes across refocus, routing, status and speed under load, many
contributed by people using OpenRig. The OpenShell worker now does real work and
exports results, but its cleanup still fails against OpenShell 0.1.6, so don't
rely on cleanup or recreate yet: see [the OpenShell worker](#the-openshell-worker-in-0710).
contributed by people using OpenRig. The two problems that kept the OpenShell
worker from working in 0.7.9 are fixed in the source, but the worker hasn't run
end to end on 0.7.10: see [the OpenShell worker](#the-openshell-worker-in-0710).
### Before you upgrade
@@ -75,27 +72,23 @@ rely on cleanup or recreate yet: see [the OpenShell worker](#the-openshell-worke
**The OpenShell worker hasn't run end to end on 0.7.10. Don't rely on this path
yet.**
0.7.10 changes the two things our release testing on 0.7.9 found. Tested against
OpenShell 0.1.6, the first fix holds and the second doesn't:
0.7.10 fixes, in the source, the two problems our release testing on 0.7.9
found:
- **The guest's CA settings reach its Claude.** The guest launcher built
Claude's environment from scratch with only the provider settings, so a CA the
OpenShell exec supplied never arrived, and API calls failed with
`SELF_SIGNED_CERT_IN_CHAIN`. It now also passes `NODE_EXTRA_CA_CERTS`,
`SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE` and `CURL_CA_BUNDLE`, each only when the
exec supplies it. Nothing else is copied, and nothing turns TLS checks off.
With OpenShell 0.1.6 the guest now authenticates, does useful work, makes a
verified export and completes its task.
Rebuild the guest image to get it (above). ([#2602](https://github.com/mvschwarz/openrig/pull/2602))
- **Deleting an owned environment reads Podman's inventory.** The cleanup step
asked for routes Podman serves only under a version prefix, misread the "Not
Found" reply, and never sent the delete. It now uses routes Podman serves
without one, and a volume counts as gone only when one complete volume listing
leaves it out. Any other answer stops as `environment_cleanup_unconfirmed`,
with the HTTP status. The maintained cleanup still fails against OpenShell
0.1.6, which doesn't mount a workspace volume (see Known limits). ([#2603](https://github.com/mvschwarz/openrig/pull/2603))
with the HTTP status. ([#2603](https://github.com/mvschwarz/openrig/pull/2603))
An exact restore hasn't been shown: recreate and restore can't run while the
cleanup fails.
A verified export, a finished task and an exact restore haven't been shown.
### Compaction, delivery and terminals
@@ -266,11 +259,9 @@ cleanup fails.
### Known limits
- **OpenShell:** maintained cleanup (`delete-owned`) fails against OpenShell
0.1.6, which doesn't mount a workspace volume, so recreate and restore can't
run (above). A cleanup error that's thrown still omits its stage and whether
its delete was sent, and the inventory is a snapshot, not an atomic read.
0.7.8's requirements, cost note
- **OpenShell:** the two fixes haven't run end to end (above). A cleanup error
that's thrown still omits its stage and whether its delete was sent, and the
inventory is a snapshot, not an atomic read. 0.7.8's requirements, cost note
and cleanup limits still apply, including "Never grant the guest access to the
daemon's main API port (normally 7433)." A same-generation reconciled launch
opens no viewer, so run `rig environment connect` yourself; non-text startup
@@ -338,8 +329,7 @@ cleanup fails.
states (`docs/reference/harness-runner-contract.md`); any other runner,
provider or path is unqualified until shown.
- **Fixed or changed from 0.7.9's limits:** the OpenShell worker's CA settings
(fixed, and shown working with OpenShell 0.1.6) and owned-delete inventory
(changed, but the cleanup still fails against 0.1.6; see above), a
and owned-delete inventory (fixed in the source, not yet run end to end), a
restore request held after a managed compaction, `rig broadcast`'s `FAILED`
for an uncertain recipient, the refusal for a seat handed over before 0.7.9,
where to read what has run under the runner contract, and Codex seats'
+5 -5
View File
@@ -26,9 +26,9 @@ use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.10/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.10/skills/rigs --skill rigs --agent codex
```
Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what you want built. The skill has your agent tell
@@ -68,11 +68,11 @@ Requires Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude Co
### Install it yourself
This runs OpenRig's install script from the `v0.7.5` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
This runs OpenRig's install script from the `v0.7.10` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
```bash
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.10/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.10/scripts/install.sh | sh
```
If the last step isn't ready, the installer reports `INSTALLED, SETUP NOT COMPLETE` and points to the details from `rig setup` above. Follow that output and any reported Fix, then rerun `rig setup`; the CLI does not need reinstalling. If the only remaining failures are provider sign-ins, sign in to each selected provider as below and continue.
+2 -2
View File
@@ -6,9 +6,9 @@ This isn't a schedule or a promise: we release often, and an item ships when a g
To see what's being worked on, by release and theme, open the [OpenRig roadmap board](https://github.com/users/mvschwarz/projects/1).
Issues labelled `good first issue` are also listed on the [contribute page](https://github.com/mvschwarz/openrig/contribute).
## Released in 0.7.10
## Released in 0.7.8
[0.7.10 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.10): it is a patch for people running their own agent teams. A step held after a managed compaction now goes in instead of waiting forever. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
[0.7.8 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.8): it lets a person answer a runner seat's permission requests, with a conformance check and an example runner to build against. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
## How to help
@@ -214,26 +214,16 @@ inside a caller-managed transaction, `eventBus.persistWithinTransaction(...)`
(`event-bus.ts:70`) with subscribers notified after commit — across domain
services (`stream-store.ts`, `workflow-runtime.ts`, `restore-orchestrator.ts`,
`node-launcher.ts`, etc.) and route handlers. The event log is append-only and
SQLite-backed, and it holds stored events only. `view.changed`, which the view
event bridge (`view-event-bridge.ts`) produces for each queue, inbox, proof or project-classification change,
is transient: `eventBus.publishTransient(...)` delivers it to the live
subscribers without a row, without a `seq` of its own and without moving the
notify watermark. It carries `transient: true` and, as its `seq`, the `seq` of
the stored event that caused it.
Upgrade note: `view.changed` rows stored by older versions stay in the log, so
`/api/events` replays and the telemetry export still return them until an events retention
(a separate change) removes them.
SQLite-backed.
SSE delivery surfaces include the following. The daemon has **11**
`streamSSE(` call sites across **10** files in `packages/daemon/src/routes/`
(`git grep -o 'streamSSE(' -- packages/daemon/src/routes | wc -l`;
`git grep -l 'streamSSE(' -- packages/daemon/src/routes | wc -l`).
- `GET /api/events` — global stream of the stored events (`server.ts:730`
`app.route("/api/events", eventsRoute)`); `?rigId=` keeps one rig's.
Transient events (`view.changed`) are not stored, so they appear here
neither live nor in a replay. Each event's SSE `id` is its `seq`. Before going live, a connection replays
- `GET /api/events` — global stream of all events (`server.ts:730`
`app.route("/api/events", eventsRoute)`); `?rigId=` keeps one rig's. Each
event's SSE `id` is its `seq`. Before going live, a connection replays
stored events, read and written 500 at a time (`routes/events.ts`):
- with a `Last-Event-ID` header (an `EventSource` sends it when it
reconnects), every event after that `seq`, whatever `replay` says;
@@ -244,10 +234,6 @@ SSE delivery surfaces include the following. The daemon has **11**
Either way the replay ends at the newest event stored as the connection
subscribes; the events stored meanwhile were buffered and follow it, each
once and in order, before the stream goes live.
- `GET /api/views/sse` (alias `/api/views/watch`) and
`GET /api/views/:viewName/sse` — `view.changed`, live only (`routes/views.ts`).
An event's `id` there is the `seq` of the stored event that caused it, so it
repeats across the views one change touches; it is not a replay cursor.
- `GET /api/stream/watch` — new stream items (`routes/stream.ts:194`).
- `GET /api/queue/watch` — queue/inbox coordination events
(`routes/queue.ts:1054`, with a `/sse` alias at `:1055`).
@@ -468,16 +468,6 @@ defaults to 20 and accepts integers from 1 to 100. Optional `view` follows the
same person perspective as `/api/attention`; a perspective read scans at most
1000 rows and signals `truncated` when the window may omit matching items.
A finished item's result file is its `evidenceRef`, read through the daemon's
shared roots with `rig_artifact_read` (MCP) or `rig queue artifact <qitemId>
--json` (CLI). For a code result, the assignee writes a JSON file of kind
`openrig.code-result/v1`, with `repo` (an absolute path or a URL), `base` and
`revision` (full commit ids, SHA-1 or SHA-256), and optionally `paths`, `branch`
and `fetch: {remote, ref}`. Both reads then add `codeResult`, the exact pair a
client can diff. A manifest whose base or revision isn't a full commit id gets
`codeResultIssue` instead. `branch` and `fetch` are hints for finding the commits,
never the revision. Other files read as before.
- `/api/stream` (`server.ts:783`) — `POST /emit` (`routes/stream.ts:56`),
`GET /list` (`:91`, including `sourceSession`, `hintDestination`, `hintTag`,
`since`, `until` and `direction` filters), `GET /watch` + `/sse` SSE
@@ -160,9 +160,8 @@ Restore behavior, each point checked in `restore-orchestrator.ts`:
`:780`; if `status === "restoring"` the node is skipped with a warning and
reported as `fresh`, `:783–785`; `degraded` only adds a warning).
- Reuses the seat's durably bound tmux session name when the prior binding
has a usable one, and otherwise falls back to the derived name. A missing
binding after the latest session's recorded intentional exit is quiet;
unclean/legacy missing bindings and incomplete or invalid binding rows still warn.
has a usable one (`:840`), and otherwise falls back to the derived name
with a warning (`:869`).
- Replays restore-safe startup using persisted startup context;
prefilters missing optional artifacts into warnings; **hard-fails a node if
a required startup file is missing** (`:1161` — status `"failed"`, error
@@ -84,15 +84,6 @@ Diagnostic-only rows, moved sources, and versions retained for unfinished
work have separate paths in `readThrough`, `writeDiagnostic`, and
`isPinnedByUnfinishedWork`.
Only instantiate (and `rig workflow run`, which posts to the same route) and
the workflows-folder scan register specs through `readThrough`.
`WorkflowRuntime.validate` (`rig workflow validate`, `POST /validate`) reads
the file with `WorkflowSpecCache.readFromDisk`: the same file and parse
checks, in memory, with no `workflow_specs` write. A spec that was only
validated from outside the workflows folder is not listed by
`rig workflow specs` and is not found by its bare name; its path still
instantiates (#2726).
## Durable state
The migration files record the storage evolution; they are not a count of
+7 -14
View File
@@ -188,7 +188,7 @@ the sections that were not read are `null`.
|---|---|---|
| `rig gateway` | — | — |
| `rig gateway human` | — | — |
| `rig gateway human add <entityId>` | — | `--display-name <name>` **required**<br>`--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>` (repeatable; without one, the person is reached in the Feed and Steering only)<br>`--delivery-class <A\|B\|C\|D>` **required**<br>`--away`<br>`--replace`<br>`--reason <reason>`<br>`--actor <actor>`<br>`--organization <id>`<br>`--group <id>` (repeatable)<br>`--policy <text>`<br>`--policy-file <path>`<br>`--default` |
| `rig gateway human add <entityId>` | — | `--display-name <name>` **required**<br>`--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>` **required** (repeatable)<br>`--delivery-class <A\|B\|C\|D>` **required**<br>`--away`<br>`--replace`<br>`--reason <reason>`<br>`--actor <actor>`<br>`--organization <id>`<br>`--group <id>` (repeatable)<br>`--policy <text>`<br>`--policy-file <path>`<br>`--default` |
| `rig gateway human list` | — | `--json` |
| `rig gateway human quiet` | — | — |
| `rig gateway human quiet set <entityId>` | — | `--daily <window>`<br>`--until <time>`<br>`--tz <zone>`<br>`--actor <actor>`<br>`--json` |
@@ -363,10 +363,6 @@ over that token. Without it, the target's `POST /api/hosts/pair-request` answers
registered person can approve (none, or several with no default; name one with
`--human`) (`packages/daemon/src/routes/hosts.ts:167-186`).
The request stays open on the target for as long as `--timeout` waits (600 seconds by default), and the person's
approval says how long and until when, in UTC. A target older than this CLI ignores the timeout and keeps the request
10 minutes.
Registration makes a host visible in topology; remote FEED attention remains opt-in.
The human-readable success output of `host add` and `host pair` now includes the FEED
next step alongside the existing `host doctor` suggestion. For an HTTP entry named
@@ -418,7 +414,6 @@ do not establish connectivity to a live peer.
|---|---|---|
| `rig acp` | — | — |
| `rig acp run <qitemId> <agent...>` | — | `--cwd <dir>`<br>`--permissions-to <session>`<br>`--load-session <id>`<br>`--poll-seconds <n>` |
| `rig acp serve` | — | `--to <member@rig>`<br>`--as <address>`<br>`--poll-seconds <n>` |
| `rig acp cancel <runId>` | — | — |
| `rig acp stop <runId> [stopCommand...]` | — | — |
@@ -491,17 +486,16 @@ default-permit record (tag `runner-permission-permit`) opens no row.
| `rig queue create` | — | `--source <session>`<br>`--destination <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--body-context <ref>`<br>`--mission <id>`<br>`--slice <id>`<br>`--gate <role>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--expires-at <iso>`<br>`--id <qitemId>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--human-intent <intent>`<br>`--alert`<br>`--human-detail-file <path>`<br>`--reply-to <qitemId>`<br>`--human-questions-file <path>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--verify`<br>`--full`<br>`--json` |
| `rig queue claim <qitemId>` | — | `--destination <session>`<br>`--full`<br>`--json` |
| `rig queue unclaim <qitemId>` | — | `--destination <session>`<br>`--reason <text>`<br>`--full`<br>`--json` |
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--closure-required-at <iso>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--host <id>`<br>`--full`<br>`--json` |
| `rig queue block <qitemId>` | — | `--on <blocker>` **required**<br>`--actor <session>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--continuation <text>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--host <id>`<br>`--full`<br>`--json` |
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--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 block <qitemId>` | — | `--on <blocker>` **required**<br>`--actor <session>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--continuation <text>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--full`<br>`--json` |
| `rig queue resolve <qitemId>` | — | `--decision <text>` **required**<br>`--actor <session>`<br>`--bearer <token>`<br>`--no-notify`<br>`--json` |
| `rig queue handoff <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--alert`<br>`--host <id>`<br>`--no-nudge`<br>`--full`<br>`--json` |
| `rig queue handoff-and-complete <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--alert`<br>`--host <id>`<br>`--no-nudge`<br>`--full`<br>`--json` |
| `rig queue whoami` | — | `--session <session>`<br>`--recent-limit <n>`<br>`--work-candidates`<br>`--json` |
| `rig queue fallback <qitemId>` | — | `--destination <session>` **required**<br>`--reason <text>`<br>`--json` |
| `rig queue show <qitemId>` | — | `--full`<br>`--json`<br>`--host <id>` |
| `rig queue artifact <qitemId>` | — | `--root <name>`<br>`--json` |
| `rig queue transitions <qitemId>` | — | `--json`<br>`--full`<br>`--host <id>` |
| `rig queue list` | — | `-a, --all`<br>`-A, --all-rigs`<br>`--full`<br>`--owned`<br>`--mine`<br>`-o <format>`<br>`--destination <session>`<br>`--source <session>`<br>`--state <state>`<br>`--target-repo <name>`<br>`--limit <n>`<br>`--json`<br>`--host <id>` |
| `rig queue show <qitemId>` | — | `--full`<br>`--json` |
| `rig queue transitions <qitemId>` | — | `--json`<br>`--full` |
| `rig queue list` | — | `-a, --all`<br>`-A, --all-rigs`<br>`--full`<br>`--owned`<br>`--mine`<br>`-o <format>`<br>`--destination <session>`<br>`--source <session>`<br>`--state <state>`<br>`--target-repo <name>`<br>`--limit <n>`<br>`--json` |
| `rig queue overdue` | — | `--rig <name>`<br>`-A, --all-rigs`<br>`--full`<br>`--limit <n>`<br>`--json` |
| `rig queue undelivered` | — | `--rig <name>`<br>`-A, --all-rigs`<br>`--full`<br>`--limit <n>`<br>`--json` |
| `rig queue inbox-drop <destinationSession>` | — | `--sender <session>`<br>`--body <text>`<br>`--body-file <path>`<br>`--tags <tags>`<br>`--urgency <urgency>`<br>`--audit <pointer>`<br>`--id <inboxId>`<br>`--json` |
@@ -605,10 +599,9 @@ Herdr session per request, including session-bound previews and window reuse.
| `rig workflow specs` | — | `--json` |
| `rig workflow guidance <instanceId>` | — | `--packet <id>`<br>`--component <id>`<br>`--full`<br>`--json` |
| `rig workflow show <instanceId>` | — | `--json` |
| `rig workflow trace <instanceId>` | — | `--full`<br>`--json` |
| `rig workflow trace <instanceId>` | — | `--json` |
| `rig workflow continue <instanceId>` | — | `--json` |
| `rig workflow run <specPath>` | — | `--root-objective <text>` **required**<br>`--created-by <session>` **required**<br>`--entry-owner <session>`<br>`--rig <name>`<br>`--project <id>`<br>`--mission <id>`<br>`--slice <id>`<br>`--tags <tags>`<br>`--json` |
| `rig workflow unregister [spec]` | — | `--prune-missing`<br>`--json` |
| `rig workflow watch <instanceId>` | — | `--json` |
| `rig workflow route <instanceId>` | — | `--to <session>` **required**<br>`--actor-session <session>` (only when the caller's session can't be derived)<br>`--packet <qitem-id>`<br>`--reason <text>`<br>`--json` |
| `rig workflow resume <instanceId>` | — | `--actor-session <session>` (only when the caller's session can't be derived)<br>`--occurrence <failed-qitem-id>`<br>`--decision <text>`<br>`--json` |
+1 -5
View File
@@ -181,11 +181,7 @@ UI event consumption is centralized through one shared `/api/events`
connection (`lib/topology-events.ts`) so related surfaces avoid duplicate
SSE connections. Current consumers: For You feed hydration, topology
activity rings + hot-potato movement, the activity feed/system surfaces,
and rig event surfaces. That connection carries stored events only:
`view.changed` is delivered on the live view streams (`/api/views/sse`) and
is not stored, so it no longer appears in the activity feed, as a For You
observation card or in the telemetry export (entries stored by older
versions still replay until an events retention, a separate change, removes them). `useGlobalEvents` reports whether that connection
and rig event surfaces. `useGlobalEvents` reports whether that connection
is up and refetches review, slice and mission data on reconnect and on
`proof.*` events. Reduced-motion preference is honoured for pulse/packet
animation. (Daemon SSE surfaces: `/api/events`, `/api/stream/watch`,
-68
View File
@@ -181,71 +181,3 @@ rig exec cancel <runId>
- One run is one turn for one row.
- The client offers the agent no file-system or terminal methods; the agent works in its own environment.
- Spend is whatever the agent's provider charges. OpenRig doesn't measure it; it records only what the agent reports.
## OpenRig as an agent in ACP apps
The other direction: `rig acp serve`. An app that speaks ACP and can launch a custom agent command can offer your OpenRig team as its agent. The app starts
`rig acp serve` and talks to it on stdin and stdout:
```sh
rig acp serve --to <member@rig>
```
Without `--to`, the receiver is this machine's `acp.to` setting: `rig config set acp.to <member@rig>`, set once. An
app that starts the agent from the ACP registry passes no arguments, so that's how it reaches your team.
- **A prompt** becomes a queue row from you to `--to`, the seat that takes your team's new work. The row's body is your
prompt, plus a line saying which app sent it and that its closing resolution is your answer.
- **Progress:** each row the work reaches shows in the app as a step (`Sent to …`, then `Handed to …` at each handoff),
with the row's notes as they're written, each with the seat that wrote it. The step's title also shows its latest
note, for apps that show steps collapsed.
- **The reply** is the closing resolution, with who closed the row and how (`done`, `done (canceled)`, `denied`, …). The
turn ending says only that the turn ended: read the reply for how the work ended.
- **A follow-up** in the same session goes to `--to` as a new row whose body names the earlier rows and their states,
and the app first shows any earlier row that's still open.
- **A question the team asks you** while a turn is open comes to the app with the ask's own choices, when its row is
tagged for that chat (`acp-session:<id>`; the task's row tells the team how). An app that takes forms (it
advertises ACP elicitation's form mode, as T3 Code does) gets a one-choice form with each choice by its label; any
other app gets a permission request with one option per choice, as Zed shows them. An untagged
question from a seat the work reached is named in the chat with where to answer it, because it could be another
chat's. Your choice is recorded as your answer, as a click in Slack would be, and the seat that asked gets it.
Dismissing it, or cancelling the turn, records nothing: the question stays open in the TUI and Slack, and no further
question opens after a cancel. A question that ends unanswered here (dismissed, cancelled, closed with the work, or
one the app couldn't show) is named in the chat as still open, as well as on its card. A question that wants an
answer in words is shown with where to answer it.
- **Sessions are kept in the queue:** the app can list your earlier sessions (`session/list`) and reopen one
(`session/load`). Reopening replays each prompt, its work, the questions the team asked you during it with your
answers, and the reply, as they stand now. Nothing is asked again on reopening: a question still open comes up in
your next prompt's turn, and a new prompt continues the session.
- **Cancel** stops `rig acp serve` waiting. The team's work carries on, and the reply says so.
- **Work handed to a seat on another machine** can't be followed from here; the reply says where it went.
**Who you are:** `--as <address>`, else `OPENRIG_SESSION_NAME`, else this machine's default person: the only
registered person, or the one chosen with `rig gateway default <id>` when there are several (the address is
`<id>@external`). The app's sign-in step, "OpenRig on this machine", checks
that the daemon answers and that you resolve to an address; until then, signing in and new sessions fail with the
reason and the fix. The agent talks to the same daemon as `rig` on this computer: the local one, so start it first
(`rig daemon start`), unless `OPENRIG_URL` names another.
The team's questions reach you only as a registered person: an `<id>@external` address needs
`rig gateway human add <id> --display-name "<your name>" --delivery-class <A|B|C|D>` first. Until then, the app says
so at your first prompt, and your work is still sent.
**In Zed**, add the agent to `settings.json`:
```json
"agent_servers": {
"OpenRig": { "type": "custom", "command": "rig", "args": ["acp", "serve", "--to", "lead@my-team"] }
}
```
Other apps take the same command and arguments wherever they configure a custom ACP agent.
**Limits:**
- One receiver per agent command (or the `acp.to` setting); the app has no team picker.
- Questions show only while a turn is open; one asked while no turn is open waits in your usual places (the TUI, Slack).
- An app that shows a permission request's options only as fixed buttons, such as Approve and Decline, and doesn't
take forms, can't show the choices: answer those questions in the TUI or Slack.
- Prompts are text. Links and embedded text files are passed on as written; images and audio aren't accepted.
- Progress is read every 2 seconds while a turn is open (`--poll-seconds`).
- Sessions are found among the 2,000 newest rows you've sent, and a reopened session's questions among the 2,000 newest
sent to you, so a caller with a lot of traffic, such as a busy seat, can't list or fully reopen its oldest sessions.
+1 -51
View File
@@ -375,7 +375,7 @@ version.
| Category | Fields | Description |
|----------|--------|-------------|
| `skills` | `id`, `path`, `on_restore`* | Skill directories containing a SKILL.md (see "Skill Resources"). Projected by the runtime adapter. Claude and Codex seats get them in the seat's own storage, `<OpenRig home>/state/seats/<rig id>/<logical seat>/<runtime>/`, outside the project: Claude to `plugin/skills/<id>` in the seat's generated plugin, where they show as `openrig:<id>`, and Codex to `skills/<id>` beside the seat's skill catalog. A Claude or Codex seat without that storage gets `<cwd>/.claude/skills/<id>` or `<cwd>/.agents/skills/<id>`. Pi and OMP project to the seat's own agent directory under OpenRig's state. |
| `skills` | `id`, `path` | Skill directories containing a SKILL.md. Projected by the runtime adapter. Claude and Codex seats get them in the seat's own storage, `<OpenRig home>/state/seats/<rig id>/<logical seat>/<runtime>/`, outside the project: Claude to `plugin/skills/<id>` in the seat's generated plugin, where they show as `openrig:<id>`, and Codex to `skills/<id>` beside the seat's skill catalog. A Claude or Codex seat without that storage gets `<cwd>/.claude/skills/<id>` or `<cwd>/.agents/skills/<id>`. Pi and OMP project to the seat's own agent directory under OpenRig's state. |
| `guidance` | `id`, `path`, `target`*, `merge`* | Guidance files, merged as a managed block (see "Guidance Resources"). |
| `subagents` | `id`, `path` | Subagent definitions. Claude copies them to `<cwd>/.claude/agents/`, Codex to `<cwd>/.agents/`; Pi and OMP don't project them. |
| `plugins` | `id`, `source`, `plugin_type`* | Local plugin directories; selected with `uses.plugins`. `source.kind` must be `local`. `plugin_type` is `claude`, `codex` or `auto` (default): Claude projects a plugin to `<cwd>/.claude/plugins/<id>` when it is `claude`, or `auto` with a `.claude-plugin/plugin.json`; Codex projects to `<cwd>/.codex/plugins/<id>` when it is `codex`, or `auto` with a `.codex-plugin/plugin.json`. Pi and OMP don't project plugins. Claude Code and Codex do not read skills from that projected plugin folder, so selected plugin skills are also projected into the seat's skill folder under their plain names: the seat's own storage (see `skills`), or `.claude/skills/<name>` or `.agents/skills/<name>` for a seat without it. The exception is the vendored `openrig-core` selected by a Claude seat: Claude loads that plugin directly, skills included, so its skills are not projected a second time. A folder already there with the same name, ignoring letter case, that OpenRig doesn't own is left alone; unless it already holds the same bytes, launch warns `plugin_skill_kept`. A skill the seat already selects by that name (from its profile, the System World or the project) keeps that source, an edited OpenRig-owned copy is kept with `plugin_skill_kept`, a retained plugin copy whose remembered source can no longer be projected is also kept with `plugin_skill_kept` (a seat selecting that plugin refreshes it at its next launch), and if the plugin skills can't be projected the seat launches without them and warns `plugin_skills_not_projected`. Hooks ship inside plugins. |
@@ -442,55 +442,6 @@ a legacy table it names the file to move the settings into.
- Resource IDs must be unique within their category
- Resource IDs are the identifiers used in `uses` references
### Skill Resources
```yaml
skills:
- id: house-style
path: skills/house-style
on_restore: follow_source # optional — keep_saved by default
```
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `id` | string | yes | — | Resource identifier, used in `uses.skills`. |
| `path` | string | yes | — | Relative path to the skill's folder, which holds its `SKILL.md`. |
| `on_restore` | string | no | `keep_saved` | What a restore (`rig up <rig>`) does with a Claude or Codex seat's copy of this skill in the seat's own storage: `keep_saved` or `follow_source`. |
An unknown `on_restore` value produces an advisory and uses `keep_saved`; it does not invalidate the spec.
A restore keeps the skills a seat was launched with. With `keep_saved`, it puts back the saved copy and never takes
today's bytes from the source. With `follow_source`, before the seat's session starts, the restore reads the seat's agent
spec as it is now, using the saved launch recipe and rig folder, and:
- replaces the seat's copy with the source folder as it is now, and records it, so `rig down` removes it and the next
restore puts it back as usual;
- copies in a `follow_source` skill the profile now selects that the seat didn't have;
- removes a skill that followed its source and that the profile no longer selects.
A copy edited in the seat (changed bytes or recorded file permissions, or an added file), or a folder with the same id that OpenRig didn't write, is kept as
it is and named in the restore's warnings. If the agent spec can't be read or resolved, the seat keeps its saved skills
and the restore says why; a source folder without a `SKILL.md` keeps that skill's saved copy. A skill changed to
`keep_saved` stops following at the next restore, and its copy stays. Older seat records without file permissions
retain their byte-only ownership checks until a new projection or refresh records permissions.
Source file symlinks are dereferenced, including links to files outside the skill folder, just as during Claude and
Codex launch projection. The seat receives an ordinary file containing the target's bytes. A broken link or a link to
a directory makes the source unreadable and keeps the saved copy; links in the seat's destination remain protected.
Only a restore does this; a running seat isn't changed, and neither is a Codex seat restarted in place. A Claude seat
loads its seat plugin when its session starts, so it sees the new copies. A Codex seat with following skills receives
its rebuilt catalog after its resumed session is ready. The catalog lists the skills actually present, replaces its
earlier OpenRig skill list, and asks it to read current skill files before using them. This includes newly selected
skills and the removal of the last following skill. An edited or linked catalog is kept and not sent, with a warning;
a delivery failure is reported instead of claiming a complete restore. Neither runtime erases skill instructions
already read into the retained conversation.
Following covers the whole selected skill folder, including scripts and references. It does not install external
tools, change MCP configuration, or update bundled plugins. The saved launch recipe supplies the rig directory,
member and profile; today's agent spec supplies the skill selection. Today's rig YAML and other agent settings are
not applied by this refresh.
### Guidance Resources
```yaml
@@ -546,7 +497,6 @@ restore behavior, and the distinction between readiness and verified proof.
9. All resource paths must be safe relative paths (a plugin's `source.path` excepted).
10. Resource IDs must be unique within their category, and each `resources.<category>` must be an array.
11. `runtime_resources` entries require `runtime` and `type` fields.
11a. An unknown skill `on_restore` value produces an advisory naming the known values and falls back to `keep_saved`.
12a. `resources.hooks` and `profiles.<name>.uses.hooks` are refused: "removed in plugin-primitive (Phase 3a)". Declare
a plugin instead.
12b. A plugin needs a unique non-empty `id`, a `source` object with `kind: local` and a non-empty `path`, and a
-28
View File
@@ -63,33 +63,6 @@ delivery has been attempted, it cannot be replayed by `continue`, including
after a daemon restart: inspect `rig seat status <seat>` and the native pane
before deciding how to recover an uncertain delivery.
## After a restored seat's prompt
A restore that stops at a prompt, such as Claude's resume selection, records the
seat's restore outcome as `attention_required`. `rig seat continue` doesn't change
that record: it only delivers pending startup context. While the record stands, it
alone keeps the seat's lifecycle at `attention_required`, even with startup `ready`:
`rig ps --nodes` shows LIFECYCLE `att`, `needsAttention` is true, and the seat counts
in the rig's `attentionCount` and the TUI's attention totals.
After a full restore of a Claude or Codex seat, answering the prompt is enough. When
the seat's activity next turns to running or idle, or when the whole restore completes
after the seat already reads running or idle, the daemon checks the live seat: its tmux
session is alive, and the pane runs the agent resuming the conversation the seat
records. When the check passes, the restore outcome becomes `operator_recovered`, so
the restore no longer makes the seat need attention. When it fails, the restore outcome
stays and the seat still needs attention, which is true when the pane runs another
conversation. A check that started before a newer restore of the seat doesn't settle
the newer outcome.
`rig seat clear-attention <seat>` runs the same check on demand. When it fails, the
command says which check failed, and `--reason` doesn't bypass it. If
the seat records the wrong conversation and you know the right one, set it with
`rig seat set-resume-token <seat> --token-stdin --reason <text>`, then run
`clear-attention` again. After a restore of only some seats, or a community
harness's assisted startup, `--reason "<what you saw>"` can acknowledge the
outcome instead.
## Two Categories of Startup
Everything an agent receives at boot time falls into one of two categories:
@@ -421,7 +394,6 @@ running seat only through the action in the last column.
| Reasoning effort | `rig seat set-effort` | `rig seat effort <seat>`: requested, saved, sent at launch, and what the session recorded, which is not verified until it takes a turn | Its next managed continuation; nothing restarts |
| Permissions | `rig seat set-permissions` (`--mode`, `--rules`, or for Codex `--codex-permissions` or `--codex-profile`), shown by `rig seat status` | What it launched with; a status read doesn't prove the running process enforces the selection | Its next managed launch or resume ([per-seat permission mode](getting-started.md#per-seat-permission-mode)) |
| Working directory | `rig seat set-cwd`, shown by `rig seat status` as **Working directory for future launches** | The directory it started in | `rig seat launch --fresh --stop` (a blank conversation) or `rig handover --source rebuild` (durable context); [details](#change-an-existing-seats-working-directory) |
| Launch wrapper (`launch_wrapper`) | Member `launch_wrapper` in `rig.yaml` (`pi`, `codex` and `claude-code` only; `launch_wrapper: []` clears, omitting retains on existing-seat apply; surfaced as `launchWrapper` in `rig ps --nodes --json` when set; no live `rig seat` edit verb) | The literal wrapper argv that prefixes the resolved Codex entry or Claude executable (preserving TTY stdio) or the resolved Pi child executable inside OpenRig's host runner (preserving RPC pipes and runner lifecycle) when that session launched; a configured prefix is not proof of isolation, tooling, memory or governance | Supported spec create/recreate/apply flow, then the seat's next managed launch, resume, restore or handover (fails closed when wrapper support is unavailable; never falls back to bare child launch; [details](rig-spec.md#launch-wrapper-runtime-pi-and-codex)) |
| Startup files and skills | The spec's startup files, skills and actions | What its conversation read when it started; editing a file afterwards doesn't reach it | A fresh launch or a handover delivers them again; for the running seat, send or walk the change (`rig walk <seat> --through <files>`) |
| Tools (MCP servers, plugins) | Selected fragments, merged into the harness's own config (`.mcp.json` for Claude, `config.toml` for Codex) | Unknown to OpenRig: registered isn't active, Claude asks before it uses a new server, and no OpenRig command reports which tools a running harness loaded | Check in the harness itself (Claude Code's `/mcp`); a running session may need a restart to pick up newly written config ([runtime config disclosure](#runtime-config-disclosure)) |
-7
View File
@@ -206,13 +206,6 @@ LIFECYCLE); see the
- **Restore outcome:** `resumed`, `fresh-primed`, `awaiting-decision`, `attention_required`,
`failed` (plus `rebuilt`, `operator_recovered` and the legacy `fresh`). A seat joined with
`rig attach` as an external CLI reads `left_attached`: restore leaves it as it is.
An `attention_required` outcome, or `failed` with a running session, makes the seat's
lifecycle `attention_required`, whatever its startup status, so it raises `needsAttention`.
After a full restore, the daemon reconciles it to `operator_recovered` when the seat's
activity next turns to running or idle (or reads so when the restore completes) and the
live seat passes its check, and `rig seat clear-attention <seat>` runs that check on
demand; `rig seat continue` doesn't change it
([startup guide](agent-startup-guide.md#after-a-restored-seats-prompt)).
## Rejected oracles — dated receipts (2026-08-26), so nobody re-derives them expensively
+5 -12
View File
@@ -65,17 +65,14 @@ or else from the file `$OPENRIG_HOME/terminal-token`, which the person writes; t
A wrong or missing token gets HTTP 401. The routes that check `OPENRIG_AUTH_BEARER_TOKEN`, and `/api/transport`
and `/api/compaction`, answer `{ "error": "unauthorized", "message", "what_failed", "why_it_matters", "what_to_do" }`.
The `GET /api/terminal/<name>` routes, the websocket included, answer `{ "error": "unauthorized", "hint" }`. A valid configured bearer token in the Authorization header can pass the browser boundary's target-name check
for a well-formed but unaccepted Host. It cannot pass a malformed Host or a refused Origin; see
[Browser access](browser-access.md).
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 of them are server-sent events (`text/event-stream`):
All three are server-sent events (`text/event-stream`):
- **`GET /api/events`**: the daemon's event log, stored events only. `?rigId=` limits it to one rig. Each event
carries `id: <seq>`. `view.changed` is not stored, so it does not appear here, live or replayed; `view.changed`
rows stored by older versions still replay until an events retention (a separate change) removes them.
- **`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.
@@ -83,9 +80,6 @@ All of them are server-sent events (`text/event-stream`):
- **`GET /api/activity/events`**: live only, no replay or ids. Each message is named after its event type:
`seat.activity_changed`, `seat.rung_health`, `proof.judged` or `proof.sources_changed`. It tells a view to read
again; it carries no derived state.
- **`GET /api/views/sse`** (alias `/api/views/watch`) and **`GET /api/views/<viewName>/sse`**: `view.changed`, live
only. Its `id:` is the `seq` of the stored event that caused it, so it can repeat across views and is not a replay
cursor.
- **`GET /api/queue/sse`** (alias `/api/queue/watch`): live only, with `id: <seq>`, for `queue.created`,
`queue.handed_off`, `queue.claimed`, `queue.unclaimed`, `qitem.fallback_routed`, `qitem.closure_overdue`,
`inbox.absorbed` and `inbox.denied`.
@@ -185,8 +179,7 @@ an open FYI, or a closed ask is not that receipt. The rig-wide reads and their l
- **`POST /api/workflow/instantiate`** requires `specPath`, `rootObjective` and `createdBySession`, and takes
`entryOwnerSession`, `targetRig` and `scope` (see [Project workspace](project-workspace.md) for `scope`).
Success is 201 with `instance`, `spec`, `entryQitemId` and `entryOwnerSession`, any `advisories`, and
`scopeIgnored` when a scope wasn't applied. The instance keeps `rootObjective`, which every instance read returns
(`/api/workflow/list`, `/api/workflow/<id>` and its `trace`). An instance started before it was kept reads `null`.
`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
+1 -4
View File
@@ -14,7 +14,7 @@ returns `503` with `error: "human_registry_unavailable"`. The result does not in
## Participants
A participant is either:
- **a person**, registered with `rig gateway human add`, with or without a connector binding (such as Slack or an HTTP loopback [relay](gateway-relay.md)); or
- **a person**, registered with `rig gateway human add`, with or without a connector binding; or
- **a shared channel**: one vetted Slack channel registered as one participant, `<id>@external`, not a person per
sender.
@@ -22,9 +22,6 @@ People and channels share one address space: a channel can't take a person's id.
A registered person is addressed as `<id>@external`, including without Slack: for example,
`rig gateway human add alex --display-name "Alex" --delivery-class B` creates `alex@external`, reached in the Feed and Steering.
A person can also bind to a local bridge adapter using a `relay` binding:
`rig gateway human add bob --display-name "Bob" --delivery-class B --binding "relay:local-bridge:file:/etc/openrig/relay.env:primary:handle=bob-ops"`
(see [Gateway Relay Reference](gateway-relay.md) for endpoint, secret provisioning, and custody architecture).
`human@host` is the unnamed person at the terminal; `human@kernel` is also accepted as a human/gate address.
These aliases (including `human-<name>@host` and `human-<name>@kernel`) are not agent sessions: reply with
`rig queue create --destination human@host --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`,
-239
View File
@@ -1,239 +0,0 @@
# Gateway Relay: HTTP Loopback Transport Adapter & Custody Reference
OpenRig's Gateway Relay is a local HTTP loopback transport adapter. It enables registered people to receive outbound notifications and questions (asks, updates, and operator digests) through an operator-owned local bridge process, and to post answers back into OpenRig with sender attribution resolved against registered human bindings.
OpenRig does not embed third-party messaging platform SDKs (such as WhatsApp, Matrix, or Signal). An external bridge process translates between OpenRig loopback HTTP requests and external messaging platforms.
```
+--------------------+ HTTP Loopback +--------------------+
| OpenRig Daemon | POST <OPENRIG_RELAY_URL> ---> | Operator Bridge |
| (Gateway Subsys) | <--- POST .../relay/inbound | (Loopback Server) |
+--------------------+ +--------------------+
|
Platform-specific transport
v
+--------------------+
| External Platform |
| (Mobile / Client) |
+--------------------+
```
---
## 1. System Topology & Custody Boundaries
### Outbound Custody & Recovery
- OpenRig enqueues outbound decisions to a local `DispatchBuffer` before issuing any HTTP `POST` to `OPENRIG_RELAY_URL`.
- Each logical decision freezes its complete `relayEnvelope` onto the parent `DispatchBuffer` entry before the first network attempt, with no self-imposed outbound 64 KiB cap or multi-part splitting.
- Transport is at-least-once: OpenRig sends the complete JSON envelope in a single HTTP `POST` per attempt with `deliveryId = <decisionId>` (preserving the full `body`, `humanDetail`, `humanQuestions`/options, `summary` overflow, and digest `items` without silent clipping).
- An HTTP `2xx` response from the bridge acknowledges durable bridge custody of that complete envelope (`deliveryId`). The bridge must persist each envelope by `deliveryId` and deduplicate replayed `deliveryId`s idempotently.
- OpenRig records the logical queue acceptance receipt (`relay-owner-notification-accepted` with `deliveryId`) and drains the decision from `DispatchBuffer` once the HTTP `POST` returns `2xx`. Bridge acceptance confirms custody by the bridge process only (and is projected as `accepted` / `"Accepted by bridge"`, whereas Slack posts use `posted` / `"Delivered"`). It does not indicate downstream platform delivery, posting, or human receipt.
- Failed dispatches remain in OpenRig's `DispatchBuffer` and record a relay-specific failure transition note (`relay-owner-notification-transport-failed` naming the `connectorRef`, distinct from Slack's `slack-owner-notification-transport-failed`). Replays after a lost ACK or daemon/bridge restart re-send the exact frozen `relayEnvelope` with the same `deliveryId` and bytes. If the relay subsystem stops while an outbound HTTP `POST` is in flight (or before it starts), the transport aborts cleanly (`stopped`) without recording a false failure transition note or false acceptance receipt, retaining the buffered decision for replay on restart. There is currently no periodic background timer redriving outbound dispatches on timeout.
### Inbound Custody & Resolution
- The operator bridge posts replies to `POST /api/gateway/relay/inbound`.
- Requests require `Authorization: Bearer <OPENRIG_RELAY_INBOUND_TOKEN>` matching a configured, ready relay profile.
- OpenRig verifies that the caller is a direct loopback peer (`127.0.0.1/8` or `::1`) over the underlying TCP socket. Proxy and forwarding headers (`Via`, `X-Forwarded-*`, `Forwarded`, `X-Real-Ip`) are ignored rather than rejected because caller identity is established directly from the loopback socket peer and the bearer token.
- Sender attribution resolves by matching `sender.handle` against human fragments registered to the authenticated connector. Harmless extra fields on the top-level inbound JSON object or inside `sender` are ignored and can never override handle-to-person attribution or alter the event fingerprint. The handle is asserted by the trusted bridge; it does not provide independent proof of the physical human on the external platform.
- When an inbound reply is accepted (`200 OK`), OpenRig reserves the event in `relay-inbound-seen.jsonl` (`received:<fingerprint>`), durably commits the answer/resolve action and its `mission_control_actions` audit record atomically through its Mission Control write contract (using existing `excludeOperationReply` revision checking without a second answer store or synthetic revision refresh), and marks the event `processed:<fingerprint>`. Exact duplicate replays of the same `eventId` and payload succeed idempotently (`deduplicated: true`).
### Exclusions & Scope
The relay adapter does not handle interactive UI buttons, message attachments, group rooms, unsolicited inbound triggers, multi-bridge failover clustering, or third-party platform SDK backends.
---
## 2. Secrets & Environment Configuration
Each relay connector loads configuration from an absolute file referenced by the binding's `secretsRef` (`file:<absolute-path>`).
### Security & File Requirements
- **Absolute Path Only**: Must begin with `file:/`. Relative paths, bare paths, and `file://` authority URLs are rejected.
- **File Permissions**: The file should restrict access to the owner only (no group or other permission bits set, i.e. `(mode & 0o077) === 0`, such as `0600` or `0400`). Owner-only modes (`0600`, `0400`) produce no warning. If group or world permission bits are set (e.g. `0644`), OpenRig logs a redacted warning in the profile's readiness `warnings` and `reason` (matching the Slack secret-file posture) while keeping the profile `ready`. Missing or unreadable secret files remain `not-ready`.
- **No Environment Fallback**: OpenRig does not fall back to process environment variables (`process.env`). All variables must come directly from the referenced file.
- **Profile Independence**: If a secrets file is missing, unreadable, or missing required keys, only connectors bound to that file report `not-ready`. Other connectors remain functional.
### Required Secrets Keys
```ini
OPENRIG_RELAY_URL=http://127.0.0.1:8088/webhook
OPENRIG_RELAY_OUTBOUND_TOKEN=sec_outbound_token_value
OPENRIG_RELAY_INBOUND_TOKEN=sec_inbound_token_value
```
- `OPENRIG_RELAY_URL`: Must use `http:`, loopback host (`127.0.0.1/8`, `::1`, or `localhost`), without URL credentials or hash fragments. Redirects (3xx) are rejected.
- `OPENRIG_RELAY_OUTBOUND_TOKEN`: Non-empty Bearer token sent by OpenRig on outbound requests to the bridge.
- `OPENRIG_RELAY_INBOUND_TOKEN`: Non-empty Bearer token required on inbound requests from the bridge. Must be unique across different configured relay connector profiles (`connectorRef`); sharing the same inbound token across multiple relay profiles is rejected as ambiguous (`not-ready`). Using the same token value for both `OPENRIG_RELAY_OUTBOUND_TOKEN` and `OPENRIG_RELAY_INBOUND_TOKEN` within a single profile is allowed, with a redacted readiness warning explaining why separate directional tokens are recommended so an outbound capture cannot authenticate inbound replies.
---
## 3. Wire Protocol Specifications
### Outbound Envelope (`POST <OPENRIG_RELAY_URL>`)
```json
{
"v": 1,
"deliveryId": "dec-019283-abc",
"recipient": { "handle": "alice-ops" },
"intent": "decision",
"outcome": "interrupt",
"summary": "Database Migration Approval",
"body": "Production migration script 004_add_indexes.sql is ready to execute. Reply YES to approve, NO to abort.",
"replyRef": "eyJ2IjoxLCJwYXlsb2FkSGFzaCI6...<base64url>"
}
```
- `v`: Protocol version integer (`1`).
- `deliveryId`: Stable whole-decision delivery identifier (`<decisionId>`).
- `recipient.handle`: Recipient's registered safe alias for this connector.
- `intent`: `"decision"` (actionable question) or `"update"` (informational notice).
- `outcome`: Delivery classification (`"interrupt"`, `"notify"`, or `"digest"`).
- `summary`: Plain text summary preview (up to 1,000 characters on the `summary` field, cut on a Unicode code-point boundary; any summary text exceeding 1,000 characters is preserved in full at the beginning of `body` or `item.body`).
- `body`: Complete plain text content (including full `summary` overflow, `body`, `humanDetail`, and rendered `humanQuestions` headers, prompts, and option labels/descriptions) sent in a single complete JSON envelope without silent character clipping or self-imposed outbound byte splitting.
- `replyRef`: Fixed-size opaque base64url token (`<= 4,096` characters for any supported ID lengths — `246`–`266` characters in protocol v1, hashing the canonical frozen `RelayReplyRefPayload` and binding `connectorHash`, `mode`, and optional `digest: true`) for actionable decisions; `null` for informational updates (and `null` at the top level of multi-item digests).
- `items`: Present when `outcome` is `"digest"`. Array of objects with `qitemId`, `notificationKey`, `summary`, optional `body`, and per-item `replyRef` (or `null`).
### Inbound Envelope (`POST /api/gateway/relay/inbound`)
```json
{
"eventId": "evt-relay-20261107-001",
"sender": { "handle": "alice-ops" },
"replyRef": "eyJ2IjoxLCJwYXlsb2FkSGFzaCI6...<base64url>",
"text": "Approved. Proceed with rollout.",
"ordering": "after-question"
}
```
- `eventId`: Bridge-assigned identifier (`1..256` chars, matching `/^[A-Za-z0-9._:-]{1,256}$/`).
- `sender.handle`: Sender's alias, matching `/^[A-Za-z0-9._-]+$/`. Must match a human fragment bound to the authenticated connector. Harmless extra fields inside `sender` or on the top-level JSON object are ignored and never override `sender.handle` resolution or affect the event fingerprint.
- `replyRef`: Exact fixed-size opaque base64url token copied from the outbound envelope (maximum 4,096 characters). Bridges must not parse or alter `replyRef`.
- `text`: Plain text response (maximum 32,768 characters, non-empty after trim, no NUL bytes). Processed as unstructured text without automatic natural-language approval parsing.
- `ordering`: Optional enum (`"after-question"` | `"uncertain"`, default `"uncertain"`). When `"uncertain"`, text is accepted as recorded context rather than verified sequence relative to the question.
#### Inbound Statuses & Responses
- **200 OK**: Request accepted (`{ ok: true, deduplicated: false, reconciled: false, qitemId, verb, actorSession, actionId }`). Exact duplicate submissions of the same `eventId` return `ok: true` with `deduplicated: true`.
- **400 Bad Request**: Malformed JSON, non-object payload, or invalid required field syntax/bounds (`eventId`, `sender.handle`, `replyRef`, `text`, or `ordering`). Harmless extra fields are ignored.
- **401 Unauthorized**: Missing, malformed, unrecognized, unready, or cross-profile ambiguous inbound Bearer token, or no humans configured (`noHumans`).
- **403 Forbidden**: Non-loopback TCP socket peer, browser cross-origin request, connector mismatch (`replyRef` emitted by another connector), or unregistered `sender.handle`. (`Via` and `X-Forwarded-*` headers are ignored).
- **404 Not Found**: Queue item referenced in `replyRef` does not exist.
- **409 Conflict**: Stale `decisionRevision` (question cancelled, answered, or re-asked), attempt to answer an informational update, unrecognized `replyRef` when both durable receipts and the pending dispatch buffer are readable, or conflicting payload submitted under an existing `eventId`.
- **413 Payload Too Large**: Request body exceeds 64 KiB (65,536 bytes).
- **503 Service Unavailable**:
- `human_registry_unavailable`: Local human registry (`gateway/humans/*.yaml`) failed to load due to a broken fragment or filesystem read error (normal `noHumans` returns `401`).
- `queue_store_unavailable`: Local SQLite queue/transition store read failed while resolving or verifying `replyRef`, returning `503` with zero seen-store reservation or Mission Control action side effects.
- `dispatch_buffer_unavailable`: An un-receipted `replyRef` required fallback verification against `<openrig-home>/gateway/dispatch-buffer.json`, and reading the buffer failed (`pendingStrict()`), returning `503` with zero seen-store reservation or Mission Control action side effects. (`replyRef`s already vouched for by durable accepted receipts in `queue_transitions` or `queue_transitions_archive` are verified before consulting the buffer and still succeed.)
- `seen_store_unavailable` (response includes `seenPath`): Local seen-ledger (`state/relay-inbound-seen.jsonl`) is unreadable, contains unparseable JSONL or unkeyable records (missing `id`), failed a reservation/processed mark, or has a corrupt/unvouched record for the requested `eventId`'s key. Keyed suspect records (`missing/invalid status` or conflicting recorded fingerprints for the same key; diagnostic `ts` or extra metadata fields are ignored) scope refusal to that `eventId` key only so unrelated `eventId`s continue to operate normally, while never treating unparseable or suspect history as unseen.
---
## 4. Identity & Binding Configuration
Bindings are managed through human fragment files (`gateway/humans/<entityId>.yaml`) and CLI verbs (`rig gateway human`).
### Binding Syntax
```
relay:<connectorRef>:file:<absolute-env-file>:<role>[:handle=<alias>]
```
- `kind`: `relay`.
- `connectorRef`: Identifier for the connector instance (e.g. `local-bridge`).
- `secretsRef`: Absolute file path reference (`file:/path/to/relay.env`).
- `role`: `primary` or `secondary` (at least one primary binding required for outbound dispatch).
- `handle`: Optional platform alias (`handle=<alias>`). Required for inbound reply resolution; omitted means outbound-only.
Handles must match `/^[A-Za-z0-9._-]+$/` and are unique per `connectorRef`. Multiple people can share a connector, each registered with their own handle.
---
## 5. Operational Lifecycle
### Step 1: Provision Secrets File
Create an owner-only secrets file (no group/other access bits, e.g. `0600` or `0400`):
```bash
mkdir -p "$HOME/.config/openrig-relay"
touch "$HOME/.config/openrig-relay/relay.env"
chmod 600 "$HOME/.config/openrig-relay/relay.env"
cat << 'EOF' > "$HOME/.config/openrig-relay/relay.env"
OPENRIG_RELAY_URL=http://127.0.0.1:8088/webhook
OPENRIG_RELAY_OUTBOUND_TOKEN=replace_with_outbound_token
OPENRIG_RELAY_INBOUND_TOKEN=replace_with_inbound_token
EOF
```
### Step 2: Register Human Binding
```bash
# Add a person with a primary relay binding
rig gateway human add alice --display-name "Alice Smith" --delivery-class B \
--binding "relay:local-bridge:file:$HOME/.config/openrig-relay/relay.env:primary:handle=alice-ops"
# Replace an existing binding slot (requires the full binding spec)
rig gateway human set alice binding.0 "relay:local-bridge:file:$HOME/.config/openrig-relay/relay.env:primary:handle=alice-ops"
```
> [!NOTE]
> `rig gateway human set alice binding.<n>` requires a full binding specification string. Supplying `unset` is not supported for binding slots. To reassign a participant, overwrite the slot with a full binding string or remove and re-add the human fragment.
Operator notices use the existing default person. If several people are registered, choose the recipient explicitly when needed:
```bash
rig gateway default alice
```
### Step 3: Activate Daemon
When bindings or secrets files change, restart the daemon process:
```bash
rig daemon stop
rig daemon start
```
*(OpenRig does not provide a `rig daemon restart` command).*
### Step 4: Verify Readiness & Status
```bash
# Check delivery readiness for all participants
rig gateway human list
# Inspect detailed readiness status for an individual
rig gateway human show alice
# Check daemon process status
rig daemon status
```
In `rig gateway human show <entityId> --json`, `deliveryReadiness.state` reports `ready` when the binding configuration and local secrets file are valid. This confirms local readiness, not whether the operator bridge process is currently running.
### Step 5: Removing Bindings or Participants
Before removing a participant, ensure any pending queue items and conversations assigned to them are resolved.
```bash
rig gateway human remove alice
```
*(The remove verb refuses to delete a participant if open conversations or non-terminal items exist, unless `--force` is specified).*
---
## 6. Troubleshooting Common Conditions
| Condition | Observation | Resolution |
| :--- | :--- | :--- |
| **Missing Secrets File** | `deliveryReadiness.state: not-ready`<br>`reason: relay secret file ... is missing or unreadable` | Verify file path in `secretsRef` is absolute and readable. |
| **Insecure Permissions** | `deliveryReadiness.state: ready` (with warning)<br>`reason: ... warning: ... insecure permissions (mode 0644; group/other access bits are set ...)` | Restrict permissions so group/other have no access (`chmod 600 <secrets-file>` or `chmod 400 <secrets-file>`). |
| **Identical Directional Tokens** | `deliveryReadiness.state: ready` (with warning)<br>`reason: ... warning: ... uses the same token for OPENRIG_RELAY_OUTBOUND_TOKEN and OPENRIG_RELAY_INBOUND_TOKEN; separate tokens are recommended ...` | Provision distinct outbound and inbound tokens so outbound capture cannot authenticate inbound replies. |
| **Invalid Loopback URL** | `deliveryReadiness.state: not-ready`<br>`reason: ... OPENRIG_RELAY_URL host must be loopback ...` | Ensure URL uses `http://` and host is `127.0.0.1/8`, `::1`, or `localhost`. |
| **Reused Inbound Token Across Profiles** | `deliveryReadiness.state: not-ready`<br>`reason: ... reuses an ambiguous OPENRIG_RELAY_INBOUND_TOKEN shared with relay profile(s): ...` | Each distinct relay connector profile (`connectorRef`) must declare a unique inbound token. |
| **Stale Question Reply** | HTTP 409 on inbound endpoint | Question was already answered, cancelled, or superseded by a newer revision. |
| **Unregistered Handle** | HTTP 403 on inbound endpoint | The `sender.handle` is not bound to the authenticated `connectorRef`. |
| **Non-Loopback Inbound** | HTTP 403 on inbound endpoint | Connection originated from a non-loopback TCP socket peer or included a cross-origin browser header. (`Via` and `X-Forwarded-*` headers are ignored). |
| **Registry or Buffer Read Failure** | HTTP 503 on inbound endpoint (`human_registry_unavailable` or `dispatch_buffer_unavailable`) | Inspect local `gateway/humans/*.yaml` fragments or `<openrig-home>/gateway/dispatch-buffer.json` for syntax/permission corruption before retrying the bridge event. |
| **Seen-Ledger Corrupt / Suspect** | HTTP 503 on inbound endpoint (`seen_store_unavailable`, includes `seenPath`) | Unreadable/unparseable/unkeyable `relay-inbound-seen.jsonl` refuses all inbound requests; a keyed suspect entry refuses only that specific `eventId`. Follow the conservative recovery procedure below. |
### Conservative Seen-Ledger Recovery (`seen_store_unavailable`)
When `POST /api/gateway/relay/inbound` returns `503` with `error: "seen_store_unavailable"`, the response body and message include the resolved `seenPath` (`<openrig-home>/state/relay-inbound-seen.jsonl`):
1. **Quiesce writers first**: Pause the loopback bridge and stop the daemon (`rig daemon stop`) so no concurrent inbound request or retry appends to the ledger during inspection.
2. **Preserve a full backup**: Copy `<seenPath>` to a timestamped backup outside `state/` before making any edit.
3. **Correlate against durable evidence**: Each ledger entry keys `id` by `eventKey = SHA256(JSON.stringify([connectorRef, eventId]))` (64-character lowercase hex digest) and stores `status` as `received:<fingerprint>` or `processed:<fingerprint>`. Because `eventKey` is a one-way hash, correlate any damaged line or keyed suspect `id` against `mission_control_actions.audit_notes_json` (which records `operationId: "relay:<eventKey>:<fingerprint>"`, `connectorRef`, and `eventId`) and bridge delivery logs by computing `SHA256(JSON.stringify([connectorRef, eventId]))` for candidate events—never by guessing or assuming the hash is reversible.
4. **Apply only evidence-verified, history-preserving repair**: Repair a damaged record only when its exact `id` (`<eventKey>`) and `status` (`received:<fingerprint>` or `processed:<fingerprint>`) are verified from `mission_control_actions.audit_notes_json` or bridge evidence, or restore only from a backup that preserves every subsequently acknowledged event. Do **not** delete, truncate, or skip unknown/unparseable records, and do **not** restore an older backup that omits acknowledged events; if an entry cannot be verified without losing history, leave the ledger in place (continuing to fail closed with `503`) and retain the backup.
5. **Resume and retry**: After a verified, history-preserving repair is saved at `<seenPath>`, start the daemon (`rig daemon start`), resume the loopback bridge, and retry the original inbound delivery with the same `eventId`.
For automated loopback bridge test reference, see `packages/daemon/test/fixtures/recording-relay.ts`.
+3 -17
View File
@@ -201,11 +201,11 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
script will do and changes nothing:
```sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.10/scripts/install.sh | sh -s -- --dry-run
```
The same command without `--dry-run`,
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh`, installs the
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.10/scripts/install.sh | sh`, installs the
latest published `@openrig/cli` with `npm install -g`,
runs the Node.js and SQLite check, then `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
Codex and may install a missing one, as described below. A failed step prints
@@ -816,19 +816,6 @@ option, the seat's agent spec for the target runtime, or the runtime's own
default), what is cleared, and the costs below. Nothing changes until you run it
without `--dry-run`.
Use `--runtime pi` to move a Claude Code or Codex seat to Pi; use `--runtime
claude-code` or `--runtime codex` to move back. Pi uses a fresh managed state
directory. Select a prepared Pi account with `--account-home /absolute/path`
(the directory must contain readable `auth.json`), or use the target's configured
login. A source account override or Claude token is cleared only at commit;
credentials are never converted between runtimes. Pi-to-Pi account changes keep
Pi settings, but cross-runtime switches resolve settings afresh for the target.
A configured `launch_wrapper` carries across runtime switches among Pi, Codex
and Claude Code. It must support each successor executable and preserve that
runtime's credential environment. A wrapped Pi seat cannot switch to Claude
Code; set `launch_wrapper: []` in the authored spec and re-apply it first.
The new runtime takes over before the old one stops. Its successor starts beside
the current occupant, reads a handover (the seat's recap, its open rows and the
predecessor's screen), and confirms it with `rig seat set-runtime <seat>
@@ -860,7 +847,7 @@ What it costs:
again afterwards to reach whoever holds the seat.
- **Leftover files after a failed switch.** The target runtime's prepared seat
storage and projections can remain.
- **Codex, Claude Code and Pi, single-pane seats.** Startup slash commands aren't
- **Codex and Claude Code, single-pane seats.** Startup slash commands aren't
run by a switch; the dry run lists them.
## Stop your teams
@@ -895,7 +882,6 @@ the lifecycle API without booting a new kernel, then stop the remaining rigs.
| --- | --- |
| Tool missing or login fails | Use the specific setup/auth hint; recheck that executable in the launch shell. Do not send work to an unready seat. |
| "Startup attention" or "Startup details" names a prompt | Answer the login, trust or bypass prompt in that seat's terminal, then run the `rig seat continue <seat>` it shows. |
| A restored seat still counts as needing attention after its prompt was answered | Its restore outcome is still `attention_required`, and answering the prompt or running `rig seat continue` doesn't change it. Once the agent is up in its pane, run `rig seat clear-attention <seat>`; see [After a restored seat's prompt](agent-startup-guide.md#after-a-restored-seats-prompt). |
| "Readiness timeout after 30s — harness did not become interactive" | The harness took longer than the readiness window. Raise it for the next launch with `rig config set runtime.readiness_timeout_seconds <1-600>`, then relaunch that seat. |
| "Claude resume did not finish within Ns; Claude may still be starting, so the launch was kept" | A restored Claude seat was not interactive when the wait ended. Its pane and resume token are kept. Look at the pane; once Claude is up, run `rig seat clear-attention <seat>`. The wait is about `runtime.readiness_timeout_seconds` in total for each seat (default 30 s), counting the first 3.2 s, and the time adds up seat by seat in a restore. Raising the setting for slow fresh launches lengthens it. If `rig up` or `rig launch` times out at 120 s, that is not a rollback: check `rig ps` before running it again. |
| Daemon is healthy, kernel is still starting | Read `rig status` and `rig ps --nodes --rig kernel`; kernel readiness is separate. |
+1 -43
View File
@@ -119,7 +119,6 @@ and nothing else is offered:
| `queue`/`claim`, `queue`/`update` | `rig_queue_claim`, `rig_queue_update` |
| `team`/`list` | `rig_team` |
| `artifact`/`read` | `rig_artifact_read` |
| `question`/`read`, `question`/`answer` | `rig_question_list`, `rig_question_answer`, only when the grant names its person (below) |
- **One seat per connected assistant:** the service gives each connection its own seat and keeps it across that
connection's reconnects. The seat must be one of the rig's nodes; a node created without a launch is enough. The caller
@@ -129,10 +128,6 @@ and nothing else is offered:
- 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.
- **A first task without a seat address:** with `--task-destination <member@rig>` on the same `rig mcp serve`, a
`rig_queue_create` that leaves out `destination` goes to that receiver. The operator sets it; a caller can't. It must be
exactly one existing local node within that request's grant, or nothing is written. An explicit `destination` works as
before.
- **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.
@@ -144,45 +139,8 @@ and nothing else is offered:
- Reading or listing an item records nothing, and nothing wakes an assistant, so an item stays queued until its receiver
claims it.
### A person answering the team's questions through their assistant
A grant can name the person who owns the connection: `person.entity_id`, their gateway entity id on this instance, so their
address is `<entity_id>@external`. The service sends it only for a connection that person owns. With it and the `question`
pair, the person's assistant can read the team's questions waiting on them and submit their answers, without opening
Steering. The caller's seat is never taken for a person: without `person`, the `question` pair offers nothing, and an
unusable `person` withholds only these two tools while the grant's other tools keep working.
- **Register the person** on the instance, once:
`rig gateway human add <entity_id> --display-name "<name>" --delivery-class B --default`.
Without `--binding`, they're asked in the Feed and on Steering only. As the default person, today's asks addressed to no
registered person (such as `human@host`) are theirs too.
- **`rig_question_list`** shows what Steering shows that person as answerable: an ask addressed to them, or a row parked on
them. Each comes with its options when it has any, and the `revision` an answer names. A question that moved to someone
else between Steering's view and the read of its row is left out.
- **`rig_question_answer`** takes the question, the revision the person saw, and either the options they chose (one per
question) or exactly what they typed. It records through Steering's own write path (`answer` for an ask, `resolve` for a
park), as the person, so Steering shows the same answer and the asker's work continues once. The answer's audit names the
route it came by: the caller's seat, the grant's principal and the grant's id and version.
- **What it says back:** a success names what was submitted (`submitted`), never a fresh read of the question's answers.
- `answered`;
- `recorded`, when parts of the question are still unanswered;
- `already-recorded`, for an identical retry whose submission is already on the question, recognised by its operation id
(never by an equal answer). That includes a settled ask or a park that's back at work;
- `question_changed`, with the current question, when someone else changed it since the revision the person saw;
- a retry of a submission that stopped partway confirms the parts already recorded and stops there (`question_changed`,
with `recorded` and `notRecorded`). The rest goes in a new answer to the question as `rig_question_list` shows it now,
so a stale retry never overwrites an answer someone gave in the meantime, including one recorded with no action (a
Slack click);
- a refusal partway names which parts were recorded (`recorded`, `notRecorded`). A send that got no answer, or a server
error, leaves that part `indeterminate`: send the same call again to confirm what was recorded;
- `not_waiting_on_person`, when it was answered or closed, or isn't theirs;
- `person_not_registered`, when the grant's person isn't registered on this instance. While no one is registered, Steering
shows every request, so nothing is answered for anyone.
- **What it never does:** answer another person's question, reach a team-internal row, or record a decision of its own. A
delegated decision (`rig_queue_decide`) isn't offered to a grant-service caller.
## Limits today
- **Webhook triggers only:** OpenRig rings a webhook. Email and other triggers aren't supported yet. A member without a trigger
isn't rung, and its person is told instead.
- **It can't act as a person:** it works rows addressed to its seat, a person's request to it included. It can't settle an ask addressed to a person, or hand work to one, except through a grant service's grant that names its person, which records that person's own answers (above).
- **It can't act as a person:** it works rows addressed to its seat, a person's request to it included. It can't settle an ask addressed to a person, or hand work to one.
-4
View File
@@ -7,10 +7,6 @@ without daemon-internal knowledge.
This subtree is part of the wider [OpenRig Instance Layout](instance-layout.md).
The instance initializer delegates these exact workspace bytes to this owner.
To inspect or read an exact reconstruction under a selected work home, use
[`rig context reconstruction`](reconstructions.md). It shares catalog and work-home
resolution with `context work-install` and performs no installation or projection.
## Default Shape
`rig config init-workspace` creates the default workspace at
+1 -1
View File
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
```
For a new install, use the latest release's tag, for example `v0.7.5`. If OpenRig is already installed, use the tag
For a new install, use the latest release's tag, for example `v0.7.10`. If OpenRig is already installed, use the tag
that matches `rig --version`, or upgrade first. If the command fails with `npx: command not found`, Node.js isn't
installed or isn't on your PATH yet (see [What you need](#what-you-need)). Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what
you want built.
-90
View File
@@ -1,90 +0,0 @@
# Read a reconstruction of selected work
A reconstruction records an author's working understanding, its evidence routes,
constraints and gaps. `rig context reconstruction` reads an exact immutable
`openrig.reconstruction/v0` revision locally. It does not start a daemon, fetch
remote context, write records or transfer the author's authority.
Inspect the purpose, depth, author-declared coverage, observation dates, complete
source manifest and approximate size before reading a view:
```bash
rig context reconstruction inspect <full-sha256> --project example --mission task --slice 01-example
rig context reconstruction view <full-sha256> --project example --mission task --slice 01-example --item decision --json
```
Without `--store`, the store is `reconstructions/` under the selected project,
mission or slice home. Project selection follows `context work-install`: an
explicit catalog project, the only entry, the calling rig's association, the
deepest project containing `--cwd`, or the only unclaimed entry. Ambiguity is an
error. The project's configured `missions.root`, declared mission membership,
slice directory names and slice frontmatter IDs are honored. A workspace
without a catalog can use its own project home. No skill installation or context
projection happens during this lookup.
Use `--store /absolute/local/store` for an imported store without resolving a
project. Supplying work selectors as well still validates that work home.
Revisions are full lowercase SHA-256 IDs; there is no implicit latest revision.
## Faithful views and gaps
Repeat `--item`, `--category` or `--tag` to select their union. A filtered view
always retains authority, constraint and gap items, plus transitive item
dependencies. It names omitted item IDs and uncited evidence routes, and keeps
declared reconstruction coverage separate from actual view coverage. Inspect
always provides the complete manifest; a filtered view provides cited routes.
A full view also includes uncited registered routes.
Base records, deltas and pinned compositions retain inherited observation dates.
A revision's creation date does not refresh those observations. Composition
members do not imply an atomic capture. Authors are claimed attribution, and
coverage is authored description rather than proof of runtime state or acceptance.
Unavailable records, missing selections, dependencies or citations make the
result `complete: false` and set exit status 1. Available composition members
remain visible with the missing member's exact store and revision. Unknown
schemas, invalid content hashes and unsupported numeric representations are
reported explicitly. The initial reader supports safe integer JSON numbers;
floating-point/exponent and unsafe integer representations are rejected rather
than assigned a different ID. Unicode keys follow v0's codepoint sorting.
## Relocate a store without changing its records
Parent and member pointers include absolute source-store paths in the hash.
Do not edit those paths in immutable records. Copy their closure into local
custody, then supply a separate JSON mapping:
```json
[
{
"sourceStore": "/original/work/reconstructions",
"localStore": "/local/import/reconstructions",
"sourceHost": "original-host"
}
]
```
```bash
rig context reconstruction inspect <full-sha256> --store /local/import/reconstructions --store-map /local/map.json
```
The mapping is read-time location information, not an identity registry or a
remote fetch permission. It preserves source-host provenance. Since v0 pointers
do not contain a host discriminator, duplicate mappings for the same source
store are ambiguous and rejected. A store path that exists on this host can be
read directly; a path that has moved needs a mapping to its local location.
## Bounds and byte checks
Reads are limited to 8 MiB per regular revision file, 32 MiB per closure, 64
record references and 128 JSON nesting levels. Symlink revision files are not
followed. Output defaults to 256 KiB; `--max-bytes` accepts 1 KiB through 8 MiB.
Oversized output fails explicitly rather than silently truncating a purportedly
complete view. Size estimates use UTF-8 bytes divided by four, not a model tokenizer.
`--check-sources` hashes declared local regular files within 128 files and 64 MiB
total. It reports changed, unchanged, unavailable or unchecked bytes without
executing source commands, fetching opaque routes or updating observations.
Discovery declarations are shown but not scanned. Unchanged known files do not
establish semantic freshness or detect new queue decisions. There is no queue
discovery cursor, native writer or continuity-consumer replacement in this reader.
+6 -69
View File
@@ -2,7 +2,7 @@
Version: 0.2 (pod-aware)
Last validated against code: 2026-10-07, at the 0.6.6 cut `2620dea8` (whole document at `fcaf1f8e`; changes to its sources since then checked)
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/types.ts`, `packages/daemon/src/domain/launch-wrapper.ts`, `packages/daemon/src/domain/startup-validation.ts`, `packages/daemon/src/domain/permission-policy/policy-ref.ts`, `packages/daemon/src/domain/profile-resolver.ts`, `packages/daemon/src/domain/rigspec-preflight.ts`, `packages/daemon/src/adapters/kernel-authority.ts`, `packages/daemon/src/domain/native-permission-store.ts`, `packages/daemon/src/domain/codex-team-workspace.ts`
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/types.ts`, `packages/daemon/src/domain/startup-validation.ts`, `packages/daemon/src/domain/permission-policy/policy-ref.ts`, `packages/daemon/src/domain/profile-resolver.ts`, `packages/daemon/src/domain/rigspec-preflight.ts`, `packages/daemon/src/adapters/kernel-authority.ts`, `packages/daemon/src/domain/native-permission-store.ts`, `packages/daemon/src/domain/codex-team-workspace.ts`
This is the canonical reference for the pod-aware RigSpec YAML format. Every field, validation rule, and default documented here was traced from the actual parser and validator code, not from prior documentation.
@@ -404,22 +404,6 @@ ask rules; help can prompt in that fallback. A helper that disappears or times o
after launch cannot supply a decision, leaving Claude's remaining native rules
in effect. The hook is a convenience policy, not a containment boundary.
Claude sessions launched with a named `team` or `kernel` hook policy read the
installed built-in lists on each Bash hook call. Replacing those files at the
same path changes the hook's lists on its next call; native inline allowances
remain as launched. Sessions launched with an encoded policy keep their launch
lists. A session retaining a versioned runtime path continues to use the files
at that path, so installing another version elsewhere does not change its hook.
This does not reload all permission settings in a running session.
An in-place rollback to a hook from before named policies has a different effect:
that helper cannot read `team` or `kernel` and returns no hook decision. Lifecycle
asks can then disappear under the native `Bash(rig:*)` allowance. Relaunch affected
Claude sessions before relying on those asks after such a rollback. Native
personal, project and managed restrictions are not removed. This applies when
the rollback replaces the hook at the path a running named-policy session uses;
it does not describe every rollback or sessions using a retained versioned path.
Codex keeps `workspace-write` with its existing approval policy and receives the
configured OpenRig workspace root plus its pod's shared state directory as
additional writable directories. The team workspace preparer creates missing
@@ -564,7 +548,7 @@ or local settings. Managed settings aren't inspected. `rig export` writes the se
with an account home or an API-key selection (select those again after import), so a later switch shows there, and `rig provider switch --stored-login` removes it. Only
`claude-code` members and legacy flat nodes accept it, and there's no rig-level default.
**Switching to an isolated account home.** A running Codex, Claude Code or Pi seat can change its account home
**Switching to an isolated account home.** A running Codex or Claude Code seat can change its native account home
while staying in its rig:
```sh
@@ -576,24 +560,7 @@ rig seat set-runtime dev-impl@my-rig --runtime claude-code \
The directory must already exist and contain the runtime's login or API settings. You choose the account and budget;
OpenRig does not choose or create credentials. `--account-home` sets `CODEX_HOME` for Codex or `CLAUDE_CONFIG_DIR` for
Claude Code before the successor starts. For Pi, the account directory must contain a readable `auth.json`; OpenRig links
that file into a separate managed successor directory. The existing Pi process keeps its credentials and runner state
until the successor confirms. Pi account changes keep the current model and effort unless explicitly overridden.
```sh
rig provider switch --seat pi-worker@my-rig --to-account work \
--account-home /home/me/accounts/pi-work --reason "Use the work account" --dry-run
# Run again without --dry-run to stage the successor and request its confirmation.
rig provider switch --seat pi-worker@my-rig --clear-account-home --reason "Return to configured login"
```
When switching into Pi with `--clear-account-home`, OpenRig restores the seat's original Pi login only if no effective member or rig Pi account home is configured. A member's `PI_CODING_AGENT_DIR` shadows the rig's value; if the member value is relative, it is ignored and the rig home does not take effect.
Pi account changes work with or without `launch_wrapper`. The wrapper receives the successor's managed Pi directory;
it must preserve the selected credentials if you want OpenRig's account choice to apply.
Use `rig seat set-runtime <seat> --runtime <runtime> --reason "Change provider"` with `pi`, `codex` or `claude-code` for cross-runtime switches.
Those resolve the target's settings afresh rather than keeping the source model or effort;
see [runtime switching](getting-started.md#move-a-seat-to-the-other-runtime).
Claude Code before the successor starts.
This switch starts a fresh conversation with a handover, even when the runtime stays the same. The existing
subscription-login switch preserves the conversation. Once the successor confirms, OpenRig commits its home, runtime,
@@ -783,7 +750,6 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
| `effort` | string | no | — | Reasoning effort override, trimmed. Claude gets `--effort`, Codex `-c model_reasoning_effort=…`, Pi `--thinking` (see "Pi" below). A non-string value is ignored with an advisory. |
| `advisor` | string | no | — | Claude-only Advisor model for this seat. Overrides the AgentSpec profile/default; blank or non-string values are ignored with an advisory. |
| `claude_mcp` | `none` | no | normal loading | Claude members only. Disable MCP server loading for this agent on every launch and resume. Omit the field to keep Claude's normal configuration; see [Claude MCP loading](#claude-mcp-loading-runtime-claude-code). |
| `launch_wrapper` | string[] | no | — | Local built-in `pi`, `codex` and `claude-code` members only. Literal argument prefix applied to the native executable and its arguments on fresh launch, relaunch, rebuild, resume, fork, restore and handover; `[]` explicitly clears a stored wrapper when re-applying an authored spec, while omitting the key leaves an existing seat's stored choice unchanged. See [Launch wrapper](#launch-wrapper-runtime-pi-and-codex). |
| `permission_policy` | string | no | — | Member-level permission policy; overrides the rig's. Not valid on terminal members. |
| `env` | map | no | — | Launch environment for this seat, laid over the rig's `env`. Same rules as the rig-level field. Valid on terminal members too. See "Launch environment" below. |
| `agent_file` | string | no | — | Claude members only. A Claude agent definition (a `.md` file with `name` in its frontmatter) for this seat. It's copied into the seat's own storage and launched as `--agent openrig:<name>`. A path beside the spec: no `..`, not absolute. |
@@ -921,42 +887,14 @@ This requires a Claude Code version that supports `--advisor`. OpenRig never wri
choose different Advisors. When Advisor is unset, no flag is emitted and Claude's
own defaults/settings apply. Other runtimes ignore this field.
<a id="launch-wrapper-runtime-pi-and-codex"></a>
### Launch wrapper (`runtime: pi`, `codex` and `claude-code`)
Set `launch_wrapper` on a local built-in `pi`, `codex` or `claude-code` member to prefix its native executable and arguments with a literal argv vector:
```yaml
members:
- id: impl
agent_ref: local:agents/impl
profile: default
runtime: codex
cwd: .
launch_wrapper: ["launcher", "exec", "--profile", "seat-a", "--"]
```
- **Scope and literal argv:** Local built-in `pi`, `codex` and `claude-code` members, including legacy flat nodes, support `launch_wrapper`; other runtimes reject it, including `[]`. Each fork uses its target member's own selection. Arguments are literal strings without NUL, CR or LF; spaces, quotes, metacharacters and empty non-executable arguments are preserved.
- **Executable resolution:** `launch_wrapper[0]` must be an absolute path or bare command name. Preflight and launch resolve it using the daemon's launch `PATH` and working directory, before adding the seat's `rig` bin directory. Relative paths containing `/`, aliases and functions are unsupported for the wrapper itself.
- **Native child and `exec` contract:** The wrapper prefixes the resolved Codex entry, prefixes the resolved Pi child executable inside the host runner, or prefixes the real Claude executable and native arguments. Managed Claude applies it after rebuilding the environment; pinned continuations retain their executable. The wrapper must `exec` its child in place, preserving working directory, environment, signals, exit status and TTY stdio (Claude/Codex) or RPC pipes (Pi). Supervising wrappers are unsupported.
- **Classic Claude lookup:** Wrapped classic launch supports bash, zsh, sh and dash and requires `command -v claude` to resolve to an executable file; aliases and functions refuse with `launch_wrapper unavailable; no agent started.` To keep custom flags or environment, put them in a `#!` script named `claude` on `PATH`; the wrapper runs that script ([#1477](https://github.com/mvschwarz/openrig/issues/1477)).
- **Fail-closed (never unwraps):** The selected wrapper applies across fresh launch, relaunch, rebuild, resume, fork, restore, handover and supported runtime switches. Invalid stored metadata, non-POSIX panes, unavailable wrapper support, missing session identity or an unusable wrapper refuses launch. Failures after dispatch remain visible in the pane or Pi runner's markers; OpenRig never runs unwrapped, including Pi's long-command (`launch_path_too_long`) host-runner retry.
- **Persistence and clear:** On new seats, absent or `launch_wrapper: []` stores no wrapper. Applying an authored spec to an existing seat retains the stored selection when absent, clears it with `launch_wrapper: []`, and replaces it with a non-empty array. Recreation/import uses the incoming spec; older snapshots preserve the current selection. First-start retry refuses wrapper changes. Runtime switches carry it to supported successors; validation occurs at execution. There is no live edit verb: change the spec and use the supported create/recreate/apply flow. Export and node inventory include the configured value.
- **Boundary:** An argv prefix is not proof of sandbox isolation, tooling availability, memory wiring or governance enforcement. Verify the wrapper and runtime independently.
### Pi (`runtime: pi`)
`runtime: pi` launches the Pi coding agent (`pi --mode rpc`) through OpenRig's RPC runner.
- **State:** Each seat uses `$OPENRIG_HOME/state/pi/<session>/agent` as its Pi agent directory, with `sessions/` beside it, instead of your default `~/.pi/agent`. The runner sets `PI_CODING_AGENT_DIR` to that directory. `<session>` is the seat's session name, unchanged; for a pod member it is `{podId}-{memberId}@{rigName}`. `$OPENRIG_HOME` defaults to `~/.openrig`. OpenRig creates these directories at launch and does not copy anything from `~/.pi/agent`. A confirmed account switch moves the seat into an isolated `state/pi/account-switches/<generation>/<session>/` directory; subsequent launches and resumes use that committed directory.
- **State:** Each seat uses `$OPENRIG_HOME/state/pi/<session>/agent` as its Pi agent directory, with `sessions/` beside it, instead of your default `~/.pi/agent`. The runner sets `PI_CODING_AGENT_DIR` to that directory. `<session>` is the seat's session name, unchanged; for a pod member it is `{podId}-{memberId}@{rigName}`. `$OPENRIG_HOME` defaults to `~/.openrig`. OpenRig creates these directories at launch and does not copy anything from `~/.pi/agent`.
- **Thinking level:** A Pi seat's effort (the choice saved with `rig seat set-effort`, else the spec's `effort`) is sent to Pi as `--thinking <level>` on every managed launch and resume: fresh launch, successor and restore. Pi accepts `off`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`; OpenRig passes the value as written and does not translate it, so Codex's `none` is not mapped to `off`. Only a value of the shape `set-effort` accepts (a letter, then up to 31 letters, digits, `-` or `_`) is sent; any other spec value, such as one with a space or one that starts with `--`, is left out, and `rig seat effort` says so. An unfamiliar level gets an advisory (a warning from `set-effort`), never a refusal, and Pi itself ignores an unknown level with a warning and keeps the level it would use without the flag: a model suffix, the resumed session's level, or its default. With no effort resolved, no flag is sent. The effort wins over a model suffix, and the model string is never changed. `rig seat effort` reports the level sent at launch and, on Pi 0.99.0 or later, the `thinkingLevel` Pi records on this launch's latest successful assistant turn (error and aborted turns are skipped); that is the session's level after Pi's own cap, not proof the provider applied it, and it is shown apart from the level sent when they differ. On older Pi it is NOT VERIFIED. Without an effort, you can still set the level as a model suffix, for example `model: openrouter/x-ai/grok-4:high`. Pi first looks for a model whose id is the whole pin, and uses it when there is one, suffix included. Only when there is none does Pi read the suffix as the level and record the model without it. Any other suffix, such as OpenRouter's `:free`, stays part of the model id. The model-divergence check reads the pin literally when the seat's `models.json` defines a model with that exact id; otherwise it accepts the model with or without the suffix. If the seat's `models.json` can't be read, a pin with a suffix stays unchecked, with the usual warning, when the recorded model matches the whole pin or the pin without its suffix. A record that matches neither is reported.
- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi/<session>/agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file (or the corresponding committed account-switch directory after a switch). A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`.
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `NODE_OPTIONS` for the selected-account Node preload, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
- **Account homes:** Select an account with `rig provider switch --account-home`, or declare an absolute `PI_CODING_AGENT_DIR` in the seat’s `env:` as its configured account home; the persisted selection takes precedence. `~` is not expanded. A relative value is ignored for managed account selection (and reported by status), leaving the managed seat login in use when no persisted account is selected. The chosen directory must already contain a readable `auth.json`; env-only credentials and wrappers that create credentials later are outside this prepared-account path. Launches with neither a persisted selection nor a declared absolute account home retain their existing credential behaviour. The runner links that file into its managed agent directory before starting Pi, including launches through `launch_wrapper`; settings, models, extensions and sessions remain seat-local. Selected accounts use a Node preload shim that redirects only their auth-lock filesystem operations to the chosen account’s `auth.json.lock` pathname, matching direct Pi use even when the source `auth.json` is a symlink. The preload removes its own `NODE_OPTIONS` entry before Pi starts tools, preserving other Node options; descendants and unrelated agent directories keep their normal filesystem behaviour. This requires a Node-hosted Pi executable and a wrapper that preserves `NODE_OPTIONS`; compiled executables and wrappers that discard the preload are not covered. Removing the selection removes only the managed auth link, not the source account file. A missing account file stops the launch. Direct launch refuses to replace an existing seat-local login or an unmanaged auth link; use a confirmed account switch to preserve that login. An ambient pane-shell `PI_CODING_AGENT_DIR` does not select an account; only the resolved seat configuration or persisted account selection does. Persisted account selections survive resume and cannot be bypassed by the long-command fallback; explicit selections clear inherited provider-key overrides. Use `rig provider switch --account-home` or `--clear-account-home` to change an existing Pi seat through a confirmed successor with a fresh conversation and handover. The account reference and committed state directory survive daemon restart and resume; unconfirmed successors leave the old account and state intact. Clearing returns to the configured home, or the original managed seat login if no home was configured. Status reports a configured account home when present; without one, its home is null because Pi uses the managed seat directory rather than the operator's default login.
**Upgrade note for prepared accounts:** An existing built-in Pi seat that declares an absolute `PI_CODING_AGENT_DIR` now selects that home on fresh launch, restart, resume and restore, even without a stored account override. Before upgrading, either prepare a readable `auth.json` there or remove that declaration to keep the managed seat login. Env-only accounts and wrappers that create the file after Pi starts are not supported by this path. Migration 151 preserves switch history; it does not create or copy credentials, or rewrite existing seat declarations. Relative values remain available to community harnesses and are not globally removed.
- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi/<session>/agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file. A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`.
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
- **Extension dialogs:** The RPC runner shows extension `select`, `confirm`, `input` and `editor` requests in the pane and cancels them because the managed pane cannot answer them. The seat needs attention through the end of the turn, until a new agent run starts. Review the cancelled request and send an instruction the extension can complete without a dialog. Extension `notify` text is shown without raising attention or sending a response.
@@ -1364,7 +1302,6 @@ spec can pass `rig spec validate` and then fail at preflight or import. Run
24. Startup action `idempotent` is a required boolean.
25. Non-idempotent actions must not include `restore` in `applies_on`.
26. `applies_on` values must be from: `fresh_start`, `restore`.
27. `launch_wrapper` is valid only on `pi`, `codex` and `claude-code` members (`[]` included); it must be an array of strings without NUL/CR/LF, and `launch_wrapper[0]` (when non-empty) must be an absolute path or a bare command name on the daemon `PATH`.
---
+1 -24
View File
@@ -236,26 +236,6 @@ false` and the protection reason, rather than claiming durable custody.
Reading never sends. Retirement frees that record's active retention quota while
preserving evidence; it does not deliver a message or close queue work.
A seat holds one message per sender, so one sender can't fill its held quota and
drop everyone else's messages. A sender's newest held message retires its earlier
ones at that seat into itself (`retired_by: outbox@system`, reason `coalesced into
<id>`), in every mode, `draft-aware` included. The newest names them: `rig seat
held-messages` shows `coalesced: { count, outboxIds }` on it, and each coalesced
record stays readable with `--id`. Held messages are never replayed, so this changes
no delivery. A `draft-aware` attempt takes its sender's place only once it settles
as held; one that is then delivered leaves the held messages as they are. A queue
wake to a `draft-aware` seat counts its sender's held message there as room, by the
same rule. At a full quota, a sender with nothing held there is still refused, and
nothing from another sender is touched. A committed wake is never refused, and the
sender's next held message coalesces it like any other. A message that settles as
held late, behind a newer held send, folds into that send's newest part and leaves
its other parts held. An urgent message coalesced under a routine one passes its
marking to the newest. Never coalesced: the other parts of the same send, a
watchdog's reminders (one per job), and an unattributed sender.
A backlog held before this change clears at that sender's next held message to the
seat, or at once with `rig seat retire-held-message`.
## Input evidence and outcomes
The cursor-aware reader lives in `session-transport.ts` and shares the composer
@@ -356,10 +336,7 @@ accounting. Historical outbox receipts do not suppress a lone reminder or preven
message tells its recipient that delivery may repeat after an unconfirmed attempt. Its job history records the
current transport result; an unknown write is not proof that nothing arrived.
The transport still protects approval/question prompts and recipient identity. Under the default
`transport.draft_delivery` (`deliver`), an ordinary send or work notice that meets a person's draft at a Claude Code or
Codex prompt is pasted into it and the combined input is submitted, unless the seat's typing guard holds input
(`draft-aware`, `openrig-urgent`, `urgent-only` or `hold`). With `refuse`, a draft refuses the send. Scheduler timing,
The transport still protects approval/question prompts, human drafts and recipient identity. Scheduler timing,
current parked-row eligibility and stop checks still apply. This changes message retries, not the idempotence of
structured continuity actions such as creating a cutover baton.
+8 -19
View File
@@ -405,11 +405,9 @@ budget window when exhausted. Slack's per-method `Retry-After` is retained acros
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. A deleted original is recorded
on the row and never asked about again; a new post of the same ask is tried afresh. The others,
which a scope, membership, a different bot token or an upgrade can fix, are retried on the next
connector start. A message is uneditable when the configured token didn't post it. They do not keep consuming edit capacity. These edit calls have their own budget,
separate from missed-message scanning below.
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
@@ -456,18 +454,9 @@ not covered by that margin. A corrupt or unreadable checkpoint leaves recovery u
and preserves the existing file; live inbound continues.
Each pass admits at most four history calls, 100 entries and 15 seconds of work, with a
five-second request ceiling. Channels and threads take turns within that one budget. Each tracked
thread is scanned once after each start and after each Socket Mode reconnect that followed a gap
(not a refresh Slack overlaps with the old connection), which catches replies sent while no
connection was up; replies while connected arrive live. A thread the pass at the connect itself
reaches is scanned once more, since that pass stops five seconds short of the connect. Those scans go in need order: a thread
leaving the seven-day window within six hours first, since once it leaves a missed reply is never
recovered; then threads whose ask still waits on the person (its queue row is pending, in progress
or blocked) before the rest, each newest first. The order comes from the saved checkpoints, so a
restart starts with the newest open ask, not the oldest thread. After that a thread is scanned again
only as a backstop, hourly while its ask waits on the person and daily otherwise. A machine the hub
relays to can't see a gap in relayed events, so there a settled thread's backstop is three hours. `rig slack status` shows how far behind the oldest waiting thread is and how
many threads are settled. Work already started on a message, such as
five-second request ceiling. Channels and threads take turns within that one budget, so with many
tracked threads each thread is scanned every few passes; `rig slack status` shows how far behind
the oldest waiting thread is. Work already started on a message, such as
an attachment transfer, is not cancelled by these limits or by a stop, and a new
pass waits for it to finish. Rate limits retain Slack's Retry-After across restart, and hold back
only the method Slack limited (channel history or thread replies).
@@ -476,8 +465,8 @@ When Slack reports a plan history limit, recovery still lands the available page
and advances normally, retaining an older-history limitation in status across restarts.
Missing bot credentials, history scope, membership, retention limits and API
failures appear as recovery limitations. A tracked thread Slack can't find stays incomplete in
status and is asked again at most once a day, across restarts; that isn't proof the thread was
deleted. No additional scope is required to keep
status, with no further call, until its thread mapping changes or the daemon restarts or rewires
the connector; that isn't proof the thread was deleted. No additional scope is required to keep
live delivery working. Threads nobody tracks, older open threads, threads closed more than seven
days ago and other channels remain outside this recovery scope; global chronological order is
not promised. This change does not replay intervals already checkpointed before broadcast-reply
+24 -50
View File
@@ -72,11 +72,11 @@ verified viewer mapping.
New tiles keep their place when a seat's tmux session is replaced. They wait up
to 30 seconds for the same session name and managed seat identity to return,
then show the new session with the original read-only setting. This uses the
shared tile command for Herdr, cmux, Rex and plain tmux, including SSH tiles.
In-place handover and restart keep showing the same session.
then reattach with the original read-only setting. This uses the shared tile
command for Herdr, cmux, Rex and plain tmux, including SSH tiles. In-place
handover and restart keep the existing attachment.
Detaching a tile you typed in returns it to showing the seat. A different seat
A deliberate detach closes the tile after about two seconds. A different seat
under the old name is never followed. If no replacement returns in time, or a
session has no managed identity, reopen the rig view with
`rig terminal open rig:<name> --window` (or your explicit provider). Views
@@ -222,26 +222,11 @@ after a grace period; the agent and its conversation keep running. The
explanation strip names joins, departures, unavailable terminals and limits.
The first workspace on an empty provider may become focused.
Live following supports **Linux and macOS with Herdr protocol 22**. Admission
checks the live protocol and selected socket, then confirms each pane's IDs,
focus state and foreground attachment. A shell startup `cd` does not change
pane identity; filesystem aliases of the same existing path are accepted.
If a creation response is lost and shell startup changes the directory, Core
may be unable to attribute that pane. It reports uncertainty and does not
repeat the creation.
The detected Herdr version remains visible in provider status. The native
qualification version is 0.9.5; a matching release label alone is not proof of
compatibility, and future protocol numbers are not assumed compatible.
If a new view's platform or protocol cannot support following, Core opens a
labelled **snapshot** instead and disables automatic following. It will not
track later owner changes. Incomplete membership prevents that snapshot from
opening; the result explains why. A failed endpoint/identity read or an existing
canvas stays unconfirmed, preserving it instead of creating a fallback view.
Other providers and selector kinds retain ordinary one-shot open. Use
`--provider herdr --json` to operate without opening a desktop window, and
`--session <name>` to select that endpoint. Typing in a tile can change the
agent's terminal size while you type.
This increment supports the qualified **Linux Herdr 0.9.5/protocol 22**
endpoint. Other platforms/providers and other selector kinds retain ordinary
one-shot open. Use `--provider herdr --json` to operate without opening a
desktop window, and `--session <name>` to select that endpoint. Opening a tile
can change the agent's terminal size; `ignore-size` does not guarantee isolation.
```bash
rig terminal open qitem:example --follow --provider herdr --json
@@ -299,17 +284,11 @@ operation uses the daemon's existing terminal bearer and browser boundary;
a bridge must retain its signed-in owner, Origin and expiry controls. It does
not grant authority based on task membership or accept a caller terminal ID.
On a supported Linux or macOS Herdr protocol-22 endpoint, it creates one viewer
On the qualified Linux Herdr 0.9.5/protocol 22 endpoint, it creates one viewer
or reuses an owned, verified `{workspaceId,paneId,terminalId,readOnly}` mapping.
A repeated/concurrent request does not create another viewer. Each viewer's
private `attach.sh` is kept for provider restarts. `--no-follow` preserves the
workspace and its scripts; stopping Herdr is not proof that its panes are gone.
Core removes only an exact, unedited owned script after confirming its viewer
is absent. Old views that are never reconciled again can therefore accumulate
scripts; there is no cleanup based only on an unreachable provider.
A lost create reply is reconciled before starting anything else; a lost run
reply is checked against the actual foreground process, never blindly repeated. Unknown or
A repeated/concurrent request does not create another viewer. A lost create
reply is reconciled before starting anything else; a lost run reply is checked
against the actual foreground process, never blindly repeated. Unknown or
changed terminal identity remains unconfirmed. The operation does not relayout,
close departed viewers, take over control, focus existing panes or launch/stop
agents. A first workspace on an otherwise empty Herdr server may become focused.
@@ -321,22 +300,17 @@ 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 tmux window has one size, and a tmux client narrower than it sees only the
left part of each line. So a tile shows its seat without attaching to it: about
once a second it reads the seat's screen with `tmux capture-pane`, redraws only
after tmux records new activity, and lets the tile's own terminal wrap each
line. A tile narrower than the agent's window still shows whole lines, and
showing a seat never changes its size. A rule drawn across the window is cut to
the tile's width. A read-only tile only shows the seat. In a writable tile, the
first thing you type or paste attaches the tile and goes through tmux as it
would in any attached client: a paste arrives as one paste, and tmux's prefix
works (prefix then `[` to scroll back, for example). The tile sizes the window
while you use it, as any client you type in does; after 30 seconds without a
key it detaches and shows the seat again. A tile that's only showing has no
scrollback of its own. If the seat's tmux server restarts, a tile shows the
seat again only when the same managed seat returns. The printed manual attach commands
keep `-f ignore-size`. Observers of a Herdr terminal do not themselves resize
it; a controlling browser can. No cross-version resize guarantee is implied.
A writable tile attaches with `tmux attach -f ignore-size` when its session
already has a client that sizes it, so the tile doesn't fight that client over
the agent window's dimensions. When the session has no sizing client, as with a
freshly launched seat, the tile attaches without the flag and sizes the window
itself: tmux applies ignore-size across the whole server, so a session viewed
only by ignore-size clients would otherwise keep its 80x24 creation size while
any other session has a sizing client. Read-only clients keep `-r` (which also
implies ignore-size), and the printed manual attach commands keep
`-f ignore-size`. This is **not resize isolation**: opening a tile can change
the agent's terminal size. Observers of a Herdr terminal do not themselves resize it;
a controlling browser can. No cross-version resize guarantee is implied.
To type into a seat without changing its size, send the text through OpenRig
(`rig send`, or `POST /api/transport/send`): it is written to the pane with tmux
-48
View File
@@ -87,37 +87,6 @@ server, start the upgraded daemon from that original server to establish its
record, or stop those seats before changing servers. Earlier versions did not
persist enough information to find an arbitrary old socket from a different shell.
## Moving an existing team onto tmux control mode
`OPENRIG_TMUX_CONTROL=1` in the daemon's environment sends its routine tmux commands
through one long-lived control-mode client instead of a new tmux client each time,
which saves a process start per command on a busy host. It takes effect only on a
tmux server at 3.7c or later. The version of the server that is running decides,
not the tmux binary on `PATH`, and a server keeps the version it started with until
it exits. So installing a newer tmux changes nothing for a server that is already up.
On an older server the daemon uses a spawned client for every command. It says so
once in its log (`[tmux] OPENRIG_TMUX_CONTROL=1 is set, but this daemon's tmux server
runs tmux 3.4 …`), and `rig doctor` reports it as `tmux_control_mode`.
To move a team that runs on an older server:
1. Install tmux 3.7c or later where the daemon finds it first on its `PATH`. For a
daemon run as a service, that's the service's environment.
2. Choose the server the team will use. `isolated` gives this OpenRig home a server of
its own, started by the daemon with that tmux, and leaves your own tmux sessions
alone: `rig config set terminal.tmux_server isolated`. In `inherit` mode the team
shares your default server, which runs the new version only after every session
on it, yours included, has ended.
3. Stop each rig deliberately with `rig down <rig>`. Its seats' sessions end, and a
turn in progress stops. Queue rows, rigs and seats stay as they are.
4. Restart the daemon with `OPENRIG_TMUX_CONTROL=1` in its environment.
`rig terminal status --json` should show `migrationPending: false`. If it's still
`true`, `blockingSessions` names the sessions still on the old server.
5. Bring each rig back with `rig up <rig> --existing`. Each seat resumes its
conversation where its harness allows, under the restoration rules above.
6. Check: the daemon's log says `[tmux] Control mode is on`, and `rig doctor` passes
`tmux_control_mode`.
## Attachments, discovery and cleanup
`rig capture`, managed launch/stop/recovery, terminal previews and desktop viewers
@@ -195,20 +164,3 @@ whose version cannot be recognized as a fixed release, OpenRig uses spawned
clients instead: starting a control client while another session is created can
crash those servers (#2304). This avoids creating OpenRig's own control client;
it cannot protect the server from other tools' control clients.
On a large fleet, the daemon's two seat-activity pollers (the activity sampler and the
structural pane cache) are a large share of its tmux traffic: each runs once a second.
The setting `daemon.seat_poll_interval_ms` slows both down, from the 1000 ms default up to
2000 ms. Set it with `rig config set daemon.seat_poll_interval_ms 2000` (saved in
`config.json`; `rig config` shows it with its source) or with `OPENRIG_SEAT_POLL_INTERVAL_MS`,
which overrides the saved value. It is startup-only: the daemon reads it once when it starts,
so a change takes a daemon restart. `rig config set` refuses a value outside 1000..2000; an
environment value outside it is dropped with a warning and the daemon keeps the saved value or
the default.
A slower cadence makes activity and idle changes show up later, and a Codex permission prompt
can also show up as much as one interval later. Keep the default if a structural sweep of your
tmux server can take more than about 2 s (about 3 s at the default): past that, a sweep that
runs over skips the next tick, the Codex working-frame proof expires, and a working Codex seat
can briefly read idle. At 2000 ms the remaining 1 s margin is also what a lagging event loop
uses up. The two settings compose: `OPENRIG_TMUX_CONTROL=1` makes each tmux command cheaper,
and this one makes there be fewer of them.
+471 -196
View File
@@ -1,211 +1,486 @@
# What changed in OpenRig 0.7.1
# What changed in OpenRig 0.7.10
This note is for an agent on an install that was just upgraded to 0.7.1. It lists only what behaves differently from
0.7.0 and what to do about it. For everything OpenRig can do, read the capability map:
`rig context get onboarding-width/public-what-you-can-do.md`. Each release replaces this file, so
`rig context get reference/whats-new.md` describes the installed version once the upgraded daemon is running. Without
a running daemon, read `$OPENRIG_HOME/reference/whats-new.md`. The daemon refreshes that copy when it starts, so right
after an upgrade it can still hold the previous version's note.
This note is for an agent on an install that was just upgraded to 0.7.10. OpenRig
0.7.10 adds `rig exec` for finite runs on Claude Code or Codex (not yet run with
a real adapter), sends a held
post-compaction step after two idle polls that can't read the input box, leaves
every tmux mode before a delivery, and shows agent spec advisories at launch. It
fixes in the source the two problems that stopped the OpenShell worker in 0.7.9,
but that worker hasn't run end to end. If you upgraded from 0.7.8 or earlier,
also read "What changed in OpenRig 0.7.9" below, and from 0.7.7 or earlier,
"What changed in OpenRig 0.7.8" too. For the full capability map, read
`rig context get onboarding-width/public-what-you-can-do.md`. After the upgraded
daemon starts, `rig context get reference/whats-new.md` describes this version.
Without a running daemon, read the packaged `daemon/docs/reference/whats-new.md`;
`$OPENRIG_HOME/reference/whats-new.md` can still be the previous copy until
startup.
## After the upgrade
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Eight migrations run on the first start (124, 125,
127, 128, 129, 130, 131 and 135). Nine settings are added: `policies.claude_compaction.preparation_root` (unset),
`skills.router_line.enabled` (off), `decisions.enabled` (false), `decisions.consumers`, `decisions.template_dir`,
`retention.stream_days` (30), `retention.stream_max_items` (20000), `provider.selected` (empty) and
`daemon.db_conflict` (`warn`). None is removed.
- **Stream retention is on.** Stream items emitted after the upgrade are deleted after 30 days, or beyond the newest
20,000, unless a record names them (a classification, duplicate link, attempt or disposition). History from before the
upgrade is kept. Raise `retention.stream_days` or `retention.stream_max_items` to keep more.
- **The bundled openrig-core plugin goes from 0.1.17 to 0.1.18.** Startup replaces the installed plugin's changed files:
its Claude hook definitions, three hook scripts and the pre-compaction hook (so hooks start on a seat whose PATH has
no Node), and the `openrig-user`, `messaging-the-human` and `forming-an-openrig-mental-model` skills. As in
0.7.0, it first copies each file it replaces under `$OPENRIG_HOME/backups/managed-refresh/<id>/`, edited or not, and
logs `[plugin-vendor] preserved previous contents of <file> at <copy> before managed refresh`. A copy doesn't mean
anyone edited the file; if someone did, reapply the change from the copy.
- **Claude seats need Claude Code 2.1.280 or newer to see their team skills,** which now arrive as a per-seat plugin
through `CLAUDE_CODE_PLUGIN_DIRS` (below). OpenRig doesn't check the version. A running seat gets the new layout at its
next launch, restore or handover.
- **If a worktree's `.claude/settings.local.json` says `"defaultMode": "acceptEdits"` and the person didn't choose it,**
an earlier OpenRig wrote it when it added or freshly launched a managed Claude seat. 0.7.1 no longer writes it but
doesn't repair it; ask the person which mode they want and set it once.
- **Restart the daemon** to load the new code: `rig daemon stop`, then
`rig daemon start`. This release adds no migrations.
- **The bundled openrig-core plugin goes from 0.1.25 to 0.1.26.** Its Claude
hooks add a `PermissionRequest` hook that reports permission requests to seat
status, and its refocus hook and the refocusing skill's trace script changed.
Managed refresh preserves replaced files under
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you still
need from the preserved copy.
- **Check agent specs before relaunching seats:** run `rig agent validate <path>`
and read any `agent spec advisory` line. A profile's `uses.<category>` written
as a single value now selects that resource; if the resource pool doesn't hold
it, a member using that profile fails to resolve with "not found in resource
pool". Ask the person before you change their spec.
- **Codex hooks:** a Codex seat kept running across the upgrade can show "Hook
failed — plugin contents changed; hook approval required" until relaunched; in
0.7.9's upgrade test, one seat restarted with OpenRig's managed restart showed
no review afterwards.
- **OpenShell:** rebuild a guest image with this release's `guest.py` before
using it; the CA repair is in that file, which the image carries.
- **Codex tool commands use a non-login shell:** managed Codex seats now pass
Codex `-c allow_login_shell=false`, so plain `rig` works however OpenRig was
installed, with a Codex that supports `allow_login_shell` (checked in Codex's
source at 0.162.1 and 0.166); not yet run with a live Codex seat. Setup that lives only in login or rc files, and shell functions and
aliases, may be missing from tool commands, and a model request for
`login: true` gets Codex's refusal, "login shell is disabled by config". To
defer to Codex's own setting, run `rig config set launch.codex_login_shell
true`; a login file that sets `PATH` outright can then hide `rig`. It applies
at each seat's next launch, and a restart in place keeps the running choice.
([#2613](https://github.com/mvschwarz/openrig/pull/2613))
## New things you can do
## Finite runs: `rig exec`
- **Choose how much automatic input reaches a seat:** `rig seat set-typing-guard <seat> --mode <mode> --reason "…"`, where
`<mode>` adds `openrig-urgent` (OpenRig's own notices and anything urgent) and `urgent-only` between `off` and `hold`.
Mark a message urgent with `rig send --urgent`, or create the row at priority `urgent` or `critical`. Held input stays
readable (`rig seat held-messages`) and is never replayed.
- **Choose how far OpenRig and peers may act on a seat:** `rig seat set-terminal-policy <seat> --preset
no-peer-interventions|help-within-instructions|full-control --reason "…"`, with `--helper <addresses…>`,
`--rig-default` (seats added later), and exceptions such as `--peer-compaction deny` or `--openrig-model allow`.
Managed compaction and saved model or effort changes are checked; a denial is `terminal_policy_refused`. Other
interventions are guidance only.
- **Read a seat's planned permissions:** `rig seat status <seat>` shows what its next fresh managed launch will pass and
where each part comes from, and its terminal policy. `rig setup --router-line yes|no` decides whether OpenRig adds its
one line to shared guidance files.
- **Let a person choose whether every ask reaches Slack:** `rig gateway human set <id> slack-subscription
every-ask|urgent-only|unset`. Every ask is on the TUI's Feed either way. Under `urgent-only`, only an ask
created or handed off with `--alert` posts. An ask marked `--alert` stands out in Slack (an interrupt with a mention)
under either setting, so use it only for something only they can do, or an approval whose wait costs now.
- **Give one Claude member its own setup** in the rig spec: `agent_file`, `append_system_prompt_file`, `plugin_dirs` and
`advisor` (an Advisor model). They're copied into that seat's storage, never the project.
- **See a seat's own skills:** `rig skill loadout --seat <session|self>`.
- **Reach the team from an assistant without a shell:** the MCP server has 34 tools, including `rig_whoami`,
`rig_restore_status`, `rig_team`, `rig_telemetry_events`, following a handoff, reading a finished item's artifact and
deciding inside a person's decision room. `rig mcp serve --http` serves them over HTTP; `rig mcp token add <name>
--seat <session>` gives each caller its own seat.
- **Hand a row's task to an outside agent:** `rig acp run <row>` for an ACP agent (Hermes, OpenClaw).
`docs/reference/a2a-agents.md` covers A2A agents.
- **Run a community harness that names, reports or reopens its conversations:** preset fields `launch_result`, `resume`,
`names_conversation` and `startup_assisted`, with examples for Cursor, Hermes and OpenClaw under
`docs/reference/community-harness-examples/`.
- **Change one edge on a running rig:** `rig edge add <rig> <from> <to> --kind <kind>` and `rig edge remove <rig>
<edge-id>` (`--plan`, `--json`). A `delegates_to` change says when it moves who work routes to.
- **Run one spec as several rigs:** `rig up <spec> --name <rig>`.
- **Retune a watchdog in place:** `rig watchdog update <jobId> [--interval-seconds n] [--message text] [--spec path]`
keeps its id and attachments. The context watch takes `--threshold-tokens` or `--threshold-percent`.
- **Put restore maps where seats may write:** `rig config set policies.claude_compaction.preparation_root <abs path>`.
- **Pick a Herdr session:** `rig terminal open <view> --provider herdr --session <name>`.
- **Close a row with its artifact:** `rig queue update <id> --state done --evidence-ref <path>`.
- **File and read subscription move reports:** `rig provider moves report` and `rig provider moves`. `--host <id>` and
`--codex-home <dir>` on `rig auth status|list|switch` and `rig seat restart` act on another machine's Codex home.
- **Route stream observations with typed decisions (opt-in, off by default):** once `decisions.enabled` is true,
`decisions.consumers` includes `stream` and the intake seat has `OPENROUTER_API_KEY`, `rig project wake --decide`
gives each eligible stream observation one bounded typed decision during that seat's wake: create a row for a supplied
owner, join supplied work, or record a duplicate or no action. Anything uncertain, refused or failed goes to the
intake seat as its own disposition, and the model isn't asked again. `rig project decisions status` shows the state
without making a call. Without `--decide`, or with decisions off, nothing changes; turning decisions off no longer
strands an accepted create or join whose effect hadn't landed.
- **Read the stream as a subscriber:** `rig stream subscribe <seat>` (`--tags`, `--type`; re-subscribing keeps the
place), then `rig stream read` returns the next bounded page for the calling seat (at most 20 items and 64 KiB, with
`more`, `unread` and any `gap`). `rig stream subscriptions` lists readers; `rig stream unsubscribe <seat>` stops one.
`rig stream show <id>` says what became of an observation.
- **Keep the kernel to the harnesses you chose:** `rig setup --providers <list>` is now saved as `provider.selected`, and
the kernel starts seats only for those.
- **Stop a second daemon on a live database:** `rig config set daemon.db_conflict stop` refuses a start whose database a
matching live daemon already holds (the default `warn` only warns). `rig daemon start --allow-db-conflict` allows one
deliberate start.
`docs/reference/acp-agents.md` is the rule. Read it before handing a row to
Claude Code or Codex.
- **Install the adapters first:** `@agentclientprotocol/claude-agent-acp`
0.101.0 and `@agentclientprotocol/codex-acp` 2.5.0, as that page shows.
`rig exec harnesses` reports the adapter and native versions it finds.
- **Run:** `rig exec run <row> --harness claude` (or `codex`). `--plan` shows
what would run and starts nothing; `--wait` waits for the outcome. A connector
owns the run, so leaving `--wait` or closing the terminal doesn't stop it.
Running it again returns the row's run instead of sending the task twice;
`--again` starts another turn once that run has ended.
- **Read the outcome** with `rig exec status <runId|row>`. `interrupted` means
the connector died without recording an outcome, so the outcome is unknown. A
`completed` turn is the agent's report, not finished work: check the work
before you close the row. `--wait` exits 0 for `completed`, 2 for any other
outcome and 3 when you stop waiting.
- **Cancel** with `rig exec cancel <runId>`, not `rig acp cancel`. An
acknowledgement isn't a settled shutdown, and it says nothing about further
spend.
- **Usage and cost** the agent reported are on the row's outcome note, for
`rig acp run` too, each with its source; what it didn't report reads unknown.
## Behaviour that changed
## Compaction and delivery
- **Old history-only events can be pruned, if you choose to.** `retention.events_days` (default `0`, which keeps every
event) turns on a pass in the retention sweep (at daemon start and daily) that deletes `view.changed`,
`snapshot.created`, `watchdog.evaluation_fired` and the `queue.created`, `queue.updated`, `queue.claimed`,
`queue.unclaimed` and `queue.handed_off` events older than that many days; 14 is a reasonable value
(`rig config set retention.events_days 14`). Every other type is kept at any age, because restore, startup proof and
the seat readers fold from them, and the newest event is always kept. What you give up: pruned events no longer
replay from `/api/events` (an older cursor or a large `?replay`), no longer appear in `rig telemetry events` (which
reports the gap) or in older log and feed entries, and a later change of the setting can't bring them back. A new
value is read at the next sweep: the daily one, or the next daemon start. The first sweep after turning it on works
through the old history in short steps while the daemon keeps answering. `retention.enabled false` stops every
retention pass from the next daemon start.
- **The database file does not shrink by itself.** Pruned rows free pages that new events reuse, which slows the file's
growth; other tables still grow. To give the space back to the disk: find the file with `rig config get db.path`, stop the daemon
(`rig daemon stop`), copy the file aside, run `sqlite3 <that file> "VACUUM;"` (it needs free disk about the size of
the file and can take a few minutes), then `rig daemon start`. Never vacuum the file of a running daemon.
- **With pruning on, `rig telemetry events` reports the pruned ranges.** A page that spans pruned events inside the
retained window has a `retained_sequence_gap` for each missing seq range and `coverage.status` `partial` (it used to be
`retained_window`); events pruned from the start of the log show up as `before_retained_floor`.
- **A held post-compaction step goes in.** After two idle polls in a row that
can't read a seat's input box, the next poll sends the step (the turn
boundary, the restore request or the audit). `rig compact <seat> --state` and
`rig seat status` say it went in over a box OpenRig couldn't read; they never
call that box empty. A draft OpenRig can't recognise in that box may receive
the restore text.
- **Still held:** a box that reads as typed, with or without a suggestion; a
seat whose draft-aware delivery or typing guard holds OpenRig's input; and the
step after a `/compact` whose result was lost. To take over a held restore by
hand, run `rig compact <seat> --cancel`, then send the seat a restore request
naming its newest restore map, its restore marker and its packet. A step
already being pasted can still land.
- **tmux modes:** a send or wake now leaves every tmux mode in a seat's pane,
including a chooser, customize or clock mode a person has open. If a send is
still refused for a tmux mode, the refusal names why leaving failed.
- **A scrolled pane with a stale record:** leaving the mode now goes through the
seat's address, so a stale binding naming the same reused tmux pane, from
another rig for example, no longer makes the send fail with nothing sent.
- **Herdr views kept across an upgrade keep working,** on Linux Herdr 0.9.5 /
protocol 22. A viewer or follow view kept from 0.7.5 through 0.7.9 now counts
as the same attachment when only OpenRig's tile script changed, and moves to
the current command, so the viewer stays confirmed and the follow view goes on
adding and closing tiles. A kept viewer that's already running keeps its
earlier sizing for as long as it runs, its own re-attaches included; writable
viewers opened after the upgrade size their pane. A different session, tmux,
server or read-only setting still holds the view, as before. Checked against
the scripts those releases used, not yet with a live Herdr. It follows
@natefaerber's approach in
[#2467](https://github.com/mvschwarz/openrig/pull/2467). ([#2634](https://github.com/mvschwarz/openrig/pull/2634))
- **Team skills live in each seat's own storage** (`$OPENRIG_HOME/state/seats/<rig id>/<logical seat>/<runtime>/`), not
in the project's `.claude/skills` or `.agents/skills`. Claude seats get them as a generated plugin (`openrig:<id>`),
Codex seats as their own catalog. If a link sits in that path, the seat starts without them and the result names
what wasn't delivered.
- **Urgent wakes are labelled:** an urgent or critical row's wake starts with `Urgent (marked by …):` at every seat. A
seat on `hold` gets one held notice per wake instead of re-nudges, and lifecycle work is refused at the middle levels as
at `hold`.
- **A daemon restart picks up a managed compaction where it stopped.** An owed restore step resumes, a `/compact` that
may have been sent is never sent again, and `rig compact --state` shows anything stranded or owed.
- **A Claude seat whose startup turn is refused** (an expired login, say) holds the rest of its startup context as owed.
A startup input sent before the refusal is noticed can still be submitted (see Known issues in the
[0.7.1 release notes](https://github.com/mvschwarz/openrig/releases/tag/v0.7.1)).
- **`rig send` refuses with `target_needs_input`** when a Claude seat's hook has just reported it running and its screen
shows an MCP enablement menu, a question or a permission prompt.
- **`rig down` asks before stopping the caller's own rig.** Without a terminal it exits 1, stops nothing and prints the
`--yes` command.
- **`rig up --plan` doesn't start a stopped daemon:** it exits 1 with `daemon_not_running` and names `rig daemon start`
and `--no-kernel`.
- **`rig daemon start`** waits up to 20 seconds for a slow daemon, and starts with a warning when only a secondary
listener, such as Tailscale's, can't be verified.
- **A seat joined with `rig attach` as an outside app** (Claude Desktop) stays attached through `rig down` and restore
(`left_attached`). To replace it, `rig unclaim <name>`, then launch.
- **New seats get the daemon's `OPENRIG_DB` and `TMUX_TMPDIR`.**
- **A rebuilt Codex seat keeps its observed permissions,** and a read-only Codex profile launches without OpenRig's
writable roots.
- **A seat's own role** goes only to that seat, never into a shared `CLAUDE.md` or `AGENTS.md`, and `rig down` returns
guidance files byte for byte.
- **Workflow actions** (`project`, `route`, `resume`, `abort`) record the calling agent; a request with neither identity
nor actor gets the shared `actor_required` error.
- **`rig provider moves` is back** for subscription move reports. It switches nothing.
- **`--dangerously-interact` into a seat's input box stops first** when the box already holds typed or pasted text: it
shows that text (apart from suggestions) and types nothing. Clear the box and retry, or pass `--type-without-checking`,
which clears nothing. Keys for a recognized native menu are sent as before.
- **A terminal seat gets exactly the text sent to it,** without the From/To lines (the result says `envelopeOmitted`).
A queue wake to a terminal seat types nothing and fails `failed:terminal-seat`; the row stays queued.
- **A daemon restart that can't see the seats' tmux server** no longer detaches seats whose recorded process still
runs, so it doesn't relaunch them beside themselves.
- **Provider credentials and the activity-hook token stay off tmux, shell and `env` command lines** at launch and
handover. A launch whose staging fails stops instead of falling back.
- **An answer retried from Slack or Steering** after an interrupted close reuses the first reply and finishes the
request once.
- **One bad row no longer stops the stuck sweep** from flagging the others; **a malformed unrelated diagnosis row** no
longer stops health diagnoses from listing.
## OpenShell workers
## Output that changed shape
0.7.10 fixes, in the source, the two problems that stopped the OpenShell worker
in 0.7.9, but the worker hasn't run end to end. Don't rely on this path yet. The
guest launcher now passes the CA settings the OpenShell exec supplies
(`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE` and
`CURL_CA_BUNDLE`) to its Claude, and deleting an owned environment reads
Podman's inventory through routes Podman serves, stopping as
`environment_cleanup_unconfirmed` on any other answer. A verified export, a
finished task and an exact restore haven't been shown. If you set one up anyway,
follow `daemon/assets/openshell/README.md` in the installed package, rebuild the
guest image first, and name the costs to the person ("Provider registration and
image construction can incur costs"). "Never grant the guest access to the
daemon's main API port (normally 7433)."
- **`rig ps --nodes`** separates every cell with one space (the default row is 5 columns wider), adds an `ATTN` column
(`yes`, `no` or `?`) before `REASON`, and marks a `running` seat whose tmux couldn't be reached as unconfirmed.
- **`rig queue show --json`** no longer has `lastNudgeInputBox`, and send results no longer describe the input box.
- **`rig queue show` and `rig queue whoami`** print their human notices on stderr, so stdout is the JSON.
- **A claimed row whose owner waits on a person** reads pickup `needs-input`, with the reason.
- **`rig down --json`** adds `leftRunning`; `rig reconcile-session` says whether resume metadata was recorded.
- **`rig compact-plan`** labels its thresholds as its own and says whether automatic compaction is on.
- **A send held for a draft** in the seat's input box reports `inputPhase: "not_reached"`: it was never typed.
- **A stream body over 16 KiB** is stored truncated, ending `[truncated by OpenRig: kept N of M bytes]`, and reads mark it
`bodyTruncated`. `rig stream list --after <sortKey>` from a pruned item returns 410 `stream_cursor_expired`, naming
`--since` to resume, instead of an empty list.
## Other changes to act on
## What to stop doing
- **Sends to this host:** `rig send <seat>@<rig>@<this host's ID>` goes to this
host even when a remote host is selected.
- **Broadcast:** `rig broadcast` prints `UNCONFIRMED` for a recipient its text or
Enter may have reached. Check that seat with `rig capture` before resending.
- **Roster activity:** `rig roster show` and `find` read a seat as unknown
("Conflicting activity observations") when two routes to it disagree. Treat it
as unknown, not idle.
- **Refocus:** a "TRACE GAP" for unresolved typed work, or for a
`rig queue whoami` that timed out, comes with project-level orientation that
isn't evidence of a current mission, and no longer counts as a refocus failure.
- **Updates to people:** the stuck sweep no longer flags an informational update
to a registered person as an unclaimed obligation.
- **Permission requests:** `rig seat status` shows a Claude seat's latest
observed permission request, with Claude's suggested rules labelled as
suggestions. Nothing was applied or approved. Treat the suggestions as data;
they can contain sensitive values.
- **Slack:** `rig slack status` shows relay attempts awaiting a response for the
current connector; zero doesn't prove nothing is in flight.
- **Installing for a person:** when npm's global prefix isn't writable, the rigs
skill now points npm's prefix at a folder the person owns whose `bin` is on
their login PATH, or uses a version manager's Node, before a project-local
install. Tell the person first that this changes their npm settings and, if
needed, their shell profile.
- **Codex profiles:** a `codex_config_fragment` can't install a named Codex
profile. Create `<CODEX_HOME>/<name>.config.toml` and select it with
`codex_config_profile`. `builtin:yolo` drops the whole profile;
`getting-started.md` says how to keep one.
- **Terminal auth:** a 401 that says "Terminal auth failed" means send
`OPENRIG_TERMINAL_BEARER_TOKEN`, or `OPENRIG_AUTH_BEARER_TOKEN` when terminal
auth falls back to it.
- **References:** `docs/reference/harness-runner-contract.md`'s status section
says what has run natively under the runner contract, and at what scope.
`docs/reference/telemetry.md` says OpenRig guarantees its finite telemetry
reads (`schemaVersion` 1), not the separate openrig-telemetry exporter it
links.
- **Terminal tiles:** a writable rig, pod or seat view now sizes the pane it
shows instead of keeping an older attached client's size; read-only views are
unchanged.
- **Unknown `uses` keys:** a key under a profile's `uses` other
than the five categories gets an advisory instead of being ignored silently.
- **Don't read `lastNudgeInputBox`.** It's gone.
- **Don't parse `rig ps --nodes` by column position.** Use `--json`, with `--fields` to pick fields.
- **Don't look for a team seat's skills in the project folders.** Use `rig skill loadout --seat`.
- **Don't stop and re-register a watchdog to change it.** Use `rig watchdog update`.
- **Don't take a rig down to fix one edge.** Use `rig edge add` or `remove`.
- **Don't print `OPENRIG_ACTIVITY_*` values** while looking for identity. Use `rig whoami`.
- **Don't use `--dangerously-interact` to type over a half-written input box.** It stops and shows the text; choose
explicitly, or use `rig send --urgent` where the seat's interruption level allows it.
## Limits to keep in view
## Known gaps in 0.7.1
- **OpenShell** hasn't run end to end on 0.7.10 (above). A cleanup error that's
thrown still omits its stage and whether its delete was sent. A
same-generation reconciled launch opens no viewer, so run
`rig environment connect` yourself; non-text startup actions aren't supported
on environment seats.
- **`rig exec`:** no real adapter has run under it yet; one run per row,
detached ownership and reconciling were checked against a stand-in agent.
Concurrent runs, long or background tasks, recovery across a reboot, MCP
servers and Windows aren't covered. A Ctrl-C just before the connector is
launched isn't caught by `--wait`.
- **Draft-aware delivery to Claude:** a narrow Claude pane showing
`✔ Update installed · Restart to update` on its own line can still hold
deliveries at an empty prompt, queue wakes included; 0.7.10 doesn't recognize
that bare banner. A held message isn't replayed automatically by that path.
- **Seats handed over before 0.7.9** keep their adopted origin until a later
launched handover or managed launch records a new session. Until then
`rig seat stop`, `clean` and in-place restart refuse them, and now say why.
Don't use `rig unclaim` to get around it: that ends OpenRig's management of
the seat.
- **Permission requests in seat status** show only the latest one, held in
memory for five minutes and lost on a daemon restart.
- **Agent spec advisories** don't show for a native resume that continues a
saved conversation.
- **The other 0.7.9 limits below still apply:** open updates for a person, Slack,
SSH host status, shared asks, permit records, existing seats, Codex hooks, and
grant-service assistants not being woken. 0.7.9's limits on a restore request
held after compaction, `rig broadcast`'s `FAILED`, what has run under the
runner contract, and Codex seats' `rig: command not found` are fixed or
changed. The 0.7.8 limits below that 0.7.9
didn't fix or change, and the
[0.7.7](https://github.com/mvschwarz/openrig/releases/tag/v0.7.7) and
[0.7.6](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6) limits, still
apply.
- **A new Claude seat's startup text can stay in its input box unsent.** If a fresh Claude seat sits idle with text at
its prompt, press Enter in its pane, or ask the person to.
- **Claude seats on a macOS native install can read as needing attention after a restore.** Check the pane before
acting on it.
- **A Slack channel-mapping change needs a daemon restart.** Don't use `rig slack disable` then `enable` for it: enabling
marks any alerts waiting to post to Slack as already seen.
- **Ubuntu 22.04's tmux 3.2a:** the query known to crash its server is gone, but a narrower race remains. Avoid 3.2a
(`tmux -V`).
- **`rig daemon start` can take 20 to 40 seconds** before its listener warning, longer than the TUI's 30-second "restore
everything" wait. On a slow host, start the daemon yourself first.
- **A launch that warns "Scoped tmux environment unavailable; retaining the previous environment"** didn't give that seat
the clean environment. Check `env` in the seat before relying on OpenRig's variables.
- **Going back to 0.7.0 with a Slack subscription set:** 0.7.0 can't read a person file that holds one, and its people
registry stops loading. Save the choice first (`rig gateway human show <id> --json > <id>.json`), then run
`rig gateway human set <id> slack-subscription unset` (`urgent-only` becomes every-ask, as on 0.7.0); set it again after
returning to 0.7.1. A person file still holding a `subscriptions:` entry fails on 0.7.0 too. Don't restore an older
gateway folder or database instead: that can lose newer people, bindings and delivery records.
- **A Herdr tile over SSH** doesn't come back by itself after a Herdr restart. Reopen it with `rig terminal open <view>
--provider herdr`.
- **Delegated decisions:** the ask is created before the decision, so without quiet hours it can post to the person
first. Pair a decision room with quiet hours, or decide before asking.
- **`restore-check` still treats a missing startup file as rig-wide.** Trust `rig up --existing`'s per-seat result.
- **Managed Claude launch requires `/usr/bin/env` support for `-0` and `-u`** (option support checked on GNU coreutils
and macOS 15; BusyBox and older macOS unverified). Without the required option support, launch stops instead of
falling back to credential-bearing command-line arguments.
- **In draft-aware mode, an agent's multi-line message to a Claude seat can remain unsubmitted when the paste changes
Claude's footer hint.** OpenRig withholds Enter, leaving the text in the composer and later sends waiting. Observed on
Claude 2.1.309 with both 0.7.0 and the 0.7.1 pre-cut build; #14 remains open.
- **A Claude seat signed in with a long-lived token (`claude setup-token`) gets no usage reading.** The reader accepts
only claude.ai subscription logins and reports `not_subscription_login`.
---
## What changed in OpenRig 0.7.9
The changes from 0.7.8 to 0.7.9, published on 2026-11-06, for an install
upgrading from 0.7.8 or earlier.
### After the upgrade
- **Restart the daemon** to load the new code: `rig daemon stop`, then
`rig daemon start`.
- **Migrations 146 to 149** record a Pi seat's guidance file, what OpenRig last
wrote in each managed guidance block, an index for reading a rig's outbox,
and the stream intake position.
- **Tell the person that stream intake is on.** Each `rig stream emit` now opens
an intake row for the kernel queue worker (`queue.worker`), or adds a note to
the one still unclaimed, and the row wakes the queue worker like any queue row.
Earlier stream history isn't replayed. If they want 0.7.8's behaviour, with no
intake rows, run `rig config set stream.intake off`.
- **The bundled openrig-core plugin goes from 0.1.24 to 0.1.25.** Its
seat-continuity-and-handover skill changed. Managed refresh preserves replaced
files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits
you still need from the preserved copy.
- **Where seat guidance goes now:**
- a fresh Pi seat gets OpenRig's guidance in its own agent folder's
`AGENTS.md`, unless that file or folder is a link;
- a fresh Codex seat on Codex 0.164.0 or later gets it as launch developer
instructions, not in the repository `AGENTS.md`, when no config layer
OpenRig reads sets `developer_instructions`;
- a Codex resume, fork, restore or handover successor, an older or unknown
Codex, and a config layer that sets `developer_instructions` or can't be read
still use the repository `AGENTS.md`;
- if only the person's shell startup sets `CODEX_HOME`, OpenRig reads
`~/.codex`, so a `developer_instructions` set in their home is replaced for
that session. Declare the same absolute path as `CODEX_HOME` in the member's
or rig's `env:` to have it checked. `docs/reference/agent-startup-guide.md`
("Codex guidance at launch") is the rule;
- a managed block a person edited by hand is kept and named
(`guidance_block_edited`), not replaced. Read the warning before you change
the block.
- **Slack:** on the first start after upgrading, the connector edits the asks
that ended earlier to show they were handled, at Slack's edit rate: about an
hour for 450 asks. `rig slack status` shows pending edits.
- **OpenShell:** rebuild a guest image built for 0.7.8 with this release's
helper; an older helper rejects the new startup step, and the error names the
rebuild.
### Runner seats
Section 7 of `docs/reference/harness-runner-contract.md` is the rule for
permissions, and section 4 for activity. Read them before writing or changing a
runner.
- **Typed words:** a reply of just yes or no counts as the agent's allow or
reject. Any other words choose nothing: OpenRig's default permits the request
when the agent offered an allow option, and the read result carries the words
whole as `said`. A runner may queue them for the agent as an ordinary prompt
after the current turn, never as the answer; one that drops them still keeps
the contract. The ask now tells the person this.
- **Permit records:** each permit OpenRig decides by default (no one to ask, or
an ask closed without a choice, including a row passed to an agent seat) is
recorded once, as a stream item from the seat, naming the tool call, why no
person answered and the option given. Read them with
`rig stream list --tag runner-permission-permit` or `--source <seat>`. A record
has no destination and never changes the permit. Stream intake reads past it, so
it opens no intake row and wakes no one.
- **The working beat:** while a prompt is outstanding and the agent is alive, a
runner may post `active` / `liveness` so a long call keeps reading working.
After delivering an answer or permit, it posts `active` /
`permission_resolved`. A runner that sends no beats behaves as before. The Pi
runner and the dsh example post it.
- **Check a runner** with
`node daemon/docs/reference/runner-conformance/check-runner.mjs --runner '<command with {agent}>' --name <harness>`.
It now covers the beat, and no longer falsely fails a correct runner on a busy
machine.
- **The dsh example** reads `DSH_SEAT_BIN` and `DSH_SEAT_HOME` from the member's
`env:`, and section 8 names ACP's `session/resume`.
### OpenShell workers
0.7.9 includes a repair for the OpenShell worker that wasn't usable in 0.7.8,
but the worker still doesn't work in 0.7.9. Don't rely on this path in 0.7.9.
Startup reaches the guest through a hash-checked first-input file, and nothing
is typed through the host viewer. Readiness checks the guest's own execution,
and a failed owned delete reports the stage that failed. Release testing on
0.7.9 then found two more problems: the guest's Claude couldn't reach its model
(`SELF_SIGNED_CERT_IN_CHAIN`), because the guest launcher left out the CA
settings the OpenShell exec supplies, and deleting an owned environment never
sent the delete, because its inventory step misread a "Not Found" reply. 0.7.10's
fixes for both are described above. "Never grant the guest access to the
daemon's main API port (normally 7433)."
### Assistants connected through a grant service (experimental)
Experimental, unsupported, off by default, and at the person's own risk. With
`rig mcp serve --http --grant-service <url>` and a grant service the person
supplies and runs, a connected assistant can claim, update and park the rows
addressed to its own seat, and see its team with `rig_team`, within the scope its
existing tools have. Nothing wakes it: an item stays queued until its receiver
claims it. Name the outside service, and the exposure of serving MCP over HTTP,
to the person before you set this up. `docs/reference/hosted-members.md` is the
rule.
### Other changes to act on
- **Held messages:** a seat that starts, or takes over a seat, with held
messages is told how many there are. Read them with
`rig seat held-messages <seat>`.
- **Unconfirmed sends:** `rig send --verify` now reports unconfirmed and exits 1
when it can't see what its Enter did, and `rig walk` reports unconfirmed
instead of lost. Inspect the recipient before any resend.
- **Deadlines:** to accept a re-estimate without unclaiming the work, run
`rig queue update <id> --closure-required-at <ISO>`.
- **Shared workspaces:** any registered person can settle another's ask as
themselves, and the first committed answer wins; a later answer gets 409
`qitem_already_terminal` and records nothing. A person registered with no
connector binding is asked on Steering and in the Feed only.
- **Handover:** a fresh or rebuild successor's first turn lists its startup
files, role first. A successor that started without a required startup file is
marked in the seat list.
- **Ambiguous rig names:** when an active rig name matches more than one rig,
`rig env down`, `status` and `logs` list the matching rig IDs and stop, and
`rig doctor --spec` reports conformance as unknown. Use the rig ID.
- **Seat activity:** window samples older than their bound no longer decide a
seat's activity, so a seat whose pane can't be read reads unknown rather than
its last verdict. Treat unknown as unknown, not idle.
- **Launch options:** `specs.builtins_visible: false` hides built-in specs
(`rig specs ls --include-builtins` still lists them); `plugin_channels` gives
one Claude member its own `--channels`, untested with a real channel plugin.
- **Files:** `rig down` removes OpenRig's own additions from a working folder
once no live seat uses them. `rig export` with no `-o` writes
`rig-export.yaml` beside an existing `rig.yaml`. `rig workspace validate` exits
1 for malformed optional frontmatter.
- **Kernels:** a new default kernel starts in `<workspace.root>/kernel`.
Existing kernels stay where they are. `docs/reference/agent-startup-guide.md`
explains the optional move; a fresh launch there starts a new native
conversation, so ask the person before moving.
- **PATH:** if preflight warns that a Codex seat's login shell can't find `rig`,
follow its fix. `getting-started.md` explains the `~/.profile` line and how to
check it with `bash -lc 'command -v node rig'`.
### Limits to keep in view
- **OpenShell:** the worker doesn't work in 0.7.9 (above). A same-generation
reconciled launch opens no viewer, so run `rig environment connect` yourself;
non-text startup actions aren't supported on environment seats.
- **Open updates for a person** read a 1,001-row window, so a page can come back
short, marked `truncated`.
- **Slack:** a private channel needs the documented route, which isn't tested,
and direct messages aren't supported. The channel and people reads haven't
been run against a live Slack workspace. Redaction of the bot token from a
transport error can still leave a malformed token fragment shorter than six
characters, so review an error before sharing it.
- **SSH host status** reads `unknown` behind a ProxyJump or ProxyCommand; that
isn't proof the connection is broken. `rig host ls` reads the ssh config with
`ssh -G`, which runs any `Match exec` command in it.
- **Shared asks:** a click's option written before its answer row can stand
beside or inside the closer's answer without the first clicker's authorship.
- **Permit records** last as long as stream retention keeps them. A permit a
runner makes on its own, when it can't reach OpenRig, is in its pane only. For
an ask opened before 0.7.9, a record may omit the option's kind.
- **Existing seats** pick up Codex launch instructions and the single
openrig-core skill load at their next fresh launch. A `CLAUDE.md` or
`AGENTS.md` that already grew a line per launch isn't trimmed.
- **Codex hooks after an upgrade:** Codex sessions kept running across the
upgrade show "Hook failed — plugin contents changed; hook approval required"
until relaunched.
- **0.7.8's limits** on the dsh `env:` names, conformance false failures, typed
words, the README's operator address, a symlinked rebuild, the rebuild receipt,
a re-parked click and unrecorded default permits are fixed or changed. The
other 0.7.8 limits below, and the
[0.7.7](https://github.com/mvschwarz/openrig/releases/tag/v0.7.7) and
[0.7.6](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6) limits, still
apply.
---
## What changed in OpenRig 0.7.8
The changes from 0.7.7 to 0.7.8, published on 2026-11-05, for an install
upgrading from 0.7.7 or earlier.
### After the upgrade
- **Restart the daemon** to load the new code: `rig daemon stop`, then
`rig daemon start`.
- **Migrations 142 to 145** store a Claude seat's MCP opt-out, seat activity
grants, OpenShell environment bindings and a seat's chosen permission person.
- **The bundled openrig-core plugin goes from 0.1.23 to 0.1.24.** One skill line
changed. Managed refresh preserves replaced files under
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you still
need from the preserved copy.
### Runner seats: a person answers permission requests
Section 7 of `docs/reference/harness-runner-contract.md` is the rule. Read it
before writing or changing a runner.
- **Who is asked:**
- **the default person** (`rig gateway default`, or the only registered
person) gets an ordinary ask in the Feed and on Slack;
- **a chosen person,** after
`rig seat set-permissions-to <seat> --to <person> --reason "…"`. `--clear`
returns to the default;
- **no one:** when no one can be asked, the request is "Permitted at once,
with the agent's own allow option";
- **refusal** is opt-in, with `--deny`. `rig seat status` shows the choice.
- **What a person's answer does:** a click gives that option, a plain yes gives
the allow, and a plain no gives the reject. "A person's no is never a grant."
An unanswered request stays pending until the agent's turn ends.
- **The runner's protocol:**
- `POST /api/activity/permission-requests` submits the request;
- `GET …/<askId>?waitSeconds=25` reads the answer (25 at most);
- `POST …/<askId>/withdraw` withdraws it at the turn's end.
Deliver an answer once, only to the live request.
- **Check a runner** with
`node daemon/docs/reference/runner-conformance/check-runner.mjs --runner '<command with {agent}>' --name <harness>`
from the installed package. It needs Node and tmux, and no daemon, model or
network.
- **The worked example** is the dsh runner under
`daemon/docs/reference/community-harness-examples/dsh/`.
- **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.
### OpenShell workers
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; the later repairs are described above.
**What it needs:**
- 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", so name them to
the person before you start. "Never grant the guest access to the daemon's main
API port (normally 7433)."
### Other changes to act on
- **Mission following:** `rig terminal open mission:<id> --follow --provider herdr`
follows a mission's current owners, with the same Herdr path as slices.
- **Launch choices:**
- `claude_mcp: none` on a Claude member launches it without MCP servers;
- `rig config set terminal.tmux_server isolated` gives this OpenRig home its
own persistent tmux server.
- **Hosts:** `rig host edit` and `rig host remove` correct or retire a host
entry. Use them instead of editing `hosts.yaml` by hand.
- **Another user's daemon:** when `rig doctor` warns that the port is held by
another OS user's daemon, follow its fix (`rig config set daemon.port <free-port>`),
or set `OPENRIG_URL` to share that daemon on purpose.
- **tmux control mode:** with `OPENRIG_TMUX_CONTROL=1`, OpenRig starts a control
client only on tmux 3.7c or newer, and uses spawned clients otherwise.
- **Read-only tmux clients:** a submit key refused because of one goes through
the paste route.
- **Slack:** a typed reply to a decision parked on its person is recorded as the
answer. Read the person's words; don't treat the close as approval.
- **The TUI:** agent pages now show health findings and recently finished items,
and an agent spec's "not loaded in this view" means it wasn't read, not that
nothing runs it.
### Limits to keep in view
- ~~**No harness has run end to end under the runner contract yet.** The dsh
example and the conformance check were checked against stand-ins.~~ *Corrected
after release:* 0.7.8's release testing ran a real dsh 0.2.1-rc.2 through the
shipped dsh example runner for a person's allow-once and reject-once, and
through an outside runner, dsh-seat 0.3.0, for launch, queue work, multiline
input, activity, cancel and exact resume. The conformance check itself runs
against a stand-in agent.
- **The answer owner is a registered person,** and a colleague's answer is
followed on this host only. A request handed to another host waits as needs
input until the turn ends.
- **Permits and answers aren't recorded** beyond the runner's pane and the ask.
If the agent offers only `allow_always`, a permit makes its harness remember a
permission nobody chose.
- **Coming as follow-ups:** recording permits made when no one can be asked, and
passing typed words that aren't a plain yes or no to the agent.
- **The dsh example keeps one hold per seat:** two asks in one turn can leave one
waiting after the hold is released.
- **An OpenShell worker's activity reads unknown,** sampled from the pane, never
idle.
- **The [0.7.7](https://github.com/mvschwarz/openrig/releases/tag/v0.7.7) and
[0.7.6](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6) limits still apply.**
+5 -42
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.7.1",
"version": "0.7.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.7.1",
"version": "0.7.10",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -4260,13 +4260,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/graphlib": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
@@ -5237,18 +5230,6 @@
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/proper-lockfile": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"retry": "^0.12.0",
"signal-exit": "^3.0.2"
}
},
"node_modules/proxy-addr": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@@ -5550,16 +5531,6 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -5868,13 +5839,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"dev": true,
"license": "ISC"
},
"node_modules/smol-toml": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz",
@@ -7298,7 +7262,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.7.1",
"version": "0.7.10",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7336,7 +7300,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.7.1",
"version": "0.7.10",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7353,7 +7317,6 @@
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.0.0",
"@types/tar": "^6.1.13",
"proper-lockfile": "^4.1.2",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
@@ -7379,7 +7342,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.7.1",
"version": "0.7.10",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.7.1",
"version": "0.7.10",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -19,7 +19,6 @@
"typecheck:prep": "npm run build -w packages/daemon",
"lint": "npm run typecheck:prep && tsc --noEmit --project packages/daemon/tsconfig.json && tsc --noEmit --project packages/ui/tsconfig.json && tsc --noEmit --project packages/cli/tsconfig.json && tsc --noEmit --project packages/tui/tsconfig.json",
"build:package": "bash scripts/build-package.sh",
"pack:release": "node scripts/pack-release.mjs",
"test:tui-package": "node scripts/test-tui-package.mjs",
"mirror-skills": "node scripts/mirror-skills.mjs",
"mirror-skills:check": "node scripts/mirror-skills.mjs --check",
-190
View File
@@ -1,190 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2026 Mike Schwarz
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-36
View File
@@ -1,36 +0,0 @@
# @openrig/acp
OpenRig as an agent in apps that speak the [Agent Client Protocol](https://agentclientprotocol.com) (ACP), such as
Zed. Your prompt goes to your OpenRig team as a queue row. The app shows who has the work
and their notes as it moves, any question the team asks you as a choice, and the team's result as the reply.
This package has one command, `openrig-acp`. It starts `rig acp serve` from the `@openrig/cli` version it depends on,
and warns on stderr if it finds another, so ACP apps and the ACP registry can launch it with `npx`.
## What you need
- OpenRig on this computer, with its daemon running (`rig daemon start`) and a team.
- The seat that receives your team's new work, set once with `rig config set acp.to <member@rig>`, or passed as
`--to <member@rig>`.
- You, as a person OpenRig knows: register with
`rig gateway human add <id> --display-name "<your name>" --delivery-class <A|B|C|D>` (the only registered person
is the default; with several, choose one with `rig gateway default <id>`), or pass `--as <address>`. The team's
questions reach an `<id>@external` address only once it's registered.
The app's sign-in step, "OpenRig on this machine", checks that the daemon answers and that it can tell who you are.
This agent talks to the same OpenRig daemon as `rig` on this computer: the local one, unless `OPENRIG_URL` names another.
## In an app, without the registry
Add a custom agent whose command is `npx` with the arguments `@openrig/acp@<version>`, plus `--to <member@rig>` if
you haven't set `acp.to`. In Zed's `settings.json`:
```json
"agent_servers": {
"OpenRig": { "type": "custom", "command": "npx", "args": ["@openrig/acp@<version>", "--to", "lead@my-team"] }
}
```
If `rig` is already installed, `rig acp serve` is the same agent without this package.
Guide: `rig context get reference/acp-agents.md#openrig-as-an-agent-in-acp-apps`.
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env node
// OpenRig as an agent in ACP apps. The ACP registry starts an agent with `npx <package>@<version>`, and npx needs a
// package with one executable: @openrig/cli has two. This one starts `rig acp serve` from the @openrig/cli this package
// depends on, as Node resolves it (never a `rig` on the PATH), with the app's stdin and stdout, arguments, environment
// and working directory, and exits as it exits. Only ACP messages may reach stdout, so anything this file says goes to
// stderr.
import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
let rig;
try {
const manifestPath = createRequire(import.meta.url).resolve("@openrig/cli/package.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
// Module resolution can reach a different @openrig/cli further up when this package's own copy is missing. That copy
// most likely still works, and one that doesn't fails visibly, so say which version runs and run it.
const wanted = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).dependencies["@openrig/cli"];
if (manifest.version !== wanted) {
console.error(`openrig-acp: found @openrig/cli ${manifest.version} at ${dirname(manifestPath)}, not the ${wanted} this package pins; running it anyway. Reinstall @openrig/acp to get ${wanted}.`);
}
rig = join(dirname(manifestPath), manifest.bin.rig);
} catch (error) {
console.error(`openrig-acp: @openrig/cli isn't installed beside this package (${error.message}). Reinstall @openrig/acp.`);
process.exit(1);
}
const child = spawn(process.execPath, [rig, "acp", "serve", ...process.argv.slice(2)], { stdio: "inherit" });
const forward = (signal) => child.kill(signal);
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, forward);
child.on("error", (error) => {
console.error(`openrig-acp: couldn't start rig acp serve: ${error.message}`);
process.exit(1);
});
child.on("exit", (code, signal) => {
if (signal) {
// End the same way the server did, so the app sees the signal rather than an exit code.
process.removeListener(signal, forward);
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});
-38
View File
@@ -1,38 +0,0 @@
{
"name": "@openrig/acp",
"version": "0.7.1",
"description": "OpenRig as an agent in ACP apps: starts rig acp serve from the @openrig/cli it pins.",
"type": "module",
"bin": {
"openrig-acp": "bin/openrig-acp.js"
},
"files": [
"bin",
"LICENSE",
"README.md"
],
"engines": {
"node": ">=22"
},
"dependencies": {
"@openrig/cli": "0.7.1"
},
"license": "Apache-2.0",
"author": {
"name": "mvschwarz",
"url": "https://esoteric.run"
},
"homepage": "https://openrig.dev",
"repository": {
"type": "git",
"url": "https://github.com/mvschwarz/openrig",
"directory": "packages/acp"
},
"keywords": [
"ai",
"agents",
"acp",
"agent-client-protocol",
"openrig"
]
}
-18
View File
@@ -1,18 +0,0 @@
{
"id": "openrig",
"name": "OpenRig",
"version": "0.7.1",
"description": "Send work to your OpenRig team of agents and follow it through to the result",
"repository": "https://github.com/mvschwarz/openrig",
"website": "https://openrig.dev",
"authors": [
"mvschwarz"
],
"license": "Apache-2.0",
"license_url": "https://github.com/mvschwarz/openrig/blob/main/LICENSE",
"distribution": {
"npx": {
"package": "@openrig/[email protected]"
}
}
}
-1
View File
@@ -1 +0,0 @@
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="8 -754 596 808" fill="currentColor"><g transform="scale(1,-1)"><path d="M306.0 -14.0Q426.0 -14.0 495.0 59.5Q564.0 133.0 564.0 272.0V428.0Q564.0 563.0 495.0 638.5Q426.0 714.0 306.0 714.0Q186.0 714.0 117.0 638.5Q83.32444763183594 601.652099609375 66.08428192138672 550.631591796875L195.21009826660156 523.18505859375Q207.8548583984375 554.14111328125 233.0 573.0Q261.0 594.0 306.0 594.0Q351.0 594.0 378.5 573.0Q406.0 552.0 419.0 516.0Q432.0 480.0 432.0 436.0V264.0Q432.0 194.0 405.0 150.0Q378.0 106.0 306.0 106.0Q234.0 106.0 207.0 150.0Q180.0 194.0 180.0 264.0V436.0Q180.0 461.6716613769531 184.25511169433594 484.6199951171875L55.7750358581543 511.9292907714844Q48.0 473.3169250488281 48.0 428.0V272.0Q48.0 133.0 117.0 59.5Q186.0 -14.0 306.0 -14.0ZM138.0 176.0C138.0 162.7451629638672 127.25483703613281 152.0 114.0 152.0C100.74516296386719 152.0 90.0 162.7451629638672 90.0 176.0C90.0 189.2548370361328 100.74516296386719 200.0 114.0 200.0C127.25483703613281 200.0 138.0 189.2548370361328 138.0 176.0Z"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.7.1",
"version": "0.7.10",
"type": "module",
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
"keywords": [
-540
View File
@@ -1,540 +0,0 @@
import { randomBytes } from "node:crypto";
import type { JsonRpcPeer } from "./json-rpc-stdio.js";
// OpenRig as an ACP agent (rig acp serve): an ACP app's prompt becomes a queue row to the team's receiver, the row's
// transitions and handoffs come back as progress, and its closing resolution is the reply. A question the team asks the
// caller while it works comes to the app as a permission request, and the choice goes back through the person's answer
// path. The queue is the only work store: a session is the rows tagged with its id, so it can be listed and reopened.
// A turn's end_turn says only that the turn ended: the reply says whether the team closed the item, and a cancel stops
// this process waiting, never the team.
export const ACP_PROTOCOL_VERSION = 1;
export const AUTH_METHOD_ID = "openrig-local";
export const SESSION_TAG = "acp-session:";
export const CWD_TAG = "acp-cwd:";
/** Where the caller's prompt ends in a row's body; the rest is the note rig acp serve adds for the team. */
const FOOTER = "\n\n---\nSent by ";
/** One queue row as this adapter reads it. */
export interface TeamRow {
qitemId: string;
state: string;
destinationSession: string;
handedOffTo: string | null;
closureReason: string | null;
closureTarget: string | null;
resolution: string | null;
/** Who closed the row, from its closing transition (a note written after the close doesn't change it); null while open. */
closer: string | null;
}
export interface TeamTransition {
transitionId: number;
ts: string;
state: string;
transitionNote: string | null;
actorSession: string;
}
/** A row to the caller that asks for a decision, with the choices it offers (none: words only). */
export interface TeamAsk {
qitemId: string;
sourceSession: string;
state: string;
tsCreated: string;
summary: string | null;
body: string;
tags: string[];
questions: Array<{ id: string; question: string; options: Array<{ id: string; label: string; recommended?: boolean }> }>;
/** What's recorded so far, by question id: the chosen option's id, or the words of a typed reply. */
answers: Record<string, string>;
}
/** A row the caller sent through rig acp serve. */
export interface SentRow {
qitemId: string;
tsCreated: string;
tsUpdated: string;
summary: string | null;
body: string;
tags: string[];
}
/** The daemon answered a create and refused it, so nothing was written. Any other failure of a create leaves it unknown. */
export class TeamRefused extends Error {}
/** The queue verbs the adapter uses, acting as the caller. */
export interface TeamQueue {
/** Create the row under the id the adapter chose, so a create whose answer is lost can be read back by that id. Throws
* TeamRefused only for a refusal the daemon answered with. */
create(row: { qitemId: string; destination: string; body: string; summary: string; tags: string[] }): Promise<void>;
show(qitemId: string): Promise<TeamRow>;
/** The row, or null when this machine has no such row; throws when it can't tell. */
find(qitemId: string): Promise<TeamRow | null>;
transitions(qitemId: string): Promise<TeamTransition[]>;
/** The row a handoff of `qitemId` created on this machine, if any. */
successor(qitemId: string): Promise<string | undefined>;
/** Open asks addressed to `person`. */
openAsks(person: string): Promise<TeamAsk[]>;
/** Every ask to `person` tagged for one chat, open or settled, to replay with it. */
sessionAsks(person: string, sessionId: string): Promise<TeamAsk[]>;
/** Record the caller's choice on an ask, through the person's answer path; a refusal comes back with its reason. */
answer(qitemId: string, questionId: string, optionId: string): Promise<{ ok: true } | { ok: false; reason: string }>;
/** Rows the caller sent through rig acp serve. */
sent(caller: string): Promise<SentRow[]>;
}
/** What a session works through: the queue as the caller, and the seat that receives the caller's tasks (when set). */
export interface TeamConnection {
queue: TeamQueue;
caller: string;
receiver: string | undefined;
/** Something about this connection the person should know, said once in each session before its first prompt's work. */
notice?: string;
}
export interface AcpAgentOptions {
/** Reaches the daemon (`rig`'s: the local one, or OPENRIG_URL's) and resolves the caller; returns guidance when it can't.
* Called lazily, never at initialize. */
connect: () => Promise<TeamConnection | { error: string }>;
version: string;
pollMs: number;
log?: (line: string) => void;
sleep?: (ms: number) => Promise<void>;
/** Settles when the app's connection ends: open turns stop following their work (nothing could carry a reply). */
closed?: Promise<void>;
}
const ACTIVE_STATES = new Set(["pending", "in-progress", "blocked"]);
/** How long a cancel waits for the app to settle questions it was showing (ACP has the client answer them cancelled). */
const QUESTION_SETTLE_MS = 2000;
const AUTH_REQUIRED = -32000;
const INVALID_PARAMS = -32602;
class AcpError extends Error {
constructor(readonly code: number, message: string, readonly data?: unknown) { super(message); }
}
interface Session {
id: string;
cwd: string;
/** The first row each prompt created, oldest first, with the last row its work reached. */
sent: Array<{ root: string; tail: string }>;
turn?: { cancel: () => void };
noticed?: boolean;
}
type ContentBlock = { type: string; text?: string; uri?: string; name?: string; resource?: { uri?: string; text?: string } };
/** The questions one turn has put to the app. Once the turn has ended, nothing from them reaches the app or the queue. */
interface TurnQuestions {
finished: boolean;
/** The person cancelled the turn: no further question opens, though one the app is showing still settles. */
cancelled: boolean;
/** Questions the app is still showing, by tool call id, each with how the chat names it. */
outstanding: Map<string, string>;
tasks: Set<Promise<void>>;
}
type Update = Record<string, unknown>;
/** The prompt as plain text for the row's body: text as written, links and embedded text resources named. */
export function promptText(prompt: ContentBlock[]): string {
const parts: string[] = [];
for (const block of prompt) {
if (block.type === "text" && block.text) parts.push(block.text);
else if (block.type === "resource_link" && block.uri) parts.push(`[${block.name ?? "link"}](${block.uri})`);
else if (block.type === "resource" && block.resource?.uri) {
parts.push(block.resource.text !== undefined ? `${block.resource.uri}:\n\`\`\`\n${block.resource.text}\n\`\`\`` : block.resource.uri);
}
}
return parts.join("\n\n").trim();
}
/** A queue row id in the form the CLI mints (`qitem-<UTC yyyymmddhhmmss>-<16 hex>`). */
export const newQitemId = () => `qitem-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(8).toString("hex")}`;
function oneLine(text: string, max: number): string {
const line = text.split("\n").find((l) => l.trim())?.trim() ?? "";
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
}
/** The person's own words in a prompt, for the row's summary and the session's title. An app that wraps them in its
* own instructions marks them (T3 Code sends several blocks of harness text, then `<user_request>…</user_request>`);
* the row's body keeps everything the app sent. Only a prompt with one opening and one closing mark is read this
* way: with more, an example could be taken for the request, so the prompt stands as sent. */
function personsWords(text: string): string {
if (text.split("<user_request>").length !== 2 || text.split("</user_request>").length !== 2) return text;
const marked = /<user_request>([\s\S]*?)<\/user_request>/.exec(text)?.[1];
return marked?.trim() ? marked : text;
}
const toolStatus = (state: string) =>
state === "pending" ? "pending" : ACTIVE_STATES.has(state) ? "in_progress" : state === "done" || state === "handed-off" ? "completed" : "failed";
const transitionLine = (t: TeamTransition) =>
`${t.ts.slice(11, 19)}Z ${t.actorSession} · ${t.state}${t.transitionNote ? `: ${t.transitionNote}` : ""}`;
const textContent = (text: string) => [{ type: "content", content: { type: "text", text } }];
/** Where one prompt's work stands after a read of its rows. */
type ChainState = { kind: "open"; tail: TeamRow } | { kind: "ended"; tail: TeamRow; reply: string };
/** Follows one prompt's work through its rows: each row is a step, and its new notes are emitted as they appear. */
class ChainFollower {
private readonly chain: string[];
private readonly seen = new Map<string, { lines: string[]; lastId: number; status: string; title: string }>();
/** The seats the work has reached; a question from one of them during the turn belongs to it. */
readonly seats = new Set<string>();
constructor(private readonly queue: TeamQueue, private readonly emit: (update: Update) => void, readonly root: string) {
this.chain = [root];
}
async read(): Promise<ChainState> {
for (;;) {
let tail!: TeamRow;
for (const qitemId of this.chain) {
const row = await this.queue.show(qitemId);
const transitions = await this.queue.transitions(qitemId);
tail = row;
this.seats.add(row.destinationSession);
const status = toolStatus(row.state);
let entry = this.seen.get(qitemId);
if (!entry) {
const title = qitemId === this.root ? `Sent to ${row.destinationSession} (${qitemId})` : `Handed to ${row.destinationSession} (${qitemId})`;
entry = { lines: [], lastId: 0, status, title };
this.seen.set(qitemId, entry);
this.emit({ sessionUpdate: "tool_call", toolCallId: qitemId, kind: "other", status, title, rawInput: { qitemId, destination: row.destinationSession } });
}
const fresh = transitions.filter((t) => t.transitionId > entry!.lastId);
if (fresh.length > 0 || status !== entry.status) {
for (const t of fresh) { entry.lines.push(transitionLine(t)); entry.lastId = Math.max(entry.lastId, t.transitionId); }
entry.status = status;
// An app that shows a step collapsed still shows its title, so the latest note leads it: a narrow panel cuts the end.
const noted = fresh.filter((t) => t.transitionNote).at(-1);
this.emit({
sessionUpdate: "tool_call_update", toolCallId: qitemId, status,
...(noted ? { title: `${noted.actorSession}: ${oneLine(noted.transitionNote!, 100)} · ${entry.title}` } : {}),
...(entry.lines.length > 0 ? { content: textContent(entry.lines.join("\n")) } : {}),
});
}
}
// Both handoff forms pass the work on: `handoff` leaves the row handed-off, `handoff-and-complete` closes it done
// with closure reason handed_off_to. Either way the answer is the successor's, not this row's.
if (tail.state === "handed-off" || (tail.state === "done" && tail.closureReason === "handed_off_to")) {
const next = await this.queue.successor(tail.qitemId);
if (next) { this.chain.push(next); continue; }
return { kind: "ended", tail, reply: `${tail.qitemId} was handed to ${tail.handedOffTo ?? tail.closureTarget ?? "another seat"}, whose row isn't on this machine, so this app can't follow it further. Ask your team for its result.` };
}
if (ACTIVE_STATES.has(tail.state)) return { kind: "open", tail };
// A done row's closure reason says how it ended (canceled is a done close too); the ordinary one goes unsaid.
const reason = tail.closureReason && tail.closureReason !== "no-follow-on" ? ` (${tail.closureReason})` : "";
const how = tail.state === "done" ? `done${reason}` : `as ${tail.state}${reason}`;
return { kind: "ended", tail, reply: `${tail.closer ?? tail.destinationSession} closed ${tail.qitemId} ${how}.\n\n${tail.resolution?.trim() || "(No resolution was written.)"}` };
}
}
}
export function serveAcpAgent(peer: JsonRpcPeer, options: AcpAgentOptions): void {
const sessions = new Map<string, Session>();
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
const log = options.log ?? (() => {});
let clientName = "an ACP app";
/** The app asks forms (ACP `elicitation/create`, mode form), so a question's choices go to it as a form, each by its
* label. A permission request shows them only in apps that list each option: T3 Code shows a fixed Approve, which
* sends the first. */
let asksForms = false;
let connection: TeamConnection | undefined;
void options.closed?.then(() => { for (const session of sessions.values()) session.turn?.cancel(); });
const connected = async (): Promise<TeamConnection> => {
if (connection) return connection;
const result = await options.connect();
if ("error" in result) throw new AcpError(AUTH_REQUIRED, result.error);
connection = result;
return result;
};
const update = (sessionId: string, body: Update) => peer.notify("session/update", { sessionId, update: body });
const say = (sessionId: string, text: string) => update(sessionId, { sessionUpdate: "agent_message_chunk", content: { type: "text", text } });
/** A question the app showed ends unanswered: its card says so, and so does the chat, which every app shows, since an
* app need not show a card's later text. */
const unanswered = (sessionId: string, toolCallId: string, named: string, why: string) => {
update(sessionId, { sessionUpdate: "tool_call_update", toolCallId, content: textContent(`${why} The question is still open in the OpenRig TUI and Slack.`) });
say(sessionId, `\n\n${why} ${named} is still open in the OpenRig TUI and Slack.\n\n`);
};
const newSessionId = () => `acps-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(4).toString("hex")}`;
/** Each of the caller's prompts in a session, oldest first, by the rows tagged with its id. */
const sessionRoots = async (team: TeamConnection, sessionId: string) =>
(await team.queue.sent(team.caller)).filter((row) => row.tags.includes(`${SESSION_TAG}${sessionId}`))
.sort((a, b) => a.tsCreated.localeCompare(b.tsCreated));
/** Put one ask's open questions to the person, as a form where the app asks forms and otherwise as permission
* requests, one at a time, and record each choice. A reply that arrives after the turn ended answers nothing here:
* the question stays open in the TUI and Slack. */
const askPerson = async (team: TeamConnection, sessionId: string, ask: TeamAsk, turn: TurnQuestions) => {
const emit = (body: Update) => { if (!turn.finished) update(sessionId, body); };
if (ask.questions.length === 0) {
emit({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: `\n\n${ask.sourceSession} asks you: ${ask.summary ?? oneLine(ask.body, 200)} (${ask.qitemId}). It wants an answer in words, which this app can't send: answer it in the OpenRig TUI or Slack.\n\n` } });
return;
}
for (const q of ask.questions.filter((question) => !(question.id in ask.answers))) {
if (turn.finished || turn.cancelled) return;
const toolCallId = `${ask.qitemId}#${q.id}`;
emit({ sessionUpdate: "tool_call", toolCallId, kind: "other", status: "pending", title: `${ask.sourceSession} asks: ${q.question}`, content: textContent(ask.body) });
const label = (o: (typeof q.options)[number]) => (o.recommended ? `${o.label} (recommended)` : o.label);
const named = `${ask.sourceSession}'s question "${q.question}" (${ask.qitemId})`;
let chosenId: string | undefined;
turn.outstanding.set(toolCallId, named);
try {
if (asksForms) {
// Each choice is offered as its label, the one form every app shows as choices: T3 Code 0.1.7 builds them only
// from `enum`, a value serving as both label and answer, and reads `oneOf` as free text. Labels that repeat
// carry their option's id, so a value names one choice; the answer is mapped back by that exact value.
const shown = q.options.map(label);
const values = new Set(shown).size === shown.length ? shown : q.options.map((o, i) => `${shown[i]} (${o.id})`);
const result = (await peer.request("elicitation/create", {
sessionId, toolCallId, mode: "form", message: `${ask.sourceSession} asks: ${q.question}`,
requestedSchema: { type: "object", required: [q.id], properties: { [q.id]: { type: "string", title: q.question, enum: values } } },
})) as { action?: string; content?: Record<string, unknown> | null } | undefined;
const value = result?.action === "accept" ? result.content?.[q.id] : undefined;
chosenId = typeof value === "string" ? q.options[values.indexOf(value)]?.id : undefined;
} else {
const result = (await peer.request("session/request_permission", {
sessionId,
toolCall: { toolCallId, title: `${ask.sourceSession} asks: ${q.question}`, kind: "other", status: "pending" },
options: q.options.map((o) => ({ optionId: o.id, name: label(o), kind: "allow_once" })),
})) as { outcome?: { outcome?: string; optionId?: string } } | undefined;
chosenId = result?.outcome?.outcome === "selected" ? result.outcome.optionId : undefined;
}
} catch (error) {
if (!turn.finished) unanswered(sessionId, toolCallId, named, `This app couldn't show the question (${(error as Error).message}).`);
return;
} finally {
turn.outstanding.delete(toolCallId);
}
if (turn.finished) return;
// Only one of the question's own options is recorded: the app's reply is checked again here.
const chosen = q.options.find((o) => o.id === chosenId);
if (!chosen) {
unanswered(sessionId, toolCallId, named, "Not answered here.");
return;
}
const recorded = await team.queue.answer(ask.qitemId, q.id, chosen.id).catch((error: Error) => ({ ok: false as const, reason: error.message }));
emit(recorded.ok
? { sessionUpdate: "tool_call_update", toolCallId, status: "completed", content: textContent(`You answered: ${chosen.label}. ${ask.sourceSession} gets it as your answer.`) }
: { sessionUpdate: "tool_call_update", toolCallId, status: "failed", content: textContent(`Your choice (${chosen.label}) wasn't recorded: ${recorded.reason}`) });
log(`session ${sessionId}: ${recorded.ok ? "answered" : "couldn't answer"} ${ask.qitemId} ${q.id}`);
}
};
peer.onRequest("initialize", async (params) => {
const p = (params ?? {}) as { clientInfo?: { name?: string; title?: string }; clientCapabilities?: { elicitation?: { form?: unknown } | null } };
clientName = p.clientInfo?.title ?? p.clientInfo?.name ?? clientName;
// ACP counts a mode as supported only when its field is present and not null.
asksForms = p.clientCapabilities?.elicitation?.form != null;
return {
protocolVersion: ACP_PROTOCOL_VERSION,
agentCapabilities: {
loadSession: true,
promptCapabilities: { image: false, audio: false, embeddedContext: true },
mcpCapabilities: { http: false, sse: false },
sessionCapabilities: { list: {} },
},
authMethods: [{
id: AUTH_METHOD_ID,
name: "OpenRig on this machine",
description: "Sends your work to your OpenRig team, as you, through the same OpenRig daemon as `rig` on this computer: the local one (start it with `rig daemon start`), unless OPENRIG_URL names another.",
}],
agentInfo: { name: "openrig", title: "OpenRig", version: options.version },
};
});
peer.onRequest("authenticate", async (params) => {
const methodId = (params as { methodId?: unknown } | undefined)?.methodId;
if (methodId !== AUTH_METHOD_ID) throw new AcpError(INVALID_PARAMS, `Unknown auth method ${String(methodId)}; this agent offers ${AUTH_METHOD_ID}`);
const team = await connected();
log(`authenticated as ${team.caller}${team.receiver ? `, sending to ${team.receiver}` : ", with no receiver set"}`);
return {};
});
peer.onRequest("session/new", async (params) => {
await connected();
const id = newSessionId();
sessions.set(id, { id, cwd: String((params as { cwd?: unknown } | undefined)?.cwd ?? ""), sent: [] });
return { sessionId: id };
});
peer.onRequest("session/list", async (params) => {
const team = await connected();
const cwd = (params as { cwd?: unknown } | undefined)?.cwd;
const byId = new Map<string, { sessionId: string; cwd: string; title: string; updatedAt: string }>();
for (const row of (await team.queue.sent(team.caller)).sort((a, b) => a.tsCreated.localeCompare(b.tsCreated))) {
const sessionId = row.tags.find((t) => t.startsWith(SESSION_TAG))?.slice(SESSION_TAG.length);
if (!sessionId) continue;
const known = byId.get(sessionId);
if (known) { if (row.tsUpdated > known.updatedAt) known.updatedAt = row.tsUpdated; continue; }
byId.set(sessionId, {
sessionId, cwd: row.tags.find((t) => t.startsWith(CWD_TAG))?.slice(CWD_TAG.length) ?? "",
title: oneLine(personsWords(row.body.split(FOOTER)[0] ?? ""), 100), updatedAt: row.tsUpdated,
});
}
const listed = [...byId.values()].filter((s) => typeof cwd !== "string" || s.cwd === cwd).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return { sessions: listed };
});
peer.onRequest("session/load", async (params) => {
const p = (params ?? {}) as { sessionId?: string; cwd?: string };
const team = await connected();
const sessionId = String(p.sessionId ?? "");
const roots = await sessionRoots(team, sessionId);
if (roots.length === 0) throw new AcpError(INVALID_PARAMS, `No session ${sessionId} from ${team.caller} on this machine`);
const session: Session = { id: sessionId, cwd: String(p.cwd ?? ""), sent: [] };
const asks = (await team.queue.sessionAsks(team.caller, sessionId)).sort((a, b) => a.tsCreated.localeCompare(b.tsCreated));
// Replay: each prompt as the caller wrote it, then its work as it stands now, the questions the team asked during
// it with what's recorded, and the reply. Nothing is asked again here; an open question comes up in the next turn.
for (const [i, row] of roots.entries()) {
update(sessionId, { sessionUpdate: "user_message_chunk", content: { type: "text", text: row.body.split(FOOTER)[0] ?? "" } });
const follower = new ChainFollower(team.queue, (u) => update(sessionId, u), row.qitemId);
const state = await follower.read();
session.sent.push({ root: row.qitemId, tail: state.tail.qitemId });
const next = roots[i + 1]?.tsCreated;
for (const ask of asks.filter((a) => (i === 0 || a.tsCreated >= row.tsCreated) && (next === undefined || a.tsCreated < next))) {
for (const q of ask.questions) {
const answer = ask.answers[q.id];
const outcome = answer !== undefined ? `You answered: ${q.options.find((o) => o.id === answer)?.label ?? answer}.`
: ACTIVE_STATES.has(ask.state) ? "Not answered yet. It's still open in the OpenRig TUI and Slack." : `Not answered: ${ask.qitemId} was closed (${ask.state}).`;
update(sessionId, {
sessionUpdate: "tool_call", toolCallId: `${ask.qitemId}#${q.id}`, kind: "other", status: answer === undefined && ACTIVE_STATES.has(ask.state) ? "pending" : "completed",
title: `${ask.sourceSession} asks: ${q.question}`, content: [...textContent(ask.body), ...textContent(outcome)],
});
}
}
say(sessionId, state.kind === "ended" ? state.reply : `${state.tail.qitemId} is still ${state.tail.state} with ${state.tail.destinationSession}.`);
}
sessions.set(sessionId, session);
return {};
});
peer.onNotification("session/cancel", (params) => {
const sessionId = (params as { sessionId?: string } | undefined)?.sessionId;
if (sessionId) sessions.get(sessionId)?.turn?.cancel();
});
peer.onRequest("session/prompt", async (params) => {
const p = (params ?? {}) as { sessionId?: string; prompt?: ContentBlock[] };
const session = p.sessionId ? sessions.get(p.sessionId) : undefined;
if (!session) throw new AcpError(INVALID_PARAMS, `No session ${String(p.sessionId)} in this agent; start one with session/new or session/load`);
if (session.turn) throw new AcpError(INVALID_PARAMS, "This session already has a turn running; wait for it or cancel it first");
const team = await connected();
const text = promptText(Array.isArray(p.prompt) ? p.prompt : []);
if (!text) {
say(session.id, "Nothing to send: the prompt had no text.");
return { stopReason: "end_turn" };
}
if (!team.receiver) {
say(session.id, "This OpenRig agent has no team receiver, so nothing was sent. Choose the seat that takes your team's new work with `rig config set acp.to <member@rig>`, or add `--to <member@rig>` to the agent's command in your app's settings.");
return { stopReason: "end_turn" };
}
if (team.notice && !session.noticed) {
session.noticed = true;
say(session.id, `${team.notice}\n\n`);
}
let wake: () => void = () => {};
const questions: TurnQuestions = { finished: false, cancelled: false, outstanding: new Map(), tasks: new Set() };
session.turn = { cancel: () => { questions.cancelled = true; wake(); } };
let root: string | undefined;
try {
// Each earlier item as it stands; one whose create was never confirmed may not exist, or can't be read: say which.
const earlier = await Promise.all(session.sent.map(async (s) => {
const row = await team.queue.find(s.tail).catch(() => undefined);
return { ...s, row, state: row === undefined ? "state unknown" : row === null ? "not found here" : row.state };
}));
const open = earlier.filter((s) => s.row && ACTIVE_STATES.has(s.row.state));
if (open.length > 0) say(session.id, `${open.map((s) => `${s.row!.qitemId} is still ${s.row!.state} with ${s.row!.destinationSession}`).join("; ")}. Sending this as a follow-up.\n\n`);
const lineage = earlier.length === 0 ? "" : `\nFollows up on ${earlier.map((s) => `${s.root} (${s.tail === s.root ? "" : `reached ${s.tail}, `}${s.state})`).join(", ")}.`;
const body = `${text}${FOOTER}${team.caller} from ${clientName} through rig acp serve (session ${session.id}).${lineage}\n` +
`The app shows this item's notes and handoffs as progress, and its closing resolution as the answer: close it with a resolution written for ${team.caller}. ` +
`A question with choices you ask ${team.caller} while this is open, tagged for this chat, appears in the app as a choice: ` +
`rig queue create --destination ${team.caller} --human-questions-file … --tags ${SESSION_TAG}${session.id}.`;
const turnStarted = new Date().toISOString();
const qitemId = newQitemId();
try {
await team.queue.create({
qitemId, destination: team.receiver, body, summary: `ACP: ${oneLine(personsWords(text), 100)}`,
tags: ["acp-serve", `${SESSION_TAG}${session.id}`, ...(session.cwd ? [`${CWD_TAG}${session.cwd}`] : [])],
});
} catch (error) {
const reason = (error as Error).message;
// A refusal the daemon answered with wrote nothing, so it can be said plainly.
if (error instanceof TeamRefused) throw new AcpError(-32603, `The team's queue refused the task: ${reason}`);
// No answer: the request may still land after any readback, so only a row that reads back settles it.
const landed = await team.queue.find(qitemId).catch(() => undefined);
if (!landed) {
session.sent.push({ root: qitemId, tail: qitemId });
say(session.id, `Sending ${qitemId} to ${team.receiver} got no answer (${reason}), and ${landed === null ? "it wasn't found on the immediate readback" : "it couldn't be read back"}: delivery is unconfirmed, so it may still reach the team. A new prompt in this session names it, and \`rig queue show ${qitemId}\` shows it if it landed.`);
throw new AcpError(-32603, `Couldn't confirm ${qitemId}: ${reason}`);
}
log(`session ${session.id}: ${qitemId}'s create got no answer, but it reads back`);
}
root = qitemId;
const sent = { root, tail: root };
session.sent.push(sent);
log(`session ${session.id}: sent ${root} to ${team.receiver}`);
// ponytail: polls the daemon for the chain's rows, their transitions and the caller's open asks; the
// daemon's event stream is the upgrade path if a poll per open turn ever costs too much.
const follower = new ChainFollower(team.queue, (u) => update(session.id, u), root);
const seen = new Set<string>();
/** Close this turn's questions: any the app still shows are named as open elsewhere, then nothing more comes from them. */
const finishQuestions = (why: string) => {
for (const [toolCallId, named] of questions.outstanding) unanswered(session.id, toolCallId, named, why);
questions.finished = true;
};
for (;;) {
const state = await follower.read();
sent.tail = state.tail.qitemId;
if (state.kind === "ended") {
finishQuestions("The work closed before this was answered here.");
say(session.id, state.reply);
return { stopReason: "end_turn" };
}
const asks = await team.queue.openAsks(team.caller);
// After a cancel, even one that landed during that read, no new question goes to the person.
for (const ask of questions.cancelled ? [] : asks) {
if (seen.has(ask.qitemId)) continue;
// An ask tagged for a chat belongs to that chat alone. An untagged one from a seat this work reached might be
// another chat's, so it isn't asked here; it's named, with where to answer it.
const chats = ask.tags.filter((tag) => tag.startsWith(SESSION_TAG));
if (chats.includes(`${SESSION_TAG}${session.id}`)) {
seen.add(ask.qitemId);
const task: Promise<void> = askPerson(team, session.id, ask, questions)
.catch((error: Error) => log(`session ${session.id}: asking about ${ask.qitemId} failed: ${error.message}`))
.finally(() => questions.tasks.delete(task));
questions.tasks.add(task);
} else if (chats.length === 0 && follower.seats.has(ask.sourceSession) && ask.tsCreated >= turnStarted) {
seen.add(ask.qitemId);
say(session.id, `\n\n${ask.sourceSession}, which this work reached, has a question for you that isn't tagged for this chat (${ask.qitemId}: ${ask.summary ?? oneLine(ask.body, 120)}). Answer it in the OpenRig TUI or Slack.\n\n`);
}
}
if (questions.cancelled) {
// ACP has the app answer the questions it was showing as cancelled; let those answers land before the turn ends.
if (questions.tasks.size > 0) await Promise.race([Promise.allSettled([...questions.tasks]), sleep(QUESTION_SETTLE_MS)]);
finishQuestions("Not answered here: the turn was cancelled.");
say(session.id, `Stopped waiting. ${state.tail.qitemId} is still ${state.tail.state} with ${state.tail.destinationSession}: cancelling here doesn't stop the team's work. A new prompt in this session goes to the team as a follow-up and shows where this stands.`);
return { stopReason: "cancelled" };
}
await Promise.race([sleep(options.pollMs), new Promise<void>((resolve) => { wake = resolve; })]);
}
} catch (error) {
if (error instanceof AcpError || !root) throw error;
say(session.id, `Lost track of ${root}: ${(error as Error).message}. The item stays with the team; its state is unknown from here.`);
throw new AcpError(-32603, `Couldn't follow ${root}: ${(error as Error).message}`);
} finally {
questions.finished = true;
session.turn = undefined;
}
});
}
+34 -60
View File
@@ -1,10 +1,9 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { createInterface } from "node:readline";
// ACP v1 is JSON-RPC 2.0 over newline-delimited JSON on the agent's stdio. This peer covers what OpenRig needs on either
// side (a few outgoing requests, incoming requests and notifications): StdioJsonRpcPeer is the client end of an agent
// process OpenRig launched, and JsonRpcPeer over this process's own stdin and stdout is the agent end (rig acp serve).
// The official SDK would add a dependency for a handful of methods, so swap to it only if the method set grows.
// ACP v1 is JSON-RPC 2.0 over newline-delimited JSON on the agent's stdio. This peer covers what an OpenRig client
// needs (a few outgoing requests, incoming requests and notifications); the official SDK would add a dependency for
// a handful of methods, so swap to it only if the method set grows.
export interface JsonRpcErrorObject {
code: number;
@@ -34,18 +33,27 @@ export type NotificationHandler = (params: unknown) => void;
/** Every packet in both directions, in order; lines that aren't JSON arrive as `in-text`. */
export type PacketRecorder = (direction: "out" | "in" | "in-text", packet: unknown) => void;
/** One JSON-RPC connection over a pair of streams: incoming lines on `input`, outgoing packets written to `output`. */
export class JsonRpcPeer {
export class StdioJsonRpcPeer {
private nextId = 0;
private readonly pending = new Map<JsonRpcId, { method: string; resolve: (value: unknown) => void; reject: (error: Error) => void }>();
private readonly requestHandlers = new Map<string, IncomingRequestHandler>();
private readonly notificationHandlers = new Map<string, NotificationHandler>();
private ended = false;
protected readonly lines: ReturnType<typeof createInterface>;
private readonly lines: ReturnType<typeof createInterface>;
readonly exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
constructor(input: NodeJS.ReadableStream, private readonly output: NodeJS.WritableStream, private readonly record: PacketRecorder = () => {}) {
this.lines = createInterface({ input });
constructor(private readonly child: ChildProcessWithoutNullStreams, private readonly record: PacketRecorder = () => {}) {
this.lines = createInterface({ input: child.stdout });
this.lines.on("line", (line) => this.receive(line));
this.exited = new Promise((resolve) => child.once("close", (code, signal) => {
this.ended = true;
for (const [id, request] of this.pending) {
this.pending.delete(id);
request.reject(new JsonRpcTransportClosed(request.method));
}
resolve({ code, signal });
}));
child.stdin.on("error", () => { /* EPIPE after the agent exits; pending requests reject on close */ });
}
request(method: string, params: unknown): Promise<unknown> {
@@ -69,18 +77,25 @@ export class JsonRpcPeer {
this.notificationHandlers.set(method, handler);
}
/** The connection is gone: nothing more is sent, and every request still waiting rejects as closed. */
protected close(): void {
this.ended = true;
for (const [id, request] of this.pending) {
this.pending.delete(id);
request.reject(new JsonRpcTransportClosed(request.method));
}
/** Close the agent's stdin (detach) and wait for the process to exit, up to `timeoutMs`. */
async end(timeoutMs: number): Promise<{ code: number | null; signal: NodeJS.Signals | null } | "still-running"> {
this.child.stdin.end();
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<"still-running">((resolve) => { timer = setTimeout(() => resolve("still-running"), timeoutMs); });
try { return await Promise.race([this.exited, timeout]); } finally { clearTimeout(timer); }
}
/** Drop this side's hold on the agent (its pipes and process handle) without signalling it, so a detached agent that
* keeps running doesn't keep the caller's process alive. */
release(): void {
this.lines.close();
for (const stream of [this.child.stdin, this.child.stdout, this.child.stderr]) stream.destroy();
this.child.unref();
}
private send(packet: Record<string, unknown>): void {
this.record("out", packet);
this.output.write(`${JSON.stringify(packet)}\n`);
this.child.stdin.write(`${JSON.stringify(packet)}\n`);
}
private receive(line: string): void {
@@ -112,7 +127,7 @@ export class JsonRpcPeer {
private async answer(method: string, id: JsonRpcId, params: unknown): Promise<void> {
const handler = this.requestHandlers.get(method);
if (!handler) {
this.send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } });
this.send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Client does not handle ${method}` } });
return;
}
try {
@@ -120,48 +135,7 @@ export class JsonRpcPeer {
if (!this.ended) this.send({ jsonrpc: "2.0", id, result });
} catch (error) {
const code = typeof (error as { code?: unknown }).code === "number" ? (error as { code: number }).code : -32603;
const data = (error as { data?: unknown }).data;
if (!this.ended) this.send({ jsonrpc: "2.0", id, error: { code, message: (error as Error).message, ...(data !== undefined ? { data } : {}) } });
if (!this.ended) this.send({ jsonrpc: "2.0", id, error: { code, message: (error as Error).message } });
}
}
}
/** The client end of an ACP agent process OpenRig launched. */
export class StdioJsonRpcPeer extends JsonRpcPeer {
readonly exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
constructor(private readonly child: ChildProcessWithoutNullStreams, record: PacketRecorder = () => {}) {
super(child.stdout, child.stdin, record);
this.exited = new Promise((resolve) => child.once("close", (code, signal) => {
this.close();
resolve({ code, signal });
}));
child.stdin.on("error", () => { /* EPIPE after the agent exits; pending requests reject on close */ });
}
/** Close the agent's stdin (detach) and wait for the process to exit, up to `timeoutMs`. */
async end(timeoutMs: number): Promise<{ code: number | null; signal: NodeJS.Signals | null } | "still-running"> {
this.child.stdin.end();
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<"still-running">((resolve) => { timer = setTimeout(() => resolve("still-running"), timeoutMs); });
try { return await Promise.race([this.exited, timeout]); } finally { clearTimeout(timer); }
}
/** Drop this side's hold on the agent (its pipes and process handle) without signalling it, so a detached agent that
* keeps running doesn't keep the caller's process alive. */
release(): void {
this.lines.close();
for (const stream of [this.child.stdin, this.child.stdout, this.child.stderr]) stream.destroy();
this.child.unref();
}
}
/** The agent end over this process's stdin and stdout (or any stream pair): `closed` settles when the input ends. */
export class StreamJsonRpcPeer extends JsonRpcPeer {
readonly closed: Promise<void>;
constructor(input: NodeJS.ReadableStream, output: NodeJS.WritableStream, record: PacketRecorder = () => {}) {
super(input, output, record);
this.closed = new Promise((resolve) => this.lines.once("close", () => { this.close(); resolve(); }));
}
}
+1 -2
View File
@@ -12,8 +12,7 @@ const REEXEC_GUARD_ENV = "OPENRIG_BIN_REEXEC";
// Each `rig` is a new process: Node's on-disk compile cache lets the next call skip compiling the modules
// again. The cache dates from Node v22.1.0, but this JavaScript API from v22.8.0: before it, `?.()` does
// nothing. NODE_COMPILE_CACHE and NODE_DISABLE_COMPILE_CACHE still control it.
// A piped bare invocation only prints usage; cold-cache writes can otherwise delay its exit.
if (process.argv.length > 2 || (process.stdin.isTTY && process.stdout.isTTY)) module.enableCompileCache?.();
module.enableCompileCache?.();
export function resolveBinEntry(invokedPath = process.argv[1], moduleUrl = import.meta.url): string {
const wrapperPath = invokedPath ? realpathSync(invokedPath) : realpathSync(fileURLToPath(moduleUrl));
+2 -183
View File
@@ -4,15 +4,11 @@ import { randomBytes } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { DaemonClient, type DaemonResponse } from "../client.js";
import { daemonMayAnswer, daemonStatusGuard, getDaemonStatus, getDaemonUrl, statusGuardMessage } from "../daemon-lifecycle.js";
import { daemonStatusGuard, getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { processIdentity } from "../local-daemon-owner.js";
import { getOpenRigHome, readOpenRigEnv } from "../openrig-compat.js";
import { answerCommand, runAcpAssignment, type AcpQueue } from "../acp/connector.js";
import { findRunProcesses } from "../acp/run-processes.js";
import { serveAcpAgent, SESSION_TAG, TeamRefused, type TeamAsk, type TeamConnection, type TeamQueue } from "../acp/agent-server.js";
import { StreamJsonRpcPeer } from "../acp/json-rpc-stdio.js";
import { CLI_VERSION } from "../version.js";
import { ConfigStore } from "../config-store.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -48,127 +44,6 @@ export function daemonAcpQueue(client: DaemonClient): AcpQueue {
};
}
/** rig acp serve's queue verbs over the daemon API, acting as the caller the client was built for. */
export function daemonTeamQueue(client: DaemonClient): TeamQueue {
const ok = <T>(response: DaemonResponse<T>, what: string): T => {
if (response.status >= 400) throw new Error(`${what} returned HTTP ${response.status}: ${JSON.stringify(response.data)}`);
return response.data;
};
const face = (data: Record<string, unknown>) => {
const row = (data.item ?? data) as Record<string, unknown>;
const text = (value: unknown) => (typeof value === "string" ? value : null);
return {
qitemId: String(row.qitemId), state: String(row.state), destinationSession: String(row.destinationSession),
handedOffTo: text(row.handedOffTo), closureReason: text(row.closureReason), closureTarget: text(row.closureTarget), resolution: text(row.resolution),
closer: text((row.closingTransition as { actorSession?: unknown } | null | undefined)?.actorSession),
};
};
const list = async (query: string, what: string): Promise<Array<Record<string, unknown>>> => {
const data = ok(await client.get<unknown>(`/api/queue/list?${query}`), what);
return (Array.isArray(data) ? data : ((data as { items?: unknown[] }).items ?? [])) as Array<Record<string, unknown>>;
};
/** The rows that ask the person for a decision (questions with choices, or a decision in words). */
const asks = (rows: Array<Record<string, unknown>>): TeamAsk[] =>
rows.filter((row) => row.humanIntent !== "update" && (Array.isArray(row.humanQuestions) || row.humanIntent === "decision")).map((row) => ({
qitemId: String(row.qitemId), sourceSession: String(row.sourceSession), state: String(row.state), tsCreated: String(row.tsCreated),
summary: typeof row.summary === "string" ? row.summary : null, body: typeof row.body === "string" ? row.body : "",
tags: Array.isArray(row.tags) ? (row.tags as string[]) : [],
questions: Array.isArray(row.humanQuestions) ? (row.humanQuestions as TeamAsk["questions"]) : [],
// A click records the option's id; a typed reply records its words.
answers: Object.fromEntries(Object.entries((row.humanAnswers as Record<string, unknown> | null) ?? {})
.map(([id, value]) => [id, typeof value === "string" ? value : String((value as { text?: unknown } | null)?.text ?? "")])),
}));
return {
async create(row) {
const response = await client.post("/api/queue/create", { qitemId: row.qitemId, destinationSession: row.destination, body: row.body, summary: row.summary, tags: row.tags });
// A 4xx is the daemon's answered refusal, with nothing written; a 5xx leaves the write unknown, like no answer.
if (response.status >= 400 && response.status < 500) throw new TeamRefused(`HTTP ${response.status}: ${JSON.stringify(response.data)}`);
ok(response, `queue create for ${row.destination}`);
},
async show(qitemId) {
return face(ok(await client.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`), `queue show ${qitemId}`));
},
async find(qitemId) {
const response = await client.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`);
if (response.status === 404) return null;
return face(ok(response, `queue show ${qitemId}`));
},
async transitions(qitemId) {
const data = ok(await client.get<Array<Record<string, unknown>>>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`), `queue transitions ${qitemId}`);
return data.map((t) => ({
transitionId: Number(t.transitionId), ts: String(t.ts), state: String(t.state),
transitionNote: typeof t.transitionNote === "string" ? t.transitionNote : null, actorSession: String(t.actorSession),
}));
},
async successor(qitemId) {
return (await list(`handedOffFrom=${encodeURIComponent(qitemId)}&limit=1`, `queue successor of ${qitemId}`))[0]?.qitemId as string | undefined;
},
async openAsks(person) {
return asks(await list(`destinationSession=${encodeURIComponent(person)}&state=pending&limit=500`, `open asks to ${person}`));
},
async sessionAsks(person, sessionId) {
const rows = await list(`destinationSession=${encodeURIComponent(person)}&limit=2000`, `rows to ${person}`);
return asks(rows.filter((row) => Array.isArray(row.tags) && (row.tags as string[]).includes(`${SESSION_TAG}${sessionId}`)));
},
async answer(qitemId, questionId, optionId) {
// The person's answer path, as a click in Slack records one: the daemon takes the actor from this client's caller.
const bearer = process.env.OPENRIG_AUTH_BEARER_TOKEN;
const response = await client.post<Record<string, unknown>>("/api/mission-control/action", { verb: "answer", qitemId, questionId, optionId },
bearer ? { headers: { Authorization: `Bearer ${bearer}` } } : undefined);
if (response.status < 400) return { ok: true };
const data = (response.data ?? {}) as { message?: unknown; error?: unknown };
return { ok: false, reason: String(data.message ?? data.error ?? `HTTP ${response.status}`) };
},
async sent(caller) {
const rows = await list(`sourceSession=${encodeURIComponent(caller)}&limit=2000`, `rows from ${caller}`);
return rows.filter((row) => Array.isArray(row.tags) && (row.tags as string[]).includes("acp-serve")).map((row) => ({
qitemId: String(row.qitemId), tsCreated: String(row.tsCreated), tsUpdated: String(row.tsUpdated),
summary: typeof row.summary === "string" ? row.summary : null, body: typeof row.body === "string" ? row.body : "", tags: row.tags as string[],
}));
},
};
}
/** The seat that takes this agent's tasks: --to, else this machine's acp.to setting (an ACP registry launch has no args).
* A setting that can't be read comes back as the reason, for the app's sign-in step to show. */
export function acpReceiver(to: string | undefined, store: Pick<ConfigStore, "get"> = new ConfigStore()): { receiver?: string } | { error: string } {
if (to?.trim()) return { receiver: to.trim() };
try {
return { receiver: String(store.get("acp.to") ?? "").trim() || undefined };
} catch (error) {
return { error: `${(error as Error).message} The agent reads acp.to from it; adding --to <member@rig> to the agent's command skips it.` };
}
}
/** Why the team's questions can't reach `address`, when it's a person this machine's OpenRig doesn't know. Says nothing
* for a seat's address, a registered person, or a registry that can't be read (unknown isn't unregistered). */
export async function unregisteredPersonNotice(address: string, home?: string): Promise<string | undefined> {
const suffix = "@external";
if (!address.endsWith(suffix)) return undefined;
try {
const registry = await import("@openrig/daemon/gateway-human-registry");
const people = registry.projectHumans(home);
if (!people.ok || people.entities.some((person) => person.address === address)) return undefined;
} catch {
return undefined;
}
const id = address.slice(0, -suffix.length);
return `Your team can't ask you questions here yet: ${address} isn't a registered person on this machine, so OpenRig refuses a question addressed to it. Your work is still sent. To be asked, register yourself with \`rig gateway human add ${id} --display-name "<your name>" --delivery-class <A|B|C|D>\`.`;
}
/** The person this machine's OpenRig names as its default (`<id>@external`), or why there isn't one. */
async function defaultPersonAddress(): Promise<{ address: string } | { reason: string }> {
try {
const registry = await import("@openrig/daemon/gateway-human-registry");
const people = registry.projectHumans();
if (!people.ok) return { reason: people.error };
const chosen = registry.resolveDefaultPerson(people.entities);
return chosen.person ? { address: chosen.person.address } : { reason: chosen.source === "none" ? chosen.reason : "no default person" };
} catch (error) {
return { reason: (error as Error).message };
}
}
function readRun(runId: string): RunFile | undefined {
try { return JSON.parse(readFileSync(join(runsDir(), `${runId}.json`), "utf8")) as RunFile; } catch { return undefined; }
}
@@ -187,7 +62,7 @@ function liveConnector(run: RunFile): boolean {
}
export function acpCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("acp").description("ACP (Agent Client Protocol): hand a queue row's task to an ACP agent, or serve OpenRig as an agent to ACP apps");
const cmd = new Command("acp").description("Hand a queue row's task to an ACP (Agent Client Protocol) agent and keep its outcome on the row");
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
const connect = async (): Promise<DaemonClient | undefined> => {
const deps = getDeps();
@@ -260,62 +135,6 @@ Guide: rig context get reference/acp-agents.md`)
}
});
cmd
.command("serve")
.description("Serve OpenRig as an ACP agent on stdio, for an ACP app (Zed and others) to launch: its prompts go to your team as queue rows")
.option("--to <member@rig>", "The seat that receives your tasks: your team's entry point (default: rig config get acp.to)")
.option("--as <address>", "Who the tasks come from (default: OPENRIG_SESSION_NAME, else this machine's default person)")
.option("--poll-seconds <n>", "How often to read the work's progress while a turn is open", "2")
.addHelpText("after", `
An ACP app launches this command and talks to it on stdin and stdout. Each prompt becomes a queue row from you to
--to. The app shows the row's notes and handoffs as progress, and the closing resolution as the reply. A later
prompt in the same session goes to --to as a follow-up that names the earlier items. A question the working seats
ask you meanwhile comes to the app as a choice, recorded as your answer. The app can list and reopen your sessions.
The turn ends when the team closes the item, or when you cancel. Cancelling stops this process waiting; the team's
work carries on. A turn that ended doesn't mean the work was accepted: the reply says how the item was closed.
Without --to, the receiver is the acp.to setting (rig config set acp.to <member@rig>), which is how an app that
starts the agent from the ACP registry, with no arguments, reaches your team.
It uses the same OpenRig daemon as rig: the one on this computer, started with rig daemon start, unless OPENRIG_URL
names another. The app's sign-in step ("OpenRig on this machine") checks that the daemon answers and that you
resolve to an address: --as, OPENRIG_SESSION_NAME, or the default person: the only one registered
(rig gateway human add <id> ...), or with several, the one chosen with rig gateway default <id>.
In Zed's settings.json:
"agent_servers": { "OpenRig": { "type": "custom", "command": "rig", "args": ["acp", "serve", "--to", "lead@my-team"] } }
Guide: rig context get reference/acp-agents.md#openrig-as-an-agent-in-acp-apps`)
.action(async (opts: { to?: string; as?: string; pollSeconds: string }) => {
// Only ACP messages may reach stdout: anything a helper prints goes to stderr instead.
console.log = console.error;
console.info = console.error;
process.stdout.on("error", () => { /* EPIPE once the app has gone; the input closing ends the server */ });
const connectTeam = async (): Promise<TeamConnection | { error: string }> => {
const status = await getDaemonStatus(getDeps().lifecycleDeps);
if (!daemonMayAnswer(status)) {
const err = statusGuardMessage(status);
return { error: `${err.fact} ${err.action}` };
}
let caller = opts.as?.trim() || self()?.trim();
if (!caller) {
const person = await defaultPersonAddress();
if ("reason" in person) return { error: `OpenRig doesn't know who you are on this machine (${person.reason}). Register yourself with rig gateway human add <id> --display-name "<your name>" --delivery-class <A|B|C|D> (the only registered person is the default; with several, choose one with rig gateway default <id>), or add --as <address> to the agent's command.` };
caller = person.address;
}
const target = acpReceiver(opts.to);
if ("error" in target) return target;
return { queue: daemonTeamQueue(new DaemonClient(getDaemonUrl(status), { sessionName: caller })), caller, receiver: target.receiver, notice: await unregisteredPersonNotice(caller) };
};
const peer = new StreamJsonRpcPeer(process.stdin, process.stdout);
serveAcpAgent(peer, {
connect: connectTeam, version: CLI_VERSION, pollMs: Math.max(1, Number(opts.pollSeconds) || 2) * 1000,
log: (line) => console.error(`[rig acp serve] ${line}`), closed: peer.closed,
});
await peer.closed;
});
cmd
.command("cancel <runId>")
.description("Ask a running rig acp run to cancel its agent's turn (the same as Ctrl-C there)")
+1 -1
View File
@@ -62,7 +62,7 @@ export function attachCommand(depsOverride?: StatusDeps): Command {
.option("--member <name>", "Member name for pod attach mode")
.option("--runtime <runtime>", "Runtime for pod attach mode; optional guard for node mode")
.option("--cwd <path>", "Working directory to record", process.cwd())
.option("--display-name <name>", "Display label inside tmux; external session address outside tmux (new bare names gain @rig)")
.option("--display-name <name>", "Display label inside tmux; external session name outside tmux")
.option("--print-env", "Print shell exports for OPENRIG_NODE_ID and OPENRIG_SESSION_NAME")
.option("--json", "JSON output, including the activity env the attached app needs. Its token is a credential: don't print or paste it, and use rig whoami for identity")
.action(async (opts: {
+2 -14
View File
@@ -55,20 +55,8 @@ manual request. Cancel an active preparation before retrying or using --skip-map
Automatic preparation allows 25 minutes from prep delivery for the map. A completed
map stays armed for idle through the next retry interval (10 minutes by default),
without another preparation prompt; --state includes idleDeadlineAt. A completed
automatic map whose /compact is refused before input (a draft in the box, the pane in
a tmux mode) or that never reaches idle in that time is held instead: --state shows
prepared with preparedAt and preparedReason, and /compact goes in at the next idle
moment with an empty box, with the same map and no new preparation prompt. A held
attempt stops on a fresh sample below the threshold or from another conversation
(below_threshold_while_prepared), once usage has risen 15 points since its request
or two hours after its map was ready (prepared_map_stale), or when its map no longer
reads ready (preparation_incomplete).
After an attempt stops with its request delivered, the next automatic request for the
same conversation waits for an idle seat, across a daemon restart too, for up to the
restart-recovery window (6 hours by default) after that attempt stopped. An unfinished
map reports preparation_incomplete. Manual requests still
share one 180-second budget and stop as before; while an attempt is held, a manual
request reads already_in_progress until --cancel.
map that never reaches idle reports map_complete_seat_never_idle; an unfinished
map reports preparation_incomplete. Manual requests still share one 180-second budget.
Stopped attempts may retry automatically after the cooldown; cancellation does not.
Cancellation disarms future input; it cannot undo a command already submitted. Raw provider /compact and normal messages are unchanged.`);
+6 -33
View File
@@ -11,7 +11,7 @@ function summarizeSettings(store: ConfigStore): Record<ValidKey, ResolvedSetting
}
// These settings remain boot-only; capture interval/line settings reload live.
const BOOT_ONLY_KEYS = ["daemon.port", "daemon.host", "daemon.db_conflict", "daemon.seat_poll_interval_ms", "db.path", "terminal.tmux_server"];
const BOOT_ONLY_KEYS = ["daemon.port", "daemon.host", "daemon.db_conflict", "db.path", "terminal.tmux_server"];
const BOOT_ONLY_PREFIXES = ["transcripts.enabled", "transcripts.path"];
function isBootOnlyKey(key: string): boolean {
@@ -55,7 +55,7 @@ Examples:
rig config init-workspace # scaffold a repo-ready project workspace
Keys:
daemon.* port, host, seat_poll_interval_ms (startup-only seat-pollers cadence, 1000–2000; default 1000)
daemon.* port, host
db.path
transcripts.* enabled, path, lines, poll_interval_seconds
(lines/poll_interval set scrollback CAPTURE depth + cadence.
@@ -103,10 +103,6 @@ Keys:
an unreadable registry does not suppress eligibility; delivery still needs a ready route)
retention.* enabled, transitions_days, watchdog_days,
watchdog_keep_per_job, batch_size
retention.events_days delete the history-only event types (views, snapshots, watchdog firings, queue events) older
than N days; 0 keeps them (default). Removed events no longer replay from /api/events, leave
the telemetry export, the log and the feed, and can't be brought back. Read at the next sweep
(daily and at daemon start)
launch.non_interruptive accept harness first-launch warnings at full bypass for new rigs (default off)
launch.codex_login_shell defer to Codex's own login-shell setting in managed Codex seats (default off)
Off adds allow_login_shell=false: tool commands inherit the seat's environment, so plain rig
@@ -134,13 +130,10 @@ Keys:
usage.pi_openrouter_charge with usage.pi_cost_facts on, Pi seats look up OpenRouter's actual charge for their requests, shown beside the estimate (default off; changes take effect when a Pi seat next launches or resumes; earlier outcomes stay visible)
transport.verify_input_box with --verify, a send to a Claude Code or Codex seat (also queue wakes) reads verified only when the seat was proven idle before the paste and its input box showed the exact text before Enter and was empty after it, on the same pane process; a busy or unknown seat, and anything else, reads unconfirmed, not failed. Reporting only (default off)
transport.local_user_guard on a Mac, the CLI refuses a local daemon whose reported user, checked with ps, isn't yours; an explicit OPENRIG_URL skips the check. A mitigation for reaching the wrong daemon, not authentication (default off)
transport.draft_delivery deliver (default): a rig send or work notice to a seat whose prompt holds a person's draft goes through: the message is pasted at the current cursor into the draft (normally its end), then Enter is sent to submit the combined input, and the send's result says whether that was confirmed. OpenRig can't read every prompt layout: where it doesn't detect the draft, the result doesn't mention it, and refuse doesn't stop the send. refuse: such a send is refused, and a work notice retried once the prompt is clear, where OpenRig detects the draft. A draft found only by reading the input box may leave that retry waiting for the seat's next activity change or the stuck sweep, rather than the moment the draft clears. Either way, menus and permission prompts are refused, a per-seat typing guard still holds, managed compaction waits for an empty prompt, and a send with --wait-for-idle still waits
decisions.enabled typed decisions (OpenRouter + Jev) for the consumers in decisions.consumers, with OPENROUTER_API_KEY from the
rig process that runs them; off makes no provider calls (default off). rig project decisions status shows the state
decisions.consumers comma-separated consumers that may call (supported: stream; default none). An unknown name makes no calls
decisions.template_dir absolute folder of <name>.yaml templates that replace the shipped ones (default empty: shipped templates only)
acp.to the seat (member@rig) that receives tasks from rig acp serve when it isn't given --to, as when an ACP app
starts it from the ACP registry (default empty: none, so nothing is sent until one is set)
Precedence: CLI flag > environment variable > config file > default`)
.action((opts: { json?: boolean; withSource?: boolean }) => {
@@ -207,17 +200,10 @@ Precedence: CLI flag > environment variable > config file > default`)
.action(async (key: string, value: string) => {
try {
store.set(key, value);
const resolved = store.resolveWithSource(key);
console.log(resolved.source === "env"
? `${key} = ${resolved.value} (source: env; file override saved but masked)`
: resolved.source === "default"
? `${key} file override saved; effective value ${resolved.value} (source: default)`
: `${key} = ${resolved.value}`);
console.log(`${key} = ${store.get(key)}`);
// SWEEP-c: boot-only key + running daemon = stale-until-restart; say so loudly.
if (isBootOnlyKey(key) && (await probeDaemonRunning())) {
console.error(resolved.source === "env"
? `note: '${key}' is read at daemon BOOT — the running daemon keeps its current value; the saved value remains masked after restart while the environment override is set.`
: `note: '${key}' is read at daemon BOOT — the running daemon keeps its current value; this takes effect on the next daemon restart (rig daemon stop && rig daemon start).`);
console.error(`note: '${key}' is read at daemon BOOT — the running daemon keeps its current value; this takes effect on the next daemon restart (rig daemon stop && rig daemon start).`);
if (key === "terminal.tmux_server") console.error("note: surviving sessions keep their previous server until deliberately stopped; rig terminal status shows the effective selection and any pending migration.");
}
} catch (err) {
@@ -233,22 +219,9 @@ Precedence: CLI flag > environment variable > config file > default`)
try {
store.reset(key);
if (key) {
const resolved = store.resolveWithSource(key);
console.log(resolved.source === "env"
? `${key} file override cleared; effective value ${resolved.value} (source: env; default: ${resolved.defaultValue}).`
: `${key} reset to default (${resolved.value}).`);
console.log(`${key} reset to default (${store.get(key)}).`);
} else {
let envKeys: string[] = [];
try {
envKeys = Object.entries(store.resolveAllWithSource())
.filter(([, resolved]) => resolved.source === "env")
.map(([name]) => name);
} catch (err) {
console.error(`note: could not read effective configuration after reset: ${(err as Error).message}`);
}
console.log(envKeys.length
? `Config reset to defaults, except where the environment overrides them: ${envKeys.join(", ")}.`
: "Config reset to defaults.");
console.log("Config reset to defaults.");
}
} catch (err) {
console.error((err as Error).message);
@@ -1,69 +0,0 @@
import { Command } from "commander";
import { join, resolve } from "node:path";
import { ConfigStore } from "../config-store.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { resolveWorkContextHome } from "../lib/work-install.js";
import { ReconstructionError, readReconstruction, readStoreMappings, reconstructionOutput } from "../lib/reconstruction-reader.js";
interface Options {
store?: string; storeMap?: string; project?: string; mission?: string; slice?: string; cwd?: string;
item: string[]; category: string[]; tag: string[]; checkSources?: boolean; maxBytes: string; json?: boolean;
}
const collect = (value: string, previous: string[]) => [...previous, value];
/** A local config/filesystem consumer. This command never starts or contacts a
* daemon, invokes a helper, writes revisions, or takes over the selected work. */
export function reconstructionCommand(): Command {
const cmd = new Command("reconstruction")
.description("Inspect or read an exact immutable reconstruction locally (read-only v0 consumer)");
for (const mode of ["inspect", "view"] as const) {
cmd.command(`${mode} <revision>`)
.description(mode === "inspect" ? "Inspect provenance, dates, size and the complete source manifest" : "Read a faithful selected view with dependencies and explicit omissions")
.option("--store <absolute-path>", "Explicit local v0/import store; default: selected work home/reconstructions")
.option("--store-map <file>", "JSON array of {sourceStore, localStore, sourceHost?}; read-time mapping only")
.option("--project <id>", "Catalog project, with the same inference as context work-install")
.option("--mission <id>", "Mission in the selected project's configured missions root")
.option("--slice <id>", "Slice under the selected mission")
.option("--cwd <path>", "Working directory used for project inference")
.option("--item <id>", "Include item (repeatable; selectors form a union)", collect, [])
.option("--category <name>", "Include category (repeatable)", collect, [])
.option("--tag <name>", "Include tag (repeatable)", collect, [])
.option("--check-sources", "Hash declared local files within 128 files / 64 MiB; never refresh observation dates")
.option("--max-bytes <bytes>", "Maximum complete output, 1024..8388608 UTF-8 bytes", "262144")
.option("--json", "Structured output; inspect always uses JSON")
.addHelpText("after", "\nSupports openrig.reconstruction/v0 with safe integer JSON numbers; other numeric representations are explicitly unsupported. No implicit remote reads. Context does not transfer authority.\n")
.action((revision: string, opts: Options) => {
try {
let store = opts.store;
let resolution: object = { mode: "explicit-store", project: null, home: null };
if (store === undefined || opts.project !== undefined || opts.mission !== undefined || opts.slice !== undefined) {
const config = new ConfigStore();
const home = resolveWorkContextHome({
workspaceRoot: String(config.resolveWithSource("workspace.root").value),
catalogPath: String(config.resolveWithSource("workspace.catalog_path").value),
cwd: resolve(opts.cwd ?? process.cwd()),
...(readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME") ? { sessionName: readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")! } : {}),
...(opts.project === undefined ? {} : { project: opts.project }),
...(opts.mission === undefined ? {} : { mission: opts.mission }),
...(opts.slice === undefined ? {} : { slice: opts.slice }),
});
if ("error" in home) throw new ReconstructionError(home.error.code, home.error.message);
resolution = { mode: store === undefined ? "work-home" : "explicit-store", ...home };
store ??= join(home.home, "reconstructions");
}
const result = readReconstruction({ store, revision,
...(opts.storeMap ? { mappings: readStoreMappings(resolve(opts.storeMap)) } : {}),
items: opts.item, categories: opts.category, tags: opts.tag, checkSources: opts.checkSources });
const response = { ...result, resolution };
console.log(reconstructionOutput(response, mode, Boolean(opts.json), Number(opts.maxBytes)));
if (!result.complete) process.exitCode = 1;
} catch (error) {
const code = error instanceof ReconstructionError ? error.code : "reconstruction_unavailable";
if (opts.json || mode === "inspect") console.log(JSON.stringify({ complete: false, error: { code, message: (error as Error).message } }, null, 2));
else console.error(`${code}: ${(error as Error).message}`);
process.exitCode = 1;
}
});
}
return cmd;
}
-3
View File
@@ -43,7 +43,6 @@ import { realDeps } from "./daemon.js";
import { prepareDaemonAutoStart } from "../daemon-auto-start.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import type { StatusDeps } from "./status.js";
import { reconstructionCommand } from "./context-reconstruction.js";
const contextRuntimeArg = enumArg(["claude-code", "claude", "codex"]);
@@ -253,8 +252,6 @@ Examples:
rig context trace --rig factory --pod dev --seat dev-qa --name LEARNED.md
`);
cmd.addCommand(reconstructionCommand());
const getDeps = (): StatusDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
+2 -4
View File
@@ -57,12 +57,10 @@ export function createProcessLiveness(deps: {
readProcessState: (pid: number) => string | null;
}): (pid: number) => ProcessLiveness {
return (pid: number) => {
const signal = deps.signal(pid);
if (signal === "missing") return "dead";
if (deps.signal(pid) === "missing") return "dead";
const state = deps.readProcessState(pid)?.trim();
if (!state) return "unknown";
if (state.startsWith("Z")) return "dead";
return signal === "not-permitted" ? "unknown" : "alive";
return state.startsWith("Z") ? "dead" : "alive";
};
}
-61
View File
@@ -375,72 +375,11 @@ export function runDoctorChecks(deps: DoctorDeps, providerChecks: DoctorCheck[]
asyncChecks.push(daemonCmuxCheck);
}
asyncChecks.push(buildTmuxControlModeCheck(deps, daemonBase));
asyncChecks.push(buildSpecConformanceCheck(deps));
return { checks, portCheck, asyncChecks };
}
type TmuxControlReport = { requested?: boolean; state?: "on" | "allowed" | "off" | "undecided"; serverVersion?: string };
/**
* #161 — does the daemon drive tmux through one control-mode client (OPENRIG_TMUX_CONTROL=1)? Asked for on a server
* older than tmux 3.7c, it uses a spawned client for every command, and nothing else says so.
*/
async function buildTmuxControlModeCheck(deps: DoctorDeps, daemonBase: string): Promise<DoctorCheck> {
const name = "tmux_control_mode";
let control = null as TmuxControlReport | null;
try {
await fetchWithTimeout((deps.fetch ?? globalThis.fetch) as typeof fetch, `${daemonBase}/api/adapters/tmux/control`, {}, {
timeoutMs: deps.ownerReportTimeoutMs ?? DOCTOR_HTTP_TIMEOUT_MS,
timeoutMessage: "Doctor's tmux control mode read timed out.",
consumeResponse: async res => {
if (res.ok && res.json) control = (await res.json()) as TmuxControlReport;
else await res.body?.cancel();
},
});
} catch { /* no daemon, or one from before this route */ }
if (!control) {
return { name, status: "skipped", message: "The daemon didn't say whether it uses tmux control mode (it isn't running, or it's older than this check)." };
}
if (!control.requested) {
if ((deps.env ?? process.env)["OPENRIG_TMUX_CONTROL"] !== "1") {
return { name, status: "skipped", message: "tmux control mode isn't asked for (OPENRIG_TMUX_CONTROL=1 in the daemon's environment)." };
}
return {
name, status: "warn",
message: "OPENRIG_TMUX_CONTROL=1 is set in this shell, but the running daemon wasn't started with it, so it spawns a tmux client for every command.",
fix: "Set OPENRIG_TMUX_CONTROL=1 in the daemon's own environment (the shell or service that starts it), then restart the daemon.",
};
}
const version = control.serverVersion;
if (control.state === "on") {
return { name, status: "pass", message: `tmux control mode is on: the daemon's tmux server runs tmux ${version}.` };
}
if (control.state === "undecided") {
return { name, status: "skipped", message: "tmux control mode is asked for, and the daemon hasn't read its tmux server's version yet. It does at its first tmux command while a session runs." };
}
if (control.state === "allowed") {
return {
name, status: "warn",
message: `tmux control mode is asked for and the server's tmux ${version} allows it, but no control client is connected right now, so tmux commands use a spawned client.`,
reason: "The daemon starts a control client at a tmux command while another session runs on the server. One that can't start or attach is tried again after a while, and commands are spawned meanwhile.",
};
}
if (!version) {
return { name, status: "warn", message: "tmux control mode is asked for but this daemon doesn't use a control client: it runs on Windows (psmux) or with OPENRIG_TMUX_SHELL=1." };
}
const known = /^\d+\.\d+[a-z]?$/.test(version);
return {
name, status: "warn",
message: known
? `tmux control mode is asked for but off: the daemon's tmux server runs tmux ${version}, and control mode needs tmux 3.7c or later.`
: `tmux control mode is asked for but off: the daemon's tmux server runs tmux ${version}, which isn't a tmux release known to be 3.7c or later.`,
reason: "Starting a control client can crash a tmux server older than 3.7c, so the daemon spawns a tmux client for every command instead. The running server's version decides this, not the tmux binary on PATH.",
fix: "Run the daemon's sessions on a tmux 3.7c or later server: rig context get reference/tmux-servers.md#moving-an-existing-team-onto-tmux-control-mode",
};
}
/**
* Build B — does the rig spec still describe the rig that is running?
*
+7 -15
View File
@@ -175,7 +175,7 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
.requiredOption("--display-name <name>", "Human-readable display name")
.option(
"--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; or file:<path> for relay: relay:<connectorRef>:file:<absolute-env-file>:primary:handle=<safe-alias>). Optional handle=<platform id> makes the binding inbound-resolvable (unique per kind for Slack, unique per connectorRef+handle for relay); omit it for outbound-only.",
"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.",
(v: string, acc: string[] = []) => { acc.push(v); return acc; },
)
.requiredOption("--delivery-class <A|B|C|D>", "Notification loudness class (the notifications register selection)")
@@ -253,24 +253,16 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
.addHelpText("after", `
Binding specification details:
Format: <kind:connectorRef:secretsRef:role[:handle=<id>]>
- kind: Connector kind: "slack" or "relay"
- connectorRef: Configuration label identifying the connector instance (e.g. "openrig-personal", "main", or "local-bridge")
- secretsRef: Pointer or label identifying where secrets are resolved:
* For Slack: "file:/path/to/slack.env" or "vault://slack/user". Credentials (like SLACK_BOT_TOKEN) are loaded from the connector's secrets env file (configured via 'rig slack setup --secrets-env-file').
* For Relay: "file:<absolute-env-file>" pointing to a chmod 600 file holding OPENRIG_RELAY_URL (HTTP loopback only, e.g. http://127.0.0.1:8088), OPENRIG_RELAY_OUTBOUND_TOKEN, and OPENRIG_RELAY_INBOUND_TOKEN (distinct non-empty values; no environment fallback).
NOTE: Never pass credentials or tokens inline here.
- kind: Connector kind (e.g. "slack")
- connectorRef: Configuration label identifying the connector instance (e.g. "openrig-personal" or "main")
- secretsRef: Pointer or label identifying where secrets are resolved (e.g. "file:/path/to/slack.env" or "vault://slack/user").
NOTE: Never pass credentials or tokens inline here; credentials (like SLACK_BOT_TOKEN) are loaded from the connector's secrets env file (configured via 'rig slack setup --secrets-env-file').
- role: "primary" (exactly one binding must be primary) or "secondary"
- handle: Optional platform user identifier (e.g. "handle=U12345678" for Slack, or "handle=<safe-alias>" matching /^[A-Za-z0-9._-]+$/ for relay) for inbound routing. For relay, handles are unique per connectorRef.
- handle: Optional platform user identifier (e.g. "handle=U12345678") for inbound routing
Examples:
# Slack binding
Example:
rig gateway human add alice --display-name "Alice" \\
--binding "slack:openrig-personal:file:/etc/openrig/slack.env:primary:handle=U12345678" \\
--delivery-class B
# Relay binding (loopback bridge adapter)
rig gateway human add bob --display-name "Bob" \\
--binding "relay:local-bridge:file:/etc/openrig/relay.env:primary:handle=bob-ops" \\
--delivery-class B`);
// ── S12 (OPR.0.5.5.12): the fragment lifecycle beyond add. Every verb operates through
+10 -16
View File
@@ -785,7 +785,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
.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")
.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; the request stays open that long on the target (a target older than this CLI keeps it 10 minutes)", "600")
.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)")
.option("--json", "JSON output")
.action(async (rawUrl: string, opts: { id?: string; timeout?: string; json?: boolean; human?: string }) => {
@@ -857,17 +857,11 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
try { return JSON.parse(s) as Record<string, unknown>; } catch { return {}; }
};
// The wait is BOUNDED: a non-finite or overflowing --timeout is clamped to
// MAX_PAIR_TIMEOUT_MS, so a target that stops answering can never spin the
// retry loop forever. Values that work today keep their wait. The target
// keeps the request open as long as this side waits, and its approval says so.
const timeoutMs = resolvePairTimeoutMs(opts.timeout);
let issued: { pairId?: string; code?: string; approvalQitemId?: string; error?: string; message?: string };
try {
const res = await httpPost(`${targetBase}/api/hosts/pair-request`, {
requester: `${userInfo().username}@${osHostname()}`,
...(opts.human ? { human: opts.human } : {}),
timeoutMs,
});
issued = parseJson(res.body) as typeof issued;
if (res.status !== 200 || !issued.pairId || !issued.code) {
@@ -887,25 +881,25 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
console.log(`On the target: rig queue update ${issued.approvalQitemId ?? "<qitem-id>"} --state done --closure-reason no-follow-on`);
}
// A note names the bound actually used so a clamped value is not silently absorbed.
// The wait is BOUNDED: a non-finite or overflowing --timeout is clamped to
// MAX_PAIR_TIMEOUT_MS, so a target that stops answering can never spin the
// retry loop forever. Values that work today keep their wait. A note names
// the bound actually used so a clamped value is not silently absorbed.
const timeoutMs = resolvePairTimeoutMs(opts.timeout);
if (!opts.json && timeoutMs === MAX_PAIR_TIMEOUT_MS) {
console.log(`Waiting up to ~${(MAX_PAIR_TIMEOUT_MS / 86_400_000).toFixed(1)} days for approval on the target (--timeout capped at the maximum).`);
}
// Poll at once, then every 2 s, never sleeping past the deadline: the target keeps the request open only as long
// as this side waits, so a short --timeout still sees an approval that's already there.
const deadline = Date.now() + timeoutMs;
let outcome: { status?: string; token?: string } = {};
for (;;) {
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 2000));
try {
const res = await deps.httpGet(`${targetBase}/api/hosts/pair-request/${issued.pairId}`);
outcome = parseJson(res.body) as typeof outcome;
if (outcome.status && outcome.status !== "pending") break;
} catch {
// transient poll failure: keep waiting until the deadline
continue; // transient poll failure: keep waiting until the deadline
}
const remaining = deadline - Date.now();
if (remaining <= 0) break;
await new Promise((r) => setTimeout(r, Math.min(2000, remaining)));
if (outcome.status && outcome.status !== "pending") break;
}
if (outcome.status !== "approved" || !outcome.token) {
+1 -1
View File
@@ -35,7 +35,7 @@ type LaunchResponse = {
// operator must act). These are NOT successful launches — never print "Launched" for
// them; exit non-zero. Mirrors the daemon's NON_RUNNING_LAUNCH_STATUSES.
const NON_RUNNING_LAUNCH_STATUSES = new Set(["awaiting-decision", "attention_required", "failed", "left_attached"]);
export function launchStatusRunning(status: string): boolean {
function launchStatusRunning(status: string): boolean {
return !NON_RUNNING_LAUNCH_STATUSES.has(status);
}
+6 -29
View File
@@ -2,7 +2,7 @@ import { Command } from "commander";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps , daemonStatusGuard} from "../daemon-lifecycle.js";
import { createMcpServer, dropUnfilledSeat, unfilledSeatText, type UnfilledSeat } from "../mcp-server.js";
import { createMcpServer } from "../mcp-server.js";
import { addGrant, McpHttpConfigError, mcpTokenFilePath, readGrantServiceHeader, readGrants, revokeGrant, startMcpHttpServer } from "../mcp-http.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { realDeps } from "./daemon.js";
@@ -25,20 +25,12 @@ export function mcpCommand(depsOverride?: StatusDeps): Command {
.option("--bind <host:port>", "With --http: where to listen (default 127.0.0.1:7437). Beyond loopback needs a grant (rig mcp token add)")
.option("--grant-service <url>", "With --http: check each request's key with the grant service at this URL instead of the token file")
.option("--grant-service-header-file <path>", "With --grant-service: a file holding one 'Name: value' header to send with each grant request")
.option("--task-destination <seat>", "With --http --grant-service: operator-configured local team seat for external requests that omit destination")
.option("--oauth-resource <url>", "With --grant-service and --oauth-issuer: the public https URL clients reach this server as (its path ends in /mcp), for OAuth discovery")
.option("--oauth-issuer <url>", "With --oauth-resource: the OAuth authorization server that issues the keys --grant-service checks")
.option("--front-door", "Serve a person's agent in their own app: it hands the team work and brings the team's questions to the person, instead of working its inbox as a member")
.addHelpText("after", `
Both MCP protocol revisions are served, over stdio and --http alike: clients on 2026-07-28 (which may start with
server/discover) and clients that open with 2025's initialize handshake.
An empty OPENRIG_SESSION_NAME, or one an app passed on unfilled (such as \${user_config.seat}), means no seat: the
server acts as none, never as a seat named after the placeholder or one inherited as RIGGED_SESSION_NAME. With no
seat, messages, new tasks and chat still go through as an unknown sender, each with a note on what is known (why there's
no seat, and how the MCP client describes itself); a task is followed by its qitemId, since there's no return address.
Claiming, updating, handing off or deciding a task needs a seat.
With --http and no token file, the server stays on loopback and acts as OPENRIG_SESSION_NAME, declared and not
authenticated, like stdio. Once a grant exists (rig mcp token add), every request needs its bearer credential, loopback
included, and each caller acts as its grant's seat within its rigs and roots. Add a grant before reaching the port
@@ -51,23 +43,14 @@ for this daemon's instance (its self id); the token file isn't read. A caller ac
must already exist here (created is enough; it needn't have been launched), and reaches only what the grant names. If
the grant service can't answer, the request is refused.
With --task-destination <member@rig>, external callers can send their first task without knowing a seat address.
Supply the intended receiver from trusted operator configuration for this server, never from a caller's input.
On every omitted destination, the receiver must be exactly one existing local node and within that request's grant.
Missing, ambiguous or forbidden receivers cause no write. This option doesn't grant access or select a receiver from
topology, names or task history. Explicit destinations keep their existing behavior; local and member calls still
require destination. A server with multiple teams needs a receiver valid for the calling grant, or explicit destinations.
With --grant-service, --oauth-resource and --oauth-issuer (both or neither) add OAuth discovery (RFC 9728) for clients
that sign in with OAuth. The resource is the public URL this server is reached as, and the issuer is the authorization
server that issues the keys --grant-service checks. GET /.well-known/oauth-protected-resource<resource path> then answers
with both, exactly as given, without a credential or a call to the grant service, and every 401 points there. Both must
be https with no userinfo, query or fragment, written as a URL writes them (a lowercase host, no default port). /mcp
stays the endpoint; a proxy in front forwards the metadata path unchanged.`)
.action(async (opts: { port?: string; http?: boolean; bind?: string; grantService?: string; grantServiceHeaderFile?: string; taskDestination?: string; oauthResource?: string; oauthIssuer?: string; frontDoor?: boolean }) => {
.action(async (opts: { port?: string; http?: boolean; bind?: string; grantService?: string; grantServiceHeaderFile?: string; oauthResource?: string; oauthIssuer?: string }) => {
const deps = getDepsF();
const unfilledSeat = dropUnfilledSeat();
if (unfilledSeat) console.error(`rig mcp serve: ${unfilledSeatText(unfilledSeat)}; serving with no seat until it names this app's address on the team.`);
let daemonUrl: string;
if (opts.port) {
@@ -97,11 +80,6 @@ stays the endpoint; a proxy in front forwards the metadata path unchanged.`)
process.exitCode = 1;
return;
}
if (opts.taskDestination !== undefined && !(opts.http && opts.grantService)) {
console.error("--task-destination needs --http --grant-service");
process.exitCode = 1;
return;
}
if ((opts.oauthResource === undefined) !== (opts.oauthIssuer === undefined)) {
console.error("--oauth-resource and --oauth-issuer go together: give both, or neither");
process.exitCode = 1;
@@ -114,7 +92,7 @@ stays the endpoint; a proxy in front forwards the metadata path unchanged.`)
return;
}
if (opts.http) {
await serveHttp(daemonUrl, opts.bind ?? "127.0.0.1:7437", opts.grantService, opts.grantServiceHeaderFile, oauth, opts.taskDestination, opts.frontDoor === true, unfilledSeat);
await serveHttp(daemonUrl, opts.bind ?? "127.0.0.1:7437", opts.grantService, opts.grantServiceHeaderFile, oauth);
return;
}
const client = deps.clientFactory(daemonUrl);
@@ -124,7 +102,7 @@ stays the endpoint; a proxy in front forwards the metadata path unchanged.`)
process.once("SIGINT", finish);
process.once("SIGTERM", finish);
// Serves the protocol revision the client opens with: 2026-07-28 per request, or 2025's initialize handshake.
const served = serveStdio(() => createMcpServer(client, { frontDoor: opts.frontDoor === true, ...(unfilledSeat ? { unfilledSeat } : {}) }), { onerror: (err) => console.error(`rig mcp serve: ${err.message}`) });
const served = serveStdio(() => createMcpServer(client), { onerror: (err) => console.error(`rig mcp serve: ${err.message}`) });
try {
// EOF is a normal stdio disconnect, not an unresolved top-level await.
if (process.stdin.readableEnded) finish();
@@ -191,7 +169,7 @@ stays the endpoint; a proxy in front forwards the metadata path unchanged.`)
}
async function serveHttp(daemonUrl: string, bind: string, grantService?: string, grantServiceHeaderFile?: string,
oauth?: { resource: string; issuer: string }, taskDestination?: string, frontDoor = false, unfilledSeat?: UnfilledSeat): Promise<void> {
oauth?: { resource: string; issuer: string }): Promise<void> {
const match = /^(\[[^\]]+\]|[^:]+):(\d+)$/.exec(bind);
if (!match) {
console.error(`--bind takes host:port (for example 127.0.0.1:7437 or [::1]:7437), not ${bind}`);
@@ -203,8 +181,7 @@ async function serveHttp(daemonUrl: string, bind: string, grantService?: string,
let served;
try {
const header = grantServiceHeaderFile ? readGrantServiceHeader(grantServiceHeaderFile) : undefined;
served = await startMcpHttpServer({ daemonUrl, host, port: Number(match[2]), tokenFile, taskDestination, frontDoor,
...(unfilledSeat ? { unfilledSeat } : {}),
served = await startMcpHttpServer({ daemonUrl, host, port: Number(match[2]), tokenFile,
...(grantService ? { grantService: { url: grantService, ...(header ? { header } : {}) } } : {}), ...(oauth ? { oauth } : {}) });
} catch (err) {
console.error(err instanceof McpHttpConfigError ? err.message : `Can't serve on ${bind}: ${(err as Error).message}`);
+1 -4
View File
@@ -603,9 +603,6 @@ API-key approval before the first turn. OpenRig does not enforce API budgets or
Codex can select a saved API-key auth profile with --to-account just like a subscription profile. Its shared-home effects
and native credential-store requirements are unchanged. No fresh account home or conversation is created.
Do not combine --api-key-file with --account-home or --clear-account-home; select a home and switch credentials separately.
Pi seats support --account-home with an existing readable auth.json, or --clear-account-home, through a fresh successor
with a handover. Their current model and effort are retained unless overridden. Pi account changes work with or without
a launch_wrapper. To change runtime, use rig seat set-runtime with --runtime pi, codex or claude-code.
--clear-account-home returns to the configured member/rig/daemon/native home through a fresh successor with a handover.
Use it without --to-account, --stored-login, --account-home or --token-file. It removes an account-home token reference
only after confirmation; it does not restore historical login bytes. Anthropic active/default profiles live outside
@@ -641,7 +638,7 @@ CLAUDE_CONFIG_DIR and are not isolated by an account home; check them before rel
try {
const status = await client.get<{ runtime?: string }>(`/api/seat/status/${encodeURIComponent(opts.seat)}`);
const runtime = status.data.runtime;
if (status.status >= 400 || (runtime !== "codex" && runtime !== "claude-code" && runtime !== "pi")) {
if (status.status >= 400 || (runtime !== "codex" && runtime !== "claude-code")) {
const refusal = { ok: false, code: "account_home_preflight_failed", message: "The seat runtime could not be read; no switch was requested." };
if (opts.json) console.log(JSON.stringify(refusal)); else console.error(refusal.message);
process.exitCode = 1; return;
+1 -5
View File
@@ -88,8 +88,6 @@ interface NodeEntry {
storedSessionStatus?: string | null;
/** A community harness's seat: its label, maintainer and declared limits. */
communityHarness?: { harness: string; maintainer: string | null; limits: string[]; identity: string; needsInput: string; presetError?: string };
/** Configured literal argv prefix wrapping local built-in Pi/Codex launches; omitted when absent. */
launchWrapper?: string[];
/** Slice 15 — `has-work-to-do` primitive. Derived from queue_items;
* NEVER derived from terminalActive. */
hasAssignedWork?: boolean;
@@ -219,7 +217,6 @@ const ALLOWED_NODE_FIELDS = new Set([
"nodeKind",
"runtime",
"communityHarness",
"launchWrapper",
"model",
"modelObservation",
"sessionStatus",
@@ -736,7 +733,6 @@ export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string,
// 15 claude-code seats — see formatDeclaredModel for the render rule.
runtime: n.runtime ?? null,
...(n.communityHarness ? { communityHarness: n.communityHarness } : {}),
...(n.launchWrapper?.length ? { launchWrapper: n.launchWrapper } : {}),
model: n.model ?? null,
// Compact tuple: [literal model, evidence source, native record time].
// Full JSON / --fields retain the complete labelled object and occupant generation.
@@ -934,7 +930,7 @@ contextUsage.percent, contextUsage.state (with --nodes). Other keys are rejected
activeCount, hasWorkCount, attentionCount, status, lifecycleState, uptime,
latestSnapshot.
--fields accepts (node-level, with --nodes): rigId, rigName, logicalId, podId,
podNamespace, hostSelfId, canonicalSessionName, nodeKind, runtime, communityHarness, launchWrapper, model, sessionStatus,
podNamespace, hostSelfId, canonicalSessionName, nodeKind, runtime, communityHarness, model, sessionStatus,
intentionallyStopped, startupStatus, restoreOutcome, lifecycleState, tmuxAttachCommand,
resumeCommand, latestError, terminalActive, hasAssignedWork,
assignedWorkCount, pendingWorkCount, inProgressWorkCount, blockedWorkCount,
+61 -187
View File
@@ -1,8 +1,8 @@
import fs from "node:fs";
import { randomBytes } from "node:crypto";
import { Command, InvalidArgumentError } from "commander";
import { DaemonClient, DaemonConnectionError, DaemonTimeoutError, DaemonResponseError, remoteDaemonClient, type DaemonResponse } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard, resolveOriginSelfHostId } from "../daemon-lifecycle.js";
import { DaemonClient, DaemonConnectionError, DaemonTimeoutError, DaemonResponseError, type DaemonResponse } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { sessionRigOf, isHumanSeatSessionRef } from "../session-name.js";
import { realDeps } from "./daemon.js";
@@ -10,13 +10,11 @@ import { enumArg, positiveIntArg } from "../cli-error.js";
import type { StatusDeps } from "./status.js";
import { resolveContextRef } from "../context-resolve.js";
import { shellQuote } from "../cross-host-executor.js";
import { bearerAuthHeaders, loadHostRegistry, resolveHost, resolveRemoteBearer } from "../host-registry.js";
import { loadHostRegistry, resolveHost, resolveRemoteBearer } from "../host-registry.js";
import { loadHostBindings } from "../host-bindings.js";
import { eligibleQueueBindings, ineligibleBindingDetail } from "@openrig/daemon/learned-host-bindings";
import { omittedReadField, readView } from "../read-view.js";
import { buildWriteReceipt, compactOutputEnabled, type ReceiptContext } from "../queue-receipt.js";
import { TRANSITIONS_SHOWN, compactShow, compactTransitions, showEmptyNotice, transitionsNotice } from "../queue-read-compact.js";
import { codeResultOf, planItemArtifact, type FileRead, type SharedRoot } from "../queue-artifact.js";
/**
* `rig queue` — coordination primitive L3/inbox/outbox commands (PL-004 Phase A).
@@ -38,8 +36,6 @@ export interface DeliveryVerifyDeps {
export interface QueueDeps extends StatusDeps {
deliveryVerify?: DeliveryVerifyDeps;
/** The host registry `--host` resolves against on the verbs that name an existing row; tests give their own. */
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
}
interface DeliveryResultBase {
@@ -562,45 +558,6 @@ async function queueReadTarget(deps: QueueDeps, client: DaemonClient, hostId: st
return { reader, options };
}
/** Where a verb that names an existing row (show, transitions, list, claim, update, block) sends its request with
* `--host`: the remote daemon itself, called by this CLI with the host's bearer, as `send --host` does (#2637). The
* selected daemon has no route that reads another host's queue, and a direct call lets the remote record the actor
* as `<seat>@<rig>@<this host>`, the provenance `send --host` gives. Without `--host`, `local`, or a `--host` naming
* the selected daemon's own host id, the selected daemon, unchanged. Explicit-only: never `rig host select`. */
interface QueueHostTarget { client: DaemonClient; options: { headers?: Record<string, string>; timeoutMs?: number }; remote?: string }
async function queueHostTarget(deps: QueueDeps, local: DaemonClient, hostId: string | undefined, write: boolean): Promise<QueueHostTarget | { error: string; message: string }> {
const remote = remoteHostOf(hostId);
if (!remote) return { client: local, options: {} };
// A --host naming the selected daemon's own id stays local, as create's does (the daemon's resolvesToLocalHost).
if (await daemonSelfHostId(local) === remote) return { client: local, options: {} };
const registry = (deps.hostRegistryLoader ?? loadHostRegistry)();
if (!registry.ok) return { error: "host_registry_unreadable", message: `--host ${remote}: this CLI's host registry can't be read (${registry.error}).` };
// The queue forwarder's rule for learned bindings (one policy): a self-id resolves only through a binding learned at
// its entry's current URL, with no recorded conflict, so a stale binding never sends a write to a replaced endpoint.
const learned = loadHostBindings();
const match = resolveHost(registry.registry, remote, eligibleQueueBindings(registry.registry, learned));
if (!match.ok) {
const unused = ineligibleBindingDetail(registry.registry, learned, remote);
return { error: "unknown_host", message: `--host ${remote}: no such host in this CLI's host registry (see rig host list).${unused ? ` ${unused}` : ""}` };
}
if (match.host.transport !== "http") return { error: "host_not_http", message: `--host ${remote}: its registry entry is SSH, not a daemon URL; queue verbs need an http host.` };
const bearer = resolveRemoteBearer(match.host);
if (!bearer.ok) return { error: "host_bearer_unavailable", message: `--host ${remote}: ${bearer.error}` };
const client = remoteDaemonClient(deps.clientFactory, match.host.url, await resolveOriginSelfHostId(deps.lifecycleDeps));
const headers = bearerAuthHeaders(bearer.token);
return {
client,
options: { ...(Object.keys(headers).length > 0 ? { headers } : {}), ...(write ? { timeoutMs: CROSS_HOST_QUEUE_WRITE_TIMEOUT_MS } : {}) },
remote,
};
}
/** The ` --host <id>` a printed follow-up command needs to reach the same row again. */
function hostFlag(target: QueueHostTarget): string {
return target.remote ? ` --host ${shellQuote(target.remote)}` : "";
}
/** A successful forward isn't a delivery receipt. Never accept a different remote row as this create's receipt. */
function createReceiptReader(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Pick<DaemonClient, "get"> {
if (!remoteHostOf(hostId)) return client;
@@ -647,10 +604,8 @@ async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: str
}
}
async function printWriteResult(client: DaemonClient, json: boolean, body: unknown, status: number, receipt: ReceiptContext | null, hostUsed = false): Promise<void> {
// A direct --host write already settled its host when it chose the target: its receipt keeps the host it used.
// create and handoff, which the selected daemon forwards, are settled here as before.
const settled = receipt && status < 400 ? (hostUsed ? receipt : await settleReceiptHost(client, receipt)) : null;
async function printWriteResult(client: DaemonClient, json: boolean, body: unknown, status: number, receipt: ReceiptContext | null): Promise<void> {
const settled = receipt && status < 400 ? await settleReceiptHost(client, receipt) : null;
const projected = settled ? buildWriteReceipt(body, settled) : null;
if (!projected) {
printResult(json, body, status);
@@ -667,7 +622,6 @@ async function printQueueItemResult(
body: unknown,
status: number,
receipt: ReceiptContext | null = null,
remoteHost?: string,
): Promise<void> {
if (status >= 400 && body && typeof body === "object"
&& (body as { error?: unknown }).error === "qitem_not_found") {
@@ -682,11 +636,10 @@ async function printQueueItemResult(
body = {
...body,
hint: `Queue item ${JSON.stringify(qitemId)} was not found on ${daemon}.\n`
+ `If it came from another host, run rig host list; replace <daemon-url> with its registered daemon URL and run: OPENRIG_URL='<daemon-url>' rig queue show ${shellQuote(qitemId)} --full --json`
+ (remoteHost ? "" : `\nOr name that host: rig queue show ${shellQuote(qitemId)} --host <host-id> --full --json`),
+ `If it came from another host, run rig host list; replace <daemon-url> with its registered daemon URL and run: OPENRIG_URL='<daemon-url>' rig queue show ${shellQuote(qitemId)} --full --json`,
};
}
await printWriteResult(client, json, body, status, receipt && remoteHost ? { ...receipt, remoteHost } : receipt, remoteHost !== undefined);
await printWriteResult(client, json, body, status, receipt);
}
// OPR.0.4.3.03 — `rig queue show` body preview.
@@ -969,9 +922,6 @@ function emitHostResolutionError(res: { error: string; message: string }, json:
const QUEUE_HOST_OPTION_HELP =
"OPR.0.4.6.MH3: route this queue write to a REGISTERED remote host (see rig host ls). EXPLICIT-ONLY — queue verbs never follow the persisted 'rig host select' selection. Equivalent to the host-qualified destination form member@rig@<host>.";
const QUEUE_HOST_ROW_OPTION_HELP =
"#2637: read or act on a row held by a REGISTERED remote host (see rig host ls). This CLI calls that host's daemon directly with its bearer, as send --host does; a write is recorded there as <seat>@<rig>@<this host>. EXPLICIT-ONLY — queue verbs never follow the persisted 'rig host select' selection. A blocker, watchdog or row id given with it must exist on that host.";
export function queueCommand(depsOverride?: QueueDeps): Command {
const cmd = new Command("queue").description("Coordination L3 — owned-work queue + inbox/outbox");
const getDeps = (): QueueDeps => depsOverride ?? {
@@ -1256,7 +1206,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
// no body claim. Verify the env (the header source) or the daemon returns 400 actor_required (no seat identity to record; P18 retired the 401 refusal).
if (!requireSeatSession("destination")) return;
const deps = getDeps();
// No --host here (#2678 review): a row is claimed by its destination seat, on the host that holds it.
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {});
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, { verb: "claim" }));
@@ -1297,9 +1246,8 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--wake-watchdog <jobId>", "For state=blocked: attach an existing live watchdog id targeting the row owner")
.option("--wake-after <duration>", "For state=blocked: atomically arm a timer (for example 90s, 15m, 2h)", wakeDurationSeconds)
.option("--summary <text>", "Summary persisted onto the item (human-seat parks, or standalone summary update without --state)")
.option("--evidence-ref <path>", "Pointer to the durable artifact, persisted onto the item on a human-seat park or a done close (--state done). For a code result, a JSON file of kind openrig.code-result/v1 (repo, base and revision as full commit ids, paths) lets a reader diff it exactly")
.option("--evidence-ref <path>", "Pointer to the durable artifact, persisted onto the item on a human-seat park or a done close (--state done)")
.option("--note <text>", "Transition note for the audit log")
.option("--host <id>", QUEUE_HOST_ROW_OPTION_HELP)
.option("--full", "Print the whole response row(s) as today, even when output.compact is on (the receipt's waiting keeps only blocker and nextBackstop)")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: {
@@ -1316,7 +1264,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
summary?: string;
evidenceRef?: string;
note?: string;
host?: string;
json?: boolean;
full?: boolean;
}) => {
@@ -1340,10 +1287,8 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
// header source) or the daemon returns 400 actor_required (no seat identity to record; P18 retired the 401 refusal). Matches `resolve`.
if (!requireSeatSession("actor")) return;
const deps = getDeps();
await withClient(deps, async (local) => {
const target = await queueHostTarget(deps, local, opts.host, true);
if ("error" in target) return emitHostResolutionError(target, opts.json ?? false);
const res = await target.client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
state: opts.state,
reopen: opts.reopen,
closureReason: opts.closureReason,
@@ -1356,9 +1301,9 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
summary: opts.summary,
evidenceRef: opts.evidenceRef,
transitionNote: opts.note,
}, target.options);
await printQueueItemResult(target.client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, { verb: "update" }), target.remote);
}, remoteHostOf(opts.host) !== undefined, remoteHostOf(opts.host));
});
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, { verb: "update" }));
});
});
// OPR.0.4.4.19 FR-6 — the first-class park affordance (C5 leg 1). One verb,
@@ -1379,7 +1324,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--continuation <text>", "What resumes. Workspace deferred/not-imminent work belongs in a mission/slice")
.option("--wake-watchdog <jobId>", "Attach an existing live watchdog id targeting the parked owner")
.option("--wake-after <duration>", "Atomically arm a timer with the park (for example 90s, 15m, 2h)", wakeDurationSeconds)
.option("--host <id>", QUEUE_HOST_ROW_OPTION_HELP)
.option("--full", "Print the whole response row(s) as today, even when output.compact is on (the receipt's waiting keeps only blocker and nextBackstop)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
@@ -1401,7 +1345,6 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
continuation?: string;
wakeWatchdog?: string;
wakeAfter?: number;
host?: string;
json?: boolean;
full?: boolean;
}) => {
@@ -1409,10 +1352,8 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
// ignored, no body actorSession. Verify the env, else the daemon returns 400 actor_required (no seat identity to record). Matches `resolve`.
if (!requireSeatSession("actor")) return;
const deps = getDeps();
await withClient(deps, async (local) => {
const target = await queueHostTarget(deps, local, opts.host, true);
if ("error" in target) return emitHostResolutionError(target, opts.json ?? false);
const res = await target.client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
state: "blocked",
blockedOn: opts.on,
summary: opts.summary,
@@ -1420,9 +1361,9 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
wakeWatchdogId: opts.wakeWatchdog,
wakeAfterSeconds: opts.wakeAfter,
transitionNote: opts.continuation ? `continuation: ${opts.continuation}` : (opts.note ?? `parked on ${opts.on}`),
}, target.options);
await printQueueItemResult(target.client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, { verb: "block" }), target.remote);
}, remoteHostOf(opts.host) !== undefined, remoteHostOf(opts.host));
});
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, { verb: "block" }));
});
});
// OPR.0.4.4.19 FR-7 — the resolve verb's CLI wrapper: a THIN client of the
@@ -1717,14 +1658,10 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
.description("Show one qitem and its derived waiting state (bounded preview; --full for complete body)")
.option("--full", "Complete original record; may be large (use --full --json for lossless JSON)")
.option("--json", "JSON preview with completeness, original byte size and exact full command")
.option("--host <id>", QUEUE_HOST_ROW_OPTION_HELP)
.action(async (qitemId: string, opts: { full?: boolean; json?: boolean; host?: string }) => {
.action(async (qitemId: string, opts: { full?: boolean; json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (local) => {
const target = await queueHostTarget(deps, local, opts.host, false);
if ("error" in target) return emitHostResolutionError(target, opts.json ?? false);
const client = target.client;
const res = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}`, target.options);
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}`);
const json = opts.json ?? false;
const item = res.data;
// --full is a pure passthrough of today's COMPLETE item shape (the
@@ -1732,7 +1669,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
// passthrough on error responses / non-object payloads, where there is
// no string body to preview.
if (opts.full || res.status >= 400 || !isRecordWithStringBody(item)) {
await printQueueItemResult(client, qitemId, json, item, res.status, null, target.remote);
await printQueueItemResult(client, qitemId, json, item, res.status);
return;
}
// The closing transition is for --full; the bounded preview prints the record as it did before.
@@ -1740,7 +1677,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
const { preview, bodyBytes, bodyTruncated } = previewBody(item.body);
// Append-only additions: keep `body` in place (now the preview) and add
// the honest size + truncation flag. Object otherwise unchanged.
const fullCommand = `rig queue show ${shellQuote(qitemId)}${hostFlag(target)} --full --json`;
const fullCommand = `rig queue show ${shellQuote(qitemId)} --full --json`;
// Everything held back from this preview is declared, so a reader that trusts readView runs --full.
const omitted = [
...(bodyTruncated ? [omittedReadField("body (after preview)", item.body.slice(preview.length))] : []),
@@ -1759,47 +1696,6 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
console.error(`… (bounded preview — complete body is ${bodyBytes} bytes; full record ${view.fullJsonBytes} JSON bytes: ${fullCommand})`);
}
if (compact && compact.emptyOmitted.length > 0) console.error(showEmptyNotice(compact.emptyOmitted, fullCommand));
}, remoteHostOf(opts.host) !== undefined, remoteHostOf(opts.host));
});
cmd
.command("artifact <qitemId>")
.description("Read a qitem's evidenceRef through the daemon's shared roots; a code-result manifest also comes back parsed as codeResult")
.option("--root <name>", "The shared root a relative evidenceRef is under")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: { root?: string; json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const json = opts.json ?? false;
const item = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}`);
if (item.status >= 400) { await printQueueItemResult(client, qitemId, json, item.data, item.status); return; }
const ref = ((item.data ?? {}) as { evidenceRef?: unknown }).evidenceRef;
const rootsRes = await client.get<{ roots?: SharedRoot[] }>("/api/files/roots");
if (rootsRes.status >= 400) { printResult(json, rootsRes.data, rootsRes.status); return; }
const roots = rootsRes.data?.roots ?? [];
// The same rule as rig_artifact_read: only a file in a shared root is read, through the daemon.
const plan = planItemArtifact(ref, roots, opts.root);
if ("unavailable" in plan) {
if (json) console.log(JSON.stringify({ available: false, qitemId, reason: plan.unavailable, evidenceRef: typeof ref === "string" ? ref : null, roots: roots.map((entry) => entry.name) }));
else console.error(`${qitemId}: ${plan.unavailable}`);
process.exitCode = 1;
return;
}
const res = await client.get<FileRead>(`/api/files/read?${new URLSearchParams({ root: plan.read.root, path: plan.read.path }).toString()}`);
if (res.status >= 400) { printResult(json, res.data, res.status); return; }
const read = res.data ?? {};
const parsed = codeResultOf(read);
if (json) {
console.log(JSON.stringify({ available: true, qitemId, root: read.root, path: read.path, size: read.size, mtime: read.mtime, binary: read.binary === true,
...(read.binary ? {} : { text: read.content ?? "" }), truncated: read.truncated === true, ...parsed }));
return;
}
if (read.binary) console.log(`A binary file (${read.root}:${read.path}): not shown as text.`);
else process.stdout.write(read.content ?? "");
// Notices go to stderr, so stdout stays the file.
if (parsed && "codeResult" in parsed) console.error(`code result: ${parsed.codeResult.repo} ${parsed.codeResult.base}..${parsed.codeResult.revision}${parsed.codeResult.paths.length ? ` (${parsed.codeResult.paths.length} paths)` : ""}`);
else if (parsed) console.error(parsed.codeResultIssue);
if (read.truncated) console.error(`… cut at ${read.truncatedAtBytes} of ${read.totalBytes} bytes, as the daemon reads it`);
});
});
@@ -1808,24 +1704,20 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
.description("Show the append-only transition log for a qitem")
.option("--json", "JSON output for agents")
.option("--full", `The whole log, as today, even when output.compact is on (compact shows the latest transitions, ${TRANSITIONS_SHOWN} in all, always including the current disposition)`)
.option("--host <id>", QUEUE_HOST_ROW_OPTION_HELP)
.action(async (qitemId: string, opts: { json?: boolean; full?: boolean; host?: string }) => {
.action(async (qitemId: string, opts: { json?: boolean; full?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (local) => {
const target = await queueHostTarget(deps, local, opts.host, false);
if ("error" in target) return emitHostResolutionError(target, opts.json ?? false);
const client = target.client;
const res = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`, target.options);
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`);
// Slice 15 P3: with output.compact on, the recent story; the notice goes to stderr so it stays
// visible when the array is piped through jq.
if (!opts.full && res.status < 400 && Array.isArray(res.data) && compactOutputEnabled()) {
const { transitions, earlier } = compactTransitions(res.data as Record<string, unknown>[]);
printResult(opts.json ?? false, transitions, res.status);
if (earlier > 0) console.error(transitionsNotice(earlier, res.data.length, `rig queue transitions ${shellQuote(qitemId)}${hostFlag(target)} --full${opts.json ? " --json" : ""}`));
if (earlier > 0) console.error(transitionsNotice(earlier, res.data.length, `rig queue transitions ${shellQuote(qitemId)} --full${opts.json ? " --json" : ""}`));
return;
}
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status, null, target.remote);
}, remoteHostOf(opts.host) !== undefined, remoteHostOf(opts.host));
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
});
});
cmd
@@ -1843,7 +1735,6 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
.option("--target-repo <name>", "PL-007: filter qitems by target_repo (exact match)")
.option("--limit <n>", "Result limit", positiveIntArg, 100)
.option("--json", "JSON output (compact; use --full --json for complete fields)")
.option("--host <id>", `${QUEUE_HOST_ROW_OPTION_HELP} With --host, the rows of every rig on that host unless --destination or --source narrows them; --owned and --mine name this host's seat and are refused.`)
.addHelpText("after", `
Default: active items in your current rig, compact summary (like 'docker ps').
Current rig is derived from OPENRIG_SESSION_NAME's @<rig> suffix.
@@ -1886,62 +1777,45 @@ Examples:
targetRepo?: string;
limit: string;
json?: boolean;
host?: string;
}) => {
const deps = getDeps();
const remote = remoteHostOf(opts.host);
const useJson = opts.json || opts.o === "json";
// The seat env names a seat of THIS host. On another host's daemon (a --host that resolves remote) it scopes
// nothing that daemon holds: no rig is derived from it, and --owned/--mine are refused.
const seatScope = (onRemote: boolean): URLSearchParams | null => {
const params = new URLSearchParams();
const sessionName = onRemote ? undefined : readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
if (onRemote && (opts.owned || opts.mine)) {
emitHostResolutionError({ error: "host_scope_unsupported", message: `--host ${remote} lists another host's rows: --${opts.owned ? "owned" : "mine"} names this host's seat. Use --destination or --source with the address as that host stores it.` }, useJson);
return null;
}
if (opts.owned && !sessionName) {
console.error("Cannot use --owned: checked OPENRIG_SESSION_NAME and RIGGED_SESSION_NAME, but neither caller seat identity is set. Set one to a canonical seat@rig address, or use --destination <session>.");
process.exitCode = 1;
return null;
}
const hasExplicitScope = !!(opts.destination || opts.source || opts.owned);
const params = new URLSearchParams();
const sessionName = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
if (opts.owned && !sessionName) {
console.error("Cannot use --owned: checked OPENRIG_SESSION_NAME and RIGGED_SESSION_NAME, but neither caller seat identity is set. Set one to a canonical seat@rig address, or use --destination <session>.");
process.exitCode = 1;
return;
}
const hasExplicitScope = !!(opts.destination || opts.source || opts.owned);
if (opts.owned && sessionName) {
params.set("destinationSession", sessionName);
} else if (opts.mine && sessionName) {
params.set("as", sessionName);
} else if (!opts.allRigs && !hasExplicitScope) {
const rigName = sessionName ? extractRigName(sessionName) : undefined;
if (rigName) {
params.set("rig", rigName);
}
if (opts.owned && sessionName) {
params.set("destinationSession", sessionName);
} else if (opts.mine && sessionName) {
params.set("as", sessionName);
} else if (!opts.allRigs && !hasExplicitScope) {
const rigName = sessionName ? extractRigName(sessionName) : undefined;
if (rigName) {
params.set("rig", rigName);
}
}
if (!opts.all) {
params.set("activeOnly", "1");
}
if (!opts.full) {
params.set("compact", "1");
}
if (opts.destination) params.set("destinationSession", opts.destination);
if (opts.source) params.set("sourceSession", opts.source);
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));
return params;
};
// Without --host, the scope is settled before the daemon is contacted, as before.
if (!remote && !seatScope(false)) return;
if (!opts.all) {
params.set("activeOnly", "1");
}
if (!opts.full) {
params.set("compact", "1");
}
if (opts.destination) params.set("destinationSession", opts.destination);
if (opts.source) params.set("sourceSession", opts.source);
warnIfUnknownStates(opts.state);
await withClient(deps, async (local) => {
const target = await queueHostTarget(deps, local, opts.host, false);
if ("error" in target) return emitHostResolutionError(target, useJson);
const params = seatScope(target.remote !== undefined);
if (!params) return;
const res = await target.client.get<unknown>(`/api/queue/list?${params.toString()}`, target.options);
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));
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/queue/list?${params.toString()}`);
const useJson = opts.json || opts.o === "json";
printResult(useJson, res.data, res.status);
}, remote !== undefined, remote);
});
});
cmd
+8 -18
View File
@@ -169,7 +169,7 @@ interface SeatHandoverMutationResult {
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 or rebuild priming packet was pasted and Enter pressed, but its submission couldn't be confirmed. */
/** 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[];
@@ -386,8 +386,7 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
const outcome = result.sourceOutcome;
if (outcome.permissions) console.log(`Codex rebuild permissions: ${outcome.permissions.state}. ${outcome.permissions.detail}`);
if (outcome.primedArtifacts.length > 0) {
const heading = result.restorePacket?.status === "unconfirmed" ? "Rebuild packet includes" : "Rebuild primed from";
console.log(`${heading} ${outcome.primedArtifacts.length} durable artifact${outcome.primedArtifacts.length === 1 ? "" : "s"}:`);
console.log(`Rebuild primed from ${outcome.primedArtifacts.length} durable artifact${outcome.primedArtifacts.length === 1 ? "" : "s"}:`);
for (const artifact of outcome.primedArtifacts) console.log(` - ${artifact.address} — ${artifact.label}`);
}
for (const gap of outcome.gaps) console.log(` ! declared but missing on disk: ${gap}`);
@@ -406,17 +405,8 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
console.log("No conversation continuity was carried: the successor started a fresh conversation.");
} 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.
if (result.source.mode === "rebuild") {
console.log(`The rebuild priming packet was sent to the successor, but its submission is unconfirmed: ${result.restorePacket.reason}.`);
if (result.restorePacket.reason === "the prompt still looked staged in the input box") {
console.log("Check the successor's current screen. Only if that exact packet is still staged, submit the existing packet once. Do not resend blindly or rerun the handover.");
} else {
console.log("Check the successor's screen before sending the priming packet again; it may already be working on it.");
}
} else {
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(`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.");
} else if (result.source.mode === "fork") {
console.log("No packet delivery: fork context rides the native conversation itself.");
@@ -1035,13 +1025,13 @@ resume/successor launch reads it at call time. Examples:
cmd
.command("set-runtime")
.argument("<seat>", "Canonical session name or logical seat ref")
.option("--runtime <runtime>", "codex, claude-code or pi")
.option("--runtime <runtime>", "codex or claude-code")
.option("--reason <text>", "Why the seat moves; recorded with the switch")
.option("--model <id>", "Target model; otherwise the seat's agent spec for the target, else the target's default")
.option("--effort <level>", "Target effort, the target runtime's own level (not translated)")
.option("--codex-config-profile <name>", "Codex configuration profile for a Codex successor")
.option("--clear-account-home", "Return to member/rig/daemon/native login selection through a fresh successor")
.option("--account-home <path>", "Target Codex/Claude home or Pi account directory containing auth.json; also permits a same-runtime successor")
.option("--account-home <path>", "Isolated target CODEX_HOME or CLAUDE_CONFIG_DIR; also permits a fresh successor on the same runtime")
.option("--token-file <path>", "With a Claude account home, its required subscription token reference")
.option("--account-label <label>", "With --token-file, the selected account's label")
.option("--permission-mode <mode>", "Target native permission choice; default: inherit the seat's member and rig policy")
@@ -1051,7 +1041,7 @@ resume/successor launch reads it at call time. Examples:
.option("--note <text>", "With --confirm: what you'll do first")
.option("--recover", "Finish an interrupted switch from its record")
.option("--json", "JSON output for agents")
.description("Move a live Codex, Claude Code or Pi seat to another runtime, successor first")
.description("Move a live Codex or Claude Code seat to the other runtime, successor first")
.addHelpText("after", `
The seat keeps its address, connections, queue rows and claims. Its successor
starts on the target runtime beside the current occupant, reads a handover, and
@@ -1065,7 +1055,7 @@ later converge reports a deferred change_runtime); the source runtime's model,
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, Claude Code and Pi, single-pane seats only. --dry-run lists the details.
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.
+1 -2
View File
@@ -258,7 +258,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
coverage?: { coverageStart: string; coveredThrough: string; pending?: { upper: string; nextLatest: string }; nextRetryAt?: number } | null;
channels?: Array<{ channel?: string | null; state?: string; reason?: string; lastScanAt?: string;
coverage?: { coverageStart: string; coveredThrough: string; nextRetryAt?: number } | null }>;
threads?: { state?: string; reason?: string; tracked?: number; scanned?: number; pending?: number; settled?: number; settledRescanHours?: number; oldestPendingSeconds?: number | null;
threads?: { state?: string; reason?: string; tracked?: number; scanned?: number; pending?: number; oldestPendingSeconds?: number | null;
backoff?: Record<string, number>; incomplete?: Record<string, number> };
limits?: string[] } } | undefined;
if (connector) {
@@ -288,7 +288,6 @@ export function slackCommand(deps: SlackDeps = {}): Command {
const behind = threads.oldestPendingSeconds != null ? ` (oldest ${Math.round(threads.oldestPendingSeconds / 60)} min behind)` : "";
log(` Thread recovery: ${threads.state ?? "unknown"}${threads.reason ? ` (${threads.reason})` : ""}; ${threads.tracked ?? 0} tracked (open, or closed within seven days); `
+ `${threads.scanned ?? 0} scanned, ${threads.pending ?? 0} waiting their turn${behind}`
+ `${threads.settled ? `, ${threads.settled} settled (nobody waiting; rescanned ${!threads.settledRescanHours || threads.settledRescanHours === 24 ? "daily" : `every ${threads.settledRescanHours} hours`})` : ""}`
+ `${reasons(threads.backoff) ? `; backing off: ${reasons(threads.backoff)}` : ""}${reasons(threads.incomplete) ? `; incomplete: ${reasons(threads.incomplete)}` : ""}`);
}
log(` Recovery counts since connector start: accepted ${recovery?.acceptedThisProcess ?? "unknown"}; dead-lettered ${recovery?.deadLetteredThisProcess ?? "unknown"} (custody, not delivery)`);
+1 -25
View File
@@ -1,8 +1,6 @@
import { Command } from "commander";
import { DaemonClient, DaemonResponseError, DaemonTimeoutError } from "../client.js";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { shellQuote } from "../cross-host-executor.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { streamOutcome } from "./stream-outcome.js";
@@ -44,21 +42,6 @@ function printResult(json: boolean, body: unknown, status: number): void {
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
/**
* Added to an emit whose reply came too late or unreadable: the item may still have landed, and the
* daemon keeps one item per stream item ID, so only a retry with the same ID can't add a second one.
*/
function emitRetryGuidance(id: string | undefined, source: string, sentAt: number): string {
if (id !== undefined) {
return `The item may still land. To retry safely, repeat the same emit with --id ${shellQuote(id)}; the daemon keeps one item per ID. To check first: rig stream show ${shellQuote(id)}.`;
}
// The daemon records the seat the request came from (its seat env), not --source, when the two differ.
const recorded = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")?.trim() || source;
// Five minutes before the send, for a daemon whose clock runs behind this one.
const since = new Date(sentAt - 5 * 60_000).toISOString();
return `The item may still land, and this emit had no --id, so emitting again can add a second item. Check first: rig stream list --source ${shellQuote(recorded)} --since ${since}. Pass a stable --id on emits so a later retry is safe.`;
}
function isWatchedStreamItem(value: unknown): value is WatchedStreamItem {
if (!value || typeof value !== "object") return false;
const item = value as Record<string, unknown>;
@@ -149,7 +132,6 @@ To keep emits passive: rig config set stream.intake off`)
const deps = getDeps();
const tags = opts.hintTags ? opts.hintTags.split(",").map((s) => s.trim()).filter(Boolean) : null;
await withClient(deps, async (client) => {
const sentAt = Date.now();
const res = await client.post<Record<string, unknown>>("/api/stream/emit", {
streamItemId: opts.id,
sourceSession: opts.source,
@@ -160,12 +142,6 @@ To keep emits passive: rig config set stream.intake off`)
hintUrgency: opts.hintUrgency ?? null,
hintTags: tags,
interrupt: opts.interrupt ?? false,
}).catch((error: unknown): never => {
// The shared renderer reads both as an UNKNOWN outcome; name the retry that can't duplicate.
if (error instanceof DaemonTimeoutError || error instanceof DaemonResponseError) {
error.message += ` ${emitRetryGuidance(opts.id, opts.source, sentAt)}`;
}
throw error;
});
printResult(opts.json ?? false, res.data, res.status);
});
+1 -1
View File
@@ -137,7 +137,7 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
.option("--session <name>", "Herdr session on the daemon's host; --session default selects Herdr's default session")
.option("--window", "Open a new desktop terminal tab/window (the default when --provider is omitted)")
.option("--expected-plan <id>", "Open only if the view still matches this preview")
.option("--follow", "Keep one qitem:<id>, slice:<id-or-name> or mission:<id> workspace following current obligation owners (Linux/macOS Herdr protocol 22)")
.option("--follow", "Keep one qitem:<id>, slice:<id-or-name> or mission:<id> workspace following current obligation owners (Linux Herdr 0.9.5/protocol 22)")
.option("--no-follow", "Stop following and keep the existing workspace and tiles as a fixed layout")
.option("--max-tiles <count>", "Following tile limit, 1–256; explicitly raise it to show more members", Number)
.option("--pin <seats...>", "Retain these following tiles after they leave the task")
+1 -23
View File
@@ -11,7 +11,6 @@ import { getDaemonStatus, getDaemonUrl, startDaemon, type LifecycleDeps, daemonS
import { shouldAutoStartDaemon } from "../daemon-reachability.js";
import { prepareDaemonAutoStart } from "../daemon-auto-start.js";
import { freshSeatCommand, launchFreshSeat } from "../fresh-seat-command.js";
import { launchStatusRunning } from "./launch.js";
import type { StartOptions } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -121,17 +120,6 @@ snapshot, resuming seats where their harness allows; it refuses while any of its
whose rig name already exists starts a new team in place of the stopped one, which
is archived, and refuses if the old one may still be running. To bring a stopped
team back with its conversations, run rig up <rig-name> --existing, not the spec.
After a restore, rig up exits 0 only when the rig came back whole (rigResult
fully_restored). It exits 1 when it didn't (partially_restored, failed or
not_attempted), including when every seat is running but some started fresh
without their conversation, or came back with a guidance or restore gap. With
--json, read rigResult and each node's status, noConversation, guidanceGaps and
restoreGaps to tell these apart: a seat with a gap keeps its own status, such as
resumed, and a fresh seat marked noConversation (a terminal seat) lost nothing.
A refused request exits 1 for a conflict (HTTP 409, such as a rig still running
or a restore not attempted) and 2 for any other refusal (400, 404 or 500, such
as no rig by that name or a failed boot).
`);
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
@@ -718,7 +706,7 @@ as no rig by that name or a failed boot).
}
console.log(`Rig "${rigName ?? rigId}" restored (ID: ${rigId})`);
if (rigResult) console.log(`Result: ${rigResult}`);
const nodes = (res.data["nodes"] as Array<{ logicalId: string; status: string; error?: string; noConversation?: true; guidanceGaps?: string[]; restoreGaps?: string[] }>) ?? [];
const nodes = (res.data["nodes"] as Array<{ logicalId: string; status: string; error?: string }>) ?? [];
for (const n of nodes) {
// OPR.0.3.4.2 — the five-term vocabulary renders distinctly; the
// awaiting-decision reason is part of the line (not a dead end), and so is a failed seat's (such as a seat held
@@ -729,16 +717,6 @@ as no rig by that name or a failed boot).
console.log(` ${n.logicalId}: ${n.status}`);
}
}
// Every seat running still exits 1 when the restore isn't whole; say why, so nobody retries a start that worked.
if (rigResult === "partially_restored" && nodes.length > 0 && nodes.every((n) => launchStatusRunning(n.status))) {
const fresh = nodes.filter((n) => (n.status === "fresh" || n.status === "fresh-primed") && !n.noConversation).length;
const gapped = nodes.filter((n) => (n.guidanceGaps?.length ?? 0) > 0 || (n.restoreGaps?.length ?? 0) > 0).map((n) => n.logicalId);
const reasons = [
...(fresh > 0 ? [`${fresh} started fresh without their conversation`] : []),
...(gapped.length > 0 ? [`${gapped.join(", ")} came back with a gap named in a warning`] : []),
];
console.log(`${nodes.length === 1 ? "The one seat is" : `All ${nodes.length} seats are`} running${reasons.length > 0 ? `; ${reasons.join("; ")}` : ""}. The restore isn't whole, so rig up exits 1.`);
}
const warnings = (res.data["warnings"] as string[]) ?? [];
for (const w of warnings) {
console.error(` warning: ${w}`);
+1 -1
View File
@@ -72,7 +72,7 @@ export function describeDaemonRejection(body: unknown, instanceHint?: string): T
return {
fact: "The failed step no longer exists in the cached workflow spec.",
consequence: "The spec drifted after this instance failed; rebinding would route a step the workflow no longer declares.",
action: "Restore the step in the spec (then re-register it through the workflows-folder scan or an instantiate by path to refresh the cache) or instantiate a fresh run.",
action: "Restore the step in the spec (re-validate to refresh the cache) or instantiate a fresh run.",
};
case "instance_version_conflict":
return {
@@ -30,8 +30,6 @@ export interface RenderInstance {
hopCount?: number;
/** OPR.0.4.6.FAC1: the instance's bound rig (API-carried; null/absent = unbound, renders nothing). */
boundRig?: string | null;
/** #2730: what the run is for, as its `--root-objective` gave it; null for an instance from before it was kept. */
rootObjective?: string | null;
/**
* WF-1 FR-2's API-carried classification (present since the
* completion fixback at 384e60f1). RAIL 1 (arch ruling): the CLI
@@ -183,7 +181,6 @@ export function renderTraceTree(
const lines: string[] = [];
const name = instance.workflowName ? `${instance.workflowName}${instance.workflowVersion ? ` v${instance.workflowVersion}` : ""}` : "";
lines.push(`${statusGlyph(instance.status)} ${instance.instanceId} ${name} status=${instance.status}${instance.hopCount !== undefined ? ` hops=${instance.hopCount}` : ""}${instance.boundRig ? ` rig=${instance.boundRig}` : ""}`);
if (instance.rootObjective) lines.push(` objective: ${instance.rootObjective}`);
if (instance.createdAt) {
lines.push(` created ${instance.createdAt}${instance.createdBySession ? ` by ${instance.createdBySession}` : ""} age ${humanAge(instance.createdAt, nowIso)}`);
}
@@ -257,8 +254,6 @@ function isStuck(instance: RenderInstance): boolean {
export interface AttentionRow {
instanceId: string;
workflowName: string;
/** #2730: what the run is for; null when the instance doesn't record it. */
rootObjective: string | null;
/** All classes for this instance — exactly-once row, combined. */
classes: string[];
reasons: string[];
@@ -325,7 +320,6 @@ export function composeAttentionRollup(instances: RenderInstance[]): AttentionRo
attention.push({
instanceId: inst.instanceId,
workflowName: inst.workflowName ?? "",
rootObjective: inst.rootObjective ?? null,
classes,
reasons,
affordance,
@@ -351,7 +345,6 @@ export function renderStatus(rollup: AttentionRollup): string[] {
lines.push(
`${fitCell("▲", 2)}${fitCell(row.instanceId, 30)}${fitCell(row.workflowName, 20)}${fitCell(row.classes.join("+"), 16)}${row.reasons.join("; ")}`,
);
if (row.rootObjective) lines.push(` ${fitCell("", 2)} objective: ${row.rootObjective}`);
lines.push(` ${fitCell("", 2)}└ ${row.affordance}`);
}
return lines;
@@ -381,7 +374,6 @@ export function renderInstanceShow(
const lines: string[] = [];
lines.push(`${statusGlyph(instance.status)} ${instance.instanceId} status=${instance.status}`);
if (instance.workflowName) lines.push(` workflow: ${instance.workflowName}${instance.workflowVersion ? ` v${instance.workflowVersion}` : ""}`);
if (instance.rootObjective) lines.push(` objective: ${instance.rootObjective}`);
// OPR.0.4.6.FAC1: the bound rig renders when present (unbound rows unchanged).
if (instance.boundRig) lines.push(` rig: ${instance.boundRig}`);
if (instance.createdAt) lines.push(` created: ${instance.createdAt}${instance.createdBySession ? ` by ${instance.createdBySession}` : ""} (age ${humanAge(instance.createdAt, nowIso)})`);
+4 -74
View File
@@ -172,7 +172,7 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
cmd
.command("validate <specPath>")
.description("Validate a workflow spec file in memory, without registering it (returns structured ok/error report)")
.description("Validate a workflow spec file (returns structured ok/error report)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
@@ -636,87 +636,20 @@ exception routes only. Existing obligations keep their owners and evidence.
});
});
cmd
.command("unregister [spec]")
.description("Take a workflow spec out of the registry: <name> or <name>@<version>, or --prune-missing for versions whose file is gone")
.option("--prune-missing", "Remove every registered version whose source file no longer exists (scoped to [spec] when given)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
A version that unfinished work (an active, waiting or resumable failed instance) is pinned to
is never removed; with --prune-missing it is retained, readable by that work but hidden from
the library, and a later prune removes it. A file still inside the workflows folder or the
built-in starters is kept, because the next scan registers it again: move or delete the file.
A file whose presence can't be checked (an access error, not "no such file") is kept and named.
An empty version (name@) is a usage error.
Examples:
$ rig workflow unregister qa-probe
$ rig workflow unregister pedido-do-board@1
$ rig workflow unregister --prune-missing
$ rig workflow unregister --prune-missing --json | jq '.removed[] | "\\(.name)@\\(.version) \\(.sourcePath)"'
`)
.action(async (spec: string | undefined, opts: { pruneMissing?: boolean; json?: boolean }) => {
if (!spec && !opts.pruneMissing) {
console.error("Name a workflow spec (<name> or <name>@<version>) or pass --prune-missing.");
process.exitCode = 1;
return;
}
let name: string | undefined;
let version: string | undefined;
if (spec) {
const at = spec.lastIndexOf("@");
name = at > 0 ? spec.slice(0, at) : spec;
version = at > 0 ? spec.slice(at + 1) : undefined;
}
if (version !== undefined && version.trim() === "") {
console.error(`The version after @ is empty in "${spec}": use <name>@<version>, or <name> for every version.`);
process.exitCode = 1;
return;
}
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<{
removed?: Array<{ name: string; version: string; sourcePath: string }>;
kept?: Array<{ name: string; version: string; sourcePath: string; reason: "pinned" | "scanned" | "unchecked" }>;
}>("/api/workflow/specs/unregister", { name, version, pruneMissing: opts.pruneMissing === true });
if (opts.json || res.status >= 400) {
printResult(opts.json ?? false, res.data, res.status);
return;
}
const removed = res.data.removed ?? [];
const kept = res.data.kept ?? [];
if (removed.length === 0 && kept.length === 0) console.log("Nothing to remove.");
for (const row of removed) console.log(`removed ${row.name}@${row.version} ${row.sourcePath}`);
for (const row of kept) {
const why = row.reason === "pinned"
? "unfinished work is pinned to this version"
: row.reason === "unchecked"
? "its file couldn't be checked (not ENOENT), so it isn't treated as missing"
: "the file is still in a scanned folder; move or delete it";
console.log(`kept ${row.name}@${row.version} ${row.sourcePath} (${why})`);
}
});
});
cmd
.command("trace <instanceId>")
.description("Show one workflow instance + its append-only step trail (audit-only verdict)")
.option("--full", "Return the complete step trail instead of the 50 most recent closures (trailCompleteness: complete)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
The default trail holds the 50 most recent closures; trailCompleteness says
"recent" when older ones were left out. --full asks the daemon for all of them.
Examples:
$ rig workflow trace WF01ABC
$ rig workflow trace WF01ABC --json | jq '.trail[] | {step, actor, exit}'
$ rig workflow trace WF01ABC --full --json | jq '.trailCompleteness, (.trail | length)'
`)
.action(async (instanceId: string, opts: { json?: boolean; full?: boolean }) => {
.action(async (instanceId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<{ instance?: unknown; reconciliation?: unknown; trail?: unknown[]; trailCompleteness?: string; frontier?: unknown[]; failures?: unknown[]; boundaryObligations?: unknown[]; unknowns?: string[] }>(
`/api/workflow/${encodeURIComponent(instanceId)}/trace${opts.full ? "?full=true" : ""}`,
const res = await client.get<{ instance?: unknown; reconciliation?: unknown; trail?: unknown[]; frontier?: unknown[]; failures?: unknown[]; boundaryObligations?: unknown[]; unknowns?: string[] }>(
`/api/workflow/${encodeURIComponent(instanceId)}/trace`,
);
// WF3 FR-2: human mode renders the per-step tree (mini-req 2's
// one-screen bar); --json byte-identical (BR-2).
@@ -734,9 +667,6 @@ Examples:
} as Parameters<typeof renderTraceTree>[0];
const trail = (res.data.trail ?? []) as Parameters<typeof renderTraceTree>[1];
for (const line of renderTraceTree(instance, trail)) console.log(line);
if (res.data.trailCompleteness === "recent") {
console.log(` (the ${trail.length} most recent closures; older ones left out - all of them: rig workflow trace ${instanceId} --full)`);
}
});
});
+2 -52
View File
@@ -15,7 +15,7 @@ import {
export interface RiggedConfig {
specs: { builtinsVisible: boolean };
launch: { nonInterruptive: boolean; codexLoginShell: boolean };
daemon: { port: number; host: string; dbConflict?: "warn" | "stop"; seatPollIntervalMs?: number };
daemon: { port: number; host: string; dbConflict?: "warn" | "stop" };
// OPR.0.4.6.MH1 FR-1 — the persisted host-selection pointer.
// OPR.0.4.6.MH1 FR-4 — the own-host display name (default "localhost").
host: { selected: string; name: string };
@@ -199,8 +199,6 @@ export interface RiggedConfig {
transport: {
verifyInputBox: boolean;
localUserGuard: boolean;
// Whether an ordinary send or work notice reaches a seat whose prompt holds a person's draft (default deliver; refuse opts out).
draftDelivery: "refuse" | "deliver";
};
// Slice 22 (OPR.0.7.2.22) — typed decisions (OpenRouter + Jev), off until enabled; read by the
// invoking rig process. templateDir holds optional <name>.yaml templates. Lockstep with the daemon twin.
@@ -209,8 +207,6 @@ export interface RiggedConfig {
consumers: string;
templateDir: string;
};
// rig acp serve's receiver when --to isn't given (an ACP registry launch has no args). Lockstep with the daemon twin.
acp: { to: string };
// OPR.0.4.6.FS-1 W2 — queue-retention maintenance knobs (twin of the daemon
// settings-store): enabled + the four bounded numeric tunables.
retention: {
@@ -220,7 +216,6 @@ export interface RiggedConfig {
watchdogKeepPerJob: number;
streamDays: number;
streamMaxItems: number;
eventsDays: number;
batchSize: number;
};
// S04 (OPR.0.5.5.4) — pickup-receipt stall threshold (twin of the daemon settings-store).
@@ -273,7 +268,7 @@ const DEFAULT_CLAUDE_COMPACTION_EXTRA_INSTRUCTION_FILE_PATH = getDefaultOpenRigP
const DEFAULTS = {
launch: { nonInterruptive: false, codexLoginShell: false },
daemon: { port: 7433, host: "127.0.0.1", dbConflict: "warn", seatPollIntervalMs: 1000 },
daemon: { port: 7433, host: "127.0.0.1", dbConflict: "warn" },
// OPR.0.4.6.MH1 FR-1 — "local" ≡ no remote selection (LOCAL_HOST_ID).
// FR-4 — own-host display name; default "localhost" (PRD-named).
host: { selected: "local", name: "localhost" },
@@ -392,7 +387,6 @@ const DEFAULTS = {
transport: {
verifyInputBox: false,
localUserGuard: false,
draftDelivery: "deliver",
},
// Slice 22 — typed decisions OFF, no consumers, shipped templates only.
decisions: {
@@ -400,8 +394,6 @@ const DEFAULTS = {
consumers: "",
templateDir: "",
},
// No ACP receiver: rig acp serve needs --to until one is set.
acp: { to: "" },
// Issue #1856 — stream intake default queue.worker; read live by the stream intake coordinator.
stream: {
intake: "queue.worker",
@@ -414,7 +406,6 @@ const DEFAULTS = {
watchdogKeepPerJob: 50,
streamDays: 30,
streamMaxItems: 20000,
eventsDays: 0,
batchSize: 500,
},
// S04 — pickup-receipt stall threshold default (twin of daemon getDefaultValue).
@@ -434,8 +425,6 @@ export const VALID_KEYS = [
"daemon.port",
"daemon.host",
"daemon.db_conflict",
// #2687: the seat pollers' cadence; startup-only (read once when the daemon starts). Twin of the daemon key.
"daemon.seat_poll_interval_ms",
// OPR.0.4.6.MH1 FR-1 — the persisted host-selection pointer (the
// kubectl current-context shape). ONE static key, lockstep with the
// daemon settings-store twin (parity test pins both). Registry-
@@ -547,13 +536,10 @@ export const VALID_KEYS = [
"usage.pi_openrouter_charge",
"transport.verify_input_box",
"transport.local_user_guard",
"transport.draft_delivery",
// Slice 22 — typed decisions (OpenRouter + Jev), off by default; lockstep with the daemon twin.
"decisions.enabled",
"decisions.consumers",
"decisions.template_dir",
// rig acp serve's receiver when --to isn't given; lockstep with the daemon twin.
"acp.to",
// OPR.0.4.6.FS-1 W2 — queue-retention maintenance knobs; CLI-settable twin,
// lockstep with the daemon settings-store SETTINGS_VALID_KEYS.
"retention.enabled",
@@ -563,8 +549,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",
// The history-only event types of `events` older than N days, 0 (default) keeps them; lockstep with the daemon twin.
"retention.events_days",
"stream.intake",
"retention.batch_size",
// S04 — pickup-receipt stall threshold; lockstep with the daemon settings-store twin.
@@ -596,7 +580,6 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"workflow.exception_routing": { primary: "OPENRIG_WORKFLOW_EXCEPTION_ROUTING" },
"daemon.host": { primary: "OPENRIG_HOST", legacy: "RIGGED_HOST" },
"daemon.db_conflict": { primary: "OPENRIG_DAEMON_DB_CONFLICT" },
"daemon.seat_poll_interval_ms": { primary: "OPENRIG_SEAT_POLL_INTERVAL_MS" },
"db.path": { primary: "OPENRIG_DB", legacy: "RIGGED_DB" },
"transcripts.enabled": { primary: "OPENRIG_TRANSCRIPTS_ENABLED", legacy: "RIGGED_TRANSCRIPTS_ENABLED" },
"transcripts.path": { primary: "OPENRIG_TRANSCRIPTS_PATH", legacy: "RIGGED_TRANSCRIPTS_PATH" },
@@ -674,11 +657,9 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"usage.pi_openrouter_charge": { primary: "OPENRIG_USAGE_PI_OPENROUTER_CHARGE" },
"transport.local_user_guard": { primary: "OPENRIG_TRANSPORT_LOCAL_USER_GUARD" },
"transport.verify_input_box": { primary: "OPENRIG_TRANSPORT_VERIFY_INPUT_BOX" },
"transport.draft_delivery": { primary: "OPENRIG_TRANSPORT_DRAFT_DELIVERY" },
"decisions.enabled": { primary: "OPENRIG_DECISIONS_ENABLED" },
"decisions.consumers": { primary: "OPENRIG_DECISIONS_CONSUMERS" },
"decisions.template_dir": { primary: "OPENRIG_DECISIONS_TEMPLATE_DIR" },
"acp.to": { primary: "OPENRIG_ACP_TO" },
// OPR.0.4.6.FS-1 W2 — retention knobs; net-new keys, OPENRIG_* primary only.
"retention.enabled": { primary: "OPENRIG_RETENTION_ENABLED" },
"retention.transitions_days": { primary: "OPENRIG_RETENTION_TRANSITIONS_DAYS" },
@@ -686,7 +667,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" },
"retention.events_days": { primary: "OPENRIG_RETENTION_EVENTS_DAYS" },
"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" },
@@ -710,7 +690,6 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"workflow.exception_routing": ["workflow", "exceptionRouting"],
"daemon.host": ["daemon", "host"],
"daemon.db_conflict": ["daemon", "dbConflict"],
"daemon.seat_poll_interval_ms": ["daemon", "seatPollIntervalMs"],
"db.path": ["db", "path"],
"transcripts.enabled": ["transcripts", "enabled"],
"transcripts.path": ["transcripts", "path"],
@@ -780,18 +759,15 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"usage.pi_openrouter_charge": ["usage", "piOpenRouterCharge"],
"transport.local_user_guard": ["transport", "localUserGuard"],
"transport.verify_input_box": ["transport", "verifyInputBox"],
"transport.draft_delivery": ["transport", "draftDelivery"],
"decisions.enabled": ["decisions", "enabled"],
"decisions.consumers": ["decisions", "consumers"],
"decisions.template_dir": ["decisions", "templateDir"],
"acp.to": ["acp", "to"],
"retention.enabled": ["retention", "enabled"],
"retention.transitions_days": ["retention", "transitionsDays"],
"retention.watchdog_days": ["retention", "watchdogDays"],
"retention.watchdog_keep_per_job": ["retention", "watchdogKeepPerJob"],
"retention.stream_days": ["retention", "streamDays"],
"retention.stream_max_items": ["retention", "streamMaxItems"],
"retention.events_days": ["retention", "eventsDays"],
"stream.intake": ["stream", "intake"],
"retention.batch_size": ["retention", "batchSize"],
"queue.pickup_stall_threshold_minutes": ["queue", "pickupStallThresholdMinutes"],
@@ -1007,16 +983,6 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
"daemon.db_conflict": (raw) => {
if (raw !== "warn" && raw !== "stop") throw new Error("daemon.db_conflict must be warn or stop");
},
"daemon.seat_poll_interval_ms": (raw, value) => {
// Startup-only: the seat pollers read it once when the daemon starts. Only slower than the 1 s default, up to the
// ceiling the structural observation's expiry allows (seat-activity-service.ts MAX_SEAT_POLL_INTERVAL_MS).
if (!/^\d+$/.test(raw.trim()) || typeof value !== "number" || !Number.isSafeInteger(value) || value < 1000 || value > 2000) {
throw new Error("Invalid daemon.seat_poll_interval_ms: must be a whole number of milliseconds in [1000, 2000]");
}
},
"transport.draft_delivery": (raw) => {
if (raw !== "refuse" && raw !== "deliver") throw new Error(`Invalid value for transport.draft_delivery: must be "refuse" or "deliver", got "${raw}"`);
},
"transcripts.lines": (raw, value) => {
if (!/^\d+$/.test(raw.trim()) || typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 1_000_000) {
throw new Error("Invalid transcripts.lines: must be an integer in [1, 1000000]");
@@ -1172,12 +1138,6 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
throw new Error(`Invalid value for stream.intake: must be "queue.worker", a canonical session (<seat>@<rig>), or "off", got "${raw}"`);
}
},
// 0 (the default) keeps these events; a positive day count turns their pruning on.
"retention.events_days": (raw, coerced) => {
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 0) {
throw new Error(`Invalid value for retention.events_days: must be an integer >= 0 (0 keeps the events), 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}"`);
@@ -1198,12 +1158,6 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
throw new Error("Invalid decisions.template_dir: use an absolute path, or an empty string for the shipped templates only");
}
},
// rig acp serve's receiver when --to isn't given: a team seat, member@rig, or empty for none.
"acp.to": (_raw, value) => {
if (typeof value !== "string" || (value !== "" && !/^[^@\s]+@[^@\s]+$/.test(value))) {
throw new Error("Invalid acp.to: use the seat that receives your ACP tasks as member@rig, or an empty string for none");
}
},
};
function validateKeyConstraints(key: ValidKey, raw: string, coerced: string | number | boolean): void {
@@ -1300,7 +1254,6 @@ export class ConfigStore {
port: v("daemon.port") as number,
host: v("daemon.host") as string,
dbConflict: v("daemon.db_conflict") as "warn" | "stop",
seatPollIntervalMs: v("daemon.seat_poll_interval_ms") as number,
},
host: {
selected: v("host.selected") as string,
@@ -1433,14 +1386,12 @@ export class ConfigStore {
transport: {
localUserGuard: v("transport.local_user_guard") as boolean,
verifyInputBox: v("transport.verify_input_box") as boolean,
draftDelivery: v("transport.draft_delivery") as "refuse" | "deliver",
},
decisions: {
enabled: v("decisions.enabled") as boolean,
consumers: v("decisions.consumers") as string,
templateDir: v("decisions.template_dir") as string,
},
acp: { to: v("acp.to") as string },
retention: {
enabled: v("retention.enabled") as boolean,
transitionsDays: v("retention.transitions_days") as number,
@@ -1448,7 +1399,6 @@ export class ConfigStore {
watchdogKeepPerJob: v("retention.watchdog_keep_per_job") as number,
streamDays: v("retention.stream_days") as number,
streamMaxItems: v("retention.stream_max_items") as number,
eventsDays: v("retention.events_days") as number,
batchSize: v("retention.batch_size") as number,
},
queue: {
+5 -39
View File
@@ -39,8 +39,6 @@ export interface DaemonEventLoopEvidence {
}
export interface DaemonStatus {
/** The selected OPENRIG_URL names a non-loopback endpoint. */
explicitRemote?: boolean;
databaseWarnings?: string[];
/** RULING 1ae863d2 — C3 3-state semantics (canonical: daemon crash-cart-detect.ts, kept in
* lockstep): "stopped" requires POSITIVE evidence (connection refused); a probe TIMEOUT or
@@ -434,13 +432,9 @@ function resolveLifecycleFile(deps: LifecycleDeps, filename: "daemon.json" | "da
* - "openrig" — healthz responded (ok or not) → this is our daemon
* - "not_openrig" — connection refused → PID is alive but not listening on our port
* - "unresponsive" — pid is alive but healthz probe timed out
* - "dead" — PID not alive
* - { blocked } — the OS refused the probe; identity is unknown */
async function checkPid(state: DaemonState, deps: LifecycleDeps, knownLiveness?: ProcessLiveness): Promise<"openrig" | "not_openrig" | "unresponsive" | "dead" | { blocked: string }> {
const liveness = knownLiveness ?? (deps.processLiveness
? deps.processLiveness(state.pid)
: deps.isProcessAlive(state.pid) ? "alive" : "dead");
if (liveness === "dead") return "dead";
* - "dead" — PID not alive */
async function checkPid(state: DaemonState, deps: LifecycleDeps): Promise<"openrig" | "not_openrig" | "unresponsive" | "dead"> {
if (!deps.isProcessAlive(state.pid)) return "dead";
const host = state.host ?? DEFAULT_HOST;
try {
await fetchDaemonProbe(deps, `http://${formatDaemonHostForUrl(host)}:${state.port}/healthz`, HEALTHZ_PROBE_TIMEOUT_MS);
@@ -448,8 +442,6 @@ async function checkPid(state: DaemonState, deps: LifecycleDeps, knownLiveness?:
return "openrig";
} catch (err) {
if (err instanceof HealthProbeTimeoutError) return "unresponsive";
const code = connectionErrorCode(err);
if (isBlockedConnectionCode(code)) return { blocked: code };
// Connection refused → PID alive but not our daemon
return "not_openrig";
}
@@ -495,13 +487,6 @@ const ENV_SCRUB_EXACT = new Set([
"TERM_PROGRAM",
"TERM_PROGRAM_VERSION",
"TERMINFO",
// The herdr-core terminal this command runs in (HERDR_CORE_TERMINAL_ENV_KEYS in the daemon's
// parent-session-env.ts). Core ends every process started with that terminal's HC_SCOPE when the
// terminal closes, and it reads the environment the process was started with, so the daemon must
// never be started with it: closing the terminal would end the daemon, its tmux server and every seat.
"HC_SCOPE",
"HC_TERMINAL_ID",
"HC_TERMINAL_SOCKET",
]);
export function buildDaemonEnv(
@@ -694,10 +679,6 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
const existing = readState(deps);
if (existing) {
const pidState = await checkPid(existing, deps);
if (typeof pidState === "object") {
const guidance = blockedConnectionGuidance(pidState.blocked);
throw new Error(`${guidance.consequence} ${guidance.action}`);
}
if (pidState === "openrig") {
// Our daemon is running (possibly unhealthy, but alive on our port)
throw new Error(`Daemon already running (pid ${existing.pid} on port ${existing.port})`);
@@ -712,10 +693,6 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
await fetchDaemonProbe(deps, `http://${formatDaemonHostForUrl(probeHost)}:${port}/healthz`, HEALTHZ_PROBE_TIMEOUT_MS);
recoveredRunning = true;
} catch (error) {
const code = connectionErrorCode(error);
if (isBlockedConnectionCode(code)) {
console.warn(`Warning: daemon health probe was blocked (${code}); the port could not be checked and a daemon may already be running.`);
}
// WSL2 can silently drop probes to an unused port. A slow live daemon can
// also time out: do not run a second child's initialization against its DB.
if (error instanceof HealthProbeTimeoutError && await deps.isPortOccupied?.(probeHost, port)) {
@@ -995,16 +972,7 @@ export async function stopDaemon(deps: LifecycleDeps): Promise<"stopped" | "no-t
}
const stateFile = resolveLifecycleFile(deps, "daemon.json");
const liveness = deps.processLiveness
? deps.processLiveness(state.pid)
: deps.isProcessAlive(state.pid) ? "alive" : "dead";
if (liveness === "unknown") {
throw new Error(`Cannot stop safely: PID ${state.pid} liveness is unknown; no signal sent, state preserved.`);
}
const pidState = await checkPid(state, deps, liveness);
if (typeof pidState === "object") {
throw new Error(`Cannot stop safely: the identity probe was blocked (${pidState.blocked}); no signal sent, state preserved.`);
}
const pidState = await checkPid(state, deps);
if (pidState === "not_openrig") {
throw new Error(`Cannot stop safely: PID ${state.pid} is present but identity is not confirmed at ${check}; no signal sent, state preserved.`);
}
@@ -1139,9 +1107,7 @@ export async function getDaemonStatus(
const endpoint = (() => {
try {
const url = new URL(openrigUrl);
const host = url.hostname || DEFAULT_HOST;
const loopback = host.toLowerCase() === "localhost" || host === "[::1]" || host === "::1" || /^127\.\d+\.\d+\.\d+$/.test(host);
return { port: Number(url.port) || DEFAULT_PORT, host, ...(!loopback ? { explicitRemote: true } : {}) };
return { port: Number(url.port) || DEFAULT_PORT, host: url.hostname || DEFAULT_HOST };
} catch {
return {};
}
+2 -3
View File
@@ -72,13 +72,12 @@ export function daemonMayAnswer(status: DaemonStatus): boolean {
/** Whether `rig up` or `rig start` should start a local daemon. Yes for one confirmed down (stopped, or a
* stale record). For an unverified status, as before this check existed, when another home's daemon is
* live (an independent home elsewhere, this one's own endpoint possibly free);
* live (an independent home elsewhere, this one's own endpoint possibly free) or the probe was blocked;
* preflight and the start's own state and listener checks then decide. Not for an explicit HOME-MOVED
* marker, and not for a daemon of this home that is alive but too busy to answer (it gets the request). */
export function shouldAutoStartDaemon(status: DaemonStatus): boolean {
if (status.state === "stopped" || status.state === "stale") return true;
if (status.state !== "unverified") return false;
if (isBlockedConnectionCode(status.probeErrorCode)) return !status.explicitRemote && !status.siblingHint?.moved;
if (status.siblingHint) return !status.siblingHint.moved;
return false;
return isBlockedConnectionCode(status.probeErrorCode);
}
@@ -1,382 +0,0 @@
import { createHash } from "node:crypto";
import { constants, openSync, closeSync, fstatSync, lstatSync, readSync, existsSync } from "node:fs";
import { isAbsolute, join, normalize } from "node:path";
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
type RecordValue = { [key: string]: Json };
export const RECONSTRUCTION_SCHEMA = "openrig.reconstruction/v0";
const MAX_DOCUMENT = 8 * 1024 * 1024;
const MAX_CLOSURE = 32 * 1024 * 1024;
const PRESERVED = ["authority", "constraint", "gap"];
export class ReconstructionError extends Error {
readonly code: string;
constructor(code: string, message: string) { super(message); this.code = code; }
}
function requireValue(value: unknown, message: string): asserts value {
if (!value) throw new ReconstructionError("invalid_reconstruction", message);
}
function object(value: Json | undefined, label: string): RecordValue {
requireValue(value !== null && typeof value === "object" && !Array.isArray(value), `${label} must be an object`);
return value;
}
function list(value: Json | undefined, label: string): Json[] {
requireValue(Array.isArray(value), `${label} must be an array`); return value;
}
function text(value: Json | undefined, label: string): string {
requireValue(typeof value === "string" && value.trim(), `${label} must be a nonempty string`); return value;
}
function strings(value: Json | undefined, label: string): string[] {
const values = list(value === undefined ? [] : value, label);
requireValue(values.every(v => typeof v === "string"), `${label} must contain strings`); return values as string[];
}
function timestamp(value: Json | undefined, label: string, nullable = false): void {
if (value === null && nullable) return;
requireValue(typeof value === "string" && /(?:Z|[+-]\d\d:\d\d)$/.test(value) && Number.isFinite(Date.parse(value)), `${label} must have an ISO timestamp with timezone${nullable ? " or null" : ""}`);
}
function absolute(value: Json | undefined, label: string): string {
const name = text(value, label); requireValue(isAbsolute(name), `${label} must be an absolute local path`); return normalize(name);
}
function revision(value: Json | undefined): string {
const id = text(value, "revision"); requireValue(/^[a-f0-9]{64}$/.test(id), "revision must be a full lowercase SHA256 ID"); return id;
}
function validUnicode(value: string): void {
for (let i = 0; i < value.length; i++) {
const unit = value.charCodeAt(i);
if (unit >= 0xd800 && unit <= 0xdbff) {
const next = value.charCodeAt(++i);
requireValue(next >= 0xdc00 && next <= 0xdfff, "unpaired Unicode surrogate is unsupported by v0 UTF-8 hashing");
} else requireValue(unit < 0xdc00 || unit > 0xdfff, "unpaired Unicode surrogate is unsupported by v0 UTF-8 hashing");
}
}
/** v0 is Python sorted compact JSON, not JS property order. Reject numeric
* representations we cannot reproduce, rather than silently changing an ID. */
export function parseReconstructionJson(input: string): Json {
let at = 0;
const space = () => { while (/[ \n\r\t]/.test(input[at] ?? "x")) at++; };
const quoted = (): string => {
const start = at++;
while (at < input.length) {
const char = input[at++];
if (char === "\\") at++;
else if (char === '"') {
const result: unknown = JSON.parse(input.slice(start, at));
requireValue(typeof result === "string", "invalid JSON string"); validUnicode(result); return result;
}
}
throw new ReconstructionError("invalid_reconstruction", "unterminated JSON string");
};
const value = (depth: number): Json => {
requireValue(depth <= 128, "JSON nesting exceeds 128"); space();
if (input[at] === '"') return quoted();
if (input[at] === "{") {
at++; space(); const result: RecordValue = Object.create(null) as RecordValue;
if (input[at] !== "}") while (true) {
requireValue(input[at] === '"', "expected JSON object key"); const key = quoted(); space();
requireValue(!Object.hasOwn(result, key), `duplicate JSON key: ${key}`);
requireValue(input[at++] === ":", "expected JSON colon"); result[key] = value(depth + 1); space();
if (input[at] !== ",") break; at++; space();
}
requireValue(input[at++] === "}", "expected JSON object end"); return result;
}
if (input[at] === "[") {
at++; space(); const result: Json[] = [];
if (input[at] !== "]") while (true) {
result.push(value(depth + 1)); space(); if (input[at] !== ",") break; at++;
}
requireValue(input[at++] === "]", "expected JSON array end"); return result;
}
for (const [token, result] of [["true", true], ["false", false], ["null", null]] as const) {
if (input.startsWith(token, at)) { at += token.length; return result; }
}
const number = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(input.slice(at))?.[0];
requireValue(number !== undefined, "expected JSON value");
if (/[.eE]/.test(number) || !Number.isSafeInteger(Number(number))) {
throw new ReconstructionError("unsupported_v0_number", "v0 reader supports safe integer JSON numbers only; floating/exponent and unsafe integers require an explicit compatible reader, never an ID rewrite");
}
at += number.length; return Number(number);
};
try {
const parsed = value(0); space(); requireValue(at === input.length, "trailing JSON input"); return parsed;
} catch (error) {
if (error instanceof ReconstructionError) throw error;
throw new ReconstructionError("invalid_reconstruction", `invalid JSON: ${(error as Error).message}`);
}
}
function codepointOrder(a: string, b: string): number {
const aa = Array.from(a), bb = Array.from(b);
for (let i = 0; i < Math.min(aa.length, bb.length); i++) {
const difference = aa[i]!.codePointAt(0)! - bb[i]!.codePointAt(0)!; if (difference) return difference;
}
return aa.length - bb.length;
}
export function canonicalV0(value: Json): string {
if (value === null || typeof value !== "object") {
if (typeof value === "number") requireValue(Number.isSafeInteger(value), "unsupported v0 number");
if (typeof value === "string") validUnicode(value);
return JSON.stringify(value);
}
if (Array.isArray(value)) return `[${value.map(canonicalV0).join(",")}]`;
return `{${Object.keys(value).sort(codepointOrder).map(key => `${canonicalV0(key)}:${canonicalV0(value[key]!)}`).join(",")}}`;
}
const hash = (bytes: string | Buffer) => createHash("sha256").update(bytes).digest("hex");
/** Regular-file, no-follow, bounded read; no source commands are executed. */
function readRegular(file: string, limit: number, onRead?: (bytes: number) => void): Buffer {
const initial = lstatSync(file);
requireValue(initial.isFile() && !initial.isSymbolicLink(), `not a regular file: ${file}`);
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
const before = fstatSync(fd);
requireValue(before.isFile() && before.dev === initial.dev && before.ino === initial.ino && before.size <= limit, `file identity or byte bound failed: ${file}`);
const chunks: Buffer[] = []; let bytes = 0;
while (bytes < limit) {
const buffer = Buffer.alloc(Math.min(65536, limit - bytes));
const count = readSync(fd, buffer); if (!count) break;
bytes += count; onRead?.(count); chunks.push(buffer.subarray(0, count));
}
const after = fstatSync(fd), current = lstatSync(file);
requireValue(before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs
&& after.dev === current.dev && after.ino === current.ino, `file changed during read: ${file}`);
return Buffer.concat(chunks);
} finally { closeSync(fd); }
}
function jsonFile(file: string, remainingBytes = MAX_DOCUMENT, onRead?: (bytes: number) => void): { value: Json; bytes: number } {
const bytes = readRegular(file, Math.min(MAX_DOCUMENT, remainingBytes), onRead);
return { value: parseReconstructionJson(new TextDecoder("utf-8", { fatal: true }).decode(bytes)), bytes: bytes.length };
}
export interface StoreMapping { sourceStore: string; localStore: string; sourceHost?: string }
export function readStoreMappings(file: string): StoreMapping[] {
return list(jsonFile(file).value, "store mappings").map(value => {
const entry = object(value, "mapping");
return { sourceStore: absolute(entry.sourceStore, "sourceStore"), localStore: absolute(entry.localStore, "localStore"),
...(entry.sourceHost === undefined ? {} : { sourceHost: text(entry.sourceHost, "sourceHost") }) };
});
}
interface Pointer { store: string; revision: string }
function pointer(value: Json | undefined): Pointer {
const v = object(value, "pointer"); return { store: absolute(v.store, "pointer.store"), revision: revision(v.revision) };
}
function records(value: Json | undefined, kind: "items" | "sources"): RecordValue[] {
const seen = new Set<string>();
return list(value === undefined ? [] : value, kind).map(v => {
const r = object(v, kind), id = text(r.id, `${kind}.id`); requireValue(!seen.has(id), `duplicate ${kind} ID: ${id}`); seen.add(id);
timestamp(r.observed_at, `${kind}.observed_at`, true);
if (kind === "items") {
text(r.category, "item.category"); text(r.text, "item.text");
if (r.status !== undefined) text(r.status, "item.status");
for (const name of ["sources", "depends_on", "tags"]) strings(r[name], name);
requireValue(["source-belief", "assessment", "observed", "reported"].includes(String(r.perspective ?? "assessment")), "invalid item perspective");
} else {
requireValue(r.path !== undefined || r.address !== undefined, "source requires path or address");
if (r.path !== undefined) absolute(r.path, "source.path");
if (r.address !== undefined) text(r.address, "source.address");
if (r.sha256 !== undefined && r.sha256 !== null) revision(r.sha256);
}
return r;
});
}
function discovery(value: Json | undefined): Json[] {
const roots = list(value === undefined ? [] : value, "discovery"), ids = new Set<string>();
for (const value of roots) {
const root = object(value, "discovery entry"), id = text(root.id, "discovery.id");
requireValue(!ids.has(id), "duplicate discovery ID"); ids.add(id);
absolute(root.path, "discovery.path");
for (const name of strings(root.known_paths, "discovery.known_paths")) {
requireValue(!isAbsolute(name) && !name.split("/").includes(".."), "discovery known_paths must stay under root");
}
const entries = root.max_entries ?? 1000, depth = root.max_depth ?? 8;
requireValue(typeof entries === "number" && Number.isInteger(entries) && entries >= 1 && entries <= 10000, "discovery.max_entries must be 1..10000");
requireValue(typeof depth === "number" && Number.isInteger(depth) && depth >= 0 && depth <= 32, "discovery.max_depth must be 0..32");
timestamp(root.observed_at ?? null, "discovery.observed_at", true);
}
return roots;
}
function validate(doc: RecordValue): void {
if (doc.schema !== RECONSTRUCTION_SCHEMA) throw new ReconstructionError("unsupported_schema", `unsupported reconstruction schema: ${String(doc.schema)}`);
requireValue(["base", "delta", "composition"].includes(String(doc.kind)), "invalid reconstruction kind");
for (const key of ["subject", "purpose", "author"]) text(doc[key], key);
timestamp(doc.created_at, "created_at");
requireValue(["orientation", "working", "continuity", "full-domain"].includes(String(doc.depth)), "invalid reconstruction depth");
for (const key of ["items", "upsert_items"]) if (doc[key] !== undefined) records(doc[key], "items");
for (const key of ["sources", "upsert_sources"]) if (doc[key] !== undefined) records(doc[key], "sources");
if (doc.coverage !== undefined) {
const coverage = object(doc.coverage, "coverage"); strings(coverage.included, "coverage.included"); strings(coverage.excluded, "coverage.excluded");
}
strings(doc.unknowns, "unknowns"); discovery(doc.discovery);
for (const key of ["remove_items", "remove_sources"]) {
const ids = strings(doc[key], key); requireValue(new Set(ids).size === ids.length, `duplicate ${key}`);
}
if (doc.kind === "delta") {
pointer(doc.parent);
requireValue(!["items", "sources", "members"].some(key => key in doc), "delta uses upsert/remove fields; members remain pinned");
} else requireValue(!Object.keys(doc).some(key => key.startsWith("upsert_") || key.startsWith("remove_")), "base/composition uses items and sources, not delta fields");
if (doc.kind === "composition") {
const members = list(doc.members, "members"); requireValue(members.length > 0 && members.length <= 64, "composition requires 1..64 members"); members.forEach(pointer);
}
}
interface Node {
document: RecordValue; sourceStore: string; localStore: string; items: RecordValue[]; sources: RecordValue[];
coverage: Json; unknowns: string[]; discovery: Json[]; members: Node[]; lineage: Pointer[];
}
interface Issue { code: string; message: string; store?: string; revision?: string; subject?: string }
export interface ReconstructionOptions {
store: string; revision: string; mappings?: StoreMapping[];
items?: string[]; categories?: string[]; tags?: string[]; checkSources?: boolean;
}
const flatten = (node: Node): Node[] => [node, ...node.members.flatMap(flatten)];
export function readReconstruction(options: ReconstructionOptions) {
const rootStore = absolute(options.store, "store"), rootRevision = revision(options.revision);
const mappings = new Map<string, StoreMapping>();
for (const entry of options.mappings ?? []) {
const source = absolute(entry.sourceStore, "sourceStore");
requireValue(!mappings.has(source), "ambiguous duplicate source-store mapping; v0 pointers have no host discriminator");
mappings.set(source, { ...entry, sourceStore: source, localStore: absolute(entry.localStore, "localStore") });
}
const issues: Issue[] = [], loaded: Array<{ revision: string; sourceStore: string; localStore: string; fileBytes: number }> = [];
let totalBytes = 0, visited = 0;
let exhausted = false;
const materialize = (store: string, id: string, stack: string[]): Node | undefined => {
if (exhausted) return undefined;
const sourceStore = absolute(store, "pointer.store"), key = `${sourceStore}\0${id}`;
try {
if (stack.length >= 64 || ++visited > 64 || totalBytes >= MAX_CLOSURE) {
exhausted = true;
throw new ReconstructionError("closure_limit", "reconstruction closure exceeds 64 references or 32 MiB");
}
requireValue(!stack.includes(key), "reconstruction reference cycle");
const mapping = mappings.get(sourceStore);
if (!mapping && sourceStore !== rootStore && !existsSync(sourceStore)) throw new ReconstructionError("unmapped_external_store", `store ${sourceStore} is not available here; supply an explicit store mapping`);
const localStore = mapping?.localStore ?? sourceStore;
// Charge positive reads even when decoding, parsing or a late identity check fails.
const file = jsonFile(join(localStore, `${revision(id)}.json`), MAX_CLOSURE - totalBytes, count => { totalBytes += count; });
const doc = object(file.value, "revision"); requireValue(doc.id === id, "stored ID does not match requested revision");
const { id: ignored, ...body } = doc; void ignored;
requireValue(hash(canonicalV0(body)) === id, "revision content hash mismatch"); validate(doc);
loaded.push({ revision: id, sourceStore, localStore, fileBytes: file.bytes });
const trail = [...stack, key]; let result: Node;
if (doc.kind === "delta") {
const parent = pointer(doc.parent), inherited = materialize(parent.store, parent.revision, trail);
if (!inherited) return undefined;
requireValue(inherited.document.subject === doc.subject, "delta cannot change subject identity");
result = { ...inherited, members: [...inherited.members], lineage: [...inherited.lineage, { store: sourceStore, revision: id }] };
for (const collection of ["items", "sources"] as const) {
const values = new Map(inherited[collection].map(v => [String(v.id), v]));
const removed = strings(doc[`remove_${collection}`], `remove_${collection}`);
const updates = records(doc[`upsert_${collection}`], collection);
for (const remove of removed) {
requireValue(values.has(remove) && !updates.some(v => v.id === remove), `invalid removal/upsert: ${remove}`); values.delete(remove);
}
for (const v of updates) values.set(String(v.id), v);
result[collection] = [...values.values()];
}
if (doc.coverage !== undefined) result.coverage = doc.coverage;
if (doc.unknowns !== undefined) result.unknowns = strings(doc.unknowns, "unknowns");
if (doc.discovery !== undefined) result.discovery = list(doc.discovery, "discovery");
} else {
result = { document: doc, sourceStore, localStore, items: records(doc.items, "items"), sources: records(doc.sources, "sources"),
coverage: doc.coverage ?? { included: [], excluded: [] }, unknowns: strings(doc.unknowns, "unknowns"), discovery: discovery(doc.discovery),
members: [], lineage: [{ store: sourceStore, revision: id }] };
for (const member of list(doc.members === undefined ? [] : doc.members, "members")) {
const p = pointer(member), node = materialize(p.store, p.revision, trail); if (node) result.members.push(node);
}
}
return { ...result, document: doc, sourceStore, localStore };
} catch (error) {
issues.push({ code: error instanceof ReconstructionError ? error.code : "revision_unavailable", message: (error as Error).message, store: sourceStore, revision: id });
return undefined;
}
};
let root = materialize(rootStore, rootRevision, []);
const seenSubjects = new Set<string>();
if (root) for (const node of flatten(root)) {
const subject = String(node.document.subject);
if (seenSubjects.has(subject)) { issues.push({ code: "duplicate_subject", message: `composition repeats subject ${subject}` }); root = undefined; break; }
seenSubjects.add(subject);
}
const nodes = root ? flatten(root) : [], allIds = new Set(nodes.flatMap(node => node.items.map(v => String(v.id))));
const filtered = Boolean(options.items?.length || options.categories?.length || options.tags?.length);
for (const id of options.items ?? []) if (!allIds.has(id)) issues.push({ code: "missing_item", message: `selected item is missing: ${id}` });
const selected = nodes.map(node => {
const byId = new Map(node.items.map(v => [String(v.id), v]));
const ids = new Set(node.items.filter(v => !filtered || options.items?.includes(String(v.id)) || options.categories?.includes(String(v.category))
|| strings(v.tags, "tags").some(tag => options.tags?.includes(tag)) || PRESERVED.includes(String(v.category))).map(v => String(v.id)));
const pending = [...ids];
while (pending.length) for (const dependency of strings(byId.get(pending.pop()!)!.depends_on, "depends_on")) {
if (!byId.has(dependency)) issues.push({ code: "missing_dependency", message: `missing dependency: ${dependency}`, subject: String(node.document.subject) });
else if (!ids.has(dependency)) { ids.add(dependency); pending.push(dependency); }
}
const items = node.items.filter(v => ids.has(String(v.id))), cited = new Set(items.flatMap(v => strings(v.sources, "sources")));
const sources = new Map(node.sources.map(v => [String(v.id), v]));
for (const id of cited) if (!sources.has(id)) issues.push({ code: "missing_citation", message: `missing source: ${id}`, subject: String(node.document.subject) });
return { node, items, cited, ids };
});
let sourceBytes = 0, sourceCount = 0;
const sourceChecks = options.checkSources ? nodes.flatMap(node => node.sources.map(source => {
const base = { subject: node.document.subject, id: source.id, observedAt: source.observed_at };
if (source.path === undefined) return { ...base, status: "unchecked", reason: "opaque/nonlocal route; not fetched" };
try {
requireValue(++sourceCount <= 128 && sourceBytes < 64 * 1024 * 1024, "source checking budget exhausted (128 files / 64 MiB total)");
const bytes = readRegular(String(source.path), 64 * 1024 * 1024 - sourceBytes, count => { sourceBytes += count; });
const actual = hash(bytes);
return { ...base, sha256: actual, status: source.sha256 ? (actual === source.sha256 ? "unchanged" : "changed") : "unchecked", reason: "byte comparison only; observation dates and semantic freshness are not refreshed" };
} catch (error) { return { ...base, status: "unavailable", reason: (error as Error).message }; }
})) : undefined;
const materializedBytes = Buffer.byteLength(JSON.stringify(root ?? null));
return {
presentationSchema: "openrig.reconstruction-reader/v1", storedSchema: RECONSTRUCTION_SCHEMA,
requestedStore: rootStore, requestedRevision: rootRevision, complete: issues.length === 0, issues,
completenessScope: "record closure and selected item/citation references; not source freshness",
caveat: "Context is evidence for understanding, not authority, competence, acceptance or current runtime state. Authors are claimed attribution. No discovery cursor or implicit remote reads.",
selectors: { mode: filtered ? "filtered" : "full", items: options.items ?? [], categories: options.categories ?? [], tags: options.tags ?? [], combination: "union", alwaysIncludeCategories: PRESERVED, includeTransitiveDependencies: true },
mappings: [...mappings.values()], records: loaded, closureBytesRead: totalBytes, atomicGroupCapture: false,
size: { materializedJsonUtf8Bytes: materializedBytes, approximateTokens: Math.ceil(materializedBytes / 4),
estimateMethod: "materialized reader JSON UTF-8 bytes / 4; rough, not a model tokenizer" },
nodes: selected.map(({ node, items, cited, ids }) => ({
subject: node.document.subject, revision: node.document.id, kind: node.document.kind, author: node.document.author,
purpose: node.document.purpose, depth: node.document.depth, createdAt: node.document.created_at,
sourceStore: node.sourceStore, localStore: node.localStore, lineage: node.lineage,
declaredCoverage: node.coverage, unknowns: node.unknowns, discovery: node.discovery,
sourceManifest: node.sources, itemObservations: node.items.map(v => ({ id: v.id, observedAt: v.observed_at })),
items, viewSources: node.sources.filter(v => !filtered || cited.has(String(v.id))),
viewCoverage: { totalItems: node.items.length, includedIds: items.map(v => v.id), omittedIds: node.items.filter(v => !ids.has(String(v.id))).map(v => v.id),
registeredSources: node.sources.length, citedSourceIds: [...cited], omittedRouteIds: filtered ? node.sources.filter(v => !cited.has(String(v.id))).map(v => v.id) : [] },
})), ...(sourceChecks ? { sourceChecks } : {}),
};
}
export type ReconstructionRead = ReturnType<typeof readReconstruction>;
export function reconstructionOutput(result: ReconstructionRead, mode: "inspect" | "view", json: boolean, maxBytes = 256 * 1024): string {
requireValue(Number.isInteger(maxBytes) && maxBytes >= 1024 && maxBytes <= MAX_DOCUMENT, "max-bytes must be 1024..8388608");
// Inspect never delivers item bodies; view carries its actual omissions and
// complete-manifest route rather than presenting full coverage as selected.
const data = { ...result, nodes: result.nodes.map(node => mode === "inspect" ? { ...node, items: undefined, viewSources: undefined } : { ...node, sourceManifest: undefined }) };
let output: string;
if (json || mode === "inspect") output = JSON.stringify(data, null, 2);
else {
const lines = ["# Reconstruction view", "", result.caveat, `Complete: ${result.complete} (${result.completenessScope})`, `Applied selectors: ${JSON.stringify(result.selectors)}`];
for (const issue of result.issues) lines.push(`UNAVAILABLE: ${issue.code}: ${issue.message}`);
for (const node of result.nodes) {
lines.push("", `## ${String(node.subject)}`, `Revision: ${String(node.revision)}`, `Author (claimed): ${String(node.author)}`, `Purpose: ${String(node.purpose)}`, `Depth: ${String(node.depth)}`,
`Source store: ${node.sourceStore}; local read store: ${node.localStore}`,
`Created: ${String(node.createdAt)} (not inherited-fact verification time)`, `Author-declared coverage: ${JSON.stringify(node.declaredCoverage)}`, `Selected view coverage: ${JSON.stringify(node.viewCoverage)}`,
"Composition members retain independent observation dates; no atomic capture is claimed.");
for (const unknown of node.unknowns) lines.push(`Unknown: ${unknown}`);
for (const item of node.items) lines.push("", `### ${String(item.id)} [${String(item.category)}]`, String(item.text),
`Perspective: ${String(item.perspective ?? "assessment")}; observed: ${String(item.observed_at)}; status: ${String(item.status ?? "unspecified")}`,
`Sources: ${JSON.stringify(item.sources ?? [])}; dependencies: ${JSON.stringify(item.depends_on ?? [])}`);
lines.push("", "Evidence routes:");
for (const source of node.viewSources) lines.push(JSON.stringify(source));
if (node.viewCoverage.omittedRouteIds.length) lines.push("Uncited routes omitted; inspect this exact revision for its complete source manifest.");
}
if (result.sourceChecks) lines.push("Source byte checks (not semantic reverification):", JSON.stringify(result.sourceChecks));
output = lines.join("\n");
}
const bytes = Buffer.byteLength(output);
if (bytes > maxBytes) throw new ReconstructionError("output_limit", `output is ${bytes} UTF-8 bytes; max-bytes is ${maxBytes}. Select a narrower view or explicitly raise the bound; no complete output was delivered.`);
return output;
}
+30 -86
View File
@@ -215,22 +215,45 @@ function resolveExplicitSlice(
return matches[0]!;
}
export interface WorkProjectOptions {
export function resolveWorkPosition(opts: {
workspaceRoot: string;
catalogPath?: string;
contextRoot: string;
systemWorldSelection: string;
systemWorldSource: SystemWorldSource;
project?: string;
mission?: string;
slice?: string;
/** Working directory for selection step 4 (the CLI passes --cwd or the process cwd). */
cwd?: string;
/** Calling seat's canonical session, for selection step 3; absent in a plain shell. */
sessionName?: string;
}
/** Catalog/home resolution shared by read-only work consumers and work-install.
* No System World, skill selection or projection is performed here. */
export function resolveWorkProject(opts: WorkProjectOptions, warnings: string[]) {
}): WorkInstallResult {
if (opts.project !== undefined && !SEGMENT.test(opts.project)) {
return failure("invalid_project", "project must be a single bounded segment");
}
if (opts.mission !== undefined && !SEGMENT.test(opts.mission)) {
return failure("invalid_mission", "mission must be a single bounded segment");
}
if (opts.slice !== undefined && !SEGMENT.test(opts.slice)) {
return failure("invalid_slice", "slice must be a single bounded segment");
}
if (opts.slice !== undefined && opts.mission === undefined) {
return failure("mission_required", "slice requires an exact mission selection");
}
const workspaceRoot = canonicalExisting(opts.workspaceRoot);
if (!workspaceRoot) return failure("workspace_root_missing", `workspace root does not exist: ${resolve(opts.workspaceRoot)}`);
if (!workspaceRoot) {
return failure("workspace_root_missing", `workspace root does not exist: ${resolve(opts.workspaceRoot)}`);
}
const warnings: string[] = [];
const systemWorld = resolveSystemWorld({
contextRoot: opts.contextRoot,
selection: opts.systemWorldSelection,
source: opts.systemWorldSource,
});
if (!systemWorld.ok) return failure(systemWorld.error.code, systemWorld.error.message);
const catalogPath = resolve(opts.catalogPath ?? join(workspaceRoot, "workspace.yaml"));
let projectId: string | null = null;
let projectRoot = workspaceRoot;
@@ -280,85 +303,6 @@ export function resolveWorkProject(opts: WorkProjectOptions, warnings: string[])
return failure("missions_root_escape", "project.yaml missions.root resolves outside the selected project root");
}
return { workspaceRoot, projectId, projectRoot, selectedBy, projectManifest, missionsRoot };
}
/** A context home is a location, not task ownership or runtime authority. */
export function resolveWorkContextHome(opts: WorkProjectOptions & { mission?: string; slice?: string }) {
const warnings: string[] = [];
if (opts.mission !== undefined && !SEGMENT.test(opts.mission)) return failure("invalid_mission", "mission must be a single bounded segment");
if (opts.slice !== undefined && !SEGMENT.test(opts.slice)) return failure("invalid_slice", "slice must be a single bounded segment");
if (opts.slice !== undefined && opts.mission === undefined) return failure("mission_required", "slice requires an exact mission selection");
const project = resolveWorkProject(opts, warnings);
if ("error" in project) return project;
let home = project.projectRoot;
if (opts.mission !== undefined) {
const nominal = join(project.missionsRoot, opts.mission);
const mission = canonicalExisting(nominal);
if (!mission) return failure("mission_not_found", `mission '${opts.mission}' does not exist in the selected project`);
if (!inside(project.projectRoot, mission)) return failure("mission_root_escape", "mission resolves outside the selected project root");
home = mission;
if (opts.slice !== undefined) {
let declared: Set<string> | undefined;
const manifestPath = join(mission, "mission.yaml");
if (existsSync(manifestPath)) {
const parsed = readYaml(manifestPath), composition = parsed.value?.["composition"];
if (parsed.value && isRecord(composition) && "slices" in composition) {
try { declared = new Set(validateMissionComposition(parsed.value, manifestPath).map(member => dirname(member.path))); }
catch (error) { return failure("mission_composition_invalid", (error as Error).message); }
} else if (!parsed.value) warnings.push(`${parsed.error}; kept conventional slice addresses`);
}
const slice = resolveExplicitSlice(mission, opts.slice, declared);
if ("error" in slice) return slice;
home = slice.root;
}
}
return { projectId: project.projectId, projectRoot: project.projectRoot, selectedBy: project.selectedBy, home, warnings };
}
export function resolveWorkPosition(opts: {
workspaceRoot: string;
catalogPath?: string;
contextRoot: string;
systemWorldSelection: string;
systemWorldSource: SystemWorldSource;
project?: string;
mission?: string;
slice?: string;
/** Working directory for selection step 4 (the CLI passes --cwd or the process cwd). */
cwd?: string;
/** Calling seat's canonical session, for selection step 3; absent in a plain shell. */
sessionName?: string;
}): WorkInstallResult {
if (opts.project !== undefined && !SEGMENT.test(opts.project)) {
return failure("invalid_project", "project must be a single bounded segment");
}
if (opts.mission !== undefined && !SEGMENT.test(opts.mission)) {
return failure("invalid_mission", "mission must be a single bounded segment");
}
if (opts.slice !== undefined && !SEGMENT.test(opts.slice)) {
return failure("invalid_slice", "slice must be a single bounded segment");
}
if (opts.slice !== undefined && opts.mission === undefined) {
return failure("mission_required", "slice requires an exact mission selection");
}
const workspaceRoot = canonicalExisting(opts.workspaceRoot);
if (!workspaceRoot) {
return failure("workspace_root_missing", `workspace root does not exist: ${resolve(opts.workspaceRoot)}`);
}
const warnings: string[] = [];
const systemWorld = resolveSystemWorld({
contextRoot: opts.contextRoot,
selection: opts.systemWorldSelection,
source: opts.systemWorldSource,
});
if (!systemWorld.ok) return failure(systemWorld.error.code, systemWorld.error.message);
const project = resolveWorkProject({ ...opts, workspaceRoot }, warnings);
if ("error" in project) return project;
const { projectId, projectRoot, selectedBy, projectManifest, missionsRoot } = project;
let projectIntent = "SPEC.md";
let projectIntentSource: WorkInstallSource = "default";
let projectContext: string[] = [];
+6 -35
View File
@@ -19,7 +19,7 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { createMcpHandler, type AuthInfo } from "@modelcontextprotocol/server";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { DaemonClient } from "./client.js";
import { createMcpServer, mintUnknownSender, type McpAccess, type UnfilledSeat } from "./mcp-server.js";
import { createMcpServer, type McpAccess } from "./mcp-server.js";
import { getOpenRigHome } from "./openrig-compat.js";
import { MCP_PRESENCE_FILE, presenceRecorder } from "./mcp-presence.js";
import { resolveRigHandle } from "./rig-handle.js";
@@ -160,8 +160,6 @@ export const GRANT_SCHEMA = "openrig.grant/v1";
const GRANT_KEY = /^[a-f0-9]{24}\.cus_[A-Za-z0-9]{8,64}\.[a-f0-9]{48}$/;
/** An instance id as the grant service takes it. */
const INSTANCE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
/** A person's gateway entity id, as the daemon's registry takes it (ENTITY_ID_PATTERN): their address is <id>@external. */
const PERSON_ENTITY_ID = /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/;
export type GrantServiceOptions = {
/** The grant endpoint. It must be https, except on loopback. */
@@ -255,7 +253,7 @@ async function grantServiceCaller(service: GrantServiceOptions, url: URL, key: s
if (body.status === "expired") return unauthenticated("This credential has expired.");
// malformed (this server's request), unavailable, or anything else.
if (body.status !== "active" || res.status !== 200) return unavailable(`HTTP ${res.status} answered status ${JSON.stringify(body.status)}`);
const { principal, grant, environment, seat, rigs, artifact_roots: roots, access_class: accessClass, objects, person } = body;
const { principal, grant, environment, seat, rigs, artifact_roots: roots, access_class: accessClass, objects } = body;
const seatRig = typeof seat === "string" ? seat.split("@") : [];
const problem = typeof principal?.id !== "string" || principal.id === "" ? "principal.id"
: typeof grant?.id !== "string" || grant.id === "" ? "grant.id"
@@ -268,18 +266,10 @@ async function grantServiceCaller(service: GrantServiceOptions, url: URL, key: s
: grant.expires_at !== null && grant.expires_at !== undefined && !(Date.parse(grant.expires_at) > Date.now()) ? "expired" : null;
if (problem === "expired") return unauthenticated("This credential has expired.");
if (problem) return unavailable(`an active answer with an unusable ${problem}`);
// The person who owns this connection, when the service vouches for one. It's optional, and an unusable one withholds
// only the person tools: every other tool the grant names is served as before.
const personId = typeof person?.entity_id === "string" && PERSON_ENTITY_ID.test(person.entity_id) ? person.entity_id : undefined;
if (person !== undefined && person !== null && !personId) {
console.error(`rig mcp serve --http: grant ${grant.id} names an unusable person.entity_id, so this connection isn't offered the person tools (rig_question_*)`);
}
// The principal the daemon records beside this caller's rows. A version that isn't a finite number is left out (a v1
// answer sends a number).
const version = Number.isFinite(grant.version) ? { version: grant.version as number } : {};
return { seat, access: { principal: principal.id, owner: false, rigs, roots, workspace: false,
external: { objects, grant: { id: grant.id, ...version }, ...(personId ? { person: personId } : {}) } },
principal: { source: "grant-service", id: principal.id, grant: grant.id, ...version } };
return { seat, access: { principal: principal.id, owner: false, rigs, roots, workspace: false, external: { objects } },
principal: { source: "grant-service", id: principal.id, grant: grant.id, ...(Number.isFinite(grant.version) ? { version: grant.version } : {}) } };
}
/** A node's seat name as the daemon derives it (deriveCanonicalFromEntry): {pod}-{member}@{rig}, the logical id split at
@@ -320,12 +310,6 @@ export type McpHttpOptions = {
clientFactory?: (daemonUrl: string, sessionName?: string, principal?: McpPrincipal) => DaemonClient;
/** Grants come from an external grant service instead of the token file, which is then never read. */
grantService?: GrantServiceOptions;
/** Operator-configured receiver for external callers omitting a task destination; never an authority grant. */
taskDestination?: string;
/** rig mcp serve --front-door: a caller that isn't external gets the front-door instructions, not the member workflow. */
frontDoor?: boolean;
/** The seat value rig mcp serve dropped as empty or unfilled, which rig_whoami names to a caller with no seat. */
unfilledSeat?: UnfilledSeat;
/** With grantService: OAuth discovery for clients that sign in with OAuth. The public resource this server is reached
* as and the authorization server that issues its keys, both served exactly as given. */
oauth?: { resource: string; issuer: string };
@@ -354,12 +338,6 @@ function readBody(req: http.IncomingMessage): Promise<string> {
export async function startMcpHttpServer(opts: McpHttpOptions): Promise<{ url: string; authenticated: boolean; close(): Promise<void> }> {
const loopback = isLoopbackHost(opts.host);
const service = opts.grantService;
if (opts.taskDestination !== undefined) {
if (!service) throw new McpHttpConfigError("--task-destination needs --grant-service");
if (!/^[^\s@]+@[^\s@]+$/.test(opts.taskDestination) || opts.taskDestination.endsWith("@external")) {
throw new McpHttpConfigError("--task-destination takes one local team seat, member@rig");
}
}
const serviceUrl = service ? grantServiceUrl(service.url) : undefined;
// The metadata describes the keys a grant service checks, so it's served only with one.
if (opts.oauth && !service) throw new McpHttpConfigError("--oauth-resource and --oauth-issuer need --grant-service");
@@ -399,19 +377,12 @@ export async function startMcpHttpServer(opts: McpHttpOptions): Promise<{ url: s
};
// One handler for both protocol revisions: a request carrying 2026-07-28's per-request envelope is served as that
// revision, anything else (initialize and the rest of 2025) statelessly as before. Each request gets a fresh server for
// the caller decided below, handed over as the request's authInfo. A caller with no seat writes as this process's one
// unknown sender, so its later requests carry the label rig_whoami named and a same-id create retry matches.
const unknownSender = mintUnknownSender();
// the caller decided below, handed over as the request's authInfo.
const mcpHandler = createMcpHandler(({ authInfo }) => {
const caller = (authInfo?.extra as { caller?: RequestCaller } | undefined)?.caller;
// Never a default seat: a request reaches the handler only with its caller decided.
if (!caller) throw new Error("a request reached the MCP handler without its caller");
// A grant that names its person gets a client acting as them, which records only their answers to the team.
const person = caller.access?.external?.person;
return createMcpServer(makeClient(opts.daemonUrl, caller.seat, caller.principal), { seat: caller.seat, access: caller.access,
taskDestination: opts.taskDestination, presenceFile: () => presenceFile, frontDoor: opts.frontDoor === true,
unknownSender, ...(opts.unfilledSeat ? { unfilledSeat: opts.unfilledSeat } : {}),
...(person ? { personClient: makeClient(opts.daemonUrl, `${person}@external`, caller.principal) } : {}) });
return createMcpServer(makeClient(opts.daemonUrl, caller.seat, caller.principal), { seat: caller.seat, access: caller.access, presenceFile: () => presenceFile });
});
const serveMcp = toNodeHandler(mcpHandler);
const bearer = (req: http.IncomingMessage): string | undefined => /^Bearer\s+(\S+)\s*$/i.exec(req.headers.authorization ?? "")?.[1];
+56 -438
View File
@@ -1,6 +1,6 @@
import { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
import { createHash, randomBytes } from "node:crypto";
import { randomBytes } from "node:crypto";
import { statSync } from "node:fs";
import nodePath from "node:path";
import type { ToolAnnotations } from "@modelcontextprotocol/server";
@@ -12,7 +12,6 @@ import { CLI_VERSION } from "./version.js";
import { ConfigStore } from "./config-store.js";
import { readRosters } from "./commands/roster.js";
import { mcpPresenceFilePath, readPresence } from "./mcp-presence.js";
import { codeResultOf, planItemArtifact, type FileRead, type SharedRoot } from "./queue-artifact.js";
const LONG_RUNNING_UP_TIMEOUT_MS = 120_000;
const WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS = 5_000;
@@ -392,11 +391,25 @@ function externalScope(access: McpAccess, seatOf: () => string | undefined): Sco
}
/** A target outside an authenticated caller's scope. It names the kind of target only, never the record. */
function outOfScope(what: string, hint?: string): TextResult {
const message = `${what} is outside what this connection may reach.${hint ? ` ${hint}` : ""}`;
function outOfScope(what: string): TextResult {
const message = `${what} is outside what this connection may reach.`;
return errorResult(JSON.stringify({ ok: false, error: "out_of_scope", message }), "out_of_scope", message);
}
type SharedRoot = { name: string; path: string };
type FileRead = { root?: string; path?: string; content?: string; binary?: boolean; size?: number; mtime?: string; truncated?: boolean; truncatedAtBytes?: number | null; totalBytes?: number };
/** The shared root an absolute path falls under as written, and its path inside that root. The reference names a file
* where the daemon runs, which may be another machine, so it's never resolved through this machine's filesystem; the
* daemon still checks containment itself when the file is read. */
function underSharedRoot(roots: SharedRoot[], absolute: string): { root: string; path: string } | null {
for (const root of roots) {
const inside = nodePath.relative(root.path, absolute);
if (inside && inside !== ".." && !inside.startsWith(`..${nodePath.sep}`) && !nodePath.isAbsolute(inside)) return { root: root.name, path: inside };
}
return null;
}
/** A file the daemon read through a shared root, as text from offset: at most CONTEXT_TEXT_LIMIT characters, with
* nextOffset when more of what the daemon read remains, and its byte cut when the file was longer than the daemon reads. */
function artifactView(source: string, read: FileRead, offset: number): Record<string, unknown> {
@@ -405,7 +418,7 @@ function artifactView(source: string, read: FileRead, offset: number): Record<st
const text = read.content ?? "";
const end = wholeCharEnd(text, offset + CONTEXT_TEXT_LIMIT);
return {
...base, binary: false, offset, text: text.slice(offset, end), totalChars: text.length, ...codeResultOf(read),
...base, binary: false, offset, text: text.slice(offset, end), totalChars: text.length,
truncated: text.length > end || read.truncated === true,
...(text.length > end ? { nextOffset: end } : {}),
...(read.truncated ? { truncatedAtBytes: read.truncatedAtBytes, totalBytes: read.totalBytes } : {}),
@@ -461,19 +474,16 @@ const DESTRUCTIVE: ToolAnnotations = { readOnlyHint: false, destructiveHint: tru
* communication with seats) reach a team within the caller's scope; workspace tools read the work tree and context when
* the caller's grant includes them; owner tools administer the instance (launch, stop, snapshot and restore, discover and
* bind, add seats, validate or inspect local files by path, the event log) and are offered only to an owner grant for now.
* A local caller (stdio, or HTTP with no auth configured) is the owner. Person tools act for the person an external grant
* names (the team's questions to them) and are offered only to such a grant: no other caller, the owner included, has a
* person to act for. */
export type McpAccessClass = "work" | "workspace" | "owner" | "person";
* A local caller (stdio, or HTTP with no auth configured) is the owner. */
export type McpAccessClass = "work" | "workspace" | "owner";
/** An authenticated HTTP caller: the seat it acts as comes with it (the DaemonClient built for it); this is what it may
* reach. A session is in scope when it is the caller's seat or a seat of one of its rigs; an item, when its source or
* destination is. An external grant (`external`) is narrower: see externalScope. */
export type McpAccess = {
principal: string; owner: boolean; rigs: readonly string[]; roots: readonly string[]; workspace: boolean;
/** An external caller's grant: the object/action pairs it names (openrig.grant/v1 `objects`), the grant it answered
* with, and `person`, the gateway entity id of the person the grant service vouches owns this connection. */
external?: { objects: Readonly<Record<string, readonly string[]>>; grant?: { id: string; version?: number | string }; person?: string };
/** An external caller's grant: the object/action pairs it names (openrig.grant/v1 `objects`). */
external?: { objects: Readonly<Record<string, readonly string[]>> };
};
/** The tool each external object/action pair offers. A pair not listed here offers nothing. */
@@ -481,19 +491,14 @@ 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" },
artifact: { read: "rig_artifact_read" },
question: { read: "rig_question_list", answer: "rig_question_answer" },
};
/** A person tool (access class `person`) acts for the person an external grant names, so a grant without a person isn't
* offered it, whatever its objects say. The caller's seat is never taken for a person. */
const personTool = (tool: string): boolean => MCP_OPERATIONS.some((op) => op.tool === tool && op.access === "person");
function externalTools(external: NonNullable<McpAccess["external"]>): Set<string> {
function externalTools(objects: Readonly<Record<string, readonly string[]>>): Set<string> {
const tools = new Set<string>();
for (const [object, actions] of Object.entries(external.objects)) {
for (const [object, actions] of Object.entries(objects)) {
for (const action of Array.isArray(actions) ? actions : []) {
const tool = Object.hasOwn(EXTERNAL_TOOLS, object) && Object.hasOwn(EXTERNAL_TOOLS[object]!, action) ? EXTERNAL_TOOLS[object]![action] : undefined;
if (tool && (!personTool(tool) || external.person)) tools.add(tool);
if (tool) tools.add(tool);
}
}
return tools;
@@ -536,8 +541,6 @@ export const MCP_OPERATIONS: ReadonlyArray<{ tool: string; routes: readonly stri
{ tool: "rig_context_pack", routes: ["GET /api/context-packs/library/by-ref"], effect: "read", access: "workspace" },
{ tool: "rig_context_get", routes: ["GET /api/context-packs/library/resolve-address"], effect: "read", access: "workspace" },
{ tool: "rig_artifact_read", routes: ["GET /api/queue/:qitemId", "GET /api/files/roots", "GET /api/files/read"], effect: "read", access: "work" },
{ tool: "rig_question_list", routes: ["GET /api/attention", "GET /api/queue/:qitemId"], effect: "read", access: "person" },
{ tool: "rig_question_answer", routes: ["GET /api/attention", "GET /api/queue/:qitemId", "POST /api/mission-control/action"], effect: "destructive", access: "person" },
{ tool: "rig_human_decision_room", routes: ["GET /api/queue/:qitemId", "GET /api/gateway/human/:entityId/decision-room"], effect: "read", access: "work" },
{ tool: "rig_queue_decide", routes: ["POST /api/queue/:qitemId/delegated-decision"], effect: "destructive", access: "work" },
{ tool: "rig_queue_handoff", routes: ["POST /api/queue/:qitemId/handoff"], effect: "destructive", access: "work" },
@@ -569,67 +572,16 @@ const SHARED_INSTRUCTIONS = [
"A question reserved for a person is theirs to answer: don't answer it on their behalf. rig_queue_decide records a decision only inside the decision room of the mode they have on, beside their answers and never as one.",
];
const INSTRUCTIONS = [...SHARED_INSTRUCTIONS, MEMBER_WORKFLOW].join("\n");
/** rig mcp serve --front-door: the seat is a person's agent in their own app, the front door to their team, not a member
* that works its inbox. The team's questions to it are the person's to answer. */
export const FRONT_DOOR_WORKFLOW_VERSION = "front-door/v2";
export const FRONT_DOOR_WORKFLOW = [
`Front door (${FRONT_DOOR_WORKFLOW_VERSION}). This seat is a person's agent in their own app: you hand the team their work and bring back what it produced, and you aren't one of its members.`,
"Items addressed to your seat (rig_queue_list with owned set) are usually the team's questions about work you handed it. They're the person's to answer, not yours: show each to the person with the team's draft, and record only what the person tells you.",
"To record an answer, claim the item (rig_queue_claim), then close it done with closure reason no-follow-on and exactly what the person typed in resolution, with no label, quotation marks or words of your own (rig_queue_update). If the person isn't there, leave the item open and tell them it's waiting. Never choose an answer for them, and don't claim an item you can't close with their words.",
"Nothing wakes this conversation when the team finishes: check your work and your items when the person asks or comes back.",
].join(" ");
const FRONT_DOOR_INSTRUCTIONS = [...SHARED_INSTRUCTIONS, FRONT_DOOR_WORKFLOW].join("\n");
// An external caller's first paragraph says what the connection is for, on its own: a client may show the model only the
// start (ChatGPT asks that the first 512 characters stand alone). The paragraphs after it say how.
const EXTERNAL_PURPOSE = "This connection links you to the person's OpenRig team, AI agents who do work for them. Give the team a task with rig_queue_create and keep its qitemId; read its progress and answer with rig_queue_show; list your requests with rig_queue_list; read files it attached with rig_artifact_read. The work carries on after this chat ends.";
const EXTERNAL_HOW = [
"This connection acts as the caller its grant names, and the team records your requests as that caller's. Use only the tools this connection offers.",
"To ask the connected team to do work, use rig_queue_create with the task in body. You may omit destination to use the receiver configured by the server operator. If no receiver is available, ask the operator to configure it; don't guess an address. An explicit destination still must be within this connection's grant.",
"Acceptance is not completed work. If a create times out, check that same qitemId before retrying and reuse it; never retry with a new id. Use revises for a requested revision of earlier work, preserving its lineage. rig_queue_list shows your own work, including an empty list before your first request.",
"rig_artifact_read reads within the shared roots and items this connection may reach.",
];
const EXTERNAL_INSTRUCTIONS = [
`${EXTERNAL_PURPOSE} A question reserved for a person is theirs to answer: don't answer it on their behalf.`,
...EXTERNAL_HOW,
].join("\n");
/** For a grant that names the person who owns the connection: the team's questions to them can be read and answered
* here, with only what they say. */
const EXTERNAL_PERSON_INSTRUCTIONS = [
`${EXTERNAL_PURPOSE} The team's questions to the person are in rig_question_list: submit only the answer they explicitly give you, with rig_question_answer, and never choose for them.`,
...EXTERNAL_HOW,
"To answer one, show the person the question and its options as rig_question_list gives them. Then pass rig_question_answer its qitemId, the revision you showed, and either the options they chose or exactly what they typed, with no label or words of your own. If the question changed, show them the new one; if it was already answered or closed, say so. It's recorded as their answer, given through this connection. A question reserved for a person is theirs to answer, never yours.",
].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, frontDoor = false): string {
if (!access?.external) return frontDoor ? FRONT_DOOR_INSTRUCTIONS : INSTRUCTIONS;
const tools = externalTools(access.external);
if (tools.has("rig_queue_claim") && tools.has("rig_queue_update")) return EXTERNAL_INBOX_INSTRUCTIONS;
return tools.has("rig_question_list") || tools.has("rig_question_answer") ? EXTERNAL_PERSON_INSTRUCTIONS : EXTERNAL_INSTRUCTIONS;
}
/** A configured default must identify exactly one existing local node. No topology relationship, name convention
* for a lead, caller identity or task history supplies a missing receiver. Resolve again on each omitted destination. */
async function taskDestinationExists(client: DaemonClient, destination: string): Promise<boolean> {
const rigName = destination.split("@")[1]!;
const rig = await resolveRigHandle(client, rigName, { strict: true });
if (rig.kind !== "resolved") return false;
const nodes = await client.get(`/api/rigs/${encodeURIComponent(rig.id)}/nodes`);
if (nodes.status !== 200 || !Array.isArray(nodes.data)) return false;
let matches = 0;
for (const entry of nodes.data as unknown[]) {
const { logicalId, rigName: name } = (entry ?? {}) as Record<string, unknown>;
if (typeof logicalId !== "string" || name !== rigName) return false;
const dot = logicalId.indexOf(".");
if (dot <= 0 || dot === logicalId.length - 1) return false;
if (`${logicalId.slice(0, dot)}-${logicalId.slice(dot + 1)}@${name}` === destination) matches++;
}
return matches === 1;
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;
}
/**
@@ -674,84 +626,29 @@ function mapResult(
return opts.structured ? { content: [{ type: "text", text }], structuredContent: structured(res.data) } : { content: [{ type: "text", text }] };
}
/** A seat value an app passed on without filling it in: empty, or still a placeholder. */
export interface UnfilledSeat { variable: "OPENRIG_SESSION_NAME" | "RIGGED_SESSION_NAME"; value: string }
// An app that doesn't substitute its settings passes the template through as written: ${user_config.seat}, ${input:seat},
// $SEAT. No seat address can contain these.
const SEAT_PLACEHOLDER = /\$\{[^}]*\}|^\s*\$[A-Za-z_]/;
/** Treat an empty or unfilled seat as no seat, for the whole process: drops both names from env, so neither requests
* nor rig_whoami act as the placeholder, or as a seat inherited under the legacy name behind an empty one. A set
* OPENRIG_SESSION_NAME decides; RIGGED_SESSION_NAME counts only when it's absent. Returns what was dropped. */
export function dropUnfilledSeat(env: NodeJS.ProcessEnv = process.env): UnfilledSeat | undefined {
const variable = env.OPENRIG_SESSION_NAME !== undefined ? "OPENRIG_SESSION_NAME" : env.RIGGED_SESSION_NAME !== undefined ? "RIGGED_SESSION_NAME" : undefined;
const value = variable ? env[variable]! : undefined;
if (!variable || value === undefined || (value.trim() && !SEAT_PLACEHOLDER.test(value))) return undefined;
delete env.OPENRIG_SESSION_NAME;
delete env.RIGGED_SESSION_NAME;
return { variable, value };
}
/** How an MCP client named itself in its handshake, cut to one short line; it's the client's claim, never an identity. */
function clientSaid(client: { name?: unknown; version?: unknown } | undefined): string | null {
const part = (value: unknown) => typeof value === "string" ? value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, 60) : "";
const name = part(client?.name);
if (!name) return null;
const version = part(client?.version);
return JSON.stringify(version ? `${name} ${version}` : name);
}
export function unfilledSeatText(seat: UnfilledSeat): string {
return seat.value.trim()
? `${seat.variable} is ${JSON.stringify(seat.value)}, a placeholder the app didn't fill in`
: `${seat.variable} is empty`;
}
/** A label for a caller with no seat: no seat's address, and random, so each `rig mcp serve` process has its own. */
export function mintUnknownSender(): string {
return `unknown-mcp-${randomBytes(4).toString("hex")}`;
}
/**
* Creates an MCP server wrapping the daemon HTTP API.
* @param client - DaemonClient connected to the daemon
* @returns McpServer instance (not yet connected to a transport)
*/
export function createMcpServer(client: DaemonClient, options: { seat?: string; access?: McpAccess; taskDestination?: string; rostersFolder?: () => string; presenceFile?: () => string; frontDoor?: boolean; unfilledSeat?: UnfilledSeat; unknownSender?: string;
/** For an external grant that names a person: a client acting as that person, used only to record their answers. */
personClient?: DaemonClient } = {}): McpServer {
export function createMcpServer(client: DaemonClient, options: { seat?: string; access?: McpAccess; rostersFolder?: () => string; presenceFile?: () => string } = {}): McpServer {
const server = new McpServer({
name: "openrig",
version: CLI_VERSION,
}, { instructions: instructionsFor(options.access, options.frontDoor === true) });
}, { instructions: instructionsFor(options.access) });
const { access } = options;
const rostersFolder = options.rostersFolder ?? (() => nodePath.join(new ConfigStore().get("workspace.root") as string, "rosters"));
const presenceFile = options.presenceFile ?? (() => mcpPresenceFilePath());
// The seat this server acts as: the authenticated caller's, else OPENRIG_SESSION_NAME where it was configured.
const seatOf = (): string | undefined => options.seat ?? readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")?.trim();
// With no seat (none configured, or one an app left unfilled), messages, new tasks and chat still go through, as an
// unknown sender. Its label is this server's own and no seat's, so two unknown callers never count as one author (a
// delegated decision compares authors). Its note says only what is known: why there's no seat, and how the client
// describes itself in its handshake, which is the client's claim, not an identity. Every other write keeps its rules.
// HTTP builds a server per request and passes its process's label, so a caller keeps the one rig_whoami names.
const unknownSender = options.unknownSender ?? mintUnknownSender();
const unknownSenderNote = (kind: "task" | "message"): string | null => {
if (seatOf()) return null;
const why = options.unfilledSeat ? unfilledSeatText(options.unfilledSeat) : "OPENRIG_SESSION_NAME isn't set where it was configured";
const said = clientSaid(server.server.getClientVersion());
return `[Unknown sender ${unknownSender}: this app has no OpenRig seat (${why}).${said ? ` Its MCP client says it is ${said}.` : ""}`
+ (kind === "task" ? " There's no return address: it follows this by its task id.]" : " There's no return address.]");
};
const accessClass = new Map(MCP_OPERATIONS.map((op) => [op.tool, op.access]));
// A local caller (no access) is offered every tool; an authenticated one, its class's; an external caller, its
// grant's pairs.
const external = access?.external ? externalTools(access.external) : null;
const external = access?.external ? externalTools(access.external.objects) : null;
const offered = (tool: string): boolean => {
if (external) return external.has(tool);
const kind = accessClass.get(tool);
if (kind === "person") return false;
if (!access || access.owner) return true;
const kind = accessClass.get(tool);
return kind === "work" || (kind === "workspace" && access.workspace);
};
const scope = access && !access.owner ? scopeFor(access, seatOf) : null;
@@ -1138,11 +1035,9 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
}
if (scope && !scope.session(session)) return outOfScope(`Session ${session}`);
const waitForIdleMs = waitForIdleSeconds === undefined ? undefined : Math.ceil(waitForIdleSeconds * 1000);
// Unsigned, as before (the transport names it "<unknown sender>"); the note tells the recipient what is known.
const note = unknownSenderNote("message");
const res = await client.post("/api/transport/send", {
session,
text: note ? `${note}\n${text}` : text,
text,
verify,
force,
waitForIdleMs,
@@ -1199,7 +1094,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
if (offered("rig_chatroom_send")) server.registerTool(
"rig_chatroom_send",
{
description: "Send a message to a rig's chatroom as this server's seat, or, with no seat, as an unknown sender with a note saying so",
description: "Send a message to a rig's chatroom as this server's seat",
inputSchema: z.object({
rigName: z.string().describe("Rig name or identifier"),
body: z.string().describe("Message body"),
@@ -1213,10 +1108,10 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
const rig = await chatRigId(client, rigName);
if ("content" in rig) return rig;
// P21: no body sender — the daemon derives it from the transport header (X-OpenRig-Session,
// stamped by the MCP server's DaemonClient from its seat env). With no seat, the unknown sender is declared.
const note = unknownSenderNote("message");
const res = await client.post(`/api/rigs/${encodeURIComponent(rig.id)}/chat/send`, note
? { body: `${note}\n${body}`, sender: unknownSender } : { body });
// stamped by the MCP server's DaemonClient from its seat env).
const res = await client.post(`/api/rigs/${encodeURIComponent(rig.id)}/chat/send`, {
body,
});
return mapResult(res);
} catch (err) {
return caught(err);
@@ -1304,9 +1199,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
{
description: "Hand a task to another seat as a durable queue item. Returns its qitemId: read the answer later with rig_queue_show. If the call times out, check rig_queue_show with the same qitemId before retrying; never retry with a new id. To ask for a revision of earlier work, name it in revises: the new item opens with 'Revision of <id>' and records it in its chainOfRecord.",
inputSchema: z.object({
destination: access?.external
? z.string().optional().describe("Destination seat: member@rig, or its name alone (e.g. workshop-lead) within this connection's team; omit to use the connected team's configured receiver")
: z.string().describe("Destination seat (e.g. dev-impl@my-rig)"),
destination: z.string().describe("Destination seat (e.g. dev-impl@my-rig)"),
body: z.string().describe("The task: what to do and what to return"),
summary: z.string().optional().describe("One-line summary"),
tags: z.array(z.string()).optional().describe("Tags, e.g. mission:<name>"),
@@ -1319,37 +1212,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
annotations: ADDITIVE,
},
async ({ destination, body, summary, tags, priority, tier, qitemId, revises, alert }) => {
// The server's one receiver counts for this connection only when its grant reaches it. Then a refused destination
// says how to reach it, so the assistant retries instead of telling the person the connection lacks access. It
// never names the receiver.
const receiver = options.taskDestination && scope?.destination(options.taskDestination) ? options.taskDestination : undefined;
const omitHint = receiver ? "Omit destination to reach the team's configured receiver." : undefined;
if (destination === undefined && access?.external) {
destination = options.taskDestination;
const unavailable = () => errorResult("The connected team's task receiver is unavailable. Ask the server operator to configure it.",
"task_destination_unavailable", "The connected team's task receiver is unavailable. Ask the server operator to configure it.");
if (!destination) return unavailable();
// Check scope before looking up the configured receiver: configuration never expands a grant or reveals
// another rig's destination. An explicit destination continues through the existing path below.
if (!scope?.destination(destination)) return outOfScope("Configured task destination");
try {
if (!await taskDestinationExists(client, destination)) return unavailable();
} catch { return unavailable(); }
} else if (destination !== undefined && access?.external && !destination.includes("@")) {
// A person names their lead as their team shows it ("workshop-lead"), and the assistant passes that on. A name
// alone is the receiver this connection reaches when it is that seat's name, else a seat of this connection's one
// rig; never another rig's. It must be in scope and exist, or nothing is written.
const named = destination;
const resolved = receiver?.split("@")[0] === named ? receiver
: access.rigs.length === 1 ? `${named}@${access.rigs[0]}` : undefined;
let exists = false;
if (resolved && scope?.destination(resolved)) {
try { exists = await taskDestinationExists(client, resolved); } catch { /* not resolved */ }
}
if (!exists) return outOfScope(`Destination ${named}`, omitHint);
destination = resolved!;
}
if (scope && !scope.destination(destination)) return outOfScope(`Destination ${destination}`, access?.external ? omitHint : undefined);
if (scope && !scope.destination(destination)) return outOfScope(`Destination ${destination}`);
// A revision carries its original's lineage as a handoff does: the original's chain, then the original.
let lineage: { chainOfRecord: string[]; body: string } | undefined;
if (revises) {
@@ -1366,12 +1229,9 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
}
}
const id = qitemId ?? `qitem-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(8).toString("hex")}`;
// With no seat, the task goes to its destination as from the unknown sender, with the note at the top of its body.
const note = unknownSenderNote("task");
try {
const res = await client.post("/api/queue/create", {
qitemId: id, destinationSession: destination, body: note ? `${note}\n\n${lineage?.body ?? body}` : lineage?.body ?? body,
...(note ? { sourceSession: unknownSender } : {}), summary, priority, tier,
qitemId: id, destinationSession: destination, body: lineage?.body ?? body, summary, priority, tier,
tags: access?.external ? tags?.filter((tag) => !OPENRIG_PROVENANCE_TAG.test(tag)) : tags,
...(alert ? { humanAlert: true } : {}),
...(lineage ? { chainOfRecord: lineage.chainOfRecord } : {}),
@@ -1385,10 +1245,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
}
// A create's return, a retry's stored row included, goes through the same external projection as show and list.
const view = externalView(scope);
// An unknown sender has no return address, so it says how to follow the task.
const sent = note && res.status < 400 && res.data && typeof res.data === "object" ? { ...res, data: { ...res.data,
unknownSender: { label: unknownSender, followUp: `No return address: read this task with rig_queue_show and qitemId ${id}.` } } } : res;
return mapResult(view && sent.status < 400 ? { ...sent, data: projectForExternal(sent.data, view) } : sent, undefined, { structured: true });
return mapResult(view && res.status < 400 ? { ...res, data: projectForExternal(res.data, view) } : res, undefined, { structured: true });
} catch (err) {
const failure = caught(err);
const text = `${(err as Error).message} The outcome is unknown: check rig_queue_show with qitemId ${id} before retrying, and retry only with that same qitemId.`;
@@ -1455,7 +1312,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
// An external caller lists its own items only, so the daemon's limit counts those and not the team's.
const mine = mineAsked || (scope?.ownListsOnly === true && !owned);
if ((owned || mine) && !sessionName) {
const message = "owned and mine need this server's seat (rig_whoami says how to give it one), or filter by destination or source";
const message = "owned and mine need this server's seat: set OPENRIG_SESSION_NAME in its env, or filter by destination or source";
return errorResult(JSON.stringify({ ok: false, error: message }), "seat_required", message);
}
if (scope) {
@@ -1531,7 +1388,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
resolution: z.string().optional().describe("The outcome the item keeps, stored only by the update that closes an open item (as rig queue update --resolution)"),
reopen: z.boolean().optional().describe("Reopen a closed item: give an active state (pending, in-progress or blocked) and a note (as rig queue update --reopen)"),
summary: z.string().optional().describe("Replace the one-line summary"),
evidenceRef: z.string().optional().describe("Pointer to the durable artifact, stored on the item when closing with state done or parking on a human seat; other updates refuse it. For a code result, a JSON file of kind openrig.code-result/v1 with repo, base and revision (full commit ids) and paths lets a reader diff it exactly"),
evidenceRef: z.string().optional().describe("Pointer to the durable artifact, stored on the item when closing with state done or parking on a human seat; other updates refuse it"),
blockedOn: z.string().optional().describe("What it waits on, when blocking: another qitemId (this item returns to pending when that one closes), or the address of the person this item came from, not a shared channel (they're asked with this item's summary, and their answer returns it to you in progress)"),
wakeAfterSeconds: z.number().int().positive().optional().describe("Wake this seat after this many seconds, when blocking. Delivered only to a seat with a terminal: an attached app can't receive it today, so the item just waits until it is checked."),
}),
@@ -1630,18 +1487,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
try {
const seat = seatOf();
if (!seat) {
const why = options.unfilledSeat ? unfilledSeatText(options.unfilledSeat) : "OPENRIG_SESSION_NAME isn't set where it was configured";
const message = `This server has no seat: ${why}, so it acts as no seat and never as another seat. Reads work. `
+ `Messages (rig_send), new tasks (rig_queue_create) and chat go through as the unknown sender ${unknownSender}, each `
+ "with a note saying so: there's no return address, so follow a task by its qitemId with rig_queue_show. "
+ "Claiming, updating, handing off or deciding a task, answering a question asked of a seat, and listing your own "
+ "items (owned or mine) need this app's seat; reply to the team's questions as a message or a new task naming the "
+ "question's qitemId. To act as this app's address on the team, such as cursor@my-team, the server needs "
+ "OPENRIG_SESSION_NAME: in Claude Code, set the plugin's OpenRig seat; in Codex, pass it with --env on codex mcp add. "
+ "VS Code, Copilot CLI and Cursor can't give a plugin's server a value: add your own rig mcp serve --front-door "
+ "server with OPENRIG_SESSION_NAME in its env and turn the plugin's openrig server off (in VS Code, give yours "
+ "another name, such as openrig-seat). Starting the app from a shell with it set lasts only for that launch.";
const none = { ok: true, seat: null, caller, unknownSender, ...(options.unfilledSeat ? { unfilledSeat: options.unfilledSeat } : {}), message };
const none = { ok: true, seat: null, caller, message: "This server has no seat: OPENRIG_SESSION_NAME isn't set where it was configured, so its writes aren't attributed to a seat." };
return { content: [{ type: "text" as const, text: JSON.stringify(none) }], structuredContent: none };
}
const params = new URLSearchParams({ sessionName: seat, compact: "1" });
@@ -1656,17 +1502,6 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
message: "This seat has no session here: it's the seat this connection's grant names, such as a hosted member's. rig_team shows its rig and hosted members, and rig_queue_list with owned set lists its work." };
return { content: [{ type: "text" as const, text: JSON.stringify(member) }], structuredContent: member };
}
// A front door (--front-door) usually has no session either: its seat is the address the person picked for their
// app, declared in OPENRIG_SESSION_NAME. Answer with it when its rig exists here, still marked declared.
if (res.status === 404 && !access && options.frontDoor === true) {
const at = seat.indexOf("@");
const rigName = at > 0 ? seat.slice(at + 1) : "";
if (rigName && (await resolveRigHandle(client, rigName, { strict: true })).kind === "resolved") {
const frontDoor = { ok: true, seat, rig: rigName, session: null, caller,
message: "This seat has no session here: it's the front-door address this server was configured with (OPENRIG_SESSION_NAME), declared, not verified. rig_team shows its rig and lead, and rig_queue_list with owned set lists the team's questions to it." };
return { content: [{ type: "text" as const, text: JSON.stringify(frontDoor) }], structuredContent: frontDoor };
}
}
if (res.status >= 400) return mapResult(res);
return mapResult({ ...res, data: { ...(res.data as Record<string, unknown>), caller } }, undefined, { structured: true });
} catch (err) {
@@ -1826,7 +1661,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
if (offered("rig_artifact_read")) server.registerTool(
"rig_artifact_read",
{
description: `Read a finished item's artifact (its evidenceRef), or a named file, through the daemon's shared roots (OPENRIG_FILES_ALLOWLIST), as text: at most ${CONTEXT_TEXT_LIMIT} characters from offset; nextOffset reads on. An artifact outside every shared root, a link, or an item with no evidenceRef comes back as available: false with the reason and the shared roots' names; nothing outside those roots is read. A code-result manifest (kind openrig.code-result/v1) also comes back parsed as codeResult: its repo, the exact base and revision commits, and paths; a branch or fetch ref in it is a hint, never the revision.`,
description: `Read a finished item's artifact (its evidenceRef), or a named file, through the daemon's shared roots (OPENRIG_FILES_ALLOWLIST), as text: at most ${CONTEXT_TEXT_LIMIT} characters from offset; nextOffset reads on. An artifact outside every shared root, a link, or an item with no evidenceRef comes back as available: false with the reason and the shared roots' names; nothing outside those roots is read.`,
inputSchema: z.object({
qitemId: z.string().optional().describe("Read this item's evidenceRef"),
root: z.string().optional().describe("A shared root's name: with path, the file to read; with qitemId, the root a relative evidenceRef is under"),
@@ -1859,232 +1694,15 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
const unavailable = (reason: string) => mapResult({ status: 200, data: {
available: false, qitemId, reason, evidenceRef: typeof ref === "string" ? ref : null, roots: roots.map((entry) => entry.name),
} }, undefined, { structured: true });
const plan = planItemArtifact(ref, roots, root);
if ("unavailable" in plan) return unavailable(plan.unavailable);
return await read("evidenceRef", plan.read.root, plan.read.path);
} catch (err) {
return caught(err);
}
},
);
// The person an external grant names: the connection's owner, as the grant service vouches. The team's questions to
// them are what Steering shows them as answerable, read from Steering's own view of them, and answered through
// Steering's write path (answer for an ask, resolve for a park), as them, through their own client. The caller's seat
// is only the route the answer came by.
const person = access?.external?.person;
const personAddress = person ? `${person}@external` : undefined;
type Answerable = { qitemId: string; verb: "answer" | "approve" | "resolve"; item: Record<string, any> };
const answerable = async (): Promise<Answerable[] | TextResult> => {
const res = await client.get(`/api/attention?view=${encodeURIComponent(person ?? "")}`);
if (res.status >= 400) return mapResult(res);
const data = (res.data ?? {}) as { items?: Array<Record<string, any>>; perspective?: { viewing?: unknown; basis?: unknown } };
// With no perspective for this person (they aren't registered, or no one is), Steering's view shows every request,
// so nothing is anyone's to answer here.
if (!person || data.perspective?.basis !== "chosen-here" || data.perspective.viewing !== personAddress) {
const message = `This connection's person (${personAddress ?? "none"}) isn't registered on this team, so no question can be read or answered for them here.`;
return errorResult(JSON.stringify({ ok: false, error: "person_not_registered", message }), "person_not_registered", message);
}
return (data.items ?? []).flatMap((item) => {
const qitemId = typeof item.id === "string" && item.id.startsWith("queue:") ? item.id.slice("queue:".length) : "";
const verb = item.answer?.verb;
return qitemId && (verb === "answer" || verb === "approve" || verb === "resolve") ? [{ qitemId, verb, item }] : [];
});
};
/** Whether a row read after the view is still the question the view named: open for its verb, with the same recipient.
* A park moved to another person between the two reads is theirs now, so nothing of it is shown or answered here. */
const stillListed = (entry: Answerable, row: Record<string, any>): boolean => entry.verb === "resolve"
? row.state === "blocked" && row.blockedOn === entry.item.recipient
: row.state === "pending" && row.destinationSession === entry.item.recipient;
/** One question as Steering shows it to the person, with the revision their answer must name, or null once the row is
* no longer the question the view named. */
const questionView = async (entry: Answerable, read?: Record<string, any>): Promise<Record<string, unknown> | null> => {
let row = read;
if (!row) {
const res = await client.get(`/api/queue/${encodeURIComponent(entry.qitemId)}`);
if (res.status >= 400) return null;
row = (res.data ?? {}) as Record<string, any>;
}
if (!stillListed(entry, row)) return null;
const questions = Array.isArray(row.humanQuestions) && row.humanQuestions.length > 0 ? row.humanQuestions : null;
return {
qitemId: entry.qitemId,
kind: entry.verb === "resolve" ? "parked" : "ask",
from: entry.verb === "resolve" ? row.destinationSession : row.sourceSession,
summary: entry.item.summary,
body: row.body,
...(row.humanDetail ? { detail: row.humanDetail } : {}),
...(row.evidenceRef ? { evidenceRef: row.evidenceRef } : {}),
...(questions ? { questions, answered: row.humanAnswers ?? {} } : {}),
answerWith: entry.verb === "answer" && questions ? "choices or words" : "words",
revision: row.decisionRevision,
askedAt: row.tsCreated,
};
};
const QUESTION_LIST_LIMIT = 25;
// 32a. rig_question_list — the team's questions to the person who owns this connection
if (offered("rig_question_list")) server.registerTool(
"rig_question_list",
{
description: "List the team's questions waiting on the person who owns this connection: what Steering shows them to answer. Each comes with its options when it has any, and the revision to name when you submit the person's answer with rig_question_answer.",
inputSchema: z.object({}),
annotations: READ_ONLY,
},
async () => {
try {
const found = await answerable();
if (!Array.isArray(found)) return found;
const items = (await Promise.all(found.slice(0, QUESTION_LIST_LIMIT).map((entry) => questionView(entry)))).filter((view) => view !== null);
return mapResult({ status: 200, data: { person: personAddress, items, ...(found.length > QUESTION_LIST_LIMIT ? { more: found.length - QUESTION_LIST_LIMIT } : {}) } }, undefined, { structured: true });
} catch (err) {
return caught(err);
}
},
);
// 32b. rig_question_answer — the person's own answer to one of those questions, given through this connection
if (offered("rig_question_answer")) server.registerTool(
"rig_question_answer",
{
description: "Submit the person's answer to a question rig_question_list showed them: only what they explicitly told you, never your own choice. Name the revision you showed them, and give either the options they chose (one per question) or exactly what they typed, with no label or words of your own. It's recorded as their answer, given through this connection, and the team's work continues. After an uncertain result, send the same call again: it confirms or finishes what was recorded.",
inputSchema: z.object({
qitemId: z.string().describe("The question's qitemId, from rig_question_list"),
revision: z.string().describe("The revision rig_question_list gave for the question you showed the person"),
choices: z.array(z.object({ questionId: z.string(), optionId: z.string() })).min(1).max(4).optional()
.describe("The options the person chose, one per question"),
words: z.string().min(1).optional().describe("Exactly what the person typed, when they answered in their own words"),
}),
annotations: DESTRUCTIVE,
},
async ({ qitemId, revision, choices, words }) => {
const refuse = (code: string, message: string, extra: Record<string, unknown> = {}): TextResult =>
errorResult(JSON.stringify({ ok: false, error: code, message, ...extra }), code, message, extra);
try {
if ((choices === undefined) === (words === undefined)) return refuse("answer_shape", "Give either the options the person chose or exactly what they typed, not both.");
const personClient = options.personClient;
if (!personClient) return refuse("person_unavailable", "This connection can't act for its person here.");
const found = await answerable();
if (!Array.isArray(found)) return found;
const read = await client.get(`/api/queue/${encodeURIComponent(qitemId)}`);
const row = read.status < 400 ? (read.data ?? {}) as Record<string, any> : null;
const listed = found.find((candidate) => candidate.qitemId === qitemId);
const entry = listed && row && stillListed(listed, row) ? listed : undefined;
// What became of a question is said only when it was addressed to, or parked on, this person.
const theirs = row !== null && (row.destinationSession === personAddress || row.blockedOn === personAddress);
const notWaiting = (): TextResult => theirs
? refuse("not_waiting_on_person", `That question isn't waiting on ${personAddress} any more: it's ${row!.state}.`, { state: row!.state, answered: row!.humanAnswers ?? null })
: refuse("not_waiting_on_person", "That question isn't waiting on this connection's person: it was answered or closed, or it isn't theirs.");
// Out of the person's view, only a row no longer open for any answer is asked about: an identical retry there
// learns whether this same operation settled it. An open row out of view is never answered.
if (!row || (!entry && (row.state === "pending" || row.state === "blocked"))) return notWaiting();
// A settled park is back at work and a settled ask is done. The verb shapes only the daemon's fingerprint: the
// operation id on its last action is what's checked.
const verb = entry ? (entry.verb === "resolve" ? "resolve" : "answer") : row.state === "in-progress" ? "resolve" : "answer";
const questions: Array<{ id: string; options: Array<{ id: string }> }> = Array.isArray(row.humanQuestions) ? row.humanQuestions : [];
if (entry && choices) {
if (entry.verb !== "answer" || questions.length === 0) return refuse("answer_shape", "This question takes the person's words, not options.");
// Options are checked against the question the person saw. If it has changed since, the daemon says so: that's
// the truth to tell them, rather than that their choice isn't an option.
if (row.decisionRevision === revision) {
const seen = new Set<string>();
for (const { questionId, optionId } of choices) {
const question = questions.find((q) => q.id === questionId);
if (!question || seen.has(questionId) || !question.options.some((option) => option.id === optionId)) {
return refuse("unknown_option", `${questionId}/${optionId} isn't one of this question's options (one choice per question).`, { questions });
}
seen.add(questionId);
}
}
if (typeof ref !== "string" || !ref.trim()) return unavailable("This item names no artifact: it has no evidenceRef.");
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(ref)) return unavailable("The evidenceRef is a link, not a file in a shared root: open it where it lives.");
if (roots.length === 0) return unavailable("This daemon shares no roots (OPENRIG_FILES_ALLOWLIST is empty), so no artifact can be read through it.");
if (!nodePath.isAbsolute(ref)) {
return root ? await read("evidenceRef", root, ref) : unavailable("The evidenceRef is a relative path: name the shared root it's under (root).");
}
// The route the answer came by, recorded beside it: never the person, and never the caller's own judgment.
const grant = access?.external?.grant;
const route = { route: "external-assistant", assistantSeat: seatOf(), principal: access?.principal, ...(grant ? { grant: grant.id, grantVersion: grant.version } : {}), person: personAddress };
// One operation per submission, one id per step: a retry of the same answer to the same revision is recognised by
// these ids, never by equal answers.
const operation = `assistant:${createHash("sha256").update(JSON.stringify([grant?.id ?? access?.principal, qitemId, revision, choices ?? null, words ?? null])).digest("hex").slice(0, 24)}`;
const steps: Array<Record<string, string>> = choices ? choices.map((choice) => ({ ...choice })) : [{ decision: words! }];
const ownStep = (id: unknown): number | null => {
if (typeof id !== "string" || !id.startsWith(`${operation}:`)) return null;
const step = Number(id.slice(operation.length + 1));
return Number.isInteger(step) && step >= 0 && step < steps.length ? step : null;
};
const label = (step: Record<string, string>): string => step.questionId ?? "their words";
/** What this call knows when it stops at step `at`: the steps before it are recorded, `at` itself is unknown when
* its send got no answer, and the rest weren't sent. Nothing is read back from the row. */
const stopped = (code: string, why: string, at: number, unknown: boolean, extra: Record<string, unknown> = {}): TextResult => {
const recorded = steps.slice(0, at).map(label);
const indeterminate = unknown ? label(steps[at]!) : null;
const notRecorded = steps.slice(unknown ? at + 1 : at).map(label);
const said = [
recorded.length ? `Recorded: ${recorded.join(", ")}.` : unknown ? "" : "This call recorded nothing.",
indeterminate ? `Unknown whether ${indeterminate} was recorded.` : "",
notRecorded.length && (recorded.length || unknown) ? `Not recorded: ${notRecorded.join(", ")}.` : "",
unknown ? "Send the same call again to confirm what was recorded." : "",
].filter(Boolean).join(" ");
const partial = recorded.length || unknown ? { recorded, ...(indeterminate ? { indeterminate } : {}), notRecorded } : {};
return refuse(code, `${why} ${said}`.trim(), { ...partial, ...extra });
};
let expected = revision;
let next = 0;
let fresh = 0;
let last: Record<string, any> | null = null;
while (next < steps.length) {
let res: DaemonResponse<Record<string, any>>;
try {
res = await personClient.post<Record<string, any>>("/api/mission-control/action", {
verb, qitemId, ...steps[next], expectedRevision: expected, auditNotes: { operationId: `${operation}:${next}`, ...route },
});
} catch (err) {
// No answer from the daemon (a timeout, a body that isn't JSON): this step may or may not have landed.
const failed = caught(err).structuredContent as { code: string; message: string };
return stopped(failed.code, failed.message, next, true);
}
let confirmed = false;
if (res.status < 400) {
confirmed = res.data?.reconciled === true;
if (!confirmed) fresh += 1;
last = res.data ?? null;
expected = typeof res.data?.decisionRevision === "string" ? res.data.decisionRevision : expected;
next += 1;
} else {
const failure = (res.data ?? {}) as Record<string, any>;
// This submission's own step is the last action on the question (its result was lost, or a later step failed):
// it's recorded. Any other change is refused.
const own = failure.error === "decision_revision_changed" ? ownStep(failure.lastAction?.auditNotes?.operationId) : null;
if (own !== null && own >= next) {
confirmed = true;
next = own + 1;
} else {
if (!entry) return notWaiting();
if (failure.error === "decision_revision_changed") {
const still = await answerable();
const again = Array.isArray(still) ? still.find((candidate) => candidate.qitemId === qitemId) : undefined;
const view = again ? await questionView(again) : null;
return view
? stopped("question_changed", "The question changed since the revision the person saw. Show them the current question and ask again.", next, false, { current: view })
: stopped("not_waiting_on_person", "The question was answered or closed by someone else.", next, false);
}
// A refusal from the daemon (4xx) records nothing; a server error (5xx) may have landed.
const refused = (mapResult(res).structuredContent ?? {}) as { code?: string; message?: string };
return stopped(refused.code ?? "request_failed", refused.message ?? "The answer stopped.", next, res.status >= 500);
}
}
// A retry never resumes after a part it confirms: since that part was recorded the question may have moved, or
// someone may have answered the rest (a Slack click leaves no action to see). The rest goes in a new answer to
// the question as it is now.
if (confirmed && next < steps.length) {
return stopped("question_changed", "This answer's earlier parts were already recorded, so the rest wasn't sent. Read the question again with rig_question_list and submit the rest as a new answer.", next, false);
}
}
// The receipt says only what this call knows: what was submitted and what the daemon answered it, never a fresh read
// of the row, which may since belong to someone else.
const open = verb === "answer" && fresh > 0 && last?.answer?.outcome === "recorded";
const outcome = fresh === 0 ? "already-recorded" : open ? "recorded" : "answered";
const message = outcome === "already-recorded" ? "This answer was already recorded; nothing new was sent."
: open ? "Recorded. The question still has parts the person hasn't answered."
: `Recorded as ${personAddress}'s answer, given through this connection. The team's work continues.`;
return mapResult({ status: 200, data: { ok: true, outcome, qitemId, submitted: choices ?? { words }, message } }, undefined, { structured: true });
const shared = underSharedRoot(roots, ref);
if (!shared) return unavailable("The evidenceRef is outside every shared root as the daemon lists them, so it isn't read. If it reaches a root through a link, read the file with root and path.");
return await read("evidenceRef", shared.root, shared.path);
} catch (err) {
return caught(err);
}
-75
View File
@@ -1,75 +0,0 @@
import nodePath from "node:path";
/** A shared root as GET /api/files/roots lists it, and a file as GET /api/files/read returns it. */
export type SharedRoot = { name: string; path: string };
export type FileRead = { root?: string; path?: string; content?: string; binary?: boolean; size?: number; mtime?: string; truncated?: boolean; truncatedAtBytes?: number | null; totalBytes?: number };
/** The shared root an absolute path falls under as written, and its path inside that root. The reference names a file
* where the daemon runs, which may be another machine, so it's never resolved through this machine's filesystem; the
* daemon still checks containment itself when the file is read. */
export function underSharedRoot(roots: SharedRoot[], absolute: string): { root: string; path: string } | null {
for (const root of roots) {
const inside = nodePath.relative(root.path, absolute);
if (inside && inside !== ".." && !inside.startsWith(`..${nodePath.sep}`) && !nodePath.isAbsolute(inside)) return { root: root.name, path: inside };
}
return null;
}
/** Which file an item's evidenceRef names under the daemon's shared roots, or why none is read. `root` names the shared
* root a relative evidenceRef is under. Shared by rig_artifact_read and rig queue artifact. */
export function planItemArtifact(ref: unknown, roots: SharedRoot[], root?: string): { read: { root: string; path: string } } | { unavailable: string } {
if (typeof ref !== "string" || !ref.trim()) return { unavailable: "This item names no artifact: it has no evidenceRef." };
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(ref)) return { unavailable: "The evidenceRef is a link, not a file in a shared root: open it where it lives." };
if (roots.length === 0) return { unavailable: "This daemon shares no roots (OPENRIG_FILES_ALLOWLIST is empty), so no artifact can be read through it." };
if (!nodePath.isAbsolute(ref)) {
return root ? { read: { root, path: ref } } : { unavailable: "The evidenceRef is a relative path: name the shared root it's under (root)." };
}
const shared = underSharedRoot(roots, ref);
return shared ? { read: shared }
: { unavailable: "The evidenceRef is outside every shared root as the daemon lists them, so it isn't read. If it reaches a root through a link, read the file with root and path." };
}
export const CODE_RESULT_KIND = "openrig.code-result/v1";
/** A code result's exact pair: the commit the work started from and the one it produced, in one repository. */
export interface CodeResult {
repo: string;
base: string;
revision: string;
paths: string[];
/** Informational: a branch moves, so it's never the revision. */
branch?: string;
/** Where the revision can be fetched from when a local clone lacks it. */
fetch?: { remote: string; ref: string };
}
// A full commit id, SHA-1 or SHA-256. A short id or a branch isn't exact.
const COMMIT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
/** The code result in an artifact an assignee wrote as its evidenceRef: only from a whole text file of this kind whose
* base and revision are full commit ids. A file of the kind that doesn't qualify says why; any other file is no code
* result, and reads as before. */
export function codeResultOf(read: FileRead): { codeResult: CodeResult } | { codeResultIssue: string } | null {
if (read.binary || read.truncated || typeof read.content !== "string") return null;
let doc: unknown;
try { doc = JSON.parse(read.content); } catch { return null; }
if (!isRecord(doc) || doc.kind !== CODE_RESULT_KIND) return null;
const issue = (why: string) => ({ codeResultIssue: `${CODE_RESULT_KIND}: ${why}` });
if (typeof doc.repo !== "string" || !doc.repo.trim()) return issue("repo must name the repository, as an absolute path or a URL");
for (const key of ["base", "revision"] as const) {
const value = doc[key];
if (typeof value !== "string" || !COMMIT.test(value)) return issue(`${key} must be a full commit id (40 or 64 lowercase hex), not a branch or a short id`);
}
const { paths, branch, fetch } = doc;
if (paths !== undefined && (!Array.isArray(paths) || !paths.every((p) => typeof p === "string" && p.length > 0))) return issue("paths must be a list of repository paths");
if (branch !== undefined && typeof branch !== "string") return issue("branch must be a string");
if (fetch !== undefined && !(isRecord(fetch) && typeof fetch.remote === "string" && fetch.remote.trim() && typeof fetch.ref === "string" && fetch.ref.trim())) {
return issue("fetch must name a remote and a ref");
}
return { codeResult: {
repo: doc.repo, base: doc.base as string, revision: doc.revision as string, paths: (paths as string[] | undefined) ?? [],
...(typeof branch === "string" ? { branch } : {}),
...(isRecord(fetch) ? { fetch: { remote: fetch.remote as string, ref: fetch.ref as string } } : {}),
} };
}
+3 -4
View File
@@ -67,9 +67,8 @@ const str = (v: unknown): string | null => (typeof v === "string" && v !== "" ?
const isRow = (v: unknown): v is Row => typeof v === "object" && v !== null && !Array.isArray(v);
function showCommand(id: string, remoteHost?: string): string {
return remoteHost
? `rig queue show ${shellQuote(id)} --host ${shellQuote(remoteHost)} --full --json`
: `rig queue show ${shellQuote(id)} --full --json`;
const show = `rig queue show ${shellQuote(id)} --full --json`;
return remoteHost ? `OPENRIG_URL='<daemon-url of host ${remoteHost}>' ${show}` : show;
}
interface WakeEvidence {
@@ -96,7 +95,7 @@ function wakeEvidence(row: Row, ctx: ReceiptContext): WakeEvidence {
}
const maybeExisting = ctx.idGiven
? "; if --id named an existing row, it was returned with no new wake"
: ctx.remoteHost && (ctx.verb === "handoff" || ctx.verb === "handoff-and-complete")
: ctx.remoteHost && ctx.verb !== "create"
? "; a re-driven cross-host handoff returns the existing successor with no new wake"
: "";
const earlier = maybeExisting ? { maybeEarlier: true as const } : {};
+8 -14
View File
@@ -6,7 +6,6 @@ import type { ConfigStore, RiggedConfig } from "./config-store.js";
import type { DaemonStatus } from "./daemon-lifecycle.js";
import { buildTmuxControlFailure, probeTmuxControlAsync } from "./tmux-health.js";
import { classifyNodeVersion } from "./node-support.js";
import { isBlockedConnectionCode } from "./daemon-reachability.js";
export interface PreflightCheck {
name: string;
@@ -50,23 +49,22 @@ interface WritableHomeCheckDeps {
checkWritable?: (path: string) => void;
}
function checkPort(host: string, port: number): Promise<"available" | "occupied" | { blocked: string }> {
if (port <= 0) return Promise.resolve("available"); // port 0 is always available
function checkPort(host: string, port: number): Promise<boolean> {
if (port <= 0) return Promise.resolve(true); // port 0 is always available
return new Promise((resolve) => {
const socket = new net.Socket();
socket.setTimeout(1000);
socket.on("connect", () => {
socket.destroy();
resolve("occupied"); // port is in use
resolve(false); // port is in use
});
socket.on("error", (error) => {
socket.on("error", () => {
socket.destroy();
const code = (error as NodeJS.ErrnoException).code;
resolve(isBlockedConnectionCode(code) ? { blocked: code } : "available");
resolve(true); // port is available
});
socket.on("timeout", () => {
socket.destroy();
resolve("available"); // timeout = nothing listening
resolve(true); // timeout = nothing listening
});
socket.connect(port, host);
});
@@ -178,16 +176,12 @@ export class SystemPreflight {
(status.host ?? "127.0.0.1") === effectiveHost &&
status.port === effectivePort;
if (daemonOnSameEndpoint && isBlockedConnectionCode(status.probeErrorCode)) {
checks.push({ name: "port_available", ok: true, warning: `Port availability could not be checked: the connection was blocked (${status.probeErrorCode}). A daemon may still be running.` });
} else if (daemonOnSameEndpoint) {
if (daemonOnSameEndpoint) {
// Our daemon is already running on this exact endpoint — skip
checks.push({ name: "port_available", ok: true });
} else {
const available = await checkPort(effectiveHost, effectivePort);
if (typeof available === "object") {
checks.push({ name: "port_available", ok: true, warning: `Port availability could not be checked: the connection was blocked (${available.blocked}). A daemon may still be running.` });
} else if (available === "available") {
if (available) {
checks.push({ name: "port_available", ok: true });
} else {
checks.push({
-146
View File
@@ -1,146 +0,0 @@
// @openrig/acp: the one-executable launcher the ACP registry starts with npx. It must stay in lockstep with the CLI it
// pins (a release that bumps one bumps all of them), pass its listing's checks, and hand the app's stdio, arguments,
// environment, working directory, exit code and signals straight to `rig acp serve`.
import { afterEach, describe, expect, it } from "vitest";
import { spawn } from "node:child_process";
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { ConfigStore } from "../src/config-store.js";
import { acpReceiver } from "../src/commands/acp.js";
const repo = fileURLToPath(new URL("../../../", import.meta.url));
const json = (path: string) => JSON.parse(readFileSync(join(repo, path), "utf8"));
const cli = json("packages/cli/package.json");
const launcher = json("packages/acp/package.json");
const listing = json("packages/acp/registry/agent.json");
describe("@openrig/acp stays in lockstep with @openrig/cli", () => {
it("has the CLI's version, pins exactly that CLI, and has one executable", () => {
expect(launcher.name).toBe("@openrig/acp");
expect(launcher.version).toBe(cli.version);
expect(launcher.dependencies).toEqual({ "@openrig/cli": cli.version });
expect(launcher.bin).toEqual({ "openrig-acp": "bin/openrig-acp.js" });
expect(statSync(join(repo, "packages/acp/bin/openrig-acp.js")).mode & 0o111).not.toBe(0);
});
it("the registry listing names this version of the launcher, with no arguments", () => {
expect(listing).toMatchObject({ id: "openrig", name: "OpenRig", version: cli.version, license: "Apache-2.0" });
expect(listing.distribution).toEqual({ npx: { package: `@openrig/acp@${cli.version}` } });
});
it("the icon passes the registry's checks: 16x16, currentColor only, no comments", () => {
const svg = readFileSync(join(repo, "packages/acp/registry/icon.svg"), "utf8");
expect(svg).not.toContain("<!--");
const root = svg.match(/^<svg\b[^>]*>/)![0];
expect(root).toMatch(/\swidth="16"/);
expect(root).toMatch(/\sheight="16"/);
const paints = [...svg.matchAll(/\s(?:fill|stroke)="([^"]*)"/g)].map((m) => m[1]!.toLowerCase());
expect(paints).toContain("currentcolor");
expect(paints.every((v) => ["currentcolor", "none", "inherit"].includes(v))).toBe(true);
expect(svg).not.toMatch(/style=|<style/);
});
});
describe("acp.to: the receiver a registry launch uses", () => {
it("is empty by default, takes member@rig, and refuses anything else", () => {
const dir = mkdtempSync(join(tmpdir(), "acp-to-"));
try {
const store = new ConfigStore(join(dir, "config.json"));
expect(store.get("acp.to")).toBe("");
for (const bad of ["lead", "lead@", "@team", "lead @team", "a@b@c"]) expect(() => store.set("acp.to", bad)).toThrow(/Invalid acp\.to/);
store.set("acp.to", "lead@my-team");
expect(store.resolve().acp).toEqual({ to: "lead@my-team" });
store.set("acp.to", "");
expect(store.get("acp.to")).toBe("");
} finally { rmSync(dir, { recursive: true, force: true }); }
});
it("--to wins; a settings file that can't be read is the reason the app's sign-in step shows", () => {
const dir = mkdtempSync(join(tmpdir(), "acp-to-"));
try {
const store = new ConfigStore(join(dir, "config.json"));
expect(acpReceiver(undefined, store)).toEqual({ receiver: undefined });
store.set("acp.to", "lead@my-team");
expect(acpReceiver(undefined, store)).toEqual({ receiver: "lead@my-team" });
writeFileSync(join(dir, "config.json"), "{not json");
expect(acpReceiver(" other@team ", store)).toEqual({ receiver: "other@team" });
const unreadable = acpReceiver(undefined, store);
expect("error" in unreadable && unreadable.error).toMatch(/malformed[\s\S]*adding --to <member@rig> to the agent's command skips it\.$/);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
});
describe("openrig-acp hands everything to rig acp serve", () => {
const dirs: string[] = [];
afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); });
/** The launcher installed beside a stand-in @openrig/cli (the version it pins, unless given) whose `rig` reports what it was given. */
function install(withCli = true, cliVersion: string = cli.version): string {
const root = mkdtempSync(join(tmpdir(), "openrig-acp-"));
dirs.push(root);
const acp = join(root, "node_modules/@openrig/acp");
mkdirSync(join(acp, "bin"), { recursive: true });
copyFileSync(join(repo, "packages/acp/package.json"), join(acp, "package.json"));
copyFileSync(join(repo, "packages/acp/bin/openrig-acp.js"), join(acp, "bin/openrig-acp.js"));
if (withCli) {
const fake = join(root, "node_modules/@openrig/cli");
mkdirSync(join(fake, "dist"), { recursive: true });
writeFileSync(join(fake, "package.json"), JSON.stringify({ name: "@openrig/cli", version: cliVersion, type: "module", bin: { rig: "dist/rig.js", "openrig-tui": "dist/tui.js" } }));
writeFileSync(join(fake, "dist/rig.js"), [
`process.stdout.write(JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd(), mark: process.env.FAKE_MARK }) + "\\n");`,
`process.stdin.on("data", (d) => process.stdout.write(d));`,
`process.stdin.on("end", () => process.exit(Number(process.env.FAKE_EXIT ?? 0)));`,
].join("\n"));
}
mkdirSync(join(root, "work"));
return root;
}
function run(root: string, args: string[], env: Record<string, string> = {}) {
const child = spawn(process.execPath, [join(root, "node_modules/@openrig/acp/bin/openrig-acp.js"), ...args],
{ cwd: join(root, "work"), env: { ...process.env, ...env }, stdio: ["pipe", "pipe", "pipe"] });
let out = "", err = "";
child.stdout.on("data", (d: Buffer) => { out += d.toString(); });
child.stderr.on("data", (d: Buffer) => { err += d.toString(); });
const done = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => child.on("exit", (code, signal) => resolve({ code, signal })));
return { child, done, out: () => out, err: () => err };
}
it("passes arguments, stdin, stdout, environment, working directory and the exit code through", async () => {
const root = install();
const app = run(root, ["--to", "lead@team"], { FAKE_MARK: "from-the-app", FAKE_EXIT: "3" });
app.child.stdin.end('{"jsonrpc":"2.0","id":1,"method":"initialize"}\n');
expect(await app.done).toEqual({ code: 3, signal: null });
const [first, echoed] = app.out().trim().split("\n");
expect(JSON.parse(first!)).toEqual({ argv: ["acp", "serve", "--to", "lead@team"], cwd: realpathSync(join(root, "work")), mark: "from-the-app" });
expect(echoed).toBe('{"jsonrpc":"2.0","id":1,"method":"initialize"}');
expect(app.err()).toBe("");
});
it("a signal from the app reaches the server, and the launcher ends the way the server did", async () => {
const app = run(install(), []);
await new Promise<void>((resolve) => app.child.stdout.once("data", () => resolve()));
app.child.kill("SIGTERM");
expect(await app.done).toEqual({ code: null, signal: "SIGTERM" });
});
it("without its @openrig/cli it says so on stderr, writes nothing to stdout, and exits 1", async () => {
const app = run(install(false), []);
app.child.stdin.end();
expect(await app.done).toEqual({ code: 1, signal: null });
expect(app.out()).toBe("");
expect(app.err()).toMatch(/^openrig-acp: @openrig\/cli isn't installed beside this package/);
});
it("an @openrig/cli of another version is named on stderr and run anyway, with the arguments and exit code passed", async () => {
const root = install(true, "0.0.0");
const app = run(root, ["--to", "lead@team"], { FAKE_EXIT: "3" });
app.child.stdin.end();
expect(await app.done).toEqual({ code: 3, signal: null });
expect(JSON.parse(app.out().trim().split("\n")[0]!).argv).toEqual(["acp", "serve", "--to", "lead@team"]);
expect(app.err()).toMatch(/^openrig-acp: found @openrig\/cli 0\.0\.0 at /);
expect(app.err()).toContain(`not the ${cli.version} this package pins; running it anyway. Reinstall @openrig/acp to get ${cli.version}.`);
});
});
-739
View File
@@ -1,739 +0,0 @@
// rig acp serve against the real queue: the daemon's queue and answer routes over a database with every migration, on
// loopback, driven by a scripted ACP client over a stream pair. The team's moves go through the same HTTP routes a seat's
// CLI uses, each as its own seat.
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { PassThrough } from "node:stream";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type Database from "better-sqlite3";
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 { queueRoutes } from "../../daemon/src/routes/queue.js";
import { missionControlRoutes } from "../../daemon/src/routes/mission-control.js";
import { MissionControlWriteContract } from "../../daemon/src/domain/mission-control/mission-control-write-contract.js";
import { MissionControlActionLog } from "../../daemon/src/domain/mission-control/mission-control-action-log.js";
import { DaemonClient } from "../src/client.js";
import { daemonTeamQueue, unregisteredPersonNotice } from "../src/commands/acp.js";
import { addHumanFragment } from "../../daemon/src/domain/gateway/human-registry.js";
import { AUTH_METHOD_ID, serveAcpAgent, type AcpAgentOptions, type TeamConnection, type TeamQueue } from "../src/acp/agent-server.js";
import { JsonRpcRequestError, StreamJsonRpcPeer } from "../src/acp/json-rpc-stdio.js";
const CALLER = "human-ada@external";
const LEAD = "lead@team";
const IMPL = "impl@team";
let db: Database.Database;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let server: any;
let port = 0;
const url = () => `http://127.0.0.1:${port}`;
const ambient = ["OPENRIG_URL", "RIGGED_URL", "OPENRIG_PORT", "OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME"].map((key) => [key, process.env[key]] as const);
beforeAll(async () => {
for (const [key] of ambient) delete process.env[key];
db = createDb();
migrate(db, ALL_MIGRATIONS);
const bus = new EventBus(db);
// The caller is a registered person, so the team's asks reach them and their answers record as theirs.
const registry = { ok: true as const, entities: [{ entityId: "human-ada", class: "human" as const, displayName: "Ada", address: CALLER, connectorBindings: [],
prefs: { deliveryClass: "B" as const, availability: "available" as const } }] };
const repo = new QueueRepository(db, bus, { validateRig: (session) => !session.endsWith("@gone-rig"), loadHumanRegistry: () => registry as never });
const writeContract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo: repo, actionLog: new MissionControlActionLog(db) });
const app = new Hono();
app.use("*", async (c, next) => {
c.set("eventBus" as never, bus); c.set("queueRepo" as never, repo); c.set("missionControlWriteContract" as never, writeContract as never);
await next();
});
app.route("/api/queue", queueRoutes());
app.route("/api/mission-control", missionControlRoutes());
await new Promise<void>((resolve) => { server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 }, (info: { port: number }) => { port = info.port; resolve(); }); });
});
afterAll(async () => {
if (server) await new Promise<void>((resolve) => server.close(() => resolve()));
db?.close();
for (const [key, value] of ambient) if (value !== undefined) process.env[key] = value;
});
const ends: PassThrough[] = [];
afterEach(() => { for (const end of ends.splice(0)) end.end(); });
/** A scripted ACP client wired to an in-process rig acp serve. */
function connect(overrides: Partial<AcpAgentOptions> = {}, receiver: string | null = LEAD, wrap: (queue: TeamQueue) => TeamQueue = (q) => q) {
const toAgent = new PassThrough();
const fromAgent = new PassThrough();
ends.push(toAgent);
const out: string[] = [];
fromAgent.on("data", (chunk: Buffer) => out.push(chunk.toString()));
const team: TeamConnection = { queue: wrap(daemonTeamQueue(new DaemonClient(url(), { sessionName: CALLER }))), caller: CALLER, receiver: receiver ?? undefined };
const agent = new StreamJsonRpcPeer(toAgent, fromAgent);
serveAcpAgent(agent, { connect: async () => team, version: "9.9.9-test", pollMs: 10, closed: agent.closed, ...overrides });
const inbound: Array<Record<string, any>> = [];
const client = new StreamJsonRpcPeer(fromAgent, toAgent, (direction, packet) => { if (direction === "in") inbound.push(packet as Record<string, any>); });
const updates: Array<Record<string, unknown>> = [];
client.onNotification("session/update", (params) => updates.push((params as { update: Record<string, unknown> }).update));
return { client, updates, out, inbound };
}
async function open(client: StreamJsonRpcPeer, clientCapabilities: Record<string, unknown> = {}): Promise<string> {
await client.request("initialize", { protocolVersion: 1, clientCapabilities, clientInfo: { name: "zed", title: "Zed" } });
await client.request("authenticate", { methodId: AUTH_METHOD_ID });
return ((await client.request("session/new", { cwd: "/tmp", mcpServers: [] })) as { sessionId: string }).sessionId;
}
const prompt = (sessionId: string, text: string) => ({ sessionId, prompt: [{ type: "text", text }] });
const seat = (session: string) => new DaemonClient(url(), { sessionName: session });
/** A team seat's move through the queue routes; a refused move fails the test with the daemon's answer. */
async function move(session: string, path: string, body: Record<string, unknown>): Promise<void> {
const response = await seat(session).post(path, body);
if (response.status >= 400) throw new Error(`${session} ${path}: HTTP ${response.status} ${JSON.stringify(response.data)}`);
}
const rowsTo = (destination: string) => db.prepare("SELECT qitem_id, source_session, state, body, tags FROM queue_items WHERE destination_session = ? ORDER BY rowid").all(destination) as Array<{ qitem_id: string; source_session: string; state: string; body: string; tags: string }>;
const messages = (updates: Array<Record<string, unknown>>) => updates.filter((u) => u.sessionUpdate === "agent_message_chunk").map((u) => (u.content as { text: string }).text);
async function until<T>(read: () => T | undefined, what: string): Promise<T> {
for (let i = 0; i < 300; i++) {
const value = read();
if (value !== undefined) return value;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`timed out waiting for ${what}`);
}
describe("rig acp serve", () => {
it("answers initialize without reaching OpenRig, and offers the one real sign-in method", async () => {
let connected = 0;
const { client, out } = connect({ connect: async () => { connected += 1; return { error: "unused" }; } });
const init = (await client.request("initialize", { protocolVersion: 1, clientCapabilities: { terminal: true } })) as Record<string, any>;
expect(init.protocolVersion).toBe(1);
expect(init.authMethods).toEqual([expect.objectContaining({ id: AUTH_METHOD_ID, name: "OpenRig on this machine" })]);
expect(init.authMethods[0].type).toBeUndefined();
expect(init.agentInfo).toEqual({ name: "openrig", title: "OpenRig", version: "9.9.9-test" });
expect(init.agentCapabilities.loadSession).toBe(true);
expect(init.agentCapabilities.sessionCapabilities).toEqual({ list: {} });
expect(connected).toBe(0);
for (const line of out.join("").split("\n").filter(Boolean)) expect(JSON.parse(line).jsonrpc).toBe("2.0");
});
it("sign-in and a new session fail with auth_required and the guidance when OpenRig can't be reached", async () => {
const { client } = connect({ connect: async () => ({ error: "OpenRig daemon is not running. Start it with: rig daemon start" }) });
await client.request("initialize", { protocolVersion: 1, clientCapabilities: {} });
for (const [method, params] of [["authenticate", { methodId: AUTH_METHOD_ID }], ["session/new", { cwd: "/tmp", mcpServers: [] }]] as const) {
const error = await client.request(method, params).then(() => undefined, (e: unknown) => e);
expect(error).toBeInstanceOf(JsonRpcRequestError);
expect((error as JsonRpcRequestError).error).toEqual({ code: -32000, message: "OpenRig daemon is not running. Start it with: rig daemon start" });
}
const unknown = await client.request("authenticate", { methodId: "api-key" }).then(() => undefined, (e: unknown) => e);
expect((unknown as JsonRpcRequestError).error.code).toBe(-32602);
});
it("a prompt becomes the caller's row to the receiver; handoffs and notes come back attributed; the resolution is the reply", async () => {
const { client, updates } = connect();
const sessionId = await open(client);
const turn = client.request("session/prompt", prompt(sessionId, "Please fix the login bug\nIt fails on Safari."));
const task = await until(() => rowsTo(LEAD)[0], "the row to the lead");
expect(task.source_session).toBe(CALLER);
expect(task.state).toBe("pending");
expect(task.body).toMatch(/^Please fix the login bug\nIt fails on Safari\.\n\n---\nSent by human-ada@external from Zed through rig acp serve \(session acps-/);
expect(task.body).toContain("close it with a resolution written for human-ada@external");
expect(JSON.parse(task.tags)).toEqual(["acp-serve", `acp-session:${sessionId}`, "acp-cwd:/tmp"]);
await move(LEAD, `/api/queue/${task.qitem_id}/claim`, {});
await move(LEAD, `/api/queue/${task.qitem_id}/update`, { state: "in-progress", transitionNote: "reproduced on Safari 19" });
await move(LEAD, `/api/queue/${task.qitem_id}/handoff`, { toSession: IMPL, transitionNote: "impl owns the auth code", nudge: false });
const next = await until(() => rowsTo(IMPL)[0], "the impl row");
await move(IMPL, `/api/queue/${next.qitem_id}/claim`, {});
await move(IMPL, `/api/queue/${next.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Fixed in PR 123: the cookie lacked SameSite=None." });
expect(await turn).toEqual({ stopReason: "end_turn" });
const calls = updates.filter((u) => u.sessionUpdate === "tool_call");
expect(calls.map((u) => [u.toolCallId, u.title])).toEqual([
[task.qitem_id, `Sent to ${LEAD} (${task.qitem_id})`],
[next.qitem_id, `Handed to ${IMPL} (${next.qitem_id})`],
]);
const lastOf = (id: string) => updates.filter((u) => u.sessionUpdate === "tool_call_update" && u.toolCallId === id).at(-1)!;
expect(lastOf(task.qitem_id).status).toBe("completed");
const leadLines = ((lastOf(task.qitem_id).content as Array<{ content: { text: string } }>)[0]!.content.text);
expect(leadLines).toMatch(/Z lead@team · in-progress: reproduced on Safari 19/);
// An app that shows the step collapsed (Zed) still shows its title, which leads with the row's latest note.
const latest = db.prepare("SELECT actor_session, transition_note FROM queue_transitions WHERE qitem_id = ? AND transition_note IS NOT NULL ORDER BY transition_id DESC LIMIT 1")
.get(task.qitem_id) as { actor_session: string; transition_note: string };
const titled = updates.filter((u) => u.sessionUpdate === "tool_call_update" && u.toolCallId === task.qitem_id && u.title).at(-1)!;
expect(titled.title).toBe(`${latest.actor_session}: ${latest.transition_note} · Sent to ${LEAD} (${task.qitem_id})`);
expect(lastOf(next.qitem_id).status).toBe("completed");
expect(messages(updates).at(-1)).toBe(`${IMPL} closed ${next.qitem_id} done.\n\nFixed in PR 123: the cookie lacked SameSite=None.`);
});
it("a handoff-and-complete passes the work on: the reply is the successor's result, not the first row's done", async () => {
const { client, updates } = connect({}, "front@team");
const sessionId = await open(client);
const turn = client.request("session/prompt", prompt(sessionId, "Upgrade the parser"));
const task = await until(() => rowsTo("front@team")[0], "the front row");
await move("front@team", `/api/queue/${task.qitem_id}/claim`, {});
await move("front@team", `/api/queue/${task.qitem_id}/handoff-and-complete`, { toSession: "back@team", transitionNote: "back owns the parser", nudge: false });
expect(db.prepare("SELECT state, closure_reason FROM queue_items WHERE qitem_id = ?").get(task.qitem_id)).toEqual({ state: "done", closure_reason: "handed_off_to" });
const next = await until(() => rowsTo("back@team")[0], "the back row");
await move("back@team", `/api/queue/${next.qitem_id}/claim`, {});
await move("back@team", `/api/queue/${next.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Parser upgraded to 3.2." });
expect(await turn).toEqual({ stopReason: "end_turn" });
expect(updates.filter((u) => u.sessionUpdate === "tool_call").map((u) => u.toolCallId)).toEqual([task.qitem_id, next.qitem_id]);
expect(messages(updates).at(-1)).toBe(`back@team closed ${next.qitem_id} done.\n\nParser upgraded to 3.2.`);
});
it("the reply names the seat that closed the row: not its owner by default, nor one that wrote a note after the close", async () => {
// A slow poll, so the close and the later note both land before the agent reads the row again.
const { client, updates } = connect({ pollMs: 500 }, "closer@team");
const sessionId = await open(client);
const turn = client.request("session/prompt", prompt(sessionId, "Ship the hotfix"));
const task = await until(() => rowsTo("closer@team")[0], "the row");
// The lead closes its report's row: the closer is neither the row's owner nor the newest writer.
await move("closer@team", `/api/queue/${task.qitem_id}/claim`, {});
await move("lead@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Hotfix shipped." });
await move("watcher@team", `/api/queue/${task.qitem_id}/update`, { transitionNote: "seen, thanks" });
const notes = db.prepare("SELECT actor_session FROM queue_transitions WHERE qitem_id = ? ORDER BY rowid").all(task.qitem_id) as Array<{ actor_session: string }>;
expect(notes.at(-1)!.actor_session).toBe("watcher@team");
expect(await turn).toEqual({ stopReason: "end_turn" });
expect(messages(updates).at(-1)).toBe(`lead@team closed ${task.qitem_id} done.\n\nHotfix shipped.`);
});
it("a create whose answer is lost but whose row landed carries on with that row", async () => {
const lostAck = (q: TeamQueue): TeamQueue => ({ ...q, create: async (row) => { await q.create(row); throw new Error("socket hang up"); } });
const { client, updates } = connect({}, "acked@team", lostAck);
const sessionId = await open(client);
const turn = client.request("session/prompt", prompt(sessionId, "Rotate the logs"));
const task = await until(() => rowsTo("acked@team")[0], "the row");
await move("acked@team", `/api/queue/${task.qitem_id}/claim`, {});
await move("acked@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Rotated." });
expect(await turn).toEqual({ stopReason: "end_turn" });
expect(rowsTo("acked@team")).toHaveLength(1);
expect(messages(updates).at(-1)).toBe(`acked@team closed ${task.qitem_id} done.\n\nRotated.`);
});
it("a create that can't be confirmed either way is named as unknown, and the next prompt names it", async () => {
let readbacks = 0;
const blind = (q: TeamQueue): TeamQueue => ({
...q,
create: async (row) => { await q.create(row); throw new Error("socket hang up"); },
find: async (id) => { readbacks += 1; if (readbacks === 1) throw new Error("connect ECONNREFUSED"); return q.find(id); },
});
const { client, updates } = connect({}, "unsure@team", blind);
const sessionId = await open(client);
const error = await client.request("session/prompt", prompt(sessionId, "Archive March")).then(() => undefined, (e: unknown) => e);
const first = rowsTo("unsure@team")[0]!;
expect((error as JsonRpcRequestError).error).toMatchObject({ code: -32603, message: `Couldn't confirm ${first.qitem_id}: socket hang up` });
expect(messages(updates).at(-1)).toBe(`Sending ${first.qitem_id} to unsure@team got no answer (socket hang up), and it couldn't be read back: delivery is unconfirmed, so it may still reach the team. A new prompt in this session names it, and \`rig queue show ${first.qitem_id}\` shows it if it landed.`);
const second = client.request("session/prompt", prompt(sessionId, "Archive March, please"));
const followUp = await until(() => rowsTo("unsure@team")[1], "the follow-up row");
expect(followUp.body).toContain(`Follows up on ${first.qitem_id} (pending).`);
expect(messages(updates)).toContain(`${first.qitem_id} is still pending with unsure@team. Sending this as a follow-up.\n\n`);
client.notify("session/cancel", { sessionId });
expect(await second).toEqual({ stopReason: "cancelled" });
});
it("an unanswered create that isn't found on the immediate readback stays unconfirmed, and is named when it lands late", async () => {
// The request is still in flight when the agent reads back: it lands only after the readback found nothing.
let late: (() => Promise<void>) | undefined;
const slow = (q: TeamQueue): TeamQueue => ({ ...q, create: async (row) => {
if (late) return q.create(row);
late = () => q.create(row);
throw new Error("socket hang up");
} });
const { client, updates } = connect({}, "late@team", slow);
const sessionId = await open(client);
const error = await client.request("session/prompt", prompt(sessionId, "Tag the release")).then(() => undefined, (e: unknown) => e);
expect(rowsTo("late@team")).toHaveLength(0);
const id = /Couldn't confirm (qitem-\S+):/.exec((error as JsonRpcRequestError).error.message)![1]!;
expect(messages(updates).at(-1)).toBe(`Sending ${id} to late@team got no answer (socket hang up), and it wasn't found on the immediate readback: delivery is unconfirmed, so it may still reach the team. A new prompt in this session names it, and \`rig queue show ${id}\` shows it if it landed.`);
await late!();
expect(rowsTo("late@team")[0]!.qitem_id).toBe(id);
const second = client.request("session/prompt", prompt(sessionId, "Tag the release, please"));
const followUp = await until(() => rowsTo("late@team")[1], "the follow-up row");
expect(followUp.body).toContain(`Follows up on ${id} (pending).`);
client.notify("session/cancel", { sessionId });
expect(await second).toEqual({ stopReason: "cancelled" });
});
it("a create the daemon answers with a refusal is said plainly, and nothing is kept as sent", async () => {
// A seat whose rig has left the topology can't be sent work: the queue answers with a refusal.
const { client, updates } = connect({}, "lead@gone-rig");
const sessionId = await open(client);
const error = await client.request("session/prompt", prompt(sessionId, "Anything")).then(() => undefined, (e: unknown) => e);
expect((error as JsonRpcRequestError).error.code).toBe(-32603);
expect((error as JsonRpcRequestError).error.message).toMatch(/^The team's queue refused the task: HTTP 4\d\d: /);
expect(rowsTo("lead@gone-rig")).toHaveLength(0);
expect(messages(updates)).toEqual([]);
});
it("cancel stops waiting, never the work; the next prompt is a follow-up that names the open item", async () => {
const { client, updates } = connect({}, "solo@team");
const sessionId = await open(client);
const first = client.request("session/prompt", prompt(sessionId, "Draft the release notes"));
const row = await until(() => rowsTo("solo@team")[0], "the first row");
client.notify("session/cancel", { sessionId });
expect(await first).toEqual({ stopReason: "cancelled" });
expect(messages(updates).at(-1)).toBe(`Stopped waiting. ${row.qitem_id} is still pending with solo@team: cancelling here doesn't stop the team's work. A new prompt in this session goes to the team as a follow-up and shows where this stands.`);
expect(rowsTo("solo@team")[0]!.state).toBe("pending");
const second = client.request("session/prompt", prompt(sessionId, "Also mention the Safari fix"));
const followUp = await until(() => rowsTo("solo@team")[1], "the follow-up row");
expect(followUp.body).toContain(`Follows up on ${row.qitem_id} (pending).`);
expect(messages(updates)).toContain(`${row.qitem_id} is still pending with solo@team. Sending this as a follow-up.\n\n`);
await move("solo@team", `/api/queue/${followUp.qitem_id}/claim`, {});
await move("solo@team", `/api/queue/${followUp.qitem_id}/update`, { state: "done", closureReason: "canceled", resolution: "Needs the first item first." });
expect(await second).toEqual({ stopReason: "end_turn" });
expect(messages(updates).at(-1)).toBe(`solo@team closed ${followUp.qitem_id} done (canceled).\n\nNeeds the first item first.`);
});
it("with no receiver configured, a prompt sends nothing and says how to set one", async () => {
const { client, updates } = connect({}, null);
const sessionId = await open(client);
const before = (db.prepare("SELECT count(*) AS n FROM queue_items").get() as { n: number }).n;
expect(await client.request("session/prompt", prompt(sessionId, "anything"))).toEqual({ stopReason: "end_turn" });
expect((db.prepare("SELECT count(*) AS n FROM queue_items").get() as { n: number }).n).toBe(before);
expect(messages(updates).at(-1)).toContain("`rig config set acp.to <member@rig>`, or add `--to <member@rig>`");
});
it("a question tagged for this chat comes as a permission request and the choice is recorded as the caller's answer; an untagged one is only named", async () => {
const { client, updates } = connect({}, "asker@team");
const asked: Array<Record<string, any>> = [];
client.onRequest("session/request_permission", async (params) => {
asked.push(params as Record<string, any>);
return { outcome: { outcome: "selected", optionId: "b" } };
});
const sessionId = await open(client);
// Asked before this turn by a seat the work never reaches: not this turn's question.
await move("bystander@team", "/api/queue/create", { qitemId: "qitem-unrelated-ask", destinationSession: CALLER, body: "unrelated", summary: "Unrelated", nudge: false,
humanQuestions: [{ id: "q1", question: "Unrelated?", options: [{ id: "y", label: "Yes" }, { id: "n", label: "No" }] }] });
const turn = client.request("session/prompt", prompt(sessionId, "Pick a database for the cache"));
const task = await until(() => rowsTo("asker@team")[0], "the work row");
await move("asker@team", `/api/queue/${task.qitem_id}/claim`, {});
// Untagged, from the seat working on it: it could be another chat's, so it's named here, never asked.
await move("asker@team", "/api/queue/create", { qitemId: "qitem-ask-untagged", destinationSession: CALLER, body: "Which region?", summary: "Region?", nudge: false,
humanQuestions: [{ id: "q1", question: "Which region?", options: [{ id: "eu", label: "EU" }, { id: "us", label: "US" }] }] });
await move("asker@team", "/api/queue/create", { qitemId: "qitem-ask-db", destinationSession: CALLER, body: "Redis is faster; SQLite is already here.", summary: "Which store?", nudge: false,
tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Which store?", options: [{ id: "a", label: "Redis" }, { id: "b", label: "SQLite", recommended: true }] }] });
const answered = await until(() => {
const row = db.prepare("SELECT human_answers FROM queue_items WHERE qitem_id = 'qitem-ask-db'").get() as { human_answers: string | null };
return row.human_answers ? JSON.parse(row.human_answers) : undefined;
}, "the recorded answer");
expect(Object.keys(answered)).toEqual(["q1"]);
expect(JSON.stringify(answered)).toContain("b");
await move("asker@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "SQLite, as you chose." });
expect(await turn).toEqual({ stopReason: "end_turn" });
expect(asked).toHaveLength(1);
expect(asked[0]!.toolCall).toMatchObject({ toolCallId: "qitem-ask-db#q1", title: "asker@team asks: Which store?" });
expect(asked[0]!.options).toEqual([{ optionId: "a", name: "Redis", kind: "allow_once" }, { optionId: "b", name: "SQLite (recommended)", kind: "allow_once" }]);
const result = updates.filter((u) => u.toolCallId === "qitem-ask-db#q1").at(-1)!;
expect(result.status).toBe("completed");
expect((result.content as Array<{ content: { text: string } }>)[0]!.content.text).toBe("You answered: SQLite. asker@team gets it as your answer.");
// "asker@team gets it as your answer": the answer path's reply row, from the caller to the seat that asked.
const replies = db.prepare("SELECT body FROM queue_items WHERE source_session = ? AND destination_session = 'asker@team' AND reply_to = 'qitem-ask-db'").all(CALLER) as Array<{ body: string }>;
expect(replies).toHaveLength(1);
expect(replies[0]!.body).toContain("SQLite");
const unrelated = db.prepare("SELECT state, human_answers FROM queue_items WHERE qitem_id = 'qitem-unrelated-ask'").get() as { state: string; human_answers: string | null };
expect(unrelated).toEqual({ state: "pending", human_answers: null });
expect(messages(updates)).toContain("\n\nasker@team, which this work reached, has a question for you that isn't tagged for this chat (qitem-ask-untagged: Region?). Answer it in the OpenRig TUI or Slack.\n\n");
expect(db.prepare("SELECT human_answers FROM queue_items WHERE qitem_id = 'qitem-ask-untagged'").get()).toEqual({ human_answers: null });
expect(task.body).toContain(`--tags acp-session:${sessionId}`);
});
it("an app that asks forms gets a question's choices as a form, each by its label, and the choice is recorded", async () => {
const { client, updates } = connect({}, "former@team");
const asked: Array<Record<string, any>> = [];
client.onRequest("elicitation/create", async (params) => { asked.push(params as Record<string, any>); return { action: "accept", content: { q1: "SQLite (recommended)" } }; });
client.onRequest("session/request_permission", async () => { throw new Error("no permission request was expected"); });
const sessionId = await open(client, { elicitation: { form: {} } });
const turn = client.request("session/prompt", prompt(sessionId, "Pick a store for the cache"));
const task = await until(() => rowsTo("former@team")[0], "the work row");
await move("former@team", `/api/queue/${task.qitem_id}/claim`, {});
await move("former@team", "/api/queue/create", { qitemId: "qitem-form-db", destinationSession: CALLER, body: "Redis is faster; SQLite is already here.", summary: "Which store?", nudge: false,
tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Which store?", options: [{ id: "a", label: "Redis" }, { id: "b", label: "SQLite", recommended: true }] }] });
const answered = await until(() => {
const row = db.prepare("SELECT human_answers FROM queue_items WHERE qitem_id = 'qitem-form-db'").get() as { human_answers: string | null };
return row.human_answers ? JSON.parse(row.human_answers) : undefined;
}, "the recorded answer");
expect(JSON.stringify(answered)).toContain("b");
await move("former@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "SQLite." });
expect(await turn).toEqual({ stopReason: "end_turn" });
expect(asked).toEqual([{
sessionId, toolCallId: "qitem-form-db#q1", mode: "form", message: "former@team asks: Which store?",
requestedSchema: { type: "object", required: ["q1"], properties: { q1: { type: "string", title: "Which store?", enum: ["Redis", "SQLite (recommended)"] } } },
}]);
const result = updates.filter((u) => u.toolCallId === "qitem-form-db#q1").at(-1)!;
expect((result.content as Array<{ content: { text: string } }>)[0]!.content.text).toBe("You answered: SQLite. former@team gets it as your answer.");
});
it("#2708 choices whose labels repeat carry their ids, so the answer names one choice", async () => {
const { client } = connect({}, "twins@team");
const asked: Array<Record<string, any>> = [];
client.onRequest("elicitation/create", async (params) => { asked.push(params as Record<string, any>); return { action: "accept", content: { q1: "Same (b)" } }; });
const sessionId = await open(client, { elicitation: { form: {} } });
const turn = client.request("session/prompt", prompt(sessionId, "Pick one"));
const task = await until(() => rowsTo("twins@team")[0], "the work row");
await move("twins@team", `/api/queue/${task.qitem_id}/claim`, {});
await move("twins@team", "/api/queue/create", { qitemId: "qitem-form-twins", destinationSession: CALLER, body: "Twins", summary: "Which?", nudge: false,
tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Which?", options: [{ id: "a", label: "Same" }, { id: "b", label: "Same" }] }] });
const answered = await until(() => {
const row = db.prepare("SELECT human_answers FROM queue_items WHERE qitem_id = 'qitem-form-twins'").get() as { human_answers: string | null };
return row.human_answers ? JSON.parse(row.human_answers) : undefined;
}, "the recorded answer");
expect(asked[0]!.requestedSchema.properties.q1.enum).toEqual(["Same (a)", "Same (b)"]);
expect(JSON.stringify(answered)).toContain('"b"');
client.notify("session/cancel", { sessionId });
expect(await turn).toEqual({ stopReason: "cancelled" });
});
it("a form declined, cancelled, or answered with no option of the question's records nothing; form: null gets a permission request", async () => {
for (const [i, reply] of [{ action: "decline" }, { action: "cancel" }, { action: "accept", content: { q1: "zzz" } }].entries()) {
const { client, updates } = connect({}, `former${i}@team`);
client.onRequest("elicitation/create", async () => reply);
const sessionId = await open(client, { elicitation: { form: {} } });
const turn = client.request("session/prompt", prompt(sessionId, "Pick one"));
const task = await until(() => rowsTo(`former${i}@team`)[0], "the work row");
await move(`former${i}@team`, `/api/queue/${task.qitem_id}/claim`, {});
await move(`former${i}@team`, "/api/queue/create", { qitemId: `qitem-form-${i}`, destinationSession: CALLER, body: "Two ways", summary: "Way?", nudge: false,
tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Which way?", options: [{ id: "a", label: "Left" }, { id: "b", label: "Right" }] }] });
const said = await until(() => updates.find((u) => u.toolCallId === `qitem-form-${i}#q1` && u.sessionUpdate === "tool_call_update"), "the outcome");
expect((said.content as Array<{ content: { text: string } }>)[0]!.content.text, JSON.stringify(reply)).toBe("Not answered here. The question is still open in the OpenRig TUI and Slack.");
expect(messages(updates), JSON.stringify(reply)).toContain(`\n\nNot answered here. former${i}@team's question "Which way?" (qitem-form-${i}) is still open in the OpenRig TUI and Slack.\n\n`);
expect(db.prepare(`SELECT human_answers FROM queue_items WHERE qitem_id = 'qitem-form-${i}'`).get()).toEqual({ human_answers: null });
client.notify("session/cancel", { sessionId });
expect(await turn).toEqual({ stopReason: "cancelled" });
}
const { client } = connect({}, "nullform@team");
let permissionAsked = false;
client.onRequest("elicitation/create", async () => { throw new Error("form: null advertises no form mode"); });
client.onRequest("session/request_permission", async () => { permissionAsked = true; return { outcome: { outcome: "cancelled" } }; });
const sessionId = await open(client, { elicitation: { form: null } });
const turn = client.request("session/prompt", prompt(sessionId, "Pick one"));
const task = await until(() => rowsTo("nullform@team")[0], "the work row");
await move("nullform@team", `/api/queue/${task.qitem_id}/claim`, {});
await move("nullform@team", "/api/queue/create", { qitemId: "qitem-form-null", destinationSession: CALLER, body: "Two ways", summary: "Way?", nudge: false,
tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Which way?", options: [{ id: "a", label: "Left" }, { id: "b", label: "Right" }] }] });
await until(() => (permissionAsked ? true : undefined), "the permission request");
client.notify("session/cancel", { sessionId });
expect(await turn).toEqual({ stopReason: "cancelled" });
});
it("a question tagged for one chat is asked in that chat only, though another chat's work reached the same seat", async () => {
const chatA = connect({}, "shared@team");
const chatB = connect({}, "shared@team");
const askedIn: Record<string, string[]> = { a: [], b: [] };
chatA.client.onRequest("session/request_permission", async (params) => { askedIn.a.push((params as any).toolCall.toolCallId); return { outcome: { outcome: "cancelled" } }; });
chatB.client.onRequest("session/request_permission", async (params) => { askedIn.b.push((params as any).toolCall.toolCallId); return { outcome: { outcome: "selected", optionId: "y" } }; });
const a = await open(chatA.client);
const b = await open(chatB.client);
const turnA = chatA.client.request("session/prompt", prompt(a, "Chat A's task"));
const turnB = chatB.client.request("session/prompt", prompt(b, "Chat B's task"));
const [rowA, rowB] = await until(() => { const rows = rowsTo("shared@team"); return rows.length === 2 ? rows : undefined; }, "both rows");
await move("shared@team", "/api/queue/create", { qitemId: "qitem-ask-for-b", destinationSession: CALLER, body: "For B only", summary: "B?", nudge: false,
tags: [`acp-session:${b}`], humanQuestions: [{ id: "q1", question: "Ship B?", options: [{ id: "y", label: "Yes" }, { id: "n", label: "No" }] }] });
await until(() => (askedIn.b.length > 0 ? true : undefined), "B's question");
for (const row of [rowA!, rowB!]) {
await move("shared@team", `/api/queue/${row.qitem_id}/claim`, {});
await move("shared@team", `/api/queue/${row.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Done." });
}
await Promise.all([turnA, turnB]);
expect(askedIn).toEqual({ a: [], b: ["qitem-ask-for-b#q1"] });
expect(chatA.updates.some((u) => String(u.toolCallId ?? "").startsWith("qitem-ask-for-b"))).toBe(false);
expect(messages(chatA.updates).some((m) => m.includes("qitem-ask-for-b"))).toBe(false);
});
it("cancel waits for the app to settle the question it's showing, and nothing about it follows the cancelled answer", async () => {
const { client, updates, inbound } = connect({}, "asker3@team");
let answerCancelled!: () => void;
client.onRequest("session/request_permission", () => new Promise((resolve) => {
answerCancelled = () => setTimeout(() => resolve({ outcome: { outcome: "cancelled" } }), 50);
}));
const sessionId = await open(client);
const turn = client.request("session/prompt", prompt(sessionId, "Choose a theme"));
await until(() => rowsTo("asker3@team")[0], "the work row");
await move("asker3@team", "/api/queue/create", { qitemId: "qitem-ask-theme", destinationSession: CALLER, body: "Light or dark", summary: "Theme?", nudge: false,
tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Which theme?", options: [{ id: "l", label: "Light" }, { id: "d", label: "Dark" }] }] });
await until(() => (answerCancelled ? true : undefined), "the question on screen");
// As ACP has it: the app sends the cancel, then answers its open question as cancelled.
client.notify("session/cancel", { sessionId });
answerCancelled();
expect(await turn).toEqual({ stopReason: "cancelled" });
await new Promise((resolve) => setTimeout(resolve, 100));
const end = inbound.findIndex((p) => p.result?.stopReason === "cancelled");
const about = inbound.map((p, i) => ({ p, i })).filter(({ p }) => p.params?.update?.toolCallId === "qitem-ask-theme#q1");
expect(about.at(-1)!.i).toBeLessThan(end);
expect(about.at(-1)!.p.params.update.content[0].content.text).toBe("Not answered here. The question is still open in the OpenRig TUI and Slack.");
const said = inbound.map((p, i) => ({ p, i })).filter(({ p }) => p.params?.update?.sessionUpdate === "agent_message_chunk"
&& p.params.update.content.text.includes(`asker3@team's question "Which theme?" (qitem-ask-theme) is still open`));
expect(said).toHaveLength(1);
expect(said[0]!.i).toBeLessThan(end);
expect(updates.filter((u) => u.toolCallId === "qitem-ask-theme#q1")).toHaveLength(2);
expect(db.prepare("SELECT human_answers FROM queue_items WHERE qitem_id = 'qitem-ask-theme'").get()).toEqual({ human_answers: null });
});
it("after a cancel no new question goes to the app, even one already waiting when the cancel wakes the turn", async () => {
// The poll never fires on its own here, so the only pass after the first is the one the cancel's wake runs.
let scans = 0;
let shown = false;
const hiddenUntilCancel = (q: TeamQueue): TeamQueue => ({ ...q, openAsks: async (person) => { scans += 1; return shown ? q.openAsks(person) : []; } });
const { client, updates } = connect({ pollMs: 60_000, sleep: (ms) => new Promise<void>((resolve) => { if (ms !== 60_000) setTimeout(resolve, ms); }) },
"asker5@team", hiddenUntilCancel);
const asked: string[] = [];
client.onRequest("session/request_permission", async (params) => { asked.push((params as any).toolCall.toolCallId); return { outcome: { outcome: "cancelled" } }; });
const sessionId = await open(client);
const turn = client.request("session/prompt", prompt(sessionId, "Pick a font"));
await until(() => (scans > 0 ? true : undefined), "the first read of open questions");
await move("asker5@team", "/api/queue/create", { qitemId: "qitem-ask-after-cancel", destinationSession: CALLER, body: "Serif or sans", summary: "Font?", nudge: false,
tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Which font?", options: [{ id: "s", label: "Serif" }, { id: "n", label: "Sans" }] }] });
shown = true;
client.notify("session/cancel", { sessionId });
expect(await turn).toEqual({ stopReason: "cancelled" });
expect(asked).toEqual([]);
expect(updates.some((u) => String(u.toolCallId ?? "").startsWith("qitem-ask-after-cancel"))).toBe(false);
expect(db.prepare("SELECT state, human_answers FROM queue_items WHERE qitem_id = 'qitem-ask-after-cancel'").get()).toEqual({ state: "pending", human_answers: null });
});
it("a cancel while an answer is being recorded keeps that answer and opens no further question from the ask", async () => {
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
const slowAnswer = (q: TeamQueue): TeamQueue => ({ ...q, answer: async (...args) => { await held; return q.answer(...args); } });
// The answer write is held until the cancel's settle wait (2 s, the only sleep that long here) has begun.
const settleThenRelease = (ms: number) => { if (ms === 2000) release(); return new Promise<void>((resolve) => setTimeout(resolve, ms)); };
const { client, updates } = connect({ sleep: settleThenRelease }, "asker6@team", slowAnswer);
const asked: string[] = [];
client.onRequest("session/request_permission", async (params) => {
const id = (params as any).toolCall.toolCallId as string;
asked.push(id);
return id.endsWith("#q1") ? { outcome: { outcome: "selected", optionId: "y" } } : { outcome: { outcome: "cancelled" } };
});
const sessionId = await open(client);
const turn = client.request("session/prompt", prompt(sessionId, "Plan the launch"));
await until(() => rowsTo("asker6@team")[0], "the work row");
await move("asker6@team", "/api/queue/create", { qitemId: "qitem-ask-two", destinationSession: CALLER, body: "Two calls to make", summary: "Launch?", nudge: false,
tags: [`acp-session:${sessionId}`], humanQuestions: [
{ id: "q1", question: "Ship this week?", options: [{ id: "y", label: "Yes" }, { id: "n", label: "No" }] },
{ id: "q2", question: "Announce it?", options: [{ id: "y", label: "Yes" }, { id: "n", label: "No" }] }] });
await until(() => (asked.length > 0 ? true : undefined), "the first question");
client.notify("session/cancel", { sessionId });
expect(await turn).toEqual({ stopReason: "cancelled" });
expect(asked).toEqual(["qitem-ask-two#q1"]);
expect(updates.some((u) => u.toolCallId === "qitem-ask-two#q2")).toBe(false);
const recorded = db.prepare("SELECT human_answers FROM queue_items WHERE qitem_id = 'qitem-ask-two'").get() as { human_answers: string | null };
expect(JSON.parse(recorded.human_answers ?? "{}")).toEqual({ q1: "y" });
});
it("when the work closes with a question still on screen, the app is told before the turn ends, and a later reply answers nothing", async () => {
const { client, inbound } = connect({}, "asker4@team");
let answerLate!: () => void;
client.onRequest("session/request_permission", () => new Promise((resolve) => { answerLate = () => resolve({ outcome: { outcome: "selected", optionId: "x" } }); }));
const sessionId = await open(client);
const turn = client.request("session/prompt", prompt(sessionId, "Plan the offsite"));
const task = await until(() => rowsTo("asker4@team")[0], "the work row");
await move("asker4@team", "/api/queue/create", { qitemId: "qitem-ask-venue", destinationSession: CALLER, body: "Two venues", summary: "Venue?", nudge: false,
tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Which venue?", options: [{ id: "x", label: "Lisbon" }, { id: "z", label: "Oslo" }] }] });
await until(() => (answerLate ? true : undefined), "the question on screen");
await move("asker4@team", `/api/queue/${task.qitem_id}/claim`, {});
await move("asker4@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Booked without it." });
expect(await turn).toEqual({ stopReason: "end_turn" });
answerLate();
await new Promise((resolve) => setTimeout(resolve, 150));
const end = inbound.findIndex((p) => p.result?.stopReason === "end_turn");
const about = inbound.map((p, i) => ({ p, i })).filter(({ p }) => p.params?.update?.toolCallId === "qitem-ask-venue#q1");
expect(about.at(-1)!.i).toBeLessThan(end);
expect(about.at(-1)!.p.params.update.content[0].content.text).toBe("The work closed before this was answered here. The question is still open in the OpenRig TUI and Slack.");
const said = inbound.findIndex((p) => p.params?.update?.sessionUpdate === "agent_message_chunk"
&& p.params.update.content.text === `\n\nThe work closed before this was answered here. asker4@team's question "Which venue?" (qitem-ask-venue) is still open in the OpenRig TUI and Slack.\n\n`);
expect(said).toBeGreaterThan(-1);
expect(said).toBeLessThan(end);
expect(db.prepare("SELECT human_answers FROM queue_items WHERE qitem_id = 'qitem-ask-venue'").get()).toEqual({ human_answers: null });
});
it("a question the caller dismisses stays open and unanswered", async () => {
const { client, updates } = connect({}, "asker2@team");
client.onRequest("session/request_permission", async () => ({ outcome: { outcome: "cancelled" } }));
const sessionId = await open(client);
const turn = client.request("session/prompt", prompt(sessionId, "Rename the service"));
const task = await until(() => rowsTo("asker2@team")[0], "the work row");
await move("asker2@team", "/api/queue/create", { qitemId: "qitem-ask-name", destinationSession: CALLER, body: "Two names", summary: "Name?", nudge: false,
tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Which name?", options: [{ id: "a", label: "Atlas" }, { id: "b", label: "Beacon" }] }] });
await until(() => updates.find((u) => u.toolCallId === "qitem-ask-name#q1" && u.sessionUpdate === "tool_call_update"), "the dismissal");
await move("asker2@team", `/api/queue/${task.qitem_id}/claim`, {});
await move("asker2@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Kept the old name." });
expect(await turn).toEqual({ stopReason: "end_turn" });
const dismissed = updates.filter((u) => u.toolCallId === "qitem-ask-name#q1").at(-1)!;
expect(dismissed.status).toBeUndefined();
expect((dismissed.content as Array<{ content: { text: string } }>)[0]!.content.text).toBe("Not answered here. The question is still open in the OpenRig TUI and Slack.");
expect(messages(updates)).toContain(`\n\nNot answered here. asker2@team's question "Which name?" (qitem-ask-name) is still open in the OpenRig TUI and Slack.\n\n`);
expect(db.prepare("SELECT state, human_answers FROM queue_items WHERE qitem_id = 'qitem-ask-name'").get()).toEqual({ state: "pending", human_answers: null });
});
it("a question the app can't show is named in the chat as still open, with why", async () => {
const { client, updates } = connect({}, "unshown@team");
client.onRequest("elicitation/create", async () => { throw new Error("form rejected"); });
const sessionId = await open(client, { elicitation: { form: {} } });
const turn = client.request("session/prompt", prompt(sessionId, "Pick a color"));
const task = await until(() => rowsTo("unshown@team")[0], "the work row");
await move("unshown@team", "/api/queue/create", { qitemId: "qitem-ask-color", destinationSession: CALLER, body: "Two colors", summary: "Color?", nudge: false,
tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Which color?", options: [{ id: "r", label: "Red" }, { id: "g", label: "Green" }] }] });
const card = await until(() => updates.find((u) => u.toolCallId === "qitem-ask-color#q1" && u.sessionUpdate === "tool_call_update"), "the failure");
expect((card.content as Array<{ content: { text: string } }>)[0]!.content.text).toMatch(/^This app couldn't show the question \(.*form rejected.*\)\. The question is still open in the OpenRig TUI and Slack\.$/);
expect(messages(updates).some((m) => /^\n\nThis app couldn't show the question \(.*form rejected.*\)\. unshown@team's question "Which color\?" \(qitem-ask-color\) is still open in the OpenRig TUI and Slack\.\n\n$/.test(m))).toBe(true);
await move("unshown@team", `/api/queue/${task.qitem_id}/claim`, {});
await move("unshown@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Picked red." });
expect(await turn).toEqual({ stopReason: "end_turn" });
expect(db.prepare("SELECT human_answers FROM queue_items WHERE qitem_id = 'qitem-ask-color'").get()).toEqual({ human_answers: null });
});
it("a session is listed and reopened from its rows: the prompt, its work and the reply replay, and a follow-up continues it", async () => {
const first = connect({}, "keeper@team");
const sessionId = await open(first.client);
const turn = first.client.request("session/prompt", prompt(sessionId, "Summarize yesterday's incidents"));
const task = await until(() => rowsTo("keeper@team")[0], "the row");
await move("keeper@team", `/api/queue/${task.qitem_id}/claim`, {});
await move("keeper@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Two incidents, both closed." });
await turn;
// A new process, as after the app restarts.
const second = connect({}, "keeper@team");
await second.client.request("initialize", { protocolVersion: 1, clientCapabilities: {} });
const listed = (await second.client.request("session/list", {})) as { sessions: Array<Record<string, unknown>> };
expect(listed.sessions.find((s) => s.sessionId === sessionId)).toMatchObject({ cwd: "/tmp", title: "Summarize yesterday's incidents" });
expect(((await second.client.request("session/list", { cwd: "/elsewhere" })) as { sessions: unknown[] }).sessions.find((s) => (s as { sessionId: string }).sessionId === sessionId)).toBeUndefined();
expect(await second.client.request("session/load", { sessionId, cwd: "/tmp", mcpServers: [] })).toEqual({});
expect(second.updates.map((u) => u.sessionUpdate)).toEqual(["user_message_chunk", "tool_call", "tool_call_update", "agent_message_chunk"]);
expect((second.updates[0]!.content as { text: string }).text).toBe("Summarize yesterday's incidents");
expect(messages(second.updates).at(-1)).toBe(`keeper@team closed ${task.qitem_id} done.\n\nTwo incidents, both closed.`);
const followUp = second.client.request("session/prompt", prompt(sessionId, "And the week before?"));
const next = await until(() => rowsTo("keeper@team")[1], "the follow-up row");
expect(next.body).toContain(`Follows up on ${task.qitem_id} (done).`);
expect(JSON.parse(next.tags)).toContain(`acp-session:${sessionId}`);
second.client.notify("session/cancel", { sessionId });
expect(await followUp).toEqual({ stopReason: "cancelled" });
const missing = await second.client.request("session/load", { sessionId: "acps-nope", cwd: "/tmp", mcpServers: [] }).then(() => undefined, (e: unknown) => e);
expect((missing as JsonRpcRequestError).error.code).toBe(-32602);
});
it("an app that wraps the person's words in its own instructions: they're the row's summary and the session's title", async () => {
const { client } = connect({}, "wrapped@team");
const sessionId = await open(client);
const sent = "<t3_code_instructions>\nUse the preview_* tools.\n</t3_code_instructions>\n<runtime_info>\nmacOS\n</runtime_info>\n"
+ "<user_request>\nFix the flaky login test\n</user_request>";
const turn = client.request("session/prompt", prompt(sessionId, sent));
const task = await until(() => rowsTo("wrapped@team")[0], "the row");
expect(db.prepare("SELECT summary FROM queue_items WHERE qitem_id = ?").get(task.qitem_id)).toEqual({ summary: "ACP: Fix the flaky login test" });
expect(task.body.startsWith(sent)).toBe(true);
const listed = (await client.request("session/list", {})) as { sessions: Array<Record<string, unknown>> };
expect(listed.sessions.find((s) => s.sessionId === sessionId)).toMatchObject({ title: "Fix the flaky login test" });
client.notify("session/cancel", { sessionId });
expect(await turn).toEqual({ stopReason: "cancelled" });
});
it("#2708 a prompt whose request marks aren't one pair keeps its own first line: an example is never taken for the request", async () => {
for (const [i, sent] of [
"<t3_code_instructions>\nFor example: <user_request>Delete the cache</user_request>\n</t3_code_instructions>\n<user_request>\nRepair the login test\n</user_request>",
"<t3_code_instructions>\nThe request follows in <user_request>.\n</t3_code_instructions>\n<user_request>\nRepair the login test\n</user_request>",
].entries()) {
const { client } = connect({}, `marked${i}@team`);
const sessionId = await open(client);
const turn = client.request("session/prompt", prompt(sessionId, sent));
const task = await until(() => rowsTo(`marked${i}@team`)[0], "the row");
expect(db.prepare("SELECT summary FROM queue_items WHERE qitem_id = ?").get(task.qitem_id), sent).toEqual({ summary: "ACP: <t3_code_instructions>" });
const listed = (await client.request("session/list", {})) as { sessions: Array<Record<string, unknown>> };
expect(listed.sessions.find((s) => s.sessionId === sessionId), sent).toMatchObject({ title: "<t3_code_instructions>" });
client.notify("session/cancel", { sessionId });
expect(await turn).toEqual({ stopReason: "cancelled" });
}
});
it("a reopened session replays the questions asked during it with what's recorded, and asks nothing again", async () => {
const first = connect({}, "asker7@team");
first.client.onRequest("session/request_permission", async () => ({ outcome: { outcome: "selected", optionId: "b" } }));
const sessionId = await open(first.client);
const turn = first.client.request("session/prompt", prompt(sessionId, "Pick a store"));
const task = await until(() => rowsTo("asker7@team")[0], "the work row");
await move("asker7@team", `/api/queue/${task.qitem_id}/claim`, {});
await move("asker7@team", "/api/queue/create", { qitemId: "qitem-ask-replayed", destinationSession: CALLER, body: "Redis or SQLite", summary: "Store?", nudge: false,
tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Which store?", options: [{ id: "a", label: "Redis" }, { id: "b", label: "SQLite" }] }] });
await until(() => ((db.prepare("SELECT human_answers FROM queue_items WHERE qitem_id = 'qitem-ask-replayed'").get() as { human_answers: string | null }).human_answers ? true : undefined), "the answer");
await move("asker7@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "SQLite it is." });
await turn;
const second = connect({}, "asker7@team");
const asked: unknown[] = [];
second.client.onRequest("session/request_permission", async (params) => { asked.push(params); return { outcome: { outcome: "cancelled" } }; });
await second.client.request("initialize", { protocolVersion: 1, clientCapabilities: {} });
expect(await second.client.request("session/load", { sessionId, cwd: "/tmp", mcpServers: [] })).toEqual({});
expect(second.updates.map((u) => u.sessionUpdate)).toEqual(["user_message_chunk", "tool_call", "tool_call_update", "tool_call", "agent_message_chunk"]);
expect(second.updates[3]).toMatchObject({ toolCallId: "qitem-ask-replayed#q1", status: "completed", title: "asker7@team asks: Which store?" });
expect((second.updates[3]!.content as Array<{ content: { text: string } }>).map((c) => c.content.text)).toEqual(["Redis or SQLite", "You answered: SQLite."]);
expect(messages(second.updates).at(-1)).toBe(`asker7@team closed ${task.qitem_id} done.\n\nSQLite it is.`);
expect(asked).toEqual([]);
});
it("a reopened session shows, in the order asked, a question closed unanswered as closed and one still open as pending", async () => {
const first = connect({}, "asker8@team");
const sessionId = await open(first.client);
const turn = first.client.request("session/prompt", prompt(sessionId, "Plan the release"));
const task = await until(() => rowsTo("asker8@team")[0], "the work row");
await move("asker8@team", `/api/queue/${task.qitem_id}/claim`, {});
await move("asker8@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Planned." });
await turn;
// Asked after the turn ended, so no live turn puts them to the app.
const choices = [{ id: "y", label: "Yes" }, { id: "n", label: "No" }];
await move("asker8@team", "/api/queue/create", { qitemId: "qitem-ask-closed-unanswered", destinationSession: CALLER, body: "Freeze on Friday?", summary: "Freeze?", nudge: false,
tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Freeze on Friday?", options: choices }] });
await move(CALLER, "/api/queue/qitem-ask-closed-unanswered/claim", {});
await move(CALLER, "/api/queue/qitem-ask-closed-unanswered/update", { state: "done", closureReason: "no-follow-on", resolution: "Moot now." });
await move("asker8@team", "/api/queue/create", { qitemId: "qitem-ask-still-open", destinationSession: CALLER, body: "Tag it 1.0?", summary: "Tag?", nudge: false,
tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Tag it 1.0?", options: choices }] });
const second = connect({}, "asker8@team");
const asked: unknown[] = [];
second.client.onRequest("session/request_permission", async (params) => { asked.push(params); return { outcome: { outcome: "cancelled" } }; });
await second.client.request("initialize", { protocolVersion: 1, clientCapabilities: {} });
expect(await second.client.request("session/load", { sessionId, cwd: "/tmp", mcpServers: [] })).toEqual({});
const questions = second.updates.filter((u) => u.sessionUpdate === "tool_call" && String(u.toolCallId).includes("#"));
expect(questions.map((u) => [u.toolCallId, u.status, (u.content as Array<{ content: { text: string } }>).at(-1)!.content.text])).toEqual([
["qitem-ask-closed-unanswered#q1", "completed", "Not answered: qitem-ask-closed-unanswered was closed (done)."],
["qitem-ask-still-open#q1", "pending", "Not answered yet. It's still open in the OpenRig TUI and Slack."],
]);
expect(asked).toEqual([]);
});
it("a connection's notice is said once in each session, before its first prompt's work", async () => {
const team: TeamConnection = { queue: daemonTeamQueue(new DaemonClient(url(), { sessionName: CALLER })), caller: CALLER, receiver: "noticed@team", notice: "Your team can't ask you questions here yet." };
const { client, updates } = connect({ connect: async () => team });
const sessionId = await open(client);
for (const [i, text] of ["One", "Two"].entries()) {
const turn = client.request("session/prompt", prompt(sessionId, text));
await until(() => rowsTo("noticed@team")[i], `row ${i + 1}`);
client.notify("session/cancel", { sessionId });
expect(await turn).toEqual({ stopReason: "cancelled" });
}
expect(messages(updates).filter((m) => m.includes("can't ask you questions"))).toEqual(["Your team can't ask you questions here yet.\n\n"]);
expect(messages(updates)[0]).toBe("Your team can't ask you questions here yet.\n\n");
});
it("names an unregistered person and how to register; says nothing for a registered person or a seat", async () => {
const home = mkdtempSync(join(tmpdir(), "acp-people-"));
try {
expect(await unregisteredPersonNotice("dev@team", home)).toBeUndefined();
expect(await unregisteredPersonNotice("cq-person@external", home)).toBe("Your team can't ask you questions here yet: cq-person@external isn't a registered person on this machine, so OpenRig refuses a question addressed to it. Your work is still sent. To be asked, register yourself with `rig gateway human add cq-person --display-name \"<your name>\" --delivery-class <A|B|C|D>`.");
expect(addHumanFragment({ entityId: "ada", class: "human", displayName: "Ada", address: "ada@external", connectorBindings: [], prefs: { deliveryClass: "B" } }, home).ok).toBe(true);
expect(await unregisteredPersonNotice("ada@external", home)).toBeUndefined();
} finally { rmSync(home, { recursive: true, force: true }); }
});
it("an unknown method gets JSON-RPC's method-not-found", async () => {
const { client } = connect();
const error = await client.request("session/fork", { sessionId: "x" }).then(() => undefined, (e: unknown) => e);
expect((error as JsonRpcRequestError).error).toEqual({ code: -32601, message: "Method not found: session/fork" });
});
});
-37
View File
@@ -1,9 +1,6 @@
import { afterEach, describe, expect, it } from "vitest";
import { spawnSync } from "node:child_process";
import ts from "typescript";
import fs from "node:fs";
import os from "node:os";
import module from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { resolveBinEntry, resolveNodeReexecBinary, run } from "../src/bin-wrapper.js";
@@ -18,40 +15,6 @@ describe("bin-wrapper", () => {
}
});
it.skipIf(!module.getCompileCacheDir || !module.enableCompileCache).each([
{ args: [], interactive: false, explicitCache: false, expected: false },
{ args: ["--version"], interactive: false, explicitCache: false, expected: true },
{ args: [], interactive: false, explicitCache: true, expected: true },
{ args: [], interactive: true, explicitCache: false, expected: true },
])("compile cache for args=$args, interactive=$interactive, explicitCache=$explicitCache", ({ args, interactive, explicitCache, expected }) => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-bin-cache-"));
const dist = path.join(tempDir, "dist");
fs.mkdirSync(dist);
fs.writeFileSync(path.join(tempDir, "package.json"), '{ "type": "module" }');
const wrapper = path.join(dist, "bin-wrapper.js");
const source = fs.readFileSync(new URL("../src/bin-wrapper.ts", import.meta.url), "utf8");
const { outputText } = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
});
fs.writeFileSync(wrapper, outputText);
const report = `import { getCompileCacheDir } from "node:module";
export async function runProgram() { console.log(JSON.stringify({ enabled: !!getCompileCacheDir() })); }
`;
fs.writeFileSync(path.join(dist, "index.js"), report + "export function createProgram() { return {}; }\n");
fs.writeFileSync(path.join(dist, "lazy-program.js"), report + "export async function createLazyProgram() { return {}; }\n");
const env = { ...process.env, TMPDIR: tempDir };
delete env.NODE_DISABLE_COMPILE_CACHE;
delete env.NODE_COMPILE_CACHE;
if (explicitCache) env.NODE_COMPILE_CACHE = path.join(tempDir, "explicit-cache");
// Set stream flags before the wrapper loads; front-door.test.ts covers a real PTY launch.
const nodeArgs = interactive
? ["--import", "data:text/javascript,process.stdin.isTTY=true;process.stdout.isTTY=true"]
: [];
const result = spawnSync(process.execPath, [...nodeArgs, wrapper, ...args], { env, encoding: "utf8", timeout: 5000 });
expect(result.status, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({ enabled: expected });
});
it("resolves the real dist entry from a symlinked wrapper path", () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-bin-wrapper-"));
const wrapperSource = fileURLToPath(new URL("../src/bin-wrapper.ts", import.meta.url));
@@ -64,24 +64,6 @@ describe("auto-start only for a daemon confirmed down", () => {
expect(c.requests.length).toBeGreaterThan(0);
});
it.each(["EPERM", "EACCES"])(`${name}: a blocked health probe reaches the shared start path (%s)`, async (code) => {
lifecycle.status = { state: "unverified", host: "127.0.0.1", port: 7433, probeErrorCode: code };
const c = client();
const cmd = (command as (d: unknown) => import("commander").Command)({ lifecycleDeps: {}, clientFactory: c.factory, preflightExec: async () => "" });
cmd.exitOverride();
await cmd.parseAsync(argv as unknown as string[]).catch(() => {});
expect(lifecycle.startDaemon).toHaveBeenCalledOnce();
});
it.each(["EPERM", "EACCES"])(`${name}: a blocked explicit remote target never starts a local daemon (%s)`, async (code) => {
lifecycle.status = { state: "unverified", host: "remote.example", port: 7433, probeErrorCode: code, explicitRemote: true };
const c = client();
const cmd = (command as (d: unknown) => import("commander").Command)({ lifecycleDeps: {}, clientFactory: c.factory, preflightExec: async () => "" });
cmd.exitOverride();
await cmd.parseAsync(argv as unknown as string[]).catch(() => {});
expect(lifecycle.startDaemon).not.toHaveBeenCalled();
});
}
it("rig up --plan starts nothing: a stopped daemon is daemon_not_running, a blocked probe keeps its own guidance", async () => {
@@ -5,10 +5,10 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { configCommand } from "../src/commands/config.js";
import { ENV_MAP } from "../src/config-store.js";
let dir: string;
let configPath: string;
let savedPort: string | undefined;
async function run(args: string[]): Promise<string> {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
@@ -25,14 +25,13 @@ describe("rig config get --with-source", () => {
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "config-get-with-source-"));
configPath = path.join(dir, "config.json");
for (const { primary, legacy } of Object.values(ENV_MAP)) {
vi.stubEnv(primary, undefined);
if (legacy) vi.stubEnv(legacy, undefined);
}
savedPort = process.env["OPENRIG_PORT"];
delete process.env["OPENRIG_PORT"];
});
afterEach(() => {
vi.unstubAllEnvs();
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
else process.env["OPENRIG_PORT"] = savedPort;
fs.rmSync(dir, { recursive: true, force: true });
});
@@ -73,55 +72,4 @@ describe("rig config get --with-source", () => {
const json = JSON.parse(await run(["get", "daemon.port", "--json"]));
expect(json).toMatchObject({ value: 7555, source: "file" });
});
it("reports the active environment override when setting or resetting a file value", async () => {
process.env["OPENRIG_PORT"] = "7666";
expect(await run(["set", "daemon.port", "7555"])).toContain("7666 (source: env; file override saved but masked)");
expect(JSON.parse(fs.readFileSync(configPath, "utf8")).daemon.port).toBe(7555);
expect(await run(["reset", "daemon.port"])).toContain("effective value 7666 (source: env; default: 7433)");
expect(await run(["reset"])).toContain("environment overrides them: daemon.port.");
});
it("retains the default reset message when no environment override remains", async () => {
expect(await run(["reset"])).toBe("Config reset to defaults.");
});
it("reports a successful reset when reading effective configuration fails", async () => {
fs.writeFileSync(configPath, JSON.stringify({ daemon: { port: 7555 } }));
vi.stubEnv("OPENRIG_CONTEXT_PACKS_ROOT", "/old-context");
const previousExitCode = process.exitCode;
process.exitCode = 0;
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
expect(await run(["reset"])).toBe("Config reset to defaults.");
expect(fs.existsSync(configPath)).toBe(false);
expect(errorSpy.mock.calls.flat().join(" ")).toContain("OPENRIG_CONTEXT_PACKS_ROOT was removed");
expect(process.exitCode).toBe(0);
} finally {
process.exitCode = previousExitCode;
errorSpy.mockRestore();
}
});
it("does not promise a masked boot setting will take effect on restart", async () => {
process.env["OPENRIG_PORT"] = "7666";
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await configCommand(configPath, { probeDaemonRunning: async () => true })
.parseAsync(["node", "rig", "set", "daemon.port", "7555"]);
expect(errorSpy.mock.calls.flat().join(" ")).toContain("remains masked after restart");
} finally { errorSpy.mockRestore(); }
});
it("distinguishes the saved threshold from the default used after an invalid environment pair", async () => {
vi.stubEnv("OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT", "98");
vi.stubEnv("OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT", undefined);
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
expect(await run(["set", "health.context_pressure.critical_percent", "97"]))
.toContain("file override saved; effective value 99 (source: default)");
expect(JSON.parse(fs.readFileSync(configPath, "utf8")).health.contextPressure.criticalPercent).toBe(97);
expect(stderr).toHaveBeenCalled();
} finally { stderr.mockRestore(); }
});
});
@@ -121,7 +121,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
it("VALID_KEYS includes legacy, user-settings, ui.preview, recovery, agents, feed, and transcript-rotation keys", () => {
const expected = [
"daemon.port", "daemon.host", "daemon.db_conflict", "daemon.seat_poll_interval_ms",
"daemon.port", "daemon.host", "daemon.db_conflict",
// OPR.0.4.6.MH1 FR-1/FR-4 — host-selection pointer + own-host name.
"host.selected",
"host.name",
@@ -202,12 +202,10 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"usage.pi_openrouter_charge",
"transport.verify_input_box",
"transport.local_user_guard",
"transport.draft_delivery",
// Slice 22 (OPR.0.7.2.22) — typed decisions, off by default.
"decisions.enabled",
"decisions.consumers",
"decisions.template_dir",
"acp.to",
// OPR.0.4.6.FS-1 W2 — queue-retention maintenance knobs.
"retention.enabled",
"retention.transitions_days",
@@ -215,7 +213,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",
"retention.events_days", // the history-only event types of events
"stream.intake",
"retention.batch_size",
"queue.pickup_stall_threshold_minutes",
@@ -332,11 +329,11 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
it("transport.verify_input_box defaults off, resolves its env twin, and appears in the resolved config", () => {
const store = new ConfigStore(configPath);
expect(store.resolveWithSource("transport.verify_input_box")).toMatchObject({ value: false, source: "default", defaultValue: false });
expect(store.resolve().transport).toEqual({ verifyInputBox: false, localUserGuard: false, draftDelivery: "deliver" });
expect(store.resolve().transport).toEqual({ verifyInputBox: false, localUserGuard: false });
try {
process.env.OPENRIG_TRANSPORT_VERIFY_INPUT_BOX = "1";
expect(store.resolveWithSource("transport.verify_input_box")).toMatchObject({ value: true, source: "env" });
expect(store.resolve().transport).toEqual({ verifyInputBox: true, localUserGuard: false, draftDelivery: "deliver" });
expect(store.resolve().transport).toEqual({ verifyInputBox: true, localUserGuard: false });
} finally {
delete process.env.OPENRIG_TRANSPORT_VERIFY_INPUT_BOX;
}
@@ -406,29 +403,6 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
expect(readFileSync(configPath, "utf8")).toBe(before);
});
it("rig config set retention.events_days 3 succeeds and rig config get returns 3", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const oldExitCode = process.exitCode;
try {
process.exitCode = undefined;
await configCommand(configPath, { probeDaemonRunning: async () => false })
.parseAsync(["node", "rig", "set", "retention.events_days", "3"]);
expect(process.exitCode).toBeUndefined();
expect(error).not.toHaveBeenCalled();
log.mockClear();
await configCommand(configPath, { probeDaemonRunning: async () => false })
.parseAsync(["node", "rig", "get", "retention.events_days"]);
expect(process.exitCode).toBeUndefined();
expect(log.mock.calls.flat().join(" ")).toMatch(/\b3\b/);
expect(new ConfigStore(configPath).get("retention.events_days")).toBe(3);
} finally {
log.mockRestore();
error.mockRestore();
process.exitCode = oldExitCode;
}
});
it("shows the file-pair explanation through the config command", async () => {
process.env.OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT = "50";
const error = vi.spyOn(console, "error").mockImplementation(() => {});
@@ -528,21 +502,6 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
}
});
it("retention.events_days: set 3 then get 3, default 0 (keep), 0 accepted, negative and non-integer refused", () => {
const store = new ConfigStore(configPath);
expect(store.get("retention.events_days")).toBe(0);
expect(store.resolve().retention.eventsDays).toBe(0);
store.set("retention.events_days", "3");
expect(store.get("retention.events_days")).toBe(3);
expect(store.resolve().retention.eventsDays).toBe(3);
expect(JSON.parse(readFileSync(configPath, "utf-8")).retention.eventsDays).toBe(3);
store.set("retention.events_days", "0");
expect(store.get("retention.events_days")).toBe(0);
for (const raw of ["-1", "1.5"]) {
expect(() => store.set("retention.events_days", raw)).toThrow();
}
});
// OPR.0.3.4.9 — CLI config resolve shape + malformed write rejection.
it("resolve().snapshots.periodic returns defaults", () => {
const store = new ConfigStore(configPath);
+1 -3
View File
@@ -24,9 +24,7 @@ const VIEWER_DESCRIPTION_FRAGMENT = "context-usage across running agents";
// situation-composed delivery verb. Both are delivery-FREE library verbs
// (get/profile SERVE bytes to the caller's stdout; nothing sends to a seat),
// so the delivery-seam exclusion below still holds.
// Reconstruction inspect/view reads local immutable records; it neither
// delivers to a seat nor starts or contacts a daemon.
const LIBRARY_SUBCOMMANDS = ["reconstruction", "work-install", "trace", "compose", "list", "show", "preview", "get", "profile", "recap-write", "sync", "add", "source", "rm"];
const LIBRARY_SUBCOMMANDS = ["work-install", "trace", "compose", "list", "show", "preview", "get", "profile", "recap-write", "sync", "add", "source", "rm"];
function topLevelNames(program: Command): string[] {
return program.commands.map((c) => c.name());
@@ -1,99 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import { Command } from "commander";
import { contextCommand } from "../src/commands/context.js";
import { resolveWorkContextHome } from "../src/lib/work-install.js";
const fixture = JSON.parse(readFileSync(new URL("./fixtures/reconstruction-v0.json", import.meta.url), "utf8")) as {
base: { id: string }; sourceStore: string;
};
describe("rig context reconstruction", () => {
let root: string, catalog: string, alpha: string, beta: string, home: string;
beforeEach(() => {
root = realpathSync(mkdtempSync(join(tmpdir(), "context-reconstruction-")));
catalog = join(root, "catalog"); alpha = join(root, "alpha"); beta = join(root, "beta"); home = join(root, "instance");
for (const path of [catalog, alpha, beta, home]) mkdirSync(path);
for (const [id, path] of [["alpha", alpha], ["beta", beta]]) {
const mission = join(path!, "workplans", "same-task");
mkdirSync(join(mission, "slices", "01-example", "reconstructions"), { recursive: true });
writeFileSync(join(path!, "project.yaml"), `schema: openrig.project/v0alpha1\nkind: project\nid: ${id}\nmissions: {root: workplans}\n`);
writeFileSync(join(mission, "slices", "01-example", "SPEC.md"), "---\nid: OPR.99.0.1\n---\nExample slice\n");
writeFileSync(join(mission, "slices", "01-example", "reconstructions", `${fixture.base.id}.json`), JSON.stringify(fixture.base));
}
writeFileSync(join(catalog, "workspace.yaml"), `schema: openrig.workspace/v0alpha1\nprojects:\n - id: alpha\n root: ${relative(catalog, alpha)}\n rigs: [example-rig]\n - id: beta\n root: ${relative(catalog, beta)}\n rigs: [other-rig]\n`);
vi.stubEnv("OPENRIG_HOME", home);
vi.stubEnv("OPENRIG_WORKSPACE_ROOT", catalog);
vi.stubEnv("OPENRIG_WORKSPACE_CATALOG_PATH", join(catalog, "workspace.yaml"));
vi.stubEnv("OPENRIG_SESSION_NAME", ""); vi.stubEnv("RIGGED_SESSION_NAME", "");
// If this local command accidentally contacts the daemon, fail in the test.
vi.spyOn(globalThis, "fetch").mockImplementation(() => { throw new Error("local reconstruction must not fetch"); });
});
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); rmSync(root, { recursive: true, force: true }); });
async function run(args: string[]) {
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const saved = process.exitCode; process.exitCode = undefined;
try {
const command = new Command().exitOverride(); command.addCommand(contextCommand());
await command.parseAsync(["node", "rig", "context", "reconstruction", ...args]);
return { output: log.mock.calls.map(call => call.join(" ")).join("\n"), errors: error.mock.calls, exitCode: process.exitCode };
} finally { process.exitCode = saved; log.mockRestore(); error.mockRestore(); }
}
it("resolves a catalog project, custom missions root and slice ID without writing or installing context", async () => {
const result = await run(["inspect", fixture.base.id, "--project", "beta", "--mission", "same-task", "--slice", "OPR.99.0.1"]);
const data = JSON.parse(result.output);
expect(result.exitCode).toBeUndefined();
expect(data.complete).toBe(true);
expect(data.resolution.projectId).toBe("beta");
expect(data.resolution.home).toBe(join(beta, "workplans", "same-task", "slices", "01-example"));
expect(data.requestedStore).toBe(join(data.resolution.home, "reconstructions"));
expect(data.nodes[0].sourceManifest).toHaveLength(2);
expect(data.nodes[0].items).toBeUndefined();
expect(readdirSync(home)).toEqual([]);
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("uses shared cwd/rig inference rather than choosing another project's same mission", () => {
const common = { workspaceRoot: catalog, catalogPath: join(catalog, "workspace.yaml"), mission: "same-task" };
expect(resolveWorkContextHome({ ...common, cwd: join(beta, "workplans") })).toMatchObject({ projectId: "beta", selectedBy: "cwd" });
expect(resolveWorkContextHome({ ...common, sessionName: "agent@example-rig", cwd: beta })).toMatchObject({ projectId: "alpha", selectedBy: "rig" });
expect(resolveWorkContextHome({ ...common, cwd: root })).toMatchObject({ error: { code: "project_required" } });
});
it("reads an explicit imported store even when the workspace configuration is unavailable", async () => {
vi.stubEnv("OPENRIG_WORKSPACE_ROOT", join(root, "missing"));
const store = join(alpha, "workplans", "same-task", "slices", "01-example", "reconstructions");
const result = await run(["view", fixture.base.id, "--store", store, "--item", "decision", "--json"]);
const data = JSON.parse(result.output);
expect(data.complete).toBe(true);
expect(data.resolution.mode).toBe("explicit-store");
expect(data.nodes[0].items.map((v: { id: string }) => v.id)).toEqual(["decision", "dependency", "constraint", "gap", "authority"]);
expect(data.nodes[0].viewCoverage.omittedIds).toEqual(["remove", "tagged", "omitted"]);
expect(readdirSync(home)).toEqual([]);
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("returns structured failures and nonzero status for missing context, invalid bounds or ambiguous projects", async () => {
const ambiguous = await run(["inspect", fixture.base.id, "--cwd", root]);
expect(ambiguous.exitCode).toBe(1); expect(JSON.parse(ambiguous.output).error.code).toBe("project_required");
const unavailable = await run(["inspect", "b".repeat(64), "--store", root]);
expect(unavailable.exitCode).toBe(1); expect(JSON.parse(unavailable.output).complete).toBe(false);
const bound = await run(["view", fixture.base.id, "--store", root, "--max-bytes", "NaN", "--json"]);
expect(bound.exitCode).toBe(1); expect(JSON.parse(bound.output).error.message).toContain("max-bytes");
});
it("rejects paths escaping the selected mission and invalid mission composition", () => {
const opts = { workspaceRoot: catalog, project: "alpha", mission: "same-task" };
expect(resolveWorkContextHome({ ...opts, mission: "../beta" })).toMatchObject({ error: { code: "invalid_mission" } });
expect(resolveWorkContextHome({ ...opts, slice: "../other" })).toMatchObject({ error: { code: "invalid_slice" } });
symlinkSync(beta, join(alpha, "workplans", "same-task", "slices", "outside"));
expect(resolveWorkContextHome({ ...opts, slice: "outside" })).toMatchObject({ error: { code: "slice_root_escape" } });
writeFileSync(join(alpha, "workplans", "same-task", "mission.yaml"), "composition: {slices: not-a-list}\n");
expect(resolveWorkContextHome({ ...opts, slice: "01-example" })).toMatchObject({ error: { code: "mission_composition_invalid" } });
});
});
@@ -158,23 +158,6 @@ describe("transport render: blocked is not down", () => {
});
describe("status probe keeps the cause; the precheck guard agrees with the transport render", () => {
it.each([
["http://remote.example:7433", true],
["http://127.0.0.1:7433", false],
["http://localhost:7433", false],
["http://[::1]:7433", false],
])("marks blocked explicit remote targets for %s", async (url, remote) => {
vi.stubEnv("OPENRIG_URL", url);
try {
const status = await getDaemonStatus(lifecycleDeps({ fetch: probeFailing("EPERM") }));
expect(status.state).toBe("unverified");
expect(status.probeErrorCode).toBe("EPERM");
expect(status.explicitRemote === true).toBe(remote);
} finally {
vi.unstubAllEnvs();
}
});
it("records EPERM on every probe path, and never calls a blocked probe 'stopped'", async () => {
process.env.OPENRIG_URL = URL;
try {
@@ -277,7 +260,7 @@ describe("sandboxed liveness: EPERM on kill(pid, 0) is not a dead daemon", () =>
expect(live("sent", "Z")).toBe("dead");
expect(live("sent", null)).toBe("unknown");
expect(live("missing", "S")).toBe("dead");
expect(live("not-permitted", "S")).toBe("unknown");
expect(live("not-permitted", "S")).toBe("alive");
expect(live("not-permitted", "Z")).toBe("dead");
expect(live("not-permitted", null)).toBe("unknown");
});
+1 -79
View File
@@ -3,7 +3,7 @@ import path from "node:path";
import fs from "node:fs";
import os from "node:os";
import { createServer } from "node:net";
import { createProcessLiveness, realDeps } from "../src/commands/daemon.js";
import { realDeps } from "../src/commands/daemon.js";
import { EventEmitter } from "node:events";
import type { ChildProcess } from "node:child_process";
import {
@@ -81,57 +81,6 @@ function writtenState(deps: LifecycleDeps): DaemonState {
}
describe("Daemon Lifecycle", () => {
it.each(["EPERM", "EACCES"])("warns and starts when state is absent and the initial probe is blocked (%s)", async (code) => {
let probes = 0;
const deps = startableDeps({ fetch: vi.fn(async () => { if (probes++ === 0) throw Object.assign(new Error("probe blocked"), { code }); return startupHealth(); }) });
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try { await startDaemon({ port: 7433 }, deps); expect(deps.spawn).toHaveBeenCalledOnce(); expect(warn).toHaveBeenCalled(); } finally { warn.mockRestore(); }
});
it("does not spawn when recorded process liveness is unknown and its probe is blocked", async () => {
const state: DaemonState = { pid: 999, port: 7433, db: "fixture.db", startedAt: "2026-01-01T00:00:00Z" };
const deps = mockDeps({ exists: vi.fn((p: string) => p === STATE_FILE), readFile: vi.fn((p: string) => p === STATE_FILE ? JSON.stringify(state) : null), isProcessAlive: vi.fn(() => false), processLiveness: vi.fn(() => "unknown"), fetch: vi.fn(async () => { throw Object.assign(new Error("blocked"), { code: "EPERM" }); }) });
await expect(startDaemon({ port: 7433 }, deps)).rejects.toThrow(/blocked the connection/i);
expect(deps.spawn).not.toHaveBeenCalled();
});
it("continues startup when the recorded process is definitely dead", async () => {
const state: DaemonState = { pid: 999, port: 7433, db: "fixture.db", startedAt: "2026-01-01T00:00:00Z" };
const deps = startableDeps({ exists: vi.fn((p: string) => p === STATE_FILE), readFile: vi.fn((p: string) => p === STATE_FILE ? JSON.stringify(state) : null), processLiveness: vi.fn(() => "dead") });
await startDaemon({ port: 7433 }, deps);
expect(deps.spawn).toHaveBeenCalledOnce();
});
it("does not signal or remove state when recorded process liveness is unknown", async () => {
const state: DaemonState = { pid: 999, port: 7433, db: "fixture.db", startedAt: "2026-01-01T00:00:00Z" };
const deps = mockDeps({
exists: vi.fn((p: string) => p === STATE_FILE),
readFile: vi.fn((p: string) => p === STATE_FILE ? JSON.stringify(state) : null),
isProcessAlive: vi.fn(() => false),
processLiveness: createProcessLiveness({ signal: () => "not-permitted", readProcessState: () => "S" }),
fetch: vi.fn(neverFetch),
});
await expect(stopDaemon(deps)).rejects.toThrow(/liveness is unknown.*no signal sent.*state preserved/i);
expect(deps.fetch).not.toHaveBeenCalled();
expect(deps.kill).not.toHaveBeenCalled();
expect(deps.removeFile).not.toHaveBeenCalled();
});
it.each(["EPERM", "EACCES"])("preserves a live recorded PID when its identity probe is blocked (%s)", async (code) => {
const state: DaemonState = { pid: 999, port: 7433, db: "fixture.db", startedAt: "2026-01-01T00:00:00Z" };
const deps = mockDeps({
exists: vi.fn((p: string) => p === STATE_FILE),
readFile: vi.fn((p: string) => p === STATE_FILE ? JSON.stringify(state) : null),
isProcessAlive: vi.fn(() => true),
fetch: vi.fn(async () => { throw Object.assign(new Error("probe blocked"), { code }); }),
});
await expect(startDaemon({ port: 7433 }, deps)).rejects.toThrow(/blocked the connection/i);
expect(deps.spawn).not.toHaveBeenCalled();
await expect(stopDaemon(deps)).rejects.toThrow(/blocked.*no signal sent/i);
expect(deps.kill).not.toHaveBeenCalled();
expect(deps.removeFile).not.toHaveBeenCalled();
});
// A started child whose own endpoint names its PID and reports `hosts`; `probe` scripts each later
// request: "ok" answers, any other value throws with that error code.
function listenerStart(hosts: string[], probe: (url: string) => string) {
@@ -1590,33 +1539,6 @@ describe("buildDaemonEnv", () => {
expect(result["CMUX_WORKSPACE_ID"]).toBe("workspace:1");
});
it("never starts the daemon with the herdr-core terminal it is run from, while keeping HC_SESSION and HC_SOCKET", () => {
const result = buildDaemonEnv(
{
HOME: "/srv/tester",
PATH: "/usr/bin",
HC_SCOPE: "s-7",
HC_TERMINAL_ID: "t7",
HC_TERMINAL_SOCKET: "/run/hc.sock",
HC_SESSION: "work",
HC_SOCKET: "/run/hc/work.sock",
},
{ port: 7433, host: "127.0.0.1", db: "/tmp/test.db" },
);
expect([result["HC_SCOPE"], result["HC_TERMINAL_ID"], result["HC_TERMINAL_SOCKET"]]).toEqual([undefined, undefined, undefined]);
expect([result["HC_SESSION"], result["HC_SOCKET"]]).toEqual(["work", "/run/hc/work.sock"]);
expect(result["HOME"]).toBe("/srv/tester");
});
it("keeps explicit core selection and an operator's own HC_* when no terminal scope is present", () => {
const result = buildDaemonEnv(
{ HOME: "/srv/tester", PATH: "/usr/bin", HC_SESSION: "work", HC_SOCKET: "/run/hc/work.sock" },
{ port: 7433, host: "127.0.0.1", db: "/tmp/test.db" },
);
expect([result["HC_SESSION"], result["HC_SOCKET"]]).toEqual(["work", "/run/hc/work.sock"]);
expect(result["HC_SCOPE"]).toBeUndefined();
});
it("explicit opts override inherited OPENRIG_* from base env", () => {
const baseEnv: Record<string, string> = {
HOME: "/Users/tester",
-56
View File
@@ -912,59 +912,3 @@ describe("doctor --spec topology HTTP deadlines", () => {
}
});
});
describe("#161 tmux control mode", () => {
const daemonSays = (body: unknown, env: NodeJS.ProcessEnv = {}) => makeDeps({
env,
fetch: async (url: string) => url.endsWith("/api/adapters/tmux/control")
? ({ ok: true, json: async () => body } as Response) : ({ ok: false } as Response),
});
const controlCheck = async (deps: DoctorDeps) =>
(await Promise.all(runDoctorChecks(deps).asyncChecks)).find((c) => c.name === "tmux_control_mode");
it("warns when it was asked for and the daemon's tmux server is older than 3.7c: the version, why, and what to do", async () => {
const check = await controlCheck(daemonSays({ requested: true, state: "off", serverVersion: "3.4" }));
expect(check?.status).toBe("warn");
expect(check?.message).toContain("tmux 3.4");
expect(check?.message).toContain("3.7c or later");
expect(check?.reason).toContain("not the tmux binary");
expect(check?.fix).toContain("rig context get reference/tmux-servers.md#moving-an-existing-team-onto-tmux-control-mode");
});
it("passes when control mode is on, naming the server's version", async () => {
const check = await controlCheck(daemonSays({ requested: true, state: "on", serverVersion: "3.8" }));
expect(check).toMatchObject({ status: "pass", message: expect.stringContaining("tmux 3.8") });
});
it("#2707 warns when the server allows it but no control client is connected: commands are spawned meanwhile", async () => {
const check = await controlCheck(daemonSays({ requested: true, state: "allowed", serverVersion: "3.8" }));
expect(check?.status).toBe("warn");
expect(check?.message).toContain("tmux 3.8 allows it");
expect(check?.message).toContain("no control client is connected right now");
});
it("#2707 names a version it doesn't recognize as unrecognized, and a missing client wrapper by its causes", async () => {
const unknown = await controlCheck(daemonSays({ requested: true, state: "off", serverVersion: "next-3.8" }));
expect(unknown?.message).toContain("tmux next-3.8, which isn't a tmux release known to be 3.7c or later");
const wrapperless = await controlCheck(daemonSays({ requested: true, state: "off" }));
expect(wrapperless?.message).toContain("Windows");
expect(wrapperless?.message).toContain("OPENRIG_TMUX_SHELL=1");
});
it("says it isn't decided while the daemon hasn't read its server's version yet", async () => {
const check = await controlCheck(daemonSays({ requested: true, state: "undecided" }));
expect(check).toMatchObject({ status: "skipped", message: expect.stringContaining("hasn't read its tmux server's version yet") });
});
it("warns when this shell sets it but the daemon wasn't started with it", async () => {
const check = await controlCheck(daemonSays({ requested: false, state: "off" }, { OPENRIG_TMUX_CONTROL: "1" }));
expect(check).toMatchObject({ status: "warn", message: expect.stringContaining("wasn't started with it") });
expect(check?.fix).toContain("daemon's own environment");
});
it("skips when it isn't asked for, or when the daemon doesn't say", async () => {
expect(await controlCheck(daemonSays({ requested: false, state: "off" }))).toMatchObject({ status: "skipped" });
const older = makeDeps({ env: { OPENRIG_TMUX_CONTROL: "1" }, fetch: async () => ({ ok: false } as Response) });
expect(await controlCheck(older)).toMatchObject({ status: "skipped", message: expect.stringContaining("didn't say") });
});
});
-193
View File
@@ -1,193 +0,0 @@
{
"provenance": "Sanitized fixtures using the v0 helper test shape; IDs computed with Python json.dumps(sort_keys=True,separators=(comma,colon),ensure_ascii=False). No real work records.",
"sourceStore": "/example/original/reconstructions",
"base": {
"schema": "openrig.reconstruction/v0",
"kind": "base",
"subject": "work:example",
"author": "example:author",
"purpose": "Continue the selected example task",
"depth": "working",
"created_at": "2026-10-01T10:00:00+00:00",
"coverage": {
"included": [
"selected task"
],
"excluded": [
"credentials"
]
},
"unknowns": [
"No current runtime check"
],
"items": [
{
"id": "decision",
"category": "decision",
"text": "Working understanding decision",
"observed_at": "2026-10-01T10:00:00+00:00",
"sources": [
"route"
],
"depends_on": [
"dependency"
]
},
{
"id": "dependency",
"category": "evidence",
"text": "Working understanding dependency",
"observed_at": "2026-10-01T10:00:00+00:00"
},
{
"id": "remove",
"category": "decision",
"text": "Working understanding remove",
"observed_at": "2026-10-01T10:00:00+00:00"
},
{
"id": "constraint",
"category": "constraint",
"text": "Working understanding constraint",
"observed_at": "2026-10-01T10:00:00+00:00"
},
{
"id": "gap",
"category": "gap",
"text": "Working understanding gap",
"observed_at": "2026-10-01T10:00:00+00:00"
},
{
"id": "authority",
"category": "authority",
"text": "Working understanding authority",
"observed_at": "2026-10-01T10:00:00+00:00"
},
{
"id": "tagged",
"category": "detail",
"text": "Working understanding tagged",
"observed_at": "2026-10-01T10:00:00+00:00",
"tags": [
"next"
]
},
{
"id": "omitted",
"category": "detail",
"text": "Working understanding omitted",
"observed_at": "2026-10-01T10:00:00+00:00"
}
],
"sources": [
{
"id": "route",
"address": "transcript:example:lines-1-5",
"observed_at": "2026-10-01T10:00:00+00:00"
},
{
"id": "uncited",
"address": "https://example.invalid/context",
"observed_at": null
}
],
"discovery": [],
"id": "da27fa1f5276d29d1da0b3877cbc9344b25212b9e331a44e970963ae6a7af3f6"
},
"delta": {
"schema": "openrig.reconstruction/v0",
"kind": "delta",
"subject": "work:example",
"author": "example:author",
"purpose": "Continue the selected example task",
"depth": "working",
"created_at": "2026-10-01T10:00:00+00:00",
"parent": {
"store": "/example/original/reconstructions",
"revision": "da27fa1f5276d29d1da0b3877cbc9344b25212b9e331a44e970963ae6a7af3f6"
},
"upsert_items": [
{
"id": "decision",
"category": "decision",
"text": "A changed assessment based on new evidence",
"observed_at": "2026-10-27T23:00:00+00:00",
"sources": [
"route"
],
"depends_on": [
"dependency"
]
}
],
"remove_items": [
"remove"
],
"id": "36623c590a22ce12401132ae90f9aae29fa88c07f6ae76ebf182fa5ed701a7a1"
},
"other": {
"schema": "openrig.reconstruction/v0",
"kind": "base",
"subject": "seat:helper",
"author": "example:author",
"purpose": "Continue the selected example task",
"depth": "working",
"created_at": "2026-10-01T10:00:00+00:00",
"items": [
{
"id": "activity",
"category": "constraint",
"text": "Working understanding activity",
"observed_at": "2026-10-01T10:00:00+00:00"
}
],
"sources": [],
"created_note": "Different observation dates are not an atomic capture",
"id": "d3dbba4b6dffe6aec89c7fc71be5f5ee07e231241ae3167b3a7e3d9bf1bc0fe3"
},
"composition": {
"schema": "openrig.reconstruction/v0",
"kind": "composition",
"subject": "group:example",
"author": "example:author",
"purpose": "Continue the selected example task",
"depth": "working",
"created_at": "2026-10-01T10:00:00+00:00",
"members": [
{
"store": "/example/original/reconstructions",
"revision": "36623c590a22ce12401132ae90f9aae29fa88c07f6ae76ebf182fa5ed701a7a1"
},
{
"store": "/example/original/reconstructions",
"revision": "d3dbba4b6dffe6aec89c7fc71be5f5ee07e231241ae3167b3a7e3d9bf1bc0fe3"
}
],
"id": "abdca73469bd2e7c75443d2e7648757616f04c62e449d623a97a6ede49f28b28"
},
"canonicalVector": {
"value": {
"10": "ten",
"2": "two",
"é": "été",
"😀": "astral",
"": "bmp",
"__proto__": {
"safe": true
},
"control": "\b\n\t",
"ints": [
0,
-4,
9007199254740991
],
"values": [
null,
false,
true
]
},
"canonical": "{\"10\":\"ten\",\"2\":\"two\",\"__proto__\":{\"safe\":true},\"control\":\"\\b\\n\\t\",\"ints\":[0,-4,9007199254740991],\"values\":[null,false,true],\"é\":\"été\",\"\":\"bmp\",\"😀\":\"astral\"}",
"sha256": "9c3b2f8426bd1ed10bf2740a1a759e6f199431f9f862af0bce968c49be627b7d"
}
}
@@ -48,23 +48,6 @@ describe("rig gateway human add verb (post-relocate)", () => {
expect(bindingOpt).toBeDefined();
expect(bindingOpt!.description).toContain("connectorRef and secretsRef are config/vault labels");
expect(bindingOpt!.description).toContain("tokens come from 'rig slack setup --secrets-env-file'");
expect(bindingOpt!.description).toContain("or file:<path> for relay");
});
it("gateway human add help text documents relay binding syntax and requirements", () => {
const gw = createProgram().commands.find((c) => c.name() === "gateway");
const human = gw!.commands.find((c) => c.name() === "human");
const addCmd = human!.commands.find((c) => c.name() === "add");
let help = "";
addCmd!.configureOutput({
writeOut: (text) => { help += text; },
writeErr: () => {},
});
addCmd!.outputHelp();
expect(help).toContain("relay:<connectorRef>:file:<absolute-env-file>:primary:handle=<safe-alias>");
expect(help).toContain("OPENRIG_RELAY_URL");
expect(help).toContain("OPENRIG_RELAY_OUTBOUND_TOKEN");
expect(help).toContain("OPENRIG_RELAY_INBOUND_TOKEN");
});
it("add writes a fragment + projection; address DERIVED; vault-pointer secretsRef (with ':') survives", async () => {
+2 -32
View File
@@ -42,10 +42,9 @@ describe("rig host pair (OPR.0.4.6.MH1 FR-6)", () => {
function fakeDeps(opts: {
post?: { status: number; body: unknown };
poll: Array<{ status: number; body: unknown }>;
}): DoctorDeps & { postCalls: () => number; posted: unknown[] } {
}): DoctorDeps & { postCalls: () => number } {
let polls = 0;
let posts = 0;
const posted: unknown[] = [];
return {
run: async () => ({ ok: true, stdout: "", stderr: "", exitCode: 0 }) as never,
httpGet: async () => {
@@ -53,15 +52,13 @@ describe("rig host pair (OPR.0.4.6.MH1 FR-6)", () => {
polls += 1;
return { status: r.status, body: JSON.stringify(r.body) };
},
httpPost: async (_url, body) => {
httpPost: async () => {
posts += 1;
posted.push(body);
const r = opts.post ?? { status: 200, body: { pairId: "p-1", code: "424242", approvalQitemId: "qitem-1" } };
return { status: r.status, body: JSON.stringify(r.body) };
},
tcpProbe: async () => "open" as const,
postCalls: () => posts,
posted,
};
}
@@ -96,33 +93,6 @@ describe("rig host pair (OPR.0.4.6.MH1 FR-6)", () => {
expect(yaml).not.toContain("issued-bearer");
}, 15_000);
it("--timeout reaches the target, which keeps the request open that long", async () => {
const deps = fakeDeps({ poll: [{ status: 200, body: { status: "denied" } }] });
await capture(() => run(deps, ["pair", "http://vps-a:7433", "--timeout", "3600"]));
expect(deps.posted).toEqual([expect.objectContaining({ timeoutMs: 3_600_000 })]);
}, 15_000);
it("a one-second wait sees an approval that is already there: the first poll comes before the target's deadline", async () => {
// A stand-in target that honours the posted lifetime: approved inside it, expired after it.
let postedAt = 0;
let lifeMs = 0;
const deps: DoctorDeps = {
run: async () => ({ ok: true, stdout: "", stderr: "", exitCode: 0 }) as never,
tcpProbe: async () => "open" as const,
httpPost: async (_url, body) => {
postedAt = Date.now();
lifeMs = (body as { timeoutMs: number }).timeoutMs;
return { status: 200, body: JSON.stringify({ pairId: "p-1", code: "424242", approvalQitemId: "qitem-1" }) };
},
httpGet: async () => ({ status: 200, body: JSON.stringify(Date.now() - postedAt > lifeMs ? { status: "expired" } : { status: "approved", token: "issued-bearer" }) }),
};
const { out, err, exitCode } = await capture(() => run(deps, ["pair", "vps-a:7433", "--id", "vps-a", "--timeout", "1"]));
expect(lifeMs).toBe(1000);
expect(err).toEqual([]);
expect(exitCode).toBeUndefined();
expect(out.join("\n")).toContain("Paired. Host 'vps-a' registered");
}, 15_000);
it("denied: loud error, exit 1, NOTHING persists", async () => {
const deps = fakeDeps({ poll: [{ status: 200, body: { status: "denied" } }] });
const { err, exitCode } = await capture(() => run(deps, ["pair", "http://vps-a:7433", "--timeout", "10"]));
-16
View File
@@ -132,22 +132,6 @@ describe("rig mcp serve --http on the daemon's routes", () => {
await expect(startMcpHttpServer({ daemonUrl, host: "0.0.0.0", port: 0, tokenFile })).rejects.toBeInstanceOf(McpHttpConfigError);
});
it("with no grants and no seat, every request writes as the process's one unknown sender, and a same-id retry matches", async () => {
delete process.env.OPENRIG_SESSION_NAME;
const served = await listen();
const local = await connect(served.url);
// Each request gets its own server here, so a label minted per server would differ on every call below.
const label = (await local("rig_whoami")).structured.unknownSender as string;
expect(label).toMatch(/^unknown-mcp-[0-9a-f]{8}$/);
for (const body of ["first", "second"]) expect((await local("rig_queue_create", { destination: "impl@team", body })).isError).toBe(false);
const retry = { qitemId: "qitem-20261107000000-unknownretry", destination: "impl@team", body: "once" };
expect((await local("rig_queue_create", retry)).isError).toBe(false);
const again = await local("rig_queue_create", retry);
expect(again.isError, again.text).toBe(false);
expect(rows()).toBe(3);
expect((db.prepare("SELECT DISTINCT source_session AS s FROM queue_items").all() as Array<{ s: string }>).map((row) => row.s)).toEqual([label]);
});
it("with grants, refuses a missing or invalid credential on loopback, never as the env's seat", async () => {
await grant("alice");
const served = await listen();
@@ -10,7 +10,6 @@ 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 { Client as ModernClient, StreamableHTTPClientTransport as ModernTransport } from "@modelcontextprotocol/client";
import type Database from "better-sqlite3";
import { createTestApp } from "../../daemon/test/helpers/test-app.js";
import { createDb } from "../../daemon/src/db/connection.js";
@@ -18,11 +17,8 @@ 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 { MissionControlWriteContract } from "../../daemon/src/domain/mission-control/mission-control-write-contract.js";
import { MissionControlActionLog } from "../../daemon/src/domain/mission-control/mission-control-action-log.js";
import { setSelfHostId } from "../../daemon/src/domain/hosts/fanout-contract.js";
import { addHumanFragment, loadHumanRegistry } from "../../daemon/src/domain/gateway/human-registry.js";
import { setDefaultPerson } from "../../daemon/src/domain/gateway/participants.js";
import { makeQueuePorts } from "../../daemon/src/domain/gateway/slack/queue-access.js";
import { buildOutboundMessage } from "../../daemon/src/domain/gateway/slack/message.js";
import { createHash } from "node:crypto";
@@ -71,11 +67,8 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
shared = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "mcp-grant-shared-")));
process.env.OPENRIG_HOME = home;
process.env.OPENRIG_SESSION_NAME = "env-seat@elsewhere";
const bus = new EventBus(db);
repo = new QueueRepository(db, bus, { validateRig: () => true });
// Steering's own write path, so a person's answer through an assistant goes where Steering's does.
const missionControlWriteContract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo: repo, actionLog: new MissionControlActionLog(db) });
setup = createTestApp(db, { appDeps: { queueRepo: repo, missionControlWriteContract, filesAllowlist: [{ name: "proof", canonicalPath: shared }] } });
repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
setup = createTestApp(db, { appDeps: { queueRepo: repo, filesAllowlist: [{ name: "proof", canonicalPath: shared }] } });
broken = undefined;
const fetchRoute = (...args: Parameters<typeof setup.app.fetch>) => broken?.(args[0]) ?? setup.app.fetch(...args);
daemon = await new Promise<ServerType>((resolve) => { const s = serve({ fetch: fetchRoute, port: 0, hostname: "127.0.0.1" }, () => resolve(s)); });
@@ -114,12 +107,11 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
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}`);
return node;
}
async function listen(header?: { name: string; value: string }, taskDestination?: string) {
async function listen(header?: { name: string; value: string }) {
const served = await startMcpHttpServer({ daemonUrl, host: "127.0.0.1", port: 0, tokenFile: path.join(home, "mcp-http-tokens.json"),
taskDestination, grantService: { url: serviceUrl, ...(header ? { header } : {}), timeoutMs: 1_000 } });
grantService: { url: serviceUrl, ...(header ? { header } : {}), timeoutMs: 1_000 } });
closers.push(() => served.close());
return served;
}
@@ -132,8 +124,7 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
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),
catalog: () => client.listTools(), instructions: () => client.getInstructions() });
return Object.assign(call, { tools: async () => (await client.listTools()).tools.map((tool) => tool.name) });
}
const post = (url: string, headers: Record<string, string> = {}) => fetch(url, {
@@ -143,160 +134,6 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
const create = (sourceSession: string, destinationSession: string, body: string) =>
repo.create({ sourceSession, destinationSession, body, nudge: false } as never);
it("routes an address-free first task to the operator's receiver with caller attribution, retry identity and revision lineage", async () => {
seat("team-a", "lead"); // pod.lead exists without any delegates_to edge.
const { url } = await listen(undefined, "pod-lead@team-a");
const ask = await connect(url);
expect(rows()).toBe(0);
const catalog = (await ask.catalog()).tools;
expect(catalog.map((tool) => tool.name).sort()).toEqual(["rig_artifact_read", "rig_queue_create", "rig_queue_list", "rig_queue_show"]);
expect(catalog.find((tool) => tool.name === "rig_queue_create")?.inputSchema.required).not.toContain("destination");
expect(ask.instructions()).toContain("You may omit destination");
expect(ask.instructions()).not.toMatch(/rig_whoami|rig_team|rig_queue_claim|rig_queue_handoff|rig_queue_decide/);
const args = { qitemId: "qitem-first-task", body: "Create a useful file and return it" };
const first = await ask("rig_queue_create", args);
expect(first.isError).toBe(false);
expect(repo.getById(args.qitemId)).toMatchObject({ sourceSession: CALLER, destinationSession: "pod-lead@team-a", body: args.body });
const retry = await ask("rig_queue_create", args);
expect(retry.isError).toBe(false);
expect(retry.structured.qitemId).toBe(args.qitemId);
expect(rows()).toBe(1);
const revision = await ask("rig_queue_create", { body: "Improve the file", revises: args.qitemId });
expect(revision.isError).toBe(false);
expect(repo.getById(revision.structured.qitemId)).toMatchObject({ sourceSession: CALLER, destinationSession: "pod-lead@team-a",
chainOfRecord: [args.qitemId], body: `Revision of ${args.qitemId}.\n\nImprove the file` });
// A supplied destination still takes the original route, even with a different configured receiver.
expect((await ask("rig_queue_create", { destination: "other@team-a", body: "Explicit task" })).isError).toBe(false);
expect((await ask("rig_queue_show", { qitemId: args.qitemId })).isError).toBe(false);
fs.writeFileSync(path.join(shared, "first-result.md"), "Team's returned file");
db.prepare("UPDATE queue_items SET evidence_ref = ? WHERE qitem_id = ?").run(path.join(shared, "first-result.md"), args.qitemId);
expect((await ask("rig_artifact_read", { qitemId: args.qitemId })).text).toContain("Team's returned file");
expect((await ask("rig_artifact_read", { root: "proof", path: "first-result.md" })).structured.code).toBe("out_of_scope");
});
it("also serves an address-free first task to a client pinned to protocol 2026-07-28", async () => {
seat("team-a", "lead");
const { url } = await listen(undefined, "pod-lead@team-a");
const client = new ModernClient({ name: "first-task", version: "1" }, { versionNegotiation: { mode: { pin: "2026-07-28" } } });
await client.connect(new ModernTransport(new URL(url), { requestInit: { headers: { Authorization: `Bearer ${KEY}` } } }));
closers.push(() => client.close());
expect(client.getProtocolEra()).toBe("modern");
const tools = (await client.listTools()).tools;
expect(tools.find((tool) => tool.name === "rig_queue_create")?.inputSchema.required).not.toContain("destination");
const result = await client.callTool({ name: "rig_queue_create", arguments: { qitemId: "qitem-modern-first", body: "Create the file" } });
expect(result.isError).not.toBe(true);
expect(repo.getById("qitem-modern-first")).toMatchObject({ sourceSession: CALLER, destinationSession: "pod-lead@team-a" });
});
it("does not infer a receiver from grant metadata, caller identity or an empty queue", async () => {
const { url } = await listen(); // The fixture's environment.lead is deliberately not a routing contract.
const ask = await connect(url);
expect((await ask("rig_queue_create", { body: "First task" })).structured.code).toBe("task_destination_unavailable");
expect(rows()).toBe(0);
expect((await ask("rig_queue_create", { destination: "lead@team-a", body: "Explicit still works" })).isError).toBe(false);
});
it.each([CALLER, "cus-another-caller@team-a", "pod-lead@team-b"])("never uses configured receiver %s outside the caller's team scope", async (target) => {
const { url } = await listen(undefined, target);
const ask = await connect(url);
expect((await ask("rig_queue_create", { body: "First task" })).structured.code).toBe("out_of_scope");
expect(rows()).toBe(0);
});
it("requires one existing receiver and rejects ambiguous rig names without writing", async () => {
const { url } = await listen(undefined, "pod-lead@team-a");
const ask = await connect(url);
expect((await ask("rig_queue_create", { body: "Receiver absent" })).structured.code).toBe("task_destination_unavailable");
seat("team-a", "lead");
setup.rigRepo.createRig("team-a");
expect((await ask("rig_queue_create", { body: "Rig ambiguous" })).structured.code).toBe("task_destination_unavailable");
expect(rows()).toBe(0);
});
it("a person who names their lead by its name alone reaches that seat of the connection's one rig", async () => {
seat("team-a", "lead");
const { url } = await listen();
const ask = await connect(url);
const sent = await ask("rig_queue_create", { qitemId: "qitem-bare-name", destination: "pod-lead", body: "Do this directly" });
expect(sent.isError).toBe(false);
expect(repo.getById("qitem-bare-name")).toMatchObject({ sourceSession: CALLER, destinationSession: "pod-lead@team-a" });
// A name that is no seat there, or the caller's own, writes nothing.
for (const name of ["my-lead", "cus-vvmaexample01-caller", ""]) {
expect((await ask("rig_queue_create", { destination: name, body: "Nobody" })).structured.code).toBe("out_of_scope");
}
expect(rows()).toBe(1);
const described = (await ask.catalog()).tools.find((tool) => tool.name === "rig_queue_create")?.inputSchema.properties?.destination as { description?: string };
expect(described.description).toContain("name alone");
});
it("with several rigs, a name alone reaches only the configured receiver of that name; a refusal says to omit destination, without naming it", async () => {
seat("team-a", "lead");
seat("team-a", "other"); // A seat of one of the rigs: with several, its name alone still doesn't pick that rig.
answer = () => [200, active({ rigs: ["team-a", "team-b"] })];
const { url } = await listen(undefined, "pod-lead@team-a");
const ask = await connect(url);
expect((await ask("rig_queue_create", { qitemId: "qitem-bare-receiver", destination: "pod-lead", body: "Do this directly" })).isError).toBe(false);
expect(repo.getById("qitem-bare-receiver")).toMatchObject({ destinationSession: "pod-lead@team-a" });
for (const destination of ["pod-other", "my-lead", "pod-lead@team-c"]) {
const refused = await ask("rig_queue_create", { destination, body: "Ambiguous or unknown" });
expect(refused.structured).toMatchObject({ code: "out_of_scope", message: expect.stringContaining("Omit destination to reach the team's configured receiver.") });
expect(refused.structured.message).not.toContain("pod-lead@team-a");
}
expect(rows()).toBe(1);
});
it("a receiver outside this connection's grant neither captures a name alone nor gives the hint", async () => {
seat("team-a", "lead");
seat("team-b", "lead");
const { url } = await listen(undefined, "pod-lead@team-b");
const ask = await connect(url);
expect((await ask("rig_queue_create", { qitemId: "qitem-own-lead", destination: "pod-lead", body: "Do this directly" })).isError).toBe(false);
expect(repo.getById("qitem-own-lead")).toMatchObject({ destinationSession: "pod-lead@team-a" });
const refused = await ask("rig_queue_create", { destination: "my-lead", body: "Nobody" });
expect(refused.structured.code).toBe("out_of_scope");
expect(refused.structured.message).not.toContain("Omit destination");
expect(rows()).toBe(1);
});
it("rechecks the grant and receiver after connection, with no write after removal or revocation", async () => {
const receiver = seat("team-a", "lead");
const { url } = await listen(undefined, "pod-lead@team-a");
const ask = await connect(url);
expect((await ask("rig_queue_create", { body: "Granted" })).isError).toBe(false);
setup.rigRepo.deleteNode(receiver.id);
expect((await ask("rig_queue_create", { body: "Receiver removed" })).structured.code).toBe("task_destination_unavailable");
seat("team-a", "lead");
answer = () => [200, active({ objects: { queue: ["show", "list"], artifact: ["read"] } })];
await expect(ask("rig_queue_create", { body: "Create permission removed" })).rejects.toThrow();
answer = () => [401, { schema: SCHEMA, status: "revoked" }];
await expect(ask("rig_queue_create", { body: "Revoked" })).rejects.toThrow();
expect(rows()).toBe(1);
});
it("rejects task destination configuration without grant service or with non-local seat syntax", async () => {
const base = { daemonUrl, host: "127.0.0.1", port: 0, tokenFile: path.join(home, "unused") };
await expect(startMcpHttpServer({ ...base, taskDestination: "pod-lead@team-a" })).rejects.toThrow("needs --grant-service");
for (const target of ["", "lead", "a@b@host", "person@external", "lead @team-a"]) {
await expect(startMcpHttpServer({ ...base, grantService: { url: serviceUrl }, taskDestination: target })).rejects.toThrow(McpHttpConfigError);
}
});
it("requires HTTP and a grant service for the task-destination CLI option", async () => {
const quiet = vi.spyOn(console, "error").mockImplementation(() => {});
const exitCode = process.exitCode;
try {
for (const args of [[], ["--http"]]) {
process.exitCode = undefined;
await mcpCommand().parseAsync(["serve", "--port", "1", "--task-destination", "pod-lead@team-a", ...args], { from: "user" });
expect(process.exitCode).toBe(1);
}
expect(quiet).toHaveBeenCalledWith("--task-destination needs --http --grant-service");
} finally {
process.exitCode = exitCode;
quiet.mockRestore();
}
});
it("asks the grant service on every request with the raw key and this daemon's self id, acts as the grant's seat, and never reads the token file", async () => {
const fileToken = await addGrant(path.join(home, "mcp-http-tokens.json"), { name: "local", seat: "local@team-a", rigs: ["team-a"], roots: [], workspace: false, owner: true });
const served = await listen({ name: "x-example-bypass", value: "preview-secret" });
@@ -524,380 +361,6 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
expect(repo.getById(gated.structured.qitemId)!.tags).toEqual(["project:acme", "workflow", "workflow:x", "instance:y"]);
});
describe("a person answering the team's questions from their assistant", () => {
/** The connection's owner, registered as the grant service provisions them: no connector, the default person. */
const PERSON = "cus-vvmaexample01";
const register = (entityId: string, asDefault: boolean) => {
expect(addHumanFragment({ entityId, class: "human", displayName: entityId, address: `${entityId}@external`, connectorBindings: [], prefs: { deliveryClass: "B" } } as never, home)).toMatchObject({ ok: true });
if (asDefault) expect(setDefaultPerson(entityId, home)).toMatchObject({ ok: true });
};
// A synthetic principal, so the route recorded beside an answer is plainly a fixture's.
const grantFor = (entityId: string | null, actions = ["read", "answer"]) => active({
principal: { id: "principal-example-01", kind: "external_caller", label: "Example caller" },
objects: { queue: ["create", "show", "list"], artifact: ["read"], question: actions }, ...(entityId ? { person: { entity_id: entityId } } : {}),
});
const LAYOUT = { id: "layout", question: "Which layout?", options: [{ id: "a", label: "Cards" }, { id: "b", label: "List" }] };
const SHIP = { id: "ship", question: "Ship it this week?", options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }] };
const COLOR = { id: "color", question: "Which accent color?", options: [{ id: "red", label: "Red" }, { id: "teal", label: "Teal" }] };
const ask = (destinationSession: string, questions: unknown[] | null = [LAYOUT]) => repo.create({ sourceSession: "lead@team-a", destinationSession,
summary: "Pick a layout", body: "Which layout should the landing page use?", evidenceRef: "/proof/layout.md", humanIntent: "decision", ...(questions ? { humanQuestions: questions } : {}), nudge: false } as never) as unknown as Promise<{ qitemId: string }>;
const replies = (qitemId: string) => (db.prepare("SELECT COUNT(*) AS n FROM queue_items WHERE destination_session = 'lead@team-a' AND tags LIKE '%human-answer%' AND body LIKE ?")
.get(`%${qitemId}%`) as { n: number }).n;
const audit = (qitemId: string) => db.prepare("SELECT actor_session AS actor, audit_notes_json AS notes FROM mission_control_actions WHERE qitem_id = ? ORDER BY rowid")
.all(qitemId).map((row: any) => ({ actor: row.actor, notes: JSON.parse(row.notes ?? "{}") }));
const steering = async (view: string) => ((await (await fetch(`${daemonUrl}/api/attention?view=${view}`)).json()) as { items: Array<{ id: string }> }).items.map((item) => item.id);
it("is offered only on a grant that names a person, and its instructions say so in their first 512 characters", async () => {
register(PERSON, true);
answer = () => [200, grantFor(PERSON)];
const asPerson = await connect((await listen()).url);
expect(await asPerson.tools()).toEqual(expect.arrayContaining(["rig_question_list", "rig_question_answer"]));
const first = (asPerson.instructions() ?? "").split("\n")[0]!;
expect(first.length).toBeLessThanOrEqual(512);
for (const tool of ["rig_queue_create", "rig_queue_show", "rig_question_list", "rig_question_answer"]) expect(first).toContain(tool);
// The question pair without a person offers nothing: the caller's seat is never taken for a person.
answer = () => [200, grantFor(null)];
const noPerson = await connect((await listen()).url);
expect(await noPerson.tools()).not.toEqual(expect.arrayContaining(["rig_question_list"]));
expect((await noPerson.tools()).filter((tool) => tool.startsWith("rig_question_"))).toEqual([]);
const plain = (noPerson.instructions() ?? "").split("\n")[0]!;
expect(plain.length).toBeLessThanOrEqual(512);
expect(plain).toContain("reserved for a person");
// An unusable person withholds only the person tools; the grant's other tools keep working, and the log says why.
const logged = vi.spyOn(console, "error").mockImplementation(() => {});
try {
answer = () => [200, grantFor("Not An Id")];
const malformed = await connect((await listen()).url);
const tools = await malformed.tools();
expect(tools).toEqual(expect.arrayContaining(["rig_queue_create", "rig_queue_show", "rig_artifact_read"]));
expect(tools.filter((tool) => tool.startsWith("rig_question_"))).toEqual([]);
expect(logged.mock.calls.flat().join("\n")).toContain("unusable person.entity_id");
} finally {
logged.mockRestore();
}
});
it("records the person's choice as theirs, through this connection: the same in Steering, the asker woken once, and a repeat answered from the record", async () => {
register(PERSON, true);
answer = () => [200, grantFor(PERSON)];
const asPerson = await connect((await listen()).url);
const row = await ask(`${PERSON}@external`);
expect(await steering(PERSON)).toContain(`queue:${row.qitemId}`);
const listed = await asPerson("rig_question_list");
expect(listed.isError).toBe(false);
const item = listed.structured.items.find((entry: any) => entry.qitemId === row.qitemId);
expect(item).toMatchObject({ kind: "ask", from: "lead@team-a", summary: "Pick a layout", questions: [LAYOUT], answerWith: "choices or words" });
expect(item.revision).toMatch(/^dr1-/);
const answered = await asPerson("rig_question_answer", { qitemId: row.qitemId, revision: item.revision, choices: [{ questionId: "layout", optionId: "b" }] });
expect(answered.structured).toMatchObject({ ok: true, outcome: "answered", submitted: [{ questionId: "layout", optionId: "b" }] });
expect(repo.getById(row.qitemId)).toMatchObject({ state: "done", humanAnswers: { layout: "b" } });
expect(replies(row.qitemId)).toBe(1);
// Recorded as the person, with the assistant as the route it came by.
expect(audit(row.qitemId)).toEqual([expect.objectContaining({ actor: `${PERSON}@external`, notes: expect.objectContaining({
route: "external-assistant", assistantSeat: CALLER, principal: "principal-example-01", grant: "8977f2dc0000aaaa1111bbbb", grantVersion: 6, person: `${PERSON}@external` }) })]);
expect(await steering(PERSON)).not.toContain(`queue:${row.qitemId}`);
// The same answer again (a retry after an uncertain result) is already recorded; a different one isn't taken.
const again = await asPerson("rig_question_answer", { qitemId: row.qitemId, revision: item.revision, choices: [{ questionId: "layout", optionId: "b" }] });
expect(again.structured).toMatchObject({ ok: true, outcome: "already-recorded" });
const changed = await asPerson("rig_question_answer", { qitemId: row.qitemId, revision: item.revision, choices: [{ questionId: "layout", optionId: "a" }] });
expect(changed.structured).toMatchObject({ ok: false, code: "not_waiting_on_person", state: "done", answered: { layout: "b" } });
expect(replies(row.qitemId)).toBe(1);
expect(audit(row.qitemId)).toHaveLength(1);
});
it("answers several questions in one call, and records the person's own words exactly", async () => {
register(PERSON, true);
answer = () => [200, grantFor(PERSON)];
const asPerson = await connect((await listen()).url);
const both = await ask(`${PERSON}@external`, [LAYOUT, SHIP]);
const revision = (await asPerson("rig_question_list")).structured.items[0].revision;
const answered = await asPerson("rig_question_answer", { qitemId: both.qitemId, revision, choices: [{ questionId: "layout", optionId: "a" }, { questionId: "ship", optionId: "no" }] });
expect(answered.structured).toMatchObject({ ok: true, outcome: "answered" });
expect(repo.getById(both.qitemId)!.humanAnswers).toEqual({ layout: "a", ship: "no" });
expect(replies(both.qitemId)).toBe(1);
const typed = await ask(`${PERSON}@external`);
const typedRevision = (await asPerson("rig_question_list")).structured.items[0].revision;
const words = "Cards, but keep the list view for admins";
expect((await asPerson("rig_question_answer", { qitemId: typed.qitemId, revision: typedRevision, words })).structured).toMatchObject({ ok: true, outcome: "answered" });
expect(JSON.stringify(repo.getById(typed.qitemId)!.humanAnswers)).toContain(JSON.stringify(words));
// An ask with no options (Steering's Approve) takes the person's words, never a choice.
const plain = await ask(`${PERSON}@external`, null);
const listed = (await asPerson("rig_question_list")).structured.items.find((entry: any) => entry.qitemId === plain.qitemId);
expect(listed).toMatchObject({ kind: "ask", answerWith: "words" });
expect(listed.questions).toBeUndefined();
expect((await asPerson("rig_question_answer", { qitemId: plain.qitemId, revision: listed.revision, choices: [{ questionId: "layout", optionId: "a" }] })).structured).toMatchObject({ code: "answer_shape" });
expect((await asPerson("rig_question_answer", { qitemId: plain.qitemId, revision: listed.revision, words: "Yes, go ahead" })).structured).toMatchObject({ ok: true, outcome: "answered", submitted: { words: "Yes, go ahead" } });
expect(repo.getById(plain.qitemId)).toMatchObject({ state: "done" });
expect(replies(plain.qitemId)).toBe(1);
});
it("records nothing on a changed question, and hands back the current one", async () => {
register(PERSON, true);
answer = () => [200, grantFor(PERSON)];
const asPerson = await connect((await listen()).url);
const row = await ask(`${PERSON}@external`);
const current = (await asPerson("rig_question_list")).structured.items[0];
const stale = await asPerson("rig_question_answer", { qitemId: row.qitemId, revision: "dr1-000000000000000000000000", choices: [{ questionId: "layout", optionId: "a" }] });
expect(stale.structured).toMatchObject({ ok: false, code: "question_changed", current: { qitemId: row.qitemId, revision: current.revision } });
expect(repo.getById(row.qitemId)).toMatchObject({ state: "pending" });
expect(audit(row.qitemId)).toEqual([]);
// On the question the person saw, a choice that isn't one of its options, or both shapes at once, is refused before
// anything is sent. On a changed question, the truth is that it changed.
expect((await asPerson("rig_question_answer", { qitemId: row.qitemId, revision: current.revision, choices: [{ questionId: "layout", optionId: "z" }] })).structured).toMatchObject({ code: "unknown_option" });
expect((await asPerson("rig_question_answer", { qitemId: row.qitemId, revision: "dr1-000000000000000000000000", choices: [{ questionId: "layout", optionId: "z" }] })).structured).toMatchObject({ code: "question_changed" });
expect((await asPerson("rig_question_answer", { qitemId: row.qitemId, revision: current.revision, words: "a", choices: [{ questionId: "layout", optionId: "a" }] })).structured).toMatchObject({ code: "answer_shape" });
expect(audit(row.qitemId)).toEqual([]);
});
it("never answers another person's question, a team-internal row, or anything for a person not registered here", async () => {
// Before anyone is registered, Steering's view shows every request; nothing is this connection's to answer.
answer = () => [200, grantFor(PERSON)];
const asPerson = await connect((await listen()).url);
const early = await ask("human@host");
expect((await asPerson("rig_question_list")).structured).toMatchObject({ ok: false, code: "person_not_registered" });
expect((await asPerson("rig_question_answer", { qitemId: early.qitemId, revision: "dr1-x", words: "yes" })).structured).toMatchObject({ code: "person_not_registered" });
register(PERSON, true);
register("ana", false);
const anas = await ask("ana@external");
const internal = await create("lead@team-a", "impl@team-a", "SECRET team-internal work");
expect((await asPerson("rig_question_list")).structured.items.map((entry: any) => entry.qitemId)).not.toContain(anas.qitemId);
for (const qitemId of [anas.qitemId, internal.qitemId]) {
const refused = await asPerson("rig_question_answer", { qitemId, revision: "dr1-x", words: "yes" });
expect(refused.structured).toMatchObject({ ok: false, code: "not_waiting_on_person" });
expect(refused.structured.state).toBeUndefined();
expect(refused.text).not.toContain("SECRET");
}
expect(repo.getById(anas.qitemId)).toMatchObject({ state: "pending" });
// A grant naming someone who isn't registered here reads and answers nothing.
answer = () => [200, grantFor("cus-nobody")];
const stranger = await connect((await listen()).url);
expect((await stranger("rig_question_list")).structured).toMatchObject({ code: "person_not_registered" });
});
it("unparks a row waiting on the person with their words, and shows them today's human@host asks as the default person", async () => {
register(PERSON, true);
answer = () => [200, grantFor(PERSON)];
const asPerson = await connect((await listen()).url);
const work = await create("lead@team-a", "impl@team-a", "Ship the release notes");
repo.update({ qitemId: work.qitemId, actorSession: "impl@team-a", state: "blocked", blockedOn: `${PERSON}@external`, summary: "Ship the notes as drafted?", evidenceRef: "/proof/notes.md", transitionNote: "asking the person" } as never);
const legacy = await ask("human@host");
const items = (await asPerson("rig_question_list")).structured.items;
const parked = items.find((entry: any) => entry.qitemId === work.qitemId);
expect(parked).toMatchObject({ kind: "parked", from: "impl@team-a", answerWith: "words" });
expect(items.map((entry: any) => entry.qitemId)).toContain(legacy.qitemId);
expect((await asPerson("rig_question_answer", { qitemId: work.qitemId, revision: parked.revision, choices: [{ questionId: "layout", optionId: "a" }] })).structured).toMatchObject({ code: "answer_shape" });
const resolved = await asPerson("rig_question_answer", { qitemId: work.qitemId, revision: parked.revision, words: "Yes, ship them" });
expect(resolved.structured).toMatchObject({ ok: true, outcome: "answered" });
expect(repo.getById(work.qitemId)).toMatchObject({ state: "in-progress" });
expect(JSON.stringify(repo.listTransitions(work.qitemId))).toContain("Yes, ship them");
// The park is back at work, its blocker cleared: an identical retry is confirmed from the record, nothing resent.
expect((await asPerson("rig_question_answer", { qitemId: work.qitemId, revision: parked.revision, words: "Yes, ship them" })).structured).toMatchObject({ ok: true, outcome: "already-recorded" });
expect(audit(work.qitemId)).toHaveLength(1);
const legacyRevision = items.find((entry: any) => entry.qitemId === legacy.qitemId).revision;
expect((await asPerson("rig_question_answer", { qitemId: legacy.qitemId, revision: legacyRevision, choices: [{ questionId: "layout", optionId: "a" }] })).structured)
.toMatchObject({ ok: true, outcome: "answered" });
expect(repo.getById(legacy.qitemId)!.humanAnswers).toEqual({ layout: "a" });
expect(audit(legacy.qitemId)[0]).toMatchObject({ actor: `${PERSON}@external` });
// The legacy ask keeps human@host: an identical retry is still confirmed by its operation, and nothing is resent.
expect((await asPerson("rig_question_answer", { qitemId: legacy.qitemId, revision: legacyRevision, choices: [{ questionId: "layout", optionId: "a" }] })).structured)
.toMatchObject({ ok: true, outcome: "already-recorded" });
expect(audit(legacy.qitemId)).toHaveLength(1);
expect(replies(legacy.qitemId)).toBe(1);
});
it("finishes a submission that stopped partway, recognising its own recorded steps by operation id, and says what was recorded", async () => {
register(PERSON, true);
answer = () => [200, grantFor(PERSON)];
const asPerson = await connect((await listen()).url);
const both = await ask(`${PERSON}@external`, [LAYOUT, SHIP]);
const revision = (await asPerson("rig_question_list")).structured.items[0].revision;
const choices = [{ questionId: "layout", optionId: "a" }, { questionId: "ship", optionId: "no" }];
// The second step's post fails after the first landed.
let posts = 0;
broken = (req) => req.method === "POST" && new URL(req.url).pathname === "/api/mission-control/action" && ++posts === 2
? new Response(JSON.stringify({ error: "unavailable", message: "the daemon couldn't take it" }), { status: 503, headers: { "Content-Type": "application/json" } }) : undefined;
const stopped = await asPerson("rig_question_answer", { qitemId: both.qitemId, revision, choices });
// A server error on a send may have landed: that step is unknown, not "not recorded".
expect(stopped.structured).toMatchObject({ ok: false, recorded: ["layout"], indeterminate: "ship", notRecorded: [] });
expect(stopped.structured.message).toContain("Recorded: layout. Unknown whether ship was recorded.");
expect(repo.getById(both.qitemId)).toMatchObject({ state: "pending", humanAnswers: { layout: "a" } });
broken = undefined;
// The identical retry confirms its own first step and stops there: the rest goes in a new answer to the question as
// it is now, which the asker hears once.
const retried = await asPerson("rig_question_answer", { qitemId: both.qitemId, revision, choices });
expect(retried.structured).toMatchObject({ ok: false, code: "question_changed", recorded: ["layout"], notRecorded: ["ship"] });
expect(audit(both.qitemId)).toHaveLength(1);
const now = (await asPerson("rig_question_list")).structured.items.find((entry: any) => entry.qitemId === both.qitemId);
expect(now.answered).toEqual({ layout: "a" });
expect((await asPerson("rig_question_answer", { qitemId: both.qitemId, revision: now.revision, choices: [{ questionId: "ship", optionId: "no" }] })).structured).toMatchObject({ ok: true, outcome: "answered" });
expect(repo.getById(both.qitemId)!.humanAnswers).toEqual({ layout: "a", ship: "no" });
expect(audit(both.qitemId)).toHaveLength(2);
expect(replies(both.qitemId)).toBe(1);
// A one-of-two answer whose result was lost: its identical retry is already recorded, not a changed question.
const half = await ask(`${PERSON}@external`, [LAYOUT, SHIP]);
const halfRevision = (await asPerson("rig_question_list")).structured.items.find((entry: any) => entry.qitemId === half.qitemId).revision;
const halfAnswer = await asPerson("rig_question_answer", { qitemId: half.qitemId, revision: halfRevision, choices: [{ questionId: "layout", optionId: "b" }] });
// The receipt carries only what this call submitted, never a fresh read of the row's answers.
expect(halfAnswer.structured).toEqual(expect.objectContaining({ ok: true, outcome: "recorded", submitted: [{ questionId: "layout", optionId: "b" }] }));
expect(halfAnswer.structured).not.toHaveProperty("answered");
expect(halfAnswer.structured).not.toHaveProperty("state");
expect((await asPerson("rig_question_answer", { qitemId: half.qitemId, revision: halfRevision, choices: [{ questionId: "layout", optionId: "b" }] })).structured).toMatchObject({ ok: true, outcome: "already-recorded" });
expect(audit(half.qitemId)).toHaveLength(1);
// Another registered person answers the rest on Steering's path: the same retry is refused, and their answer isn't
// taken for this submission's.
register("ana", false);
const other = await fetch(`${daemonUrl}/api/mission-control/action`, { method: "POST", headers: { "Content-Type": "application/json", "X-OpenRig-Session": "ana@external" },
body: JSON.stringify({ verb: "answer", qitemId: half.qitemId, questionId: "ship", optionId: "yes" }) });
expect(other.status).toBe(200);
const after = await asPerson("rig_question_answer", { qitemId: half.qitemId, revision: halfRevision, choices: [{ questionId: "layout", optionId: "b" }] });
expect(after.structured).toMatchObject({ ok: false, code: "not_waiting_on_person" });
expect(audit(half.qitemId)).toHaveLength(2);
expect(audit(half.qitemId)[1]).toMatchObject({ actor: "ana@external" });
});
it("stops a resumed answer when the question was rerouted after its recorded step: nothing lands on the new recipient's ask", async () => {
register(PERSON, true);
register("ana", false);
answer = () => [200, grantFor(PERSON)];
const asPerson = await connect((await listen()).url);
const both = await ask(`${PERSON}@external`, [LAYOUT, SHIP]);
const revision = (await asPerson("rig_question_list")).structured.items[0].revision;
const choices = [{ questionId: "layout", optionId: "a" }, { questionId: "ship", optionId: "no" }];
let posts = 0;
broken = (req) => req.method === "POST" && new URL(req.url).pathname === "/api/mission-control/action" && ++posts === 2
? new Response(JSON.stringify({ error: "unavailable", message: "the daemon couldn't take it" }), { status: 503, headers: { "Content-Type": "application/json" } }) : undefined;
await asPerson("rig_question_answer", { qitemId: both.qitemId, revision, choices });
// The retry passes the eligibility read; then, just before its first send, the ask is rerouted to Ana.
let rerouted = false;
broken = (req) => {
if (!rerouted && req.method === "POST" && new URL(req.url).pathname === "/api/mission-control/action") {
rerouted = true;
repo.routeToFallback(both.qitemId, "ana@external", "rerouted to Ana");
}
return undefined;
};
const stopped = await asPerson("rig_question_answer", { qitemId: both.qitemId, revision, choices });
expect(stopped.structured).toMatchObject({ ok: false, code: "question_changed", recorded: ["layout"], notRecorded: ["ship"] });
expect(stopped.structured).not.toHaveProperty("current");
expect(repo.getById(both.qitemId)).toMatchObject({ state: "pending", destinationSession: "ana@external", humanAnswers: { layout: "a" } });
expect(audit(both.qitemId)).toHaveLength(1);
});
it("reports a send that got no answer as unknown, with what was recorded before it, and finishes on the same call", async () => {
register(PERSON, true);
answer = () => [200, grantFor(PERSON)];
const asPerson = await connect((await listen()).url);
const both = await ask(`${PERSON}@external`, [LAYOUT, SHIP]);
const revision = (await asPerson("rig_question_list")).structured.items[0].revision;
const choices = [{ questionId: "layout", optionId: "b" }, { questionId: "ship", optionId: "yes" }];
// The second send gets a body that isn't JSON, so the client throws.
let posts = 0;
broken = (req) => req.method === "POST" && new URL(req.url).pathname === "/api/mission-control/action" && ++posts === 2
? new Response("upstream down", { status: 503, headers: { "Content-Type": "text/plain" } }) : undefined;
const unknown = await asPerson("rig_question_answer", { qitemId: both.qitemId, revision, choices });
expect(unknown.structured).toMatchObject({ ok: false, recorded: ["layout"], indeterminate: "ship", notRecorded: [] });
expect(unknown.structured.message).toContain("Recorded: layout.");
expect(unknown.structured.message).toContain("Unknown whether ship was recorded.");
expect(unknown.structured.message).toContain("Send the same call again");
expect(unknown.structured.message).not.toContain("Nothing was recorded");
broken = undefined;
// The same call confirms what was recorded (the unanswered send never reached the daemon), and the rest is a new answer.
expect((await asPerson("rig_question_answer", { qitemId: both.qitemId, revision, choices })).structured).toMatchObject({ code: "question_changed", recorded: ["layout"], notRecorded: ["ship"] });
const rest = (await asPerson("rig_question_list")).structured.items.find((entry: any) => entry.qitemId === both.qitemId);
expect((await asPerson("rig_question_answer", { qitemId: both.qitemId, revision: rest.revision, choices: [{ questionId: "ship", optionId: "yes" }] })).structured).toMatchObject({ ok: true, outcome: "answered" });
expect(repo.getById(both.qitemId)!.humanAnswers).toEqual({ layout: "b", ship: "yes" });
expect(audit(both.qitemId)).toHaveLength(2);
expect(replies(both.qitemId)).toBe(1);
// A first send with no answer says it's unknown, not that nothing was recorded.
const one = await ask(`${PERSON}@external`);
const oneRevision = (await asPerson("rig_question_list")).structured.items.find((entry: any) => entry.qitemId === one.qitemId).revision;
broken = (req) => req.method === "POST" && new URL(req.url).pathname === "/api/mission-control/action"
? new Response("upstream down", { status: 503, headers: { "Content-Type": "text/plain" } }) : undefined;
const first = await asPerson("rig_question_answer", { qitemId: one.qitemId, revision: oneRevision, choices: [{ questionId: "layout", optionId: "a" }] });
expect(first.structured).toMatchObject({ ok: false, recorded: [], indeterminate: "layout" });
expect(first.structured.message).not.toContain("Nothing was recorded");
broken = undefined;
});
it("never lets a stale retry overwrite a newer answer: given through Steering's path or recorded with no action", async () => {
register(PERSON, true);
register("ana", false);
answer = () => [200, grantFor(PERSON)];
const asPerson = await connect((await listen()).url);
const choices = [{ questionId: "layout", optionId: "a" }, { questionId: "ship", optionId: "no" }, { questionId: "color", optionId: "red" }];
const failSecondSend = () => {
let posts = 0;
broken = (req) => req.method === "POST" && new URL(req.url).pathname === "/api/mission-control/action" && ++posts === 2
? new Response(JSON.stringify({ error: "invalid_request", message: "refused this once" }), { status: 400, headers: { "Content-Type": "application/json" } }) : undefined;
};
const firstTry = async (qitemId: string) => {
const revision = (await asPerson("rig_question_list")).structured.items.find((entry: any) => entry.qitemId === qitemId).revision;
failSecondSend();
expect((await asPerson("rig_question_answer", { qitemId, revision, choices })).structured).toMatchObject({ recorded: ["layout"], notRecorded: ["ship", "color"] });
broken = undefined;
return revision;
};
// Ana answers ship through Steering's own path (an audited action) before the retry.
const audited = await ask(`${PERSON}@external`, [LAYOUT, SHIP, COLOR]);
const auditedRevision = await firstTry(audited.qitemId);
const ana = await fetch(`${daemonUrl}/api/mission-control/action`, { method: "POST", headers: { "Content-Type": "application/json", "X-OpenRig-Session": "ana@external" },
body: JSON.stringify({ verb: "answer", qitemId: audited.qitemId, questionId: "ship", optionId: "yes" }) });
expect(ana.status).toBe(200);
expect((await asPerson("rig_question_answer", { qitemId: audited.qitemId, revision: auditedRevision, choices })).structured).toMatchObject({ ok: false, code: "question_changed" });
expect(repo.getById(audited.qitemId)).toMatchObject({ state: "pending", humanAnswers: { layout: "a", ship: "yes" } });
// Ana's ship is recorded with no action at all (Slack's writer) before the retry: the retry confirms its own layout
// and stops, so yes stays.
const silent = await ask(`${PERSON}@external`, [LAYOUT, SHIP, COLOR]);
const silentRevision = await firstTry(silent.qitemId);
expect(repo.recordHumanAnswer({ qitemId: silent.qitemId, actorSession: "ana@external", questionId: "ship", optionId: "yes" } as never)).toMatchObject({ status: "recorded" });
const retried = await asPerson("rig_question_answer", { qitemId: silent.qitemId, revision: silentRevision, choices });
expect(retried.structured).toMatchObject({ ok: false, code: "question_changed", recorded: ["layout"], notRecorded: ["ship", "color"] });
expect(repo.getById(silent.qitemId)).toMatchObject({ state: "pending", humanAnswers: { layout: "a", ship: "yes" } });
expect(audit(silent.qitemId)).toHaveLength(1);
});
it("leaves out a park that moved to someone else between Steering's view and the read of its row, and never answers it", async () => {
register(PERSON, true);
register("ana", false);
answer = () => [200, grantFor(PERSON)];
const asPerson = await connect((await listen()).url);
const work = await create("lead@team-a", "impl@team-a", "Ship the release notes");
repo.update({ qitemId: work.qitemId, actorSession: "impl@team-a", state: "blocked", blockedOn: `${PERSON}@external`, summary: "Ship the notes as drafted?", evidenceRef: "/proof/notes.md", transitionNote: "asking the person" } as never);
const seen = (await asPerson("rig_question_list")).structured.items.find((entry: any) => entry.qitemId === work.qitemId);
expect(seen).toMatchObject({ kind: "parked" });
// Between the two reads, the owner unparks it and asks Ana instead, with evidence only she should see.
const movePark = () => {
repo.update({ qitemId: work.qitemId, actorSession: "impl@team-a", state: "in-progress", transitionNote: "rethinking" } as never);
repo.update({ qitemId: work.qitemId, actorSession: "impl@team-a", state: "blocked", blockedOn: "ana@external", summary: "Ana: ship them?", evidenceRef: "/proof/ana-only.md", transitionNote: "asking Ana" } as never);
};
let moved = false;
broken = (req) => {
if (!moved && req.method === "GET" && new URL(req.url).pathname === `/api/queue/${work.qitemId}`) { moved = true; movePark(); }
return undefined;
};
const listed = await asPerson("rig_question_list");
expect(listed.structured.items.map((entry: any) => entry.qitemId)).not.toContain(work.qitemId);
expect(listed.text).not.toContain("ana-only");
broken = undefined;
const refused = await asPerson("rig_question_answer", { qitemId: work.qitemId, revision: seen.revision, words: "Yes" });
expect(refused.structured).toMatchObject({ ok: false, code: "not_waiting_on_person" });
expect(refused.text).not.toContain("ana-only");
expect(repo.getById(work.qitemId)).toMatchObject({ state: "blocked", blockedOn: "ana@external" });
expect(audit(work.qitemId)).toEqual([]);
});
});
it("reaches only the external caller's own items and the team's questions to it: never team-internal, another caller's, another rig's or a person's", async () => {
const served = await listen();
const asExternal = await connect(served.url);
@@ -73,7 +73,6 @@ it("serves a materialized, never-launched caller seat over a grant service on a
for (const [name, value] of Object.entries({ ...daemon.readEnv, OPENRIG_DB: dbPath })) if (value !== undefined) env[name] = value;
let log = "";
const server: ChildProcess = spawn(process.execPath, [rigBin, "mcp", "serve", "--http", "--bind", `127.0.0.1:${port}`,
"--task-destination", LEAD,
"--grant-service", `http://127.0.0.1:${(service.address() as AddressInfo).port}/api/connector/v1/grant`], { env, stdio: ["ignore", "pipe", "pipe"] });
server.stdout?.on("data", (chunk) => { log += chunk; });
server.stderr?.on("data", (chunk) => { log += chunk; });
@@ -99,11 +98,9 @@ it("serves a materialized, never-launched caller seat over a grant service on a
};
// The tools the grant's objects name.
expect((await client.listTools()).tools.map((tool) => tool.name).sort()).toEqual(["rig_queue_create", "rig_queue_list", "rig_queue_show"]);
const created = await call("rig_queue_create", { body: "please build it" });
const created = await call("rig_queue_create", { destination: LEAD, body: "please build it" });
const shown = await call("rig_queue_show", { qitemId: created.qitemId });
expect(shown.item).toMatchObject({ qitemId: created.qitemId, sourceSession: CALLER, destinationSession: LEAD });
const explicit = await call("rig_queue_create", { destination: LEAD, body: "Explicit destination still works" });
expect(explicit.destinationSession).toBe(LEAD);
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
@@ -70,8 +70,8 @@ describe("MCP operation table against the daemon", () => {
if (saved.session === undefined) delete process.env.OPENRIG_SESSION_NAME; else process.env.OPENRIG_SESSION_NAME = saved.session;
});
async function connect(options: Parameters<typeof createMcpServer>[1] = {}) {
const mcp = createMcpServer(new DaemonClient(url), options);
async function connect() {
const mcp = createMcpServer(new DaemonClient(url));
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: "operations", version: "1.0.0" });
await mcp.connect(serverTransport);
@@ -89,18 +89,12 @@ describe("MCP operation table against the daemon", () => {
it("names every registered tool once, with hints that match each tool's effect", async () => {
const client = await connect();
const { tools } = await client.listTools();
// A local caller is the owner and is offered every tool but the person tools, which need a person to act for.
expect(MCP_OPERATIONS.filter((op) => op.access !== "person").map((op) => op.tool).sort()).toEqual(tools.map((tool) => tool.name).sort());
// An external grant naming its person is offered those, from its question pair.
const asPerson = await connect({ seat: "cus-x-caller@team-a", access: { principal: "principal-example-02", owner: false, rigs: ["team-a"], roots: [], workspace: false,
external: { objects: { question: ["read", "answer"] }, person: "ada" } } });
const personTools = (await asPerson.listTools()).tools;
expect(personTools.map((tool) => tool.name).sort()).toEqual(MCP_OPERATIONS.filter((op) => op.access === "person").map((op) => op.tool).sort());
const hints = Object.fromEntries([...tools, ...personTools].map((tool) => [tool.name, tool.annotations]));
expect(MCP_OPERATIONS.map((op) => op.tool).sort()).toEqual(tools.map((tool) => tool.name).sort());
const hints = Object.fromEntries(tools.map((tool) => [tool.name, tool.annotations]));
for (const op of MCP_OPERATIONS) {
expect(hints[op.tool], op.tool).toEqual(HINTS[op.effect]);
// Every tool says who an authenticated HTTP caller must be to be offered it.
expect(["work", "workspace", "owner", "person"], op.tool).toContain(op.access);
expect(["work", "workspace", "owner"], op.tool).toContain(op.access);
}
});
+6 -282
View File
@@ -1,11 +1,11 @@
import { describe, it, expect, vi, beforeAll, afterAll, onTestFinished } from "vitest";
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createMcpServer, dropUnfilledSeat, mintUnknownSender, FRONT_DOOR_WORKFLOW, FRONT_DOOR_WORKFLOW_VERSION, MEMBER_WORKFLOW, MEMBER_WORKFLOW_VERSION, type McpAccess, type UnfilledSeat } from "../src/mcp-server.js";
import { DaemonClient, DaemonConnectionError, senderIdentityHeaders, type DaemonResponse } from "../src/client.js";
import { createMcpServer, MEMBER_WORKFLOW, MEMBER_WORKFLOW_VERSION, type McpAccess } from "../src/mcp-server.js";
import { DaemonClient, DaemonConnectionError, type DaemonResponse } from "../src/client.js";
function mockClient(overrides?: {
get?: (path: string) => Promise<DaemonResponse>;
@@ -24,9 +24,9 @@ describe("MCP Server", () => {
let daemonClient: DaemonClient;
let cleanup: () => Promise<void>;
async function setup(clientOverrides?: Parameters<typeof mockClient>[0], serverOptions?: Parameters<typeof createMcpServer>[1]) {
async function setup(clientOverrides?: Parameters<typeof mockClient>[0]) {
daemonClient = mockClient(clientOverrides);
const server = createMcpServer(daemonClient, serverOptions);
const server = createMcpServer(daemonClient);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
mcpClient = new Client({ name: "test-client", version: "1.0.0" });
@@ -327,271 +327,6 @@ describe("MCP Server", () => {
}
});
it("rig_artifact_read returns a code-result manifest's exact pair as codeResult, and every other file as before", async () => {
const base = "a".repeat(40), revision = "b".repeat(40);
const manifest = JSON.stringify({ kind: "openrig.code-result/v1", repo: "/work/app", base, revision, paths: ["src/a.ts"], branch: "feature/x" });
const files: Record<string, string> = { "result.json": manifest, "report.md": "# Report\n", "loose.json": JSON.stringify({ kind: "openrig.code-result/v1", repo: "/work/app", base: "main", revision }) };
const getFn = vi.fn(async (p: string) => {
const item = /^\/api\/queue\/(q-[a-z]+)$/.exec(p);
if (item) return { status: 200, data: { qitemId: item[1], state: "done", evidenceRef: `/shared/proof/${({ "q-code": "result.json", "q-text": "report.md", "q-loose": "loose.json" } as Record<string, string>)[item[1]!]}` } };
if (p === "/api/files/roots") return { status: 200, data: { roots: [{ name: "proof", path: "/shared/proof" }] } };
const read = /^\/api\/files\/read\?root=proof&path=(.+)$/.exec(p);
if (read) { const name = decodeURIComponent(read[1]!); return { status: 200, data: { root: "proof", path: name, content: files[name], size: files[name]!.length } }; }
return { status: 404, data: {} };
});
await setup({ get: getFn });
const code = await mcpClient.callTool({ name: "rig_artifact_read", arguments: { qitemId: "q-code" } });
expect(code.structuredContent).toMatchObject({ available: true, text: manifest,
codeResult: { repo: "/work/app", base, revision, paths: ["src/a.ts"], branch: "feature/x" } });
// A manifest whose base is a branch isn't exact: it says why, and gives no pair.
const loose = await mcpClient.callTool({ name: "rig_artifact_read", arguments: { qitemId: "q-loose" } });
expect(loose.structuredContent).toMatchObject({ available: true, codeResultIssue: expect.stringContaining("base must be a full commit id") });
expect(loose.structuredContent).not.toHaveProperty("codeResult");
const text = await mcpClient.callTool({ name: "rig_artifact_read", arguments: { qitemId: "q-text" } });
expect(text.structuredContent).toMatchObject({ available: true, text: "# Report\n" });
expect(text.structuredContent).not.toHaveProperty("codeResult");
expect(text.structuredContent).not.toHaveProperty("codeResultIssue");
await cleanup();
});
it("a front door gets front-door instructions, not the member workflow, and its declared seat with no session reads as itself", async () => {
try {
vi.stubEnv("OPENRIG_SESSION_NAME", "claude-app@my-rig");
vi.stubEnv("RIGGED_SESSION_NAME", "");
vi.stubEnv("OPENRIG_TARGET_REPO", "");
vi.stubEnv("RIGGED_TARGET_REPO", "");
// A hosted address has no session, so whoami answers 404; the rig itself exists.
const getFn = vi.fn(async (p: string) => p.startsWith("/api/whoami") ? { status: 404, data: { error: "session not found" } }
: p.startsWith("/api/rigs/summary") ? { status: 200, data: [{ id: "rig-1", name: "my-rig", archivedAt: null }] } : { status: 404, data: {} });
await setup({ get: getFn }, { frontDoor: true });
const instructions = mcpClient.getInstructions() ?? "";
expect(FRONT_DOOR_WORKFLOW).toContain(`(${FRONT_DOOR_WORKFLOW_VERSION})`);
expect(instructions).toContain(FRONT_DOOR_WORKFLOW);
// The person's harm first: nothing tells this agent to work its inbox without asking.
expect(instructions).not.toContain(MEMBER_WORKFLOW);
expect(instructions).not.toContain("without asking whether you may");
for (const part of ["the person's to answer", "record only what the person tells you", "Never choose an answer for them", "exactly what the person typed in resolution, with no label"]) expect(FRONT_DOOR_WORKFLOW).toContain(part);
// An agent told to say the words are the person's wrapped them as `The person's answer: "…"`, so the stored answer wasn't what they typed.
expect(FRONT_DOOR_WORKFLOW).not.toContain("saying they're the person's");
const front = await mcpClient.callTool({ name: "rig_whoami", arguments: {} });
expect(front.isError).toBeFalsy();
expect(front.structuredContent).toMatchObject({ ok: true, seat: "claude-app@my-rig", rig: "my-rig", session: null,
caller: { authenticated: false, identity: "declared" } });
await cleanup();
// Without --front-door, the same server is a member: the member workflow, and whoami's 404 as today.
await setup({ get: getFn });
expect(mcpClient.getInstructions() ?? "").toContain(MEMBER_WORKFLOW);
expect((await mcpClient.callTool({ name: "rig_whoami", arguments: {} })).isError).toBe(true);
await cleanup();
// A front door whose rig isn't here is a misconfiguration, still answered with the 404.
vi.stubEnv("OPENRIG_SESSION_NAME", "claude-app@no-such-rig");
await setup({ get: getFn }, { frontDoor: true });
expect((await mcpClient.callTool({ name: "rig_whoami", arguments: {} })).isError).toBe(true);
await cleanup();
} finally {
vi.unstubAllEnvs();
}
});
it("an empty or unfilled seat, as an app that doesn't fill in its settings passes it, is no seat: never the placeholder or an inherited seat", async () => {
// What an app hands the server when it doesn't substitute its plugin settings, and what stays a seat.
const cases: Array<[NodeJS.ProcessEnv, UnfilledSeat | undefined]> = [
[{ OPENRIG_SESSION_NAME: "${user_config.seat}" }, { variable: "OPENRIG_SESSION_NAME", value: "${user_config.seat}" }],
[{ OPENRIG_SESSION_NAME: "${input:seat}" }, { variable: "OPENRIG_SESSION_NAME", value: "${input:seat}" }],
[{ OPENRIG_SESSION_NAME: "$SEAT" }, { variable: "OPENRIG_SESSION_NAME", value: "$SEAT" }],
// An empty seat doesn't fall back to one inherited under the legacy name.
[{ OPENRIG_SESSION_NAME: "", RIGGED_SESSION_NAME: "dev-impl@my-rig" }, { variable: "OPENRIG_SESSION_NAME", value: "" }],
[{ RIGGED_SESSION_NAME: "${user_config.seat}" }, { variable: "RIGGED_SESSION_NAME", value: "${user_config.seat}" }],
[{ OPENRIG_SESSION_NAME: "claude-app@my-rig" }, undefined],
[{ RIGGED_SESSION_NAME: "claude-app@my-rig" }, undefined],
[{}, undefined],
];
for (const [given, dropped] of cases) {
const env = { ...given };
expect(dropUnfilledSeat(env), JSON.stringify(given)).toEqual(dropped);
expect(env, JSON.stringify(given)).toEqual(dropped ? {} : given);
}
try {
vi.stubEnv("OPENRIG_SESSION_NAME", "${user_config.seat}");
vi.stubEnv("RIGGED_SESSION_NAME", "");
const unfilledSeat = dropUnfilledSeat()!;
// No request carries the placeholder as its sender, and whoami asks no daemon about it.
expect(senderIdentityHeaders()).toEqual({});
const quietGet = vi.fn(async () => ({ status: 200, data: {} }));
await setup({ get: quietGet }, { frontDoor: true, unfilledSeat });
const whoami = await mcpClient.callTool({ name: "rig_whoami", arguments: {} });
expect(whoami.isError).toBeFalsy();
expect(whoami.structuredContent).toMatchObject({ ok: true, seat: null, caller: { authenticated: false },
unfilledSeat: { variable: "OPENRIG_SESSION_NAME", value: "${user_config.seat}" } });
const message = (whoami.structuredContent as { message: string }).message;
expect(message).toContain("OPENRIG_SESSION_NAME is \"${user_config.seat}\", a placeholder the app didn't fill in");
expect(message).toContain("such as cursor@my-team");
// An app that can't give a plugin's server a value is told what it can do, never only "set it in the env".
expect(message).toContain("VS Code, Copilot CLI and Cursor can't give a plugin's server a value: add your own rig mcp serve --front-door server");
expect(message).toContain("lasts only for that launch");
expect(quietGet).not.toHaveBeenCalled();
const owned = await mcpClient.callTool({ name: "rig_queue_list", arguments: { owned: true } });
expect(owned.isError).toBe(true);
const ownedText = (owned.content as Array<{ text: string }>)[0]!.text;
expect(ownedText).toContain("rig_whoami says how to give it one");
expect(ownedText).not.toContain("in its env");
expect(quietGet).not.toHaveBeenCalled();
await cleanup();
} finally {
vi.unstubAllEnvs();
}
});
describe("with no seat, messages, new tasks and chat go through as an unknown sender", () => {
const UNKNOWN = /^unknown-mcp-[0-9a-f]{8}$/;
const post = () => vi.fn(async (route: string, body?: unknown) => route === "/api/queue/create"
? { status: 201, data: { qitemId: (body as { qitemId: string }).qitemId, state: "pending" } }
: route.startsWith("/api/rigs?") || route === "/api/rigs" ? { status: 200, data: [] } : { status: 200, data: { ok: true } });
const sent = (fn: ReturnType<typeof post>, route: string) =>
fn.mock.calls.filter(([path]) => path === route).map(([, body]) => body as Record<string, unknown>);
const seatless = async (unfilled: string, fn: ReturnType<typeof post>, get?: (path: string) => Promise<DaemonResponse>,
extra: Parameters<typeof createMcpServer>[1] = {}) => {
vi.stubEnv("OPENRIG_SESSION_NAME", unfilled);
vi.stubEnv("RIGGED_SESSION_NAME", "dev-impl@my-rig");
const unfilledSeat = dropUnfilledSeat()!;
await setup({ post: fn, ...(get ? { get } : {}) }, { frontDoor: true, unfilledSeat, ...extra });
const whoami = await mcpClient.callTool({ name: "rig_whoami", arguments: {} });
return (whoami.structuredContent as { unknownSender: string }).unknownSender;
};
it("a new task reaches its destination from this server's unknown sender, with a note on what's known and its id to follow", async () => {
try {
const fn = post();
const label = await seatless("${user_config.seat}", fn);
expect(label).toMatch(UNKNOWN);
// No request carries the placeholder or the inherited legacy seat: there's no sender header at all.
expect(senderIdentityHeaders()).toEqual({});
const result = await mcpClient.callTool({ name: "rig_queue_create", arguments: { destination: "lead@my-team", body: "Check the build." } });
expect(result.isError).toBeFalsy();
const [create] = sent(fn, "/api/queue/create");
expect(create).toMatchObject({ destinationSession: "lead@my-team", sourceSession: label });
expect(create!.body).toBe(`[Unknown sender ${label}: this app has no OpenRig seat (OPENRIG_SESSION_NAME is "\${user_config.seat}", a placeholder the app didn't fill in).`
+ ` Its MCP client says it is "test-client 1.0.0". There's no return address: it follows this by its task id.]\n\nCheck the build.`);
expect(JSON.stringify(create)).not.toContain("dev-impl@my-rig");
const qitemId = create!.qitemId as string;
expect(result.structuredContent).toMatchObject({ qitemId, unknownSender: { label,
followUp: `No return address: read this task with rig_queue_show and qitemId ${qitemId}.` } });
await cleanup();
} finally {
vi.unstubAllEnvs();
}
});
it("a message says so in its text and stays unsigned; a chat message declares the unknown sender", async () => {
try {
const fn = post();
const get = vi.fn(async (route: string) => route.startsWith("/api/rigs")
? { status: 200, data: [{ id: "rig-1", name: "my-team" }] } : { status: 200, data: {} });
const label = await seatless("", fn, get);
await mcpClient.callTool({ name: "rig_send", arguments: { session: "lead@my-team", text: "Is the build green?" } });
const [send] = sent(fn, "/api/transport/send");
expect(send!.text).toBe(`[Unknown sender ${label}: this app has no OpenRig seat (OPENRIG_SESSION_NAME is empty).`
+ ` Its MCP client says it is "test-client 1.0.0". There's no return address.]\nIs the build green?`);
expect(send).not.toHaveProperty("sender");
expect(send).not.toHaveProperty("actorSession");
await mcpClient.callTool({ name: "rig_chatroom_send", arguments: { rigName: "my-team", body: "Hello team" } });
const chat = fn.mock.calls.find(([path]) => /\/chat\/send$/.test(path))?.[1] as Record<string, unknown> | undefined;
expect(chat).toMatchObject({ sender: label });
expect(String(chat?.body)).toMatch(/^\[Unknown sender unknown-mcp-[0-9a-f]{8}: .* There's no return address\.\]\nHello team$/);
await cleanup();
} finally {
vi.unstubAllEnvs();
}
});
it("two seatless servers are two unknown senders, and the writes that need an author still carry none", async () => {
try {
const first = await seatless("${user_config.seat}", post());
await cleanup();
const fn = post();
const second = await seatless("${user_config.seat}", fn);
// Never one shared label: a delegated decision compares the asker with the decider, so two apps must not match.
expect(first).toMatch(UNKNOWN);
expect(second).toMatch(UNKNOWN);
expect(second).not.toBe(first);
for (const [name, args] of [
["rig_queue_claim", { qitemId: "qitem-a" }],
["rig_queue_update", { qitemId: "qitem-a", state: "done", closureReason: "no-follow-on", resolution: "done" }],
["rig_queue_handoff", { qitemId: "qitem-a", to: "lead@my-team", body: "next" }],
["rig_queue_unclaim", { qitemId: "qitem-a" }],
["rig_queue_decide", { qitemId: "qitem-a", applicationId: "app-1", choices: [{ chosen: "yes" }], reason: "because", reversible: true }],
] as const) {
fn.mockClear();
await mcpClient.callTool({ name, arguments: args as unknown as Record<string, unknown> });
// Each tool reached the daemon, so the check below isn't vacuous.
expect(fn.mock.calls.length, name).toBeGreaterThan(0);
for (const [, body] of fn.mock.calls) {
const text = JSON.stringify(body ?? {});
expect(text, name).not.toContain("unknown-mcp-");
expect(text, name).not.toMatch(/"(sourceSession|actorSession|fromSession|destinationSession|actor|sender)":/);
}
}
await cleanup();
} finally {
vi.unstubAllEnvs();
}
});
it("servers given their process's unknown sender all write as it, as HTTP's per-request servers do", async () => {
try {
const shared = mintUnknownSender();
expect(shared).toMatch(UNKNOWN);
const bodies: unknown[] = [];
for (const request of [1, 2]) {
const fn = post();
expect(await seatless("${user_config.seat}", fn, undefined, { unknownSender: shared }), `request ${request}`).toBe(shared);
await mcpClient.callTool({ name: "rig_queue_create", arguments: { qitemId: "qitem-retry", destination: "lead@my-team", body: "Check the build." } });
const [create] = sent(fn, "/api/queue/create");
expect(create, `request ${request}`).toMatchObject({ qitemId: "qitem-retry", sourceSession: shared });
bodies.push(create!.body);
await cleanup();
}
// A retry with the same id carries the same source and body, so the daemon's id-reuse check matches it.
expect(bodies[1]).toBe(bodies[0]);
} finally {
vi.unstubAllEnvs();
}
});
it("a seat, from the environment or a grant, keeps its own identity: no unknown sender and no note", async () => {
try {
vi.stubEnv("OPENRIG_SESSION_NAME", "claude-app@my-rig");
const fn = post();
await setup({ post: fn });
await mcpClient.callTool({ name: "rig_queue_create", arguments: { destination: "lead@my-team", body: "Check the build." } });
const [fromEnv] = sent(fn, "/api/queue/create");
expect(fromEnv).not.toHaveProperty("sourceSession");
expect(fromEnv!.body).toBe("Check the build.");
expect(senderIdentityHeaders()).toEqual({ "X-OpenRig-Session": "claude-app@my-rig" });
await cleanup();
vi.stubEnv("OPENRIG_SESSION_NAME", "");
dropUnfilledSeat();
const granted = post();
const grant: McpAccess = { principal: "grant-1", owner: false, rigs: ["my-team"], roots: [], workspace: false };
await setup({ post: granted, get: vi.fn(async () => ({ status: 200, data: [] })) }, { seat: "hosted@my-team", access: grant });
await mcpClient.callTool({ name: "rig_queue_create", arguments: { destination: "lead@my-team", body: "Check the build." } });
const [fromGrant] = sent(granted, "/api/queue/create");
expect(fromGrant).not.toHaveProperty("sourceSession");
expect(fromGrant!.body).toBe("Check the build.");
await cleanup();
} finally {
vi.unstubAllEnvs();
}
});
});
it("the queue tools return structured content; every error a tool returns carries one shape and keeps its old text", async () => {
// The queue's missing-item read answers with its code alone, as GET /api/queue/:qitemId does.
const missing = { error: "qitem_not_found" };
@@ -833,7 +568,7 @@ describe("MCP Server", () => {
await cleanup();
});
it("gives an external caller its request workflow without member tools or authority", async () => {
it("doesn't give an external caller's connection the member workflow or its authority, only the shared lines", async () => {
const server = createMcpServer(mockClient(), { seat: "cus-1-caller@team",
access: { principal: "cus-1", owner: false, rigs: ["team"], roots: [], workspace: false, external: { objects: { queue: ["create", "show", "list"] } } } });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
@@ -844,8 +579,6 @@ describe("MCP Server", () => {
expect(instructions).toContain("reserved for a person");
expect(instructions).not.toContain(MEMBER_WORKFLOW_VERSION);
expect(instructions).not.toContain("basically authorized");
expect(instructions).toContain("You may omit destination");
expect(instructions).not.toMatch(/rig_whoami|rig_team|rig_queue_handoff|rig_queue_decide/);
await external.close();
await server.close();
});
@@ -1108,9 +841,6 @@ describe("MCP Server", () => {
});
it("rig_send with waitForIdleSeconds extends request timeout", async () => {
// A seated server: with no seat the text would open with the unknown-sender note.
vi.stubEnv("OPENRIG_SESSION_NAME", "desk@my-rig");
onTestFinished(() => { vi.unstubAllEnvs(); });
const postFn = vi.fn(async () => ({
status: 200,
data: { ok: true, sessionName: "dev-impl@my-rig" },
@@ -1251,9 +981,6 @@ describe("MCP Server", () => {
});
it("the chatroom tools take a rig id as well as a name; watch reads one topic, sender and page size; send ignores an old sender argument", async () => {
// A seated server: the header names the sender, so the body carries none.
vi.stubEnv("OPENRIG_SESSION_NAME", "desk@my-rig");
onTestFinished(() => { vi.unstubAllEnvs(); });
const getFn = vi.fn(async (path: string) => {
if (path.startsWith("/api/rigs/summary")) return { status: 200, data: [{ id: "01RIG", name: "my-rig", nodeCount: 2 }] };
return { status: 200, data: [] };
@@ -1315,9 +1042,6 @@ describe("MCP Server", () => {
const text = (result: Awaited<ReturnType<Client["callTool"]>>) => (result.content as Array<{ text: string }>)[0]!.text;
it("rig_queue_create posts the task with a minted request id and no body sender", async () => {
// A seated server: the header names the sender (with no seat, the unknown sender is declared in the body instead).
vi.stubEnv("OPENRIG_SESSION_NAME", "desk@my-rig");
onTestFinished(() => { vi.unstubAllEnvs(); });
const postFn = vi.fn(async () => ({ status: 201, data: { qitemId: "qitem-1" } }));
await setup({ post: postFn });
const result = await mcpClient.callTool({ name: "rig_queue_create", arguments: { destination: "impl@team", body: "Do X", summary: "X", tags: ["mission:m"] } });
-21
View File
@@ -3,7 +3,6 @@ import { mkdtempSync, rmSync, mkdirSync, chmodSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import net from "node:net";
import { EventEmitter } from "node:events";
import { Command } from "commander";
import { SystemPreflight } from "../src/system-preflight.js";
import { ConfigStore } from "../src/config-store.js";
@@ -162,26 +161,6 @@ describe("SystemPreflight", () => {
expect(portCheck!.fix).toContain("rig config set daemon.port");
});
it.each(["EPERM", "EACCES"])("does not say a blocked port probe is available (%s)", async (code) => {
const socket = Object.assign(new EventEmitter(), {
setTimeout: vi.fn(), destroy: vi.fn(),
connect() { queueMicrotask(() => socket.emit("error", Object.assign(new Error("blocked"), { code }))); },
});
const constructor = vi.spyOn(net, "Socket").mockImplementation(() => socket as unknown as net.Socket);
try {
const result = await createPreflight().run({ port: 7433 });
expect(result.ready).toBe(true);
expect(result.checks.find((c) => c.name === "port_available")).toMatchObject({ ok: true, warning: expect.stringContaining(code) });
expect(result.checks.find((c) => c.name === "port_available")!.warning).toContain("could not be checked");
} finally { constructor.mockRestore(); }
});
it("warns when a recorded daemon on the same endpoint has a blocked probe", async () => {
const pf = createPreflight({ daemonStatus: { state: "running", port: 7433, host: "127.0.0.1", probeErrorCode: "EPERM" } });
const result = await pf.run({ port: 7433, host: "127.0.0.1" });
expect(result.checks.find((c) => c.name === "port_available")).toMatchObject({ ok: true, warning: expect.stringContaining("EPERM") });
});
// Test 6
it("port check skipped when daemon running on same host:port", async () => {
const pf = createPreflight({
+1 -10
View File
@@ -90,15 +90,6 @@ describe("rig provider CLI grammar (daemon-backed)", () => {
expect(seen.length).toBe(before);
});
it.each([false, true])("preflights and submits a Pi account change (clear=%s)", async clear => {
const before = seen.length;
const result = await run(["switch", "--seat", "pi-seat@rig", ...(clear ? ["--clear-account-home"] : ["--to-account", "work", "--account-home", "/accounts/pi"]), "--reason", "Pi budget", "--json"], runningDeps(port));
expect(result.exitCode ?? 0).toBe(0);
const calls = seen.slice(before);
expect(JSON.parse(calls.find(c => c.url.startsWith("/api/seat/set-runtime/"))!.body)).toMatchObject({ runtime: "pi", dryRun: true });
expect(JSON.parse(calls.find(c => c.url === "/api/provider/switch")!.body)).toMatchObject(clear ? { clearAccountHome: true } : { accountHome: "/accounts/pi" });
});
it("switches to a selected account home without sending credential values", async () => {
const result = await run(["switch", "--seat", "dev-a@rig", "--to-account", "api-account", "--account-home", "/accounts/api",
"--reason", "Plan limit", "--model", "gpt-5.5", "--json"], runningDeps(port));
@@ -204,7 +195,7 @@ describe("rig provider CLI grammar (daemon-backed)", () => {
seen.push({ url: req.url ?? "", method: req.method ?? "", body });
const send = (code: number, obj: unknown) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify(obj)); };
const path = (req.url ?? "").split("?")[0];
if (path?.startsWith("/api/seat/status/")) return send(200, { runtime: path.includes("pi-seat") ? "pi" : "codex" });
if (path?.startsWith("/api/seat/status/")) return send(200, { runtime: "codex" });
if (path?.startsWith("/api/seat/set-runtime/")) {
const request = JSON.parse(body);
if (path.includes("old-daemon")) return send(200, { ok: true, dryRun: true });
-50
View File
@@ -1018,56 +1018,6 @@ describe("Ps CLI", () => {
expect(parsed.hint).toContain("--nodes");
expect(parsed.hint).toContain("--full");
});
it("ps --nodes --json, --full, and --fields project launchWrapper array when configured and omit when absent without adding table columns", async () => {
psData = [
{ rigId: "rig-1", name: "test-rig", rigName: "test-rig", nodeCount: 2, runningCount: 2, status: "running", lifecycleState: "running", uptime: "1m", latestSnapshot: null },
];
nodesData["rig-1"] = [
{
rigId: "rig-1", rigName: "test-rig", logicalId: "dev.pi", podId: "pod-1", podNamespace: "dev",
canonicalSessionName: "dev-pi@test-rig", nodeKind: "agent", runtime: "pi",
launchWrapper: ["mtn", "sandbox", "exec", "--seat", "synthetic", "--"],
sessionStatus: "running", startupStatus: "ready", restoreOutcome: "n-a", lifecycleState: "running",
tmuxAttachCommand: null, resumeCommand: null, latestError: null,
},
{
rigId: "rig-1", rigName: "test-rig", logicalId: "dev.codex", podId: "pod-1", podNamespace: "dev",
canonicalSessionName: "dev-codex@test-rig", nodeKind: "agent", runtime: "codex",
sessionStatus: "running", startupStatus: "ready", restoreOutcome: "n-a", lifecycleState: "running",
tmuxAttachCommand: null, resumeCommand: null, latestError: null,
},
];
const { logs: compactLogs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--json"]);
});
const compactParsed = JSON.parse(compactLogs.join(""));
expect(compactParsed[0].launchWrapper).toEqual(["mtn", "sandbox", "exec", "--seat", "synthetic", "--"]);
expect("launchWrapper" in compactParsed[1]).toBe(false);
const { logs: fullLogs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--json", "--full"]);
});
const fullParsed = JSON.parse(fullLogs.join(""));
expect(fullParsed[0].launchWrapper).toEqual(["mtn", "sandbox", "exec", "--seat", "synthetic", "--"]);
expect("launchWrapper" in fullParsed[1]).toBe(false);
const { logs: fieldsLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--json", "--fields", "logicalId,launchWrapper"]);
});
expect(exitCode).toBeUndefined();
const fieldsParsed = JSON.parse(fieldsLogs.join(""));
expect(fieldsParsed.entries).toEqual([
{ logicalId: "dev.pi", launchWrapper: ["mtn", "sandbox", "exec", "--seat", "synthetic", "--"] },
{ logicalId: "dev.codex" },
]);
const { logs: tableLogs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A"]);
});
expect(tableLogs.join("\n")).not.toContain("WRAPPER");
});
});
});
-110
View File
@@ -1,110 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queueCommand, type QueueDeps } from "../src/commands/queue.js";
import { CODE_RESULT_KIND, codeResultOf, planItemArtifact } from "../src/queue-artifact.js";
vi.mock("../src/daemon-lifecycle.js", async () => ({
...await vi.importActual("../src/daemon-lifecycle.js"),
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, port: 7433 })),
getDaemonUrl: vi.fn(() => "http://selected-daemon:7433"),
}));
const BASE = "a".repeat(40);
const REVISION = "b".repeat(40);
const manifest = (fields: Record<string, unknown>) => ({ content: JSON.stringify({ kind: CODE_RESULT_KIND, repo: "/work/app", base: BASE, revision: REVISION, ...fields }) });
describe("a code result in an item's artifact", () => {
it("is parsed only from a whole manifest of its kind whose base and revision are full commit ids", () => {
expect(codeResultOf(manifest({ paths: ["src/a.ts"], branch: "feature/x", fetch: { remote: "https://example.test/app.git", ref: "feature/x" } }))).toEqual({ codeResult: {
repo: "/work/app", base: BASE, revision: REVISION, paths: ["src/a.ts"], branch: "feature/x", fetch: { remote: "https://example.test/app.git", ref: "feature/x" },
} });
// SHA-256 repositories, and no paths: diff everything between the pair.
expect(codeResultOf(manifest({ base: "c".repeat(64), revision: "d".repeat(64) }))).toEqual({ codeResult: { repo: "/work/app", base: "c".repeat(64), revision: "d".repeat(64), paths: [] } });
});
it("says why a manifest of the kind isn't exact, and never turns a branch or short id into a revision", () => {
for (const [fields, why] of [
[{ revision: "feature/x" }, "revision must be a full commit id"],
[{ base: "abc1234" }, "base must be a full commit id"],
[{ base: BASE.toUpperCase() }, "base must be a full commit id"],
[{ repo: " " }, "repo must name the repository"],
[{ paths: ["src/a.ts", ""] }, "paths must be a list"],
[{ fetch: { remote: "origin" } }, "fetch must name a remote and a ref"],
] as const) {
const parsed = codeResultOf(manifest(fields));
expect(parsed, JSON.stringify(fields)).toMatchObject({ codeResultIssue: expect.stringContaining(why) });
}
});
it("leaves every other file as it was: not this kind, not JSON, binary or cut", () => {
expect(codeResultOf({ content: JSON.stringify({ kind: "something-else", base: BASE, revision: REVISION, repo: "/r" }) })).toBeNull();
expect(codeResultOf({ content: "# A report\nNot JSON." })).toBeNull();
expect(codeResultOf({ ...manifest({}), binary: true })).toBeNull();
expect(codeResultOf({ ...manifest({}), truncated: true })).toBeNull();
});
it("reads an item's evidenceRef only from a shared root, with the reasons rig_artifact_read gives", () => {
const roots = [{ name: "proof", path: "/shared/proof" }];
expect(planItemArtifact("/shared/proof/run/result.json", roots)).toEqual({ read: { root: "proof", path: "run/result.json" } });
expect(planItemArtifact("run/result.json", roots, "proof")).toEqual({ read: { root: "proof", path: "run/result.json" } });
expect(planItemArtifact(null, roots)).toEqual({ unavailable: "This item names no artifact: it has no evidenceRef." });
expect(planItemArtifact("https://example.test/pull/1", roots)).toMatchObject({ unavailable: expect.stringContaining("is a link") });
expect(planItemArtifact("/shared/proof/a.md", [])).toMatchObject({ unavailable: expect.stringContaining("shares no roots") });
expect(planItemArtifact("run/result.json", roots)).toMatchObject({ unavailable: expect.stringContaining("relative path") });
expect(planItemArtifact("/elsewhere/a.md", roots)).toMatchObject({ unavailable: expect.stringContaining("outside every shared root") });
});
});
describe("rig queue artifact", () => {
const id = "qitem-20261107-result";
let output: string[];
let errors: string[];
beforeEach(() => {
output = []; errors = [];
vi.spyOn(console, "log").mockImplementation((...args) => output.push(args.join(" ")));
vi.spyOn(console, "error").mockImplementation((...args) => errors.push(args.join(" ")));
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { output.push(String(chunk)); return true; });
process.exitCode = undefined;
});
afterEach(() => { process.exitCode = undefined; vi.restoreAllMocks(); });
function deps(evidenceRef: string | null, content: string): { deps: QueueDeps; paths: string[] } {
const paths: string[] = [];
const replies: Record<string, unknown> = {
[`/api/queue/${id}`]: { qitemId: id, state: "done", evidenceRef },
"/api/files/roots": { roots: [{ name: "proof", path: "/shared/proof" }] },
"/api/files/read?root=proof&path=run%2Fresult.json": { root: "proof", path: "run/result.json", content, size: content.length, mtime: "2026-11-07T09:50:00.000Z" },
};
const client = {
baseUrl: "http://selected-daemon:7433",
get: vi.fn(async (path: string) => { paths.push(path); return path in replies ? { status: 200, data: replies[path] } : { status: 404, data: { error: "not_found" } }; }),
post: vi.fn(),
};
return { paths, deps: { lifecycleDeps: {} as QueueDeps["lifecycleDeps"], clientFactory: () => client as unknown as ReturnType<QueueDeps["clientFactory"]> } };
}
it("prints the item's file with its code result, for a client that calls the CLI", async () => {
const content = manifest({ paths: ["src/a.ts"] }).content;
const { deps: d, paths } = deps("/shared/proof/run/result.json", content);
await queueCommand(d).parseAsync(["node", "rig", "artifact", id, "--json"]);
expect(process.exitCode).toBeUndefined();
expect(JSON.parse(output.join(""))).toEqual({ available: true, qitemId: id, root: "proof", path: "run/result.json", size: content.length,
mtime: "2026-11-07T09:50:00.000Z", binary: false, text: content, truncated: false,
codeResult: { repo: "/work/app", base: BASE, revision: REVISION, paths: ["src/a.ts"] } });
// Read through the daemon's shared roots only.
expect(paths).toEqual([`/api/queue/${id}`, "/api/files/roots", "/api/files/read?root=proof&path=run%2Fresult.json"]);
output = []; errors = [];
await queueCommand(d).parseAsync(["node", "rig", "artifact", id]);
expect(output.join("")).toBe(content);
expect(errors.join("\n")).toContain(`code result: /work/app ${BASE}..${REVISION} (1 paths)`);
});
it("says why there's no file to read, and exits 1", async () => {
const { deps: d, paths } = deps("https://example.test/pull/1", "");
await queueCommand(d).parseAsync(["node", "rig", "artifact", id, "--json"]);
expect(JSON.parse(output.join(""))).toMatchObject({ available: false, qitemId: id, evidenceRef: "https://example.test/pull/1", roots: ["proof"],
reason: expect.stringContaining("is a link") });
expect(process.exitCode).toBe(1);
expect(paths).not.toContain("/api/files/read?root=proof&path=run%2Fresult.json");
});
});
@@ -1,288 +0,0 @@
// #2637 — `--host <id>` on the queue verbs that name an existing row: show, transitions, list, claim, update, block.
// The CLI calls the remote host's daemon directly, with that host's bearer, as `send --host` does; the selected
// daemon is asked only for its own host id. Real DaemonClients against two local HTTP servers (the selected
// daemon and the remote one), so the paths, the bearer and the sender header are the ones on the wire.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DaemonClient } from "../src/client.js";
import { queueCommand, type QueueDeps } from "../src/commands/queue.js";
let localPort = 0;
vi.mock("../src/daemon-lifecycle.js", async () => {
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
return {
...actual,
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: localPort })),
getDaemonUrl: vi.fn(() => `http://127.0.0.1:${localPort}`),
// This instance's own host id, as the durable store would give it.
resolveOriginSelfHostId: vi.fn(async () => "vm-a"),
};
});
interface Seen { method: string; path: string; headers: http.IncomingHttpHeaders; body: unknown }
type Answer = (req: Seen) => { status: number; data: unknown };
async function serve(answer: Answer): Promise<{ url: string; seen: Seen[]; close: () => Promise<void> }> {
const seen: Seen[] = [];
const server = http.createServer((req, res) => {
let raw = "";
req.on("data", (c) => { raw += c; });
req.on("end", () => {
const entry: Seen = { method: req.method ?? "", path: req.url ?? "", headers: req.headers, body: raw ? JSON.parse(raw) : undefined };
seen.push(entry);
const { status, data } = answer(entry);
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as AddressInfo;
return { url: `http://127.0.0.1:${port}`, seen, close: () => new Promise((resolve) => server.close(() => resolve())) };
}
const ROW = { qitemId: "qitem-remote-1", state: "pending", body: "the remote row", sourceSession: "dev@fabrica@vm-a", destinationSession: "ops@devbox" };
describe("queue verbs on an existing row with --host (#2637)", () => {
let local: Awaited<ReturnType<typeof serve>>;
let remote: Awaited<ReturnType<typeof serve>>;
let remoteAnswer: Answer;
let home: string;
let out: string[];
let err: string[];
let hosts: Array<Record<string, unknown>>;
const deps = (): QueueDeps => ({
lifecycleDeps: {} as QueueDeps["lifecycleDeps"],
clientFactory: (url: string) => new DaemonClient(url),
hostRegistryLoader: () => ({ ok: true, registry: { hosts } }) as ReturnType<NonNullable<QueueDeps["hostRegistryLoader"]>>,
});
const run = async (...args: string[]) => {
process.exitCode = undefined;
await queueCommand(deps()).parseAsync(["node", "queue", ...args]);
};
const remoteQueueCalls = () => remote.seen.filter((s) => s.path.startsWith("/api/queue"));
const localQueueCalls = () => local.seen.filter((s) => s.path.startsWith("/api/queue"));
beforeEach(async () => {
home = mkdtempSync(join(tmpdir(), "openrig-queue-host-"));
vi.stubEnv("OPENRIG_HOME", home);
vi.stubEnv("OPENRIG_URL", "");
vi.stubEnv("OPENRIG_SESSION_NAME", "dev@fabrica");
vi.stubEnv("DEVBOX_BEARER", "s3cret");
local = await serve((req) => req.path === "/healthz"
? { status: 200, data: { healthy: true, selfHostId: "vm-a" } }
: { status: 500, data: { error: "the selected daemon must not be asked for the row" } });
localPort = Number(new URL(local.url).port);
remoteAnswer = (req) => {
if (req.path === "/healthz") return { status: 200, data: { healthy: true, selfHostId: "devbox-id" } };
if (req.path.endsWith("/transitions")) return { status: 200, data: [{ transitionId: 1, state: "pending" }] };
if (req.path.startsWith("/api/queue/list")) return { status: 200, data: [ROW] };
return { status: 200, data: ROW };
};
remote = await serve((req) => remoteAnswer(req));
hosts = [
{ id: "devbox", transport: "http", url: remote.url, bearer_env: "DEVBOX_BEARER" },
{ id: "mac", transport: "ssh", target: "bruno@mac" },
{ id: "nobearer", transport: "http", url: remote.url, bearer_env: "UNSET_BEARER_VAR" },
];
out = [];
err = [];
vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => { out.push(a.join(" ")); });
vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => { err.push(a.join(" ")); });
});
afterEach(async () => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
process.exitCode = undefined;
await local.close();
await remote.close();
rmSync(home, { recursive: true, force: true });
});
it("show --host reads the row from the remote daemon with its bearer, and its full command keeps --host", async () => {
await run("show", "qitem-remote-1", "--host", "devbox", "--json");
const [call] = remoteQueueCalls();
expect(call).toMatchObject({ method: "GET", path: "/api/queue/qitem-remote-1" });
expect(call!.headers.authorization).toBe("Bearer s3cret");
expect(localQueueCalls()).toEqual([]);
const shown = JSON.parse(out.join("\n"));
expect(shown.qitemId).toBe("qitem-remote-1");
expect(shown.readView.fullCommand).toBe("rig queue show 'qitem-remote-1' --host 'devbox' --full --json");
expect(process.exitCode).toBeUndefined();
});
it("transitions --host reads the remote log", async () => {
await run("transitions", "qitem-remote-1", "--host", "devbox", "--json", "--full");
expect(remoteQueueCalls().map((c) => c.path)).toEqual(["/api/queue/qitem-remote-1/transitions"]);
expect(JSON.parse(out.join("\n"))).toEqual([{ transitionId: 1, state: "pending" }]);
});
it("list --host lists the remote rows, its filters kept and no rig derived from this host's seat", async () => {
await run("list", "--host", "devbox", "--state", "pending", "--json");
const [call] = remoteQueueCalls();
const query = new URL(call!.path, "http://x").searchParams;
expect(query.get("state")).toBe("pending");
expect(query.has("rig")).toBe(false);
expect(query.has("as")).toBe(false);
expect(JSON.parse(out.join("\n"))).toEqual([ROW]);
});
it.each(["--mine", "--owned"])("list --host %s is refused before any request: it names this host's seat", async (scope) => {
await run("list", "--host", "devbox", scope, "--json");
expect(process.exitCode).toBe(1);
expect(JSON.parse(out.join("\n")).error).toBe("host_scope_unsupported");
expect(remote.seen).toEqual([]);
});
it("update --host writes on the remote daemon, the actor stamped with this host's id", async () => {
await run("update", "qitem-remote-1", "--host", "devbox", "--state", "done", "--closure-reason", "no-follow-on", "--note", "settled from vm-a", "--json");
const [call] = remoteQueueCalls();
expect(call).toMatchObject({ method: "POST", path: "/api/queue/qitem-remote-1/update" });
expect(call!.headers["x-openrig-session"]).toBe("dev@fabrica@vm-a");
expect(call!.headers.authorization).toBe("Bearer s3cret");
expect(call!.body).toMatchObject({ state: "done", closureReason: "no-follow-on", transitionNote: "settled from vm-a" });
expect(localQueueCalls()).toEqual([]);
});
it("block --host parks the remote row through the remote update route", async () => {
await run("block", "qitem-remote-1", "--host", "devbox", "--on", "external:ci", "--wake-after", "30m", "--json");
const [call] = remoteQueueCalls();
expect(call).toMatchObject({ method: "POST", path: "/api/queue/qitem-remote-1/update" });
expect(call!.body).toMatchObject({ state: "blocked", blockedOn: "external:ci", wakeAfterSeconds: 1800 });
expect(call!.headers["x-openrig-session"]).toBe("dev@fabrica@vm-a");
});
it("claim takes no --host: a row is claimed by its destination seat, on the host that holds it", async () => {
await run("claim", "qitem-remote-1", "--host", "devbox", "--json").catch(() => undefined);
expect(remote.seen).toEqual([]);
expect(localQueueCalls()).toEqual([]);
});
it("a --host naming the selected daemon's own host id stays local", async () => {
local.close();
local = await serve((req) => req.path === "/healthz"
? { status: 200, data: { selfHostId: "vm-a" } }
: { status: 200, data: { ...ROW, qitemId: "qitem-local-1" } });
localPort = Number(new URL(local.url).port);
await run("show", "qitem-local-1", "--host", "vm-a", "--json");
expect(localQueueCalls().map((c) => c.path)).toEqual(["/api/queue/qitem-local-1"]);
expect(remote.seen).toEqual([]);
expect(JSON.parse(out.join("\n")).readView.fullCommand).toBe("rig queue show 'qitem-local-1' --full --json");
});
it("list --host naming the selected daemon's own id is a local list: the seat's rig and --mine apply", async () => {
local.close();
local = await serve((req) => req.path === "/healthz" ? { status: 200, data: { selfHostId: "vm-a" } } : { status: 200, data: [] });
localPort = Number(new URL(local.url).port);
await run("list", "--host", "vm-a", "--json");
await run("list", "--host", "vm-a", "--mine", "--json");
expect(process.exitCode).toBeUndefined();
const [plain, mine] = localQueueCalls().map((c) => new URL(c.path, "http://x").searchParams);
expect(plain!.get("rig")).toBe("fabrica");
expect(mine!.get("as")).toBe("dev@fabrica");
expect(remote.seen).toEqual([]);
});
it.each([
["mac", "host_not_http"],
["nobearer", "host_bearer_unavailable"],
])("--host %s fails with %s and sends no queue request; only this daemon's own id is asked first", async (host, code) => {
await run("update", "qitem-remote-1", "--host", host, "--state", "done", "--json");
expect(process.exitCode).toBe(1);
expect(JSON.parse(out.join("\n")).error).toBe(code);
expect(remote.seen).toEqual([]);
expect(local.seen.map((s) => s.path)).toEqual(["/healthz"]);
});
// The learned bindings: a self-id names a registry entry only through a binding the queue forwarder would use.
const bind = (binding: Record<string, unknown>) => writeFileSync(join(home, "host-bindings.json"),
JSON.stringify({ version: 1, bindings: { devbox: { hostId: "devbox-self-id", firstObservedAt: "2026-11-01T00:00:00Z", lastObservedAt: "2026-11-07T00:00:00Z", ...binding } } }));
it("a self-id learned at the entry's current URL routes to that entry", async () => {
bind({ observedUrl: remote.url });
await run("update", "qitem-remote-1", "--host", "devbox-self-id", "--state", "done", "--json");
expect(remoteQueueCalls().map((c) => c.path)).toEqual(["/api/queue/qitem-remote-1/update"]);
expect(process.exitCode).toBeUndefined();
});
it.each([
["the entry's URL changed since it was learned", { observedUrl: "http://127.0.0.1:1" }, "but the entry now points at"],
["it has no observed URL", {}, "learned before its URL was recorded"],
["it records a conflict", { observedUrl: "PLACEHOLDER", conflict: { hostId: "other-id", observedAt: "2026-11-07T01:00:00Z" } }, "recorded conflict"],
])("a self-id whose binding isn't eligible (%s) is not routed, and says why", async (_why, binding, reason) => {
bind(binding.observedUrl === "PLACEHOLDER" ? { ...binding, observedUrl: remote.url } : binding);
await run("update", "qitem-remote-1", "--host", "devbox-self-id", "--state", "done", "--json");
expect(process.exitCode).toBe(1);
const body = JSON.parse(out.join("\n"));
expect(body.error).toBe("unknown_host");
expect(body.message).toContain(reason);
expect(body.message).toContain("--host devbox");
expect(remoteQueueCalls()).toEqual([]);
});
it("a direct write's receipt keeps the host it used when this daemon's later health read fails", async () => {
vi.stubEnv("OPENRIG_OUTPUT_COMPACT", "1");
let probes = 0;
local.close();
local = await serve((req) => req.path === "/healthz"
? (++probes === 1 ? { status: 200, data: { selfHostId: "vm-a" } } : { status: 500, data: { error: "busy" } })
: { status: 500, data: { error: "the selected daemon must not be asked for the row" } });
localPort = Number(new URL(local.url).port);
await run("update", "qitem-remote-1", "--host", "devbox", "--state", "in-progress", "--json");
const receipt = JSON.parse(out.join("\n"));
expect(receipt.host).toBe("devbox");
expect(receipt.hostUnconfirmed).toBeUndefined();
expect(receipt.receipt.fullCommand).toBe("rig queue show 'qitem-remote-1' --host 'devbox' --full --json");
});
it("an unknown --host fails with unknown_host; only the selected daemon's own id is asked, to keep its own id local", async () => {
await run("update", "qitem-remote-1", "--host", "nowhere", "--state", "done", "--json");
expect(process.exitCode).toBe(1);
expect(JSON.parse(out.join("\n")).error).toBe("unknown_host");
expect(remote.seen).toEqual([]);
expect(local.seen.map((s) => s.path)).toEqual(["/healthz"]);
});
it("a registry entry with no bearer configured is an anonymous host: called with no Authorization header", async () => {
hosts.push({ id: "open", transport: "http", url: remote.url });
await run("show", "qitem-remote-1", "--host", "open", "--json");
const [call] = remoteQueueCalls();
expect(call!.path).toBe("/api/queue/qitem-remote-1");
expect(call!.headers.authorization).toBeUndefined();
});
it("a remote write's compact receipt names the host and a full read that reaches it", async () => {
vi.stubEnv("OPENRIG_OUTPUT_COMPACT", "1");
const { compactOutputEnabled } = await import("../src/queue-receipt.js");
expect(compactOutputEnabled()).toBe(true);
// The remote daemon's own id equals the alias: the receipt must still call the row remote.
remoteAnswer = (req) => req.path === "/healthz" ? { status: 200, data: { selfHostId: "devbox" } } : { status: 200, data: ROW };
await run("update", "qitem-remote-1", "--host", "devbox", "--state", "in-progress", "--json");
const receipt = JSON.parse(out.join("\n"));
expect(receipt.host).toBe("devbox");
expect(receipt.receipt.fullCommand).toBe("rig queue show 'qitem-remote-1' --host 'devbox' --full --json");
});
it("without --host every verb asks the selected daemon, as before", async () => {
local.close();
local = await serve((req) => req.path === "/healthz"
? { status: 200, data: { selfHostId: "vm-a" } }
: { status: 200, data: req.path.startsWith("/api/queue/list") ? [] : ROW });
localPort = Number(new URL(local.url).port);
await run("show", "qitem-remote-1", "--json");
await run("list", "--json");
await run("update", "qitem-remote-1", "--state", "done", "--json");
expect(remote.seen).toEqual([]);
expect(localQueueCalls().map((c) => c.path.split("?")[0])).toEqual(["/api/queue/qitem-remote-1", "/api/queue/list", "/api/queue/qitem-remote-1/update"]);
// The local list still derives the rig from the seat, and no sender is stamped with a host id.
expect(new URL(localQueueCalls()[1]!.path, "http://x").searchParams.get("rig")).toBe("fabrica");
expect(localQueueCalls()[2]!.headers["x-openrig-session"]).toBe("dev@fabrica");
});
});
@@ -11,7 +11,7 @@ vi.mock("../src/daemon-lifecycle.js", async () => ({
const id = "qitem-xh-missing";
const verbs = [
["show"], ["transitions"], ["artifact"], ["claim"], ["unclaim"],
["show"], ["transitions"], ["claim"], ["unclaim"],
["update", "--note", "a note"],
["block", "--on", "external:dependency"],
["resolve", "--decision", "continue"],
@@ -66,8 +66,7 @@ for (const json of [false, true]) {
expect(body.error).toBe("qitem_not_found");
expect(body.message).toBe("original detail");
expect(body.hint).toBe(`Queue item "${id}" was not found on daemon "selected-host".\n`
+ `If it came from another host, run rig host list; replace <daemon-url> with its registered daemon URL and run: OPENRIG_URL='<daemon-url>' rig queue show ${shellQuote(id)} --full --json\n`
+ `Or name that host: rig queue show ${shellQuote(id)} --host <host-id> --full --json`);
+ `If it came from another host, run rig host list; replace <daemon-url> with its registered daemon URL and run: OPENRIG_URL='<daemon-url>' rig queue show ${shellQuote(id)} --full --json`);
expect(calls).toHaveLength(2);
expect(calls[1]).toEqual({ path: "/healthz", options: { timeoutMs: 1_000 } });
expect(calls.some(c => c.path.includes("/hosts"))).toBe(false);
Loaded 100 of 490 files, more files were not shown because too many files have changed in this diff. Show more