mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-10 11:41:37 -10:00
Compare commits
235
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89dcf739cc | ||
|
|
f4c12b73c4 | ||
|
|
410adcf4bc | ||
|
|
fde6d6c49f | ||
|
|
3c2dee1b58 | ||
|
|
25dcef874b | ||
|
|
cabd764a17 | ||
|
|
4899766116 | ||
|
|
ad6687a8bc | ||
|
|
289c72075b | ||
|
|
2870fa90cf | ||
|
|
5b6702add4 | ||
|
|
faa3e4917e | ||
|
|
c38d1183cb | ||
|
|
fa2a2bdbdb | ||
|
|
ed16e0d289 | ||
|
|
c7f841537e | ||
|
|
f18f492d8d | ||
|
|
d379d59cc2 | ||
|
|
b21aba8644 | ||
|
|
8650b33bb8 | ||
|
|
06aee3e79c | ||
|
|
68307458fc | ||
|
|
87a91c542a | ||
|
|
1a2bbad8a5 | ||
|
|
09b11a369f | ||
|
|
d5832cd90d | ||
|
|
06c05d6ba3 | ||
|
|
e935533db1 | ||
|
|
a34c256144 | ||
|
|
c531d48df6 | ||
|
|
7f7dc9720c | ||
|
|
be1b910a4b | ||
|
|
f4448fe901 | ||
|
|
24dd3eb888 | ||
|
|
a11a78ebb5 | ||
|
|
1011e7472f | ||
|
|
5e333e0179 | ||
|
|
3d1f2f9bee | ||
|
|
b7e03daaa0 | ||
|
|
0e49fb87f3 | ||
|
|
fcf0c0fe5d | ||
|
|
b8d93cb64b | ||
|
|
1f924f9d7a | ||
|
|
2363db4c5c | ||
|
|
e9682010b8 | ||
|
|
c4bc5553ba | ||
|
|
9e004f2a8f | ||
|
|
3518c03b73 | ||
|
|
a43e09c1b9 | ||
|
|
6bc3186975 | ||
|
|
00d6d25cce | ||
|
|
10736e1d9c | ||
|
|
1fe9296b48 | ||
|
|
369a129f69 | ||
|
|
b6e1604710 | ||
|
|
977d0ede41 | ||
|
|
7078c754d6 | ||
|
|
603d0776fb | ||
|
|
71aba02eb9 | ||
|
|
ac97493c3a | ||
|
|
85ff745a02 | ||
|
|
1fb614b7c9 | ||
|
|
c26be7eed8 | ||
|
|
83afa753f5 | ||
|
|
ce459d2e9b | ||
|
|
0830585ede | ||
|
|
37b22dc646 | ||
|
|
8c8d797fb4 | ||
|
|
521405f48d | ||
|
|
a57285df01 | ||
|
|
7d43d56638 | ||
|
|
cc3a31db65 | ||
|
|
f3268fa5fe | ||
|
|
fb02dd9a4a | ||
|
|
1b83d297ad | ||
|
|
d79049c4d0 | ||
|
|
8b003da576 | ||
|
|
b29a70f14f | ||
|
|
a2355b7ffe | ||
|
|
d13ad48681 | ||
|
|
6b6aa91dc6 | ||
|
|
8064fc89f6 | ||
|
|
7f4e326f27 | ||
|
|
46ba2f5d6b | ||
|
|
3de06efe4c | ||
|
|
c415da618d | ||
|
|
a8574f8094 | ||
|
|
30b8232181 | ||
|
|
ae04d252ba | ||
|
|
4147d7d0d8 | ||
|
|
2a17eef1de | ||
|
|
1c9d0076cf | ||
|
|
2add9baaf5 | ||
|
|
0964fb7bd4 | ||
|
|
e1dedbf531 | ||
|
|
faff36aa93 | ||
|
|
e806c4d3db | ||
|
|
5eaf0c1a2e | ||
|
|
0a91a8f0f1 | ||
|
|
1760d6e776 | ||
|
|
b40bc71ca6 | ||
|
|
4d7c50d514 | ||
|
|
1538231b1f | ||
|
|
44affadaa6 | ||
|
|
011b995835 | ||
|
|
ce0186c408 | ||
|
|
545a423489 | ||
|
|
d2c5bbdbd2 | ||
|
|
220e27a9a7 | ||
|
|
859b540cf0 | ||
|
|
0fce9b52e7 | ||
|
|
4d6200e11a | ||
|
|
3ac7aea4ee | ||
|
|
d7d1dd41eb | ||
|
|
f225192e4e | ||
|
|
7d15cefc8e | ||
|
|
cab7f569fb | ||
|
|
78d73bc7b3 | ||
|
|
ba8c01467a | ||
|
|
aa0599cd41 | ||
|
|
8a46d8fabc | ||
|
|
59416773ce | ||
|
|
39c4f9fd6e | ||
|
|
0f88021f4a | ||
|
|
1bcbef7e3f | ||
|
|
73ad2c4e07 | ||
|
|
92a1c738fc | ||
|
|
937acb681b | ||
|
|
1be8615a33 | ||
|
|
172685c892 | ||
|
|
2d0bd8b10e | ||
|
|
4a18628c8a | ||
|
|
f554785b9d | ||
|
|
1898ac9f56 | ||
|
|
759d309483 | ||
|
|
b270e55dc8 | ||
|
|
f91a1f681b | ||
|
|
856650a129 | ||
|
|
5e5b328e7c | ||
|
|
7ae8f6ef5b | ||
|
|
7f3b85e993 | ||
|
|
a979ac5b95 | ||
|
|
45c85ec971 | ||
|
|
2eb3c498bc | ||
|
|
b7eac810de | ||
|
|
6b9e725950 | ||
|
|
feede634a9 | ||
|
|
1b67f88f68 | ||
|
|
48baf465e7 | ||
|
|
7fae244b05 | ||
|
|
bb417260e1 | ||
|
|
6375c58819 | ||
|
|
d17cab58cf | ||
|
|
0401c79078 | ||
|
|
33ba747047 | ||
|
|
a5cedb48f6 | ||
|
|
18314f9b18 | ||
|
|
1336a02e6d | ||
|
|
88debfdb64 | ||
|
|
8379221f3e | ||
|
|
53713ad284 | ||
|
|
c5a2be5614 | ||
|
|
3e41daea2d | ||
|
|
0344edd0d6 | ||
|
|
a0f410e161 | ||
|
|
a34cb55ad7 | ||
|
|
642be39e5b | ||
|
|
8edc076d81 | ||
|
|
b1bed9f8c2 | ||
|
|
772fa5c5fb | ||
|
|
c26af88dc7 | ||
|
|
6fa0ad5c6e | ||
|
|
f5b62726ff | ||
|
|
30b9556355 | ||
|
|
bfbe416861 | ||
|
|
00cb932c0f | ||
|
|
73638fcddd | ||
|
|
0c57bdb38c | ||
|
|
9b1d94d6f0 | ||
|
|
378d25301d | ||
|
|
b1065870f6 | ||
|
|
892eb55b5e | ||
|
|
cfbe5372db | ||
|
|
09781df0ae | ||
|
|
36b0ae6464 | ||
|
|
126c52ab50 | ||
|
|
46ce2bd860 | ||
|
|
6a46172df1 | ||
|
|
5049550203 | ||
|
|
d1688735d5 | ||
|
|
c271299f55 | ||
|
|
9a9205da2a | ||
|
|
ce6b1670b0 | ||
|
|
abd515021f | ||
|
|
4697977a17 | ||
|
|
1ff76d5954 | ||
|
|
d539277a5f | ||
|
|
bf5e8d8c21 | ||
|
|
b75ab56552 | ||
|
|
033587c042 | ||
|
|
ab7f32e096 | ||
|
|
829ddfccc7 | ||
|
|
c97cd5c964 | ||
|
|
b571206ffd | ||
|
|
f4d1d962a9 | ||
|
|
f68d28e417 | ||
|
|
ff49a2c362 | ||
|
|
06a0663b42 | ||
|
|
3a31311795 | ||
|
|
6e55271447 | ||
|
|
b12934a53f | ||
|
|
b79c76f0b6 | ||
|
|
430b676727 | ||
|
|
86c677837b | ||
|
|
77d6cfa09c | ||
|
|
f78bbe759f | ||
|
|
cfa6118815 | ||
|
|
cdac54fef7 | ||
|
|
f520ed5b0d | ||
|
|
42d8a74b91 | ||
|
|
983c80f3a5 | ||
|
|
8fa8e83d46 | ||
|
|
0ff8482e76 | ||
|
|
bd39da9872 | ||
|
|
74ca621314 | ||
|
|
143bac76ef | ||
|
|
73cf8138e7 | ||
|
|
af4aff14fe | ||
|
|
4168fb468d | ||
|
|
781b5fbf64 | ||
|
|
ec926c2e2d | ||
|
|
e0a285968d | ||
|
|
fe7e5393a5 | ||
|
|
d84aeac91a |
No files matched your search
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -20,6 +20,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
|
||||
linux-seat-environment:
|
||||
if: ${{ github.event_name != 'push' }}
|
||||
# This integration test reads /proc/<pid>/environ and is skipped on macOS.
|
||||
# Cover /proc seat environments and Linux canonical-input limits that macOS cannot exercise.
|
||||
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 --maxWorkers=1 --reporter=verbose
|
||||
npm run test -w packages/daemon -- test/seat-env-parent-session.integration.test.ts test/pi-shell-launch-native.test.ts --maxWorkers=1 --reporter=verbose
|
||||
SUITE
|
||||
|
||||
package-tests:
|
||||
|
||||
+453
-38
@@ -8,17 +8,372 @@ 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
|
||||
get through a seat pane left in more than one tmux mode, and `rig exec` hands a
|
||||
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).
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- **Restart the daemon after installing:**
|
||||
```bash
|
||||
npm install -g @openrig/cli@0.7.10
|
||||
rig daemon stop && rig daemon start
|
||||
```
|
||||
This release adds no migrations.
|
||||
- **The bundled openrig-core plugin advances 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 that copy. ([#2615](https://github.com/mvschwarz/openrig/pull/2615), [#2562](https://github.com/mvschwarz/openrig/pull/2562), [#2582](https://github.com/mvschwarz/openrig/pull/2582), [#2587](https://github.com/mvschwarz/openrig/pull/2587))
|
||||
- **Check your agent specs:** `rig agent validate <path>` now prints each
|
||||
advisory. A profile's `uses.<category>` written as a single value now selects
|
||||
that resource instead of being dropped, so a member using that profile fails to
|
||||
resolve if the resource pool doesn't hold it (see Behaviour that changed).
|
||||
([#2585](https://github.com/mvschwarz/openrig/pull/2585), [#2606](https://github.com/mvschwarz/openrig/pull/2606))
|
||||
- **If you run an OpenShell environment, rebuild its guest image** with this
|
||||
release's `guest.py` (`daemon/assets/openshell/README.md` describes the build).
|
||||
The CA repair below is in that file, which the image carries. ([#2602](https://github.com/mvschwarz/openrig/pull/2602))
|
||||
|
||||
### Finite runs: `rig exec`
|
||||
|
||||
- **`rig exec` runs one turn of a queue row's task on Claude Code or Codex**
|
||||
through its maintained ACP adapter, in a connector that outlives the command.
|
||||
`rig exec harnesses` shows the versions installed,
|
||||
`rig exec run <row> --harness claude` (or `codex`) starts it (`--plan` starts
|
||||
nothing, `--wait` waits), and `rig exec status` and `rig exec cancel` follow
|
||||
it. ([#2597](https://github.com/mvschwarz/openrig/pull/2597))
|
||||
- **One run per row, and the row stays open.** Running it again returns the
|
||||
row's run instead of sending the task twice; `--again` starts another turn once
|
||||
it has ended. A run whose connector died without recording an outcome reads
|
||||
`interrupted`, outcome unknown. The outcome is the agent's report, and
|
||||
accepting the work stays with you. `--wait` exits 0 for `completed`, 2 for any
|
||||
other outcome and 3 when you stop waiting; a Ctrl-C there stops the wait, not
|
||||
the run. ([#2597](https://github.com/mvschwarz/openrig/pull/2597), [#2601](https://github.com/mvschwarz/openrig/pull/2601))
|
||||
- **An ACP run's outcome note,** `rig acp run`'s included, carries the usage and
|
||||
cost the agent reported, each with its source, or unknown. ([#2597](https://github.com/mvschwarz/openrig/pull/2597))
|
||||
- **`docs/reference/acp-agents.md`** covers both adapters
|
||||
(`@agentclientprotocol/claude-agent-acp` 0.101.0 and
|
||||
`@agentclientprotocol/codex-acp` 2.5.0): installing them, which native
|
||||
versions run, and how their permissions, refusals and cancels differ. ([#2596](https://github.com/mvschwarz/openrig/pull/2596))
|
||||
|
||||
### The OpenShell worker in 0.7.10
|
||||
|
||||
**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:
|
||||
- **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))
|
||||
|
||||
An exact restore hasn't been shown: recreate and restore can't run while the
|
||||
cleanup fails.
|
||||
|
||||
### Compaction, delivery and terminals
|
||||
|
||||
- **A held post-compaction step now goes in.** Each step after a managed
|
||||
compaction (the turn boundary, the restore request, the audit) went only into
|
||||
an input box that read empty, so a box OpenRig couldn't read held the restore
|
||||
request on every poll, and the seat sat idle without its restore. Now, after
|
||||
two idle polls in a row that can't read the box, the next poll sends the step,
|
||||
and `rig compact <seat> --state` and `rig seat status` say it went in over a
|
||||
box OpenRig couldn't read, never that the box was empty. Some cases still hold
|
||||
(see Known limits). ([#2576](https://github.com/mvschwarz/openrig/pull/2576), refs [#2575](https://github.com/mvschwarz/openrig/issues/2575))
|
||||
- **Wakes and sends leave every tmux mode.** A pane scrolled into copy mode, with
|
||||
run-shell output, a chooser, customize or clock mode on top, kept a mode after
|
||||
one cancel, and the send was refused until someone left it by hand. Delivery
|
||||
now runs tmux's `copy-mode -q`, which leaves them all, with a single cancel
|
||||
where that fails (tmux before 3.1a), and a refusal names why leaving failed.
|
||||
([#2594](https://github.com/mvschwarz/openrig/pull/2594))
|
||||
- **A scrolled pane with a stale record no longer blocks delivery.** tmux
|
||||
reuses pane IDs after a server restart, so a stale binding, from another rig
|
||||
for example, can name the same pane as a live seat. Leaving the mode was
|
||||
addressed by that raw pane ID, which then matched two seats, so it was
|
||||
refused before anything was written and the send failed with nothing sent.
|
||||
Leaving the mode now goes through the seat's address, as the text and Enter
|
||||
writes already did. The stale binding itself is unchanged.
|
||||
([#2644](https://github.com/mvschwarz/openrig/pull/2644))
|
||||
- **A writable rig, pod or seat view sizes the pane it shows,** instead of
|
||||
keeping an older attached client's size. Read-only views are unchanged. ([#2614](https://github.com/mvschwarz/openrig/pull/2614))
|
||||
- **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))
|
||||
|
||||
### Coordination and status
|
||||
|
||||
- **Routing and receipts:**
|
||||
- a send to `<seat>@<rig>@<this host's ID>` goes to this host even when a
|
||||
remote host is selected;
|
||||
- `rig broadcast` prints `UNCONFIRMED`, not `FAILED`, for a recipient its text
|
||||
or Enter may have reached, and points to `rig capture` before any resend;
|
||||
- `rig seat stop`, `clean` and in-place restart, when they refuse a seat
|
||||
handed over before 0.7.9, say why and no longer suggest `rig unclaim`;
|
||||
- `rig roster show` and `find` read a seat's activity as unknown
|
||||
("Conflicting activity observations") when two routes to it disagree;
|
||||
- `rig seat set-runtime` finds a pod seat's authored recap.
|
||||
([#2600](https://github.com/mvschwarz/openrig/pull/2600), [#2572](https://github.com/mvschwarz/openrig/pull/2572), [#2574](https://github.com/mvschwarz/openrig/pull/2574), [#2593](https://github.com/mvschwarz/openrig/pull/2593))
|
||||
- **Refocus:** unresolved typed work, and a `rig queue whoami` that timed out,
|
||||
print a named trace gap with project-level orientation, labelled as not
|
||||
evidence of a current mission, instead of counting as a refocus failure.
|
||||
([#2582](https://github.com/mvschwarz/openrig/pull/2582), [#2587](https://github.com/mvschwarz/openrig/pull/2587))
|
||||
- **Updates to people:** the stuck sweep no longer files an informational update
|
||||
to a registered person as an unclaimed obligation, unless its quiet-hours hold
|
||||
was orphaned. ([#2586](https://github.com/mvschwarz/openrig/pull/2586))
|
||||
- **Slack:** `rig slack status` shows relay attempts to another host still
|
||||
awaiting a response. It's a count for the current connector at that moment, so
|
||||
zero doesn't prove nothing is in flight. ([#2569](https://github.com/mvschwarz/openrig/pull/2569))
|
||||
- **Permission requests in seat status:** `rig seat status` shows a Claude
|
||||
seat's latest permission request as openrig-core's hook observed it, with
|
||||
Claude's own suggested rules labelled as suggestions. Nothing is applied,
|
||||
approved or stored. ([#2562](https://github.com/mvschwarz/openrig/pull/2562))
|
||||
|
||||
### Install, specs and errors
|
||||
|
||||
- **The rigs skill's install step,** when npm's global prefix isn't writable,
|
||||
now has the agent point npm's prefix at a folder the person owns whose `bin` is
|
||||
on their login PATH, or use a version manager's Node, saying first what that
|
||||
changes, and install inside the project only if neither works. ([#2611](https://github.com/mvschwarz/openrig/pull/2611))
|
||||
- **Plain `rig` works in managed Codex seats 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. OpenRig now passes Codex `-c allow_login_shell=false`, so tool commands run in
|
||||
a non-login shell and inherit the environment their pane's shell set up,
|
||||
OpenRig's command folder included. To defer to Codex's own setting instead,
|
||||
run `rig config set launch.codex_login_shell true`; 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))
|
||||
- **Agent spec advisories show where you'll see them:** `rig up`, a fresh restore
|
||||
and a first-start retry report each as a launch warning naming the member,
|
||||
spec and path, and `rig agent validate` prints them whether or not the spec is
|
||||
valid. They never stop a launch. ([#2606](https://github.com/mvschwarz/openrig/pull/2606))
|
||||
- **An unknown key under a profile's `uses`,** such as a typo,
|
||||
gets an advisory instead of being ignored silently. ([#2618](https://github.com/mvschwarz/openrig/pull/2618))
|
||||
- **Clearer errors:**
|
||||
- a rejected terminal-token request says "Terminal auth failed" and names
|
||||
`OPENRIG_TERMINAL_BEARER_TOKEN`, or `OPENRIG_AUTH_BEARER_TOKEN` when
|
||||
terminal auth falls back to it;
|
||||
- the TUI shows the daemon's own message when it refuses a launch;
|
||||
- `rig doctor` gives up on its cmux status and `--spec` topology reads after 2
|
||||
seconds each, so a listener that never answers can't hold it.
|
||||
([#2577](https://github.com/mvschwarz/openrig/pull/2577), [#2556](https://github.com/mvschwarz/openrig/pull/2556), [#2564](https://github.com/mvschwarz/openrig/pull/2564))
|
||||
- **Docs:** a Codex config fragment can't install a named Codex profile, with
|
||||
how to keep a profile with full access or a `codex_permissions` envelope; and
|
||||
a Claude seat that selects openrig-core loads its skills with the plugin.
|
||||
([#2604](https://github.com/mvschwarz/openrig/pull/2604), [#2584](https://github.com/mvschwarz/openrig/pull/2584))
|
||||
|
||||
### Speed under load
|
||||
|
||||
- **The execution view** (`GET /api/views/execution`, behind the TUI's execution
|
||||
view and `rig view show execution`) reads the review registry once per request
|
||||
instead of once per slice, and yields to the event loop while it does. Its
|
||||
other synchronous steps are unchanged. ([#2578](https://github.com/mvschwarz/openrig/pull/2578))
|
||||
- **A queue listing** looks recovery rows up through one index per listing
|
||||
instead of a scan for each listed row. It hasn't been measured on a live
|
||||
daemon. ([#2588](https://github.com/mvschwarz/openrig/pull/2588))
|
||||
- **The event notification drain** reads pages of at most 500 rows instead of
|
||||
the whole remaining tail. It bounds rows per read, not bytes, and still runs to
|
||||
the end synchronously; no latency change on a live install is claimed.
|
||||
([#2610](https://github.com/mvschwarz/openrig/pull/2610))
|
||||
|
||||
### For runner authors and integrators
|
||||
|
||||
- **The runner contract says what has run with a real runner.**
|
||||
`docs/reference/harness-runner-contract.md` and the dsh example say what has
|
||||
run natively, at what scope, and what hasn't; any other runner, provider or
|
||||
path stays unqualified until shown. The 0.7.8 Known limits line below that said
|
||||
no harness had run end to end is struck and corrected in place.
|
||||
([#2592](https://github.com/mvschwarz/openrig/pull/2592), [#2595](https://github.com/mvschwarz/openrig/pull/2595))
|
||||
- **Telemetry:** `docs/reference/telemetry.md` says OpenRig's guarantee ends at
|
||||
its finite reads, versioned by `schemaVersion` 1, and links the separately
|
||||
maintained openrig-telemetry exporter, which OpenRig doesn't test or
|
||||
guarantee. ([#2589](https://github.com/mvschwarz/openrig/pull/2589))
|
||||
- **Also:** the real-tmux transport test waits for the shell's prompt before
|
||||
typing. ([#2559](https://github.com/mvschwarz/openrig/pull/2559))
|
||||
|
||||
### Behaviour that changed
|
||||
|
||||
- **Managed Codex seats run tool commands in a non-login shell** (above).
|
||||
Inherited setup isn't re-run for each command, so setup that lives only in
|
||||
login or rc files (such as `~/.bashrc` lines your login file doesn't source, or
|
||||
a tmux `default-command` that doesn't start a login shell), shell functions and
|
||||
aliases may be missing from tool commands. A model request for `login: true`
|
||||
gets Codex's own refusal, "login shell is disabled by config". With
|
||||
`launch.codex_login_shell` on, OpenRig omits its override rather than forcing
|
||||
login shells, so a login file that sets `PATH` outright can then hide `rig`.
|
||||
([#2613](https://github.com/mvschwarz/openrig/pull/2613))
|
||||
- **A held post-compaction step goes in after two idle polls that can't read the
|
||||
input box** (above). A draft OpenRig can't recognise in that box may receive
|
||||
the restore text. ([#2576](https://github.com/mvschwarz/openrig/pull/2576))
|
||||
- **A send or wake leaves every tmux mode in a seat's pane,** including a
|
||||
chooser, customize or clock mode a person has open, where before it was
|
||||
refused. ([#2594](https://github.com/mvschwarz/openrig/pull/2594))
|
||||
- **A send to `<seat>@<rig>@<this host's ID>`** goes to this host, not the
|
||||
selected remote host. ([#2600](https://github.com/mvschwarz/openrig/pull/2600))
|
||||
- **A profile's `uses.<category>` written as a single value** (for example
|
||||
`uses.skills: review`) used to validate and then be dropped. It now counts as a
|
||||
one-item list, with an advisory suggesting list syntax: a member using that
|
||||
profile gets the resource, or fails to resolve with "not found in resource
|
||||
pool" when the pool doesn't hold it. Other malformed `uses` shapes are
|
||||
advisories, not new errors. ([#2585](https://github.com/mvschwarz/openrig/pull/2585))
|
||||
- **`rig up` can print new warnings,** one per agent spec advisory, and
|
||||
`rig agent validate` prints them. ([#2606](https://github.com/mvschwarz/openrig/pull/2606))
|
||||
- **`rig broadcast`** prints `UNCONFIRMED` for a recipient it may have reached;
|
||||
its exit code and `N/M delivered` count are unchanged. ([#2572](https://github.com/mvschwarz/openrig/pull/2572))
|
||||
- **`rig roster show` and `find`** read conflicting activity as unknown.
|
||||
([#2574](https://github.com/mvschwarz/openrig/pull/2574))
|
||||
- **The refocus hook** no longer counts unresolved typed work or a timed-out
|
||||
`rig queue whoami` as a failure, and a timed-out refocus clears the pending
|
||||
post-compaction retry. ([#2582](https://github.com/mvschwarz/openrig/pull/2582), [#2587](https://github.com/mvschwarz/openrig/pull/2587))
|
||||
- **The stuck sweep** no longer flags informational updates to registered people
|
||||
as unclaimed obligations. ([#2586](https://github.com/mvschwarz/openrig/pull/2586))
|
||||
- **A rejected terminal-token request** gets "Terminal auth failed" instead of
|
||||
"Mission Control auth failed"; its status code and fields are unchanged.
|
||||
([#2577](https://github.com/mvschwarz/openrig/pull/2577))
|
||||
|
||||
### 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
|
||||
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
|
||||
actions aren't supported on environment seats. ([#2603](https://github.com/mvschwarz/openrig/pull/2603))
|
||||
- **`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 cancel is acknowledged, not settled, and
|
||||
says nothing about further spend. Cancel an exec run with `rig exec cancel`:
|
||||
`rig acp cancel` given its ID while it's still requested can signal the wrong
|
||||
process. A Ctrl-C just before the connector is launched isn't caught by
|
||||
`--wait`.
|
||||
([#2597](https://github.com/mvschwarz/openrig/pull/2597), [#2601](https://github.com/mvschwarz/openrig/pull/2601))
|
||||
- **After a managed compaction, a step can still hold:** when the input box
|
||||
reads as typed, with or without a suggestion; on a seat whose draft-aware
|
||||
delivery or typing guard holds OpenRig's input; and after a `/compact` whose
|
||||
result was lost. To take over 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. Which
|
||||
reading the reported seat's box gave isn't established, so the report stays
|
||||
open. ([#2575](https://github.com/mvschwarz/openrig/issues/2575))
|
||||
- **Draft-aware delivery to Claude:** on 0.7.8 with Claude Code 2.1.319,
|
||||
deliveries to idle Claude agents, queue wakes included, were reported held at
|
||||
an empty prompt when a narrow pane showed
|
||||
`✔ Update installed · Restart to update` on its own line. 0.7.10 still doesn't
|
||||
recognize that bare banner, though it recognizes `/rc` and the full versioned
|
||||
banner. A message the draft-aware delivery path holds isn't replayed
|
||||
automatically by that path. ([#2605](https://github.com/mvschwarz/openrig/issues/2605))
|
||||
- **Grant-service assistants aren't woken:** "an item stays queued until its
|
||||
receiver claims it."
|
||||
- **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 we don't test,
|
||||
and direct messages aren't supported. The channel and people reads, and a
|
||||
click on a re-parked ask, 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. ([#2503](https://github.com/mvschwarz/openrig/pull/2503))
|
||||
- **SSH host status** reads `unknown` behind a ProxyJump or ProxyCommand; that
|
||||
isn't proof the connection is broken. ([#2488](https://github.com/mvschwarz/openrig/pull/2488))
|
||||
- **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."
|
||||
- **Existing seats** last launched fresh before 0.7.9 pick up Codex launch
|
||||
instructions and the single skill load at their next fresh launch. A
|
||||
`CLAUDE.md` or `AGENTS.md` that already grew isn't trimmed.
|
||||
- **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. ([#2361](https://github.com/mvschwarz/openrig/pull/2361))
|
||||
- **Permission requests in seat status** show only the latest one, held in
|
||||
memory for five minutes and lost on a daemon restart. Claude's suggested rules
|
||||
can contain sensitive values, and `rig seat status` and both seat-status routes
|
||||
can return them. ([#2562](https://github.com/mvschwarz/openrig/pull/2562))
|
||||
- **Agent spec advisories** don't show for a native resume that continues a
|
||||
saved conversation, since it doesn't reload the spec. ([#2606](https://github.com/mvschwarz/openrig/pull/2606))
|
||||
- **Seats handed over before 0.7.9** keep their adopted origin until a later
|
||||
launched handover or managed launch records a new session, so until then
|
||||
`rig seat stop`, `restart` and `clean` refuse them; the refusal now says so.
|
||||
([#2440](https://github.com/mvschwarz/openrig/pull/2440), [#2572](https://github.com/mvschwarz/openrig/pull/2572))
|
||||
- **Codex hooks after an upgrade:** Codex sessions kept running across the
|
||||
upgrade to 0.7.9 showed "Hook failed — plugin contents changed; hook approval
|
||||
required" until relaunched; in that upgrade test, one seat restarted with
|
||||
OpenRig's managed restart showed no review afterwards. 0.7.10 changes
|
||||
openrig-core's files again.
|
||||
- **The runner contract** has run natively only at the scope its status section
|
||||
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
|
||||
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'
|
||||
`rig: command not found` (now a non-login shell by default; see Behaviour that
|
||||
changed).
|
||||
- **The 0.7.8 limits that 0.7.9 didn't fix or change, and the 0.7.7 and 0.7.6
|
||||
limits, below still apply.**
|
||||
|
||||
### Credits
|
||||
|
||||
Thanks to @dajiaohuang, @Aummadour, @billbenetti, @smota, @Nikhila-Dhevi-R and
|
||||
@Vaishnavi220506 for merged contributions, and to @AlephCasara, whose work on a
|
||||
fork pointed us to the unpaged event notification drain
|
||||
([#2610](https://github.com/mvschwarz/openrig/pull/2610)), and to @natefaerber, whose
|
||||
approach in [#2467](https://github.com/mvschwarz/openrig/pull/2467) the Herdr view fix follows. Thanks to
|
||||
@billbenetti, @KM199, @ziadeh, @dajiaohuang, @jajabong, @george661 and
|
||||
@aanestesia for reports carried into this release, and to @smota for the
|
||||
OpenTelemetry discussion behind [#2589](https://github.com/mvschwarz/openrig/pull/2589).
|
||||
|
||||
## [0.7.9]
|
||||
|
||||
Published 2026-11-06 as `@openrig/[email protected]`, built from the commit tagged
|
||||
`v0.7.9` (`ddb9d25b`).
|
||||
|
||||
OpenRig 0.7.9 follows through on 0.7.8. A person's typed words on a runner
|
||||
seat's permission ask now reach the agent, each permit OpenRig grants a runner
|
||||
by default is recorded, and a runner can keep a long, quiet turn reading
|
||||
working. New stream observations go to the queue worker by default. Fresh Pi
|
||||
seats, and fresh Codex seats on Codex 0.164.0 or later, can keep OpenRig's
|
||||
guidance out of your repository's `AGENTS.md`. There are fixes across delivery,
|
||||
asks, handover, install and recovery. It includes a repair for the OpenShell
|
||||
worker that wasn't usable in 0.7.8, but that repair hasn't run end to end in our
|
||||
release testing: see [the OpenShell worker](#the-openshell-worker-hasnt-run-end-to-end-in-079).
|
||||
asks, handover, install and recovery, many reported or contributed by people
|
||||
using OpenRig. The OpenShell worker that wasn't usable in 0.7.8 still isn't
|
||||
usable in 0.7.9; read [The OpenShell worker](#the-openshell-worker-in-079)
|
||||
before relying on it.
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
@@ -75,22 +430,35 @@ release testing: see [the OpenShell worker](#the-openshell-worker-hasnt-run-end-
|
||||
`DSH_SEAT_BIN` and `DSH_SEAT_HOME`, which a member's `env:` can set, and
|
||||
reports dsh's real version. ([#2364](https://github.com/mvschwarz/openrig/pull/2364), [#2414](https://github.com/mvschwarz/openrig/pull/2414))
|
||||
|
||||
### The OpenShell worker hasn't run end to end in 0.7.9
|
||||
### The OpenShell worker in 0.7.9
|
||||
|
||||
0.7.9 includes a repair for the OpenShell worker that wasn't usable in 0.7.8,
|
||||
but it hasn't run end to end in our release testing. Don't rely on this path in
|
||||
0.7.9. What the repair changes:
|
||||
- **Startup reaches the guest through a hash-checked first-input file.** Nothing
|
||||
is typed through the host viewer, so startup text can't reach the host shell
|
||||
if the viewer exits.
|
||||
- **Readiness checks the guest's own execution,** and opening the viewer does
|
||||
one identity check instead of re-checking every projected file.
|
||||
- **A failed owned delete reports the stage that failed,** and whether its
|
||||
dispatch is unknown.
|
||||
- **The README** names the kernel operator `operator-agent@kernel`.
|
||||
**The OpenShell worker still doesn't work in 0.7.9. Don't rely on this path.**
|
||||
|
||||
0.7.9 repairs the layer that stopped it in 0.7.8: in our release testing the
|
||||
guest's agent now starts and reaches its first input. Startup reaches the guest
|
||||
through a hash-checked first-input file, and nothing is typed through the host
|
||||
viewer; the case where the viewer exits wasn't run in our release testing.
|
||||
Readiness checks the guest's own execution, and a failed owned delete reports
|
||||
the stage that failed.
|
||||
([#2418](https://github.com/mvschwarz/openrig/pull/2418), [#2404](https://github.com/mvschwarz/openrig/pull/2404))
|
||||
|
||||
Our release testing on 0.7.9 then found two more problems:
|
||||
- **The worker can't reach its model.** The guest's Claude starts, but every API
|
||||
call fails with `SELF_SIGNED_CERT_IN_CHAIN`. The guest launcher builds a clean
|
||||
environment and leaves out the CA settings the OpenShell exec supplies
|
||||
(`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE` and
|
||||
`CURL_CA_BUNDLE`).
|
||||
- **Deleting an owned environment doesn't run.** Its inventory step misreads a
|
||||
"Not Found" reply, so the delete is never sent and the owned resources stay.
|
||||
In our test they were removed with the vendor's own cleanup.
|
||||
|
||||
Attached resources were kept, as intended, and the worker stopped cleanly. With
|
||||
the four CA settings passed through by hand, a worker did useful work; a
|
||||
verified export, an exact restore and a finished task weren't shown. Fixes for
|
||||
both problems are on `main` ([#2602](https://github.com/mvschwarz/openrig/pull/2602),
|
||||
[#2603](https://github.com/mvschwarz/openrig/pull/2603)). They aren't in 0.7.9
|
||||
and haven't been run end to end.
|
||||
|
||||
### Assistants connected through a grant service (experimental)
|
||||
|
||||
**Experimental and unsupported. Off by default. Use at your own risk.** This path
|
||||
@@ -262,38 +630,80 @@ See `docs/reference/hosted-members.md`. ([#2384](https://github.com/mvschwarz/op
|
||||
|
||||
### Known limits
|
||||
|
||||
- **OpenShell:** the repair hasn't run end to end in our release testing
|
||||
(above). 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 actions aren't supported
|
||||
on environment seats.
|
||||
- **OpenShell:** the worker doesn't work in 0.7.9 (see
|
||||
[The OpenShell worker](#the-openshell-worker-in-079)). 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 actions aren't supported on environment seats.
|
||||
- **Grant-service assistants aren't woken:** "an item stays queued until its
|
||||
receiver claims it."
|
||||
- **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 we don't test,
|
||||
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.
|
||||
and direct messages aren't supported. The channel and people reads, and a
|
||||
click on a re-parked ask, haven't been run against a live Slack workspace in
|
||||
our release testing. 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. ([#2503](https://github.com/mvschwarz/openrig/pull/2503))
|
||||
- **SSH host status** reads `unknown` behind a ProxyJump or ProxyCommand; that
|
||||
isn't proof the connection is broken.
|
||||
isn't proof the connection is broken. ([#2488](https://github.com/mvschwarz/openrig/pull/2488))
|
||||
- **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 skill load
|
||||
at their next fresh launch. A `CLAUDE.md` or `AGENTS.md` that already grew
|
||||
isn't trimmed.
|
||||
- **Codex hooks after an upgrade:** Codex sessions kept open across the 0.7.8
|
||||
upgrade showed "Hook failed — plugin contents changed; hook approval
|
||||
required". The 0.7.9 behaviour, and hook recovery after a relaunch, haven't
|
||||
been verified.
|
||||
- **With Node outside the login shell's PATH,** Codex seats can still report
|
||||
`rig: command not found`; preflight now warns, and the getting-started guide
|
||||
gives the `~/.profile` fix.
|
||||
- **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. ([#2361](https://github.com/mvschwarz/openrig/pull/2361))
|
||||
- **Draft-aware delivery to Claude:** on 0.7.8 with Claude Code 2.1.319,
|
||||
deliveries to idle Claude agents, queue wakes included, were reported held at
|
||||
an empty prompt when a narrow pane showed
|
||||
`✔ Update installed · Restart to update` on its own line. 0.7.9 still doesn't
|
||||
recognize that bare banner, though it recognizes `/rc` and the full versioned
|
||||
banner. A message the draft-aware delivery path holds isn't replayed
|
||||
automatically by that path.
|
||||
([#2605](https://github.com/mvschwarz/openrig/issues/2605))
|
||||
- **After a managed compaction, the restore request can stay held.** OpenRig
|
||||
delivers each post-compaction step only when it can read the seat's input box
|
||||
as empty. When it can't, it holds the restore request and keeps retrying on
|
||||
its polls, so the seat can sit idle at an empty prompt with its restore not
|
||||
yet requested. `rig compact <seat> --state` and `rig seat status` show the
|
||||
restore as owed, and from the second failed attempt the seat's owner gets a
|
||||
queue item naming it. To restore by hand, send the seat a restore request
|
||||
naming its newest restore map, its restore marker and its packet, then settle
|
||||
the owed attempt with `rig compact <seat> --cancel`; a poll can still race
|
||||
that step. Reported on 0.7.8, and 0.7.9 behaves the same way. ([#2575](https://github.com/mvschwarz/openrig/issues/2575))
|
||||
- **Seats handed over before 0.7.9** keep their adopted origin until a later
|
||||
launched handover or managed launch records a new session, so until then
|
||||
`rig seat stop`, `restart` and `clean` refuse them as before. ([#2440](https://github.com/mvschwarz/openrig/pull/2440))
|
||||
- **`rig broadcast`:** an uncertain recipient's outbox receipt now reads
|
||||
`indeterminate`, but the broadcast's own output for that recipient still says
|
||||
FAILED. Check that seat with `rig capture` before resending. ([#2507](https://github.com/mvschwarz/openrig/pull/2507))
|
||||
- **Codex hooks after an upgrade:** Codex sessions kept running across the
|
||||
upgrade show "Hook failed — plugin contents changed; hook approval required"
|
||||
until relaunched. In our upgrade test from 0.7.8, one seat restarted with
|
||||
OpenRig's managed restart showed no review afterwards.
|
||||
- **Codex seats can still report `rig: command not found`** when `rig` isn't on
|
||||
the login shell's PATH: with Node outside it, or when npm can't install
|
||||
globally and OpenRig is installed inside the project instead. Preflight now
|
||||
warns at launch. In our testing the agents recovered by calling `rig` by its
|
||||
full path; the getting-started guide describes a `~/.profile` fix, which we
|
||||
didn't run.
|
||||
- **What we ran end to end under the runner contract:** this release's dsh
|
||||
example runner, natively, with dsh 0.2.1-rc.2 on DeepSeek's
|
||||
`deepseek/deepseek-v4-flash` through dsh's pi-ai OpenRouter chat-completions
|
||||
profile, against 0.7.8 Core: two useful queue tasks, queued and multiline
|
||||
input, a quiet tool interval of about 26 seconds that still read working,
|
||||
allow-once and reject-once with their file effects, cancel, a return to the
|
||||
same conversation with recall, and shutdown. The permission answers came from
|
||||
an automated test operator, and the run's preset added `resume: true`, which
|
||||
the shipped preset doesn't declare. dsh's own DeepSeek Messages route through
|
||||
OpenRouter failed with HTTP 400, and the cause isn't established. Not run: a
|
||||
missing-conversation refusal, cross-host restore, OpenShell, and other
|
||||
adapters, models or providers. 0.7.8's release tests had already run its dsh
|
||||
example end to end, so the 0.7.8 CHANGELOG's "No harness has run end to end
|
||||
under the contract yet" was out of date.
|
||||
- **Fixed or changed from 0.7.8's limits:** the dsh example's `env:` names, the
|
||||
conformance check's false failures, a person's typed words, the README's
|
||||
operator address, a rebuild behind a symlink, the rebuild receipt, a click on
|
||||
@@ -440,8 +850,13 @@ check timed out after 5 seconds, so no team work ran. Don't rely on this path in
|
||||
|
||||
### Known limits
|
||||
|
||||
- **No harness has run end to end under the contract yet.** The dsh example and
|
||||
the conformance check were checked against stand-ins.
|
||||
- ~~**No harness has run end to end under the 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.** "A colleague's answer is
|
||||
followed on this host. If the ask is handed to another host, the request
|
||||
waits, shown as needs input, until the agent's turn ends."
|
||||
|
||||
@@ -26,9 +26,9 @@ use:
|
||||
|
||||
```sh
|
||||
# Claude Code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.9/skills/rigs --skill rigs --agent claude-code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.11/skills/rigs --skill rigs --agent claude-code
|
||||
# Codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.9/skills/rigs --skill rigs --agent codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.11/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.9` 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.11` 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.9/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.9/scripts/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/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.
|
||||
@@ -519,6 +519,9 @@ OpenRig is open source and self-hosted, with Claude Code and Codex in the same t
|
||||
|
||||
- **Website**: [openrig.dev](https://openrig.dev)
|
||||
- **Docs**: [openrig.dev/docs](https://openrig.dev/docs) ([documentation index for agents](https://openrig.dev/llms.txt))
|
||||
- **What's new**: [openrig.dev/whats-new](https://openrig.dev/whats-new) (every release, in plain words)
|
||||
- **Features**: [openrig.dev/features](https://openrig.dev/features)
|
||||
- **Integrations**: [openrig.dev/integrations](https://openrig.dev/integrations) (assistants, harnesses and environments, with setup and test status)
|
||||
- **Blog**: [openrig.dev/blog](https://openrig.dev/blog) · [Why I Built OpenRig](https://esoteric.run/blog/why-i-built-openrig)
|
||||
- **Open Specification**: [openrig.dev/specs](https://openrig.dev/specs)
|
||||
- **Videos**: [youtube.com/@openrig](https://www.youtube.com/@openrig)
|
||||
|
||||
+2
-2
@@ -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.8
|
||||
## Released in 0.7.10
|
||||
|
||||
[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.
|
||||
[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.
|
||||
|
||||
## How to help
|
||||
|
||||
|
||||
@@ -398,6 +398,21 @@ adapter never closes a session on it and reports `rex_outcome_unknown`.
|
||||
Herdr and Rex build their layout trees from one grid helper
|
||||
(`buildTileGrid`, `tile-layout.ts`).
|
||||
|
||||
`HerdrAppProvider` (`herdr-app-provider.ts`) opens a view in a herdr-app
|
||||
window (one provider per app home, cached in `startup.ts`). In the daemon it is
|
||||
used only when named (`resolveHerdrAppProvider` in `terminal-service.ts`). Its
|
||||
transport (`herdr-app-transport.ts`) sends one JSON line per
|
||||
connection to `<app home>/herdr-app.sock` and reads the window's own "did not
|
||||
answer" as `unknown`. A view is a child of OpenRig's stack in a derived
|
||||
`layouts/rig.json` (`deriveRigLayout`, `herdr-app-layout.ts`), written
|
||||
atomically, with viewers started by `hc run --bg --no-parent` and journalled
|
||||
first (`herdr-app-state.ts`). Removal rewrites the file before a viewer ends,
|
||||
and ends it only after the `rig-strip` mod's acknowledgement
|
||||
(`<app home>/openrig/ack.json`) shows a layout without it. An `hc events`
|
||||
watcher (`startup.ts`) turns a viewer that ended elsewhere into an ended cell.
|
||||
Its four mods ship in `packages/daemon/assets/herdr-app/mods/` and are
|
||||
installed by `herdr-app-install.ts`.
|
||||
|
||||
### Tmux server selection
|
||||
|
||||
Before reconciliation, `startup.ts` calls `selectTmuxServer` in
|
||||
|
||||
@@ -214,16 +214,26 @@ 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.
|
||||
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.
|
||||
|
||||
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 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
|
||||
- `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
|
||||
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;
|
||||
@@ -234,6 +244,10 @@ 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`).
|
||||
|
||||
@@ -237,7 +237,9 @@ The sweep reports six finding kinds. Its custody check covers closures that
|
||||
name a successor qitem or a seat (`closure_reason = handed_off_to`) in the last
|
||||
24 hours, plus older closures with open findings. A seat target is satisfied by
|
||||
a local successor carrying this row's lineage, and any target by a
|
||||
`custody-verified: <target> …` note on the closed row. A new custody finding for
|
||||
`custody-verified:` note on the closed row: per note, its first word that names one
|
||||
of the row's targets exactly (or a comma list of them) is what it verifies, and later
|
||||
mentions don't count; the finding prints each target's note. A new custody finding for
|
||||
a human destination goes to the agent that closed the item, using the closing
|
||||
transition from active or archived history. Later notes and notification
|
||||
receipts do not change that owner. If the closing actor is missing, human, system
|
||||
@@ -278,7 +280,13 @@ canceled, no-follow-on, escalation, superseded}`. The reasons
|
||||
`closure_target` (`:86`):
|
||||
|
||||
- `handed_off_to` — work continues with a different seat (`closure_target` =
|
||||
new owner).
|
||||
new owner). `rig queue show` on such a row names the row the handoff made in
|
||||
`successors` (rows whose `handedOffFrom` is this row, and a local row its
|
||||
closure target names), or says in `successorNote` why none is named. A
|
||||
cross-host successor is named as recorded (`<id>@<host>`); nothing stored
|
||||
changes. Past 20 such rows the newest 20 are named, with
|
||||
`successorsTruncated: true`. A scoped MCP caller doesn't get these fields;
|
||||
`rig_queue_show`'s scope-checked `successor` answers it instead.
|
||||
- `blocked_on` — an active `state=blocked` park record, rejected with `state=done`.
|
||||
To wait for a qitem, use `rig queue block <id> --on <child>` or
|
||||
`rig queue update <id> --state blocked --blocked-on <child>`; the item returns
|
||||
@@ -468,6 +476,16 @@ 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,8 +160,9 @@ 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 (`:840`), and otherwise falls back to the derived name
|
||||
with a warning (`:869`).
|
||||
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.
|
||||
- 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
|
||||
|
||||
@@ -169,9 +169,19 @@ Routes: `packages/daemon/src/routes/{transport,transcripts,ask,chat,whoami}.ts`
|
||||
posture, one for each mode OpenRig can launch: bypass
|
||||
(`⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents`), accept
|
||||
edits (OpenRig's default), auto, don't ask, plan, and default
|
||||
(`⏸ manual mode on · ← for agents`); no statusLine row between the
|
||||
closing rule and the footer (OpenRig's statusLine collector prints
|
||||
nothing); a blank, effort or tmux-notice row above the box; and above
|
||||
(`⏸ manual mode on · ← for agents`); between the closing rule and the
|
||||
footer, either no row (OpenRig's statusLine collector prints nothing) or
|
||||
the rows a person's own statusLine prints, with the indicator Claude
|
||||
draws at the right of the first one (`● high · /effort`), trusted only by
|
||||
position, as the composer reader trusts them (#2703, #2798): the closing
|
||||
rule is the first rule above the footer, the frame above it must check
|
||||
out, and no row between them may be blank or look like a menu or dialog
|
||||
(`❯`, `›` or `»` anywhere in the row, a row that starts with a numbered
|
||||
choice such as `1. ` or `2) `, with or without a border or gutter before
|
||||
it, a box corner, or a row framed by side borders); so a statusLine that
|
||||
uses `›`, `»` or `❯` as a separator, prints a blank row or starts a row
|
||||
with a number and a dot reads `unknown`; a blank, effort or tmux-notice
|
||||
row above the box; and above
|
||||
that, in the status slot, the completion row of the seat's last turn
|
||||
(`✻ <verb> for <duration> · done <time>` at column 0, matched by the
|
||||
Claude Code 2.1.296 bundle's grammar: one of its eight past-tense verbs,
|
||||
@@ -204,13 +214,15 @@ Routes: `packages/daemon/src/routes/{transport,transcripts,ask,chat,whoami}.ts`
|
||||
status row, `esc to interrupt`, `paste again to expand`, a queued label,
|
||||
Codex's pending-input headers or `↳` rows, a spinner). Any other frame,
|
||||
including every running, streaming, steer-preview, paste-hint or unknown
|
||||
layout, another seat's name, a statusLine that prints a row, a background
|
||||
shell in the footer and a fullscreen renderer with a statusLine (which
|
||||
reserves a row for it), reads `seat: not-idle` or
|
||||
`unknown`, and the send reads unconfirmed. This holds when a running turn
|
||||
hides its signal (Codex hides its `• Working` row while it streams;
|
||||
Claude Code shows only `paste again to expand` in its footer for 8 s
|
||||
after a paste, and no `esc to interrupt` under a statusLine). So a send to
|
||||
layout, another seat's name, a second rule drawn under the box, a blank,
|
||||
prompt-, menu- or dialog-shaped row between the closing rule and the
|
||||
footer (so a fullscreen renderer that reserves a blank row for the
|
||||
statusLine reads as before), and a background shell in the footer, reads
|
||||
`seat: not-idle` or `unknown`, and the send reads unconfirmed. This
|
||||
holds when a running turn hides its signal (Codex hides its `• Working`
|
||||
row while it streams; Claude Code shows only `paste again to expand` in
|
||||
its footer for 8 s after a paste, and no `esc to interrupt` under a
|
||||
statusLine). So a send to
|
||||
a busy or unknown seat always reads unconfirmed.
|
||||
|
||||
**Versions.** The layouts were recorded from Claude Code 2.1.296 and
|
||||
@@ -272,7 +284,13 @@ Routes: `packages/daemon/src/routes/{transport,transcripts,ask,chat,whoami}.ts`
|
||||
characters never widen it. Any other break, and a break inside a word of
|
||||
the text, reads `ambiguous` and the send stays unconfirmed. A collapsed paste label, Claude's
|
||||
queued-messages label or `Try "…"` suggestion, or another draft is never
|
||||
the message. The default `--verify` matcher above is unchanged.
|
||||
the message. The default `--verify` matcher above is unchanged. Claude
|
||||
Code's box ends at the last rule on the screen; the rows under it up to
|
||||
the footer are a person's statusLine, read by position with the same
|
||||
rule as the seat check above (#2798), so a box with statusLine rows
|
||||
under it reads as one without them, and a blank, prompt-, menu- or
|
||||
dialog-shaped row there leaves it `unrecognized`. The startup Enter
|
||||
guard reads the same region.
|
||||
- **The box is empty after Enter on the same pane process.** The capture
|
||||
after Enter must show the box empty, with the pane process
|
||||
(`#{pane_pid}`) and the seat's binding unchanged.
|
||||
|
||||
@@ -84,6 +84,15 @@ 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
|
||||
|
||||
+100
-17
@@ -9,23 +9,23 @@ applies-when: |
|
||||
the source that implements a command. Use command help for invocation details.
|
||||
siblings: [README.md, codemap.md]
|
||||
prerequisite-reads: [../reference/help.md]
|
||||
last-verified-against-source: 34fdcb80543903b054a982cc48bb302ae6871682
|
||||
last-updated: 2026-10-26
|
||||
last-verified-against-source: b29a70f14fc11797d46cbd9f12d4e61a523c3d06
|
||||
last-updated: 2026-11-09
|
||||
---
|
||||
|
||||
# CLI Reference — Registered Commands and Options
|
||||
|
||||
Verified against source commit `34fdcb80543903b054a982cc48bb302ae6871682`.
|
||||
Verified against source commit `e1dedbf531b6db9961b4c77c0a74564e58274a50`.
|
||||
The inventory below comes from the actual Commander tree returned by
|
||||
[`createProgram()`](../../packages/cli/src/index.ts), not a grep of command
|
||||
strings or an installed CLI from a different commit.
|
||||
|
||||
There are **90 top-level registrations**, **410 registered command objects**
|
||||
There are **92 top-level registrations**, **440 registered command objects**
|
||||
below `rig` (including groups and the hidden `restore apply` command), and
|
||||
**1,304 explicitly registered option objects**, including the root version
|
||||
**1,460 explicitly registered option objects**, including the root version
|
||||
option. Aliases do not add command objects; short/long spellings of one
|
||||
option do not add option objects. Commander-generated help is additional.
|
||||
These are counts for that source snapshot, not a claim about a deployed release. The provider-switch and set-runtime rows include the later account-home options; those additions are not included in the snapshot counts. The `environment` section and the `host edit`, `host remove` and `terminal attach` rows were added later from source at `522d635c`, and aren't in the snapshot counts either. With `environment`, main registers 91 top-level commands.
|
||||
These are counts for that source snapshot, not a claim about a deployed release. Every registered family is inside the snapshot: the `exec` and `environment` families, the `roster` and `telemetry` rows, and the `host edit`, `host remove` and `terminal attach` rows that earlier snapshots listed as later additions are all counted here, as are the herdr-app provider's `terminal close` row and its `--app-home` options.
|
||||
|
||||
## Reading this reference
|
||||
|
||||
@@ -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>]>` **required** (repeatable)<br>`--delivery-class <A\|B\|C\|D>` **required**<br>`--away`<br>`--replace`<br>`--reason <reason>`<br>`--actor <actor>`<br>`--organization <id>`<br>`--group <id>` (repeatable)<br>`--policy <text>`<br>`--policy-file <path>`<br>`--default` |
|
||||
| `rig gateway human add <entityId>` | — | `--display-name <name>` **required**<br>`--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>` (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 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` |
|
||||
@@ -226,6 +226,7 @@ the sections that were not read are `null`.
|
||||
`groups`, `warnings` and `participantFile`. `default` with no argument prints the current default and its source
|
||||
(`chosen` or `only-person`), or `null` with the reason. See `docs/reference/gateway-participants.md`.
|
||||
- `human set <entityId> slack-subscription <value>` — sets the person's Slack subscription: `every-ask` (the default) posts every ask to them to Slack; `urgent-only` posts only asks created with `--alert`, and other asks wait on Steering and the TUI; `unset` removes the authored subscription so the default applies. `human show` prints the value with its origin, `authored` or `default` (`slack-subscription: every-ask (default)`); `--json` carries it as `record.slackSubscription` `{ value, source }`.
|
||||
- `human set <entityId> binding.<n> <spec|unset>` — replaces `binding.<n>` with a full `kind:connectorRef:secretsRef:role[:handle=<id>]` spec, or removes that slot when `<value>` is `unset`. Removing a binding keeps the person, their preferences, their other bindings and their open questions, and prints `remainingBindings` (with their shifted `binding.<n>` slot indexes; the primary's slot shifts down too when a lower-index secondary is removed) plus `note`. No replacement `primary` is chosen silently: unsetting a `primary` while secondary bindings remain is refused, teaching how to replace the primary slot in place, promote an existing secondary binding (first save that binding's full spec from `rig gateway human show <entityId> --json`, then unset that secondary slot, then find the primary's current slot in `remainingBindings` or `rig gateway human show <entityId> --json` and set it to the saved spec with role `primary` — promotion replaces the former primary binding, and its connector reference and inbound handle are not retained as a secondary), or unset the secondary slots first using their current slot indexes from `remainingBindings` or `rig gateway human show <entityId>` and finish at `binding.0` when removing every binding. Removing the last binding makes the current delivery episode inactive so new missing-post findings stop (an already-open recovery item for an undelivered notice may remain, and the current delivery-outcome display may disappear while historical receipt notes remain); it does not answer, close or cancel open questions, cancel or reroute already-prepared deliveries, or retract anything a bridge has already accepted.
|
||||
|
||||
### parked
|
||||
|
||||
@@ -353,6 +354,8 @@ the sections that were not read are `null`.
|
||||
| `rig host edit <id>` | — | `--target <target>`<br>`--user <user>`<br>`--url <url>`<br>`--notes <text>`<br>`--json` |
|
||||
| `rig host remove <id>` | — | `--json` |
|
||||
| `rig host pair <url>` | — | `--id <id>`<br>`--timeout <seconds>`<br>`--human <address>`<br>`--json` |
|
||||
| `rig host approve <request>` | — | `--bearer <token>`<br>`--json` |
|
||||
| `rig host deny <request>` | — | `--bearer <token>`<br>`--json` |
|
||||
| `rig host list` | `ls` | `--json` |
|
||||
| `rig host doctor <id>` | — | `--posture <profile>`<br>`--public-addr <ip>`<br>`--json` |
|
||||
|
||||
@@ -361,7 +364,44 @@ even when it is bound to a tailnet address and so starts without one: pairing ha
|
||||
over that token. Without it, the target's `POST /api/hosts/pair-request` answers 409
|
||||
`pair_target_no_bearer`; it answers 409 `pair_human_required` when no single
|
||||
registered person can approve (none, or several with no default; name one with
|
||||
`--human`) (`packages/daemon/src/routes/hosts.ts:167-186`).
|
||||
`--human`) (`packages/daemon/src/routes/hosts.ts:379-398`).
|
||||
|
||||
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.
|
||||
|
||||
On a 0.7.11 or later target, its person answers the request in the TUI (`rig tui`, then `attention`: open it), on
|
||||
Steering, or in Slack: with Approve or Deny, or in their own words. Typed words never deny it.
|
||||
- **When the target has a kernel operator (`operator-agent@kernel`),** it asks. The person's answer, a click or their
|
||||
words, comes back to it as a reply, and it decides with `rig host approve <code>` or `rig host deny <code>`. It asks
|
||||
the person back when the answer isn't clear. The person is told once that their answer was received. If the operator
|
||||
isn't running, the request and that receipt say so, and they name the terminal route. The request stays pending, never
|
||||
granted or denied, until the operator decides or the request expires. If the operator doesn't pick up the answer, the
|
||||
person is told at expiry that it didn't.
|
||||
- **When the target has no kernel operator** (or the queue can't send rows to it, or OpenRig couldn't read which; the
|
||||
request says which), Approve and Deny decide at once. A reply in words, or with none (a file), is kept on the
|
||||
request, and the person is asked again with a new request.
|
||||
- **Either way,** the outcome reaches the person as its own update: approved or denied, and by whom.
|
||||
|
||||
On either target, `rig host approve <request>` or `rig host deny <request>` (the pairing code, or the request's
|
||||
approval row id), run on the target from an agent seat or a plain terminal, decides it. It needs the target daemon's operator bearer
|
||||
token: an agent seat carries `OPENRIG_AUTH_BEARER_TOKEN`, and a person sets it or passes `--bearer <token>`. Without
|
||||
it the daemon answers 401, and nothing is decided. It decides once, and refuses an
|
||||
unknown (404 `pair_request_unknown`), expired (409 `pair_request_expired`) or already-decided (409
|
||||
`pair_request_decided`) request, including one its row already decided before the requester's next check (a click on
|
||||
a target with no kernel operator, or a refusing close). It records who decided: the seat, or `human@host` from a plain terminal. The person
|
||||
gets one update with the result. An agent seat's `rig queue update <id> --state done --closure-reason no-follow-on
|
||||
--resolution approve` on an unanswered request still approves, and any other close that carries no answer and isn't
|
||||
the person's own reply, or `--state denied`, refuses. To refuse a pairing, answer Deny on a target with no kernel operator, run
|
||||
`rig host deny`, or let it expire (nothing is persisted). At its deadline a request's ask closes as expired, even when the requester stopped waiting, so a late click there isn't
|
||||
accepted. The decisions live in the daemon's memory, as the requests do, so a daemon restart drops an open request.
|
||||
|
||||
A 0.7.10 or earlier target asks no question. Its person approves with the request's Approve, in the TUI (`rig tui`,
|
||||
then `attention`: open it; the TUI from 0.7.1) or on Steering, and from an agent seat `rig queue update <id> --state done
|
||||
--closure-reason no-follow-on` approves. There, every close that leaves the request `done` approves it, whatever its
|
||||
closure reason: a typed reply, or an agent seat's `rig queue update <id> --state done --closure-reason denied`,
|
||||
included. To refuse a pairing on such a target, let it expire, or from an agent seat set the request `denied`
|
||||
(`rig queue update <id> --state denied`); a `canceled` or `failed` state refuses too.
|
||||
|
||||
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
|
||||
@@ -402,7 +442,7 @@ do not establish connectivity to a live peer.
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig mcp` | — | — |
|
||||
| `rig mcp serve` | — | `--port <port>`<br>`--http`<br>`--bind <host:port>`<br>`--grant-service <url>`<br>`--grant-service-header-file <path>`<br>`--oauth-resource <url>`<br>`--oauth-issuer <url>` |
|
||||
| `rig mcp serve` | — | `--port <port>`<br>`--http`<br>`--bind <host:port>`<br>`--grant-service <url>`<br>`--grant-service-header-file <path>`<br>`--oauth-resource <url>`<br>`--oauth-issuer <url>`<br>`--front-door`<br>`--task-destination <seat>` |
|
||||
| `rig mcp token` | — | — |
|
||||
| `rig mcp token add <name>` | — | `--seat <session>` **required**<br>`--rig <name...>`<br>`--root <name...>`<br>`--workspace`<br>`--owner` |
|
||||
| `rig mcp token list` | — | `--json` |
|
||||
@@ -414,9 +454,21 @@ do not establish connectivity to a live peer.
|
||||
|---|---|---|
|
||||
| `rig acp` | — | — |
|
||||
| `rig acp run <qitemId> <agent...>` | — | `--cwd <dir>`<br>`--permissions-to <session>`<br>`--load-session <id>`<br>`--poll-seconds <n>` |
|
||||
| `rig acp serve` | — | `--to <member@rig>`<br>`--as <address>`<br>`--poll-seconds <n>` |
|
||||
| `rig acp cancel <runId>` | — | — |
|
||||
| `rig acp stop <runId> [stopCommand...]` | — | — |
|
||||
|
||||
### exec
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig exec` | — | — |
|
||||
| `rig exec harnesses` | — | `--json` |
|
||||
| `rig exec run <qitemId>` | — | `--harness <name>` **required**<br>`--cwd <dir>`<br>`--permissions-to <session>`<br>`--load-session <id>`<br>`--wait`<br>`--plan`<br>`--again`<br>`--poll-seconds <n>` |
|
||||
| `rig exec status <runIdOrQitemId>` | — | `--json` |
|
||||
| `rig exec cancel <runId>` | — | — |
|
||||
| `rig exec connector <runId>` (hidden) | — | — |
|
||||
|
||||
### agent
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
@@ -475,16 +527,17 @@ 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>`--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 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>`--source <name@external>`<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 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` |
|
||||
| `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 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 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` |
|
||||
@@ -493,6 +546,7 @@ default-permit record (tag `runner-permission-permit`) opens no row.
|
||||
| `rig queue inbox-pending <destinationSession>` | — | `--json` |
|
||||
| `rig queue outbox-record` | — | `--sender <session>`<br>`--destination <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--tags <tags>`<br>`--urgency <urgency>`<br>`--audit <pointer>`<br>`--id <outboxId>`<br>`--json` |
|
||||
| `rig queue outbox-list <senderSession>` | — | `--limit <n>`<br>`--json` |
|
||||
| `rig queue erase` | — | `--members <file>`<br>`--rig <id>`<br>`--ids <file>`<br>`--action <action>` (`delete`, `scrub`)<br>`--routine`<br>`--apply`<br>`--actor <session>`<br>`--json` |
|
||||
|
||||
### slack
|
||||
|
||||
@@ -552,13 +606,16 @@ default-permit record (tag `runner-permission-permit`) opens no row.
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig terminal` | — | — |
|
||||
| `rig terminal open <view>` | — | `--provider <name>`<br>`--session <name>`<br>`--window`<br>`--expected-plan <id>`<br>`--json` |
|
||||
| `rig terminal open <view>` | — | `--provider <name>`<br>`--session <name>`<br>`--window`<br>`--app-home <dir>`<br>`--expected-plan <id>`<br>`--follow`<br>`--no-follow`<br>`--max-tiles <count>`<br>`--pin <seats...>`<br>`--unpin <seats...>`<br>`--suppress <seats...>`<br>`--unsuppress <seats...>`<br>`--suppression-ms <ms>`<br>`--grace-ms <ms>`<br>`--focus-retention-ms <ms>`<br>`--json` |
|
||||
| `rig terminal views` | — | `--json` |
|
||||
| `rig terminal status` | — | `--provider <name>`<br>`--session <name>`<br>`--json` |
|
||||
| `rig terminal close <view>` | — | `--app-home <dir>`<br>`--json` |
|
||||
| `rig terminal status` | — | `--provider <name>`<br>`--session <name>`<br>`--app-home <dir>`<br>`--json` |
|
||||
| `rig terminal attach <session>` | — | `--read-only` |
|
||||
|
||||
See [named terminal sessions](../reference/terminal-sessions.md) to select a
|
||||
Herdr session per request, including session-bound previews and window reuse.
|
||||
herdr-app views (`--provider herdr-app`, plain `rig terminal open <view>` inside an answering herdr-app terminal versus `--window` for a desktop window, `close`, `--app-home`) are in
|
||||
[herdr-app](../reference/herdr-app.md).
|
||||
|
||||
### watchdog
|
||||
|
||||
@@ -588,9 +645,10 @@ 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>` | — | `--json` |
|
||||
| `rig workflow trace <instanceId>` | — | `--full`<br>`--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` |
|
||||
@@ -656,6 +714,9 @@ Only Claude and Codex tenures can be woken.
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig context` | — | — |
|
||||
| `rig context reconstruction` | — | — |
|
||||
| `rig context reconstruction inspect` | — | `--store <absolute-path>`<br>`--store-map <file>`<br>`--project <id>`<br>`--mission <id>`<br>`--slice <id>`<br>`--cwd <path>`<br>`--item <id>`<br>`--category <name>`<br>`--tag <name>`<br>`--check-sources`<br>`--max-bytes <bytes>`<br>`--json` |
|
||||
| `rig context reconstruction view` | — | `--store <absolute-path>`<br>`--store-map <file>`<br>`--project <id>`<br>`--mission <id>`<br>`--slice <id>`<br>`--cwd <path>`<br>`--item <id>`<br>`--category <name>`<br>`--tag <name>`<br>`--check-sources`<br>`--max-bytes <bytes>`<br>`--json` |
|
||||
| `rig context work-install` | — | `--project <id>`<br>`--mission <id>`<br>`--slice <id>`<br>`--deliver`<br>`--runtime <runtime>`<br>`--cwd <path>`<br>`--topology <ids>`<br>`--apply-skills`<br>`--json` |
|
||||
| `rig context trace` | — | `--rig <rig>` **required**<br>`--pod <pod>`<br>`--seat <seat>`<br>`--name <file>` **required**<br>`--json` |
|
||||
| `rig context compose` | — | `--out <ref>` **required**<br>`--from <files...>` **required** |
|
||||
@@ -1064,6 +1125,28 @@ off, draft-aware and hold modes, immediate refusal results and held-message insp
|
||||
|---|---|---|
|
||||
| `rig handover <seat>` | — | `--source <source>`<br>`--reason <reason>`<br>`--operator <address>`<br>`--dry-run`<br>`--json` |
|
||||
|
||||
A rebuild starts a new native conversation and primes it from durable artifacts. In the JSON result,
|
||||
`currentStatus.continuityOutcome` describes the executed source (`rebuilt`, `forked`, `fresh`, or unknown
|
||||
for a discovered successor); `sourceOutcome` lists the rebuild artifacts and gaps or the fork origin.
|
||||
The legacy `continuityTransferred` field is always `false`, not a transfer verdict. A complete binding
|
||||
does not prove a lineage write: `sideEffects.provenanceRecordWritten` reads back this handover's occupant
|
||||
tenure. If it is false, inspect the daemon log; do not repeat a handover whose seat is already bound.
|
||||
|
||||
For Codex rebuilds, `sourceOutcome.permissions` distinguishes an emitted predecessor policy, a saved
|
||||
successor selection, and an unverified fallback. A saved `--codex-permissions` envelope wins over the
|
||||
observed predecessor policy and retains the usual launch additions: workspace-write adds Git metadata,
|
||||
seat-folder and pod-state writable roots; read-only adds none. The result describes this launch policy,
|
||||
not an observed exact difference. A supported observed predecessor policy is carried without fresh
|
||||
directory additions when no newer saved selection replaces it.
|
||||
An unreadable or unsupported predecessor policy does not stop the rebuild; without a saved envelope,
|
||||
the ordinary launch settings apply and the result reports the missing comparison. Emitted arguments
|
||||
are not proof of the successor's native enforcement, and the named Codex config profile still loads.
|
||||
|
||||
`sourceOutcome.model.requested` is the seat's configured Codex model pin, or `null` for the runtime's
|
||||
configuration/default. A rebuild reapplies that choice rather than the predecessor's in-pane model;
|
||||
`predecessorCompared: false` explicitly reports that no native model comparison was performed. This
|
||||
reports launch intent, not the model actually used by the successor. Older daemons omit this object.
|
||||
|
||||
### startup-proof
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|
||||
@@ -8,7 +8,7 @@ applies-when: |
|
||||
read it before claiming a stub-agent scenario covers a behaviour.
|
||||
siblings: [arteries.md, README.md, codemap.md]
|
||||
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
|
||||
last-updated: 2026-10-08
|
||||
last-updated: 2026-11-09
|
||||
---
|
||||
|
||||
# Test layers: what to run before you push, and what each layer proves
|
||||
@@ -448,9 +448,12 @@ not as current status.
|
||||
|
||||
## Help wanted: command families without a behavioural scenario
|
||||
|
||||
> This list may be stale. It was taken from the `rig --help` tree of 0.6.3: 85 visible
|
||||
> top-level families. At `e8f0ab34`, `packages/cli/src/index.ts` registers 87 top-level
|
||||
> commands; `roster` and `telemetry` were added since and have no row yet. Before picking one up, check `packages/test-system/scenarios/`, the cases in
|
||||
> This list may be stale. The tables below cover the families that have a proposed first
|
||||
> check: at `b29a70f14`, the Commander tree returned by `createProgram()`
|
||||
> (`packages/cli/src/index.ts`) registers 92 top-level families, 87 of which have a row
|
||||
> below (7 of them marked partial); `roster` and `telemetry` now have rows too. These 5
|
||||
> families still have no row: `acp`, `community-harness`, `edge`, `environment`, `exec`.
|
||||
> Before picking one up, check `packages/test-system/scenarios/`, the cases in
|
||||
> `scripts/run-pr-scenarios.sh`, and the current `rig --help`.
|
||||
> One scenario doesn't cover a family's every option, sequence or platform. A `--help`
|
||||
> check only proves the command is discoverable. Existing unit tests are not counted here.
|
||||
@@ -522,6 +525,8 @@ Before you start, two honest constraints:
|
||||
| reads | `status` | Two rigs in different states. Status matches their exact identities and transitions. |
|
||||
| reads | `ps` | Two rigs with overlapping seat names. Rig and node filters return exactly the intended members. |
|
||||
| reads | `usage` | Deterministic stub telemetry. top and series respect seat and time bounds. Missing data isn't reported as zero. |
|
||||
| reads | `roster` | A scratch workspace with two rigs and a rosters folder: `rig roster find` returns the expected specialists, and an empty folder reports none without an error. |
|
||||
| reads | `telemetry` | Deterministic retained-metadata fixtures: one bounded page returns exactly the recorded rows, and a history gap is named rather than skipped silently. |
|
||||
| reads | `health` | Known fresh, stale and cleared findings. Scope, explain and diagnosis values match the fixture. |
|
||||
| continuity | `snapshot` | Snapshot two seats. Rig, membership and snapshot bytes survive a daemon restart. |
|
||||
| continuity | `restore` | Restore after a daemon and tmux reset. Seat identity and nonce answers survive. |
|
||||
|
||||
@@ -181,7 +181,11 @@ 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. `useGlobalEvents` reports whether that connection
|
||||
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
|
||||
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`,
|
||||
|
||||
@@ -13,7 +13,8 @@ The row's notes get, in order:
|
||||
- **progress,** about once a minute (tool calls so far, output so far); the full transcript is in the run's log file, named on the row;
|
||||
- **each permission request and its answer** (below);
|
||||
- **the outcome:** `completed`, `stopped` (for example `max_tokens`), `cancelled`, `failed` or `unknown`, with the
|
||||
agent's output;
|
||||
agent's output and the tokens and cost it reported, each labelled with where it came from (unknown when it reported
|
||||
none);
|
||||
- **what happened to the agent's processes** after the run ended.
|
||||
|
||||
The outcome is the agent's report, not a judgment of the work. The row stays open and yours: read the result, then
|
||||
@@ -62,6 +63,78 @@ shared one running; stop your own with its own command.
|
||||
- `failed`: the agent returned an error. The row keeps its original code, message and data.
|
||||
- `cancelled`, `stopped`: as above.
|
||||
|
||||
## Claude Code and Codex, through their ACP adapters
|
||||
|
||||
Both have maintained ACP adapters. What follows was checked on 2026-11-06 with OpenRig 0.7.8: one small file task per
|
||||
case, with `claude-haiku-5-5` and with `gpt-6.1-sol` at low effort. Other models weren't checked.
|
||||
|
||||
Install the adapters once; running them through `npx` adds about 10 seconds a turn:
|
||||
|
||||
```sh
|
||||
npm install --prefix <dir> @agentclientprotocol/[email protected] @agentclientprotocol/[email protected]
|
||||
export PATH="<dir>/node_modules/.bin:$PATH"
|
||||
|
||||
rig acp run <row> --cwd <work folder> -- env CLAUDE_CODE_EXECUTABLE=<your claude> ANTHROPIC_MODEL=<model> claude-agent-acp
|
||||
rig acp run <row> --cwd <work folder> -- codex-acp
|
||||
```
|
||||
|
||||
**Which versions run.** The binding note on the row names the adapter and its version.
|
||||
- `claude-agent-acp` 0.101.0 runs Claude Code through the Claude Agent SDK 0.3.318. With `CLAUDE_CODE_EXECUTABLE` set,
|
||||
it runs that `claude`, with that `claude`'s own login; the check ran Claude Code 2.1.310 this way. Without it, an npm
|
||||
install runs the Claude binary from the SDK's platform package (Claude Code 2.1.318, by the SDK's package metadata),
|
||||
which wasn't checked. Installing with `--omit=optional` leaves that binary out, and then, without
|
||||
`CLAUDE_CODE_EXECUTABLE`, the adapter refuses to start; it never falls back to a `claude` on PATH. The adapter also
|
||||
loads your Claude configuration, skills included.
|
||||
- `codex-acp` 2.5.0 depends on `@openai/codex` `^0.165.0`, and 0.165.0 was the only release in that range when this
|
||||
was checked. A later install can resolve a newer 0.165 release; `npm ls @openai/codex` in `<dir>` shows yours. It uses
|
||||
the machine's existing Codex login.
|
||||
- `@zed-industries/claude-code-acp` and `@zed-industries/codex-acp` are deprecated on npm in favour of these. The zed
|
||||
`codex-acp` 0.16.0's Codex was too old for the model in the check.
|
||||
|
||||
**Permissions.** Each agent keeps its own permission mode, and OpenRig adds no restriction. In the check, neither
|
||||
asked anything for file writes or shell commands: Claude started in `auto` mode and Codex in `workspace-write`. To
|
||||
have the agent ask, so that each request becomes a permission row:
|
||||
- **Claude:** give it a dedicated `CLAUDE_CONFIG_DIR` whose settings set `permissions.defaultMode` to `"default"`.
|
||||
- **Codex:** set `INITIAL_AGENT_MODE=read-only` in its environment. Approval and sandbox keys in `CODEX_CONFIG` didn't
|
||||
make it ask.
|
||||
|
||||
These are the settings the check used to make the agents ask. Whether to use them is the caller's choice; they're not a
|
||||
recommended default. Send permission rows to a seat that can be woken: a row to a `terminal` seat sets off the stuck
|
||||
sweep.
|
||||
|
||||
**Answers differ between the two.**
|
||||
- **Claude** offers `allow-once`, `allow-with-updates` (allow always) and `reject`. `reject` refuses that one action,
|
||||
and the turn carries on.
|
||||
- **Codex** offers `allow_once`, `accept_execpolicy_amendment` (allow always) and `cancel`. Its only refusal, `cancel`,
|
||||
ends the whole turn, and the outcome is `cancelled`.
|
||||
|
||||
**Continuing, and a session that doesn't exist.**
|
||||
- **Continuing:** `--load-session` continued an earlier session on a second row with both adapters.
|
||||
- **A made-up session id:** both refused it and started nothing, but they report it differently. Claude returns
|
||||
`-32002 Resource not found`, and Codex returns `-32603 Internal error` with "no rollout found" in its data. The row
|
||||
shows `failed` with the agent's own error. Neither code by itself means a missing session, so read the message.
|
||||
|
||||
**A completed turn isn't finished work.** In the check, Claude was asked to run a command and wait for it. It moved the
|
||||
command into a background task, said it would report when it finished, and ended the turn `completed`. The task died
|
||||
with the adapter, and its file never appeared. Check the work itself before you close the row. Beyond this one case,
|
||||
long and background work weren't checked.
|
||||
|
||||
**Cancel.** `rig acp cancel` was acknowledged in 20-30 ms for both, sent while the agent waited on a permission
|
||||
answer; a cancel mid-generation or mid-tool wasn't timed. Straight after, Claude's run reported `incomplete`
|
||||
with one process still running, which was gone a few seconds later; Codex's reported `settled`. An acknowledgement
|
||||
isn't a finished shutdown, and it says nothing about further spend.
|
||||
|
||||
**Usage and cost.** Claude's adapter reports token usage and a cost in USD; Codex's reports tokens only. Both are in
|
||||
the run's log file, not on the row.
|
||||
|
||||
**Not checked:**
|
||||
- long or multi-tool tasks, and background work;
|
||||
- concurrent runs;
|
||||
- `rig acp stop` with a real stop command;
|
||||
- an adapter that crashes mid-turn;
|
||||
- continuing after a daemon restart;
|
||||
- MCP servers.
|
||||
|
||||
## Notes for specific agents
|
||||
|
||||
- **OpenClaw:** `openclaw acp` is a bridge to a running OpenClaw Gateway, so start the Gateway first, then run, for
|
||||
@@ -70,8 +143,109 @@ shared one running; stop your own with its own command.
|
||||
- **Continuing a session:** `--load-session <id>` continues an earlier session when the agent advertises loading. If it
|
||||
doesn't, or the load fails, the run reports that and starts nothing new.
|
||||
|
||||
## Running it in the background: `rig exec`
|
||||
|
||||
`rig exec` runs the same single turn on a named harness, `claude` or `codex`, through that harness's ACP adapter, in a
|
||||
connector that outlives the command:
|
||||
|
||||
```sh
|
||||
rig exec harnesses # the adapter and native versions installed here, and how each differs
|
||||
rig exec run <row> --harness claude --plan # what would run, with versions; starts nothing
|
||||
rig exec run <row> --harness claude [--wait] # start it; --wait waits here for the outcome
|
||||
rig exec status <runId|row> # state, outcome, usage and cost, and what still runs
|
||||
rig exec cancel <runId>
|
||||
```
|
||||
|
||||
- **The connector owns the run.** Closing the terminal, or leaving `--wait` with Ctrl-C, doesn't stop it. Its record
|
||||
in `$OPENRIG_HOME/acp/runs/` holds its process and its state: `requested`, `started`, `finished` or `interrupted`.
|
||||
The row gets the same notes as with `rig acp run`.
|
||||
- **One run per row.** Running it again returns the row's run, still going or ended, instead of sending the task
|
||||
twice. `--again` starts another turn once that run has ended.
|
||||
- **Interrupted.** A run whose connector died without recording an outcome reads `interrupted`, outcome unknown, with
|
||||
one note on the row, the next time `status`, `run` or `--wait` looks at it. Whether any of the agent's processes
|
||||
remain is said there too.
|
||||
- **Missing.** With `--load-session`, a session reads `missing` only on the adapter's own evidence: for Claude,
|
||||
`-32002` naming that session; for Codex, its "no rollout found for thread id" error for that session. Any other error
|
||||
reads `failed`, with the agent's code and message, and no new session is started either way.
|
||||
- **The outcome is the agent's report.** `finished` with `completed` means the turn ended; accepting the work stays
|
||||
with you, on the row. `--wait` exits 0 for `completed`, 2 for any other outcome, and 3 when you stop waiting.
|
||||
Without `--wait`, a run that has already ended exits the same way, and one still going exits 0. Either way the code
|
||||
follows the row's run, including one another caller started.
|
||||
- **Permissions:** the agent keeps its own mode, and `rig exec` adds no policy; your environment passes through.
|
||||
`rig exec harnesses` lists what each harness reads.
|
||||
|
||||
`rig acp run` is unchanged: it stays in your terminal for the length of the turn.
|
||||
|
||||
## Limits
|
||||
|
||||
- One run is one turn for one row.
|
||||
- The client offers the agent no file-system or terminal methods; the agent works in its own environment.
|
||||
- Spend is whatever the agent's provider charges; OpenRig doesn't measure it.
|
||||
- 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.
|
||||
@@ -229,9 +229,10 @@ This gives access to the shared pool: skills such as `systematic-debugging`, `ve
|
||||
`development-team` and `test-driven-development`; the `openrig-core` plugin, which carries `openrig-user` and OpenRig's
|
||||
other skills; and runtime resources such as `claude-default-settings`, `claude-default-mcp`, `codex-default-config` and
|
||||
`claude-activity-hooks`. Agents select the ones they need via profile `uses`, for example
|
||||
`plugins: [shared:openrig-core]`. That selection projects the plugin's skills into the seat. For a Claude seat it also loads
|
||||
the plugin's hooks, through a `CLAUDE_CODE_PLUGIN_DIRS` the daemon sets for a seat that selected `openrig-core` (see
|
||||
`refocus-channel.md`); a seat that didn't select it gets neither.
|
||||
`plugins: [shared:openrig-core]`. For a Claude seat selecting the vendored `openrig-core`, the daemon loads the plugin
|
||||
itself through `CLAUDE_CODE_PLUGIN_DIRS`, including its skills and hooks, without projecting a second copy of its skills
|
||||
(see `refocus-channel.md`). Other selected plugin skills follow the projection rules below. A seat that didn't select
|
||||
`openrig-core` does not get its hooks through this selection.
|
||||
|
||||
---
|
||||
|
||||
@@ -287,7 +288,11 @@ Each array contains resource IDs. These can be:
|
||||
- **Qualified** (`shared:systematic-debugging`) — resolves against a specific imported spec's resources
|
||||
|
||||
The `uses` categories are: `skills`, `guidance`, `subagents`, `plugins`, `runtime_resources`. A `uses.hooks` key, even an
|
||||
empty list, is refused: hooks ship inside plugins.
|
||||
empty list, is refused: hooks ship inside plugins. Any other key selects nothing and gets a validation advisory, shown at
|
||||
launch and by `rig agent validate`, so a typo such as `skils` doesn't go unnoticed. A single value written in place of a
|
||||
category's list (`skills: vault-user`) is read as a one-item list, with an advisory suggesting list syntax, and resolves
|
||||
exactly like that list: when the profile is selected, a resource that isn't available fails that member's resolution with
|
||||
"not found in resource pool".
|
||||
|
||||
**Skills resolve more widely than other resources.** For Claude Code and Codex seats, a skill ID that no declared
|
||||
resource provides is looked up at launch in the project's runtime skill folder in the seat's cwd (`<cwd>/.claude/skills` or
|
||||
@@ -370,10 +375,10 @@ version.
|
||||
|
||||
| Category | Fields | Description |
|
||||
|----------|--------|-------------|
|
||||
| `skills` | `id`, `path` | Skill directories containing a SKILL.md. Projected by the runtime adapter. Claude and Codex seats get them in the seat's own storage, `<OpenRig home>/state/seats/<rig id>/<logical seat>/<runtime>/`, outside the project: Claude to `plugin/skills/<id>` in the seat's generated plugin, where they show as `openrig:<id>`, and Codex to `skills/<id>` beside the seat's skill catalog. A Claude or Codex seat without that storage gets `<cwd>/.claude/skills/<id>` or `<cwd>/.agents/skills/<id>`. Pi and OMP project to the seat's own agent directory under OpenRig's state. |
|
||||
| `skills` | `id`, `path`, `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. |
|
||||
| `guidance` | `id`, `path`, `target`*, `merge`* | Guidance files, merged as a managed block (see "Guidance Resources"). |
|
||||
| `subagents` | `id`, `path` | Subagent definitions. Claude copies them to `<cwd>/.claude/agents/`, Codex to `<cwd>/.agents/`; Pi and OMP don't project them. |
|
||||
| `plugins` | `id`, `source`, `plugin_type`* | Local plugin directories; selected with `uses.plugins`. `source.kind` must be `local`. `plugin_type` is `claude`, `codex` or `auto` (default): Claude projects a plugin to `<cwd>/.claude/plugins/<id>` when it is `claude`, or `auto` with a `.claude-plugin/plugin.json`; Codex projects to `<cwd>/.codex/plugins/<id>` when it is `codex`, or `auto` with a `.codex-plugin/plugin.json`. Pi and OMP don't project plugins. Neither Claude Code nor Codex reads skills from that plugin folder, so when a seat is instantiated a selected plugin's skills are also projected into the seat's skill folder under their plain names: the seat's own storage (see `skills`), or `.claude/skills/<name>` or `.agents/skills/<name>` for a seat without it. A folder already there with the same name, ignoring letter case, that OpenRig doesn't own is left alone; unless it already holds the same bytes, launch warns `plugin_skill_kept`. A skill the seat already selects by that name (from its profile, the System World or the project) keeps that source, an edited OpenRig-owned copy is kept with `plugin_skill_kept`, a retained plugin copy whose remembered source can no longer be projected is also kept with `plugin_skill_kept` (a seat selecting that plugin refreshes it at its next launch), and if the plugin skills can't be projected the seat launches without them and warns `plugin_skills_not_projected`. Hooks ship inside plugins. |
|
||||
| `plugins` | `id`, `source`, `plugin_type`* | Local plugin directories; selected with `uses.plugins`. `source.kind` must be `local`. `plugin_type` is `claude`, `codex` or `auto` (default): Claude projects a plugin to `<cwd>/.claude/plugins/<id>` when it is `claude`, or `auto` with a `.claude-plugin/plugin.json`; Codex projects to `<cwd>/.codex/plugins/<id>` when it is `codex`, or `auto` with a `.codex-plugin/plugin.json`. Pi and OMP don't project plugins. 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. |
|
||||
| `runtime_resources` | `id`, `path`, `runtime`, `type` | Runtime-specific resources. `runtime` and `type` are required. An entry whose `runtime` isn't the seat's runtime is skipped. Pi and OMP project none. |
|
||||
|
||||
*Fields marked with `*` are optional.
|
||||
@@ -419,6 +424,16 @@ Where a fragment's table is one the user already declares, the user's table
|
||||
wins: the managed table is dropped, their values are never merged, rewritten or
|
||||
overwritten, and the rest of the fragment still applies.
|
||||
|
||||
**A fragment can't install a named Codex profile.** Codex versions with named
|
||||
profiles read each one from its own file, `<CODEX_HOME>/<name>.config.toml`,
|
||||
with top-level keys, and won't load a profile while `config.toml` still holds a
|
||||
legacy `[profiles.<name>]` table. A fragment only adds tables to `config.toml`,
|
||||
so neither form works there. Create the profile file yourself and select it with
|
||||
the member's `codex_config_profile`; see "Codex: select sandbox and approvals
|
||||
together" in [getting-started.md](getting-started.md#codex-select-sandbox-and-approvals-together).
|
||||
Before a launch or restore, OpenRig checks that a selected profile loads, and for
|
||||
a legacy table it names the file to move the settings into.
|
||||
|
||||
### Resource Path Rules
|
||||
|
||||
- All resource paths must be safe relative paths (no `..` traversal, no absolute paths), except a plugin's
|
||||
@@ -427,6 +442,55 @@ overwritten, and the rest of the fragment still applies.
|
||||
- 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
|
||||
@@ -482,6 +546,7 @@ restore behavior, and the distinction between readiness and verified proof.
|
||||
9. All resource paths must be safe relative paths (a plugin's `source.path` excepted).
|
||||
10. Resource IDs must be unique within their category, and each `resources.<category>` must be an array.
|
||||
11. `runtime_resources` entries require `runtime` and `type` fields.
|
||||
11a. An unknown skill `on_restore` value produces an advisory naming the known values and falls back to `keep_saved`.
|
||||
12a. `resources.hooks` and `profiles.<name>.uses.hooks` are refused: "removed in plugin-primitive (Phase 3a)". Declare
|
||||
a plugin instead.
|
||||
12b. A plugin needs a unique non-empty `id`, a `source` object with `kind: local` and a non-empty `path`, and a
|
||||
|
||||
@@ -63,6 +63,33 @@ 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:
|
||||
@@ -394,6 +421,7 @@ 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)) |
|
||||
|
||||
|
||||
@@ -206,6 +206,13 @@ 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
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ This draft was published with **OpenRig 0.7.6** (tag [`v0.7.6`](https://github.c
|
||||
| Community launcher presets, RigSpec members and AgentSpec role/startup declarations | Implemented interfaces | They compose configuration. A launcher must implement its declared effects; successful parsing does not prove delivery or native behavior. |
|
||||
| `runner: true` with `launch_result: true` | Released in 0.7.6 | The declaration is retained at launch, and Core uses the runner identity/activity path. It does not supply a runner executable. |
|
||||
| `passes_model: true` | Released in 0.7.6 | Passes the declared model through `OPENRIG_MODEL`. It does not observe or verify the effective native model. Cursor and Hermes examples leave it off pending native qualification. |
|
||||
| The dsh/ACP input, cancellation and answered permission recipe (a person answers, and the default is permit) | OpenRig's side released in 0.7.8; runner side not qualified | OpenRig asks a person for a runner's permission requests and returns the answer. No end-to-end dsh integration is established, and the native verdict is pending. Native reopen capabilities and the tools-off case remain implementer questions. |
|
||||
| The dsh/ACP input, cancellation and answered permission recipe (a person answers, and the default is permit) | OpenRig's side released in 0.7.8; runner side shown on two runners, not qualified in general | OpenRig asks a person for a runner's permission requests and returns the answer. In 0.7.8's release testing on a Claude Haiku model, dsh-seat 0.3.0 (dsh 0.2.1-rc.2 over ACP) showed input, activity, cancel, exact resume and shutdown, and the dsh example runner showed a person's allow-once and reject-once; see the [runner contract](harness-runner-contract.md)'s status for what wasn't shown. Native reopen capabilities and the tools-off case remain implementer questions. |
|
||||
| Exact native resume via an author launcher | Existing interface; integration-specific evidence | A requested conversation must reopen exactly or fail honestly. One provider/harness result does not establish portability to another. |
|
||||
| Environment preparation, projection, artifact export and owned-resource cleanup | OpenShell reference driver released in 0.7.8; generic seam proposed | The platform chapter makes the responsibilities reusable. Core ships one maintained reference driver, OpenShell 0.1.6, as `rig environment`. It doesn't provide a generic platform driver or `DeploySpec` loader. |
|
||||
| `HarnessSpec`, `DeploySpec`, generic lifecycle fields and wake-on-demand declarations | Proposed vocabulary or unsupported configuration | They are not new accepted YAML keys. Use existing composition and an explicit author implementation. |
|
||||
|
||||
@@ -7,11 +7,11 @@ runner: true
|
||||
launch_result: true
|
||||
first_input: true
|
||||
limits:
|
||||
- written for dsh 0.2.1-rc.2 (npm @deepseek-ai/dsh) over ACP protocol 1, with Node 22 or later, and checked against a stand-in agent only
|
||||
- written for dsh 0.2.1-rc.2 (npm @deepseek-ai/dsh) over ACP protocol 1, with Node 22 or later; run with a real dsh on two stacks only, dsh's Claude route with a Claude Haiku model in 0.7.8's release testing (with that release's copy of this runner, for an allow-once and a reject-once), and dsh's pi-ai OpenRouter chat-completions profile with the model deepseek/deepseek-v4-flash on OpenRig 0.7.8 (for queue work, activity through a quiet tool call, an allow-once and a reject-once, and cancel); in both, the permission answers came from a test answerer registered as a person, not a live person; dsh's DeepSeek Messages adapter pointed at OpenRouter failed with HTTP 400 in the second, cause not established; no other dsh route or model is checked
|
||||
- each seat gets its own dsh home under $OPENRIG_HOME/state/dsh/<node>/home, holding a patch that turns off dsh's session-log and plugin-list uploads to DeepSeek; give it credentials (.credentials.yaml there) or a provider key in the seat's env; set DSH_SEAT_HOME in the member's env to use a home you prepared, and DSH_SEAT_BIN for another dsh binary (a member's env can't set OPENRIG_* names, and the runner doesn't read dsh's own DSH_HOME)
|
||||
- DSH_PERMISSION_MODE defaults to workspace-write; a permission request goes to the default person or the seat's override (rig seat set-permissions-to), is permitted with dsh's allow_once (else allow_always) when no one can be asked or OpenRig can't answer, and is refused and held as needs input only after --deny
|
||||
- allow_always creates a standing grant in dsh, which OpenRig doesn't record or revoke; the runner prefers allow_once, and where dsh keeps the grant and for how long isn't checked
|
||||
- resume isn't declared until a real dsh has reopened a conversation and recalled it; the runner reopens by the method dsh advertises and reports missing only on dsh's own not-found error, not when dsh's session list names the conversation; dsh answers -32602 for a missing conversation (and for one active elsewhere), so a missing dsh conversation reads unconfirmed
|
||||
- resume isn't declared; a real dsh has reopened a conversation after down, snapshot and up and recalled it, with resume added to a copy of this preset, but a missing conversation's refusal hasn't been run; the runner reopens by the method dsh advertises and reports missing only on dsh's own not-found error, not when dsh's session list names the conversation; dsh answers -32602 for a missing conversation (and for one active elsewhere), so a missing dsh conversation reads unconfirmed
|
||||
- a prompt whose whole text is /abort can't be sent; a Ctrl-C byte inside a paste also aborts; without a TTY, input is read line by line; this isn't a binary-safe channel
|
||||
- one prompt at a time; a message that arrives mid-turn waits for the next turn
|
||||
- the seat's model isn't passed; dsh uses its own configured model
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Install as $OPENRIG_HOME/community-harnesses/omo.yaml.
|
||||
# OmO Native (npm omo-ai 5.1.48) on the senpi engine — a Pi fork speaking Pi's RPC protocol.
|
||||
# With protocol: pi-rpc, OpenRig runs OmO through its Pi runner: structured activity, prompt/steer,
|
||||
# transcript isolation, and session file resumption.
|
||||
format: 1
|
||||
name: omo
|
||||
command: omo
|
||||
maintainer: Your name or team
|
||||
protocol: pi-rpc
|
||||
resume: true
|
||||
limits:
|
||||
- checked with omo 5.1.48 (senpi engine 2026.10.25-2)
|
||||
- credentials resolved via omo's agent directory or models.json
|
||||
- models declared via provider/id format (e.g. zai/glm-5.3-flash, openrouter/...)
|
||||
@@ -53,6 +53,7 @@ startup_assisted: true # optional: startup submission needs a person's
|
||||
first_input: true # optional: launcher takes the first turn from OPENRIG_FIRST_INPUT (below)
|
||||
# runner: true # optional, needs launch_result: the launcher reports the seat's activity (below)
|
||||
# passes_model: true # optional: the launcher takes the seat's declared model from OPENRIG_MODEL (below)
|
||||
# launch_wrapper: true # optional: a runner applies OPENRIG_LAUNCH_WRAPPER to its native child (below)
|
||||
```
|
||||
|
||||
- **`command`** is a single program and its arguments. Shell syntax such as `|`, `&&`, `;`, redirections or `$(…)`
|
||||
@@ -164,6 +165,10 @@ and reports what it's doing, as OpenRig's own Pi runner does. The seat's activit
|
||||
unavailable.
|
||||
- **Input is as for any seat.** A send pastes the text and presses Enter, so a runner should submit everything up to that
|
||||
Enter as one prompt.
|
||||
- **`launch_wrapper: true` declares wrapper support and requires `runner: true`.** A member with a wrapper and a runner preset without this flag is refused at preflight and launch (not schema validation):
|
||||
`launch_wrapper unsupported: the community preset "<runtime>" runs a runner without launch_wrapper: true; no agent started. Add launch_wrapper: true to the preset if its runner applies OPENRIG_LAUNCH_WRAPPER, or remove launch_wrapper from this member.` See the
|
||||
[harness and runner contract](harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch)
|
||||
for the environment handoff, native-child execution and refusal requirements.
|
||||
|
||||
**What the runner posts.** The runner posts each event to `POST /api/activity/hooks` with the local activity hook token,
|
||||
one at a time and in order, without making the agent wait. It finds the address and token in `OPENRIG_URL` (or
|
||||
@@ -188,6 +193,37 @@ one at a time and in order, without making the agent wait. It finds the address
|
||||
- **A runner's full duties** (liveness, input, cancellation and permissions) are in the draft
|
||||
[harness and runner contract](harness-runner-contract.md).
|
||||
|
||||
## Pi-compatible RPC harnesses (`protocol: pi-rpc`)
|
||||
|
||||
For a community harness that is a fork of Pi or speaks Pi's CLI and JSONL RPC protocol (such as OmO Native on senpi or Prime Agent), set `protocol: pi-rpc`.
|
||||
|
||||
```yaml
|
||||
format: 1
|
||||
name: omo
|
||||
command: omo
|
||||
maintainer: Your name or team
|
||||
protocol: pi-rpc
|
||||
resume: true # only after you've checked a real reopen
|
||||
limits:
|
||||
- checked with omo 5.1.48 (senpi engine 2026.10.25-2)
|
||||
```
|
||||
|
||||
- **OpenRig runs the harness through its existing Pi runner.** Instead of screen scraping, the harness runs under `pi-runner` inside the seat's pane. The runner provides input buffering, paste framing, prompt/steer RPC queueing, clean transcript mirroring, and typed activity hook reporting to the daemon.
|
||||
- **`command` is the executable.** OpenRig resolves `command` (e.g. `omo` or `/usr/local/bin/senpi`) against the seat pane's environment, resolving symlinks and version-manager shims (such as `mise`) safely.
|
||||
- **Session identity and resume come from the runner.** Transcripts and sessions are persisted as JSONL files under `$OPENRIG_HOME/state/pi/<session>/sessions/`, and the fork's `get_state` reports the session file, so `launch_result` and `names_conversation` aren't needed.
|
||||
- **With `resume: true`** declared, resuming or restoring a seat continues that exact session file via `--session <file>`. Without it, the preset is launch-only; a restore that would resume its saved conversation waits for a fresh-start decision.
|
||||
- **A fork that reports no session file** reads as needing attention, and the launch doesn't read ready.
|
||||
- **A resume that comes up on another session file** is refused, and that file's identity isn't recorded, so the seat keeps the one it asked for. The seat reads as needing attention.
|
||||
- **Launch arguments a fork must accept.** When spawned under `pi-runner`, the executable named by `command` must accept Pi's core CLI arguments:
|
||||
- `--mode rpc`
|
||||
- `--session-dir <dir>`
|
||||
- `--name <session-name>`
|
||||
- `--approve` or `--no-approve` (depending on seat trust)
|
||||
- And conditionally when configured or resuming/forking: `--model <provider/id>`, `--thinking <level>`, `--session <sessionFile>`, or `--fork <sessionFile>`.
|
||||
Forks that reject these flags cannot currently run with `protocol: pi-rpc`.
|
||||
- **Skills, guidance, and model passthrough.** Skills are projected into `<agentDir>/skills/` and managed guidance into `AGENTS.md`. For a fork that reads Pi's variables (`PI_CODING_AGENT_DIR`, etc.), declared models are passed as `--model provider/id` with provider API key allowlisting, and effort is passed as `--thinking <level>`. Usage is not reported for a fork, and extension dialogs are handled as Pi's (cancelled when an extension requests interactive confirmation in a managed pane).
|
||||
- **Mutual exclusions:** `protocol: pi-rpc` manages session identity and activity through the Pi runner; it cannot be combined with `runner: true`, `launch_result: true`, `names_conversation: true`, `startup_assisted: true`, or `first_input: true`.
|
||||
|
||||
## An example: Hermes
|
||||
|
||||
[`community-harness-examples/hermes/`](community-harness-examples/hermes/) has a preset and a small launcher for Hermes
|
||||
@@ -310,9 +346,27 @@ Its [README](community-harness-examples/dsh/README.md) has the setup and a check
|
||||
and maintain them as your own.
|
||||
|
||||
The launcher is the contributor's code, in the contributor's package. OpenRig runs no code from a preset and loads no
|
||||
module into its daemon. A preset that sets `launch_result`, `names_conversation`, `startup_assisted`, `first_input`, `runner` or `passes_model` is refused by an older
|
||||
module into its daemon. A preset that sets `launch_result`, `names_conversation`, `startup_assisted`, `first_input`, `runner`, `passes_model` or `launch_wrapper` is refused by an older
|
||||
OpenRig that doesn't know the field.
|
||||
|
||||
## Community-maintained runner: nodecode (@smota)
|
||||
|
||||
A runner preset for [nodecode](https://nodecode.ai), a self-modifying Common Lisp agent with a headless JSON-lines mode
|
||||
(`nodecode rpc`). Listed here so it can be found; it isn't certified, shipped with OpenRig or supported by OpenRig's maintainers.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Maintainer | [@smota](https://github.com/smota) |
|
||||
| Repository | [smota/openrig-nodecode](https://github.com/smota/openrig-nodecode), Apache-2.0, no npm dependencies |
|
||||
| Tested with | Unreleased OpenRig `main` at `7078c754d` (its package version reads 0.7.1), built from source without fork-only commits; nodecode `0.1.13+98dd6dc80`; Node 22.23.4. Not tested on a released OpenRig (none knows `launch_wrapper`). |
|
||||
| Needs | an OpenRig that knows `launch_wrapper` (this file documents it). OpenRig 0.7.10 and earlier refuse the preset with "unknown field launch_wrapper". |
|
||||
| Test record | connector `d8dd4e6`: 95 unit checks (fake and native nodecode), `npm run test:live` (6 steps against the author's daemon built from that `main`) and an opt-in native permission-question proof with a seeded `:NOBODY` failure; results are the author's, not reproduced by OpenRig |
|
||||
| Declares | `launch_result`, `runner`, `resume`, `first_input`, `passes_model`, `launch_wrapper` |
|
||||
| Resume | `nodecode rpc <session>`; a session the gateway doesn't know is refused with `error: "missing"` (exit 3) and never replaced. The installer adds `--resume-policy last-completed`: when the session's last turn failed or was cancelled, the runner moves the head back to the last completed turn before taking input, so a resumed seat continues from its last good state; `--resume-policy head` (the bare runner's default) resumes at the head as left. Pass either flag to `scripts/install-preset.mjs` to choose. |
|
||||
| Reports | `active`, `PreToolUse`, `Stop` and `session_identity` to `/api/activity/hooks`; permission questions through `/api/activity/permission-requests` |
|
||||
| Fork commits | none needed. Stock `main` accepts the runner's `session_identity` report (the hook answers ok and the identity event is published) but does not persist a community resume token from it; the seat's identity reads unconfirmed, as for any community harness. Two fork-only daemon commits (`be17fa528`, `cbb109b62`) add that plus model and context readings, and are not part of this entry. |
|
||||
| Limits | no turn cancel; permissions bridged from nodecode's `organism.questions` only, and that proof is driven by a model prompt, so it is opt-in; a question reaches OpenRig only when every choice reads as allow or reject (once or always), others are answered in the pane by typing the exact choice, and only the first four choices are offered through OpenRig; model, provider and effort apply to fresh sessions, a resumed one stays pinned; its gateway keeps running after `rig down`; the installed preset runs each seat in its own nodecode profile (`--seat-profile auto`, sharing the operator's memories and skills), while the bare runner uses the operator's organism unless `--profile` is set; `node` must be on the pane's `PATH` |
|
||||
|
||||
## Pass the first startup input at launch
|
||||
|
||||
A format-1 preset may set `first_input: true` (a boolean, default false). OpenRig composes the same initial identity turn it would otherwise paste, including the first merged text file, durable-obligation guidance and any selected orientation challenge. Before launch it writes that text to a per-attempt 0600 file under its own `state/first-input/` folder and passes the path as `OPENRIG_FIRST_INPUT`. The typed launch command carries the path, not the text; an argument-based launcher still puts the text in the native harness’s argv.
|
||||
|
||||
@@ -51,7 +51,7 @@ in the case named:
|
||||
The daemon reads two tokens from its environment at start:
|
||||
|
||||
- **`OPENRIG_AUTH_BEARER_TOKEN`** checks Mission Control's write routes (`POST /api/mission-control/action` and
|
||||
`/notifications/test`), the `/api/hosts` writes (`add`, `update`, `remove`, `pair`), and the `/api/rig-mode`
|
||||
`/notifications/test`), the `/api/hosts` writes (`add`, `update`, `remove`, `pair`, `pair-decision`), and the `/api/rig-mode`
|
||||
binding writes. `rig host pair` hands this token to the paired machine, so a daemon that will be paired with
|
||||
needs it set (see `rig host pair` in [the command reference](../as-built/cli-reference.md)).
|
||||
- **`OPENRIG_TERMINAL_BEARER_TOKEN`** checks every `/api/transport` and `/api/compaction` route, `POST
|
||||
@@ -61,18 +61,23 @@ The daemon reads two tokens from its environment at start:
|
||||
|
||||
A route whose token isn't set lets every request through. Send a token as `Authorization: Bearer <token>`; the
|
||||
`GET /api/terminal/<name>` routes, the websocket included, also take `?token=<token>`. The CLI sends the terminal token from `OPENRIG_TERMINAL_BEARER_TOKEN`,
|
||||
or else from the file `$OPENRIG_HOME/terminal-token`, which the person writes; the daemon doesn't create it.
|
||||
or else from the file `$OPENRIG_HOME/terminal-token`, which the person writes; the daemon doesn't create it. `rig host
|
||||
approve|deny` and the TUI's answers to asks send `OPENRIG_AUTH_BEARER_TOKEN` from their environment (`rig host` also
|
||||
takes `--bearer`), and otherwise the terminal token; refused for want of it, they say what to set.
|
||||
|
||||
A wrong or missing token gets HTTP 401. The routes that check `OPENRIG_AUTH_BEARER_TOKEN`, and `/api/transport`
|
||||
and `/api/compaction`, answer `{ "error": "unauthorized", "message", "what_failed", "why_it_matters", "what_to_do" }`.
|
||||
The `GET /api/terminal/<name>` routes, the websocket included, answer `{ "error": "unauthorized", "hint" }`. A token
|
||||
doesn't get past the browser boundary's 403 refusals, which [Browser access](browser-access.md) describes.
|
||||
The `GET /api/terminal/<name>` routes, the websocket included, answer `{ "error": "unauthorized", "hint" }`. A valid configured bearer token in the Authorization header can pass the browser boundary's target-name check
|
||||
for a well-formed but unaccepted Host. It cannot pass a malformed Host or a refused Origin; see
|
||||
[Browser access](browser-access.md).
|
||||
|
||||
## Event streams
|
||||
|
||||
All three are server-sent events (`text/event-stream`):
|
||||
All of them are server-sent events (`text/event-stream`):
|
||||
|
||||
- **`GET /api/events`**: the daemon's event log. `?rigId=` limits it to one rig. Each event carries `id: <seq>`.
|
||||
- **`GET /api/events`**: the daemon's event log, stored events only. `?rigId=` limits it to one rig. Each event
|
||||
carries `id: <seq>`. `view.changed` is not stored, so it does not appear here, live or replayed; `view.changed`
|
||||
rows stored by older versions still replay until an events retention (a separate change) removes them.
|
||||
Without `Last-Event-ID`, `?replay=<n>` first sends the newest `n` stored events (`0`: none, live events only);
|
||||
`?replay=all`, no `replay`, or any other value sends the whole log first. A `Last-Event-ID` header (what
|
||||
`EventSource` sends when it reconnects) sends the events after that id instead, whatever `replay` says.
|
||||
@@ -80,6 +85,9 @@ All three 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`.
|
||||
@@ -179,7 +187,8 @@ 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.
|
||||
`scopeIgnored` when a scope wasn't applied. The instance keeps `rootObjective`, which every instance read returns
|
||||
(`/api/workflow/list`, `/api/workflow/<id>` and its `trace`). An instance started before it was kept reads `null`.
|
||||
- **`GET /api/provider/usage`** returns `{ hostUsage }`, this host's usage rows (`host: "local"`, a `provider`
|
||||
and a `state`). A Claude row's `windows` are the seats' status-line readings, one entry per reading, each with
|
||||
its `window`, `usedPercent`, `resetsAt`, `asOf` and `seatSession`. A Codex row has `windows: []`. Treating the
|
||||
|
||||
@@ -44,13 +44,15 @@ All other edge kinds (`can_observe`, `collaborates_with`, `escalates_to`) do NOT
|
||||
|
||||
`delegates_to` also names a seat's orchestrator for the queue: the source of the `delegates_to` edge into a seat. The
|
||||
wake ladder escalates an unacknowledged row to that orchestrator, and stuck-row findings and recovery go to it. A seat
|
||||
with no incoming `delegates_to` edge has no default orchestrator. `spawned_by` plays no part here.
|
||||
with no incoming `delegates_to` edge has no default orchestrator. A seat with several incoming `delegates_to` edges has
|
||||
no single orchestrator either: its recovery goes to its declared `escalates_to` destination, or keeps the existing
|
||||
fallback, and the recovery says the parents were ambiguous. `spawned_by` plays no part here.
|
||||
|
||||
### Fallback escalation across rigs
|
||||
|
||||
A seat with **no declared `delegates_to` parent** may declare one `escalates_to` destination. The wake ladder and
|
||||
stuck sweep use it for new recovery rows; they do not move the original obligation or reassign an already-open recovery. A stopped or unbound declared
|
||||
parent still takes precedence, preserving the existing parent route and its fallback.
|
||||
A seat with **no declared `delegates_to` parent, or several**, may declare one `escalates_to` destination. The wake ladder and
|
||||
stuck sweep use it for new recovery rows; they do not move the original obligation or reassign an already-open recovery. A
|
||||
single stopped or unbound declared parent still takes precedence, preserving the existing parent route and its fallback.
|
||||
|
||||
```yaml
|
||||
edges:
|
||||
|
||||
@@ -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; or
|
||||
- **a person**, registered with `rig gateway human add`, with or without a connector binding (such as Slack or an HTTP loopback [relay](gateway-relay.md)); or
|
||||
- **a shared channel**: one vetted Slack channel registered as one participant, `<id>@external`, not a person per
|
||||
sender.
|
||||
|
||||
@@ -22,6 +22,9 @@ 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`,
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
# 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` (in `push` mode) or holding the decision for connector pull (in `pull` mode).
|
||||
- Each logical decision freezes its complete `relayEnvelope` onto the parent `DispatchBuffer` entry before the first delivery pass, with no self-imposed outbound 64 KiB cap or multi-part splitting.
|
||||
- **Push mode (`OPENRIG_RELAY_MODE=push`, default)**:
|
||||
- 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`.
|
||||
- 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.
|
||||
- **Pull mode (`OPENRIG_RELAY_MODE=pull`)**:
|
||||
- Client-only connectors that do not bind a listening HTTP server set `OPENRIG_RELAY_MODE=pull`. A pull profile is ready with `OPENRIG_RELAY_INBOUND_TOKEN` alone (`OPENRIG_RELAY_URL` and `OPENRIG_RELAY_OUTBOUND_TOKEN` are optional and unused in pull mode).
|
||||
- Outbound decisions remain in `DispatchBuffer` awaiting connector pull and are never treated as failed push destinations (`relay-owner-notification-transport-failed` is not recorded). While the outbox lists a notice (staged in `DispatchBuffer` and `relay-attempted-decisions.jsonl` for its `connectorRef`), it is not a missing post for up to **24 hours** (`PULL_OUTBOX_NEVER_POSTED_WINDOW_MS`) and reads `never-posted` (`undelivered-human-notice`) if still unacknowledged after that. A notice the outbox does not list (never staged, staging failed, or its `DispatchBuffer` or seen stores are unreadable, so the outbox answers `503`) uses the usual 120-second window (`NEVER_POSTED_WINDOW_MS`). This is reporting only: nothing is held, refused or re-sent.
|
||||
- The connector lists its pending complete envelopes via `GET /api/gateway/relay/outbox` (read-only; no consumer leases, read receipts, or required polling cadences) and acknowledges durable custody via `POST /api/gateway/relay/outbox/ack`.
|
||||
- An acknowledgment on `POST /api/gateway/relay/outbox/ack` has the exact same custody meaning as a push `2xx`: OpenRig records `relay-owner-notification-accepted` (closing informational `update` items as delivered), marks its local outbound seen/delivered guards, and drains the decision from `DispatchBuffer`. Duplicate acknowledgments reconcile idempotently (`alreadyAccepted: true`), and retries after a partial local write failure finish the remaining receipt and drain sequence.
|
||||
- **Custody boundary (both modes)**: Bridge acceptance (via push `2xx` or pull `POST /api/gateway/relay/outbox/ack`) confirms durable custody of the complete envelope by the connector only (projected as `accepted` / `"Accepted by bridge"`, whereas Slack posts use `posted` / `"Delivered"`). It does not mean posted, seen, answered, or approved.
|
||||
|
||||
### 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
|
||||
|
||||
**Push mode (`OPENRIG_RELAY_MODE=push`, default when omitted):**
|
||||
```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
|
||||
```
|
||||
|
||||
**Pull mode (`OPENRIG_RELAY_MODE=pull`):**
|
||||
```ini
|
||||
OPENRIG_RELAY_MODE=pull
|
||||
OPENRIG_RELAY_INBOUND_TOKEN=sec_inbound_token_value
|
||||
```
|
||||
|
||||
- `OPENRIG_RELAY_MODE`: Optional mode selector (`push` or `pull`; defaults to `push` when omitted). Any other value is rejected as `not-ready`.
|
||||
- `OPENRIG_RELAY_URL`: Required in `push` mode; ignored/unused in `pull` mode (`endpointUrl: null`). When required, 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`: Required in `push` mode; ignored/unused in `pull` mode. Non-empty Bearer token sent by OpenRig on outbound HTTP `POST` requests to the bridge.
|
||||
- `OPENRIG_RELAY_INBOUND_TOKEN`: Non-empty Bearer token required in both `push` and `pull` modes to authenticate connector requests (`GET /api/gateway/relay/outbox`, `POST /api/gateway/relay/outbox/ack`, and `POST /api/gateway/relay/inbound`). 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 `push` 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>` or `GET /api/gateway/relay/outbox`)
|
||||
|
||||
```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`).
|
||||
|
||||
### Pull Outbox Endpoints (`GET /api/gateway/relay/outbox` & `POST /api/gateway/relay/outbox/ack`)
|
||||
|
||||
Both endpoints require a direct loopback TCP peer (`127.0.0.1/8` or `::1`), reject browser cross-origin headers (`403`), and authenticate the caller via `Authorization: Bearer <OPENRIG_RELAY_INBOUND_TOKEN>` (`401`). If the human registry, `DispatchBuffer` (`pendingStrict()`), or outbound `SeenStore` cannot be read, both return `503` (`human_registry_unavailable`, `dispatch_buffer_unavailable`, `seen_store_unavailable`, `queue_store_unavailable`, or `receipt_write_failed`) rather than returning an empty `deliveries` array.
|
||||
|
||||
- **`GET /api/gateway/relay/outbox?limit=<1..200>`** (default `50`): Returns `{ "ok": true, "connectorRef": "<ref>", "deliveries": [ <RelayOutboundEnvelope>, ... ] }` with complete pending envelopes from `DispatchBuffer` belonging to the authenticated `connectorRef` in FIFO buffer order. Strictly read-only (no leases or read receipts).
|
||||
- **`POST /api/gateway/relay/outbox/ack`**: Closed JSON object `{ "deliveryId": "<decisionId>" }` accepting any non-empty string `deliveryId` the outbox can list, within the 64 KiB request-body size bound (`413 payload_too_large`); unknown fields or an empty or non-string `deliveryId` return `400 invalid_envelope`. Finishes any remaining `relay-owner-notification-accepted` transition receipts, marks `slack-outbound-seen.jsonl` and `relay-delivered-decisions.jsonl`, releases the in-memory outbound poll guard, and drains `deliveryId` from `DispatchBuffer`. Returns `200 { "ok": true, "deliveryId": "<id>", "connectorRef": "<ref>", "alreadyAccepted": false }` (`"alreadyAccepted": true` on duplicate ack for the same `connectorRef`, or `404 unknown_delivery` if unknown or owned by another connector; duplicate and unknown acks are answered from `relay-delivered-decisions.jsonl` and `relay-attempted-decisions.jsonl`, and the transition receipt scan runs only when those disagree). On any `503` partial failure, a retry with the same `deliveryId` finishes the remaining receipt and drain steps.
|
||||
|
||||
### 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>` accepts either a full binding specification string or `unset` to remove that binding slot:
|
||||
> - **One-primary rule:** No replacement primary is chosen silently. If removing a `primary` binding would leave other bindings, the command refuses. To replace the primary in place, set its slot to the new primary spec; to promote an existing secondary binding, first save that binding's full spec (`kind:connectorRef:secretsRef:role[:handle=<id>]`) from `rig gateway human show <entityId> --json`, then unset that secondary slot, then find the primary's current slot in `remainingBindings` (or `rig gateway human show <entityId> --json`, since the primary's slot shifts down when a lower-index secondary is removed) and set it to the saved spec with role `primary` (promotion replaces the former primary binding; its connector reference and inbound handle are not retained as a secondary); to remove every binding, unset the secondary bindings first using their current slot indexes from `remainingBindings` or `rig gateway human show <entityId>`, then finish at `binding.0` (once all secondaries are gone, the primary is at `binding.0`).
|
||||
> - **Shifted slot indexes:** Removal returns `remainingBindings` with their new `binding.<n>` slot numbers (the primary's slot shifts down too when a lower-index secondary is removed). Because indexes shift down after removal, do not blindly retry `binding.0 unset`; inspect `remainingBindings`, the operation receipt, or `rig gateway human show <entityId>` before retrying.
|
||||
> - **What removal does:** Removing a binding keeps the person, their preferences, their other bindings, and their open questions. Removing the last binding makes the current delivery episode inactive, so new missing-post findings stop (an already-open recovery item for an undelivered notice may remain); the current delivery-outcome display may disappear too, while historical receipt notes remain. It does not answer, close, or cancel open questions, cancel or reroute already-prepared deliveries, or retract anything a bridge has already accepted. A person with no binding needs a daemon from the release that accepts one (v0.7.9+); do not roll back past it while any fragment has `connectorBindings: []`.
|
||||
|
||||
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
|
||||
To remove a connector binding while keeping the participant, their preferences, their other bindings, and their open questions, unset the binding slot:
|
||||
```bash
|
||||
rig gateway human set alice binding.0 unset --reason "retire relay bridge"
|
||||
```
|
||||
Before removing a participant altogether, 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`.
|
||||
@@ -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.9/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
|
||||
The same command without `--dry-run`,
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.9/scripts/install.sh | sh`, installs the
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/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
|
||||
@@ -270,6 +270,17 @@ opt in with
|
||||
`rig config set terminal.inherit_tmux_environment true`. Setup points out this option
|
||||
when it detects custom tmux configuration; it never enables it automatically. The setting
|
||||
applies to future launches and successors, not running agents or your personal tmux server.
|
||||
|
||||
Managed Codex seats run tool commands in a non-login shell: OpenRig passes Codex
|
||||
`-c allow_login_shell=false`. Tool commands inherit the environment their pane's shell set up,
|
||||
OpenRig's command folder included, so plain `rig` works however OpenRig was installed.
|
||||
Inherited setup isn't re-run for each command. Setup that lives only in login or rc files, such as
|
||||
lines in `~/.bashrc` that your login file doesn't source, or a tmux `default-command` that doesn't
|
||||
start a login shell, may be missing from tool commands, and so may shell functions and aliases.
|
||||
To defer to Codex's own setting instead, run `rig config set launch.codex_login_shell true`. That
|
||||
omits OpenRig's override rather than forcing login shells, and a login file that sets `PATH`
|
||||
outright can then hide `rig`. It applies at each seat's next launch; a restart in place keeps
|
||||
the running choice.
|
||||
If the scoped environment cannot be prepared, launch keeps its previous environment and
|
||||
reports a warning in fresh launch results and the daemon log. For handovers, check
|
||||
that log for this warning. This is not a promise that shell startup cannot add or override values.
|
||||
@@ -805,6 +816,19 @@ 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>
|
||||
@@ -836,7 +860,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 and Claude Code, single-pane seats.** Startup slash commands aren't
|
||||
- **Codex, Claude Code and Pi, single-pane seats.** Startup slash commands aren't
|
||||
run by a switch; the dry run lists them.
|
||||
|
||||
## Stop your teams
|
||||
@@ -871,9 +895,12 @@ 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. |
|
||||
| `rig status`, `rig terminal open` or `rig daemon start --wait-for-kernel` says a seat "needs attention" or "is waiting on a person" | The daemon flags that seat, and the line gives its recorded reason: a startup that failed or stopped at a sign-in, trust or other gate, a held message, a missing required file, or a question. "Is waiting on a person" means the daemon sees a live question you can answer in that seat's terminal. Look at it with `rig capture <seat>` or in its tile, then follow the row above that matches its message. "Couldn't check" means the check itself failed; it says nothing either way. |
|
||||
| A Codex seat, including the kernel's operator, answers every turn with a model error such as "… not supported when using Codex with a ChatGPT account" | Its Codex sign-in works, but that account can't run the model; the kernel picks its runtimes from the providers chosen at setup and their sign-ins, not from which models an account can run. If Claude Code is signed in, move just that seat: `rig seat set-runtime <seat> --runtime claude-code --reason "<model> isn't available to this Codex account"` (`--dry-run` first). Otherwise give it a model the account can run with `rig seat set-model <seat> --model <id> --reason "…"`, then start it again with `rig seat launch <seat> --fresh --stop --reason "…"` and check the model it reports. |
|
||||
| Kernel seats fail within a second of the first start, and tmux reports "server exited unexpectedly" | Check that your tmux can capture a pane, then start the failed seats again; see the tmux check below this table. |
|
||||
| Shared terminal is absent | Check the kernel's `operator.human` terminal separately from its agents; recover only that seat as described below. Standalone `rig tui` remains available while resolving it. |
|
||||
| Viewing terminal was closed | Open the conversations again with `rig terminal open saved:kernel --window`; `rig tui --shared` is only the dashboard fallback if that window cannot open. Do not relaunch the team. |
|
||||
@@ -1150,8 +1177,15 @@ rig up ./openrig-specs/rigs/launch/starter/rig.yaml --cwd .
|
||||
|
||||
OpenRig's `permission_policy: builtin:yolo` setting selects
|
||||
`-s danger-full-access -a never` on fresh, resume and fork launches, replacing
|
||||
the named-profile argument. The profile recipe above remains useful when you
|
||||
want to maintain those choices in native configuration. The legacy
|
||||
the named-profile argument. That drops the whole profile, not only its sandbox
|
||||
and approval settings. To keep a named profile with full access, put full access
|
||||
in the profile itself and leave `permission_policy` absent, as in the recipe
|
||||
above. To keep it with a workspace-write or read-only posture that OpenRig
|
||||
records, use the member's `codex_permissions` (see
|
||||
[per-member Codex permissions](rig-spec.md#per-member-codex-permissions)):
|
||||
OpenRig passes `-p` and adds that envelope's flags, and for that seat the
|
||||
envelope replaces a policy posture such as `builtin:yolo`. Full access isn't an
|
||||
envelope. The legacy
|
||||
environment-only `OPENRIG_YOLO=1` path remains sandbox-only when no resolved
|
||||
policy is present. A standalone `codex --yolo` command is not an OpenRig setting.
|
||||
At full access, Codex can show its full-access and GPT-5.1 migration notices at
|
||||
|
||||
@@ -6,13 +6,30 @@
|
||||
> - **Released in 0.7.8:** seat activity grants (section 4), and OpenRig's side of the answered permission path
|
||||
> (section 7).
|
||||
> - **Runner-author behavior, not yet qualified:** the input, cancellation and permission recipe in sections 5-7, and
|
||||
> the runner-side shutdown obligation in section 9. A dsh port implementing this recipe is not qualified.
|
||||
> the runner-side shutdown obligation in section 9. Two runners have shown parts of it natively (below); no
|
||||
> runner is qualified beyond that.
|
||||
> - **Existing interfaces with author obligations:** launch-result identity, exact resume, first input and model
|
||||
> handoff. These interfaces do not establish that a particular runner implements them correctly.
|
||||
> - **Unchanged:** plain community presets and the built-in runtimes (Claude Code, Codex, Pi, OMP, terminal).
|
||||
>
|
||||
> No harness has been run end to end under this contract yet, and the native verdict is pending. The first candidate
|
||||
> is dsh over ACP.
|
||||
> **What has run natively, in 0.7.8's release testing, against a Claude Haiku model:**
|
||||
> - **dsh-seat 0.3.0**, an outside runner for dsh 0.2.1-rc.2 over ACP: launch and first input, useful queue work, a
|
||||
> multiline message with a `/abort` line inside it delivered as one prompt, activity that stayed working through a
|
||||
> 52-second quiet tool interval and then read idle, cancel by a bare `/abort` typed in the pane, and the same
|
||||
> conversation resumed by `session/resume` after `rig down --snapshot` and `rig up`, with the runner and its child
|
||||
> gone after the managed down.
|
||||
> - **The dsh example runner** (`community-harness-examples/dsh/`): dsh's native `session/request_permission` reached
|
||||
> a person, whose allow-once took effect and whose reject-once didn't.
|
||||
>
|
||||
> **Not shown natively:** a DeepSeek model, the example runner's own resume (its preset declares none), the default
|
||||
> permit when nobody can be asked (checked without a model only), a typed or Slack answer, a second person's
|
||||
> answer, reboot recovery, and the model OpenRig observes. Any other runner, provider or path is unqualified until
|
||||
> it's shown.
|
||||
>
|
||||
> **Since 0.7.8, on one stack:** the dsh example runner has run with a real dsh 0.2.1-rc.2 on dsh's pi-ai OpenRouter
|
||||
> chat-completions profile with `deepseek/deepseek-v4-flash`: queue work, activity, an allow-once and a reject-once
|
||||
> answered by an automated test operator, cancel, and an exact return with resume added to a copy of the preset. A
|
||||
> missing conversation's refusal hasn't been run, and the preset still declares no resume.
|
||||
>
|
||||
> Release status and pinned references are in the [joined author guide](author-contracts.md). The
|
||||
> [community presets reference](community-harness-presets.md) describes the implemented fields, including the runner
|
||||
@@ -22,14 +39,19 @@ This chapter covers **which agent runs in a seat and how OpenRig talks to it**.
|
||||
sandbox, another machine) is the [execution platform chapter](execution-platform-contract.md). The two are separate on purpose:
|
||||
the same harness can run in many places, and one place can host many harnesses.
|
||||
|
||||
## 1. Plain preset or runner preset?
|
||||
## 1. Plain preset, runner preset, or OpenRig-hosted protocol runner?
|
||||
|
||||
| | Plain community preset | Runner preset (`runner: true`) |
|
||||
|---|---|---|
|
||||
| **What runs in the pane** | The harness's own terminal app | A small program you maintain, *the runner*, which hosts the agent (for example an ACP agent over stdio) |
|
||||
| **How OpenRig reads activity** | From the screen: a changing screen reads working; a quiet one reads **unknown**, never idle | From the runner's reports: working, idle, needs input |
|
||||
| **How input arrives** | Typed into the app's prompt | Typed into the runner, which turns each submitted message into one agent prompt |
|
||||
| **Choose it when** | The harness has a usable terminal UI (Cursor, Hermes, OpenClaw examples) | The harness speaks a protocol rather than a screen (ACP, JSON-RPC, stream-JSON), or you can report turn boundaries reliably |
|
||||
| | Plain community preset | Runner preset (`runner: true`) | OpenRig-hosted protocol runner (`protocol: pi-rpc`) |
|
||||
|---|---|---|---|
|
||||
| **What runs in the pane** | The harness's own terminal app | A small program you maintain, *the runner*, which hosts the agent (for example an ACP agent over stdio) | OpenRig's own Pi runner, which starts your binary (`command`) in Pi's RPC mode |
|
||||
| **How OpenRig reads activity** | From the screen: a changing screen reads working; a quiet one reads **unknown**, never idle | From the runner's reports: working, idle, needs input | From the Pi runner's reports, through the same channel as built-in Pi; identity is the session file Pi's `get_state` reports |
|
||||
| **How input arrives** | Typed into the app's prompt | Typed into the runner, which turns each submitted message into one agent prompt; a message sent mid-turn waits | Pi's input: a submitted block is a prompt, a message sent while busy steers the current turn, `/followup` queues after it and `/abort` cancels it |
|
||||
| **Choose it when** | The harness has a usable terminal UI (Cursor, Hermes, OpenClaw examples) | The harness speaks a protocol rather than a screen (ACP, JSON-RPC, stream-JSON), or you can report turn boundaries reliably | Your harness is a Pi fork, or speaks Pi's CLI and JSONL RPC protocol |
|
||||
|
||||
**For the third shape, you promise your binary's Pi-RPC compatibility at a version you name in `limits`.** OpenRig doesn't
|
||||
test each fork. It can't be combined with `runner`, `launch_result`, `names_conversation`, `startup_assisted` or
|
||||
`first_input`. The flags it must accept are in
|
||||
[Pi-compatible RPC harnesses](community-harness-presets.md#pi-compatible-rpc-harnesses-protocol-pi-rpc).
|
||||
|
||||
**If your harness works well as a terminal app, a plain preset is enough. Use a runner only when you can report state
|
||||
honestly.** A runner that guesses is worse than a quiet screen read as unknown.
|
||||
@@ -61,7 +83,20 @@ limits:
|
||||
- **A preset without `runner` behaves exactly as today,** and so does a session recorded before the field existed.
|
||||
- **The other fields keep their existing meanings:**
|
||||
- `first_input: true` passes the seat's first startup turn in a file named by `OPENRIG_FIRST_INPUT`;
|
||||
- `passes_model: true` passes the seat's declared model as `OPENRIG_MODEL`.
|
||||
- `passes_model: true` passes the seat's declared model as `OPENRIG_MODEL`;
|
||||
- `launch_wrapper: true` declares that the runner applies a member's configured wrapper to its native child.
|
||||
|
||||
**Launch wrapper.** `launch_wrapper` is an optional boolean in format 1 and requires `runner: true`. When the member
|
||||
has a wrapper and its runner preset declares `launch_wrapper: true`, OpenRig passes `OPENRIG_LAUNCH_WRAPPER` as a JSON array of resolved argv strings.
|
||||
The runner must exec its native child as `<argv...> <agent> <args>`, preserving argument boundaries, and keep its own
|
||||
process outside the wrapper. The runner must remove `OPENRIG_LAUNCH_WRAPPER` from the environment it passes to the wrapper
|
||||
and native child; the handoff belongs to the runner only. The wrapper must `exec` the agent in place. If wrapping fails,
|
||||
the runner must never fall back to an unwrapped spawn. A runner preset without the flag is refused at preflight and launch (not schema validation) when a wrapper is
|
||||
configured: `launch_wrapper unsupported: the community preset "<runtime>" runs a runner without launch_wrapper: true; no agent started. Add launch_wrapper: true to the preset if its runner applies OPENRIG_LAUNCH_WRAPPER, or remove launch_wrapper from this member.`
|
||||
A plain preset needs no flag: OpenRig prefixes its command
|
||||
immediately before the preset's native command, after OpenRig's launcher assignments (for example,
|
||||
`/usr/bin/env OPENRIG_LAUNCH_RESULT=… <wrapper> <preset.command>`), at the same seam as Codex. OpenRig never sets
|
||||
`OPENRIG_LAUNCH_WRAPPER` for a plain preset and removes any inherited value.
|
||||
|
||||
## 3. Identity: report the conversation once, in the launch record
|
||||
|
||||
|
||||
@@ -91,7 +91,11 @@ and `severity=info`. CLI/TUI call this **needs diagnosis**, not a confirmed
|
||||
warning. Enabled diagnosis policy admits one bounded packet even though the
|
||||
product denominator is unknown. Ordinary unavailable/stale detector records
|
||||
remain ineligible. Diagnosis and human-notification traffic are excluded from
|
||||
the numerator and cannot recursively generate another investigation.
|
||||
the numerator and cannot recursively generate another investigation. So is the
|
||||
daemon's own bookkeeping: transitions by system actors (`*@system` and the
|
||||
bare `system` of claim handover) and daemon notes recorded under any actor
|
||||
(`auto-unparked:`, `blocker custody moved `, `park wake `, `parked-owner `).
|
||||
The reported `transitionIds` are the counted transitions.
|
||||
|
||||
The agent reads the actual evidence, including beyond the supplied references,
|
||||
and resolves the semantic outcome granularity and consequence boundary. Use the
|
||||
|
||||
@@ -97,6 +97,24 @@ missing tools or logins, kernel startup, a closed viewing terminal and recovery
|
||||
before choosing an action. A healthy daemon doesn't by itself mean the project's seats are ready: check
|
||||
`rig ps --nodes --rig <rig-name>`.
|
||||
|
||||
If the whole team stopped when a herdr terminal closed, OpenRig or its tmux server was started from inside that
|
||||
terminal: herdr-core ends every process started there when the terminal closes. `rig daemon start` no longer passes a
|
||||
herdr terminal's scope on, but processes started before that keep it until they restart. OpenRig never restarts them
|
||||
for you; restart at a time you choose, from a terminal outside herdr.
|
||||
|
||||
With `terminal.tmux_server` at its default (`inherit`), OpenRig puts seats on the tmux server of the shell that
|
||||
started it, usually your own default server. If that server was already running, and you had started it from a herdr
|
||||
terminal, it carries that terminal's scope. OpenRig can't prevent this, because the server is yours.
|
||||
The same applies when a command like `rig chatroom watch`, run in a herdr terminal, starts the default server. Start
|
||||
tmux outside herdr, or use `terminal.tmux_server isolated` (see `tmux-servers.md`), which gives OpenRig a server it
|
||||
starts itself.
|
||||
|
||||
The daemon keeps `HC_SESSION` and `HC_SOCKET`. The herdr-core they select is the one the herdr-app provider starts
|
||||
viewers in; `rig terminal status --provider herdr-app` names it (`core` and `assumption`), as do open failures
|
||||
(`viewer_start_failed`, `herdr_app_unavailable`) and the note when a window has not confirmed showing a view. If your
|
||||
herdr-app window is attached to a different herdr-core than the daemon's, its viewers will not appear there; start the
|
||||
daemon with that core's `HC_SESSION` and `HC_SOCKET`.
|
||||
|
||||
### The agent is waiting for permission or can't reach the daemon
|
||||
|
||||
Read [Have your agent configure permissions](getting-started.md#have-your-agent-configure-permissions)
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# OpenRig views in herdr-app
|
||||
|
||||
herdr-app (Herdr 2, the desktop app on herdr-core) can show OpenRig's terminal views inside its own window. A view
|
||||
opens as a **Rig view**: an equal grid of the view's seats, each with a title row (`<agent> · <slice>`), padded to a
|
||||
full grid. Rig views live in OpenRig's own herdr-app layout, **Rig** (`layouts/rig.json`), beside your tabs. A strip
|
||||
above them switches between **Your tabs** and each view.
|
||||
|
||||
Nothing here needs a desktop display or SSH: the view opens in the herdr-app window the request came from.
|
||||
herdr-app has to run on the same machine as the OpenRig daemon: OpenRig reaches it through its control socket
|
||||
(`<app home>/herdr-app.sock`) and writes its mods and layout into that app home. An app on another machine, connected
|
||||
to this machine's herdr-core over SSH, isn't supported.
|
||||
|
||||
## Set up
|
||||
|
||||
Nothing to install by hand: opening a view installs OpenRig's four mods (rig-strip, rig-cell-header, rig-pad,
|
||||
rig-ended) and updates them when OpenRig is updated.
|
||||
|
||||
- The mods go to `<app home>/mods/rig-*`. The app home is herdr-app's own: `HERDR_APP_HOME`, else
|
||||
`$XDG_CONFIG_HOME/herdr-app`, else `~/.config/herdr-app`. `--app-home <dir>` on `rig terminal open` names another.
|
||||
- OpenRig owns only the files it ships there (recorded in `<app home>/openrig/mods.json`). An update replaces those
|
||||
and nothing else in the folder. Mods you install yourself, from any source, are never touched.
|
||||
- To move between a view's cells from the keyboard, bind the strip's actions in `keys.json`, section `layout:rig`
|
||||
(your agent can add it, as for any herdr-app key):
|
||||
`"layout:rig": { "alt-left": "rig-strip.left", "alt-right": "rig-strip.right", "alt-up": "rig-strip.up", "alt-down": "rig-strip.down" }`.
|
||||
In the Rig layout these replace herdr-app's `global` defaults for the same keys (`pane.go`, `agent.prev`/`agent.next`),
|
||||
which keep working on your other layouts. A key bound in `terminal` wins while a cell has focus, so pick keys that
|
||||
aren't bound there. Once you edit `keys.json`, herdr-app treats it as yours and stops adding new default shortcuts.
|
||||
- An open never replaces a mod you changed: that folder stays as it is, the view opens anyway, and the open's notes
|
||||
name the folder and why. Only a mod a view needs that is missing and can't be installed stops an open.
|
||||
|
||||
## Open a view
|
||||
|
||||
- **From the TUI inside herdr-app:** Terminals, choose a view (Enter previews it), then **Open terminals**. The TUI
|
||||
recognises that it runs in a herdr-core terminal of a herdr-app window on this machine, and the preview and the
|
||||
open both use that window, so the plan you previewed is the one that opens. The preview says the view opens in
|
||||
this window; it shows no desktop or SSH recipe. A shared TUI viewed from a herdr-app terminal does the same. The
|
||||
kernel view opens in its narrow arrangement there (one seat per page): the window's width isn't the TUI's.
|
||||
- **From a shell:** `--provider herdr-app` opens the view in the herdr-app window you run it in. Inside a herdr-app terminal, plain `rig terminal open <view>` does the same when that window is answering. `--window` always asks for a new desktop window.
|
||||
|
||||
| Command | Where the view opens |
|
||||
|---|---|
|
||||
| `rig terminal open <view> --provider herdr-app [--app-home <dir>]` | In the herdr-app window this runs in (requires the window to answer). |
|
||||
| `rig terminal open <view>` | Inside a herdr-app terminal when the window is answering, in that herdr-app window; otherwise a desktop window. |
|
||||
| `rig terminal open <view> --window` | Always asks for a new desktop terminal window, even inside a herdr-app terminal. |
|
||||
|
||||
The herdr-app window must be answering on its socket (`<app home>/herdr-app.sock`); otherwise an explicit open fails and says
|
||||
so. Finding the socket file is how OpenRig guesses you are in a herdr-app window; whether the window is reachable is
|
||||
checked by asking it. When a plain `rig terminal open <view>` in a herdr-app terminal finds that the socket does not
|
||||
answer, it says so and opens a desktop window instead; `--provider herdr-app` never
|
||||
falls back to a desktop window or an SSH recipe.
|
||||
|
||||
What an open does, in order:
|
||||
|
||||
1. Each viewer is recorded first (`$OPENRIG_HOME/private/herdr-app/clients/`), then started by herdr-core outside
|
||||
every tab (`hc run --bg --no-parent`, labelled `openrig.*`). A local viewer runs the view's tile for its seat, as in
|
||||
[terminal sessions](terminal-sessions.md): it shows the seat. An interactive cell starts with the read-only tile and
|
||||
takes no input until herdr-app has shown the view (step 3); then its writable tile takes over, and the first thing
|
||||
you type attaches it. A read-only cell only shows. A seat on another host opens as its own ssh attachment, writable
|
||||
or read-only as that view composes it. If the seat's session ends, the viewer says so and waits; it never leaves a
|
||||
shell in the cell.
|
||||
2. One atomic write of `layouts/rig.json`: your current layout, with OpenRig's stack beside your tab block. Your own
|
||||
layout file is never written. A `rig.json` OpenRig didn't write is never overwritten.
|
||||
3. The Rig layout is shown, the view is selected once herdr-app confirms it shows the new file, and the keyboard
|
||||
goes to the first live cell. Once herdr-app has acknowledged the layout with the view, OpenRig writes the gate file
|
||||
of each of the view's interactive cells, on every page (including pages the strip hasn't shown yet, since the strip
|
||||
moves between pages on its own), and each cell takes input a fraction of a second later (it checks every 0.2 s).
|
||||
Anything that reached a cell before that, typed or written to its terminal (`hc send`), is dropped, not delivered
|
||||
late. The open reports a view herdr-app
|
||||
hasn't confirmed as unconfirmed, not opened; its viewers keep running and it stays in the Rig layout, and its cells
|
||||
take input once herdr-app shows it. OpenRig keeps watching for 10 minutes, across a daemon restart; if herdr-app
|
||||
hasn't shown it by then, each interactive cell says so, takes no input, and names the command that opens the view
|
||||
again (`rig terminal open <view> --provider herdr-app`).
|
||||
|
||||
Other layouts and other herdr-app windows don't show Rig views. Each window (app home) has its own.
|
||||
|
||||
## The keyboard
|
||||
|
||||
- An open puts the keyboard on the new view's first live cell.
|
||||
- Entering a view again (a click in the strip, or herdr-app restarting with it shown) returns the keyboard to the cell
|
||||
you were last in there, else the first live cell.
|
||||
- **Your tabs** gives the keyboard back to your own pane.
|
||||
- OpenRig's own rewrites (another view opening or closing, a cell ending) never move the keyboard, whether you are in
|
||||
a view or in your tabs.
|
||||
- Typing in an interactive cell, once the view is shown, attaches its tile to the seat; after 30 seconds without a key
|
||||
it goes back to showing it. When the seat's session is replaced (a handover, a fresh launch, a restart), the tile follows the same seat to
|
||||
its new session, as every OpenRig tile does.
|
||||
|
||||
## What OpenRig keeps in the app home
|
||||
|
||||
All under `<app home>/openrig/`:
|
||||
|
||||
| File | What it holds |
|
||||
|---|---|
|
||||
| `mods.json` | The files OpenRig shipped to `mods/rig-*`, so an update replaces only those. |
|
||||
| `ack.json` | Written by the strip: the last layout revision herdr-app shows and the terminals it references. A viewer ends only once this shows its cell is gone. |
|
||||
| `strip.json` | Written by the strip: the cell you were last in per view, and the view shown. herdr-app gives a mod new storage whenever its props change, which is every OpenRig write, so this is kept as a file. |
|
||||
|
||||
## Close a view
|
||||
|
||||
```sh
|
||||
rig terminal close <rv-id> [--app-home <dir>] # the id is in the open result's `space`
|
||||
```
|
||||
|
||||
The view's cells leave `rig.json` first. A viewer ends only after herdr-app confirms it shows a layout without it, or
|
||||
when the Rig layout isn't shown (herdr-app then reads the new file when it is). Only viewers OpenRig started (its
|
||||
record, its labels and its exact command all match) are ended. Seats are never touched. Anything uncertain is left
|
||||
running and named in the result.
|
||||
|
||||
If a viewer ends some other way (killed outside OpenRig, herdr-core restarted), OpenRig rewrites its cell as an
|
||||
ended cell straight away. Views OpenRig recorded keep that owner across a daemon restart: when the daemon starts, it
|
||||
reconciles each app home it has views in (a viewer that ended while it was down is rewritten then) and finishes any
|
||||
pending ends. If `hc events` can't run (herdr-core missing or down), the watcher tries again after 5 s, then waits
|
||||
twice as long each time, up to 5 minutes. Stopping the daemon ends only its own `hc events` watcher and timers; viewers
|
||||
and views stay.
|
||||
|
||||
## Which herdr-core
|
||||
|
||||
```sh
|
||||
rig terminal status --provider herdr-app [--app-home <dir>]
|
||||
```
|
||||
|
||||
`core` and `assumption` in the result name the herdr-core the daemon's `hc` runner starts and ends viewers in: the
|
||||
daemon's `HC_SESSION` (else `default`) and `HC_SOCKET` if set when the daemon started. The herdr-app window must be a
|
||||
client of that same core. Terminal addresses are per core, so a window attached to another core would show other
|
||||
terminals, or none, in the view's cells. Open failures (`herdr_app_unavailable`, `viewer_start_failed`) and the note
|
||||
when a window has not acknowledged a write also name the daemon's core (`HC_SESSION`/`HC_SOCKET`) so a mismatch is
|
||||
visible straight away.
|
||||
|
||||
A status or a preview only reads: it starts no herdr-core process and creates nothing. OpenRig watches herdr-core's
|
||||
events (`hc events`) and creates its viewers folder only when it opens or closes a view, or when it starts with views
|
||||
already recorded.
|
||||
|
||||
## Limits
|
||||
|
||||
- A Rig view is a layout inside herdr-app, not a herdr tab: other windows and other layouts don't show it.
|
||||
- Following a task (`--follow`) isn't available in herdr-app yet.
|
||||
- When a viewer ends unexpectedly, herdr-app removes its block and collapses the layout, which briefly opens the Files
|
||||
panel on that cell's folder until OpenRig's rewrite restores the layout. Viewers start in OpenRig's own empty
|
||||
folder (`$OPENRIG_HOME/private/herdr-app/viewers`). Planned closes never trigger this.
|
||||
- OpenRig doesn't yet check that the window runs on the daemon's herdr-core (`core` in `rig terminal status`); it
|
||||
relies on both using the same session on the same machine.
|
||||
- `POST /api/terminal/open` (and `POST /api/terminal/close`) carries no bearer middleware, matching the daemon's other
|
||||
open routes; an open writes `mods/rig-*`, `openrig/mods.json` and `layouts/rig.json` into the named `--app-home` once
|
||||
`herdr-app` answers a `status` request on `<app home>/herdr-app.sock`.
|
||||
- Runtime-tested on Linux only; macOS and Windows haven't been tried on real machines.
|
||||
@@ -86,8 +86,14 @@ entry. The entry holds secret names, never values:
|
||||
These facts stay separate on the row:
|
||||
- **Wake:** with a trigger, the row's wake result is `wake-accepted:webhook (<url_ref>) answered <status>` when the trigger took the
|
||||
ring, or `failed:webhook …`. Accepted means the tool's trigger answered, not that the member read the row.
|
||||
- With no trigger, the result is `unroutable`, and OpenRig tells the person: the operator seat when it's a person, else whoever
|
||||
created the row.
|
||||
- With no trigger, the result is `unroutable`. The member finds work by reading its own inbox and picks a row up by claiming
|
||||
it, so a row gets no finding of its own. Work it leaves unclaimed past the stuck sweep's unclaimed age
|
||||
(`queue.stuck_sweep_unclaimed_age_minutes`, 60 by default) is listed in one standing notice per member. The notice goes to
|
||||
the operator seat when it's a person, else the rows' creator when they all share one, else the operator agent, else the
|
||||
member itself. A notice to a creator lists only that creator's rows: when another creator's row joins, the notice moves to
|
||||
the next recipient. It says whether the member has read its own pending inbox recently: a check-in, never a receipt or a
|
||||
claim. A member with a declared route (a delegation parent or `escalates_to`) gets one finding per unclaimed row at that
|
||||
route instead.
|
||||
- It tells the person too when the member was rung but hasn't claimed the work by the stuck sweep's unclaimed age
|
||||
(`queue.stuck_sweep_unclaimed_age_minutes`, 60 by default).
|
||||
- The member sees new work when it checks: when it's rung, when its person asks it to, or on a schedule the tool runs.
|
||||
@@ -95,7 +101,7 @@ These facts stay separate on the row:
|
||||
- **Result:** the closing transition and its resolution, or the handoff.
|
||||
- **Notification:** where a person asked, Slack's posted receipt. A posted notice isn't the person's acknowledgment.
|
||||
|
||||
**On the team:** `rig_team` lists the rig's hosted members (roster entries marked hosted that aren't one of its nodes), with their capabilities and caveat, no pane, and activity `unknown`.
|
||||
**On the team:** `rig_team` lists the rig's hosted members (roster entries marked hosted that aren't one of its nodes), with their capabilities and caveat, no pane, and activity `unknown`. With an external grant offering `team/list`, call it without `rigId` to discover the connected team. It selects the grant's sole rig, or returns only its granted names when a choice is needed.
|
||||
OpenRig doesn't observe a hosted member between its calls, so it never reads idle. `rig roster find` shows it the same way.
|
||||
- **Presence:** each entry carries `presence`, which says what OpenRig last saw of the member. It isn't pickup, and `null` means unknown:
|
||||
- `lastCallAt`: its last authenticated call to this host's `rig mcp serve --http`, recorded at most once a minute in
|
||||
@@ -119,6 +125,7 @@ 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
|
||||
@@ -128,6 +135,10 @@ 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.
|
||||
@@ -136,11 +147,49 @@ and nothing else is offered:
|
||||
- **Queued:** the item exists and is `pending` with no claim.
|
||||
- **Picked up:** the receiver's claim (`in-progress`).
|
||||
- **Answered:** closed `done`, with the answer in `resolution`.
|
||||
- Reading or listing an item records nothing, and nothing wakes an assistant, so an item stays queued until its receiver
|
||||
claims it.
|
||||
- Reading or listing an item records nothing on the item, and nothing wakes an assistant, so an item stays queued until its
|
||||
receiver claims it. A member listing its own pending inbox records only a check-in time, which the notice above reads.
|
||||
|
||||
### 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.
|
||||
isn't rung; work it leaves unclaimed past the unclaimed age is listed in one notice instead, which goes to its person, else
|
||||
its rows' shared creator, the operator agent or the member itself.
|
||||
- **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).
|
||||
@@ -59,7 +59,9 @@ rig queue block <task> --on external:<what you check> --wake-after 1h \
|
||||
```
|
||||
|
||||
`--on` is always required; `external:<what>` names the outside thing you're waiting on. The timer first fires after
|
||||
the interval, not now.
|
||||
the interval, not now. Left without `--wake-after` (or `--wake-watchdog`), the task is still set aside, but nothing
|
||||
will wake you, and the reply says so with a `park_without_wake` advisory. Set it aside again with `--wake-after` to add
|
||||
the timer.
|
||||
|
||||
**4. Say back what you set, in one or two plain lines:** what you'll check, how often, how they'll be told, and when
|
||||
it stops. For example: "I'll check the release PR every hour and message you here once it merges. Then I'll stop."
|
||||
|
||||
@@ -7,6 +7,10 @@ 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
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# Queue erasure: closed-work retention and account deletion
|
||||
|
||||
A daemon that serves several accounts sometimes needs to remove queue content for good: closed work after its retention window, or everything that belongs to an account when the account is deleted. `rig queue erase` and three retention settings do that.
|
||||
|
||||
Erasure can't be undone. Run every scope as a dry run first, without `--apply`. The dry run prints exactly what `--apply` would erase and changes nothing.
|
||||
|
||||
## What is erased
|
||||
|
||||
A queue row is erased in one of two ways:
|
||||
|
||||
- **Scrub.** The row's content is removed, and the row itself stays, marked `contentErasedAt`. A retried create with the same id still finds the row, so it makes no new work. If anything writes to the row after its scrub (a note, a summary, a reopen and close), the next run erases that content too.
|
||||
- **Delete.** The row and everything linked to it are removed. Only its id is kept, in an erased-id record with no endpoints and no content. A later create that reuses the id is refused with `qitem_erased` (409), so a retried or forwarded create can't bring the work back.
|
||||
|
||||
| A scrub clears | A scrub keeps |
|
||||
|---|---|
|
||||
| body, summary, resolution, tags, blocker text, chain of record, evidence reference, human detail, questions and answers, delegated decision, the input-box reading | id, source and destination, state, priority, tier, times, closure reason, handoff links |
|
||||
| a closure's target when it's a reason or note, in the row, both transition tables and events. That's every target under a reason other than the two at right, and any target under those two that isn't a session address or a queue id | a closure's target that names a successor: under `handed_off_to` or `superseded`, shaped as a session address (`member@rig`, optionally `@host`) or a queue id (`qitem-…`) |
|
||||
| transition notes and recorded principals, live and archived | each transition's state, actor and time |
|
||||
| events' `summary`, `reason` and `error` (the event keeps its type and ids, marked `contentErased`) | |
|
||||
| mission-control audit text and snapshots | |
|
||||
|
||||
**The row's own copies go with it:**
|
||||
- its transitions;
|
||||
- events whose payload names it;
|
||||
- its mission-control audit rows.
|
||||
|
||||
**Copies held with someone else go only when they're the owner's.** These are outbox and inbox entries that point at the row or wake for it, and stream reader actions and dispositions that name it (including `duplicate_of`). For account deletion, the owner is the account: both of a copy's sessions must be members. Otherwise the owner is the row's own source and destination. Any other copy is kept and listed under `keptCopies`:
|
||||
- `mixed`: one of its sessions is the owner's;
|
||||
- `foreign`: none of them are.
|
||||
|
||||
**A row a workflow still references is never erased.** That covers a step trail, a frontier binding, a failure record, or a live instance's frontier. It's reported under `gaps` with the code `workflow-reference`.
|
||||
|
||||
## Scopes
|
||||
|
||||
Name exactly one.
|
||||
|
||||
| Scope | Erases | Action |
|
||||
|---|---|---|
|
||||
| `--members <file>` | Account deletion. Every row whose source and destination are both in the file, in any state, open work included. Add `--rig <id>` for the account rig's own records | delete |
|
||||
| `--ids <file>` | Exactly the listed rows. A report's `qitems` entries, or a ledger's lines, keep each entry's own action. A ledger's entries are replayed only for the batches that committed (below) | `--action delete` (default for plain ids) or `--action scrub` |
|
||||
| `--routine` | Closed rows past this daemon's retention windows (below) | scrub, then delete |
|
||||
|
||||
A file is one entry per line (blank lines and `#` comments are skipped; a line starting with `{` is a JSON entry) or a JSON array.
|
||||
|
||||
**Account deletion is exact.**
|
||||
- The members file is the account's recorded membership: the exact session names of its seats, including departed ones. List every address form the account used, such as a host-suffixed form. Names are compared as given, never by pattern, so a row addressed through an unlisted form is neither erased nor reported.
|
||||
- **Mixed rows:** a row with exactly one member endpoint is reported under `mixed` (with which endpoint is the member) and left untouched. Deciding who owns it is a separate decision. A row rerouted out of the account while the run is under way (by a fallback, say) is kept too, and reported as `changed-during-run`.
|
||||
- **After the rows:** account deletion also removes outbox and inbox entries between two members that no row owns, and the members' stream items. An entry with one member endpoint, or a stream item something still references, is counted and left.
|
||||
|
||||
**The account's rig.** A rig's own records outlive the rig. Events are kept past rig and node deletion by design, and snapshots, node and seat records and watchdog jobs have no key that cascades. `--rig <id>`, given with `--members`, erases them:
|
||||
- the rig's events, including those of its nodes;
|
||||
- snapshots, operation correlations and bootstrap runs;
|
||||
- seat identity verdicts, delivery-guard changes, environment bindings and usage records for its nodes and member sessions;
|
||||
- watchdog jobs aimed at a member, with their history;
|
||||
- stream subscriptions and thread maps of member sessions.
|
||||
|
||||
Records that name the account but also belong elsewhere are counted under `rig.left` and kept. These are a member's watchdog job aimed outside the account, a workflow instance or spec bound to the rig, classifier leases and custom views.
|
||||
- **While the rig exists,** its sessions and the members must match both ways, or the run is refused (`erase_rig_mismatch`). Every member's session name must carry the rig's name (departed members included), and every current session of the rig must be a member, so the rig holds no one else's seat.
|
||||
- **On that verified run,** the rig's id and name are recorded with a digest of the membership.
|
||||
- **Once the rig is removed,** the same command, with the same membership, uses that record to erase what's left under the rig's id. This includes the events the removal wrote, and the record still lets it count records bound to the rig's name.
|
||||
- **When nothing of the rig is left,** the record becomes a completion receipt: the rig's name is cleared, and its id, the membership digest and the time stay. An identical rerun, after a lost response say, still verifies and reports the same clean result, with `rig.completedAt`. The receipt is kept for good. It holds no content.
|
||||
- **A removed rig's id that was never verified** with this membership erases nothing keyed by it. Those records are counted under `rig.left`, the name-bound tables are listed under `rig.unknown`, and the run isn't clean.
|
||||
|
||||
**Retention windows** (`rig config set …`, read at the daily sweep and at daemon start):
|
||||
|
||||
| Setting | Default | Effect |
|
||||
|---|---|---|
|
||||
| `retention.closed_content_days` | `0` (off) | Scrub closed rows this many days after they closed |
|
||||
| `retention.closed_metadata_days` | `0` (off) | Delete closed rows, keeping only their ids, this many days after they closed |
|
||||
| `retention.erasure_ledger` | empty (none) | An absolute file path, on local disk, since each batch appends to it while it holds the daemon. Every applied erasure, by the sweep or the command, appends each row's id, action and batch id there, one JSON line each, before anything in its batch changes, then a commit line once the batch commits (an abort line if it rolls back). A path that can't be written stops the run before it changes anything |
|
||||
|
||||
- **Closed** means `done`, `canceled`, `denied` or `handed-off`. `failed` and open work are never touched by the windows.
|
||||
- **Age:** a row's age counts from when it entered its final closed state. A note on a closed row doesn't restart it, and a row reopened and closed again counts from its latest close.
|
||||
- **Running them now:** `rig queue erase --routine --apply` runs the windows immediately. `retention.enabled false` turns off the whole sweep, these windows included.
|
||||
- **When the sweep's erasure stops on an error,** that's logged, and the sweep's other passes still run.
|
||||
|
||||
## Account deletion, in order
|
||||
|
||||
1. Revoke the account's access.
|
||||
2. Keep its exact membership list and rig id.
|
||||
3. Run `rig queue erase --members <file> --rig <id>` as a dry run, then with `--apply`. Rerun until `complete` is true.
|
||||
4. Remove the rig.
|
||||
5. Rerun the same command with `--apply`, to erase the records the removal wrote.
|
||||
6. The account is deleted only when the report's `clean` is true. Otherwise its `mixed`, `gaps`, `keptCopies`, `unlinked` and `rig.left` or `rig.unknown` entries need a decision. Rerunning won't resolve them.
|
||||
|
||||
## Output
|
||||
|
||||
The command prints one JSON report (`--json` makes it a single line):
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `applied` | `false` for a dry run |
|
||||
| `complete` | the run finished. `false` when it stopped at its batch limit or on an error; rerun the same command to continue |
|
||||
| `clean` | `complete`, and nothing was left: no `mixed` rows, mixed `keptCopies`, `gaps`, `unreadableEvents`, left-over entries, or rig records left or unknown. Only a clean report means the scope is fully erased. It's about this run's scope: a routine or members run's `clean` isn't affected by the ledger's other batches, and only a replay (`--ids` with a ledger) reports a batch it can't show committed, as the gap `ledger-unresolved` |
|
||||
| `error` | why the run stopped (`stage` and `message`). Everything listed in `qitems` was committed before it; the command exits 2 |
|
||||
| `qitems` | each row erased (or that would be), with its `action` and `state` |
|
||||
| `mixed` | members scope: rows with one member endpoint, left untouched |
|
||||
| `keptCopies` | copies of erased rows held with someone else, kept: `table`, `id`, `qitemId`, `ownership` (`mixed` or `foreign`) |
|
||||
| `gaps` | rows not erased and why: `workflow-reference`; `not-found` for an id to scrub that doesn't exist; `changed-during-run` for a row that no longer qualified when its batch ran (reopened, rerouted out of the account, or written to so it left its window) or an absent id created since selection; `unreadable-event` for a row an unreadable event names; `ledger-unresolved` for a replayed entry whose batch can't be shown to have committed |
|
||||
| `skipped` | ids scope, a ledger replay: entries whose batch rolled back, with the batch id; not replayed |
|
||||
| `ledger` | an applied run with a ledger: batches whose missing commit line it wrote from the database's record (`reconciled`) |
|
||||
| `alreadyAbsent` | ids to delete with no row; their erased-id record is written (again), and they're listed once it's committed. They're rechecked as the record is written, so an id created during the run is reported as `changed-during-run` instead |
|
||||
| `unreadableEvents` | events naming a selected row whose payload can't be read: `seq` and the row ids found in its text. They're kept, since what they hold can't be inspected, and so are the rows they name, so every rerun reports them again until the event is repaired or removed. The run isn't clean |
|
||||
| `linked` | counts of the copies erased with the rows |
|
||||
| `unlinked` | members scope: entries and stream items erased that no row owned, plus those left (`mixedEntries`, `referencedStreamItems`) |
|
||||
| `rig` | members scope with `--rig`: `found`, `verified`, records erased per table (`erased`, counted as each chunk commits), records left (`left`), tables that can't be counted (`unknown`), and `completedAt` once the rig's erasure is complete |
|
||||
|
||||
**How a run works:**
|
||||
- **Selection:** a run selects everything first and writes nothing.
|
||||
- **Events:** it reads the events table once, in small windows, to find each row's events.
|
||||
- **Batches:** each batch, of up to 100 rows, is one transaction. It rechecks its rows, writes their ledger lines, then changes their events, copies and rows.
|
||||
- **The recheck drops** a row that a workflow has taken up, or that an unreadable event names. For account deletion, it also drops a row that is no longer between two members. For a routine run, it drops one that was reopened or no longer falls in the same window.
|
||||
- **Each dropped row** is reported in `gaps`.
|
||||
- **The current endpoints** decide whose its copies are.
|
||||
- **How long it holds the daemon:** the batch caps rows, not events or copies, so a row with many of either makes its batch longer. On a copy of a store with 2.4 million events, the longest single hold in a first routine run was about 0.25 s. With a ledger, a run doesn't read the ledger file: it reads the end of the file (one byte) and the database's record of batches still missing a commit line, so the ledger's size doesn't add to the hold.
|
||||
- **Stopping:** a run that stops leaves whole batches done, events included. A rerun picks up the rest, and erasing a row a second time changes nothing.
|
||||
- **Ledger lines and commits:** a batch's lines are written before its first change, so they can name rows that a rolled-back batch never erased. Each batch therefore also records its id in the database, in its own transaction, and appends a commit line (`{"batch": …, "committed": true}`) after it commits, or an abort line (`"committed": false`) when it rolls back.
|
||||
- **A missing commit line:** a run that can't write one stops once the committed batch is reported. The database's record notes when each batch's commit line was written. Each applied run with a ledger writes the commit line for any recorded batch still without one, from the record alone, without reading the ledger file. It lists those under `ledger.reconciled`.
|
||||
- **Neither line nor record:** a batch with neither, such as one whose transaction was cut off by a crash, is never decided from the database: on a restored database, a batch committed after the backup looks exactly like one that rolled back. A replay reports its entries as `ledger-unresolved` and doesn't apply them, until an operator resolves it by appending its commit or abort line once they know which.
|
||||
- **A line cut short:** a full disk, say, can cut a line short. Each run starts the ledger on a fresh line first, so the cut line can't fuse with the next one. A replay refuses the file until that line is deleted. That's safe: an entry line's batch rolled back when its write failed, and a commit line is written again from the record.
|
||||
|
||||
**Refusals**, with nothing erased:
|
||||
|
||||
| Code | Cause |
|
||||
|---|---|
|
||||
| `erase_scope_required` | no scope named |
|
||||
| `erase_scope_conflict` | more than one scope |
|
||||
| `erase_members_invalid` | an empty list, or names with surrounding spaces |
|
||||
| `erase_rig_invalid` | a rig without `--members`, or an empty rig id |
|
||||
| `erase_rig_mismatch` | the rig exists, and its sessions and the members don't match both ways |
|
||||
| `erase_ids_invalid` | an empty ids list, or ids with surrounding spaces |
|
||||
| `erase_action_invalid` | an action other than `delete` or `scrub` |
|
||||
| `erase_routine_off` | both windows are `0` |
|
||||
|
||||
The command records who ran it from `OPENRIG_SESSION_NAME`, or from `--actor <session>` when that isn't set.
|
||||
|
||||
## Restores and backups
|
||||
|
||||
A restored database can bring erased content back: rows, copies, rig records and the erased-id records themselves. It can even bring back a row that was still open when the backup was taken and was closed and erased later. Before a restored daemon serves anyone, replay what was erased after the backup was taken:
|
||||
|
||||
1. **Keep a ledger.** Set `retention.erasure_ledger` to a file outside the backed-up data. Every applied erasure appends its ids, actions and batch there before changing anything, and a commit line once the batch commits. That covers the daily windows, `--routine`, `--ids` and account deletion's rows.
|
||||
2. **Replay the ledger:** `rig queue erase --ids <ledger> --apply`. It replays exactly the batches that committed, each line with its own action, so a scrub is replayed as a scrub, never promoted to a deletion.
|
||||
- **What's replayed:** a row that was open in the backup and erased later is erased by its id, and other open work isn't touched.
|
||||
- **What's skipped:** a batch with an abort line was never erased, and is listed under `skipped`.
|
||||
- **What's left:** a batch with neither line, which the restored database doesn't record either, is reported as `ledger-unresolved` and not replayed. The run isn't clean until an operator decides it.
|
||||
3. **Replay account deletions:** rerun `rig queue erase --members <file> --rig <id> --apply` for each account deleted since the backup, from its membership list and rig id. Keep those outside the backed-up data until every backup taken before the deletion has expired. This also covers the copies and rig records the ledger doesn't list.
|
||||
4. **Rerun the windows:** `rig queue erase --routine --apply`.
|
||||
|
||||
Backup expiry is the deployment's own schedule. Content erased from the live database stays in backups until they expire.
|
||||
|
||||
## What this doesn't cover
|
||||
|
||||
These stores need their own handling where a deployment uses them:
|
||||
- terminal transcripts;
|
||||
- daemon and service logs;
|
||||
- gateway and Slack stores and Slack's own copies;
|
||||
- downloaded files and artifacts;
|
||||
- chat messages (removed with their rig);
|
||||
- events tied neither to a row nor to the account's rig.
|
||||
|
||||
The rig itself (its nodes, sessions and bindings) is removed by removing the rig, between the two runs above.
|
||||
@@ -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.9`. If OpenRig is already installed, use the tag
|
||||
For a new install, use the latest release's tag, for example `v0.7.11`. 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.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# 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.
|
||||
@@ -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/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/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`
|
||||
|
||||
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,6 +404,22 @@ 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
|
||||
@@ -548,7 +564,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 or Claude Code seat can change its native account home
|
||||
**Switching to an isolated account home.** A running Codex, Claude Code or Pi seat can change its account home
|
||||
while staying in its rig:
|
||||
|
||||
```sh
|
||||
@@ -560,7 +576,24 @@ 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.
|
||||
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).
|
||||
|
||||
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,
|
||||
@@ -662,7 +695,7 @@ first turn. Check `/status` after a real tool turn to confirm the authentication
|
||||
|
||||
For Codex, a saved native API-key login is selected with the existing `--to-account <name>`
|
||||
path, exactly like a saved subscription login. Prepare it using native `codex login --with-api-key`
|
||||
with the key on stdin, then `rig auth save --codex-home <home> <name>`; never put a key in argv.
|
||||
with the key on stdin, then `CODEX_HOME=<home> rig auth save <name>`; never put a key in argv.
|
||||
Use the seat's existing home. Preparing a login replaces that home's active credentials, so do it
|
||||
before running seats there, or prepare/import a native auth profile separately. Switches preserve
|
||||
an outgoing credential copy and report peers sharing the home. A native keyring or administrator
|
||||
@@ -740,7 +773,7 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
|
||||
| `id` | string | yes | — | Member identifier. Must not contain dots or `@`. Must be unique within the pod. |
|
||||
| `agent_ref` | string | yes | — | Reference to an AgentSpec. Must start with `local:` (relative) or `path:` (absolute). Exception: `builtin:terminal` for infrastructure nodes. |
|
||||
| `profile` | string | yes | — | Profile name from the referenced AgentSpec. Use `default` for the default profile. Exception: `none` for terminal nodes. |
|
||||
| `codex_config_profile` | string | no | — | Codex-only native profile passed as `-p <name>`; letters, numbers, `_`, `.`, `-`. Separate from the AgentSpec `profile`. With the normal launch mode, this replaces OpenRig's explicit workspace-write sandbox flag. A full-bypass policy instead emits danger-full-access and omits this profile argument. |
|
||||
| `codex_config_profile` | string | no | — | Codex-only native profile passed as `-p <name>`; letters, numbers, `_`, `.`, `-`. Separate from the AgentSpec `profile`. With the normal launch mode, this replaces OpenRig's explicit workspace-write sandbox flag. A full-bypass policy instead emits danger-full-access and omits this profile argument. To keep the profile, put full access in the profile itself, or record a workspace-write or read-only `codex_permissions` envelope, which keeps it. |
|
||||
| `codex_permissions` | mapping | no | — | Codex-only sandbox and approval envelope, emitted as explicit flags on every managed launch and resume; see [per-member Codex permissions](#per-member-codex-permissions). |
|
||||
| `claude_token` | mapping | no | — | Claude-only `{ label, file }`: the seat's own subscription token file, by absolute path, used from its first launch; see "A seat's own subscription token" under [launch environment](#launch-environment). |
|
||||
| `runtime` | string | yes | — | Agent runtime. Current supported values: `claude-code`, `codex`, `pi`, `omp`, `terminal`, `stub`. `stub` is a deterministic test harness, not an agent: a Node runner in the seat's tmux pane goes through the normal launch, skill projection, startup-file and readiness path, follows `<cwd>/.openrig/stub/script.json` when present, and calls no model. See "What a stub can and cannot prove" in `docs/as-built/test-layers.md`. |
|
||||
@@ -750,6 +783,7 @@ 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 `pi`, `codex`, `claude-code` and community-harness members; runner presets must declare wrapper support. 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. |
|
||||
@@ -887,14 +921,42 @@ 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 `pi`, `codex`, `claude-code` or community-harness 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 `pi`, `codex`, `claude-code` and supported community-harness members support `launch_wrapper`; legacy flat nodes support it only on `pi`, `codex` and `claude-code`. Other runtimes, including the maintained `openshell-claude` environment path, reject it, including `[]`. Community preset capability and execution requirements are defined in the [runner contract](harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch). 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`.
|
||||
- **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.
|
||||
- **Thinking level:** A Pi seat's effort (the choice saved with `rig seat set-effort`, else the spec's `effort`) is sent to Pi as `--thinking <level>` on every managed launch and resume: fresh launch, successor and restore. Pi accepts `off`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`; OpenRig passes the value as written and does not translate it, so Codex's `none` is not mapped to `off`. Only a value of the shape `set-effort` accepts (a letter, then up to 31 letters, digits, `-` or `_`) is sent; any other spec value, such as one with a space or one that starts with `--`, is left out, and `rig seat effort` says so. An unfamiliar level gets an advisory (a warning from `set-effort`), never a refusal, and Pi itself ignores an unknown level with a warning and keeps the level it would use without the flag: a model suffix, the resumed session's level, or its default. With no effort resolved, no flag is sent. The effort wins over a model suffix, and the model string is never changed. `rig seat effort` reports the level sent at launch and, on Pi 0.99.0 or later, the `thinkingLevel` Pi records on this launch's latest successful assistant turn (error and aborted turns are skipped); that is the session's level after Pi's own cap, not proof the provider applied it, and it is shown apart from the level sent when they differ. On older Pi it is NOT VERIFIED. Without an effort, you can still set the level as a model suffix, for example `model: openrouter/x-ai/grok-4:high`. Pi first looks for a model whose id is the whole pin, and uses it when there is one, suffix included. Only when there is none does Pi read the suffix as the level and record the model without it. Any other suffix, such as OpenRouter's `:free`, stays part of the model id. The model-divergence check reads the pin literally when the seat's `models.json` defines a model with that exact id; otherwise it accepts the model with or without the suffix. If the seat's `models.json` can't be read, a pin with a suffix stays unchecked, with the usual warning, when the recorded model matches the whole pin or the pin without its suffix. A record that matches neither is reported.
|
||||
- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi/<session>/agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file. A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`.
|
||||
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
|
||||
- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi/<session>/agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file (or the corresponding committed account-switch directory after a switch). A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`.
|
||||
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `NODE_OPTIONS` for the selected-account Node preload, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
|
||||
|
||||
- **Account homes:** Select an account with `rig provider switch --account-home`, or declare an absolute `PI_CODING_AGENT_DIR` in the seat’s `env:` as its configured account home; the persisted selection takes precedence. `~` is not expanded. A relative value is ignored for managed account selection (and reported by status), leaving the managed seat login in use when no persisted account is selected. The chosen directory must already contain a readable `auth.json`; env-only credentials and wrappers that create credentials later are outside this prepared-account path. Launches with neither a persisted selection nor a declared absolute account home retain their existing credential behaviour. The runner links that file into its managed agent directory before starting Pi, including launches through `launch_wrapper`; settings, models, extensions and sessions remain seat-local. Selected accounts use a Node preload shim that redirects only their auth-lock filesystem operations to the chosen account’s `auth.json.lock` pathname, matching direct Pi use even when the source `auth.json` is a symlink. The preload removes its own `NODE_OPTIONS` entry before Pi starts tools, preserving other Node options; descendants and unrelated agent directories keep their normal filesystem behaviour. This requires a Node-hosted Pi executable and a wrapper that preserves `NODE_OPTIONS`; compiled executables and wrappers that discard the preload are not covered. Removing the selection removes only the managed auth link, not the source account file. A missing account file stops the launch. Direct launch refuses to replace an existing seat-local login or an unmanaged auth link; use a confirmed account switch to preserve that login. An ambient pane-shell `PI_CODING_AGENT_DIR` does not select an account; only the resolved seat configuration or persisted account selection does. Persisted account selections survive resume and cannot be bypassed by the long-command fallback; explicit selections clear inherited provider-key overrides. Use `rig provider switch --account-home` or `--clear-account-home` to change an existing Pi seat through a confirmed successor with a fresh conversation and handover. The account reference and committed state directory survive daemon restart and resume; unconfirmed successors leave the old account and state intact. Clearing returns to the configured home, or the original managed seat login if no home was configured. Status reports a configured account home when present; without one, its home is null because Pi uses the managed seat directory rather than the operator's default login.
|
||||
|
||||
**Upgrade note for prepared accounts:** An existing built-in Pi seat that declares an absolute `PI_CODING_AGENT_DIR` now selects that home on fresh launch, restart, resume and restore, even without a stored account override. Before upgrading, either prepare a readable `auth.json` there or remove that declaration to keep the managed seat login. Env-only accounts and wrappers that create the file after Pi starts are not supported by this path. Migration 151 preserves switch history; it does not create or copy credentials, or rewrite existing seat declarations. Relative values remain available to community harnesses and are not globally removed.
|
||||
|
||||
- **Extension dialogs:** The RPC runner shows extension `select`, `confirm`, `input` and `editor` requests in the pane and cancels them because the managed pane cannot answer them. The seat needs attention through the end of the turn, until a new agent run starts. Review the cancelled request and send an instruction the extension can complete without a dialog. Extension `notify` text is shown without raising attention or sending a response.
|
||||
|
||||
@@ -959,7 +1021,7 @@ This is human-authored (you choose the pod/member IDs) and system-validated (the
|
||||
| `spawned_by` | Source was spawned by target; the target launches first. Constrains launch order. | Child → parent in hierarchical topologies |
|
||||
| `can_observe` | Source can observe target's output. Does NOT constrain launch order. | Reviewer → implementer, monitor → worker |
|
||||
| `collaborates_with` | Peer collaboration relationship. Does NOT constrain launch order. | Co-equal peers working together |
|
||||
| `escalates_to` | Fallback queue escalation for a seat without a declared delegation parent. Does NOT constrain launch order. | Local member or same-daemon `pod-member@rig` address |
|
||||
| `escalates_to` | Fallback queue escalation for a seat without a single declared delegation parent (none, or several). Does NOT constrain launch order. | Local member or same-daemon `pod-member@rig` address |
|
||||
|
||||
A named `escalates_to` target may be in another rig on the same daemon and need not exist at import time.
|
||||
See [fallback escalation across rigs](edge-types.md#fallback-escalation-across-rigs) for precedence, single-hop delivery,
|
||||
@@ -1302,6 +1364,7 @@ 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 on `pi`, `codex`, `claude-code` and supported community-harness members (`[]` included); see the [runner contract](harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch) for community capability requirements. 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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ node check-runner.mjs --runner 'my-runner --agent-command {agent}' --name my-har
|
||||
- **`--name`** is your preset's name, which your posts carry as `runtime`.
|
||||
- **`--resume` and `--first-input`** say your preset declares `resume: true` and `first_input: true`. Without them,
|
||||
those cases are skipped, because OpenRig wouldn't pass that input.
|
||||
- **`--launch-wrapper`** says your preset declares `launch_wrapper: true` and enables case 10 from the
|
||||
[runner contract](../harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch).
|
||||
- **`--cases 2,4`** runs only some cases. `--json` prints a machine-readable report. `--keep` keeps each launch's
|
||||
folder: the agent's message log, the launch record and the scenario.
|
||||
- **`--wait-ms 20000`** waits longer for each step (default 8000), for a runner whose agent takes a while to start. The check's shorter settling times, and the fake agent's own delays and holds, grow in proportion.
|
||||
@@ -62,6 +64,7 @@ In an installed OpenRig, the check is at `$(npm root -g)/@openrig/cli/daemon/doc
|
||||
| 7c | 9 | **Advisory.** Closing the pane ends the agent process. Section 9 marks this duty as proposed, and lets a runner list in `limits` what may keep running, so this result is shown as met or not met and never counts as a pass or a fail, or toward the exit status. The fake agent outlives a hangup here, so the runner has to end it |
|
||||
| 8 | 7 | An unknown request from the agent gets JSON-RPC `-32601`, and the next prompt still goes through |
|
||||
| 9 | 9 | `OPENRIG_FIRST_INPUT`'s exact bytes, newlines and spaces included, are the first prompt |
|
||||
| 10 | 2 | The native child runs through the configured wrapper, preserving literal arguments, without inheriting the runner's wrapper handoff |
|
||||
|
||||
## What it doesn't check
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
// - a small local endpoint stands in for OpenRig's activity route, and records what your runner posts;
|
||||
// - input arrives the way `rig send` delivers it: one bracketed paste with raw newlines, then Enter.
|
||||
//
|
||||
// node check-runner.mjs --runner '<command>' [--name <preset name>] [--resume] [--first-input]
|
||||
// node check-runner.mjs --runner '<command>' [--name <preset name>] [--resume] [--first-input] [--launch-wrapper]
|
||||
// [--cases 1,2,4] [--wait-ms 8000] [--pass-env NAME] [--json] [--keep]
|
||||
//
|
||||
// `{agent}` in the command is replaced with the fake agent's path, an executable that takes any arguments your runner
|
||||
// adds. For example: --runner 'my-runner --agent-command {agent}'. --resume and --first-input say your preset declares
|
||||
// `resume: true` and `first_input: true`; without them those cases are skipped, since OpenRig wouldn't pass that input.
|
||||
// `resume: true` and `first_input: true`; --launch-wrapper declares `launch_wrapper: true`. Without a declaration, its case is skipped.
|
||||
// The runner gets a clean environment: PATH, HOME, USER, SHELL, LANG and TMPDIR, the OpenRig variables, and any
|
||||
// --pass-env names. Exit status: 0 when nothing fails, 1 when a case fails, 2 for a usage or setup problem.
|
||||
//
|
||||
@@ -27,10 +27,10 @@ import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const usage = "usage: node check-runner.mjs --runner '<command with {agent}>' [--name <preset>] [--resume] [--first-input] [--cases 1,2] [--wait-ms 8000] [--pass-env NAME] [--json] [--keep]";
|
||||
const usage = "usage: node check-runner.mjs --runner '<command with {agent}>' [--name <preset>] [--resume] [--first-input] [--launch-wrapper] [--cases 1,2] [--wait-ms 8000] [--pass-env NAME] [--json] [--keep]";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const opts = { name: "runner", cases: undefined, passEnv: [], resume: false, firstInput: false, json: false, keep: false, waitMs: 8000 };
|
||||
const opts = { name: "runner", cases: undefined, passEnv: [], resume: false, firstInput: false, launchWrapper: false, json: false, keep: false, waitMs: 8000 };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const flag = argv[i];
|
||||
const value = () => { if (i + 1 >= argv.length) throw new Error(`${flag} needs a value`); return argv[++i]; };
|
||||
@@ -41,6 +41,7 @@ function parseArgs(argv) {
|
||||
else if (flag === "--wait-ms") opts.waitMs = Number(value());
|
||||
else if (flag === "--resume") opts.resume = true;
|
||||
else if (flag === "--first-input") opts.firstInput = true;
|
||||
else if (flag === "--launch-wrapper") opts.launchWrapper = true;
|
||||
else if (flag === "--json") opts.json = true;
|
||||
else if (flag === "--keep") opts.keep = true;
|
||||
else throw new Error(`unknown option ${flag}`);
|
||||
@@ -178,7 +179,7 @@ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
|
||||
const launches = [];
|
||||
async function launch(label, { scenario = {}, env = {}, asks } = {}) {
|
||||
async function launch(label, { scenario = {}, env = {}, asks, launchWrapper = false } = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), `runner-check-${label}-`));
|
||||
const cwd = join(dir, "work");
|
||||
mkdirSync(cwd);
|
||||
@@ -196,6 +197,13 @@ async function launch(label, { scenario = {}, env = {}, asks } = {}) {
|
||||
const agent = join(dir, "agent");
|
||||
writeFileSync(agent, `#!/bin/sh\nexec ${sh(process.execPath)} ${sh(join(here, "fake-acp-agent.mjs"))} --scenario ${sh(join(dir, "scenario.json"))} --log ${sh(h.agentLog)} "$@"\n`);
|
||||
chmodSync(agent, 0o755);
|
||||
h.agent = agent;
|
||||
if (launchWrapper) {
|
||||
h.wrapperLog = join(dir, "wrapper.json");
|
||||
const wrapper = join(dir, "wrapper.sh");
|
||||
writeFileSync(wrapper, `#!/bin/sh\nrecord=$1\ntag=$2\nseparator=$3\nshift 3\n${sh(process.execPath)} -e 'const [file, tag, separator, ...argv] = process.argv.slice(1); require("node:fs").writeFileSync(file, JSON.stringify({tag, separator, argv, launchWrapper: process.env.OPENRIG_LAUNCH_WRAPPER ?? null}));' "$record" "$tag" "$separator" "$@"\nexec "$@"\n`);
|
||||
env = { ...env, OPENRIG_LAUNCH_WRAPPER: JSON.stringify(["/bin/sh", wrapper, h.wrapperLog, "literal ' $ ; space", "--"]) };
|
||||
}
|
||||
const vars = {
|
||||
PATH: process.env.PATH, HOME: process.env.HOME, USER: process.env.USER, SHELL: process.env.SHELL, LANG: process.env.LANG,
|
||||
TMPDIR: process.env.TMPDIR, TERM: "xterm-256color",
|
||||
@@ -781,9 +789,22 @@ const cases = {
|
||||
if (!opts.keep) rmSync(dir, { recursive: true, force: true });
|
||||
return r;
|
||||
},
|
||||
async "10"() {
|
||||
const r = result("10", "Launch wrapper: the runner wraps its native child and scrubs the handoff environment");
|
||||
if (!opts.launchWrapper) return r.skip("the preset doesn't declare launch_wrapper (--launch-wrapper): OpenRig passes no OPENRIG_LAUNCH_WRAPPER");
|
||||
const h = await launch("10", { launchWrapper: true });
|
||||
r.check("the wrapped child opens its conversation", await ready(h), "no launch record");
|
||||
const seen = existsSync(h.wrapperLog) ? JSON.parse(readFileSync(h.wrapperLog, "utf8")) : undefined;
|
||||
r.check("the wrapper receives literal arguments and the native child command", seen?.tag === "literal ' $ ; space" && seen?.separator === "--"
|
||||
&& seen?.argv?.[0] === h.agent, JSON.stringify(seen ?? null));
|
||||
r.check("OPENRIG_LAUNCH_WRAPPER is absent from the wrapper and child environment", seen?.launchWrapper === null
|
||||
&& h.events("started")[0]?.launchWrapper === null, "wrapper handoff leaked to native child");
|
||||
return r;
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
const order = ["1", "1b", "2", "3", "3b", "4", "4b", "4c", "5", "6", "7", "7b", "7c", "8", "9"];
|
||||
const order = ["1", "1b", "2", "3", "3b", "4", "4b", "4c", "5", "6", "7", "7b", "7c", "8", "9", "10"];
|
||||
const selected = opts.cases ?? order;
|
||||
const unknown = selected.filter((id) => !order.includes(id));
|
||||
if (unknown.length) { console.error(`unknown case ${unknown.join(", ")}; cases are ${order.join(", ")}`); process.exit(2); }
|
||||
|
||||
@@ -42,7 +42,7 @@ const fail = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, me
|
||||
const exit = (status, why) => { log({ dir: "event", event: "exit", status, why }); process.exit(status); };
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
log({ dir: "event", event: "started", pid: process.pid, cwd: process.cwd(), argv: process.argv.slice(2) });
|
||||
log({ dir: "event", event: "started", pid: process.pid, cwd: process.cwd(), argv: process.argv.slice(2), launchWrapper: process.env.OPENRIG_LAUNCH_WRAPPER ?? null });
|
||||
// Ctrl-C is the runner's to handle, so the agent never takes it as a signal. A stubborn agent also outlives a hangup and
|
||||
// its input closing, as some real agents do: only the runner ending it (SIGTERM or SIGKILL) stops it.
|
||||
process.on("SIGINT", () => log({ dir: "event", event: "signal", signal: "SIGINT" }));
|
||||
|
||||
@@ -161,10 +161,25 @@ or `pending`, `draft`. A word that matches none of these counts as `active`, so
|
||||
slice marked `complete` counts as `active`. A slice with no `status:` counts as
|
||||
`draft`.
|
||||
|
||||
`rig view show execution` shows the slice's own `status:` word (from its
|
||||
`SPEC.md`, or a legacy `README.md`) and that word's bucket beside each slice's
|
||||
ladder as `recorded_status` (`word: null` when there's none). Like the scopes
|
||||
views, it never reads `PROGRESS.md`; `GET /api/slices` lets a `status:` in
|
||||
`PROGRESS.md` frontmatter override the node file's. It's the team's record, not
|
||||
verified, so it never turns a rung true or makes a slice next-up.
|
||||
The ladder's rungs read Part B records: plan lock, `candidate:<sha>` rows, review
|
||||
artifacts and an EC-3 `worktree_path=` row. On this flow they read `INDETERMINATE`
|
||||
(plan lock reads `false`), and `q4_records` says once for the mission that no
|
||||
build is recorded. Built means a Git commit, so work that isn't a commit never
|
||||
reads built.
|
||||
|
||||
When the owning scope selects `proofPolicy.judges`, use `rig proof judge` to
|
||||
record an attributed accept, reject or withdraw decision on a contract item,
|
||||
and `rig proof show` to read derived readiness up through slice, mission and
|
||||
project. The nearest slice, mission or project policy selects authorized judges;
|
||||
project. The nearest slice, mission or project policy selects authorized judges.
|
||||
By default an item's latest judgment from any of them decides it; with
|
||||
`proofPolicy.everyJudge: true`, every listed judge's own current accept is
|
||||
required, and `rig proof show` lists each judge's own latest judgment either way.
|
||||
`rig proof --help` describes setup, selectors and evidence requirements. In a
|
||||
multi-project workspace catalog, name the project with `--project <id>` or a
|
||||
`<id>:` scope prefix (`alpha:m0/slices/01-t001`); unqualified scopes use the
|
||||
|
||||
@@ -41,6 +41,69 @@ nothing to send later.
|
||||
|
||||
## Configure and inspect
|
||||
|
||||
### Latest permission observation
|
||||
|
||||
`rig seat status <seat>` can show the latest observed harness permission request,
|
||||
with a fixed tool descriptor, source time and actor category. This is one latest
|
||||
observation across actors, not an aggregate of pending prompts. `request_observed`
|
||||
leaves the displayed actor's pending state unknown. `completed` applies only to
|
||||
that actor's later tool batch; other actors' pending state is still unknown. An
|
||||
unavailable observation proves neither a permission stop nor the absence of a
|
||||
waiting native prompt. With no observation, human status omits this section;
|
||||
JSON keeps the explicit unavailable state. The JSON field is `permission_stop`,
|
||||
and its `pending` value has the same observation-only scope.
|
||||
|
||||
An observation may also carry Claude's **suggested rules** as `suggested_rules`.
|
||||
These are read-only evidence, not applied or approved, not guaranteed effective,
|
||||
not the exact command, and not a complete list of dialog options. The supported
|
||||
subset of [Claude's permission update schema](https://code.claude.com/docs/en/hooks#permission-update-entries)
|
||||
is `addRules`, `replaceRules` and `removeRules`, preserving type, ordered rules
|
||||
(`toolName`, optional `ruleContent`), behavior and destination exactly. Strings
|
||||
are neither trimmed nor broadened; absent `ruleContent` remains distinct from
|
||||
an empty string. Mode/directory updates and malformed entries are withheld.
|
||||
|
||||
The envelope distinguishes a missing native field from a present empty array;
|
||||
`unsupported` and `size_cut` mark incomplete evidence. An older relay without
|
||||
this field, or a failed formatter, is unavailable rather than proof of native
|
||||
absence. Bounds are eight entries, sixteen rules per entry, 128 UTF-16 code
|
||||
units per tool name, 2,048 per rule-content string, and 16,384 UTF-8 bytes of
|
||||
serialized evidence. Oversized whole entries are dropped with `size_cut`,
|
||||
never shortened into different plausible rules. Both producer and ingress
|
||||
validate these bounds independently. Terminal JSON escapes controls and
|
||||
nonprinting format/separator characters while preserving decoded string values;
|
||||
its representation can expand, so the byte bound applies to stored/transported
|
||||
evidence, not the entire formatted status output.
|
||||
|
||||
**Suggested rule strings can contain sensitive values.** The relay sends only
|
||||
this bounded rule subset alongside the fixed descriptor. Rule content can itself
|
||||
contain sensitive command-like text; raw `tool_input`, arguments outside that
|
||||
suggestion, and provider reasons are not forwarded. It posts to `/api/activity/hooks` at the
|
||||
configured `OPENRIG_URL` (or host/port), or the discovered endpoint in
|
||||
`OPENRIG_HOME/activity-endpoint.json`. Both `GET /api/seat/status/:seatRef` and
|
||||
`GET /api/seat/status/node/:nodeId` can return the evidence. The daemon adds no
|
||||
event, log, queue or telemetry persistence for it; a caller can still capture
|
||||
status output in a transcript or file. Evidence shares the observation's
|
||||
five-minute lifetime and existing seat/actor limits, is discarded lazily on the
|
||||
next observation/status read after expiry, and is lost on restart. No rule,
|
||||
permission/configuration write, hook decision, edited input, prompt submission
|
||||
or new attention/delivery behavior is produced.
|
||||
|
||||
Actor attribution is a convention derived from the [Claude hook schema](https://code.claude.com/docs/en/hooks#common-input-fields): an omitted or null `agent_id`
|
||||
is treated as main, a valid ID identifies a subagent or in-process teammate, and
|
||||
an invalid ID is dropped rather than inferred as main. The fixtures are synthetic;
|
||||
no live PermissionRequest capture qualifies that convention on an installed native
|
||||
version. A hook denial without a source-bound guard receipt has unknown origin.
|
||||
This diagnostic adds no guard collector, permission decision or attention policy.
|
||||
|
||||
The new PermissionRequest diagnostic is optional for activity-hook readiness.
|
||||
Every preexisting required event, relay asset and explicit hook-disable check
|
||||
still applies. An older installed plugin at the same manifest version retains
|
||||
its bytes and may not send this event: plugin-only delivery needs a future normal
|
||||
maintainer release refresh, and managed delivery needs normal reprojection. A
|
||||
manifest or green readiness check does not prove live hook execution. Raw tool
|
||||
arguments and provider reason text are withheld; diagnostic evidence is bounded,
|
||||
memory-only and expires after five minutes.
|
||||
|
||||
### Who may intervene
|
||||
|
||||
`rig seat status <seat>` shows the seat's terminal interaction account alongside
|
||||
@@ -90,12 +153,16 @@ drive a recognized native menu as before. Otherwise, with the delivery guard
|
||||
attached, it reads the visible composer under the seat's lease immediately before
|
||||
pasting, after buffer preparation. Empty input proceeds. A recognized dim
|
||||
suggestion is displayed separately and counts as empty; typed or pasted text
|
||||
(author unknown) stops the send with `override_input_present`. An unreadable input
|
||||
stops with `override_input_unknown`, never an empty-input claim. Both return single-send HTTP
|
||||
(author unknown) stops the send with `override_input_present`: single-send HTTP
|
||||
409, `sent: false`, and caller-only `existingInput` evidence (visible rows, suggestion
|
||||
rows, cursor/dimensions and target binding). A collapsed paste shows its label,
|
||||
not the hidden contents. OpenRig neither holds nor retries this stopped override.
|
||||
|
||||
An input that can't be read doesn't stop the override. The answer is typed and
|
||||
nothing is cleared. The result reports `existingInput` with classification
|
||||
`unreadable`, never empty, and a warning that the answer may join or submit
|
||||
existing text.
|
||||
|
||||
Inspect the shown input in the native terminal, deliberately clear the prompt with
|
||||
native/tmux controls, then issue the send again. Manual clearing runs outside
|
||||
OpenRig's lease and observation checks and is not recorded. For a deliberate
|
||||
@@ -173,6 +240,26 @@ 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
|
||||
@@ -273,7 +360,10 @@ 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, human drafts and recipient identity. Scheduler timing,
|
||||
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,
|
||||
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.
|
||||
|
||||
|
||||
@@ -66,7 +66,14 @@ not fill in; if so, follow the form.
|
||||
asked at RECORD stays on Steering and the Feed without a post. Running it again reuses each ask's
|
||||
one decision rather than making another, so an ask whose post is confirmed isn't posted again. As
|
||||
with any Slack post, one whose earlier attempt Slack couldn't confirm may post once more, marked as
|
||||
a possible repeat. `--wait <seconds>` (default 10) is how long it waits for Slack receipts.
|
||||
a possible repeat. An ask OpenRig can't fully check still posts, once per gateway run, through the
|
||||
person's delivery rules, so a readable quiet window still holds it. That's an ask whose attempt
|
||||
record can't be read, whose state can't be checked, or whose person's quiet hours can't be read.
|
||||
The Slack post can show "Delivery may repeat after an earlier unconfirmed attempt.", "OpenRig
|
||||
couldn't check that this ask is still open." and "OpenRig couldn't read your quiet hours." These
|
||||
supplementary notes are omitted if they do not fit. The row and command also report when the
|
||||
person's quiet-hours setting couldn't be read. An ask that is no longer open isn't posted. `--wait <seconds>` (default 10) is how long it waits for
|
||||
Slack receipts.
|
||||
|
||||
## What the app asks for
|
||||
|
||||
@@ -349,8 +356,7 @@ decisions, and posts its own replies.
|
||||
- **Changing forwarding entries is not a drain or cancellation:** relays already retained keep
|
||||
their original host and remain visible in `rig slack status`, even after the last entry is
|
||||
removed. An enabled connector retries them; disabling it pauses retry without deleting them.
|
||||
Let relays finish before removing a forward entry. A zero dead-letter backlog alone does not
|
||||
prove they have drained: it does not count relay HTTP requests still in flight.
|
||||
Let relays finish before removing a forward entry; `rig slack status` shows local in-flight attempts separately from retained records, but zero does not prove global quiescence.
|
||||
- **Who answered:** every post starts `From <seat> on <machine>`, using the machine's host id (set
|
||||
it with `host.name`).
|
||||
- **The hub's own channels** are handled on the hub, as before. A channel can be relayed to one
|
||||
@@ -378,11 +384,18 @@ Quiet hours hold a person's routine asks while they're on, and post them once th
|
||||
wake-ladder alerts. These reach the person exactly as they would outside quiet. An ask below the posting level is only
|
||||
logged, as at any time.
|
||||
- **When quiet ends,** each held ask that never started posts once, on its own, with no mention. One that no longer applies
|
||||
isn't posted, and its row says why: it closed, a newer notification replaced it, its recipient changed, or it's now below
|
||||
the posting level.
|
||||
isn't posted, and its row says why: it closed, a newer notification replaced it, its recipient changed, its person has no
|
||||
connector any more (it stays on Steering and the Feed), or it's now below the posting level.
|
||||
- **A post that may already be in Slack**, confirmed or only partly sent, isn't held: it finishes as itself, in its own
|
||||
thread.
|
||||
- **If quiet hours can't be read,** nothing is posted to that person until they can.
|
||||
- **If quiet hours can't be read,** that isn't a quiet window. An ask that would post goes to its usual destination with
|
||||
no mention, and the post says "OpenRig couldn't read your quiet hours."; the ask's row records it. The person's other
|
||||
delivery settings still decide: an escalation, a digest, an ask below the posting level, and a quiet window that can be
|
||||
read work as before. This covers ordinary posts and digests, and asks already held for quiet hours: while the quiet
|
||||
hours can't be read, the next release check (within about a minute) posts each of them, with no mention and the same
|
||||
line, even if the window may still be on. The line is supplementary: a post at Slack's text or block limit leaves it
|
||||
out, and the ask's row and `rig slack post-open-asks` still say the quiet hours couldn't be read. A person reached
|
||||
through a relay waits until their quiet hours can be read, as before, because that post can't carry the line.
|
||||
|
||||
## Reconnect recovery and status
|
||||
|
||||
@@ -406,9 +419,11 @@ 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 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.
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -455,9 +470,18 @@ 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, 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
|
||||
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
|
||||
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).
|
||||
@@ -466,8 +490,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, 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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -115,3 +115,67 @@ successful empty history. The CLI preserves the error body in JSON mode and exit
|
||||
1 for request errors, 2 for unavailable reads. Existing SSE and usage APIs retain
|
||||
their behavior. Collector deployment, exporters and durable store epochs are
|
||||
separate work.
|
||||
|
||||
## Independent exporter mapping and limits
|
||||
|
||||
OpenRig's telemetry guarantee ends at the finite HTTP/CLI read envelope above. Any
|
||||
projection of those finite reads (or of cached rig/node inventory) into
|
||||
OpenTelemetry logs, custom metrics, or spans is defined and owned by an external
|
||||
consumer or exporter—not guaranteed by OpenRig's core API.
|
||||
|
||||
The external OTLP exporter at `smota/openrig-telemetry` applies the following
|
||||
exporter-specific mapping choices to OpenRig's source facts (other consumers may
|
||||
make different choices):
|
||||
|
||||
- **Recorded queue-state intervals (not execution time)**: In OpenRig, queue
|
||||
transition rows record state changes (`pending`, `in-progress`, `blocked`, and
|
||||
departure states), not agent execution duration or model latency; the first
|
||||
observation of an item has no prior boundary; repeated same-state rows remain
|
||||
distinct source records; and any page carrying an ID-range or withheld-record gap
|
||||
(`before_retained_floor`, `retained_sequence_gap`, `conflicting_transition_id`,
|
||||
`record_byte_limit`, `historical_transitions_not_read`, truncated gap details, or
|
||||
a boot/watermark discontinuity) cannot prove what happened in the missing range.
|
||||
In `smota/openrig-telemetry`, the exporter pairs an entering state with a later
|
||||
departure state to measure only the recorded interval between two observed rows
|
||||
with valid, ordered UTC timestamps, suppresses or drops open intervals across gap
|
||||
boundaries rather than inventing durations, and reports cumulative transition
|
||||
counts only for rows observed from its checkpoint (including historical rows when
|
||||
started with `start=retained`), never lifetime daemon totals.
|
||||
- **No current-owner, seat, tenure, or native-session join**: In OpenRig's finite
|
||||
API, the `queue-transitions` stream does not join transition rows to current seat
|
||||
owners, mutable node or rig names, occupant tenures (`tenures`), or native
|
||||
harness sessions, and it never selects queue bodies or transition notes; each
|
||||
transition row does include `qitemId`, `actorSession`, `closureTarget`, and
|
||||
`originalTimestamp`. In its exported OTLP output, `smota/openrig-telemetry`
|
||||
chooses to emit derived queue spans as root/internal observations without
|
||||
invented parent/child trace hierarchy, cross-stream causal links, or `gen_ai.*`
|
||||
model/token/cost attributes; places `qitemId` only on opt-in log/span attributes
|
||||
rather than metric dimensions; and omits the source fields `actorSession`,
|
||||
`closureTarget`, and raw `originalTimestamp` from exported OTLP logs, metrics,
|
||||
and spans.
|
||||
- **Sampling unknowns on cached seat inventory**: OpenRig's cached rig and seat
|
||||
inventory endpoints (`GET /api/rigs`, `GET /api/rigs?archived=only`,
|
||||
`GET /api/rigs/:rigId/nodes`) return point-in-time daemon projections
|
||||
(`activityState.display`, `sessionStatus`, `arbitratedNeedsInput`, and
|
||||
`activityState.lastSwap`), not a continuous event stream or independent OS
|
||||
process probe; intermediate activity changes or multiple occupant handovers
|
||||
between polls are not observable from those snapshots, and initial
|
||||
observation has no backfilled history. In OpenRig source,
|
||||
`arbitratedNeedsInput` is a tri-state boolean (`true`, `false`, or `null`):
|
||||
`true` means the seat needs input, `false` means it was observed clear of
|
||||
needing input, and `null` means no evidence exists either way (it does not
|
||||
indicate `false`). Separately, external consumers or metric mappings must
|
||||
choose how to interpret or project this tri-state value into their own
|
||||
metrics or counters. `smota/openrig-telemetry` polls these snapshots as
|
||||
point-in-time observations and relies on downstream metric storage to enforce
|
||||
freshness/staleness TTL when polling stops or a seat departs.
|
||||
|
||||
### External OTLP exporter repository and version compatibility
|
||||
|
||||
An independent, standalone OTLP/HTTP exporter is maintained outside OpenRig core at:
|
||||
|
||||
- **Repository**: <https://github.com/smota/openrig-telemetry>
|
||||
|
||||
Its repository documents installation, configuration, and supported OpenRig versions; OpenRig does not test or guarantee the exporter.
|
||||
|
||||
OpenRig's finite telemetry reads are versioned (`schemaVersion: 1`), while cached rig and node inventory endpoints are operational projections rather than a versioned telemetry contract.
|
||||
@@ -72,16 +72,52 @@ 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 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.
|
||||
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.
|
||||
|
||||
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
|
||||
Writable tiles attach immediately, as in 0.7.10. Their visible terminal sizes
|
||||
the agent's PTY under tmux's normal client sizing policy, and resizing the
|
||||
terminal reflows new output. Read-only tiles remain observers: they neither
|
||||
resize nor type into another seat. An ordinary tmux detach ends the writable
|
||||
tile without stopping the agent. A replacement with a different managed seat
|
||||
identity is not followed. If no replacement returns in time, or a
|
||||
session has no managed identity, reopen the rig view with
|
||||
`rig terminal open rig:<name> --window` (or your explicit provider). Views
|
||||
opened before this change must be closed and reopened to get this behavior.
|
||||
|
||||
As in published 0.7.10, Enter or Ctrl-C typed while a new writable tile is still
|
||||
attaching can close that viewer. The agent keeps running; reopen the view.
|
||||
Once attached, keys and paste use native tmux input handling.
|
||||
|
||||
## Sizing and older views
|
||||
|
||||
Use tmux's inherited `window-size latest` policy for the terminal most recently
|
||||
used to control sizing. OpenRig does not change a person's global tmux policy.
|
||||
A deliberate `window-size manual` setting remains an explicit fixed-size choice.
|
||||
OpenRig's temporary layout and readable-capture pins release by unsetting the
|
||||
window's local `window-size` option; they do not restore an old local override.
|
||||
With the last pin released, the window inherits the global policy again.
|
||||
|
||||
Browser terminals do not pin the agent's PTY by default. For an explicit fixed
|
||||
90-by-27 browser terminal, use `rig config set terminal.fixed_geometry true`.
|
||||
Set it to `false` to return to the default. This applies to newly created browser
|
||||
brokers after existing browser subscribers disconnect; it does not configure
|
||||
native Herdr tiles. A temporary readable capture uses
|
||||
`rig capture <seat> --readable` or `--size <columns>x<rows>` and releases its pin
|
||||
when the capture finishes. It does not unset a manually fixed window it did not
|
||||
pin.
|
||||
|
||||
Kept writable 0.7.8/0.7.9 viewers can retain an old `ignore-size` attach, and
|
||||
pre-reflow 0.7.11 viewers can retain the old mirrored display. Changing saved
|
||||
argv cannot change that running client. Close only the owned Herdr viewer pane
|
||||
and open a fresh view with `rig terminal open <view> --provider herdr`; the
|
||||
agent's tmux session and conversation keep running. For a retained `--follow`
|
||||
canvas, first select `--no-follow`, then close its owned viewer panes and open
|
||||
a fresh ordinary view. Continuing that old following canvas is not an automatic
|
||||
live-client migration. Existing 0.7.10 sizing clients and read-only viewers do
|
||||
not need this step.
|
||||
|
||||
## Desktop windows and previews
|
||||
|
||||
```sh
|
||||
@@ -153,6 +189,10 @@ timed out, OpenRig closes nothing: the result says `rex_outcome_unknown`, or
|
||||
adds a note when other pages opened, and names the session to check in Rex
|
||||
before retrying. `rig terminal status` gives Rex about a second to answer.
|
||||
|
||||
## herdr-app
|
||||
|
||||
`--provider herdr-app` opens the view in the herdr-app window you run it in. Inside a herdr-app terminal, plain `rig terminal open <view>` does the same when that window is answering (finding the socket file is how OpenRig guesses you are in a herdr-app window, and whether the window is reachable is checked by asking it). `--window` always asks for a new desktop window. See [OpenRig views in herdr-app](herdr-app.md).
|
||||
|
||||
## HTTP API
|
||||
|
||||
- `POST /api/terminal/open`: `{ view, provider?: "herdr", session?: string, expectedPlan?: string }`.
|
||||
@@ -222,11 +262,26 @@ after a grace period; the agent and its conversation keep running. The
|
||||
explanation strip names joins, departures, unavailable terminals and limits.
|
||||
The first workspace on an empty provider may become focused.
|
||||
|
||||
This increment supports the qualified **Linux Herdr 0.9.5/protocol 22**
|
||||
endpoint. Other platforms/providers and other selector kinds retain ordinary
|
||||
one-shot open. Use `--provider herdr --json` to operate without opening a
|
||||
desktop window, and `--session <name>` to select that endpoint. Opening a tile
|
||||
can change the agent's terminal size; `ignore-size` does not guarantee isolation.
|
||||
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.
|
||||
|
||||
```bash
|
||||
rig terminal open qitem:example --follow --provider herdr --json
|
||||
@@ -284,11 +339,17 @@ operation uses the daemon's existing terminal bearer and browser boundary;
|
||||
a bridge must retain its signed-in owner, Origin and expiry controls. It does
|
||||
not grant authority based on task membership or accept a caller terminal ID.
|
||||
|
||||
On the qualified Linux Herdr 0.9.5/protocol 22 endpoint, it creates one viewer
|
||||
On a supported Linux or macOS Herdr protocol-22 endpoint, it creates one viewer
|
||||
or reuses an owned, verified `{workspaceId,paneId,terminalId,readOnly}` mapping.
|
||||
A repeated/concurrent request does not create another viewer. 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. Each viewer's
|
||||
private `attach.sh` is kept for provider restarts. `--no-follow` preserves the
|
||||
workspace and its scripts; stopping Herdr is not proof that its panes are gone.
|
||||
Core removes only an exact, unedited owned script after confirming its viewer
|
||||
is absent. Old views that are never reconciled again can therefore accumulate
|
||||
scripts; there is no cleanup based only on an unreachable provider.
|
||||
|
||||
A lost create reply is reconciled before starting anything else; a lost run
|
||||
reply is checked against the actual foreground process, never blindly repeated. Unknown or
|
||||
changed terminal identity remains unconfirmed. The operation does not relayout,
|
||||
close departed viewers, take over control, focus existing panes or launch/stop
|
||||
agents. A first workspace on an otherwise empty Herdr server may become focused.
|
||||
@@ -300,17 +361,22 @@ small presentation provenance, separate from queue membership. Herdr display
|
||||
labels are not that provenance. Other providers/platforms remain unsupported
|
||||
for ensure-viewer until qualified; ordinary open retains its existing behavior.
|
||||
|
||||
A writable tile attaches with `tmux attach -f ignore-size` when its session
|
||||
already has a client that sizes it, so the tile doesn't fight that client over
|
||||
the agent window's dimensions. When the session has no sizing client, as with a
|
||||
freshly launched seat, the tile attaches without the flag and sizes the window
|
||||
itself: tmux applies ignore-size across the whole server, so a session viewed
|
||||
only by ignore-size clients would otherwise keep its 80x24 creation size while
|
||||
any other session has a sizing client. Read-only clients keep `-r` (which also
|
||||
implies ignore-size), and the printed manual attach commands keep
|
||||
`-f ignore-size`. This is **not resize isolation**: opening a tile can change
|
||||
the agent's terminal size. Observers of a Herdr terminal do not themselves resize it;
|
||||
a controlling browser can. No cross-version resize guarantee is implied.
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -87,6 +87,37 @@ 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
|
||||
@@ -164,3 +195,20 @@ 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.
|
||||
+196
-313
@@ -1,328 +1,211 @@
|
||||
# What changed in OpenRig 0.7.9
|
||||
# What changed in OpenRig 0.7.1
|
||||
|
||||
This note is for an agent on an install that was just upgraded to 0.7.9. OpenRig
|
||||
0.7.9 records default runner permits, passes a person's typed words to the
|
||||
agent, turns stream intake on by default, and moves guidance for fresh Pi seats,
|
||||
and fresh Codex seats on 0.164.0 or later, out of the repository's `AGENTS.md`.
|
||||
If you upgraded from 0.7.7 or
|
||||
earlier, also read "What changed in OpenRig 0.7.8" below. 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.
|
||||
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.
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
|
||||
## Runner seats
|
||||
## New things you can do
|
||||
|
||||
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`.
|
||||
- **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.
|
||||
|
||||
## OpenShell workers
|
||||
## Behaviour that changed
|
||||
|
||||
0.7.9 includes a repair for the OpenShell worker that wasn't usable in 0.7.8,
|
||||
but it hasn't run end to end in our release testing. Don't rely on this path in
|
||||
0.7.9. Startup now 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. 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)."
|
||||
- **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`.
|
||||
|
||||
## Assistants connected through a grant service (experimental)
|
||||
- **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.
|
||||
|
||||
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.
|
||||
## Output that changed shape
|
||||
|
||||
## Other changes to act on
|
||||
- **`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.
|
||||
|
||||
- **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'`.
|
||||
## What to stop doing
|
||||
|
||||
## Limits to keep in view
|
||||
- **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.
|
||||
|
||||
- **OpenShell** hasn't run end to end in our release testing (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 open across the 0.7.8
|
||||
upgrade showed "Hook failed — plugin contents changed; hook approval
|
||||
required". The 0.7.9 behaviour, and hook recovery after a relaunch, haven't
|
||||
been verified.
|
||||
- **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 and 0.7.7 limits below, and the
|
||||
[0.7.6 limits](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6), still
|
||||
apply.
|
||||
## Known gaps in 0.7.1
|
||||
|
||||
---
|
||||
|
||||
## 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; 0.7.9's repair is 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.
|
||||
- **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 limits below, and the [0.7.6 limits](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6), still apply.**
|
||||
|
||||
---
|
||||
|
||||
## What changed in OpenRig 0.7.7
|
||||
|
||||
The changes from 0.7.6 to 0.7.7, a reliability patch for 0.7.6, published on
|
||||
2026-11-02. All three fixes are in 0.7.8.
|
||||
|
||||
### The fixes
|
||||
|
||||
- A Claude seat at an empty prompt reads idle again when Claude prints
|
||||
"✔ Update installed · /restart to apply" (or "Restart to apply") below its mode
|
||||
bar after installing an update. Before, that line hid the mode bar, so the seat
|
||||
read unknown and managed compaction waited for an idle prompt it could not see.
|
||||
Both spellings are recognized; lines that only look similar still read unknown.
|
||||
([#2311](https://github.com/mvschwarz/openrig/pull/2311))
|
||||
- Managed compaction checks the input box before it pastes its preparation
|
||||
prompt, `/compact` or a restore prompt, even with the typing guard off. Typed,
|
||||
unreadable or changed input holds that step. A manual `rig compact` then says it
|
||||
stopped to preserve the input box; read `rig compact <seat> --state` and retry
|
||||
once the box is empty. A held restore prompt waits and goes ahead on its own once
|
||||
the box is empty. An empty box or a high-confidence suggestion goes ahead.
|
||||
Ordinary `rig send` and the typing-guard defaults are unchanged.
|
||||
([#2347](https://github.com/mvschwarz/openrig/pull/2347))
|
||||
- A Claude seat whose `claude` is a `#!` wrapper that starts the real Claude without
|
||||
`exec` can be rebuilt again (`rig seat handover --source rebuild`): readiness also
|
||||
accepts the wrapper's direct child when it names this launch's conversation token,
|
||||
and refuses every other process as before. A rebuild that fails no longer leaves
|
||||
the seat's resume target on the new, unprimed conversation.
|
||||
([#2341](https://github.com/mvschwarz/openrig/issues/2341), [#2354](https://github.com/mvschwarz/openrig/pull/2354))
|
||||
|
||||
### Limits to keep in view
|
||||
|
||||
- Other reasons managed compaction can wait are unchanged.
|
||||
- The input-box check is a read just before the paste, not a lock: typing that
|
||||
starts in the same moment can still be appended to.
|
||||
- If a seat's current occupant generation was never registered (an adopted Claude
|
||||
with another generation, or a managed launch whose tenure record failed to write),
|
||||
hooks don't move its resume token after the first one.
|
||||
- The [0.7.6 limits](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6) 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`.
|
||||
Generated
+42
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.9",
|
||||
"version": "0.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openrig",
|
||||
"version": "0.7.9",
|
||||
"version": "0.7.1",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
@@ -4260,6 +4260,13 @@
|
||||
"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",
|
||||
@@ -5230,6 +5237,18 @@
|
||||
"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",
|
||||
@@ -5531,6 +5550,16 @@
|
||||
"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",
|
||||
@@ -5839,6 +5868,13 @@
|
||||
"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",
|
||||
@@ -7262,7 +7298,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.7.9",
|
||||
"version": "0.7.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -7300,7 +7336,7 @@
|
||||
},
|
||||
"packages/daemon": {
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.7.9",
|
||||
"version": "0.7.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
@@ -7317,6 +7353,7 @@
|
||||
"@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"
|
||||
@@ -7342,7 +7379,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.7.9",
|
||||
"version": "0.7.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.9",
|
||||
"version": "0.7.1",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -19,6 +19,7 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
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.
|
||||
@@ -0,0 +1,36 @@
|
||||
# @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`.
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/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);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"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]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.7.9",
|
||||
"version": "0.7.1",
|
||||
"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": [
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { JsonRpcPeer } from "./json-rpc-stdio.js";
|
||||
|
||||
// OpenRig as an ACP agent (rig acp serve): an ACP app's prompt becomes a queue row to the team's receiver, the row's
|
||||
// transitions and handoffs come back as progress, and its closing resolution is the reply. A question the team asks the
|
||||
// caller while it works comes to the app as a permission request, and the choice goes back through the person's answer
|
||||
// path. The queue is the only work store: a session is the rows tagged with its id, so it can be listed and reopened.
|
||||
// A turn's end_turn says only that the turn ended: the reply says whether the team closed the item, and a cancel stops
|
||||
// this process waiting, never the team.
|
||||
|
||||
export const ACP_PROTOCOL_VERSION = 1;
|
||||
export const AUTH_METHOD_ID = "openrig-local";
|
||||
export const SESSION_TAG = "acp-session:";
|
||||
export const CWD_TAG = "acp-cwd:";
|
||||
/** Where the caller's prompt ends in a row's body; the rest is the note rig acp serve adds for the team. */
|
||||
const FOOTER = "\n\n---\nSent by ";
|
||||
|
||||
/** One queue row as this adapter reads it. */
|
||||
export interface TeamRow {
|
||||
qitemId: string;
|
||||
state: string;
|
||||
destinationSession: string;
|
||||
handedOffTo: string | null;
|
||||
closureReason: string | null;
|
||||
closureTarget: string | null;
|
||||
resolution: string | null;
|
||||
/** Who closed the row, from its closing transition (a note written after the close doesn't change it); null while open. */
|
||||
closer: string | null;
|
||||
}
|
||||
|
||||
export interface TeamTransition {
|
||||
transitionId: number;
|
||||
ts: string;
|
||||
state: string;
|
||||
transitionNote: string | null;
|
||||
actorSession: string;
|
||||
}
|
||||
|
||||
/** A row to the caller that asks for a decision, with the choices it offers (none: words only). */
|
||||
export interface TeamAsk {
|
||||
qitemId: string;
|
||||
sourceSession: string;
|
||||
state: string;
|
||||
tsCreated: string;
|
||||
summary: string | null;
|
||||
body: string;
|
||||
tags: string[];
|
||||
questions: Array<{ id: string; question: string; options: Array<{ id: string; label: string; recommended?: boolean }> }>;
|
||||
/** What's recorded so far, by question id: the chosen option's id, or the words of a typed reply. */
|
||||
answers: Record<string, string>;
|
||||
}
|
||||
|
||||
/** A row the caller sent through rig acp serve. */
|
||||
export interface SentRow {
|
||||
qitemId: string;
|
||||
tsCreated: string;
|
||||
tsUpdated: string;
|
||||
summary: string | null;
|
||||
body: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/** The daemon answered a create and refused it, so nothing was written. Any other failure of a create leaves it unknown. */
|
||||
export class TeamRefused extends Error {}
|
||||
|
||||
/** The queue verbs the adapter uses, acting as the caller. */
|
||||
export interface TeamQueue {
|
||||
/** Create the row under the id the adapter chose, so a create whose answer is lost can be read back by that id. Throws
|
||||
* TeamRefused only for a refusal the daemon answered with. */
|
||||
create(row: { qitemId: string; destination: string; body: string; summary: string; tags: string[] }): Promise<void>;
|
||||
show(qitemId: string): Promise<TeamRow>;
|
||||
/** The row, or null when this machine has no such row; throws when it can't tell. */
|
||||
find(qitemId: string): Promise<TeamRow | null>;
|
||||
transitions(qitemId: string): Promise<TeamTransition[]>;
|
||||
/** The row a handoff of `qitemId` created on this machine, if any. */
|
||||
successor(qitemId: string): Promise<string | undefined>;
|
||||
/** Open asks addressed to `person`. */
|
||||
openAsks(person: string): Promise<TeamAsk[]>;
|
||||
/** Every ask to `person` tagged for one chat, open or settled, to replay with it. */
|
||||
sessionAsks(person: string, sessionId: string): Promise<TeamAsk[]>;
|
||||
/** Record the caller's choice on an ask, through the person's answer path; a refusal comes back with its reason. */
|
||||
answer(qitemId: string, questionId: string, optionId: string): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
/** Rows the caller sent through rig acp serve. */
|
||||
sent(caller: string): Promise<SentRow[]>;
|
||||
}
|
||||
|
||||
/** What a session works through: the queue as the caller, and the seat that receives the caller's tasks (when set). */
|
||||
export interface TeamConnection {
|
||||
queue: TeamQueue;
|
||||
caller: string;
|
||||
receiver: string | undefined;
|
||||
/** Something about this connection the person should know, said once in each session before its first prompt's work. */
|
||||
notice?: string;
|
||||
}
|
||||
|
||||
export interface AcpAgentOptions {
|
||||
/** Reaches the daemon (`rig`'s: the local one, or OPENRIG_URL's) and resolves the caller; returns guidance when it can't.
|
||||
* Called lazily, never at initialize. */
|
||||
connect: () => Promise<TeamConnection | { error: string }>;
|
||||
version: string;
|
||||
pollMs: number;
|
||||
log?: (line: string) => void;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
/** Settles when the app's connection ends: open turns stop following their work (nothing could carry a reply). */
|
||||
closed?: Promise<void>;
|
||||
}
|
||||
|
||||
const ACTIVE_STATES = new Set(["pending", "in-progress", "blocked"]);
|
||||
/** How long a cancel waits for the app to settle questions it was showing (ACP has the client answer them cancelled). */
|
||||
const QUESTION_SETTLE_MS = 2000;
|
||||
const AUTH_REQUIRED = -32000;
|
||||
const INVALID_PARAMS = -32602;
|
||||
|
||||
class AcpError extends Error {
|
||||
constructor(readonly code: number, message: string, readonly data?: unknown) { super(message); }
|
||||
}
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
cwd: string;
|
||||
/** The first row each prompt created, oldest first, with the last row its work reached. */
|
||||
sent: Array<{ root: string; tail: string }>;
|
||||
turn?: { cancel: () => void };
|
||||
noticed?: boolean;
|
||||
}
|
||||
|
||||
type ContentBlock = { type: string; text?: string; uri?: string; name?: string; resource?: { uri?: string; text?: string } };
|
||||
|
||||
/** The questions one turn has put to the app. Once the turn has ended, nothing from them reaches the app or the queue. */
|
||||
interface TurnQuestions {
|
||||
finished: boolean;
|
||||
/** The person cancelled the turn: no further question opens, though one the app is showing still settles. */
|
||||
cancelled: boolean;
|
||||
/** Questions the app is still showing, by tool call id, each with how the chat names it. */
|
||||
outstanding: Map<string, string>;
|
||||
tasks: Set<Promise<void>>;
|
||||
}
|
||||
type Update = Record<string, unknown>;
|
||||
|
||||
/** The prompt as plain text for the row's body: text as written, links and embedded text resources named. */
|
||||
export function promptText(prompt: ContentBlock[]): string {
|
||||
const parts: string[] = [];
|
||||
for (const block of prompt) {
|
||||
if (block.type === "text" && block.text) parts.push(block.text);
|
||||
else if (block.type === "resource_link" && block.uri) parts.push(`[${block.name ?? "link"}](${block.uri})`);
|
||||
else if (block.type === "resource" && block.resource?.uri) {
|
||||
parts.push(block.resource.text !== undefined ? `${block.resource.uri}:\n\`\`\`\n${block.resource.text}\n\`\`\`` : block.resource.uri);
|
||||
}
|
||||
}
|
||||
return parts.join("\n\n").trim();
|
||||
}
|
||||
|
||||
/** A queue row id in the form the CLI mints (`qitem-<UTC yyyymmddhhmmss>-<16 hex>`). */
|
||||
export const newQitemId = () => `qitem-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(8).toString("hex")}`;
|
||||
|
||||
function oneLine(text: string, max: number): string {
|
||||
const line = text.split("\n").find((l) => l.trim())?.trim() ?? "";
|
||||
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
|
||||
}
|
||||
|
||||
/** The person's own words in a prompt, for the row's summary and the session's title. An app that wraps them in its
|
||||
* own instructions marks them (T3 Code sends several blocks of harness text, then `<user_request>…</user_request>`);
|
||||
* the row's body keeps everything the app sent. Only a prompt with one opening and one closing mark is read this
|
||||
* way: with more, an example could be taken for the request, so the prompt stands as sent. */
|
||||
function personsWords(text: string): string {
|
||||
if (text.split("<user_request>").length !== 2 || text.split("</user_request>").length !== 2) return text;
|
||||
const marked = /<user_request>([\s\S]*?)<\/user_request>/.exec(text)?.[1];
|
||||
return marked?.trim() ? marked : text;
|
||||
}
|
||||
|
||||
const toolStatus = (state: string) =>
|
||||
state === "pending" ? "pending" : ACTIVE_STATES.has(state) ? "in_progress" : state === "done" || state === "handed-off" ? "completed" : "failed";
|
||||
|
||||
const transitionLine = (t: TeamTransition) =>
|
||||
`${t.ts.slice(11, 19)}Z ${t.actorSession} · ${t.state}${t.transitionNote ? `: ${t.transitionNote}` : ""}`;
|
||||
|
||||
const textContent = (text: string) => [{ type: "content", content: { type: "text", text } }];
|
||||
|
||||
/** Where one prompt's work stands after a read of its rows. */
|
||||
type ChainState = { kind: "open"; tail: TeamRow } | { kind: "ended"; tail: TeamRow; reply: string };
|
||||
|
||||
/** Follows one prompt's work through its rows: each row is a step, and its new notes are emitted as they appear. */
|
||||
class ChainFollower {
|
||||
private readonly chain: string[];
|
||||
private readonly seen = new Map<string, { lines: string[]; lastId: number; status: string; title: string }>();
|
||||
/** The seats the work has reached; a question from one of them during the turn belongs to it. */
|
||||
readonly seats = new Set<string>();
|
||||
|
||||
constructor(private readonly queue: TeamQueue, private readonly emit: (update: Update) => void, readonly root: string) {
|
||||
this.chain = [root];
|
||||
}
|
||||
|
||||
async read(): Promise<ChainState> {
|
||||
for (;;) {
|
||||
let tail!: TeamRow;
|
||||
for (const qitemId of this.chain) {
|
||||
const row = await this.queue.show(qitemId);
|
||||
const transitions = await this.queue.transitions(qitemId);
|
||||
tail = row;
|
||||
this.seats.add(row.destinationSession);
|
||||
const status = toolStatus(row.state);
|
||||
let entry = this.seen.get(qitemId);
|
||||
if (!entry) {
|
||||
const title = qitemId === this.root ? `Sent to ${row.destinationSession} (${qitemId})` : `Handed to ${row.destinationSession} (${qitemId})`;
|
||||
entry = { lines: [], lastId: 0, status, title };
|
||||
this.seen.set(qitemId, entry);
|
||||
this.emit({ sessionUpdate: "tool_call", toolCallId: qitemId, kind: "other", status, title, rawInput: { qitemId, destination: row.destinationSession } });
|
||||
}
|
||||
const fresh = transitions.filter((t) => t.transitionId > entry!.lastId);
|
||||
if (fresh.length > 0 || status !== entry.status) {
|
||||
for (const t of fresh) { entry.lines.push(transitionLine(t)); entry.lastId = Math.max(entry.lastId, t.transitionId); }
|
||||
entry.status = status;
|
||||
// An app that shows a step collapsed still shows its title, so the latest note leads it: a narrow panel cuts the end.
|
||||
const noted = fresh.filter((t) => t.transitionNote).at(-1);
|
||||
this.emit({
|
||||
sessionUpdate: "tool_call_update", toolCallId: qitemId, status,
|
||||
...(noted ? { title: `${noted.actorSession}: ${oneLine(noted.transitionNote!, 100)} · ${entry.title}` } : {}),
|
||||
...(entry.lines.length > 0 ? { content: textContent(entry.lines.join("\n")) } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Both handoff forms pass the work on: `handoff` leaves the row handed-off, `handoff-and-complete` closes it done
|
||||
// with closure reason handed_off_to. Either way the answer is the successor's, not this row's.
|
||||
if (tail.state === "handed-off" || (tail.state === "done" && tail.closureReason === "handed_off_to")) {
|
||||
const next = await this.queue.successor(tail.qitemId);
|
||||
if (next) { this.chain.push(next); continue; }
|
||||
return { kind: "ended", tail, reply: `${tail.qitemId} was handed to ${tail.handedOffTo ?? tail.closureTarget ?? "another seat"}, whose row isn't on this machine, so this app can't follow it further. Ask your team for its result.` };
|
||||
}
|
||||
if (ACTIVE_STATES.has(tail.state)) return { kind: "open", tail };
|
||||
// A done row's closure reason says how it ended (canceled is a done close too); the ordinary one goes unsaid.
|
||||
const reason = tail.closureReason && tail.closureReason !== "no-follow-on" ? ` (${tail.closureReason})` : "";
|
||||
const how = tail.state === "done" ? `done${reason}` : `as ${tail.state}${reason}`;
|
||||
return { kind: "ended", tail, reply: `${tail.closer ?? tail.destinationSession} closed ${tail.qitemId} ${how}.\n\n${tail.resolution?.trim() || "(No resolution was written.)"}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function serveAcpAgent(peer: JsonRpcPeer, options: AcpAgentOptions): void {
|
||||
const sessions = new Map<string, Session>();
|
||||
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
||||
const log = options.log ?? (() => {});
|
||||
let clientName = "an ACP app";
|
||||
/** The app asks forms (ACP `elicitation/create`, mode form), so a question's choices go to it as a form, each by its
|
||||
* label. A permission request shows them only in apps that list each option: T3 Code shows a fixed Approve, which
|
||||
* sends the first. */
|
||||
let asksForms = false;
|
||||
let connection: TeamConnection | undefined;
|
||||
void options.closed?.then(() => { for (const session of sessions.values()) session.turn?.cancel(); });
|
||||
|
||||
const connected = async (): Promise<TeamConnection> => {
|
||||
if (connection) return connection;
|
||||
const result = await options.connect();
|
||||
if ("error" in result) throw new AcpError(AUTH_REQUIRED, result.error);
|
||||
connection = result;
|
||||
return result;
|
||||
};
|
||||
|
||||
const update = (sessionId: string, body: Update) => peer.notify("session/update", { sessionId, update: body });
|
||||
const say = (sessionId: string, text: string) => update(sessionId, { sessionUpdate: "agent_message_chunk", content: { type: "text", text } });
|
||||
/** A question the app showed ends unanswered: its card says so, and so does the chat, which every app shows, since an
|
||||
* app need not show a card's later text. */
|
||||
const unanswered = (sessionId: string, toolCallId: string, named: string, why: string) => {
|
||||
update(sessionId, { sessionUpdate: "tool_call_update", toolCallId, content: textContent(`${why} The question is still open in the OpenRig TUI and Slack.`) });
|
||||
say(sessionId, `\n\n${why} ${named} is still open in the OpenRig TUI and Slack.\n\n`);
|
||||
};
|
||||
const newSessionId = () => `acps-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(4).toString("hex")}`;
|
||||
|
||||
/** Each of the caller's prompts in a session, oldest first, by the rows tagged with its id. */
|
||||
const sessionRoots = async (team: TeamConnection, sessionId: string) =>
|
||||
(await team.queue.sent(team.caller)).filter((row) => row.tags.includes(`${SESSION_TAG}${sessionId}`))
|
||||
.sort((a, b) => a.tsCreated.localeCompare(b.tsCreated));
|
||||
|
||||
/** Put one ask's open questions to the person, as a form where the app asks forms and otherwise as permission
|
||||
* requests, one at a time, and record each choice. A reply that arrives after the turn ended answers nothing here:
|
||||
* the question stays open in the TUI and Slack. */
|
||||
const askPerson = async (team: TeamConnection, sessionId: string, ask: TeamAsk, turn: TurnQuestions) => {
|
||||
const emit = (body: Update) => { if (!turn.finished) update(sessionId, body); };
|
||||
if (ask.questions.length === 0) {
|
||||
emit({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: `\n\n${ask.sourceSession} asks you: ${ask.summary ?? oneLine(ask.body, 200)} (${ask.qitemId}). It wants an answer in words, which this app can't send: answer it in the OpenRig TUI or Slack.\n\n` } });
|
||||
return;
|
||||
}
|
||||
for (const q of ask.questions.filter((question) => !(question.id in ask.answers))) {
|
||||
if (turn.finished || turn.cancelled) return;
|
||||
const toolCallId = `${ask.qitemId}#${q.id}`;
|
||||
emit({ sessionUpdate: "tool_call", toolCallId, kind: "other", status: "pending", title: `${ask.sourceSession} asks: ${q.question}`, content: textContent(ask.body) });
|
||||
const label = (o: (typeof q.options)[number]) => (o.recommended ? `${o.label} (recommended)` : o.label);
|
||||
const named = `${ask.sourceSession}'s question "${q.question}" (${ask.qitemId})`;
|
||||
let chosenId: string | undefined;
|
||||
turn.outstanding.set(toolCallId, named);
|
||||
try {
|
||||
if (asksForms) {
|
||||
// Each choice is offered as its label, the one form every app shows as choices: T3 Code 0.1.7 builds them only
|
||||
// from `enum`, a value serving as both label and answer, and reads `oneOf` as free text. Labels that repeat
|
||||
// carry their option's id, so a value names one choice; the answer is mapped back by that exact value.
|
||||
const shown = q.options.map(label);
|
||||
const values = new Set(shown).size === shown.length ? shown : q.options.map((o, i) => `${shown[i]} (${o.id})`);
|
||||
const result = (await peer.request("elicitation/create", {
|
||||
sessionId, toolCallId, mode: "form", message: `${ask.sourceSession} asks: ${q.question}`,
|
||||
requestedSchema: { type: "object", required: [q.id], properties: { [q.id]: { type: "string", title: q.question, enum: values } } },
|
||||
})) as { action?: string; content?: Record<string, unknown> | null } | undefined;
|
||||
const value = result?.action === "accept" ? result.content?.[q.id] : undefined;
|
||||
chosenId = typeof value === "string" ? q.options[values.indexOf(value)]?.id : undefined;
|
||||
} else {
|
||||
const result = (await peer.request("session/request_permission", {
|
||||
sessionId,
|
||||
toolCall: { toolCallId, title: `${ask.sourceSession} asks: ${q.question}`, kind: "other", status: "pending" },
|
||||
options: q.options.map((o) => ({ optionId: o.id, name: label(o), kind: "allow_once" })),
|
||||
})) as { outcome?: { outcome?: string; optionId?: string } } | undefined;
|
||||
chosenId = result?.outcome?.outcome === "selected" ? result.outcome.optionId : undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!turn.finished) unanswered(sessionId, toolCallId, named, `This app couldn't show the question (${(error as Error).message}).`);
|
||||
return;
|
||||
} finally {
|
||||
turn.outstanding.delete(toolCallId);
|
||||
}
|
||||
if (turn.finished) return;
|
||||
// Only one of the question's own options is recorded: the app's reply is checked again here.
|
||||
const chosen = q.options.find((o) => o.id === chosenId);
|
||||
if (!chosen) {
|
||||
unanswered(sessionId, toolCallId, named, "Not answered here.");
|
||||
return;
|
||||
}
|
||||
const recorded = await team.queue.answer(ask.qitemId, q.id, chosen.id).catch((error: Error) => ({ ok: false as const, reason: error.message }));
|
||||
emit(recorded.ok
|
||||
? { sessionUpdate: "tool_call_update", toolCallId, status: "completed", content: textContent(`You answered: ${chosen.label}. ${ask.sourceSession} gets it as your answer.`) }
|
||||
: { sessionUpdate: "tool_call_update", toolCallId, status: "failed", content: textContent(`Your choice (${chosen.label}) wasn't recorded: ${recorded.reason}`) });
|
||||
log(`session ${sessionId}: ${recorded.ok ? "answered" : "couldn't answer"} ${ask.qitemId} ${q.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
peer.onRequest("initialize", async (params) => {
|
||||
const p = (params ?? {}) as { clientInfo?: { name?: string; title?: string }; clientCapabilities?: { elicitation?: { form?: unknown } | null } };
|
||||
clientName = p.clientInfo?.title ?? p.clientInfo?.name ?? clientName;
|
||||
// ACP counts a mode as supported only when its field is present and not null.
|
||||
asksForms = p.clientCapabilities?.elicitation?.form != null;
|
||||
return {
|
||||
protocolVersion: ACP_PROTOCOL_VERSION,
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
promptCapabilities: { image: false, audio: false, embeddedContext: true },
|
||||
mcpCapabilities: { http: false, sse: false },
|
||||
sessionCapabilities: { list: {} },
|
||||
},
|
||||
authMethods: [{
|
||||
id: AUTH_METHOD_ID,
|
||||
name: "OpenRig on this machine",
|
||||
description: "Sends your work to your OpenRig team, as you, through the same OpenRig daemon as `rig` on this computer: the local one (start it with `rig daemon start`), unless OPENRIG_URL names another.",
|
||||
}],
|
||||
agentInfo: { name: "openrig", title: "OpenRig", version: options.version },
|
||||
};
|
||||
});
|
||||
|
||||
peer.onRequest("authenticate", async (params) => {
|
||||
const methodId = (params as { methodId?: unknown } | undefined)?.methodId;
|
||||
if (methodId !== AUTH_METHOD_ID) throw new AcpError(INVALID_PARAMS, `Unknown auth method ${String(methodId)}; this agent offers ${AUTH_METHOD_ID}`);
|
||||
const team = await connected();
|
||||
log(`authenticated as ${team.caller}${team.receiver ? `, sending to ${team.receiver}` : ", with no receiver set"}`);
|
||||
return {};
|
||||
});
|
||||
|
||||
peer.onRequest("session/new", async (params) => {
|
||||
await connected();
|
||||
const id = newSessionId();
|
||||
sessions.set(id, { id, cwd: String((params as { cwd?: unknown } | undefined)?.cwd ?? ""), sent: [] });
|
||||
return { sessionId: id };
|
||||
});
|
||||
|
||||
peer.onRequest("session/list", async (params) => {
|
||||
const team = await connected();
|
||||
const cwd = (params as { cwd?: unknown } | undefined)?.cwd;
|
||||
const byId = new Map<string, { sessionId: string; cwd: string; title: string; updatedAt: string }>();
|
||||
for (const row of (await team.queue.sent(team.caller)).sort((a, b) => a.tsCreated.localeCompare(b.tsCreated))) {
|
||||
const sessionId = row.tags.find((t) => t.startsWith(SESSION_TAG))?.slice(SESSION_TAG.length);
|
||||
if (!sessionId) continue;
|
||||
const known = byId.get(sessionId);
|
||||
if (known) { if (row.tsUpdated > known.updatedAt) known.updatedAt = row.tsUpdated; continue; }
|
||||
byId.set(sessionId, {
|
||||
sessionId, cwd: row.tags.find((t) => t.startsWith(CWD_TAG))?.slice(CWD_TAG.length) ?? "",
|
||||
title: oneLine(personsWords(row.body.split(FOOTER)[0] ?? ""), 100), updatedAt: row.tsUpdated,
|
||||
});
|
||||
}
|
||||
const listed = [...byId.values()].filter((s) => typeof cwd !== "string" || s.cwd === cwd).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
return { sessions: listed };
|
||||
});
|
||||
|
||||
peer.onRequest("session/load", async (params) => {
|
||||
const p = (params ?? {}) as { sessionId?: string; cwd?: string };
|
||||
const team = await connected();
|
||||
const sessionId = String(p.sessionId ?? "");
|
||||
const roots = await sessionRoots(team, sessionId);
|
||||
if (roots.length === 0) throw new AcpError(INVALID_PARAMS, `No session ${sessionId} from ${team.caller} on this machine`);
|
||||
const session: Session = { id: sessionId, cwd: String(p.cwd ?? ""), sent: [] };
|
||||
const asks = (await team.queue.sessionAsks(team.caller, sessionId)).sort((a, b) => a.tsCreated.localeCompare(b.tsCreated));
|
||||
// Replay: each prompt as the caller wrote it, then its work as it stands now, the questions the team asked during
|
||||
// it with what's recorded, and the reply. Nothing is asked again here; an open question comes up in the next turn.
|
||||
for (const [i, row] of roots.entries()) {
|
||||
update(sessionId, { sessionUpdate: "user_message_chunk", content: { type: "text", text: row.body.split(FOOTER)[0] ?? "" } });
|
||||
const follower = new ChainFollower(team.queue, (u) => update(sessionId, u), row.qitemId);
|
||||
const state = await follower.read();
|
||||
session.sent.push({ root: row.qitemId, tail: state.tail.qitemId });
|
||||
const next = roots[i + 1]?.tsCreated;
|
||||
for (const ask of asks.filter((a) => (i === 0 || a.tsCreated >= row.tsCreated) && (next === undefined || a.tsCreated < next))) {
|
||||
for (const q of ask.questions) {
|
||||
const answer = ask.answers[q.id];
|
||||
const outcome = answer !== undefined ? `You answered: ${q.options.find((o) => o.id === answer)?.label ?? answer}.`
|
||||
: ACTIVE_STATES.has(ask.state) ? "Not answered yet. It's still open in the OpenRig TUI and Slack." : `Not answered: ${ask.qitemId} was closed (${ask.state}).`;
|
||||
update(sessionId, {
|
||||
sessionUpdate: "tool_call", toolCallId: `${ask.qitemId}#${q.id}`, kind: "other", status: answer === undefined && ACTIVE_STATES.has(ask.state) ? "pending" : "completed",
|
||||
title: `${ask.sourceSession} asks: ${q.question}`, content: [...textContent(ask.body), ...textContent(outcome)],
|
||||
});
|
||||
}
|
||||
}
|
||||
say(sessionId, state.kind === "ended" ? state.reply : `${state.tail.qitemId} is still ${state.tail.state} with ${state.tail.destinationSession}.`);
|
||||
}
|
||||
sessions.set(sessionId, session);
|
||||
return {};
|
||||
});
|
||||
|
||||
peer.onNotification("session/cancel", (params) => {
|
||||
const sessionId = (params as { sessionId?: string } | undefined)?.sessionId;
|
||||
if (sessionId) sessions.get(sessionId)?.turn?.cancel();
|
||||
});
|
||||
|
||||
peer.onRequest("session/prompt", async (params) => {
|
||||
const p = (params ?? {}) as { sessionId?: string; prompt?: ContentBlock[] };
|
||||
const session = p.sessionId ? sessions.get(p.sessionId) : undefined;
|
||||
if (!session) throw new AcpError(INVALID_PARAMS, `No session ${String(p.sessionId)} in this agent; start one with session/new or session/load`);
|
||||
if (session.turn) throw new AcpError(INVALID_PARAMS, "This session already has a turn running; wait for it or cancel it first");
|
||||
const team = await connected();
|
||||
const text = promptText(Array.isArray(p.prompt) ? p.prompt : []);
|
||||
if (!text) {
|
||||
say(session.id, "Nothing to send: the prompt had no text.");
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
if (!team.receiver) {
|
||||
say(session.id, "This OpenRig agent has no team receiver, so nothing was sent. Choose the seat that takes your team's new work with `rig config set acp.to <member@rig>`, or add `--to <member@rig>` to the agent's command in your app's settings.");
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
if (team.notice && !session.noticed) {
|
||||
session.noticed = true;
|
||||
say(session.id, `${team.notice}\n\n`);
|
||||
}
|
||||
|
||||
let wake: () => void = () => {};
|
||||
const questions: TurnQuestions = { finished: false, cancelled: false, outstanding: new Map(), tasks: new Set() };
|
||||
session.turn = { cancel: () => { questions.cancelled = true; wake(); } };
|
||||
let root: string | undefined;
|
||||
try {
|
||||
// Each earlier item as it stands; one whose create was never confirmed may not exist, or can't be read: say which.
|
||||
const earlier = await Promise.all(session.sent.map(async (s) => {
|
||||
const row = await team.queue.find(s.tail).catch(() => undefined);
|
||||
return { ...s, row, state: row === undefined ? "state unknown" : row === null ? "not found here" : row.state };
|
||||
}));
|
||||
const open = earlier.filter((s) => s.row && ACTIVE_STATES.has(s.row.state));
|
||||
if (open.length > 0) say(session.id, `${open.map((s) => `${s.row!.qitemId} is still ${s.row!.state} with ${s.row!.destinationSession}`).join("; ")}. Sending this as a follow-up.\n\n`);
|
||||
const lineage = earlier.length === 0 ? "" : `\nFollows up on ${earlier.map((s) => `${s.root} (${s.tail === s.root ? "" : `reached ${s.tail}, `}${s.state})`).join(", ")}.`;
|
||||
const body = `${text}${FOOTER}${team.caller} from ${clientName} through rig acp serve (session ${session.id}).${lineage}\n` +
|
||||
`The app shows this item's notes and handoffs as progress, and its closing resolution as the answer: close it with a resolution written for ${team.caller}. ` +
|
||||
`A question with choices you ask ${team.caller} while this is open, tagged for this chat, appears in the app as a choice: ` +
|
||||
`rig queue create --destination ${team.caller} --human-questions-file … --tags ${SESSION_TAG}${session.id}.`;
|
||||
const turnStarted = new Date().toISOString();
|
||||
const qitemId = newQitemId();
|
||||
try {
|
||||
await team.queue.create({
|
||||
qitemId, destination: team.receiver, body, summary: `ACP: ${oneLine(personsWords(text), 100)}`,
|
||||
tags: ["acp-serve", `${SESSION_TAG}${session.id}`, ...(session.cwd ? [`${CWD_TAG}${session.cwd}`] : [])],
|
||||
});
|
||||
} catch (error) {
|
||||
const reason = (error as Error).message;
|
||||
// A refusal the daemon answered with wrote nothing, so it can be said plainly.
|
||||
if (error instanceof TeamRefused) throw new AcpError(-32603, `The team's queue refused the task: ${reason}`);
|
||||
// No answer: the request may still land after any readback, so only a row that reads back settles it.
|
||||
const landed = await team.queue.find(qitemId).catch(() => undefined);
|
||||
if (!landed) {
|
||||
session.sent.push({ root: qitemId, tail: qitemId });
|
||||
say(session.id, `Sending ${qitemId} to ${team.receiver} got no answer (${reason}), and ${landed === null ? "it wasn't found on the immediate readback" : "it couldn't be read back"}: delivery is unconfirmed, so it may still reach the team. A new prompt in this session names it, and \`rig queue show ${qitemId}\` shows it if it landed.`);
|
||||
throw new AcpError(-32603, `Couldn't confirm ${qitemId}: ${reason}`);
|
||||
}
|
||||
log(`session ${session.id}: ${qitemId}'s create got no answer, but it reads back`);
|
||||
}
|
||||
root = qitemId;
|
||||
const sent = { root, tail: root };
|
||||
session.sent.push(sent);
|
||||
log(`session ${session.id}: sent ${root} to ${team.receiver}`);
|
||||
|
||||
// ponytail: polls the daemon for the chain's rows, their transitions and the caller's open asks; the
|
||||
// daemon's event stream is the upgrade path if a poll per open turn ever costs too much.
|
||||
const follower = new ChainFollower(team.queue, (u) => update(session.id, u), root);
|
||||
const seen = new Set<string>();
|
||||
/** Close this turn's questions: any the app still shows are named as open elsewhere, then nothing more comes from them. */
|
||||
const finishQuestions = (why: string) => {
|
||||
for (const [toolCallId, named] of questions.outstanding) unanswered(session.id, toolCallId, named, why);
|
||||
questions.finished = true;
|
||||
};
|
||||
for (;;) {
|
||||
const state = await follower.read();
|
||||
sent.tail = state.tail.qitemId;
|
||||
if (state.kind === "ended") {
|
||||
finishQuestions("The work closed before this was answered here.");
|
||||
say(session.id, state.reply);
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
const asks = await team.queue.openAsks(team.caller);
|
||||
// After a cancel, even one that landed during that read, no new question goes to the person.
|
||||
for (const ask of questions.cancelled ? [] : asks) {
|
||||
if (seen.has(ask.qitemId)) continue;
|
||||
// An ask tagged for a chat belongs to that chat alone. An untagged one from a seat this work reached might be
|
||||
// another chat's, so it isn't asked here; it's named, with where to answer it.
|
||||
const chats = ask.tags.filter((tag) => tag.startsWith(SESSION_TAG));
|
||||
if (chats.includes(`${SESSION_TAG}${session.id}`)) {
|
||||
seen.add(ask.qitemId);
|
||||
const task: Promise<void> = askPerson(team, session.id, ask, questions)
|
||||
.catch((error: Error) => log(`session ${session.id}: asking about ${ask.qitemId} failed: ${error.message}`))
|
||||
.finally(() => questions.tasks.delete(task));
|
||||
questions.tasks.add(task);
|
||||
} else if (chats.length === 0 && follower.seats.has(ask.sourceSession) && ask.tsCreated >= turnStarted) {
|
||||
seen.add(ask.qitemId);
|
||||
say(session.id, `\n\n${ask.sourceSession}, which this work reached, has a question for you that isn't tagged for this chat (${ask.qitemId}: ${ask.summary ?? oneLine(ask.body, 120)}). Answer it in the OpenRig TUI or Slack.\n\n`);
|
||||
}
|
||||
}
|
||||
if (questions.cancelled) {
|
||||
// ACP has the app answer the questions it was showing as cancelled; let those answers land before the turn ends.
|
||||
if (questions.tasks.size > 0) await Promise.race([Promise.allSettled([...questions.tasks]), sleep(QUESTION_SETTLE_MS)]);
|
||||
finishQuestions("Not answered here: the turn was cancelled.");
|
||||
say(session.id, `Stopped waiting. ${state.tail.qitemId} is still ${state.tail.state} with ${state.tail.destinationSession}: cancelling here doesn't stop the team's work. A new prompt in this session goes to the team as a follow-up and shows where this stands.`);
|
||||
return { stopReason: "cancelled" };
|
||||
}
|
||||
await Promise.race([sleep(options.pollMs), new Promise<void>((resolve) => { wake = resolve; })]);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AcpError || !root) throw error;
|
||||
say(session.id, `Lost track of ${root}: ${(error as Error).message}. The item stays with the team; its state is unknown from here.`);
|
||||
throw new AcpError(-32603, `Couldn't follow ${root}: ${(error as Error).message}`);
|
||||
} finally {
|
||||
questions.finished = true;
|
||||
session.turn = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { appendFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { JsonRpcRequestError, JsonRpcTransportClosed, StdioJsonRpcPeer } from "./json-rpc-stdio.js";
|
||||
import { JsonRpcRequestError, JsonRpcTransportClosed, StdioJsonRpcPeer, type JsonRpcErrorObject } from "./json-rpc-stdio.js";
|
||||
import { findRunProcesses, RUN_MARKER } from "./run-processes.js";
|
||||
|
||||
// `rig acp run`: one queue row's task to one ACP agent turn, with the outcome back on that row. OpenRig carries the
|
||||
@@ -48,10 +48,22 @@ export interface AcpRunOptions {
|
||||
detachRequested?: Promise<void>;
|
||||
/** How long to wait for the agent to exit after its stdin closes (default 5 s). */
|
||||
exitWaitMs?: number;
|
||||
/** A harness's own evidence that a `session/load` error means that session doesn't exist. Without it, or when it
|
||||
* doesn't match, the load error reads `failed` with the agent's code and message: no code means missing by itself. */
|
||||
missingSession?: (error: JsonRpcErrorObject, sessionId: string) => boolean;
|
||||
/** Called once with what the stop found: the agent's exit and whether any process carrying the run's marker remains. */
|
||||
onSettled?: (report: string) => void;
|
||||
}
|
||||
|
||||
export type AcpOutcomeKind = "completed" | "stopped" | "cancelled" | "failed" | "unknown";
|
||||
export interface AcpOutcome { kind: AcpOutcomeKind; summary: string; text: string; sessionId?: string }
|
||||
export type AcpOutcomeKind = "completed" | "stopped" | "cancelled" | "failed" | "unknown" | "missing";
|
||||
/** What the agent itself reported; OpenRig measures none of it. */
|
||||
export interface AcpUsage {
|
||||
/** The prompt response's `usage` (an unstable ACP field), as the agent counted it. */
|
||||
tokens?: { total?: number; input?: number; output?: number; thought?: number; cachedRead?: number; cachedWrite?: number };
|
||||
/** The last `usage_update` cost: the agent's own cumulative figure for the whole session, in its unit. */
|
||||
cost?: { amount: number; currency: string };
|
||||
}
|
||||
export interface AcpOutcome { kind: AcpOutcomeKind; summary: string; text: string; sessionId?: string; usage?: AcpUsage }
|
||||
|
||||
/** Agent text that reports a failed turn while still ending it `end_turn` (OpenClaw #178057; its #44294 is open). */
|
||||
export const INTERRUPTION_MARKERS = ["[OpenClaw interruption]"];
|
||||
@@ -87,6 +99,7 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
|
||||
let sessionId: string | undefined;
|
||||
let text = "";
|
||||
let usage: { used?: number; size?: number; cost?: unknown } | undefined;
|
||||
const reported: AcpUsage = {};
|
||||
let toolCalls = 0;
|
||||
let lastTool = "";
|
||||
let lastProgressNote = Date.now();
|
||||
@@ -104,6 +117,8 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
|
||||
progress(`\n[tool] ${lastTool}\n`);
|
||||
} else if (update.sessionUpdate === "usage_update") {
|
||||
usage = { used: update.used as number, size: update.size as number, cost: update.cost };
|
||||
const cost = update.cost as { amount?: unknown; currency?: unknown } | null | undefined;
|
||||
if (typeof cost?.amount === "number" && typeof cost.currency === "string") reported.cost = { amount: cost.amount, currency: cost.currency };
|
||||
}
|
||||
if (Date.now() - lastProgressNote >= 60_000) {
|
||||
lastProgressNote = Date.now();
|
||||
@@ -153,10 +168,11 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
|
||||
|
||||
const record = async (outcome: AcpOutcome): Promise<AcpOutcome> => {
|
||||
const shown = outcome.text.length > NOTE_TEXT_LIMIT ? `${outcome.text.slice(0, NOTE_TEXT_LIMIT)}\n… (truncated; the full transcript is in ${options.logPath})` : outcome.text;
|
||||
const withUsage: AcpOutcome = { ...outcome, usage: { ...reported } };
|
||||
await queue.update(row.qitemId, {
|
||||
note: `ACP run ${options.runId} outcome ${outcome.kind}: ${outcome.summary}${shown ? `\n\n${shown}` : ""}\nThis is the agent's report, not a judgment of the work; the row stays open for you to judge and close with your own resolution.`,
|
||||
note: `ACP run ${options.runId} outcome ${outcome.kind}: ${outcome.summary}${shown ? `\n\n${shown}` : ""}\nThis is the agent's report, not a judgment of the work; the row stays open for you to judge and close with your own resolution.\n${describeUsage(withUsage.usage)}`,
|
||||
});
|
||||
return outcome;
|
||||
return withUsage;
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -166,7 +182,12 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
|
||||
const loads = init.agentCapabilities?.loadSession === true;
|
||||
if (options.loadSession) {
|
||||
if (!loads) return await record({ kind: "failed", summary: `the agent doesn't advertise loadSession, so session ${options.loadSession} wasn't loaded and no new session was started`, text: "" });
|
||||
await peer.request("session/load", { sessionId: options.loadSession, cwd: options.cwd, mcpServers: [] });
|
||||
try {
|
||||
await peer.request("session/load", { sessionId: options.loadSession, cwd: options.cwd, mcpServers: [] });
|
||||
} catch (error) {
|
||||
if (!(error instanceof JsonRpcRequestError) || !options.missingSession?.(error.error, options.loadSession)) throw error;
|
||||
return await record({ kind: "missing", text: "", summary: `session ${options.loadSession} doesn't exist for this agent (session/load returned ${describeError(error.error)}); no new session was started` });
|
||||
}
|
||||
sessionId = options.loadSession;
|
||||
} else {
|
||||
sessionId = (await peer.request("session/new", { cwd: options.cwd, mcpServers: [] }) as { sessionId: string }).sessionId;
|
||||
@@ -190,13 +211,18 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
|
||||
void options.detachRequested?.then(() => { void peer.end(0); reject(new DetachedBeforeTurnEnd()); });
|
||||
});
|
||||
detached.catch(() => {});
|
||||
const response = await Promise.race([peer.request("session/prompt", { sessionId, prompt: [{ type: "text", text: row.body }] }), detached]) as { stopReason?: string };
|
||||
const response = await Promise.race([peer.request("session/prompt", { sessionId, prompt: [{ type: "text", text: row.body }] }), detached]) as { stopReason?: string; usage?: Record<string, unknown> };
|
||||
turnOver = true;
|
||||
const tokens = response.usage;
|
||||
if (tokens && typeof tokens === "object") {
|
||||
const count = (key: string) => (typeof tokens[key] === "number" ? tokens[key] as number : undefined);
|
||||
reported.tokens = { total: count("totalTokens"), input: count("inputTokens"), output: count("outputTokens"), thought: count("thoughtTokens"), cachedRead: count("cachedReadTokens"), cachedWrite: count("cachedWriteTokens") };
|
||||
}
|
||||
return await record(classify(response.stopReason, text, usage, cancelRequestedAt, sessionId));
|
||||
} catch (error) {
|
||||
turnOver = true;
|
||||
if (error instanceof JsonRpcRequestError) {
|
||||
return await record({ kind: "failed", sessionId, text, summary: `${error.method} returned error ${error.error.code} ${error.error.message}${error.error.data === undefined ? "" : ` ${JSON.stringify(error.error.data)}`}` });
|
||||
return await record({ kind: "failed", sessionId, text, summary: `${error.method} returned error ${describeError(error.error)}` });
|
||||
}
|
||||
if (error instanceof DetachedBeforeTurnEnd) {
|
||||
return await record({ kind: "unknown", sessionId, text, summary: "detached before the turn ended; the agent may still be working" });
|
||||
@@ -212,7 +238,9 @@ export async function runAcpAssignment(queue: AcpQueue, options: AcpRunOptions):
|
||||
const settled = left === undefined ? "unknown (the process table couldn't be read)"
|
||||
: left.length === 0 ? "settled: no process carrying this run's marker remains"
|
||||
: `incomplete: ${left.length} process(es) carrying this run's marker still run (${left.map((p) => p.pid).join(", ")}); stop them with the runtime's own stop, or rig acp stop ${options.runId} -- <its stop command>`;
|
||||
await queue.update(row.qitemId, { note: `ACP run ${options.runId} detached: agent ${exit === "still-running" ? "still running after stdin closed" : `exited (code ${exit.code ?? "none"}, signal ${exit.signal ?? "none"})`}; ${settled}` }).catch(() => {});
|
||||
const report = `agent ${exit === "still-running" ? "still running after stdin closed" : `exited (code ${exit.code ?? "none"}, signal ${exit.signal ?? "none"})`}; ${settled}`;
|
||||
options.onSettled?.(report);
|
||||
await queue.update(row.qitemId, { note: `ACP run ${options.runId} detached: ${report}` }).catch(() => {});
|
||||
peer.release(); // a still-running agent keeps running, but holds nothing that keeps this process alive
|
||||
}
|
||||
}
|
||||
@@ -231,6 +259,22 @@ export function classify(stopReason: string | undefined, text: string, usage: {
|
||||
return { kind: "completed", sessionId, text, summary: "the agent ended its turn (end_turn)" };
|
||||
}
|
||||
|
||||
const describeError = (error: JsonRpcErrorObject) => `${error.code} ${error.message}${error.data === undefined ? "" : ` ${JSON.stringify(error.data)}`}`;
|
||||
|
||||
/** Usage and cost as the agent reported them, with where each came from; a figure it didn't report reads unknown. */
|
||||
export function describeUsage(usage: AcpUsage | undefined): string {
|
||||
const t = usage?.tokens;
|
||||
const parts = t ? [["in", t.input], ["out", t.output], ["thought", t.thought], ["cache read", t.cachedRead], ["cache write", t.cachedWrite]]
|
||||
.filter(([, n]) => n !== undefined).map(([label, n]) => `${n} ${label}`) : [];
|
||||
const tokens = t?.total !== undefined || parts.length > 0
|
||||
? `${t?.total !== undefined ? `${t.total} tokens` : "tokens"}${parts.length ? ` (${parts.join(", ")})` : ""}, from the agent's prompt response`
|
||||
: "tokens unknown (the agent's prompt response reported none)";
|
||||
const cost = usage?.cost
|
||||
? `cost ${usage.cost.amount} ${usage.cost.currency}, the agent's own cumulative figure for the session (usage_update)`
|
||||
: "cost unknown (the agent reported none)";
|
||||
return `Usage as the agent reported it: ${tokens}; ${cost}.`;
|
||||
}
|
||||
|
||||
/** An owner answers with the offered option's id; its kind (allow_once …) works when only one option has it. */
|
||||
export function chooseOption(offered: PermissionOption[], answer: string | null | undefined): PermissionOption | undefined {
|
||||
const value = answer?.trim();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
// ACP v1 is JSON-RPC 2.0 over newline-delimited JSON on the agent's stdio. This peer covers what an OpenRig client
|
||||
// needs (a few outgoing requests, incoming requests and notifications); the official SDK would add a dependency for
|
||||
// a handful of methods, so swap to it only if the method set grows.
|
||||
// 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.
|
||||
|
||||
export interface JsonRpcErrorObject {
|
||||
code: number;
|
||||
@@ -33,27 +34,18 @@ export type NotificationHandler = (params: unknown) => void;
|
||||
/** Every packet in both directions, in order; lines that aren't JSON arrive as `in-text`. */
|
||||
export type PacketRecorder = (direction: "out" | "in" | "in-text", packet: unknown) => void;
|
||||
|
||||
export class StdioJsonRpcPeer {
|
||||
/** One JSON-RPC connection over a pair of streams: incoming lines on `input`, outgoing packets written to `output`. */
|
||||
export class JsonRpcPeer {
|
||||
private nextId = 0;
|
||||
private readonly pending = new Map<JsonRpcId, { method: string; resolve: (value: unknown) => void; reject: (error: Error) => void }>();
|
||||
private readonly requestHandlers = new Map<string, IncomingRequestHandler>();
|
||||
private readonly notificationHandlers = new Map<string, NotificationHandler>();
|
||||
private ended = false;
|
||||
private readonly lines: ReturnType<typeof createInterface>;
|
||||
readonly exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
|
||||
protected readonly lines: ReturnType<typeof createInterface>;
|
||||
|
||||
constructor(private readonly child: ChildProcessWithoutNullStreams, private readonly record: PacketRecorder = () => {}) {
|
||||
this.lines = createInterface({ input: child.stdout });
|
||||
constructor(input: NodeJS.ReadableStream, private readonly output: NodeJS.WritableStream, private readonly record: PacketRecorder = () => {}) {
|
||||
this.lines = createInterface({ input });
|
||||
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> {
|
||||
@@ -77,25 +69,18 @@ export class StdioJsonRpcPeer {
|
||||
this.notificationHandlers.set(method, handler);
|
||||
}
|
||||
|
||||
/** Close the agent's stdin (detach) and wait for the process to exit, up to `timeoutMs`. */
|
||||
async end(timeoutMs: number): Promise<{ code: number | null; signal: NodeJS.Signals | null } | "still-running"> {
|
||||
this.child.stdin.end();
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<"still-running">((resolve) => { timer = setTimeout(() => resolve("still-running"), timeoutMs); });
|
||||
try { return await Promise.race([this.exited, timeout]); } finally { clearTimeout(timer); }
|
||||
}
|
||||
|
||||
/** Drop this side's hold on the agent (its pipes and process handle) without signalling it, so a detached agent that
|
||||
* keeps running doesn't keep the caller's process alive. */
|
||||
release(): void {
|
||||
this.lines.close();
|
||||
for (const stream of [this.child.stdin, this.child.stdout, this.child.stderr]) stream.destroy();
|
||||
this.child.unref();
|
||||
/** 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));
|
||||
}
|
||||
}
|
||||
|
||||
private send(packet: Record<string, unknown>): void {
|
||||
this.record("out", packet);
|
||||
this.child.stdin.write(`${JSON.stringify(packet)}\n`);
|
||||
this.output.write(`${JSON.stringify(packet)}\n`);
|
||||
}
|
||||
|
||||
private receive(line: string): void {
|
||||
@@ -127,7 +112,7 @@ export class StdioJsonRpcPeer {
|
||||
private async answer(method: string, id: JsonRpcId, params: unknown): Promise<void> {
|
||||
const handler = this.requestHandlers.get(method);
|
||||
if (!handler) {
|
||||
this.send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Client does not handle ${method}` } });
|
||||
this.send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -135,7 +120,48 @@ export class StdioJsonRpcPeer {
|
||||
if (!this.ended) this.send({ jsonrpc: "2.0", id, result });
|
||||
} catch (error) {
|
||||
const code = typeof (error as { code?: unknown }).code === "number" ? (error as { code: number }).code : -32603;
|
||||
if (!this.ended) this.send({ jsonrpc: "2.0", id, error: { code, message: (error as Error).message } });
|
||||
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 } : {}) } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The client end of an ACP agent process OpenRig launched. */
|
||||
export class StdioJsonRpcPeer extends JsonRpcPeer {
|
||||
readonly exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
|
||||
|
||||
constructor(private readonly child: ChildProcessWithoutNullStreams, record: PacketRecorder = () => {}) {
|
||||
super(child.stdout, child.stdin, record);
|
||||
this.exited = new Promise((resolve) => child.once("close", (code, signal) => {
|
||||
this.close();
|
||||
resolve({ code, signal });
|
||||
}));
|
||||
child.stdin.on("error", () => { /* EPIPE after the agent exits; pending requests reject on close */ });
|
||||
}
|
||||
|
||||
/** Close the agent's stdin (detach) and wait for the process to exit, up to `timeoutMs`. */
|
||||
async end(timeoutMs: number): Promise<{ code: number | null; signal: NodeJS.Signals | null } | "still-running"> {
|
||||
this.child.stdin.end();
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<"still-running">((resolve) => { timer = setTimeout(() => resolve("still-running"), timeoutMs); });
|
||||
try { return await Promise.race([this.exited, timeout]); } finally { clearTimeout(timer); }
|
||||
}
|
||||
|
||||
/** Drop this side's hold on the agent (its pipes and process handle) without signalling it, so a detached agent that
|
||||
* keeps running doesn't keep the caller's process alive. */
|
||||
release(): void {
|
||||
this.lines.close();
|
||||
for (const stream of [this.child.stdin, this.child.stdout, this.child.stderr]) stream.destroy();
|
||||
this.child.unref();
|
||||
}
|
||||
}
|
||||
|
||||
/** The agent end over this process's stdin and stdout (or any stream pair): `closed` settles when the input ends. */
|
||||
export class StreamJsonRpcPeer extends JsonRpcPeer {
|
||||
readonly closed: Promise<void>;
|
||||
|
||||
constructor(input: NodeJS.ReadableStream, output: NodeJS.WritableStream, record: PacketRecorder = () => {}) {
|
||||
super(input, output, record);
|
||||
this.closed = new Promise((resolve) => this.lines.once("close", () => { this.close(); resolve(); }));
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,10 @@ export const RUN_MARKER = "OPENRIG_ACP_RUN";
|
||||
/** Only the pid: on macOS the listing carries the environment, which may hold secrets, so nothing else leaves here. */
|
||||
export interface RunProcess { pid: number }
|
||||
|
||||
/** The caller's processes carrying this run's marker. Linux reads /proc; elsewhere `ps e` shows own environments. */
|
||||
export async function findRunProcesses(runId: string): Promise<RunProcess[]> {
|
||||
const marker = `${RUN_MARKER}=${runId}`;
|
||||
/** The caller's processes carrying this run's marker (`rig exec` names its connector's own). Linux reads /proc;
|
||||
* elsewhere `ps e` shows own environments. */
|
||||
export async function findRunProcesses(runId: string, markerName = RUN_MARKER): Promise<RunProcess[]> {
|
||||
const marker = `${markerName}=${runId}`;
|
||||
if (process.platform === "linux") {
|
||||
const found: RunProcess[] = [];
|
||||
for (const entry of await readdir("/proc")) {
|
||||
|
||||
@@ -12,7 +12,8 @@ const REEXEC_GUARD_ENV = "OPENRIG_BIN_REEXEC";
|
||||
// Each `rig` is a new process: Node's on-disk compile cache lets the next call skip compiling the modules
|
||||
// again. The cache dates from Node v22.1.0, but this JavaScript API from v22.8.0: before it, `?.()` does
|
||||
// nothing. NODE_COMPILE_CACHE and NODE_DISABLE_COMPILE_CACHE still control it.
|
||||
module.enableCompileCache?.();
|
||||
// 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?.();
|
||||
|
||||
export function resolveBinEntry(invokedPath = process.argv[1], moduleUrl = import.meta.url): string {
|
||||
const wrapperPath = invokedPath ? realpathSync(invokedPath) : realpathSync(fileURLToPath(moduleUrl));
|
||||
|
||||
@@ -12,6 +12,14 @@ export function terminalAuthHeaders(): Record<string, string> {
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
/** The credential for operator-gated writes (Mission Control actions, a pairing decision): an explicit bearer, else the
|
||||
* daemon's operator bearer configured as OPENRIG_AUTH_BEARER_TOKEN, else the terminal credential (the same token on a
|
||||
* daemon that aliases them; none where none is needed). */
|
||||
export function operatorAuthHeaders(bearer?: string): Record<string, string> {
|
||||
const token = bearer?.trim() || process.env.OPENRIG_AUTH_BEARER_TOKEN?.trim();
|
||||
return token ? { Authorization: `Bearer ${token}` } : terminalAuthHeaders();
|
||||
}
|
||||
|
||||
/** The header carrying the caller's seat identity, derived ONCE at the transport chokepoint. */
|
||||
export const SENDER_IDENTITY_HEADER = "X-OpenRig-Session";
|
||||
|
||||
@@ -429,9 +437,9 @@ export class DaemonClient {
|
||||
// DaemonResponseError carrying the status + a bounded snippet, instead of a
|
||||
// raw SyntaxError bubbling to a cryptic (json) or silent (human) CLI exit.
|
||||
// A well-formed non-2xx body still parses and returns {status,data}; 204 has none.
|
||||
if (res.status === 204) return { status: res.status, data: undefined as T };
|
||||
if (res.status === 204) return { status: res.status, data: undefined as T, headers: res.headers };
|
||||
try {
|
||||
return { status: res.status, data: JSON.parse(text) as T };
|
||||
return { status: res.status, data: JSON.parse(text) as T, headers: res.headers };
|
||||
} catch {
|
||||
throw new DaemonResponseError(res.status, text);
|
||||
}
|
||||
|
||||
@@ -4,16 +4,20 @@ import { randomBytes } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { DaemonClient, type DaemonResponse } from "../client.js";
|
||||
import { daemonStatusGuard, getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { daemonMayAnswer, daemonStatusGuard, getDaemonStatus, getDaemonUrl, statusGuardMessage } 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";
|
||||
|
||||
const runsDir = () => join(getOpenRigHome(), "acp", "runs");
|
||||
const newId = (prefix: string, bytes: number) => `${prefix}-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(bytes).toString("hex")}`;
|
||||
export const runsDir = () => join(getOpenRigHome(), "acp", "runs");
|
||||
export const newId = (prefix: string, bytes: number) => `${prefix}-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(bytes).toString("hex")}`;
|
||||
|
||||
interface RunFile { runId: string; qitemId: string; pid: number; processStart?: string; agent: string[]; startedAt: string; endedAt?: string }
|
||||
|
||||
@@ -44,6 +48,127 @@ 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; }
|
||||
}
|
||||
@@ -62,7 +187,7 @@ function liveConnector(run: RunFile): boolean {
|
||||
}
|
||||
|
||||
export function acpCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("acp").description("Hand a queue row's task to an ACP (Agent Client Protocol) agent and keep its outcome on the row");
|
||||
const 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 getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
const connect = async (): Promise<DaemonClient | undefined> => {
|
||||
const deps = getDeps();
|
||||
@@ -135,6 +260,62 @@ Guide: rig context get reference/acp-agents.md`)
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("serve")
|
||||
.description("Serve OpenRig as an ACP agent on stdio, for an ACP app (Zed and others) to launch: its prompts go to your team as queue rows")
|
||||
.option("--to <member@rig>", "The seat that receives your tasks: your team's entry point (default: rig config get acp.to)")
|
||||
.option("--as <address>", "Who the tasks come from (default: OPENRIG_SESSION_NAME, else this machine's default person)")
|
||||
.option("--poll-seconds <n>", "How often to read the work's progress while a turn is open", "2")
|
||||
.addHelpText("after", `
|
||||
An ACP app launches this command and talks to it on stdin and stdout. Each prompt becomes a queue row from you to
|
||||
--to. The app shows the row's notes and handoffs as progress, and the closing resolution as the reply. A later
|
||||
prompt in the same session goes to --to as a follow-up that names the earlier items. A question the working seats
|
||||
ask you meanwhile comes to the app as a choice, recorded as your answer. The app can list and reopen your sessions.
|
||||
|
||||
The turn ends when the team closes the item, or when you cancel. Cancelling stops this process waiting; the team's
|
||||
work carries on. A turn that ended doesn't mean the work was accepted: the reply says how the item was closed.
|
||||
|
||||
Without --to, the receiver is the acp.to setting (rig config set acp.to <member@rig>), which is how an app that
|
||||
starts the agent from the ACP registry, with no arguments, reaches your team.
|
||||
|
||||
It uses the same OpenRig daemon as rig: the one on this computer, started with rig daemon start, unless OPENRIG_URL
|
||||
names another. The app's sign-in step ("OpenRig on this machine") checks that the daemon answers and that you
|
||||
resolve to an address: --as, OPENRIG_SESSION_NAME, or the default person: the only one registered
|
||||
(rig gateway human add <id> ...), or with several, the one chosen with rig gateway default <id>.
|
||||
|
||||
In Zed's settings.json:
|
||||
"agent_servers": { "OpenRig": { "type": "custom", "command": "rig", "args": ["acp", "serve", "--to", "lead@my-team"] } }
|
||||
|
||||
Guide: rig context get reference/acp-agents.md#openrig-as-an-agent-in-acp-apps`)
|
||||
.action(async (opts: { to?: string; as?: string; pollSeconds: string }) => {
|
||||
// Only ACP messages may reach stdout: anything a helper prints goes to stderr instead.
|
||||
console.log = console.error;
|
||||
console.info = console.error;
|
||||
process.stdout.on("error", () => { /* EPIPE once the app has gone; the input closing ends the server */ });
|
||||
const connectTeam = async (): Promise<TeamConnection | { error: string }> => {
|
||||
const status = await getDaemonStatus(getDeps().lifecycleDeps);
|
||||
if (!daemonMayAnswer(status)) {
|
||||
const err = statusGuardMessage(status);
|
||||
return { error: `${err.fact} ${err.action}` };
|
||||
}
|
||||
let caller = opts.as?.trim() || self()?.trim();
|
||||
if (!caller) {
|
||||
const person = await defaultPersonAddress();
|
||||
if ("reason" in person) return { error: `OpenRig doesn't know who you are on this machine (${person.reason}). Register yourself with rig gateway human add <id> --display-name "<your name>" --delivery-class <A|B|C|D> (the only registered person is the default; with several, choose one with rig gateway default <id>), or add --as <address> to the agent's command.` };
|
||||
caller = person.address;
|
||||
}
|
||||
const target = acpReceiver(opts.to);
|
||||
if ("error" in target) return target;
|
||||
return { queue: daemonTeamQueue(new DaemonClient(getDaemonUrl(status), { sessionName: caller })), caller, receiver: target.receiver, notice: await unregisteredPersonNotice(caller) };
|
||||
};
|
||||
const peer = new StreamJsonRpcPeer(process.stdin, process.stdout);
|
||||
serveAcpAgent(peer, {
|
||||
connect: connectTeam, version: CLI_VERSION, pollMs: Math.max(1, Number(opts.pollSeconds) || 2) * 1000,
|
||||
log: (line) => console.error(`[rig acp serve] ${line}`), closed: peer.closed,
|
||||
});
|
||||
await peer.closed;
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("cancel <runId>")
|
||||
.description("Ask a running rig acp run to cancel its agent's turn (the same as Ctrl-C there)")
|
||||
|
||||
@@ -45,7 +45,7 @@ export function agentCommand(depsOverride?: AgentDeps): Command {
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
|
||||
const res = await client.postText<{ valid?: boolean; errors?: string[] }>("/api/agents/validate", yaml);
|
||||
const res = await client.postText<{ valid?: boolean; errors?: string[]; advisories?: string[] }>("/api/agents/validate", yaml);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
@@ -65,6 +65,8 @@ export function agentCommand(depsOverride?: AgentDeps): Command {
|
||||
}
|
||||
|
||||
const data = res.data;
|
||||
// Advisories are printed whether or not the spec is valid; they never make it invalid.
|
||||
for (const advisory of data.advisories ?? []) console.error(`⚠ agent spec advisory: ${advisory}`);
|
||||
if (data.valid) {
|
||||
const name = yamlScalar(yaml, "name") ?? "unknown";
|
||||
const version = yamlScalar(yaml, "version") ?? "unknown";
|
||||
|
||||
@@ -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 name outside tmux")
|
||||
.option("--display-name <name>", "Display label inside tmux; external session address outside tmux (new bare names gain @rig)")
|
||||
.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: {
|
||||
|
||||
@@ -222,10 +222,13 @@ async function runCrossHostBroadcast(
|
||||
* local route does, so per-target honesty prints identically. A partial
|
||||
* fan-out sets a non-zero exit either way. */
|
||||
function renderBroadcastResults(data: Record<string, unknown>): void {
|
||||
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; managedCompaction?: { notice?: unknown } }>) ?? [];
|
||||
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; inputPhase?: string; sent?: boolean; managedCompaction?: { notice?: unknown } }>) ?? [];
|
||||
for (const r of results) {
|
||||
if (r.ok) {
|
||||
console.log(`${r.sessionName}: sent`);
|
||||
} else if (r.inputPhase === "attempted" || r.inputPhase === "written" || r.sent === true) {
|
||||
// The daemon records this recipient's receipt as indeterminate (#2507): its text or Enter may have reached it.
|
||||
console.log(`${r.sessionName ? `${r.sessionName}: ` : ""}UNCONFIRMED — ${r.error ?? "unknown error"}; the text may have reached it, so check with rig capture before resending`);
|
||||
} else {
|
||||
// A target that resolved to no sessions has no name to report.
|
||||
console.log(`${r.sessionName ? `${r.sessionName}: ` : ""}FAILED — ${r.error ?? "unknown error"}`);
|
||||
|
||||
@@ -54,9 +54,9 @@ export const READABLE_REQUEST_TIMEOUT_MS = 15_000;
|
||||
/** What happened to the window after a readable read, as far as OpenRig knows. */
|
||||
export function readableRelease(release: string | undefined): string {
|
||||
switch (release) {
|
||||
case "given-back": return "its window was then given back its size and its own window-size";
|
||||
case "given-back": return "its size was put back and its window-size override was unset";
|
||||
case "kept-by-another-holder": return "a web terminal opened on it meanwhile and holds its window now; it gives the window back when it closes";
|
||||
case "failed": return "giving its window back failed; a later readable capture or web terminal visit gives it back";
|
||||
case "failed": return "giving its window back failed; a later readable capture can retry releasing its pin";
|
||||
default: return "its window's record had changed, so OpenRig left the window as it found it";
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,7 @@ function renderCapture(data: Record<string, unknown>): void {
|
||||
const readable = r["readable"] as { resized?: boolean; width?: number; height?: number; reason?: string; release?: string; sizeBack?: string } | undefined;
|
||||
if (readable?.resized === true) {
|
||||
const after = readable.release === "given-back" && readable.sizeBack
|
||||
? `its own window-size was given back, but ${readable.sizeBack}`
|
||||
? `its window-size override was unset, but ${readable.sizeBack}`
|
||||
: readableRelease(readable.release);
|
||||
console.error(`[rig capture: ${r["sessionName"]}: read at ${readable.width}x${readable.height}; ${after}]`);
|
||||
} else if (readable?.resized === false) console.error(`[rig capture: ${r["sessionName"]}: not resized for reading: ${readable.reason}]`);
|
||||
@@ -190,11 +190,11 @@ Supported notes:
|
||||
"[rig capture: ...: pane WxH is smaller than 80x24, ...]" note on stderr says the prompt may be
|
||||
cut off and suggests --readable. --json has it as promptPane: width, height and hint.
|
||||
- --readable (or --size WxH) reads a seat whose pane is smaller than that: it resizes the seat's
|
||||
window, waits for the seat to redraw, captures, then puts the window back, both its size and
|
||||
its own window-size setting, so Herdr, tmux attach and other clients size it as before. The
|
||||
window, waits for the seat to redraw, captures, then puts its size back and unsets the temporary
|
||||
window-size override, so Herdr, tmux attach and other clients size it again. The
|
||||
seat redraws twice, and anyone watching it sees the resize briefly. A window that's already big
|
||||
enough is read as it is, and so is one whose size is pinned (window-size manual, as while the
|
||||
web terminal shows it), one whose sizing can't be read, and one with several panes. With --rig
|
||||
web terminal has opted into fixed geometry), one whose sizing can't be read, and one with several panes. With --rig
|
||||
or --pod, small seats are read at that size for up to 8 seconds in all, and the rest as they
|
||||
are. A stderr note says which; --json has it as readable.
|
||||
- --lines N shows at most the last N lines, counted up from the last non-blank line. When lines
|
||||
|
||||
@@ -55,8 +55,20 @@ 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
|
||||
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.
|
||||
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.
|
||||
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.`);
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ function summarizeSettings(store: ConfigStore): Record<ValidKey, ResolvedSetting
|
||||
}
|
||||
|
||||
// These settings remain boot-only; capture interval/line settings reload live.
|
||||
const BOOT_ONLY_KEYS = ["daemon.port", "daemon.host", "daemon.db_conflict", "db.path", "terminal.tmux_server"];
|
||||
const BOOT_ONLY_KEYS = ["daemon.port", "daemon.host", "daemon.db_conflict", "daemon.seat_poll_interval_ms", "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
|
||||
daemon.* port, host, seat_poll_interval_ms (startup-only seat-pollers cadence, 1000–2000; default 1000)
|
||||
db.path
|
||||
transcripts.* enabled, path, lines, poll_interval_seconds
|
||||
(lines/poll_interval set scrollback CAPTURE depth + cadence.
|
||||
@@ -103,11 +103,31 @@ 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)
|
||||
retention.closed_content_days remove the content of closed queue work (done, canceled, denied, handed-off) N
|
||||
days after it closed; the row stays, without content. 0 keeps it (default). Can't be undone
|
||||
retention.closed_metadata_days delete closed queue rows N days after they closed, keeping only their ids so a retry
|
||||
can't recreate them. 0 keeps them (default). Both read at the next sweep; "rig queue erase
|
||||
--routine --apply" runs them now
|
||||
retention.erasure_ledger an absolute file where queue erasure appends each erased id and its action, one JSON
|
||||
line each, before it commits them; keep it outside backups and replay it with "rig queue
|
||||
erase --ids <file> --apply" after a restore. Empty keeps no ledger (default)
|
||||
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
|
||||
works wherever OpenRig is installed. Inherited setup isn't re-run per command: setup only in
|
||||
login or rc files (including a tmux default-command that isn't a login shell) and shell
|
||||
functions or aliases may be absent. On omits that override; it doesn't force login shells,
|
||||
and a login file that sets PATH outright can then hide rig. Applies at each seat's next
|
||||
launch; a restart in place keeps the running choice.
|
||||
terminal.inherit_tmux_environment inherit stored tmux global values for new agents and successors (default off)
|
||||
POSIX launches otherwise start from daemon + seat environment, preserving shell startup.
|
||||
Turn on for custom setups that intentionally rely on tmux globals. Running agents are unchanged.
|
||||
If the scoped boundary is unavailable, launch continues with its previous environment and a diagnostic.
|
||||
terminal.fixed_geometry use 90x27 web geometry while subscribed (default off; other viewers share the pinned size)
|
||||
terminal.status_bar show the inner tmux status bar on launch (default off)
|
||||
terminal.tmux_server inherit the launching server, or isolated per OpenRig home (restart required)
|
||||
terminal.history_limit rows of tmux scrollback per pane in the ~/.tmux.conf block rig setup writes (2000-1000000; default 10000).
|
||||
@@ -123,10 +143,13 @@ 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 }) => {
|
||||
@@ -193,10 +216,17 @@ Precedence: CLI flag > environment variable > config file > default`)
|
||||
.action(async (key: string, value: string) => {
|
||||
try {
|
||||
store.set(key, value);
|
||||
console.log(`${key} = ${store.get(key)}`);
|
||||
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}`);
|
||||
// SWEEP-c: boot-only key + running daemon = stale-until-restart; say so loudly.
|
||||
if (isBootOnlyKey(key) && (await probeDaemonRunning())) {
|
||||
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).`);
|
||||
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).`);
|
||||
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) {
|
||||
@@ -212,9 +242,22 @@ Precedence: CLI flag > environment variable > config file > default`)
|
||||
try {
|
||||
store.reset(key);
|
||||
if (key) {
|
||||
console.log(`${key} reset to default (${store.get(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}).`);
|
||||
} else {
|
||||
console.log("Config reset to defaults.");
|
||||
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.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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;
|
||||
}
|
||||
@@ -43,6 +43,7 @@ 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"]);
|
||||
|
||||
@@ -252,6 +253,8 @@ 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),
|
||||
|
||||
@@ -57,10 +57,12 @@ export function createProcessLiveness(deps: {
|
||||
readProcessState: (pid: number) => string | null;
|
||||
}): (pid: number) => ProcessLiveness {
|
||||
return (pid: number) => {
|
||||
if (deps.signal(pid) === "missing") return "dead";
|
||||
const signal = deps.signal(pid);
|
||||
if (signal === "missing") return "dead";
|
||||
const state = deps.readProcessState(pid)?.trim();
|
||||
if (!state) return "unknown";
|
||||
return state.startsWith("Z") ? "dead" : "alive";
|
||||
if (state.startsWith("Z")) return "dead";
|
||||
return signal === "not-permitted" ? "unknown" : "alive";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -265,13 +267,19 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
// need a kernel-ready signal opt in here.
|
||||
if (opts.waitForKernel) {
|
||||
const { waitForKernelReady, skippedKernelMessage } = await import("../daemon-lifecycle.js");
|
||||
const { attentionLines, checkKernelAttention } = await import("../seat-attention.js");
|
||||
const { DaemonClient } = await import("../client.js");
|
||||
const timeoutMs = opts.waitForKernelMs && /^\d+$/.test(opts.waitForKernelMs)
|
||||
? parseInt(opts.waitForKernelMs, 10)
|
||||
: 60_000;
|
||||
const baseUrl = `http://${formatDaemonHostForUrl(state.host ?? "127.0.0.1")}:${state.port}`;
|
||||
const result = await waitForKernelReady(baseUrl, timeoutMs);
|
||||
// A kernel seat held at a startup prompt or sign-in keeps the kernel short of ready: name it and why.
|
||||
const heldLines = result.kernelState !== "ready" && result.kernelState !== "skipped"
|
||||
? attentionLines(await checkKernelAttention(new DaemonClient(baseUrl)), "pane").map(line => ` ${line}`) : [];
|
||||
if (result.ok) {
|
||||
console.log(`Kernel ${result.kernelState}; variant=${result.variant ?? "(none)"}`);
|
||||
for (const line of heldLines) console.log(line);
|
||||
} else if (result.kernelState === "skipped") {
|
||||
// Skipped on purpose, so neither a timeout nor an auth problem: say why, and the way back.
|
||||
console.error(skippedKernelMessage(result.detail));
|
||||
@@ -285,6 +293,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
"\n" +
|
||||
"Fix: inspect `rig ps --rig kernel` for stalled agents, or run `claude auth status` / `codex login status` to confirm runtime auth.",
|
||||
);
|
||||
for (const line of heldLines) console.error(line);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { execSync } from "node:child_process";
|
||||
import { resolveDaemonPath } from "../daemon-lifecycle.js";
|
||||
import { DaemonClient, ForeignLocalDaemonError, formatDaemonHostForUrl } from "../client.js";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { fetchWithTimeout } from "../fetch-with-timeout.js";
|
||||
import { buildWritableHomeCheck } from "../system-preflight.js";
|
||||
import {
|
||||
CMUX_SETTINGS_DISCLOSURE_PATH,
|
||||
@@ -45,7 +46,7 @@ export interface DoctorDeps {
|
||||
/** The caller's effective user id, compared with the one the port's daemon reports. Absent: this process's own;
|
||||
* null: this platform has none. */
|
||||
callerUid?: number | null;
|
||||
/** How long each part of the port's /healthz response may take (default 2000 ms). */
|
||||
/** How long each part of /healthz and the cmux status response may take (default 2000 ms). */
|
||||
ownerReportTimeoutMs?: number;
|
||||
/**
|
||||
* Build B — path to a rig spec to check against the RUNNING rig of the same name (`--spec`).
|
||||
@@ -61,6 +62,7 @@ export interface DoctorDeps {
|
||||
}
|
||||
|
||||
const DEFAULT_PORT = 7433;
|
||||
const DOCTOR_HTTP_TIMEOUT_MS = 2000;
|
||||
const CMUX_SOCKET_CONTROL_FIX = 'In cmux Settings > Automation, select "Automation mode" for Socket Control Mode, or set automation.socketControlMode to "automation" in ~/.config/cmux/cmux.json (older versions use ~/.config/cmux/settings.json; current cmux reads that legacy file only for keys absent from cmux.json). cmux reloads saved settings automatically; rerun `rig doctor` to re-probe without restarting the daemon.';
|
||||
|
||||
/** The port's OpenRig daemon, judged by the OS user it reports. /healthz's pid and euid are the daemon's own report,
|
||||
@@ -351,9 +353,16 @@ export function runDoctorChecks(deps: DoctorDeps, providerChecks: DoctorCheck[]
|
||||
} finally { health.abort(); }
|
||||
|
||||
try {
|
||||
const cmuxRes = await fetchFn(`${daemonBase}/api/adapters/cmux/status`);
|
||||
if (cmuxRes.ok && cmuxRes.json) {
|
||||
const data = (await cmuxRes.json()) as { available?: boolean };
|
||||
let data: { available?: boolean } = {};
|
||||
const cmuxRes = await fetchWithTimeout(fetchFn as typeof fetch, `${daemonBase}/api/adapters/cmux/status`, {}, {
|
||||
timeoutMs: deps.ownerReportTimeoutMs ?? DOCTOR_HTTP_TIMEOUT_MS,
|
||||
timeoutMessage: "Doctor's cmux status read timed out.",
|
||||
consumeResponse: async res => {
|
||||
if (res.ok && res.json) data = (await res.json()) as { available?: boolean };
|
||||
else await res.body?.cancel();
|
||||
},
|
||||
});
|
||||
if (cmuxRes.ok) {
|
||||
if (data.available) {
|
||||
return { name: "cmux_daemon", status: "pass", message: "Daemon cmux control available." };
|
||||
}
|
||||
@@ -366,11 +375,72 @@ 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?
|
||||
*
|
||||
@@ -512,18 +582,32 @@ class AmbiguousLiveRigError extends Error {
|
||||
export async function readLiveLogicalIds(base: string, rigName: string, ownerClient = new DaemonClient(base)): Promise<string[] | null> {
|
||||
try {
|
||||
await ownerClient.assertLocalOwner();
|
||||
const rigsRes = await fetch(`${base}/api/rigs`);
|
||||
let rigs: Array<{ id?: string; name?: string }> = [];
|
||||
const rigsRes = await fetchWithTimeout(fetch, `${base}/api/rigs`, {}, {
|
||||
timeoutMs: DOCTOR_HTTP_TIMEOUT_MS,
|
||||
timeoutMessage: "Doctor's live rig read timed out.",
|
||||
consumeResponse: async res => {
|
||||
if (res.ok) rigs = (await res.json()) as typeof rigs;
|
||||
else await res.body?.cancel();
|
||||
},
|
||||
});
|
||||
if (!rigsRes.ok) return null;
|
||||
const rigs = (await rigsRes.json()) as Array<{ id?: string; name?: string }>;
|
||||
const matches = rigs.filter((r) => r.name === rigName);
|
||||
if (matches.length > 1) throw new AmbiguousLiveRigError(matches.map(r => r.id ?? "(missing id)"));
|
||||
if (matches.length === 0) return null;
|
||||
const rig = matches[0];
|
||||
if (!rig?.id) return null;
|
||||
await ownerClient.assertLocalOwner();
|
||||
const nodesRes = await fetch(`${base}/api/rigs/${rig.id}/nodes`);
|
||||
let nodes: Array<{ logicalId?: string | null }> = [];
|
||||
const nodesRes = await fetchWithTimeout(fetch, `${base}/api/rigs/${rig.id}/nodes`, {}, {
|
||||
timeoutMs: DOCTOR_HTTP_TIMEOUT_MS,
|
||||
timeoutMessage: "Doctor's live node read timed out.",
|
||||
consumeResponse: async res => {
|
||||
if (res.ok) nodes = (await res.json()) as typeof nodes;
|
||||
else await res.body?.cancel();
|
||||
},
|
||||
});
|
||||
if (!nodesRes.ok) return null;
|
||||
const nodes = (await nodesRes.json()) as Array<{ logicalId?: string | null }>;
|
||||
return nodes.map((n) => n.logicalId ?? "");
|
||||
} catch (error) {
|
||||
if (error instanceof ForeignLocalDaemonError || error instanceof AmbiguousLiveRigError) throw error;
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
import { Command } from "commander";
|
||||
import { spawn } from "node:child_process";
|
||||
import { closeSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { delimiter, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { daemonStatusGuard, getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { readOpenRigEnv } from "../openrig-compat.js";
|
||||
import { describeUsage, runAcpAssignment, type AcpOutcomeKind, type AcpQueue, type AcpUsage } from "../acp/connector.js";
|
||||
import type { JsonRpcErrorObject } from "../acp/json-rpc-stdio.js";
|
||||
import { findRunProcesses, RUN_MARKER } from "../acp/run-processes.js";
|
||||
import { daemonAcpQueue, newId, processStart, runsDir } from "./acp.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
// `rig exec`: one queue row's task as one turn on a named, version-qualified ACP harness (Claude Code or Codex through
|
||||
// their maintained adapters), run by a detached connector that outlives the calling CLI. The connector owns the run,
|
||||
// the run file is its registry, and the row stays the custody record: the outcome is the agent's report, and accepting
|
||||
// the work stays with the row's custodian. `rig acp run` is the synchronous form and is unchanged.
|
||||
|
||||
/** Set only in the connector's own environment, so a run's connector can be found before it records its pid. */
|
||||
export const CONNECTOR_MARKER = "OPENRIG_EXEC_CONNECTOR";
|
||||
|
||||
export interface Harness {
|
||||
name: string;
|
||||
/** The adapter package, and the version this configuration was checked with. */
|
||||
package: string;
|
||||
qualified: string;
|
||||
bin: string;
|
||||
native: { package: string; label: string; versionField?: string };
|
||||
/** Environment the caller may set to choose what runs or what the agent asks; passed through untouched. */
|
||||
env: string[];
|
||||
reject: string;
|
||||
/** The adapter's own evidence that a `session/load` error means the session doesn't exist. */
|
||||
missingSession: (error: JsonRpcErrorObject, sessionId: string) => boolean;
|
||||
}
|
||||
|
||||
/** The ACP SDK's `sessionNotFound`: -32002 with `data.sessionId` naming the session asked for. A bare -32002 isn't. */
|
||||
const sessionNotFound = (error: JsonRpcErrorObject, sessionId: string) =>
|
||||
error.code === -32002 && (error.data as { sessionId?: unknown } | null | undefined)?.sessionId === sessionId;
|
||||
|
||||
export const HARNESSES: Record<string, Harness> = {
|
||||
claude: {
|
||||
name: "claude", package: "@agentclientprotocol/claude-agent-acp", qualified: "0.101.0", bin: "claude-agent-acp",
|
||||
native: { package: "@anthropic-ai/claude-agent-sdk", label: "Claude Code", versionField: "claudeCodeVersion" },
|
||||
env: ["CLAUDE_CODE_EXECUTABLE", "ANTHROPIC_MODEL", "CLAUDE_CONFIG_DIR"],
|
||||
reject: "a reject refuses that one action, and the turn continues",
|
||||
missingSession: sessionNotFound,
|
||||
},
|
||||
codex: {
|
||||
name: "codex", package: "@agentclientprotocol/codex-acp", qualified: "2.5.0", bin: "codex-acp",
|
||||
native: { package: "@openai/codex", label: "Codex" },
|
||||
env: ["INITIAL_AGENT_MODE", "CODEX_HOME", "CODEX_CONFIG"],
|
||||
reject: "its only refusal, cancel, ends the whole turn",
|
||||
// codex-acp 2.5.0 passes a failed thread resume through as -32603 carrying Codex's own text, which the adapter's
|
||||
// own code reads as a missing rollout. Any other -32603 is an internal error, not a missing session.
|
||||
missingSession: (error, sessionId) => sessionNotFound(error, sessionId) || (error.code === -32603
|
||||
&& JSON.stringify(error.data ?? "").includes("no rollout found for thread id") && JSON.stringify(error.data).includes(sessionId)),
|
||||
},
|
||||
};
|
||||
|
||||
export interface ResolvedHarness {
|
||||
harness: Harness;
|
||||
/** The adapter's program as found on PATH; it runs as-is. */
|
||||
command?: string;
|
||||
adapterVersion?: string;
|
||||
native: string;
|
||||
problem?: string;
|
||||
}
|
||||
|
||||
function readJson(file: string): Record<string, unknown> | undefined {
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>; } catch { return undefined; }
|
||||
}
|
||||
|
||||
function onPath(bin: string, path = ""): string | undefined {
|
||||
for (const dir of path.split(delimiter)) {
|
||||
if (!dir) continue;
|
||||
try { if (statSync(join(dir, bin)).isFile()) return join(dir, bin); } catch { /* not in this folder */ }
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The installed package a file belongs to: the nearest folder above it whose package.json has that name. */
|
||||
function packageRoot(file: string, name: string): string | undefined {
|
||||
for (let dir = dirname(file); dir !== dirname(dir); dir = dirname(dir)) {
|
||||
if (readJson(join(dir, "package.json"))?.name === name) return dir;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Node's lookup from the adapter's folder, reading package.json directly (a package's exports map may not list it). */
|
||||
function dependencyManifest(from: string, name: string): Record<string, unknown> | undefined {
|
||||
for (let dir = from; dir !== dirname(dir); dir = dirname(dir)) {
|
||||
const manifest = readJson(join(dir, "node_modules", name, "package.json"));
|
||||
if (manifest) return manifest;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveHarness(harness: Harness, env: NodeJS.ProcessEnv = process.env): ResolvedHarness {
|
||||
const found = onPath(harness.bin, env.PATH);
|
||||
if (!found) {
|
||||
return { harness, native: "unknown", problem: `${harness.bin} isn't on PATH; install it with npm install --prefix <dir> ${harness.package}@${harness.qualified} and put <dir>/node_modules/.bin on PATH` };
|
||||
}
|
||||
const root = packageRoot(realpathSync(found), harness.package);
|
||||
const version = root ? readJson(join(root, "package.json"))?.version : undefined;
|
||||
const manifest = root ? dependencyManifest(root, harness.native.package) : undefined;
|
||||
const field = harness.native.versionField ? manifest?.[harness.native.versionField] : undefined;
|
||||
const bundled = !manifest ? `${harness.native.label}: ${harness.native.package} wasn't found beside the adapter`
|
||||
: field !== undefined ? `${harness.native.label} ${String(field)}, from ${harness.native.package} ${String(manifest.version)}`
|
||||
: `${harness.native.label} ${String(manifest.version)} (${harness.native.package})`;
|
||||
const native = harness.name === "claude" && env.CLAUDE_CODE_EXECUTABLE
|
||||
? `the Claude Code at ${env.CLAUDE_CODE_EXECUTABLE} (CLAUDE_CODE_EXECUTABLE; its version isn't read here), not ${bundled}`
|
||||
: bundled;
|
||||
return { harness, command: found, adapterVersion: typeof version === "string" ? version : undefined, native };
|
||||
}
|
||||
|
||||
/** The harness's caller-set environment, by name; CODEX_CONFIG's contents aren't shown. */
|
||||
function callerEnv(harness: Harness, env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
return harness.env.filter((name) => env[name] !== undefined).map((name) => (name === "CODEX_CONFIG" ? `${name} (set)` : `${name}=${env[name]}`));
|
||||
}
|
||||
|
||||
export function describeHarness(resolved: ResolvedHarness): string {
|
||||
const { harness } = resolved;
|
||||
const installed = resolved.problem ? `not installed: ${resolved.problem}`
|
||||
: `${harness.package} ${resolved.adapterVersion ?? "(version unreadable)"} at ${resolved.command}${resolved.adapterVersion === harness.qualified ? "" : `; checked with ${harness.qualified}`}`;
|
||||
const set = callerEnv(harness);
|
||||
return [
|
||||
`${harness.name}: ${installed}`,
|
||||
` runs: ${resolved.native}`,
|
||||
` answers: ${harness.reject}`,
|
||||
` permissions: the agent's own mode decides what it asks; rig exec adds no policy. Read here: ${harness.env.join(", ")}${set.length ? ` (set: ${set.join(", ")})` : " (none set)"}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
type ExecState = "requested" | "started" | "finished" | "interrupted";
|
||||
|
||||
/** A run's registry entry. `pid` is whichever process owns the run now: the requesting CLI until the connector records itself. */
|
||||
export interface ExecRun {
|
||||
runId: string;
|
||||
qitemId: string;
|
||||
harness: string;
|
||||
agent: string[];
|
||||
cwd: string;
|
||||
self: string;
|
||||
permissionsTo?: string;
|
||||
loadSession?: string;
|
||||
pollMs: number;
|
||||
adapterVersion?: string;
|
||||
native: string;
|
||||
state: ExecState;
|
||||
pid: number;
|
||||
processStart?: string;
|
||||
requestedAt: string;
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
outcome?: AcpOutcomeKind;
|
||||
summary?: string;
|
||||
usage?: AcpUsage;
|
||||
settle?: string;
|
||||
transcript: string;
|
||||
connectorLog: string;
|
||||
}
|
||||
|
||||
const now = () => new Date().toISOString();
|
||||
const runPath = (runId: string) => join(runsDir(), `${runId}.json`);
|
||||
/** A row's claims: one file per generation, `000001.json` upward, each published whole and never removed. */
|
||||
const rowDir = (qitemId: string) => join(runsDir(), "rows", qitemId.replace(/[^A-Za-z0-9._-]/g, "_"));
|
||||
|
||||
/** A run's two transitions, each published once: `start` by the connector that runs it, or by whoever settles a run
|
||||
* that never started; `end` by whichever records how a started run ended. The slots are the run's state; the record
|
||||
* file is a copy written after each, for readers like `rig acp cancel`, and may lag a crash. */
|
||||
const slotPath = (runId: string, slot: "start" | "end") => join(runsDir(), `${runId}.${slot}.json`);
|
||||
type StartSlot = { by: "connector"; pid: number; processStart?: string; startedAt: string } | { by: "settled"; ended: Partial<ExecRun> };
|
||||
type EndSlot = { by: "connector" | "settled"; ended: Partial<ExecRun> };
|
||||
|
||||
let drafts = 0;
|
||||
/** Publishes `content` at `target` whole, and only if nothing is there: a hard link fails if the target exists. */
|
||||
function publishOnce(target: string, content: unknown): boolean {
|
||||
const draft = `${target}.${process.pid}.${++drafts}.tmp`;
|
||||
writeFileSync(draft, `${JSON.stringify(content)}\n`);
|
||||
try {
|
||||
linkSync(draft, target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
|
||||
throw error;
|
||||
} finally { rmSync(draft, { force: true }); }
|
||||
}
|
||||
|
||||
export function readRun(runId: string): ExecRun | undefined {
|
||||
const base = readJson(runPath(runId)) as ExecRun | undefined;
|
||||
if (!base || typeof base.state !== "string") return undefined;
|
||||
const start = readJson(slotPath(runId, "start")) as StartSlot | undefined;
|
||||
if (start?.by === "settled") return { ...base, ...start.ended };
|
||||
let run: ExecRun = start?.by === "connector"
|
||||
? { ...base, state: "started", pid: start.pid, processStart: start.processStart, startedAt: start.startedAt }
|
||||
: base;
|
||||
const end = readJson(slotPath(runId, "end")) as EndSlot | undefined;
|
||||
if (end) run = { ...run, ...end.ended };
|
||||
return run;
|
||||
}
|
||||
|
||||
function writeRun(run: ExecRun): void {
|
||||
const tmp = `${runPath(run.runId)}.${process.pid}.tmp`;
|
||||
writeFileSync(tmp, `${JSON.stringify(run)}\n`);
|
||||
renameSync(tmp, runPath(run.runId));
|
||||
}
|
||||
|
||||
function topClaim(qitemId: string): { generation: number; runId?: string } | undefined {
|
||||
let names: string[];
|
||||
try { names = readdirSync(rowDir(qitemId)); } catch { return undefined; }
|
||||
const generation = Math.max(0, ...names.filter((name) => /^\d{6}\.json$/.test(name)).map((name) => Number(name.slice(0, 6))));
|
||||
if (generation === 0) return undefined;
|
||||
const runId = readJson(join(rowDir(qitemId), `${String(generation).padStart(6, "0")}.json`))?.runId;
|
||||
return { generation, runId: typeof runId === "string" ? runId : undefined };
|
||||
}
|
||||
|
||||
export const rowRun = (qitemId: string): string | undefined => topClaim(qitemId)?.runId;
|
||||
|
||||
/** Makes `runId` the row's run: when the row has none, or when its current run is exactly `previous`. The next
|
||||
* generation's file is published with a hard link, which fails if it exists, so of two callers that read the same
|
||||
* current run only one claims, and nothing is ever moved aside. `afterRead` lets a test act between the read and
|
||||
* the publish. */
|
||||
export function claimRow(qitemId: string, runId: string, previous?: string, afterRead?: () => void): boolean {
|
||||
const dir = rowDir(qitemId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const top = topClaim(qitemId);
|
||||
if (top?.runId !== previous) return false;
|
||||
afterRead?.();
|
||||
return publishOnce(join(dir, `${String((top?.generation ?? 0) + 1).padStart(6, "0")}.json`), { runId, qitemId, replaces: previous ?? null });
|
||||
}
|
||||
|
||||
type Liveness = "live" | "dead" | "unknown";
|
||||
|
||||
/** A started run is live while its recorded process is still the one that recorded itself. A requested run is live
|
||||
* only while a process carries its connector marker: the record then names the requester, which isn't the connector
|
||||
* and may itself be waiting. */
|
||||
async function liveness(run: ExecRun): Promise<Liveness> {
|
||||
if (run.endedAt) return "dead";
|
||||
if (run.state !== "requested" && run.processStart !== undefined && processStart(run.pid) === run.processStart) return "live";
|
||||
const found = await findRunProcesses(run.runId, CONNECTOR_MARKER).catch(() => undefined);
|
||||
return found === undefined ? "unknown" : found.length > 0 ? "live" : "dead";
|
||||
}
|
||||
|
||||
/** Ends `run` as `ended`, exclusively: a run that hasn't started by claiming its start slot, so its connector can no
|
||||
* longer start; a started run by claiming its end slot, so its connector can no longer record an outcome over this.
|
||||
* Only the winner writes the copy and the row note; a loser gets the run as whoever won left it. */
|
||||
export async function settleRun(run: ExecRun, ended: Partial<ExecRun>, note?: string, queue?: () => Promise<AcpQueue | undefined>): Promise<{ won: boolean; run: ExecRun }> {
|
||||
const won = run.state === "requested"
|
||||
? publishOnce(slotPath(run.runId, "start"), { by: "settled", ended } satisfies StartSlot)
|
||||
: publishOnce(slotPath(run.runId, "end"), { by: "settled", ended } satisfies EndSlot);
|
||||
const after = readRun(run.runId) ?? run;
|
||||
if (!won) return { won, run: after };
|
||||
writeRun(after);
|
||||
const q = note ? await queue?.().catch(() => undefined) : undefined;
|
||||
await q?.update(run.qitemId, { note: note! }).catch(() => {});
|
||||
return { won, run: after };
|
||||
}
|
||||
|
||||
/** A run whose connector is gone without recording an outcome reads interrupted, outcome unknown, with one row note. */
|
||||
export async function reconcileRun(run: ExecRun, queue?: () => Promise<AcpQueue | undefined>, connectorExit?: string): Promise<ExecRun> {
|
||||
if (run.endedAt || (await liveness(run)) !== "dead") return run;
|
||||
const current = readRun(run.runId) ?? run;
|
||||
if (current.endedAt || (current.state !== run.state && (await liveness(current)) !== "dead")) return current;
|
||||
const left = await findRunProcesses(run.runId, RUN_MARKER).catch(() => undefined);
|
||||
const agent = left === undefined ? "whether an agent process remains is unknown (the process table couldn't be read)"
|
||||
: left.length === 0 ? "no process carrying the run's marker remains"
|
||||
: `${left.length} process(es) carrying the run's marker still run (${left.map((p) => p.pid).join(", ")})`;
|
||||
const summary = current.state === "requested"
|
||||
? `interrupted: its connector ${connectorExit ?? "isn't running"} and never recorded itself, so no turn was sent; ${agent}; its log is ${current.connectorLog}`
|
||||
: `interrupted: its connector ended without recording an outcome, so the outcome is unknown; ${agent}`;
|
||||
const settled = await settleRun(current, { state: "interrupted", outcome: "unknown", summary, endedAt: now() },
|
||||
`rig exec run ${run.runId} ${summary}. Its transcript: ${run.transcript}`, queue);
|
||||
return settled.run;
|
||||
}
|
||||
|
||||
export function describeRun(run: ExecRun): string {
|
||||
const state = run.state === "requested" ? "requested; its connector hasn't recorded itself yet"
|
||||
: run.state === "started" ? `running (connector pid ${run.pid}, since ${run.startedAt})`
|
||||
: run.state === "interrupted" ? run.summary ?? "interrupted; outcome unknown"
|
||||
: `finished, outcome ${run.outcome}: ${run.summary}`;
|
||||
return [
|
||||
`${run.runId} for ${run.qitemId} on ${run.harness} (${run.agent[0]}${run.adapterVersion ? ` ${run.adapterVersion}` : ""}; runs ${run.native})`,
|
||||
` ${state}`,
|
||||
run.state === "finished" ? ` ${describeUsage(run.usage)}` : undefined,
|
||||
run.endedAt ? " The outcome is the agent's report, not acceptance: the row stays open for its custodian to judge and close." : undefined,
|
||||
run.settle ? ` After the turn: ${run.settle}` : undefined,
|
||||
` transcript ${run.transcript}; connector log ${run.connectorLog}`,
|
||||
].filter((line) => line !== undefined).join("\n");
|
||||
}
|
||||
|
||||
/** Tests only: points where a test acts as a concurrent reconciler would. */
|
||||
export interface ConnectorHooks { afterGuard?: () => Promise<void>; afterStart?: () => Promise<void>; beforeEnd?: () => Promise<void> }
|
||||
|
||||
/** The detached connector: claims the run's start, runs the turn as `rig acp run` does, then claims its end. */
|
||||
export async function runExecConnector(runId: string, queue: AcpQueue | undefined, hooks: ConnectorHooks = {}): Promise<number> {
|
||||
const run = readRun(runId);
|
||||
if (!run) { console.error(`No rig exec run ${runId} on this machine`); return 1; }
|
||||
// Only the connector `rig exec run` started for this record runs it, once: anything else would send the row's task a
|
||||
// second time. The record is left untouched.
|
||||
if (run.state !== "requested" || process.env[CONNECTOR_MARKER] !== runId) {
|
||||
console.error(`${runId} is ${run.state}${run.state === "requested" ? ", and this process wasn't started for it" : ""}; rig exec connector runs only the run rig exec run started it for`);
|
||||
return 1;
|
||||
}
|
||||
delete process.env[CONNECTOR_MARKER]; // the agent doesn't inherit it; this process's own environment still shows it
|
||||
// Before the run names this process, so a `rig exec cancel` that reads the record can't reach a default SIGINT.
|
||||
let cancel!: () => void;
|
||||
let detach!: () => void;
|
||||
const cancelRequested = new Promise<void>((done) => { cancel = done; });
|
||||
const detachRequested = new Promise<void>((done) => { detach = done; });
|
||||
let interrupts = 0;
|
||||
process.on("SIGINT", () => { interrupts += 1; if (interrupts === 1) cancel(); else detach(); });
|
||||
process.on("SIGHUP", () => { /* no terminal owns this process; a hangup isn't a cancel */ });
|
||||
await hooks.afterGuard?.();
|
||||
// Claiming the start is what lets this process send the turn. A reconciler that judged the run dead may have claimed
|
||||
// it first; then the run stays settled and nothing is sent.
|
||||
if (!publishOnce(slotPath(runId, "start"), { by: "connector", pid: process.pid, processStart: processStart(process.pid), startedAt: now() } satisfies StartSlot)) {
|
||||
console.error(`${runId} was settled before this connector started (${readRun(runId)?.summary ?? "no summary"}); no turn is sent`);
|
||||
return 1;
|
||||
}
|
||||
const started = readRun(runId)!;
|
||||
writeRun(started);
|
||||
await hooks.afterStart?.();
|
||||
/** Records the end only if nothing recorded one first; a reconciler's record stands, and the row hears what happened. */
|
||||
const end = async (ended: Partial<ExecRun>): Promise<void> => {
|
||||
await hooks.beforeEnd?.();
|
||||
if (publishOnce(slotPath(runId, "end"), { by: "connector", ended } satisfies EndSlot)) { writeRun(readRun(runId)!); return; }
|
||||
const recorded = readRun(runId);
|
||||
console.error(`${runId} was recorded ${recorded?.state} before this connector finished; its own outcome was ${ended.outcome}`);
|
||||
await queue?.update(run.qitemId, { note: `rig exec run ${runId}: its connector finished with outcome ${ended.outcome} (${ended.summary}) after the run had been recorded ${recorded?.state}; that record stands` }).catch(() => {});
|
||||
};
|
||||
if (!queue) {
|
||||
await end({ state: "finished", outcome: "failed", summary: "the OpenRig daemon couldn't be reached, so no turn was sent", endedAt: now() });
|
||||
return 1;
|
||||
}
|
||||
let settle: string | undefined;
|
||||
try {
|
||||
const outcome = await runAcpAssignment(queue, {
|
||||
qitemId: run.qitemId, agent: run.agent, cwd: run.cwd, self: run.self, permissionsTo: run.permissionsTo,
|
||||
loadSession: run.loadSession, runId, logPath: run.transcript, pollMs: run.pollMs,
|
||||
progress: (text) => process.stdout.write(text), cancelRequested, detachRequested,
|
||||
missingSession: HARNESSES[run.harness]?.missingSession, onSettled: (report) => { settle = report; },
|
||||
});
|
||||
await end({ state: "finished", outcome: outcome.kind, summary: outcome.summary, usage: outcome.usage, settle, endedAt: now() });
|
||||
return outcome.kind === "completed" ? 0 : 2;
|
||||
} catch (error) {
|
||||
await end({ state: "finished", outcome: "failed", summary: (error as Error).message, settle, endedAt: now() });
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExecDeps {
|
||||
status?: StatusDeps;
|
||||
/** The queue as the calling seat (default: the daemon's). */
|
||||
queue?: () => Promise<AcpQueue | undefined>;
|
||||
/** The detached connector's command line (default: this CLI's own entry, `exec connector <runId>`). */
|
||||
connectorCommand?: (runId: string) => string[];
|
||||
pollMs?: number;
|
||||
startWaitMs?: number;
|
||||
/** Tests only: acts between the row-claim read and its publish, as a concurrent caller would. */
|
||||
beforeClaim?: () => void;
|
||||
/** Tests only: runs right after the CLI has told its caller about the run, as a process descheduled there would. */
|
||||
afterAnnounce?: () => void;
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((done) => setTimeout(done, ms));
|
||||
const exitFor = (run: ExecRun) => (run.outcome === "completed" ? 0 : 2);
|
||||
type Waiter = { left: boolean; leave: () => void };
|
||||
|
||||
export function execCommand(deps: ExecDeps = {}): Command {
|
||||
const cmd = new Command("exec").description("Run a queue row's task on a named harness (Claude Code or Codex over ACP) in a connector that outlives the command, with the outcome kept on the row");
|
||||
const queueFor = deps.queue ?? (async () => {
|
||||
const d = deps.status ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
const status = await getDaemonStatus(d.lifecycleDeps);
|
||||
return daemonStatusGuard(status) ? daemonAcpQueue(d.clientFactory(getDaemonUrl(status))) : undefined;
|
||||
});
|
||||
const connectorCommand = deps.connectorCommand
|
||||
?? ((runId: string) => [process.execPath, fileURLToPath(new URL("../index.js", import.meta.url)), "exec", "connector", runId]);
|
||||
const pollMs = deps.pollMs ?? 1000;
|
||||
|
||||
/** The wait's own Ctrl-C, armed before the CLI tells its caller anything about the run, so a Ctrl-C from then on
|
||||
* only stops the wait: armed later, one landing just after the announcement met the default and killed the CLI. The
|
||||
* connector is in its own session either way. */
|
||||
const armWait = (wait: boolean | undefined): Waiter | undefined => {
|
||||
if (!wait) return undefined;
|
||||
const waiter: Waiter = { left: false, leave: () => { waiter.left = true; } };
|
||||
process.on("SIGINT", waiter.leave);
|
||||
return waiter;
|
||||
};
|
||||
/** Polls the run file until it ends, or until the waiter leaves. */
|
||||
const waitForEnd = async (runId: string, waiter: Waiter): Promise<ExecRun | "left"> => {
|
||||
for (let polls = 1; ; polls++) {
|
||||
const run = readRun(runId);
|
||||
if (!run) throw new Error(`the record of ${runId} is gone`);
|
||||
if (run.endedAt) return run;
|
||||
if (waiter.left) return "left";
|
||||
if (polls % 5 === 0) { const checked = await reconcileRun(run, queueFor); if (checked.endedAt) return checked; }
|
||||
await sleep(pollMs);
|
||||
}
|
||||
};
|
||||
const finish = (result: ExecRun | "left", runId: string) => {
|
||||
if (result === "left") {
|
||||
console.log(`\n[rig exec] stopped waiting; ${runId} keeps running. rig exec status ${runId} shows it; rig exec cancel ${runId} cancels it.`);
|
||||
process.exitCode = 3;
|
||||
return;
|
||||
}
|
||||
console.log(describeRun(result));
|
||||
process.exitCode = exitFor(result);
|
||||
};
|
||||
/** Every path that holds the row's run ends here: a run that has ended exits by its outcome (0 completed, 2 any
|
||||
* other), --wait waits for one still going (3 if the waiter leaves), and without --wait a run still going exits 0. */
|
||||
const report = async (run: ExecRun, waiter: Waiter | undefined) => {
|
||||
try {
|
||||
if (run.endedAt) process.exitCode = exitFor(run);
|
||||
else if (waiter) finish(await waitForEnd(run.runId, waiter), run.runId);
|
||||
} finally { if (waiter) process.removeListener("SIGINT", waiter.leave); }
|
||||
};
|
||||
|
||||
cmd
|
||||
.command("harnesses")
|
||||
.description("List the named harnesses: the adapter and native versions installed here, and how each one differs")
|
||||
.option("--json", "JSON output")
|
||||
.action((opts: { json?: boolean }) => {
|
||||
const all = Object.values(HARNESSES).map((harness) => resolveHarness(harness));
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(all.map((r) => ({
|
||||
name: r.harness.name, package: r.harness.package, checkedWith: r.harness.qualified, installed: r.adapterVersion ?? null,
|
||||
command: r.command ?? null, runs: r.native, answers: r.harness.reject, reads: r.harness.env, set: callerEnv(r.harness), problem: r.problem ?? null,
|
||||
})), null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(all.map(describeHarness).join("\n\n"));
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("run <qitemId>")
|
||||
.description("Start one row's task as a single turn on a named harness, in a connector that outlives this command")
|
||||
.requiredOption("--harness <name>", `The harness: ${Object.keys(HARNESSES).join(" or ")} (rig exec harnesses)`)
|
||||
.option("--cwd <dir>", "The agent session's working directory", process.cwd())
|
||||
.option("--permissions-to <session>", "The seat that answers the agent's permission requests (default: this seat)")
|
||||
.option("--load-session <id>", "Continue an earlier session; nothing new starts when it can't be loaded")
|
||||
.option("--wait", "Wait here for the outcome; leaving the wait (Ctrl-C) never cancels the run")
|
||||
.option("--plan", "Show what would run, with its versions, and start nothing")
|
||||
.option("--again", "Start another turn on a row whose earlier run has ended")
|
||||
.option("--poll-seconds <n>", "How often the connector looks for a permission answer", "2")
|
||||
.addHelpText("after", `
|
||||
A connector owns the run and outlives this command: closing this terminal, or leaving --wait, doesn't stop it.
|
||||
It runs one turn of the row's task on the harness's ACP adapter, as rig acp run does, and records the binding,
|
||||
progress, permission requests and the outcome on the row. The outcome is the agent's report: a completed turn isn't
|
||||
accepted work, so the row stays open for you to judge and close.
|
||||
|
||||
One run per row: running it again returns the row's run, still going or ended, instead of sending the task twice.
|
||||
--again starts another turn once that run has ended. A run whose connector died without recording an outcome reads
|
||||
interrupted, outcome unknown.
|
||||
|
||||
The agent keeps its own permission mode, and rig exec adds no policy: your environment passes through (rig exec
|
||||
harnesses lists what each harness reads). A permission request becomes a row to --permissions-to.
|
||||
|
||||
rig exec status <runId|qitemId> state, outcome, usage and cost as the agent reported them, and what still runs
|
||||
rig exec cancel <runId> asks the agent to cancel its turn; the acknowledgement isn't settlement
|
||||
|
||||
Guide: rig context get reference/acp-agents.md`)
|
||||
.action(async (qitemId: string, opts: { harness: string; cwd: string; permissionsTo?: string; loadSession?: string; wait?: boolean; plan?: boolean; again?: boolean; pollSeconds: string }) => {
|
||||
const harness = HARNESSES[opts.harness];
|
||||
if (!harness) { console.error(`No harness ${opts.harness}; the named ones are ${Object.keys(HARNESSES).join(", ")}`); process.exitCode = 1; return; }
|
||||
const resolved = resolveHarness(harness);
|
||||
if (opts.plan) {
|
||||
console.log(`${describeHarness(resolved)}\n would run: ${resolved.command ?? harness.bin} for ${qitemId} in ${resolve(opts.cwd)}${opts.loadSession ? `, continuing session ${opts.loadSession}` : ", in a new session"}; permission requests to ${opts.permissionsTo ?? "this seat"}`);
|
||||
if (resolved.problem) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const seat = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
|
||||
if (!seat) { console.error("rig exec run acts as the seat that owns the row, but OPENRIG_SESSION_NAME isn't set"); process.exitCode = 1; return; }
|
||||
if (resolved.problem) { console.error(resolved.problem); process.exitCode = 1; return; }
|
||||
const queue = await queueFor();
|
||||
if (!queue) { process.exitCode = 1; return; }
|
||||
mkdirSync(runsDir(), { recursive: true });
|
||||
|
||||
const previousId = rowRun(qitemId);
|
||||
const previous = previousId ? readRun(previousId) : undefined;
|
||||
if (previous) {
|
||||
const run = await reconcileRun(previous, async () => queue);
|
||||
if (opts.again && !run.endedAt) {
|
||||
console.error(`${run.runId} is still ${run.state} on ${qitemId}; cancel it (rig exec cancel ${run.runId}) or let it end before --again`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (!opts.again) {
|
||||
const waiter = armWait(opts.wait);
|
||||
console.log(`${qitemId} already has a run, so the task isn't sent twice:\n${describeRun(run)}\nOnce it has ended, --again starts another turn on this row.`);
|
||||
await report(run, waiter);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const row = await queue.show(qitemId).catch((error: Error) => error);
|
||||
if (row instanceof Error) { console.error(`rig exec run: ${row.message}`); process.exitCode = 1; return; }
|
||||
if (row.destinationSession !== seat) { console.error(`${qitemId} belongs to ${row.destinationSession}; run rig exec from that seat, or hand the row to ${seat} first`); process.exitCode = 1; return; }
|
||||
if (row.state !== "pending" && row.state !== "in-progress") { console.error(`${qitemId} is ${row.state}; rig exec runs an in-progress or pending row`); process.exitCode = 1; return; }
|
||||
|
||||
const runId = newId("exec", 4);
|
||||
const run: ExecRun = {
|
||||
runId, qitemId, harness: harness.name, agent: [resolved.command!], cwd: resolve(opts.cwd), self: seat,
|
||||
permissionsTo: opts.permissionsTo, loadSession: opts.loadSession, pollMs: Math.max(1, Number(opts.pollSeconds) || 2) * 1000,
|
||||
adapterVersion: resolved.adapterVersion, native: resolved.native, state: "requested", pid: process.pid,
|
||||
processStart: processStart(process.pid), requestedAt: now(),
|
||||
transcript: join(runsDir(), `${runId}.jsonl`), connectorLog: join(runsDir(), `${runId}.connector.log`),
|
||||
};
|
||||
writeRun(run);
|
||||
if (!claimRow(qitemId, runId, previousId, deps.beforeClaim)) {
|
||||
rmSync(runPath(runId), { force: true });
|
||||
const winnerId = rowRun(qitemId);
|
||||
const winner = winnerId ? readRun(winnerId) : undefined;
|
||||
const waiter = winner ? armWait(opts.wait) : undefined;
|
||||
console.log(`${qitemId} was started by another caller as ${winnerId ?? "another run"}, so the task isn't sent twice${winner ? `:\n${describeRun(winner)}` : ""}`);
|
||||
if (winner) await report(winner, waiter);
|
||||
else process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const log = openSync(run.connectorLog, "a");
|
||||
const [file, ...args] = connectorCommand(runId);
|
||||
let spawnError: Error | undefined;
|
||||
let connectorExit: string | undefined;
|
||||
try {
|
||||
const child = spawn(file!, args, { detached: true, stdio: ["ignore", log, log], env: { ...process.env, [CONNECTOR_MARKER]: runId } });
|
||||
child.once("error", (error) => { spawnError = error; });
|
||||
child.once("exit", (code, signal) => { connectorExit = `exited (code ${code ?? "none"}, signal ${signal ?? "none"})`; });
|
||||
child.unref();
|
||||
} finally { closeSync(log); }
|
||||
const waiter = armWait(opts.wait);
|
||||
let current: ExecRun = run;
|
||||
for (const deadline = Date.now() + (deps.startWaitMs ?? 10_000); Date.now() < deadline; await sleep(50)) {
|
||||
current = readRun(runId) ?? current;
|
||||
if (current.state !== "requested" || spawnError || connectorExit || waiter?.left) break;
|
||||
}
|
||||
if (spawnError && current.state === "requested") {
|
||||
current = (await settleRun(current, { state: "finished", outcome: "failed", summary: `the connector couldn't be started: ${spawnError.message}`, endedAt: now() })).run;
|
||||
} else if (connectorExit && current.state === "requested") {
|
||||
current = await reconcileRun(current, async () => queue, connectorExit);
|
||||
}
|
||||
console.log(`[rig exec] ${runId} for ${qitemId} on ${harness.name}: ${current.endedAt ? `ended, outcome ${current.outcome}: ${current.summary}` : current.state === "started" ? "started" : "requested; its connector hasn't recorded itself yet"}`);
|
||||
console.log(` rig exec status ${runId} shows it; rig exec cancel ${runId} cancels it.`);
|
||||
deps.afterAnnounce?.();
|
||||
await report(current, waiter);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("status <runIdOrQitemId>")
|
||||
.description("Show a run's state, the outcome as the agent reported it, usage and cost, and what still runs")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (target: string, opts: { json?: boolean }) => {
|
||||
const runId = readRun(target) ? target : rowRun(target);
|
||||
const run = runId ? readRun(runId) : undefined;
|
||||
if (!run) { console.error(`No rig exec run for ${target} on this machine`); process.exitCode = 1; return; }
|
||||
const current = await reconcileRun(run, queueFor);
|
||||
console.log(opts.json ? JSON.stringify(current, null, 2) : describeRun(current));
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("cancel <runId>")
|
||||
.description("Ask a running rig exec run's agent to cancel its turn")
|
||||
.action((runId: string) => {
|
||||
const run = readRun(runId);
|
||||
if (!run || run.endedAt || run.state !== "started" || run.processStart === undefined || processStart(run.pid) !== run.processStart) {
|
||||
console.error(`No running rig exec run ${runId}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
process.kill(run.pid, "SIGINT");
|
||||
console.log(`Cancel requested for ${runId}; its row ${run.qitemId} records what the agent acknowledges. An acknowledgement isn't settlement: rig exec status ${runId} shows the outcome and what still runs.`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("connector <runId>", { hidden: true })
|
||||
.description("The detached connector that owns a run (started by rig exec run)")
|
||||
.action(async (runId: string) => { process.exitCode = await runExecConnector(runId, await queueFor()); });
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -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). Optional handle=<platform id> makes the binding inbound-resolvable (unique per kind); 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; 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.",
|
||||
(v: string, acc: string[] = []) => { acc.push(v); return acc; },
|
||||
)
|
||||
.requiredOption("--delivery-class <A|B|C|D>", "Notification loudness class (the notifications register selection)")
|
||||
@@ -253,16 +253,24 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
|
||||
.addHelpText("after", `
|
||||
Binding specification details:
|
||||
Format: <kind:connectorRef:secretsRef:role[:handle=<id>]>
|
||||
- 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').
|
||||
- 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.
|
||||
- role: "primary" (exactly one binding must be primary) or "secondary"
|
||||
- handle: Optional platform user identifier (e.g. "handle=U12345678") for inbound routing
|
||||
- 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.
|
||||
|
||||
Example:
|
||||
Examples:
|
||||
# Slack binding
|
||||
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
|
||||
@@ -559,9 +567,14 @@ Examples:
|
||||
|
||||
human
|
||||
.command("set <entityId> <field> <value>")
|
||||
.description("Edit one field through the verb (same validation as add; re-projection immediate). Fields: display-name, delivery-class, away, availability (available|focus|away|off, or unset), slack-subscription (the person's Slack subscription: every-ask, the default, posts every ask to Slack too; urgent-only posts only asks created with --alert, the rest wait on Steering and the TUI; or unset), binding.<n>; and, kept in the participant side file: organization (an id, or unset), groups (comma-separated ids, or unset), policy (text, @<path> to read a file, or unset)")
|
||||
.description("Edit one field through the verb (same validation as add; re-projection immediate). Fields: display-name, delivery-class, away, availability (available|focus|away|off, or unset), slack-subscription (the person's Slack subscription: every-ask, the default, posts every ask to Slack too; urgent-only posts only asks created with --alert, the rest wait on Steering and the TUI; or unset), binding.<n> (a full kind:connectorRef:secretsRef:role[:handle=<id>] spec, or unset to remove that binding); and, kept in the participant side file: organization (an id, or unset), groups (comma-separated ids, or unset), policy (text, @<path> to read a file, or unset)")
|
||||
.option("--reason <reason>", "Reason recorded with connector binding changes", "update human binding")
|
||||
.option("--actor <actor>", "Named operator when outside a managed seat")
|
||||
.addHelpText("after", `
|
||||
Removing a connector binding (binding.<n> unset):
|
||||
- The one-primary rule stays: no replacement primary is chosen silently. If removing a primary binding would leave other bindings, the command refuses. To replace the primary in place, set its slot to the new primary spec; to promote an existing secondary binding, first save that binding's full spec (kind:connectorRef:secretsRef:role[:handle=<id>]) from rig gateway human show <entityId> --json, then unset that secondary slot, then find the primary's current slot in remainingBindings (or rig gateway human show <entityId> --json, since the primary's slot shifts down when a lower-index secondary is removed) and set it to the saved spec with role primary (promotion replaces the former primary binding; its connector reference and inbound handle are not retained as a secondary); to remove every binding, unset the secondary bindings first using their current slot indexes from remainingBindings or rig gateway human show <entityId>, then finish at binding.0 (once all secondaries are gone, the primary is at binding.0).
|
||||
- Remaining bindings are printed with their shifted binding.<n> slot indexes (the primary's slot shifts down too when a lower-index secondary is removed). Because indexes shift after removal, do not blindly retry 'binding.0 unset'; check remainingBindings, the receipt, or 'rig gateway human show <entityId>' before retrying.
|
||||
- Removing a binding keeps the person, their preferences, their other bindings and their open questions. Removing the last binding makes the current delivery episode inactive, so new missing-post findings stop (an already-open recovery item for an undelivered notice may remain); the current delivery-outcome display may disappear too, while historical receipt notes remain. It does not answer, close or cancel open questions, cancel or reroute already-prepared deliveries, or retract anything a bridge has already accepted.`)
|
||||
.action(async (entityId: string, field: string, value: string, opts: { reason: string; actor?: string }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
if (field === "organization" || field === "groups" || field === "policy") {
|
||||
@@ -594,7 +607,19 @@ Examples:
|
||||
return { value: res, after: { digest }, effect: digest === prior ? "no-op" : "applied" };
|
||||
},
|
||||
});
|
||||
console.log(JSON.stringify({ ok: true, entityId, field, path: result.value.path, receipt: result.receipt }));
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
entityId,
|
||||
field,
|
||||
path: result.value.path,
|
||||
receipt: result.receipt,
|
||||
...(value === "unset"
|
||||
? {
|
||||
remainingBindings: result.value.remainingBindings ?? [],
|
||||
note: result.value.note,
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
} catch (error) { console.error(`refused: ${(error as Error).message}`); process.exitCode = 1; }
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import { getOpenRigHome } from "../openrig-compat.js";
|
||||
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry, type SshHostEntry } from "../host-registry.js";
|
||||
import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executor.js";
|
||||
import { recordHostObservation, loadHostBindings, describeBindingConflict, defaultHostBindingsPath, type HostObservationOutcome } from "../host-bindings.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { DaemonClient, operatorAuthHeaders } from "../client.js";
|
||||
import { readOwnHostName, readSelectedHost } from "../host-selection.js";
|
||||
import { parseFeedHostSubscriptionKey } from "../config-store.js";
|
||||
|
||||
@@ -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", "600")
|
||||
.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("--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,11 +857,17 @@ 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) {
|
||||
@@ -878,28 +884,29 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
if (!opts.json) {
|
||||
console.log(`Pairing code: ${issued.code}`);
|
||||
console.log(`Waiting for approval on the target (its attention queue carries item ${issued.approvalQitemId ?? "?"}).`);
|
||||
console.log(`On the target: rig queue update ${issued.approvalQitemId ?? "<qitem-id>"} --state done --closure-reason no-follow-on`);
|
||||
console.log("On the target, its person answers the pairing request in the TUI (rig tui, then attention), on Steering, or in Slack: with Approve or Deny, or in their own words. On a 0.7.11 or later target with a kernel operator, the operator reads the answer and decides (a stopped operator's answer waits for it); on a target with no kernel operator, Approve and Deny decide, and typed words are kept and the person is asked again. On an older target any typed reply approves.");
|
||||
console.log(`On a 0.7.11 or later target, anyone at its terminal with its operator bearer token can also decide: rig host approve ${issued.code} (or rig host deny ${issued.code}), with OPENRIG_AUTH_BEARER_TOKEN set or --bearer <token>.`);
|
||||
}
|
||||
|
||||
// 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);
|
||||
// A note names the bound actually used so a clamped value is not silently absorbed.
|
||||
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 } = {};
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
for (;;) {
|
||||
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 {
|
||||
continue; // transient poll failure: keep waiting until the deadline
|
||||
// transient poll failure: keep waiting until the deadline
|
||||
}
|
||||
if (outcome.status && outcome.status !== "pending") break;
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) break;
|
||||
await new Promise((r) => setTimeout(r, Math.min(2000, remaining)));
|
||||
}
|
||||
|
||||
if (outcome.status !== "approved" || !outcome.token) {
|
||||
@@ -952,6 +959,44 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
printHostNextSteps(added.entry);
|
||||
});
|
||||
|
||||
// The explicit decision on a pairing request made to THIS host: run by the agent that reads the person's answer, or by
|
||||
// a person at a terminal here. One decision per request; the daemon records who made it.
|
||||
for (const decision of ["approve", "deny"] as const) {
|
||||
cmd
|
||||
.command(decision)
|
||||
.description(decision === "approve"
|
||||
? "Approve a pairing request made to this host: the requesting machine receives this host's token"
|
||||
: "Deny a pairing request made to this host: nothing is handed over")
|
||||
.argument("<request>", "The pairing code, or the request's approval row id")
|
||||
.option("--bearer <token>", "This daemon's operator bearer token (or set OPENRIG_AUTH_BEARER_TOKEN); pairing targets always have one")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (request: string, opts: { bearer?: string; json?: boolean }) => {
|
||||
type Decided = { ok?: boolean; error?: string; message?: string; code?: string; requester?: string; by?: string };
|
||||
// The decision is gated by the operator bearer (OPENRIG_AUTH_BEARER_TOKEN), as rig queue resolve and rig mode are.
|
||||
let res: { status: number; data: Decided };
|
||||
try {
|
||||
res = await new DaemonClient().post<Decided>("/api/hosts/pair-decision", { request: request.trim(), decision },
|
||||
{ headers: operatorAuthHeaders(opts.bearer) });
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) console.log(JSON.stringify(res.data));
|
||||
if (res.status !== 200 || !res.data?.ok) {
|
||||
if (!opts.json) console.error(res.status === 401
|
||||
? `This daemon refused the decision without its operator bearer token. Set OPENRIG_AUTH_BEARER_TOKEN to the token this daemon runs with, or pass --bearer <token>. Nothing was decided.`
|
||||
: res.data?.message ?? res.data?.error ?? `the pairing decision failed (HTTP ${res.status})`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) return;
|
||||
console.log(decision === "approve"
|
||||
? `Approved pairing ${res.data.code} from ${res.data.requester}, as ${res.data.by}. The requesting machine receives this host's token when it next checks.`
|
||||
: `Denied pairing ${res.data.code} from ${res.data.requester}, as ${res.data.by}. Nothing was handed over.`);
|
||||
});
|
||||
}
|
||||
|
||||
cmd
|
||||
.command("list")
|
||||
// OPR.0.4.6.MH1 QA fixback: the PRD/proof contract spells the verb
|
||||
|
||||
@@ -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"]);
|
||||
function launchStatusRunning(status: string): boolean {
|
||||
export function launchStatusRunning(status: string): boolean {
|
||||
return !NON_RUNNING_LAUNCH_STATUSES.has(status);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } from "../mcp-server.js";
|
||||
import { createMcpServer, dropUnfilledSeat, unfilledSeatText, type UnfilledSeat } 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,12 +25,20 @@ 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
|
||||
@@ -43,14 +51,23 @@ 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; oauthResource?: string; oauthIssuer?: string }) => {
|
||||
.action(async (opts: { port?: string; http?: boolean; bind?: string; grantService?: string; grantServiceHeaderFile?: string; taskDestination?: string; oauthResource?: string; oauthIssuer?: string; frontDoor?: boolean }) => {
|
||||
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) {
|
||||
@@ -80,6 +97,11 @@ 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;
|
||||
@@ -92,7 +114,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);
|
||||
await serveHttp(daemonUrl, opts.bind ?? "127.0.0.1:7437", opts.grantService, opts.grantServiceHeaderFile, oauth, opts.taskDestination, opts.frontDoor === true, unfilledSeat);
|
||||
return;
|
||||
}
|
||||
const client = deps.clientFactory(daemonUrl);
|
||||
@@ -102,7 +124,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), { onerror: (err) => console.error(`rig mcp serve: ${err.message}`) });
|
||||
const served = serveStdio(() => createMcpServer(client, { frontDoor: opts.frontDoor === true, ...(unfilledSeat ? { unfilledSeat } : {}) }), { 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();
|
||||
@@ -169,7 +191,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 }): Promise<void> {
|
||||
oauth?: { resource: string; issuer: string }, taskDestination?: string, frontDoor = false, unfilledSeat?: UnfilledSeat): 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}`);
|
||||
@@ -181,7 +203,8 @@ 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,
|
||||
served = await startMcpHttpServer({ daemonUrl, host, port: Number(match[2]), tokenFile, taskDestination, frontDoor,
|
||||
...(unfilledSeat ? { unfilledSeat } : {}),
|
||||
...(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}`);
|
||||
|
||||
@@ -143,6 +143,11 @@ export function proofCommand(): Command {
|
||||
The selected daemon owns show/judge. Scope addresses are mission/slices/slice#item.
|
||||
The project owner selects proofPolicy: { judges: [exact-seat-address] } in the
|
||||
owning slice.yaml, mission.yaml or project.yaml (nearest wins; no default gate roles).
|
||||
By default an item's latest judgment from any listed judge decides it. Add
|
||||
everyJudge: true to require every listed judge's own current accept: one judge's
|
||||
accept then can't hide another's reject, withdraw, missing judgment or stale
|
||||
evidence. Turning it on is a policy change, so earlier judgments must be recorded
|
||||
again. rig proof show lists each judge's own latest judgment either way.
|
||||
The contract is the authored ## Proof contract. A sole item needs no # selector.
|
||||
|
||||
Example (existing evidence, no copied hashes or operation key):
|
||||
@@ -163,9 +168,17 @@ checkboxes do not accept an item under the selected proof policy.
|
||||
if (cmd.opts().workspace) throw new Error("--workspace applies to proof add. Select the judgment daemon using OPENRIG_URL; proof show reports its scope basis.");
|
||||
return new DaemonClient();
|
||||
};
|
||||
const response = async (r: { status: number; data: unknown }): Promise<Record<string, any>> => {
|
||||
// A path_escape response names the supported routes to another workspace's tree.
|
||||
const projectSelected = (project: unknown, scope: unknown) => Boolean(project) || /^[^/:]+:/.test(typeof scope === "string" ? scope : "");
|
||||
const escapeHint = (project: boolean) => project
|
||||
? "\nThe scope is outside that project's missions root; name a mission/slice path inside it."
|
||||
: "\nThe scope is outside the selected daemon workspace. For a catalog project, pass --project <id> or prefix the scope with <project>:."
|
||||
+ " To use another daemon's workspace, select that daemon with OPENRIG_URL.";
|
||||
const response = async (r: { status: number; data: unknown }, project = false): Promise<Record<string, any>> => {
|
||||
const data = r.data as Record<string, any>;
|
||||
if (r.status >= 400) throw new Error(`${data.error ?? r.status}: ${data.message ?? "Read the named source and retry"}`);
|
||||
if (r.status >= 400) {
|
||||
throw new Error(`${data.error ?? r.status}: ${data.message ?? "Read the named source and retry"}${data.error === "path_escape" ? escapeHint(project) : ""}`);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
const projectHelp = "Workspace-catalog project id; resolves the scope under that project's missions root (same as a <project>:<scope> prefix)";
|
||||
@@ -175,7 +188,7 @@ checkboxes do not accept an item under the selected proof policy.
|
||||
.action(async (scope, opts) => {
|
||||
try {
|
||||
const query = new URLSearchParams({ ...(opts.project ? { project: opts.project } : {}), ...(scope ? { scope } : {}) }).toString();
|
||||
const data = await response(await client().get(`/api/proof${query ? `?${query}` : ""}`));
|
||||
const data = await response(await client().get(`/api/proof${query ? `?${query}` : ""}`), projectSelected(opts.project, scope));
|
||||
console.log(opts.json ? JSON.stringify(data) : JSON.stringify(data, null, 2));
|
||||
} catch (e) { console.error(e instanceof Error ? e.message : String(e)); process.exitCode = 1; }
|
||||
});
|
||||
@@ -196,7 +209,7 @@ checkboxes do not accept an item under the selected proof policy.
|
||||
const refs = [...new Set<string>([...opts.evidence, ...(opts.comparison ? [opts.comparison] : [])])];
|
||||
const query = new URLSearchParams({ ...(opts.project ? { project: opts.project } : {}), scope });
|
||||
for (const ref of refs) query.append("evidence", ref);
|
||||
const c = client(), view = await response(await c.get(`/api/proof?${query}`));
|
||||
const c = client(), view = await response(await c.get(`/api/proof?${query}`), projectSelected(opts.project, scope));
|
||||
const items = view.items as Array<{ id: string; text: string; index: number; revision: string; judgment: { id: string } | null }> | undefined;
|
||||
const item = selector ? items?.find(i => i.id === selector || i.text === selector || String(i.index) === selector) : items?.length === 1 ? items[0] : undefined;
|
||||
if (!item) throw new Error("Select one current item with scope#item; rig proof show lists IDs, text and indices");
|
||||
@@ -209,7 +222,7 @@ checkboxes do not accept an item under the selected proof policy.
|
||||
operationId: opts.operationId, replace: opts.replace === true, ...(opts.project ? { project: opts.project } : {}),
|
||||
// Pin the project root the prepared read resolved; the daemon refuses (409 project_changed) if it moved.
|
||||
...((view.project as { root?: string } | undefined)?.root ? { projectRoot: (view.project as { root: string }).root } : {}) };
|
||||
const result = await response(await c.post("/api/proof/judge", body));
|
||||
const result = await response(await c.post("/api/proof/judge", body), projectSelected(opts.project, scope));
|
||||
console.log(opts.json ? JSON.stringify(result) : JSON.stringify(result, null, 2));
|
||||
} catch (e) { console.error(e instanceof Error ? e.message : String(e)); process.exitCode = 1; }
|
||||
});
|
||||
|
||||
@@ -603,6 +603,9 @@ 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
|
||||
@@ -638,7 +641,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")) {
|
||||
if (status.status >= 400 || (runtime !== "codex" && runtime !== "claude-code" && runtime !== "pi")) {
|
||||
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;
|
||||
|
||||
@@ -43,7 +43,7 @@ interface PsEntry {
|
||||
isArchived?: boolean;
|
||||
}
|
||||
|
||||
interface NodeEntry {
|
||||
export interface NodeEntry {
|
||||
/** Served daemon attention, never re-derived here. Missing on an older daemon. */
|
||||
needsAttention?: boolean;
|
||||
arbitratedNeedsInput?: boolean | null;
|
||||
@@ -88,6 +88,8 @@ 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;
|
||||
@@ -217,6 +219,7 @@ const ALLOWED_NODE_FIELDS = new Set([
|
||||
"nodeKind",
|
||||
"runtime",
|
||||
"communityHarness",
|
||||
"launchWrapper",
|
||||
"model",
|
||||
"modelObservation",
|
||||
"sessionStatus",
|
||||
@@ -713,6 +716,26 @@ function startedWithoutLine(files: string[]): string {
|
||||
return `missing required ${files.map((file) => file.split("/").pop()).join(", ")}`;
|
||||
}
|
||||
|
||||
/** Why a seat that needs attention does, from what the daemon recorded: a missing session, a startup error, a held
|
||||
* message, a missing required file, or the served needs-input reason. Otherwise "reason unavailable". */
|
||||
export function attentionReason(n: NodeEntry): string {
|
||||
return missingSessionGuidance(n) ?? n.latestError ?? (n.heldReason ? `held: ${n.heldReason}` : null)
|
||||
?? (n.startedWithoutRequired?.length ? startedWithoutLine(n.startedWithoutRequired) : null)
|
||||
?? questionReason(n) ?? "reason unavailable";
|
||||
}
|
||||
|
||||
/** Whether the daemon serves a live question for the seat, with its own precedence: the arbitrated needs-input answer,
|
||||
* and the hook only when there's none. A served "no" beats a stale hook. */
|
||||
export function servesQuestion(n: NodeEntry): boolean {
|
||||
return n.arbitratedNeedsInput ?? (n.agentActivity?.state === "needs_input");
|
||||
}
|
||||
|
||||
/** The served question's reason, with the daemon's precedence: the arbitrated answer, and the hook only without one. */
|
||||
export function questionReason(n: NodeEntry): string | null {
|
||||
return n.arbitratedNeedsInput === true ? n.activityState?.needsInput.reason ?? null
|
||||
: n.arbitratedNeedsInput == null && n.agentActivity?.state === "needs_input" ? n.agentActivity.reason : null;
|
||||
}
|
||||
|
||||
export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string, unknown>> {
|
||||
return nodes.map((n) => {
|
||||
const keepDiagnostics = n.needsAttention !== false;
|
||||
@@ -733,6 +756,7 @@ 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.
|
||||
@@ -930,7 +954,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, model, sessionStatus,
|
||||
podNamespace, hostSelfId, canonicalSessionName, nodeKind, runtime, communityHarness, launchWrapper, model, sessionStatus,
|
||||
intentionallyStopped, startupStatus, restoreOutcome, lifecycleState, tmuxAttachCommand,
|
||||
resumeCommand, latestError, terminalActive, hasAssignedWork,
|
||||
assignedWorkCount, pendingWorkCount, inProgressWorkCount, blockedWorkCount,
|
||||
@@ -1366,8 +1390,12 @@ async function handleNodes(
|
||||
if (useCompact) {
|
||||
console.log(padCompactNodeRow("RIG", "SESSION", "LIFECYCLE", "ACTIVITY", "WORK", "REASON", "ATTN"));
|
||||
for (const n of humanList as NodeEntry[]) {
|
||||
// With the daemon's attention flag set, a live question shows its own reason rather than a recorded error. Without
|
||||
// that flag (an older daemon), the order stays as it was, so a startup failure isn't hidden by a hook. The question's
|
||||
// reason is shown whole, as the activity reason always was: it's what a person reads to know what to answer.
|
||||
const reason = missingSessionGuidance(n) ?? (n.needsAttention !== false || hasCurrentUsageLimit(n)
|
||||
? (n.latestError ? truncate(n.latestError, 40) : n.startedWithoutRequired?.length ? truncate(startedWithoutLine(n.startedWithoutRequired), 40)
|
||||
? (n.needsAttention === true && servesQuestion(n) ? questionReason(n) ?? "reason unavailable"
|
||||
: n.latestError ? truncate(n.latestError, 40) : n.startedWithoutRequired?.length ? truncate(startedWithoutLine(n.startedWithoutRequired), 40)
|
||||
: n.agentActivity?.reason ?? "—")
|
||||
: livenessUnconfirmed(n) ? "liveness unconfirmed" : "—");
|
||||
console.log(padCompactNodeRow(
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import fs from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { Command, InvalidArgumentError } from "commander";
|
||||
import { DaemonClient, DaemonConnectionError, DaemonTimeoutError, DaemonResponseError, type DaemonResponse } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
|
||||
import { DaemonClient, DaemonConnectionError, DaemonTimeoutError, DaemonResponseError, remoteDaemonClient, type DaemonResponse } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard, resolveOriginSelfHostId } from "../daemon-lifecycle.js";
|
||||
import { readOpenRigEnv } from "../openrig-compat.js";
|
||||
import { sessionRigOf, isHumanSeatSessionRef } from "../session-name.js";
|
||||
import { sessionRigOf, isHumanSeatSessionRef, parseSessionName } from "../session-name.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
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 { loadHostRegistry, resolveHost, resolveRemoteBearer } from "../host-registry.js";
|
||||
import { bearerAuthHeaders, 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).
|
||||
@@ -36,6 +38,8 @@ 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 {
|
||||
@@ -540,7 +544,11 @@ function notThisCreate(row: Record<string, unknown>, sent: SentCreate, storedSou
|
||||
/** Both reconciliation and --verify read the daemon that holds the row, with its own bearer. */
|
||||
async function queueReadTarget(deps: QueueDeps, client: DaemonClient, hostId: string | undefined) {
|
||||
let remote = remoteHostOf(hostId);
|
||||
if (remote && await daemonSelfHostId(client) === remote) remote = undefined;
|
||||
const requestedRemote = remote;
|
||||
const selfHostId = requestedRemote ? await daemonSelfHostId(client) : undefined;
|
||||
if (remote && selfHostId === remote) remote = undefined;
|
||||
const remoteConfirmed = requestedRemote !== undefined && typeof selfHostId === "string"
|
||||
&& selfHostId.trim().length > 0 && selfHostId !== requestedRemote;
|
||||
let reader = client;
|
||||
let options: { timeoutMs: number; headers?: Record<string, string> } = { timeoutMs: CREATE_READ_BACK_TIMEOUT_MS };
|
||||
if (remote) {
|
||||
@@ -555,7 +563,46 @@ async function queueReadTarget(deps: QueueDeps, client: DaemonClient, hostId: st
|
||||
reader = deps.clientFactory(match.host.url);
|
||||
if (bearer.token) options = { ...options, headers: { Authorization: `Bearer ${bearer.token}` } };
|
||||
}
|
||||
return { reader, options };
|
||||
return { reader, options, remote, remoteConfirmed };
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
@@ -580,7 +627,7 @@ function createReceiptReader(deps: QueueDeps, client: DaemonClient, hostId: stri
|
||||
|
||||
/** A create whose answer didn't arrive, or arrived unreadable, may still have committed. Read the row back once by the ID it
|
||||
* was sent with, from the daemon that holds it. Only this create's row counts; it never sends the create again. */
|
||||
async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Promise<{ row: Record<string, unknown> | null; note: string }> {
|
||||
async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Promise<{ row: Record<string, unknown> | null; note: string; remoteConfirmed?: boolean }> {
|
||||
const remote = remoteHostOf(hostId);
|
||||
const where = remote ? ` on host '${remote}'` : "";
|
||||
const target = await queueReadTarget(deps, client, hostId);
|
||||
@@ -595,7 +642,8 @@ async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: str
|
||||
if (differs) {
|
||||
return { row: null, note: `Row ${qitemId}${where} exists but isn't this create (its ${differs} differs), so the outcome is still unknown. Don't reuse this ID for other work.` };
|
||||
}
|
||||
return { row: res.data, note: `The create committed after all: row ${qitemId}${where} (read back by its ID).` };
|
||||
return { row: res.data, note: `The create committed after all: row ${qitemId}${where} (read back by its ID).`,
|
||||
remoteConfirmed: target.remoteConfirmed };
|
||||
}
|
||||
if (res.status === 404) return { row: null, note: `No row ${qitemId}${where} yet (read back once by its ID); it may still commit.` };
|
||||
return { row: null, note: `Reading row ${qitemId}${where} back answered ${res.status}, so the outcome is still unknown.` };
|
||||
@@ -604,8 +652,10 @@ async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: str
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
const projected = settled ? buildWriteReceipt(body, settled) : null;
|
||||
if (!projected) {
|
||||
printResult(json, body, status);
|
||||
@@ -622,6 +672,7 @@ 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") {
|
||||
@@ -636,10 +687,11 @@ 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`,
|
||||
+ `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`),
|
||||
};
|
||||
}
|
||||
await printWriteResult(client, json, body, status, receipt);
|
||||
await printWriteResult(client, json, body, status, receipt && remoteHost ? { ...receipt, remoteHost } : receipt, remoteHost !== undefined);
|
||||
}
|
||||
|
||||
// OPR.0.4.3.03 — `rig queue show` body preview.
|
||||
@@ -843,6 +895,54 @@ function requireSeatSession(ignoredFlag: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The flags `rig queue update` takes that write more than a note. `--source` records a note only (#2866). */
|
||||
const UPDATE_STATE_WRITE_FLAGS = [
|
||||
["state", "--state"], ["reopen", "--reopen"], ["closureReason", "--closure-reason"], ["closureTarget", "--closure-target"],
|
||||
["closureRequiredAt", "--closure-required-at"], ["resolution", "--resolution"], ["blockedOn", "--blocked-on"],
|
||||
["wakeWatchdog", "--wake-watchdog"], ["wakeAfter", "--wake-after"], ["summary", "--summary"], ["evidenceRef", "--evidence-ref"],
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* #2866: who appends a `rig queue update` note. Inside a managed seat it is the seat (the X-OpenRig-Session header the
|
||||
* DaemonClient stamps), whatever --source says. Outside one, a routine can name itself with --source <name>@external:
|
||||
* it rides the body as the declared actor, which the daemon records as claimed:v1 because no header names a seat, so
|
||||
* the note is attributed to an external actor and never to a seat, and OPENRIG_SESSION_NAME is never forged. The
|
||||
* external path records a note only, on the local daemon (the caller refuses a --host that resolves to another host).
|
||||
* Returns the body actor to send (undefined = the header decides), or false after printing why nothing was sent.
|
||||
*/
|
||||
function resolveUpdateActor(source: string | undefined, opts: Record<string, unknown>): { actorSession?: string } | false {
|
||||
const refuse = (message: string): false => { console.error(message); process.exitCode = 1; return false; };
|
||||
// Trimmed like the DaemonClient's header (senderIdentityHeaders): a blank env is no seat, so the two never disagree.
|
||||
const seat = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")?.trim() || undefined;
|
||||
if (source !== undefined) {
|
||||
const trimmed = source.trim();
|
||||
if (parseSessionName(trimmed).kind !== "external") {
|
||||
return refuse(`--source must be <name>@external (got '${source}'): it names a routine outside any seat, never a seat. Nothing was sent.`);
|
||||
}
|
||||
const writes = UPDATE_STATE_WRITE_FLAGS.filter(([key]) => opts[key] !== undefined).map(([, flag]) => flag);
|
||||
if (opts.note === undefined || writes.length > 0) {
|
||||
return refuse(`--source records a note only: pass --note and nothing else that writes${writes.length > 0 ? ` (got ${writes.join(", ")})` : ""}. ` +
|
||||
"Changing a row needs a seat. Nothing was sent.");
|
||||
}
|
||||
if (seat) {
|
||||
process.stderr.write(`warning: --source ${trimmed} is ignored: this runs in the seat ${seat}, which is the note's actor.\n`);
|
||||
return {};
|
||||
}
|
||||
return { actorSession: trimmed };
|
||||
}
|
||||
if (seat) return {};
|
||||
const noteOnly = opts.note !== undefined && UPDATE_STATE_WRITE_FLAGS.every(([key]) => opts[key] === undefined);
|
||||
return refuse(noteOnly
|
||||
? "notes need a seat; an external routine can pass --source <name>@external. OPENRIG_SESSION_NAME is not set, so " +
|
||||
"there's no seat to act as: run this from a managed seat, or add --source <name>@external to record the note as " +
|
||||
"that external routine (claimed, never a seat). --actor is ignored: the actor is the seat's own identity."
|
||||
: "OPENRIG_SESSION_NAME is not set, so there's no seat to act as. Run this from a managed seat, or set " +
|
||||
"OPENRIG_SESSION_NAME to the seat's session name (for example dev-impl@my-rig). --actor is ignored: the actor is " +
|
||||
"the seat's own identity. Changing a row needs a seat, and notes need a seat; an external routine can pass " +
|
||||
"--source <name>@external for a note only. A person answering an ask addressed to them (a host pairing approval, " +
|
||||
"say) does it in the TUI (rig tui, then attention) or in Steering, not with this command.");
|
||||
}
|
||||
|
||||
function extractRigName(sessionName: string): string | undefined {
|
||||
// OPR.0.4.6.MH1 FR-8: the shared parse contract (greedy first-@ rig).
|
||||
return sessionRigOf(sessionName);
|
||||
@@ -922,6 +1022,9 @@ 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 ?? {
|
||||
@@ -1132,8 +1235,51 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
}, crossHostWriteOptions(hostResolved.hostId)).catch((error: unknown) => (error instanceof Error ? error : new Error(String(error))));
|
||||
const sent: SentCreate = { destinationSession: hostResolved.destination, claimedSource: managedSource === undefined ? source : undefined, body: resolvedBody,
|
||||
remoteHost: remoteHostOf(hostResolved.hostId) };
|
||||
const emitScopeAdvisories = (created: Record<string, unknown>) => {
|
||||
const advisories = Array.isArray(created?.advisories) ? created.advisories : [];
|
||||
for (const value of advisories) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
||||
const advisory = value as { code?: unknown; message?: unknown };
|
||||
if (typeof advisory.message !== "string") continue;
|
||||
switch (advisory.code) {
|
||||
case "scope_check_skipped":
|
||||
console.error(`Note: ${advisory.message}`);
|
||||
break;
|
||||
case "unresolved_scope":
|
||||
case "ambiguous_scope":
|
||||
case "empty_scope":
|
||||
case "conflicting_scope":
|
||||
case "scope_check_unavailable":
|
||||
console.error(`Warning: ${advisory.message}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
const emitRecoveredScopeAdvisory = (recoveredRemote: boolean) => {
|
||||
const recoveredTags: string[] = [];
|
||||
for (const raw of tags ?? []) {
|
||||
if (recoveredTags.length >= 8) break;
|
||||
if (!raw.startsWith("mission:") && !raw.startsWith("slice:")) continue;
|
||||
const prefix = raw.startsWith("mission:") ? "mission:" : "slice:";
|
||||
const body = raw.slice(prefix.length).replace(/[\x00-\x1f\x7f]/g, "").trim().slice(0, 120);
|
||||
const sanitized = body ? `${prefix}${body}` : prefix;
|
||||
if (!recoveredTags.includes(sanitized)) recoveredTags.push(sanitized);
|
||||
}
|
||||
if (recoveredTags.length === 0) return;
|
||||
// Only a confirmed different host ID justifies "forwarded";
|
||||
// all other recovered outcomes remain neutral.
|
||||
if (recoveredRemote) {
|
||||
console.error(`Note: queue create scope check skipped for cross-host write (${recoveredTags.join(", ")}); row was forwarded.`);
|
||||
} else {
|
||||
console.error(`Warning: queue create scope check outcome unavailable after response recovery (${recoveredTags.join(", ")}): original scope check result was lost with the create response; row was created.`);
|
||||
}
|
||||
};
|
||||
// Every create that's answered with a row ends here: its warning, --verify's delivery, and the output.
|
||||
const finish = async (created: Record<string, unknown>, status: number) => {
|
||||
const finish = async (created: Record<string, unknown>, status: number, recovered?: "neutral" | "remote") => {
|
||||
if (status < 400) {
|
||||
if (recovered) emitRecoveredScopeAdvisory(recovered === "remote");
|
||||
else emitScopeAdvisories(created);
|
||||
}
|
||||
const createWarning = created?.createWarning as { code?: unknown; message?: unknown } | undefined;
|
||||
if (status < 400 && createWarning?.code === "qitem_body_not_saved" && typeof createWarning.message === "string") {
|
||||
console.error(`Warning: ${createWarning.message}`);
|
||||
@@ -1163,7 +1309,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
const late = await readBackCreate(deps, client, hostResolved.hostId, qitemId, sent);
|
||||
console.error(late.note);
|
||||
if (late.row) {
|
||||
await finish(late.row, 200);
|
||||
await finish(late.row, 200, late.remoteConfirmed ? "remote" : "neutral");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1180,7 +1326,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
const late = await readBackCreate(deps, client, hostResolved.hostId, qitemId, sent);
|
||||
console.error(late.note);
|
||||
if (late.row) {
|
||||
await finish(late.row, 200);
|
||||
await finish(late.row, 200, late.remoteConfirmed ? "remote" : "neutral");
|
||||
return;
|
||||
}
|
||||
// Preserve that failure and expose the same recovery ID.
|
||||
@@ -1206,6 +1352,7 @@ 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" }));
|
||||
@@ -1246,12 +1393,15 @@ 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)")
|
||||
.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("--note <text>", "Transition note for the audit log")
|
||||
.option("--source <name@external>", "Outside a managed seat: the external routine that appends the --note, recorded as an external actor (claimed:v1), never a seat; note only, on this host's daemon (a --host that resolves to another host is refused). A managed seat stays the actor. Like any note, it resets the item's stall and notification timing (and its recovery and operator episodes) and advances a claimed item's last activity, but never resets how long an unclaimed item has waited")
|
||||
.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: {
|
||||
actor?: string;
|
||||
source?: string;
|
||||
state?: string;
|
||||
reopen?: boolean;
|
||||
closureReason?: string;
|
||||
@@ -1264,6 +1414,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
summary?: string;
|
||||
evidenceRef?: string;
|
||||
note?: string;
|
||||
host?: string;
|
||||
json?: boolean;
|
||||
full?: boolean;
|
||||
}) => {
|
||||
@@ -1285,10 +1436,22 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
// P21 I3 reconcile: the actor is DERIVED from the seat env (X-OpenRig-Session, stamped by
|
||||
// DaemonClient) — --actor is deprecated + ignored, no body actorSession. Verify the env (the
|
||||
// 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;
|
||||
// #2866: outside a seat, --source <name>@external names the routine appending a note (the declared body actor).
|
||||
const actor = resolveUpdateActor(opts.source, opts);
|
||||
if (!actor) return;
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
|
||||
await withClient(deps, async (local) => {
|
||||
const target = await queueHostTarget(deps, local, opts.host, true);
|
||||
if ("error" in target) return emitHostResolutionError(target, opts.json ?? false);
|
||||
if (actor.actorSession !== undefined && target.remote) {
|
||||
// #2866: the other host would record the routine bare, indistinguishable from a routine of its own (a
|
||||
// forwarded create stamps the origin; this path doesn't). A --host that resolves locally is fine.
|
||||
console.error(`--source with --host ${target.remote} is not supported: that host would record ${actor.actorSession} ` +
|
||||
"without the host it ran on. Run the routine on the host that holds the row. Nothing was sent.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const res = await target.client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
|
||||
state: opts.state,
|
||||
reopen: opts.reopen,
|
||||
closureReason: opts.closureReason,
|
||||
@@ -1301,9 +1464,10 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
summary: opts.summary,
|
||||
evidenceRef: opts.evidenceRef,
|
||||
transitionNote: opts.note,
|
||||
});
|
||||
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, { verb: "update" }));
|
||||
});
|
||||
...actor,
|
||||
}, 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));
|
||||
});
|
||||
|
||||
// OPR.0.4.4.19 FR-6 — the first-class park affordance (C5 leg 1). One verb,
|
||||
@@ -1324,6 +1488,7 @@ 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", `
|
||||
@@ -1345,6 +1510,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
continuation?: string;
|
||||
wakeWatchdog?: string;
|
||||
wakeAfter?: number;
|
||||
host?: string;
|
||||
json?: boolean;
|
||||
full?: boolean;
|
||||
}) => {
|
||||
@@ -1352,8 +1518,10 @@ 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 (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
|
||||
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`, {
|
||||
state: "blocked",
|
||||
blockedOn: opts.on,
|
||||
summary: opts.summary,
|
||||
@@ -1361,9 +1529,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}`),
|
||||
});
|
||||
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, { verb: "block" }));
|
||||
});
|
||||
}, 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));
|
||||
});
|
||||
|
||||
// OPR.0.4.4.19 FR-7 — the resolve verb's CLI wrapper: a THIN client of the
|
||||
@@ -1658,10 +1826,14 @@ 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")
|
||||
.action(async (qitemId: string, opts: { full?: boolean; json?: boolean }) => {
|
||||
.option("--host <id>", QUEUE_HOST_ROW_OPTION_HELP)
|
||||
.action(async (qitemId: string, opts: { full?: boolean; json?: boolean; host?: string }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}`);
|
||||
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);
|
||||
const json = opts.json ?? false;
|
||||
const item = res.data;
|
||||
// --full is a pure passthrough of today's COMPLETE item shape (the
|
||||
@@ -1669,7 +1841,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);
|
||||
await printQueueItemResult(client, qitemId, json, item, res.status, null, target.remote);
|
||||
return;
|
||||
}
|
||||
// The closing transition is for --full; the bounded preview prints the record as it did before.
|
||||
@@ -1677,7 +1849,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)} --full --json`;
|
||||
const fullCommand = `rig queue show ${shellQuote(qitemId)}${hostFlag(target)} --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))] : []),
|
||||
@@ -1696,6 +1868,47 @@ 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`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1704,20 +1917,24 @@ 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)`)
|
||||
.action(async (qitemId: string, opts: { json?: boolean; full?: boolean }) => {
|
||||
.option("--host <id>", QUEUE_HOST_ROW_OPTION_HELP)
|
||||
.action(async (qitemId: string, opts: { json?: boolean; full?: boolean; host?: string }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`);
|
||||
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);
|
||||
// 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)} --full${opts.json ? " --json" : ""}`));
|
||||
if (earlier > 0) console.error(transitionsNotice(earlier, res.data.length, `rig queue transitions ${shellQuote(qitemId)}${hostFlag(target)} --full${opts.json ? " --json" : ""}`));
|
||||
return;
|
||||
}
|
||||
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status, null, target.remote);
|
||||
}, remoteHostOf(opts.host) !== undefined, remoteHostOf(opts.host));
|
||||
});
|
||||
|
||||
cmd
|
||||
@@ -1735,6 +1952,7 @@ 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.
|
||||
@@ -1777,45 +1995,62 @@ Examples:
|
||||
targetRepo?: string;
|
||||
limit: string;
|
||||
json?: boolean;
|
||||
host?: string;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
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);
|
||||
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);
|
||||
|
||||
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.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;
|
||||
warnIfUnknownStates(opts.state);
|
||||
if (opts.state) params.set("state", opts.state);
|
||||
if (opts.targetRepo) params.set("targetRepo", opts.targetRepo);
|
||||
if (opts.limit) params.set("limit", String(opts.limit));
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/queue/list?${params.toString()}`);
|
||||
const useJson = opts.json || opts.o === "json";
|
||||
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);
|
||||
printResult(useJson, res.data, res.status);
|
||||
});
|
||||
}, remote !== undefined, remote);
|
||||
});
|
||||
|
||||
cmd
|
||||
@@ -1873,6 +2108,100 @@ Examples:
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("erase")
|
||||
.description("Erase queue rows in one exact scope: an account's rows, a list of ids, or the closed-work retention windows; a dry run unless --apply")
|
||||
.option("--members <file>", "Account deletion: the account's recorded session names (exact, departed seats included), one per line or a JSON array")
|
||||
.option("--rig <id>", "With --members: the account rig's id. Also erases the rig's own records that outlive it (events, snapshots, node and seat records); refused unless every member is a session of it")
|
||||
.option("--ids <file>", "These qitem ids, one per line or a JSON array; a report's qitems entries, or an erasure ledger's JSON lines, keep each one's action")
|
||||
.option("--action <action>", "With --ids: delete (default) removes each row and keeps only its id; scrub removes its content and keeps the row", enumArg(["delete", "scrub"]))
|
||||
.option("--routine", "Run this daemon's closed-work windows now (retention.closed_content_days, retention.closed_metadata_days)")
|
||||
.option("--apply", "Erase. Without it the command reports what would be erased and changes nothing")
|
||||
.option("--actor <session>", "Who is erasing, when OPENRIG_SESSION_NAME isn't set (recorded as a claimed identity)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", [
|
||||
"",
|
||||
"Name exactly one scope. What each one erases:",
|
||||
" --members every row whose source and destination are both members, in any state, deleted with its transitions,",
|
||||
" outbox and inbox copies, stream actions, events and audit rows; then member-to-member outbox and inbox",
|
||||
" entries no row owns, and the members' stream items. A row with one member endpoint is reported under",
|
||||
" mixed and left untouched. With --rig, also the rig's own events, snapshots and node and seat records.",
|
||||
" --ids the listed rows, deleted or scrubbed (a report's qitems entries keep their action); a deleted id",
|
||||
" that's already gone keeps (or regains) its id marker.",
|
||||
" --routine closed rows (done, canceled, denied, handed-off) past the windows, aged from when they closed.",
|
||||
"A deleted row keeps only its id, and a create that reuses that id is refused (qitem_erased). A row a workflow still",
|
||||
"references is reported under gaps and left. Output is JSON: clean (true only when nothing was left), complete,",
|
||||
"qitems, mixed, gaps, alreadyAbsent, linked, unlinked and rig. Refusals: erase_scope_required, erase_scope_conflict,",
|
||||
"erase_members_invalid, erase_rig_invalid, erase_rig_mismatch, erase_ids_invalid, erase_action_invalid,",
|
||||
"erase_routine_off. A run that stops on an error returns what it committed, with error.stage, and exits 2.",
|
||||
"Reference: docs/reference/queue-erasure.md",
|
||||
].join("\n"))
|
||||
.action(async (opts: { members?: string; rig?: string; ids?: string; action?: string; routine?: boolean; apply?: boolean; actor?: string; json?: boolean }) => {
|
||||
const list = (flag: string, path: string): unknown[] | undefined => {
|
||||
let text: string;
|
||||
try {
|
||||
text = fs.readFileSync(path, "utf8");
|
||||
} catch (err) {
|
||||
console.error(`--${flag} ${path}: can't be read (${err instanceof Error ? err.message : String(err)}). Nothing was erased.`);
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
if (text.trimStart().startsWith("[")) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch { /* reported below */ }
|
||||
console.error(`--${flag} ${path}: not a JSON array. Nothing was erased.`);
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
// One entry per line: a plain id or session, or (an erasure ledger's line) a JSON object.
|
||||
const entries: unknown[] = [];
|
||||
for (const line of text.split(/\r?\n/).map((entry) => entry.trim()).filter((entry) => entry.length > 0 && !entry.startsWith("#"))) {
|
||||
if (!line.startsWith("{")) {
|
||||
entries.push(line);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
entries.push(JSON.parse(line));
|
||||
} catch {
|
||||
console.error(`--${flag} ${path}: a line isn't valid JSON (${line.slice(0, 60)}). Nothing was erased.`);
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
};
|
||||
const body: Record<string, unknown> = { apply: opts.apply === true };
|
||||
if (opts.members !== undefined) {
|
||||
const members = list("members", opts.members);
|
||||
if (!members) return;
|
||||
body.members = members;
|
||||
}
|
||||
// Sent as given: the daemon refuses a rig without members (erase_rig_invalid), never drops it.
|
||||
if (opts.rig !== undefined) body.rig = opts.rig;
|
||||
if (opts.ids !== undefined) {
|
||||
const ids = list("ids", opts.ids);
|
||||
if (!ids) return;
|
||||
body.ids = ids;
|
||||
if (opts.action) body.action = opts.action;
|
||||
}
|
||||
if (opts.routine) body.routine = true;
|
||||
if (!readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")) {
|
||||
if (!opts.actor) {
|
||||
console.error("OPENRIG_SESSION_NAME is not set and --actor wasn't given, so there's no one to record as erasing. Nothing was erased.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
body.actorSession = opts.actor;
|
||||
}
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/queue/erase", body);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Inbox subcommands ----
|
||||
|
||||
cmd
|
||||
|
||||
@@ -60,7 +60,19 @@ export function enrichRosterMember(member: RosterMember, inventory: RosterInvent
|
||||
const node = identities.size === 1 ? matches[0] : undefined;
|
||||
const sessionStatus = node ? text(node.sessionStatus) : null;
|
||||
const current = sessionStatus === "running";
|
||||
const activity = current && object(node?.agentActivity) ? node.agentActivity : null;
|
||||
const activities = matches.map(n => {
|
||||
const activity = current && object(n.agentActivity) ? n.agentActivity : null;
|
||||
return { state: text(activity?.state) ?? "unknown", reason: text(activity?.reason), sampledAt: text(activity?.sampledAt) };
|
||||
});
|
||||
const agreed = node && new Set(activities.map(a => JSON.stringify([a.state, a.reason]))).size === 1;
|
||||
const times = activities.map(a => a.sampledAt);
|
||||
const sameTime = new Set(times).size === 1;
|
||||
const comparable = times.every((t): t is string => t !== null && Number.isFinite(Date.parse(t)));
|
||||
// A later timestamp is meaningful only when every route supplied a comparable time.
|
||||
const sampledAt = sameTime ? times[0] ?? null : comparable
|
||||
? [...times].sort((a, b) => Date.parse(b!) - Date.parse(a!) || b!.localeCompare(a!))[0] ?? null : null;
|
||||
const activity = agreed ? { ...activities[0]!, sampledAt }
|
||||
: { state: "unknown", reason: matches.length > 1 ? "Conflicting activity observations" : null, sampledAt: null };
|
||||
const observations = matches.map(n => modelObservation(n.modelObservation, current));
|
||||
const observation = node && new Set(observations.map(o => JSON.stringify(o))).size === 1 ? observations[0]!
|
||||
: unknownModelObservation(matches.length ? "conflicting-observations" : "host-or-seat-unavailable");
|
||||
@@ -75,7 +87,7 @@ export function enrichRosterMember(member: RosterMember, inventory: RosterInvent
|
||||
observedModel: observation.model,
|
||||
modelObservation: observation,
|
||||
sessionStatus,
|
||||
agentActivity: { state: text(activity?.state) ?? "unknown", reason: text(activity?.reason), sampledAt: text(activity?.sampledAt) },
|
||||
agentActivity: activity,
|
||||
// A hosted member's last call to this host's MCP server, for a roster row on this host only: another host's member,
|
||||
// or one whose host can't be told, is unknown (null), never this host's record for a same-named seat. Its last ring
|
||||
// is in rig_team.
|
||||
|
||||
@@ -2186,6 +2186,17 @@ function sharedSliceNameFinding(missionsRoot: string, missionName: string, slice
|
||||
};
|
||||
}
|
||||
|
||||
/** Show the inherited workspace route because Commander lists a parent option only in the parent's help. */
|
||||
export function scopeWorkspaceHelp(tier: "slice" | "mission", verb?: string): string {
|
||||
return `
|
||||
Workspace: the parent option --workspace selects the missions root this command reads.
|
||||
rig scope --workspace <project root> ${tier} ${verb ?? "<verb>"} ...
|
||||
A project root uses its project.yaml missions.root, else its missions/ folder; a
|
||||
folder named missions is used as is. Use it for a catalog project, for example
|
||||
$(rig config get workspace.projects_root)/<project>. Without --workspace,
|
||||
OPENRIG_WORK_ROOT applies, then the configured workspace.slices_root.`;
|
||||
}
|
||||
|
||||
export function scopeCommand(): Command {
|
||||
const cmd = new Command("scope")
|
||||
.description("Scope tree primitive: missions, slices, sub-slices (per conventions/scope-and-versioning)")
|
||||
@@ -2216,6 +2227,10 @@ export function scopeCommand(): Command {
|
||||
mission.addCommand(buildMissionVerifiedCommand());
|
||||
mission.addCommand(buildApproveCommand("mission"));
|
||||
cmd.addCommand(mission);
|
||||
for (const [tier, group] of [["slice", slice], ["mission", mission]] as const) {
|
||||
group.addHelpText("after", scopeWorkspaceHelp(tier));
|
||||
for (const verb of group.commands) verb.addHelpText("after", scopeWorkspaceHelp(tier, verb.name()));
|
||||
}
|
||||
cmd.addCommand(buildResolveNotesCommand());
|
||||
cmd.addCommand(buildAuditCommand());
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ async function defaultReadStdin(): Promise<string> {
|
||||
}
|
||||
|
||||
interface SeatStatusResponse {
|
||||
permission_stop?: { state: "unavailable"; command: "unavailable"; origin: "unknown" }
|
||||
| { state: "request_observed" | "completed"; command: string; origin: "harness";
|
||||
source: "PermissionRequest"; observed_at: string; actor: "main" | "subagent"; pending: "unknown" | false;
|
||||
suggested_rules?: { state: "missing" | "present" | "unavailable"; entries: unknown[]; unsupported: boolean; size_cut: boolean } };
|
||||
seat_ref: string;
|
||||
rig_id: string;
|
||||
rig_name: string;
|
||||
@@ -136,6 +140,7 @@ interface SeatHandoverMutationResult {
|
||||
ok: true;
|
||||
dryRun: false;
|
||||
mutated: true;
|
||||
/** Legacy compatibility field, always false. Read currentStatus.continuityOutcome and sourceOutcome instead. */
|
||||
continuityTransferred: false;
|
||||
seat: SeatHandoverPlan["seat"];
|
||||
source: {
|
||||
@@ -164,8 +169,9 @@ interface SeatHandoverMutationResult {
|
||||
/** OPR.0.5.5.5 — per-source execution record (wire mirror of the daemon shape). */
|
||||
sourceOutcome?:
|
||||
| { mode: "fork"; forkedFrom: string; forkId?: "captured" | "pending"; limit?: string }
|
||||
| { mode: "rebuild"; primedArtifacts: Array<{ address: string; label: string }>; gaps: string[]; emptyChainReason?: string; permissions?: { state: string; detail: string } };
|
||||
/** The restore packet was pasted and Enter pressed, but its submission couldn't be confirmed. */
|
||||
| { mode: "rebuild"; primedArtifacts: Array<{ address: string; label: string }>; gaps: string[]; emptyChainReason?: string; permissions?: { state: string; detail: string };
|
||||
model?: { requested: string | null; predecessorCompared: false } };
|
||||
/** The restore or rebuild priming 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[];
|
||||
@@ -208,6 +214,13 @@ const LEVEL_WORDS: Record<string, string> = {
|
||||
"sends-only": "Deliberate sends from people and agents type; OpenRig's own notices are held.",
|
||||
};
|
||||
|
||||
// JSON escapes C0; also escape C1, format/separator and default-ignorable characters.
|
||||
// Escaping the serialized representation leaves JSON.parse's rule strings unchanged.
|
||||
function terminalJson(value: unknown, space?: number): string {
|
||||
return JSON.stringify(value, null, space).replace(/[\u007f-\u009f\p{Cf}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/gu,
|
||||
character => character.split("").map(unit => `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`).join(""));
|
||||
}
|
||||
|
||||
/** Show configured seat state, including selected account references, without claiming the running login is verified. */
|
||||
function printHuman(status: SeatStatusResponse): void {
|
||||
console.log(`Seat ${status.seat_ref}`);
|
||||
@@ -243,6 +256,24 @@ function printHuman(status: SeatStatusResponse): void {
|
||||
else console.log("Hold pauses all automatic input, including writing lifecycle. Changing mode does not replay held messages.");
|
||||
}
|
||||
console.log(`Session: ${display(status.session_status, "unknown")}`);
|
||||
const stop = status.permission_stop;
|
||||
if (stop && stop.state !== "unavailable") {
|
||||
console.log(`Latest permission observation at ${stop.observed_at}: ${stop.command}; harness permission request (${stop.source}, ${stop.actor})`);
|
||||
console.log(stop.state === "completed" ? " Displayed actor's tool batch completed; this observation is no longer pending."
|
||||
: " Displayed actor's pending status: unknown (an observation does not establish a waiting prompt).");
|
||||
console.log(" Other actors' pending state: unknown (only the latest observation is shown).");
|
||||
console.log(" Claude's suggested rules: read-only evidence; not applied or approved, effectiveness unknown.");
|
||||
console.log(" These are neither the exact command nor a complete list of dialog options.");
|
||||
const rules = stop.suggested_rules;
|
||||
if (!rules || rules.state === "unavailable") console.log(" Suggested-rule evidence: unavailable.");
|
||||
else if (rules.state === "missing") console.log(" Suggested-rule field: missing from the observed hook.");
|
||||
else {
|
||||
if (!rules.entries.length) console.log(" Supported suggested-rule entries: empty.");
|
||||
for (const entry of rules.entries) console.log(` ${terminalJson(entry)}`);
|
||||
}
|
||||
if (rules?.unsupported) console.log(" Unsupported or malformed suggested-rule evidence was withheld.");
|
||||
if (rules?.size_cut) console.log(" Suggested-rule evidence is incomplete because of size bounds.");
|
||||
}
|
||||
if (status.compaction) console.log(`Managed compaction (${status.compaction.stage}): ${status.compaction.detail}`);
|
||||
const reason = sessionReason(status.session_reason);
|
||||
if (reason) console.log(`Reason: ${reason}`);
|
||||
@@ -355,9 +386,14 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
|
||||
if (result.sourceOutcome.forkId === "pending") console.log(`Fork id: PENDING. ${result.sourceOutcome.limit ?? ""}`.trim());
|
||||
} else if (result.sourceOutcome?.mode === "rebuild") {
|
||||
const outcome = result.sourceOutcome;
|
||||
console.log("Rebuild starts a new conversation; durable artifacts carry its context, not a native resume.");
|
||||
if (outcome.permissions) console.log(`Codex rebuild permissions: ${outcome.permissions.state}. ${outcome.permissions.detail}`);
|
||||
if (outcome.model) {
|
||||
console.log(`Codex rebuild model: ${outcome.model.requested ? `requested ${outcome.model.requested}` : "runtime configuration/default"}. The predecessor's in-pane model was not compared or carried.`);
|
||||
}
|
||||
if (outcome.primedArtifacts.length > 0) {
|
||||
console.log(`Rebuild primed from ${outcome.primedArtifacts.length} durable artifact${outcome.primedArtifacts.length === 1 ? "" : "s"}:`);
|
||||
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"}:`);
|
||||
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}`);
|
||||
@@ -376,8 +412,17 @@ 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.
|
||||
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.");
|
||||
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("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.");
|
||||
@@ -550,7 +595,7 @@ Examples:
|
||||
const res = await client.get<SeatStatusResponse | SeatStatusError>(`/api/seat/status/${encodeURIComponent(seat)}`);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
console.log(terminalJson(res.data, 2));
|
||||
if (res.status >= 400) process.exitCode = res.status >= 500 ? 2 : 1;
|
||||
return;
|
||||
}
|
||||
@@ -996,13 +1041,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 or claude-code")
|
||||
.option("--runtime <runtime>", "codex, claude-code or pi")
|
||||
.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>", "Isolated target CODEX_HOME or CLAUDE_CONFIG_DIR; also permits a fresh successor on the same runtime")
|
||||
.option("--account-home <path>", "Target Codex/Claude home or Pi account directory containing auth.json; also permits a same-runtime successor")
|
||||
.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")
|
||||
@@ -1012,7 +1057,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 or Claude Code seat to the other runtime, successor first")
|
||||
.description("Move a live Codex, Claude Code or Pi seat to another 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
|
||||
@@ -1026,7 +1071,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 and Claude Code, single-pane seats only. --dry-run lists the details.
|
||||
Codex, Claude Code and Pi, 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.
|
||||
@@ -1398,10 +1443,16 @@ Examples:
|
||||
.option("--json", "JSON output for agents")
|
||||
.description("Stop exactly one LIVE managed seat (kills only that seat's tmux session; audited)")
|
||||
.addHelpText("after", `
|
||||
Siblings are untouched. A dead seat is refused (use rig seat clean); an adopted
|
||||
session is refused (use rig unclaim). Stopping ends that agent's session and any
|
||||
work in progress: check it first (rig capture <seat>) and stop only a seat you
|
||||
own or were asked to stop. rig seat launch <seat> --fresh --reason <text> starts
|
||||
Siblings are untouched. A dead seat is refused (use rig seat clean). An adopted
|
||||
session is refused: to end only it, run rig unclaim <session> (it releases the
|
||||
session and leaves its process running), then end that process in its own
|
||||
terminal. rig down <rig> ends every seat in its rig, so it is the route only
|
||||
for a one-seat rig.
|
||||
A seat handed over (fresh, fork or rebuild) before OpenRig 0.7.9 still reads
|
||||
adopted and is refused too, until its next launched handover or managed launch;
|
||||
the refusal says so. Stopping ends that agent's session and any work in progress:
|
||||
check it first (rig capture <seat>) and stop only a seat you own or were asked to
|
||||
stop. rig seat launch <seat> --fresh --reason <text> starts
|
||||
a fresh occupant there; it does not resume the stopped conversation.
|
||||
A community harness stop closes its managed pane; its native processes or compute
|
||||
may outlive it. Inspect their own lifecycle receipts.
|
||||
|
||||
@@ -61,6 +61,8 @@ export interface EnvelopeMeta {
|
||||
/** Twin of pane-envelope's field: the row a person's (@external) message delivers, named in the
|
||||
* reply hint with --reply-to so the answer joins its Slack thread. ABSENT ⇒ the plain hint. */
|
||||
replyToQitem?: string;
|
||||
/** Twin of pane-envelope's field (#2895 (a)): false ⇒ the reply line names no `rig send`. ABSENT or true ⇒ today's hint. */
|
||||
senderReachable?: boolean;
|
||||
}
|
||||
|
||||
/** The To-line projection + anti-storm scale (header-alone distinguishability). */
|
||||
@@ -108,7 +110,9 @@ export function wrapSendBody(
|
||||
? `↩ Reply if needed: rig queue create --destination ${senderLabel}${meta?.replyToQitem ? ` --human-intent update --reply-to ${meta.replyToQitem}` : ""} --summary "<one line>" --body "..." --verify`
|
||||
: isHumanSeatSessionRef(personAddress)
|
||||
? `↩ Reply if needed: rig queue create --destination ${senderLabel} --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`
|
||||
: `↩ Reply: rig send ${senderLabel} "..."`;
|
||||
: meta?.senderReachable === false
|
||||
? `↩ No reply by rig send: ${senderLabel} has no session.`
|
||||
: `↩ Reply: rig send ${senderLabel} "..."`;
|
||||
return [...header, "---", body, "---", reply].join("\n");
|
||||
}
|
||||
|
||||
@@ -246,7 +250,7 @@ export function sendCommand(depsOverride?: SendDeps): Command {
|
||||
.option("--force", "Back-compat no-op: a mid-task/busy pane already sends-with-advisory by default; --force never bypasses the interactive-prompt/permission guard")
|
||||
.option("--wait-for-idle <seconds>", "Wait until the target is explicitly idle before sending. If it's still busy, or its activity is unknown, when the wait ends, nothing is sent (exit 1) and nothing is kept to send later; put separate work in a queue row (rig queue create)")
|
||||
.option("--raw", "Send exact text/keystrokes without the From/To messaging envelope (still guarded against interactive prompts)")
|
||||
.option("--dangerously-interact", "DANGEROUS: type an answer as keystrokes into an interactive prompt (implies --raw; requires --reason). Letters can select menu shortcuts; CR/ESC act as keys. Enter is added only for the complete visibly staged answer.")
|
||||
.option("--dangerously-interact", "DANGEROUS: type an answer as keystrokes into an interactive prompt (implies --raw; requires --reason). Letters can select menu shortcuts; CR/ESC act as keys. Enter is added for the complete visibly staged answer, or away from a recognized prompt when the input box can't be found or read.")
|
||||
.option("--type-without-checking", "With --dangerously-interact: type without reading or clearing existing input; may join or submit that text")
|
||||
.option("--reason <text>", "Why the prompt is being driven (required with --dangerously-interact; recorded in the audit log)")
|
||||
.option("--urgent", "Mark this message urgent: it types at seats whose interruption level holds other messages. Anyone may mark one; the recipient sees who did")
|
||||
@@ -322,8 +326,10 @@ would run the envelope lines; the output says so. Use
|
||||
--dangerously-interact --reason "<why>" to DELIBERATELY drive a prompt (select an
|
||||
option or approve a permission) — the only override of the prompt guard; it
|
||||
implies --raw and is audit-logged. The answer is typed as keystrokes: letters may
|
||||
be menu shortcuts, and CR/ESC act as keys. An Enter is added only when the complete
|
||||
answer is visibly staged in a recognized text input. Combining --verify with
|
||||
be menu shortcuts, and CR/ESC act as keys. An Enter is added when the complete
|
||||
answer is visibly staged in a recognized text input, and, away from a recognized
|
||||
prompt or menu, when the input box can't be found or read (the result says so).
|
||||
Combining --verify with
|
||||
--dangerously-interact never adds an automatic submit retry, even when the daemon
|
||||
did not detect a prompt or did not report its disposition. Unknown consumption
|
||||
is not a send failure. On a managed seat with the delivery guard attached, a
|
||||
@@ -332,7 +338,10 @@ pasted text stops the send and is shown only to you, separately from recognized
|
||||
dim suggestions. Collapsed paste contents and unrecognized inputs are not known.
|
||||
Inspect the native terminal, deliberately clear its prompt with native/tmux
|
||||
controls, then retry. Manual clearing runs outside OpenRig's lease and observation
|
||||
checks and is not recorded. --type-without-checking explicitly skips this read;
|
||||
checks and is not recorded. An input that can't be read doesn't stop the send:
|
||||
the answer is typed, nothing is cleared, the result says the input couldn't be
|
||||
read, and the answer may join or submit what is there.
|
||||
--type-without-checking explicitly skips this read;
|
||||
it does not clear anything and may join or submit existing text. Human typing
|
||||
can still race a capture and paste; the check is not atomic.
|
||||
|
||||
@@ -391,10 +400,10 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
|
||||
}
|
||||
const isFanOut = fanModes.length === 1;
|
||||
|
||||
// Slice-03 Atom 6b: --context is single-seat LOCAL in v1 (fan-out and
|
||||
// cross-host --context are follow-ons). Reject loudly rather than silently
|
||||
// dropping the requested context.
|
||||
if (opts.context && (isFanOut || opts.host)) {
|
||||
// --context is single-seat LOCAL in v1. Reject fan-out here; the
|
||||
// cross-host check follows target resolution so a self-qualified target
|
||||
// can override a persisted remote selection.
|
||||
if (opts.context && isFanOut) {
|
||||
console.error("--context is supported on a single-seat LOCAL send in v1 (not with --to/--pod/--rig or --host).");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
@@ -467,14 +476,10 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
|
||||
session = targetResolution.target;
|
||||
const crossHostHint = targetResolution.hint;
|
||||
if (targetResolution.warning) console.error(targetResolution.warning);
|
||||
opts.host = explicitHost ?? targetResolution.sugarHost ?? opts.host;
|
||||
opts.host = targetResolution.localHost ? undefined : explicitHost ?? targetResolution.sugarHost ?? opts.host;
|
||||
|
||||
// Atom 6b QA fix (root cause): re-reject --context on the cross-host path
|
||||
// AFTER the agent@rig@host sugar host folds into opts.host. The early guard
|
||||
// runs before resolveCrossHostTarget, so it cannot see the sugar host — the
|
||||
// hole that let a sugar-form --context reach the remote argv (shipping a
|
||||
// literal null with no message, or silently dropping the context with one).
|
||||
// --context is single-seat LOCAL in v1; it is never handed to a remote send.
|
||||
// Reject --context after resolving the target, before a remote send can
|
||||
// silently drop the context or forward an empty message.
|
||||
if (opts.context && opts.host) {
|
||||
console.error("--context is supported on a LOCAL send in v1 (not with --host or an agent@rig@host cross-host target).");
|
||||
process.exitCode = 1;
|
||||
@@ -1138,9 +1143,11 @@ async function runFanOutSend(params: {
|
||||
}
|
||||
|
||||
const data = res.data;
|
||||
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; outcome?: string; envelopeOmitted?: boolean; managedCompaction?: { notice?: unknown }; existingInput?: unknown }>) ?? [];
|
||||
// Context beside a recipient's own result: it was in a managed compaction or restore when the send ran.
|
||||
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; outcome?: string; envelopeOmitted?: boolean; managedCompaction?: { notice?: unknown }; existingInput?: unknown; warning?: string }>) ?? [];
|
||||
// Context beside a recipient's own result: its own advisory (such as unreadable input), and whether
|
||||
// it was in a managed compaction or restore when the send ran.
|
||||
const printNotice = (r: (typeof results)[number]) => {
|
||||
if (r.warning) console.log(`${r.sessionName}: Advisory: ${r.warning}`);
|
||||
if (typeof r.managedCompaction?.notice === "string") console.log(`Notice: ${r.managedCompaction.notice}`);
|
||||
};
|
||||
// Round-2 F1 (desk-binding): ONE verdict line per recipient. A
|
||||
|
||||
@@ -56,6 +56,9 @@ interface SeatInspection {
|
||||
bootstrapLine?: { state: "unasked" | "unknown" } | { state: "yes" | "no"; by: "saved" | "environment" } | null;
|
||||
/** OPR.0.7.0.9: what the seat's launch passes of its own additions; absent from an older daemon. */
|
||||
launchAdditions?: SeatLaunchAdditionsView | null;
|
||||
/** #2895 (b): set for a seat on a runtime without seat skills; absent from an older daemon. */
|
||||
unsupported?: true;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
interface SeatApplyResponse {
|
||||
@@ -185,6 +188,10 @@ export function skillCommand(depsOverride?: StatusDeps): Command {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (!opts.apply && data.unsupported) {
|
||||
if (!opts.json) console.log(data.note);
|
||||
return;
|
||||
}
|
||||
const inspection = opts.apply ? data.inspection : data;
|
||||
if (!opts.json) printSeat(inspection);
|
||||
if (!opts.apply) {
|
||||
|
||||
@@ -122,7 +122,7 @@ const CHANNEL_MAP_APPLY =
|
||||
const POST_OPEN_ASKS = "`rig slack post-open-asks`";
|
||||
|
||||
type OpenAskOutcome = "posted" | "already-posted" | "queued" | "held" | "below-floor" | "no-longer-active" | "superseded"
|
||||
| "recipient-changed" | "state-unavailable" | "transport-failed" | "uncertain";
|
||||
| "recipient-changed" | "no-connector" | "state-unavailable" | "transport-failed" | "uncertain";
|
||||
interface OpenAsk { qitemId: string; recipient: string; summary: string | null; outcome: OpenAskOutcome; detail?: string }
|
||||
interface PostOpenAsksResponse { ok: boolean; asks: OpenAsk[] }
|
||||
|
||||
@@ -135,6 +135,7 @@ const OUTCOME_LABEL: Record<OpenAskOutcome, string> = {
|
||||
"no-longer-active": "no longer active",
|
||||
superseded: "superseded",
|
||||
"recipient-changed": "recipient changed",
|
||||
"no-connector": "no connector (Steering and the Feed only)",
|
||||
"state-unavailable": "state unavailable",
|
||||
"transport-failed": "transport failed",
|
||||
uncertain: "uncertain",
|
||||
@@ -252,13 +253,13 @@ export function slackCommand(deps: SlackDeps = {}): Command {
|
||||
const connector = observation.connector as { configurationDigest?: string; inboundReady?: boolean; outboundReady?: boolean;
|
||||
inboundRouting?: { fallback: string; destination: string };
|
||||
settlement?: { pending: number; lastFailure: string | null };
|
||||
deadLetterBacklog?: number | null; deadLetterBacklogState?: string; deadLetterBacklogReason?: string;
|
||||
deadLetterBacklog?: number | null; deadLetterBacklogState?: string; deadLetterBacklogReason?: string; relayAttemptsInFlight?: number;
|
||||
inbound?: InboundDeliveryStatus & { state?: string; generation?: number; lastEventAt?: string };
|
||||
recovery?: { state?: string; reason?: string; lastScanAt?: string; acceptedThisProcess?: number; deadLetteredThisProcess?: number;
|
||||
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; oldestPendingSeconds?: number | null;
|
||||
threads?: { state?: string; reason?: string; tracked?: number; scanned?: number; pending?: number; settled?: number; settledRescanHours?: number; oldestPendingSeconds?: number | null;
|
||||
backoff?: Record<string, number>; incomplete?: Record<string, number> };
|
||||
limits?: string[] } } | undefined;
|
||||
if (connector) {
|
||||
@@ -288,9 +289,11 @@ 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)`);
|
||||
if (connector.relayAttemptsInFlight !== undefined) log(` Relay attempts awaiting response: ${connector.relayAttemptsInFlight} (this process, point-in-time; zero does not prove global quiescence)`);
|
||||
if (connector.deadLetterBacklogState !== undefined) {
|
||||
const scope = "inbound messages, reactions and click answers";
|
||||
if (connector.deadLetterBacklogState === "unknown") {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { readLifecycleDescription } from "../daemon-lifecycle-status.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { attentionLines, checkKernelAttention } from "../seat-attention.js";
|
||||
|
||||
export interface StatusDeps {
|
||||
lifecycleDeps: LifecycleDeps;
|
||||
@@ -136,8 +137,12 @@ export function statusCommand(depsOverride?: StatusDeps): Command {
|
||||
} catch {
|
||||
// config resolution unavailable - null rather than a guess.
|
||||
}
|
||||
// A kernel short of ready: its seats that need attention, and any it couldn't check (never read as none).
|
||||
const attention = kernelRes?.status === 200 && kernelRes.data?.kernel_state && kernelRes.data.kernel_state !== "ready"
|
||||
? await checkKernelAttention(client) : null;
|
||||
const kernel = kernelRes && kernelRes.status === 200 && kernelRes.data?.kernel_state
|
||||
? { state: kernelRes.data.kernel_state, lastBootFailure: kernelRes.data.last_boot_failure ?? null }
|
||||
? { state: kernelRes.data.kernel_state, lastBootFailure: kernelRes.data.last_boot_failure ?? null,
|
||||
...(attention && (attention.attention.length || attention.unknown.length) ? { attention } : {}) }
|
||||
: { state: kernelRes && kernelRes.status === 503 ? "not_tracked" : "unknown", lastBootFailure: null };
|
||||
const daemon = { state: "running", reachable: true, pid: status.pid, port: status.port, healthy: true };
|
||||
if (summaryRes.status !== 200) {
|
||||
@@ -163,6 +168,10 @@ export function statusCommand(depsOverride?: StatusDeps): Command {
|
||||
if (failure) {
|
||||
console.log(` Recovered from an earlier boot failure (${failure.state}${failure.at ? ` at ${failure.at}` : ""}): ${failure.detail ?? "no detail recorded"}`);
|
||||
}
|
||||
// A kernel seat held at a startup prompt or sign-in keeps the kernel short of ready: name it and why.
|
||||
if (kernelRes.data.kernel_state !== "ready") {
|
||||
for (const line of attentionLines(await checkKernelAttention(client), "pane")) console.log(` ${line}`);
|
||||
}
|
||||
} else if (kernelRes && kernelRes.status === 503) {
|
||||
console.log("Kernel: not tracked (no kernel-boot tracker wired)");
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { DaemonClient, DaemonResponseError, DaemonTimeoutError } 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";
|
||||
@@ -42,6 +44,21 @@ 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>;
|
||||
@@ -132,6 +149,7 @@ 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,
|
||||
@@ -142,6 +160,12 @@ 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);
|
||||
});
|
||||
|
||||
@@ -24,10 +24,40 @@ import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { openTerminalWindow, type WindowDeps } from "../terminal-window.js";
|
||||
import { assertLocalTmux, attachTmux, isLocalTmuxHost, readTmuxServerArgs, type TmuxAttachment } from "../tmux-attachment.js";
|
||||
import { attentionLines, checkSeatAttention } from "../seat-attention.js";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
/** herdr-app's home, resolved the way herdr-app resolves it (as the TUI does). */
|
||||
function herdrAppHome(env: NodeJS.ProcessEnv): string {
|
||||
return env["HERDR_APP_HOME"] || join(env["XDG_CONFIG_HOME"] || join(env["HOME"] || homedir(), ".config"), "herdr-app");
|
||||
}
|
||||
|
||||
export interface TerminalDeps extends StatusDeps {
|
||||
windowDeps?: WindowDeps;
|
||||
attachTmux?: (target: TmuxAttachment) => Promise<number>;
|
||||
/** This process's environment (herdr-app context); injectable for tests. */
|
||||
env?: NodeJS.ProcessEnv;
|
||||
socketExists?: (path: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The herdr-app client a command addresses, or null when it doesn't use herdr-app: `--provider herdr-app` opens the
|
||||
* view in the herdr-app window you run it in. Inside a herdr-app terminal, plain `rig terminal open <view>` does the
|
||||
* same when that window is answering (its socket file is a hint; the window is asked before it is chosen). `--window`
|
||||
* always asks for a new desktop window.
|
||||
*/
|
||||
export function herdrAppClient(opts: { provider?: string; window?: boolean; appHome?: string }, deps: TerminalDeps):
|
||||
{ appHome: string; ambient: boolean } | null {
|
||||
if (opts.window) return null;
|
||||
const env = deps.env ?? process.env;
|
||||
const named = opts.provider?.trim();
|
||||
const exists = deps.socketExists ?? existsSync;
|
||||
const ambient = !named
|
||||
&& !!env["HC_TERMINAL_ID"] && !!env["HC_TERMINAL_SOCKET"] && exists(join(herdrAppHome(env), "herdr-app.sock"));
|
||||
if (named !== "herdr-app" && !ambient) return null;
|
||||
return { appHome: opts.appHome ? resolve(opts.appHome) : herdrAppHome(env), ambient };
|
||||
}
|
||||
export interface FollowControls {
|
||||
enabled: boolean; maxTiles?: number; graceMs?: number; focusRetentionMs?: number;
|
||||
@@ -44,6 +74,8 @@ export interface OpenViewResult {
|
||||
session?: string;
|
||||
ok: boolean;
|
||||
opened: string[];
|
||||
/** Opened seats on another host, with that host: complete when sent, so its absence (an older daemon) means unknown. */
|
||||
remote?: Array<{ seat: string; host: string }>;
|
||||
absent: { seat: string; host: string | null; reason: string }[];
|
||||
degraded: { seat: string; host: string; reason: string; code?: "pane_unconfirmed" }[];
|
||||
pages: number;
|
||||
@@ -106,6 +138,23 @@ function humanOpen(r: OpenViewResult): string {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Names the opened seats that need attention (a startup prompt, a sign-in, a failed start), so a held seat doesn't
|
||||
* read as an open that failed for no reason. Local seats are checked on this daemon; a seat on another host, or one
|
||||
* whose host the result doesn't say, is reported unchecked, never matched to a local seat of the same name. */
|
||||
async function withHeldSeats(client: DaemonClient, r: OpenViewResult): Promise<OpenViewResult> {
|
||||
const seats = [...new Set([...r.opened, ...(r.following?.tiles.map(t => t.seat) ?? [])])];
|
||||
if (!Array.isArray(r.remote)) {
|
||||
// An older daemon names opened seats without saying which are remote: none can be checked locally by name.
|
||||
const lines = seats.length ? attentionLines({ attention: [], unknown: seats.map(seat => ({ seat, why: "the daemon didn't say which host each tile is on" })) }, "tile") : [];
|
||||
return lines.length ? { ...r, notes: [...(r.notes ?? []), ...lines] } : r;
|
||||
}
|
||||
const remote = new Map(r.remote.map(({ seat, host }) => [seat, host]));
|
||||
const check = await checkSeatAttention(client, seats.filter(seat => !remote.has(seat)));
|
||||
for (const [seat, host] of remote) check.unknown.push({ seat, why: `it's on host ${host}, which this daemon doesn't check` });
|
||||
const lines = attentionLines(check, "tile");
|
||||
return lines.length ? { ...r, notes: [...(r.notes ?? []), ...lines] } : r;
|
||||
}
|
||||
|
||||
/** A successful existing-workspace selection also needs no new panes. */
|
||||
function printOpen(json: boolean, r: OpenViewResult, status: number): void {
|
||||
if (json) {
|
||||
@@ -121,7 +170,7 @@ function printOpen(json: boolean, r: OpenViewResult, status: number): void {
|
||||
export function terminalCommand(depsOverride?: TerminalDeps): Command {
|
||||
const cmd = new Command("terminal").description(
|
||||
"Open the OpenRig view and agent conversations in terminal tiles",
|
||||
).addHelpText("after", "\nThe OpenRig TUI and operator (after install, and whenever the person wants their agents):\n rig terminal open saved:kernel --window\n Opens the window itself on the daemon's desktop; an agent can run it from its shell.\n Inside Rex it opens a Rex session and shows it in the Rex app; otherwise it uses herdr when installed, else the same layout in plain tmux.\n Herdr needs a terminal the person can see; TUI navigation does not open one.\n Over headless SSH, give the exact connection/attach command for a new terminal/tab.\n Only if the window cannot open: rig tui --shared is the dashboard-only fallback.\n On a desktop, the agent opens the view; it does not finish by printing a command to copy.\n");
|
||||
).addHelpText("after", "\nThe OpenRig TUI and operator (after install, and whenever the person wants their agents):\n rig terminal open saved:kernel --window\n Opens the window itself on the daemon's desktop; an agent can run it from its shell.\n Inside a herdr-app terminal, omit --window to open in that window.\n Inside Rex it opens a Rex session and shows it in the Rex app; otherwise it uses herdr when installed, else the same layout in plain tmux.\n Herdr needs a terminal the person can see; TUI navigation does not open one.\n Over headless SSH, give the exact connection/attach command for a new terminal/tab.\n Only if the window cannot open: rig tui --shared is the dashboard-only fallback.\n On a desktop, the agent opens the view; it does not finish by printing a command to copy.\n");
|
||||
|
||||
const getDeps = (): TerminalDeps =>
|
||||
depsOverride ?? {
|
||||
@@ -133,11 +182,12 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
|
||||
.command("open")
|
||||
.argument("<view>", "a rig name, pod:<rig>/<pod>, seat:<rig>/<logical-id>, mission:<id>, slice:<id>, qitem:<id> (current local obligation owners), or a saved-view id")
|
||||
.description("Open a desktop terminal showing the view's live agents as interactive tiles")
|
||||
.option("--provider <name>", "herdr, cmux or rex: open in the running provider without a desktop window; each open adds a new workspace (inside Herdr, --window reuses a matching one); tmux requires --window. Inside Rex, the default opens in Rex")
|
||||
.option("--provider <name>", "herdr, herdr-app, cmux or rex: open in the running provider without a desktop window; each open adds a new workspace (inside Herdr, --window reuses a matching one), and herdr-app opens a Rig view in the herdr-app window this runs in, which must be answering; tmux requires --window. Inside Rex, the default opens in Rex")
|
||||
.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("--window", "Open a new desktop terminal tab/window (the default when --provider is omitted outside herdr-app); inside a herdr-app terminal --window still asks for a desktop window, and plain open reuses herdr-app")
|
||||
.option("--app-home <dir>", "herdr-app: the client to open in (default: herdr-app's own home, from HERDR_APP_HOME or ~/.config/herdr-app)")
|
||||
.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 Herdr 0.9.5/protocol 22)")
|
||||
.option("--follow", "Keep one qitem:<id>, slice:<id-or-name> or mission:<id> workspace following current obligation owners (Linux/macOS Herdr 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")
|
||||
@@ -148,7 +198,7 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
|
||||
.option("--grace-ms <ms>", "Departure grace, default 4000, at most 60000", Number)
|
||||
.option("--focus-retention-ms <ms>", "Extra focused-reading retention, default 12000, at most 300000", Number)
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (view: string, opts: { provider?: string; session?: string; json?: boolean; window?: boolean; expectedPlan?: string;
|
||||
.action(async (view: string, opts: { provider?: string; session?: string; json?: boolean; window?: boolean; expectedPlan?: string; appHome?: string;
|
||||
follow?: boolean; maxTiles?: number; pin?: string[]; unpin?: string[]; suppress?: string[]; unsuppress?: string[];
|
||||
suppressionMs?: number; graceMs?: number; focusRetentionMs?: number }) => {
|
||||
const deps = getDeps();
|
||||
@@ -157,12 +207,51 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
|
||||
const hasControls = [maxTiles, pin, unpin, suppress, unsuppress, suppressionMs, graceMs, focusRetentionMs].some(v => v !== undefined);
|
||||
const following: FollowControls | undefined = opts.follow !== undefined || hasControls
|
||||
? { enabled: opts.follow !== false, maxTiles, pin, unpin, suppress, unsuppress, suppressionMs, graceMs, focusRetentionMs } : undefined;
|
||||
const herdrApp = herdrAppClient(opts, deps);
|
||||
await withClient(deps, async (client) => {
|
||||
// An ambient herdr-app terminal does not claim --follow; only an explicit --provider herdr-app errors with follow_unsupported.
|
||||
let selectedHerdrApp = herdrApp?.ambient && following ? null : herdrApp;
|
||||
let ambientFallbackNote: string | undefined;
|
||||
if (selectedHerdrApp?.ambient) {
|
||||
const probe = await client.get<{
|
||||
available?: boolean;
|
||||
launch?: { socketPath?: string };
|
||||
providers?: Array<{ status?: { available?: boolean; launch?: { socketPath?: string } } }>;
|
||||
}>(
|
||||
`/api/terminal/status?provider=herdr-app&appHome=${encodeURIComponent(selectedHerdrApp.appHome)}`,
|
||||
);
|
||||
const report = probe.data?.providers?.[0]?.status ?? probe.data;
|
||||
if (probe.status >= 400 || typeof report?.available !== "boolean") {
|
||||
const socketPath = report?.launch?.socketPath ?? join(selectedHerdrApp.appHome, "herdr-app.sock");
|
||||
ambientFallbackNote = `herdr-app's socket exists at ${socketPath} but the daemon could not confirm whether it is answering (HTTP ${probe.status}); opening a desktop window instead. Use --provider herdr-app to require it.`;
|
||||
console.error(ambientFallbackNote);
|
||||
selectedHerdrApp = null;
|
||||
} else if (!report.available) {
|
||||
const socketPath = report.launch?.socketPath ?? join(selectedHerdrApp.appHome, "herdr-app.sock");
|
||||
ambientFallbackNote = `herdr-app's socket exists at ${socketPath} but it isn't answering; opening a desktop window instead. Use --provider herdr-app to require it.`;
|
||||
console.error(ambientFallbackNote);
|
||||
selectedHerdrApp = null;
|
||||
}
|
||||
}
|
||||
if (selectedHerdrApp) {
|
||||
// herdr-app: a fresh Rig view in that client's own window, through the daemon. No desktop window.
|
||||
if (following) { printResult(opts.json ?? false, { ok: false, provider: "herdr-app", code: "follow_unsupported", error: "Following isn't available in herdr-app yet; open the view without --follow." }, 400); return; }
|
||||
const { appHome } = selectedHerdrApp;
|
||||
const res = await client.post<OpenViewResult>("/api/terminal/open", { view, provider: "herdr-app",
|
||||
herdrApp: { appHome },
|
||||
...(opts.expectedPlan !== undefined ? { expectedPlan: opts.expectedPlan } : {}) }, { timeoutMs: TERMINAL_OPEN_TIMEOUT_MS });
|
||||
if (!Array.isArray(res.data?.opened)) { printResult(opts.json ?? false, res.data, res.status); return; }
|
||||
printOpen(opts.json ?? false, await withHeldSeats(client, res.data), res.status);
|
||||
return;
|
||||
}
|
||||
// Herdr's control socket can answer with no desktop client attached.
|
||||
// The default requests a desktop; an explicit provider reuses its workspace.
|
||||
// --window never selects herdr-app
|
||||
if (following?.enabled !== false && (opts.window || !opts.provider)) {
|
||||
const result = await openTerminalWindow(client, view, opts.provider, deps.windowDeps, opts.expectedPlan, session, following);
|
||||
printOpen(opts.json ?? false, result, 200);
|
||||
const windowProvider = opts.provider?.trim() === "herdr-app" ? undefined : opts.provider;
|
||||
const result = await openTerminalWindow(client, view, windowProvider, deps.windowDeps, opts.expectedPlan, session, following);
|
||||
const out = ambientFallbackNote ? { ...result, notes: [...(result.notes ?? []), ambientFallbackNote] } : result;
|
||||
printOpen(opts.json ?? false, await withHeldSeats(client, out), 200);
|
||||
return;
|
||||
}
|
||||
let expectedPlan = opts.expectedPlan;
|
||||
@@ -187,7 +276,7 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
return;
|
||||
}
|
||||
printOpen(opts.json ?? false, res.data, res.status);
|
||||
printOpen(opts.json ?? false, await withHeldSeats(client, res.data), res.status);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,18 +292,38 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("close <view>")
|
||||
.description("Close a herdr-app Rig view (rv-…): its cells leave the layout, then only its own viewers end; seats keep running")
|
||||
.option("--app-home <dir>", "herdr-app home the view is in (default: herdr-app's own)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (view: string, opts: { appHome?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const env = deps.env ?? process.env;
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<OpenViewResult>("/api/terminal/close", { view, provider: "herdr-app",
|
||||
herdrApp: { appHome: opts.appHome ? resolve(opts.appHome) : herdrAppHome(env) } }, { timeoutMs: TERMINAL_OPEN_TIMEOUT_MS });
|
||||
if (opts.json) { printResult(true, res.data, res.status); return; }
|
||||
console.log(res.data?.ok ? `Closed Rig view ${view}.` : `Not closed: ${res.data?.error ?? `HTTP ${res.status}`}`);
|
||||
for (const note of res.data?.notes ?? []) console.log(` ${note}`);
|
||||
if (res.status >= 400) process.exitCode = 1;
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("status")
|
||||
.description("Show terminal provider availability + liveness (doctor)")
|
||||
.option("--provider <name>", "restrict to one provider (herdr / cmux / rex)")
|
||||
.option("--provider <name>", "restrict to one provider (herdr / herdr-app / cmux / rex)")
|
||||
.option("--session <name>", "Inspect a named Herdr session on the daemon's host")
|
||||
.option("--app-home <dir>", "herdr-app: the client to inspect (default: herdr-app's own home)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { provider?: string; session?: string; json?: boolean }) => {
|
||||
.action(async (opts: { provider?: string; session?: string; appHome?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
let path = opts.provider
|
||||
? `/api/terminal/status?provider=${encodeURIComponent(opts.provider)}`
|
||||
: "/api/terminal/status";
|
||||
if (opts.provider === "herdr-app") path += `&appHome=${encodeURIComponent(opts.appHome ? resolve(opts.appHome) : herdrAppHome(deps.env ?? process.env))}`;
|
||||
const session = opts.session?.trim();
|
||||
if (session !== undefined) path += `${opts.provider ? "&" : "?"}session=${encodeURIComponent(session)}`;
|
||||
const res = await client.get<{ session?: string; error?: string }>(path);
|
||||
|
||||
@@ -11,6 +11,7 @@ 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";
|
||||
@@ -120,6 +121,17 @@ 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) };
|
||||
|
||||
@@ -706,7 +718,7 @@ team back with its conversations, run rig up <rig-name> --existing, not the spec
|
||||
}
|
||||
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 }>) ?? [];
|
||||
const nodes = (res.data["nodes"] as Array<{ logicalId: string; status: string; error?: string; noConversation?: true; guidanceGaps?: string[]; restoreGaps?: 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
|
||||
@@ -717,6 +729,16 @@ team back with its conversations, run rig up <rig-name> --existing, not the spec
|
||||
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}`);
|
||||
|
||||
@@ -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 (re-validate to refresh the cache) or instantiate a fresh run.",
|
||||
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.",
|
||||
};
|
||||
case "instance_version_conflict":
|
||||
return {
|
||||
|
||||
@@ -30,6 +30,8 @@ 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
|
||||
@@ -181,6 +183,7 @@ 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)}`);
|
||||
}
|
||||
@@ -254,6 +257,8 @@ 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[];
|
||||
@@ -320,6 +325,7 @@ export function composeAttentionRollup(instances: RenderInstance[]): AttentionRo
|
||||
attention.push({
|
||||
instanceId: inst.instanceId,
|
||||
workflowName: inst.workflowName ?? "",
|
||||
rootObjective: inst.rootObjective ?? null,
|
||||
classes,
|
||||
reasons,
|
||||
affordance,
|
||||
@@ -345,6 +351,7 @@ 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;
|
||||
@@ -374,6 +381,7 @@ 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)})`);
|
||||
|
||||
@@ -172,7 +172,7 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
|
||||
cmd
|
||||
.command("validate <specPath>")
|
||||
.description("Validate a workflow spec file (returns structured ok/error report)")
|
||||
.description("Validate a workflow spec file in memory, without registering it (returns structured ok/error report)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
@@ -637,19 +637,86 @@ exception routes only. Existing obligations keep their owners and evidence.
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("trace <instanceId>")
|
||||
.description("Show one workflow instance + its append-only step trail (audit-only verdict)")
|
||||
.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 }) => {
|
||||
.action(async (instanceId: string, opts: { json?: boolean; full?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<{ instance?: unknown; reconciliation?: unknown; trail?: unknown[]; frontier?: unknown[]; failures?: unknown[]; boundaryObligations?: unknown[]; unknowns?: string[] }>(
|
||||
`/api/workflow/${encodeURIComponent(instanceId)}/trace`,
|
||||
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" : ""}`,
|
||||
);
|
||||
// WF3 FR-2: human mode renders the per-step tree (mini-req 2's
|
||||
// one-screen bar); --json byte-identical (BR-2).
|
||||
@@ -667,6 +734,9 @@ 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)`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
|
||||
export interface RiggedConfig {
|
||||
specs: { builtinsVisible: boolean };
|
||||
launch: { nonInterruptive: boolean };
|
||||
daemon: { port: number; host: string; dbConflict?: "warn" | "stop" };
|
||||
launch: { nonInterruptive: boolean; codexLoginShell: boolean };
|
||||
daemon: { port: number; host: string; dbConflict?: "warn" | "stop"; seatPollIntervalMs?: number };
|
||||
// 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 };
|
||||
@@ -170,6 +170,7 @@ export interface RiggedConfig {
|
||||
tmuxServer: "inherit" | "isolated";
|
||||
/** Rows of tmux scrollback per pane; `rig setup` writes it into the managed `~/.tmux.conf` block. */
|
||||
historyLimit: number;
|
||||
fixedGeometry: boolean;
|
||||
inheritTmuxEnvironment: boolean;
|
||||
};
|
||||
// Slice 15 (OPR.0.7.0.15) — the ONE switch for compact command-output
|
||||
@@ -199,6 +200,8 @@ 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.
|
||||
@@ -207,6 +210,8 @@ 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: {
|
||||
@@ -216,6 +221,10 @@ export interface RiggedConfig {
|
||||
watchdogKeepPerJob: number;
|
||||
streamDays: number;
|
||||
streamMaxItems: number;
|
||||
eventsDays: number;
|
||||
closedContentDays: number;
|
||||
closedMetadataDays: number;
|
||||
erasureLedger: string;
|
||||
batchSize: number;
|
||||
};
|
||||
// S04 (OPR.0.5.5.4) — pickup-receipt stall threshold (twin of the daemon settings-store).
|
||||
@@ -267,8 +276,8 @@ const DEFAULT_CLAUDE_COMPACTION_EXTRA_INSTRUCTION_FILE_PATH = getDefaultOpenRigP
|
||||
);
|
||||
|
||||
const DEFAULTS = {
|
||||
launch: { nonInterruptive: false },
|
||||
daemon: { port: 7433, host: "127.0.0.1", dbConflict: "warn" },
|
||||
launch: { nonInterruptive: false, codexLoginShell: false },
|
||||
daemon: { port: 7433, host: "127.0.0.1", dbConflict: "warn", seatPollIntervalMs: 1000 },
|
||||
// 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" },
|
||||
@@ -365,6 +374,7 @@ const DEFAULTS = {
|
||||
statusBar: false,
|
||||
tmuxServer: "inherit",
|
||||
historyLimit: 10000,
|
||||
fixedGeometry: false,
|
||||
inheritTmuxEnvironment: false,
|
||||
},
|
||||
// Slice 15 — compact command-output defaults OFF until the switch is on.
|
||||
@@ -387,6 +397,7 @@ const DEFAULTS = {
|
||||
transport: {
|
||||
verifyInputBox: false,
|
||||
localUserGuard: false,
|
||||
draftDelivery: "deliver",
|
||||
},
|
||||
// Slice 22 — typed decisions OFF, no consumers, shipped templates only.
|
||||
decisions: {
|
||||
@@ -394,6 +405,8 @@ 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",
|
||||
@@ -406,6 +419,10 @@ const DEFAULTS = {
|
||||
watchdogKeepPerJob: 50,
|
||||
streamDays: 30,
|
||||
streamMaxItems: 20000,
|
||||
eventsDays: 0,
|
||||
closedContentDays: 0,
|
||||
closedMetadataDays: 0,
|
||||
erasureLedger: "",
|
||||
batchSize: 500,
|
||||
},
|
||||
// S04 — pickup-receipt stall threshold default (twin of daemon getDefaultValue).
|
||||
@@ -425,6 +442,8 @@ 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-
|
||||
@@ -470,6 +489,7 @@ export const VALID_KEYS = [
|
||||
"skills.router_line.enabled",
|
||||
"onboarding.default_pack.enabled",
|
||||
"launch.non_interruptive",
|
||||
"launch.codex_login_shell",
|
||||
"health.context_pressure.warning_percent",
|
||||
"health.context_pressure.critical_percent",
|
||||
"files.allowlist",
|
||||
@@ -521,6 +541,7 @@ export const VALID_KEYS = [
|
||||
"terminal.tmux_server",
|
||||
// The managed ~/.tmux.conf block's history-limit, written by `rig setup`; lockstep with the daemon twin.
|
||||
"terminal.history_limit",
|
||||
"terminal.fixed_geometry",
|
||||
"terminal.inherit_tmux_environment",
|
||||
// Slice 15 — compact command-output defaults; lockstep with the daemon twin.
|
||||
"output.compact",
|
||||
@@ -535,10 +556,13 @@ 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",
|
||||
@@ -548,6 +572,14 @@ 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",
|
||||
// Closed queue work: content erased N days after it closed, then the row (only its id kept); 0 (default) keeps them;
|
||||
// lockstep with the daemon twin.
|
||||
"retention.closed_content_days",
|
||||
"retention.closed_metadata_days",
|
||||
// Queue erasure's ledger file (JSON lines of erased ids and actions); empty keeps none; lockstep with the daemon twin.
|
||||
"retention.erasure_ledger",
|
||||
"stream.intake",
|
||||
"retention.batch_size",
|
||||
// S04 — pickup-receipt stall threshold; lockstep with the daemon settings-store twin.
|
||||
@@ -579,6 +611,7 @@ 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" },
|
||||
@@ -598,6 +631,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
"skills.router_line.enabled": { primary: "OPENRIG_SKILLS_ROUTER_LINE_ENABLED" },
|
||||
"onboarding.default_pack.enabled": { primary: "OPENRIG_ONBOARDING_DEFAULT_PACK_ENABLED" },
|
||||
"launch.non_interruptive": { primary: "OPENRIG_LAUNCH_NON_INTERRUPTIVE" },
|
||||
"launch.codex_login_shell": { primary: "OPENRIG_LAUNCH_CODEX_LOGIN_SHELL" },
|
||||
"health.context_pressure.warning_percent": { primary: "OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT" },
|
||||
"health.context_pressure.critical_percent": { primary: "OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT" },
|
||||
// UEP env-var graduation: existing OPENRIG_FILES_ALLOWLIST /
|
||||
@@ -645,6 +679,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
"terminal.status_bar": { primary: "OPENRIG_TERMINAL_STATUS_BAR" },
|
||||
"terminal.tmux_server": { primary: "OPENRIG_TMUX_SERVER" },
|
||||
"terminal.history_limit": { primary: "OPENRIG_TERMINAL_HISTORY_LIMIT" },
|
||||
"terminal.fixed_geometry": { primary: "OPENRIG_TERMINAL_FIXED_GEOMETRY" },
|
||||
"terminal.inherit_tmux_environment": { primary: "OPENRIG_TERMINAL_INHERIT_TMUX_ENVIRONMENT" },
|
||||
"output.compact": { primary: "OPENRIG_OUTPUT_COMPACT" },
|
||||
"output.whoami_peers": { primary: "OPENRIG_OUTPUT_WHOAMI_PEERS" },
|
||||
@@ -655,9 +690,11 @@ 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" },
|
||||
@@ -665,6 +702,10 @@ 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" },
|
||||
"retention.closed_content_days": { primary: "OPENRIG_RETENTION_CLOSED_CONTENT_DAYS" },
|
||||
"retention.closed_metadata_days": { primary: "OPENRIG_RETENTION_CLOSED_METADATA_DAYS" },
|
||||
"retention.erasure_ledger": { primary: "OPENRIG_RETENTION_ERASURE_LEDGER" },
|
||||
"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" },
|
||||
@@ -688,6 +729,7 @@ 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"],
|
||||
@@ -707,6 +749,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"skills.router_line.enabled": ["skills", "routerLine", "enabled"],
|
||||
"onboarding.default_pack.enabled": ["onboarding", "defaultPack", "enabled"],
|
||||
"launch.non_interruptive": ["launch", "nonInterruptive"],
|
||||
"launch.codex_login_shell": ["launch", "codexLoginShell"],
|
||||
"health.context_pressure.warning_percent": ["health", "contextPressure", "warningPercent"],
|
||||
"health.context_pressure.critical_percent": ["health", "contextPressure", "criticalPercent"],
|
||||
"files.allowlist": ["files", "allowlist"],
|
||||
@@ -746,6 +789,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"terminal.status_bar": ["terminal", "statusBar"],
|
||||
"terminal.tmux_server": ["terminal", "tmuxServer"],
|
||||
"terminal.history_limit": ["terminal", "historyLimit"],
|
||||
"terminal.fixed_geometry": ["terminal", "fixedGeometry"],
|
||||
"terminal.inherit_tmux_environment": ["terminal", "inheritTmuxEnvironment"],
|
||||
"output.compact": ["output", "compact"],
|
||||
"output.whoami_peers": ["output", "whoamiPeers"],
|
||||
@@ -756,15 +800,21 @@ 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"],
|
||||
"retention.closed_content_days": ["retention", "closedContentDays"],
|
||||
"retention.closed_metadata_days": ["retention", "closedMetadataDays"],
|
||||
"retention.erasure_ledger": ["retention", "erasureLedger"],
|
||||
"stream.intake": ["stream", "intake"],
|
||||
"retention.batch_size": ["retention", "batchSize"],
|
||||
"queue.pickup_stall_threshold_minutes": ["queue", "pickupStallThresholdMinutes"],
|
||||
@@ -980,6 +1030,16 @@ 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]");
|
||||
@@ -1135,6 +1195,30 @@ 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}"`);
|
||||
}
|
||||
},
|
||||
// 0 (the default) keeps closed work; a positive day count turns its erasure on (queue-erasure).
|
||||
"retention.closed_content_days": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 0) {
|
||||
throw new Error(`Invalid value for retention.closed_content_days: must be an integer >= 0 (0 keeps the content), got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"retention.closed_metadata_days": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 0) {
|
||||
throw new Error(`Invalid value for retention.closed_metadata_days: must be an integer >= 0 (0 keeps the rows), got "${raw}"`);
|
||||
}
|
||||
},
|
||||
// Empty (the default) keeps no erasure ledger; otherwise an absolute file path, kept outside the backed-up data.
|
||||
"retention.erasure_ledger": (raw) => {
|
||||
const value = (raw ?? "").trim();
|
||||
if (value !== "" && !value.startsWith("/")) {
|
||||
throw new Error(`Invalid value for retention.erasure_ledger: must be empty or an absolute file path, 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}"`);
|
||||
@@ -1155,6 +1239,12 @@ 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 {
|
||||
@@ -1251,6 +1341,7 @@ 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,
|
||||
@@ -1329,7 +1420,7 @@ export class ConfigStore {
|
||||
auditLog: v("feed.subscriptions.audit_log") as boolean,
|
||||
},
|
||||
},
|
||||
launch: { nonInterruptive: v("launch.non_interruptive") as boolean },
|
||||
launch: { nonInterruptive: v("launch.non_interruptive") as boolean, codexLoginShell: v("launch.codex_login_shell") as boolean },
|
||||
runtime: {
|
||||
readinessTimeoutSeconds: v("runtime.readiness_timeout_seconds") as number,
|
||||
codex: {
|
||||
@@ -1365,6 +1456,7 @@ export class ConfigStore {
|
||||
statusBar: v("terminal.status_bar") as boolean,
|
||||
tmuxServer: v("terminal.tmux_server") as "inherit" | "isolated",
|
||||
historyLimit: v("terminal.history_limit") as number,
|
||||
fixedGeometry: v("terminal.fixed_geometry") as boolean,
|
||||
inheritTmuxEnvironment: v("terminal.inherit_tmux_environment") as boolean,
|
||||
},
|
||||
output: {
|
||||
@@ -1383,12 +1475,14 @@ 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,
|
||||
@@ -1396,6 +1490,10 @@ 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,
|
||||
closedContentDays: v("retention.closed_content_days") as number,
|
||||
closedMetadataDays: v("retention.closed_metadata_days") as number,
|
||||
erasureLedger: v("retention.erasure_ledger") as string,
|
||||
batchSize: v("retention.batch_size") as number,
|
||||
},
|
||||
queue: {
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface CrossHostTargetResolution {
|
||||
target: string;
|
||||
/** The sugar-derived host id (registered suffix), if any. */
|
||||
sugarHost: string | undefined;
|
||||
/** The qualifier explicitly names this host, overriding a persisted remote selection. */
|
||||
localHost?: boolean;
|
||||
/**
|
||||
* Loud-failure hint when the target was 3-part-SHAPED but the suffix
|
||||
* matched no registered host. Callers append it to failure surfaces for
|
||||
@@ -102,7 +104,7 @@ export function resolveCrossHostTarget(
|
||||
};
|
||||
}
|
||||
// Routes home: strip the self-suffix, no cross-host sugarHost (local send).
|
||||
return { ok: true, target: base, sugarHost: undefined, hint: undefined };
|
||||
return { ok: true, target: base, sugarHost: undefined, localHost: true, hint: undefined };
|
||||
}
|
||||
|
||||
const loader = registryLoader ?? loadHostRegistry;
|
||||
|
||||
Loaded 100 of 839 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user