mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 18:48:31 -10:00
Compare commits
181
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a226b1b6cb | ||
|
|
d83962a843 | ||
|
|
f90481bc23 | ||
|
|
cd8e32f18d | ||
|
|
6130b4308c | ||
|
|
6f0f0888c1 | ||
|
|
d98f4759fe | ||
|
|
bc793ba3ff | ||
|
|
5eb917891a | ||
|
|
32531ae08e | ||
|
|
c17f8a53d6 | ||
|
|
3c8b72d54c | ||
|
|
14d89431f9 | ||
|
|
c105b9c2f3 | ||
|
|
52d6dff860 | ||
|
|
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 | ||
|
|
911c407d46 | ||
|
|
f7113e5a5d | ||
|
|
80e568e9d9 | ||
|
|
0042a91277 | ||
|
|
9b9e6c4097 | ||
|
|
512040e5c3 | ||
|
|
faccc35040 | ||
|
|
78afeaa2e2 | ||
|
|
acdf61532f | ||
|
|
aeb218a112 | ||
|
|
d33aca86ea | ||
|
|
aaaeb8edc6 | ||
|
|
59247e7a8f | ||
|
|
16946c577a | ||
|
|
2a1e609cb1 | ||
|
|
fa3262d360 | ||
|
|
94230b17bb | ||
|
|
840aeaa687 | ||
|
|
b21466e8ae | ||
|
|
190b0de73d | ||
|
|
f692215383 | ||
|
|
de3b0658a6 | ||
|
|
826bc1cc5c | ||
|
|
3f0a5cc744 | ||
|
|
7d7634947d | ||
|
|
7c5c514457 | ||
|
|
78978dcb21 | ||
|
|
2eddb4a2d7 | ||
|
|
91d21bc962 | ||
|
|
3448150528 | ||
|
|
889b804ada | ||
|
|
863c0f58f2 | ||
|
|
87297f0f8e | ||
|
|
de47425168 | ||
|
|
101e5e3ff6 | ||
|
|
4ebb06059a | ||
|
|
a7bd8fe50f | ||
|
|
d9b09ec3f2 | ||
|
|
a3e5e92112 | ||
|
|
7b02076427 | ||
|
|
e10b8014f6 | ||
|
|
23daf8513a | ||
|
|
f7206db6de | ||
|
|
74501bdf63 | ||
|
|
432a6bcc41 | ||
|
|
c4b1181dc4 | ||
|
|
72a124de82 | ||
|
|
499dfe3ece | ||
|
|
9b3f91ecae | ||
|
|
8baec13548 | ||
|
|
f8ba5c4bac | ||
|
|
3917d119de | ||
|
|
7ae36414ed | ||
|
|
a0242b206f | ||
|
|
d7e2b5ca63 | ||
|
|
bb007efabc | ||
|
|
8e85902060 | ||
|
|
c7f5fc9674 | ||
|
|
5fb816f2b9 | ||
|
|
5f31cb5760 | ||
|
|
886b9d0586 | ||
|
|
1fa46a6118 | ||
|
|
be85d82546 | ||
|
|
7cabac4432 | ||
|
|
e935df86f1 | ||
|
|
6fb1e2c1c5 | ||
|
|
91ad6e719f | ||
|
|
fe29a92e44 | ||
|
|
ab858f691b | ||
|
|
18b5d79c9d | ||
|
|
c9b1ae480f | ||
|
|
076c710d42 | ||
|
|
e7e6dc4172 | ||
|
|
2128bb85d1 | ||
|
|
3352bee0d6 | ||
|
|
94b01e5c88 | ||
|
|
79e5b7952d | ||
|
|
ea4c178126 | ||
|
|
98f0f4939c | ||
|
|
9819da9d5e | ||
|
|
3fe80b97f6 | ||
|
|
ac7a6b0713 | ||
|
|
cb3fb1890d | ||
|
|
a0622b63fd | ||
|
|
0f6e6ea8f3 | ||
|
|
1593295db6 | ||
|
|
e3c461a7e3 | ||
|
|
c9e73d363c | ||
|
|
51c2bd9d4d | ||
|
|
6a35cd39f3 | ||
|
|
85881245b4 | ||
|
|
e5cd19e25a | ||
|
|
9d2d0d47d8 | ||
|
|
7b858418f3 | ||
|
|
d7fc1fce0c | ||
|
|
fa257fa12c | ||
|
|
ba8d259966 | ||
|
|
b35c2c5e93 | ||
|
|
72d321228f | ||
|
|
a7683fd14d | ||
|
|
5de5c851aa | ||
|
|
9e8ce9e078 | ||
|
|
4f61275582 | ||
|
|
cd01d2ac34 | ||
|
|
6a2a4855b0 | ||
|
|
95f0ae6d67 | ||
|
|
0ed456e68f | ||
|
|
f913586a38 | ||
|
|
8fca221aab | ||
|
|
0b60f8da4a | ||
|
|
fbca740dd1 | ||
|
|
522d635cdf | ||
|
|
8834943465 | ||
|
|
b9a4390b96 | ||
|
|
c3eb2f73e5 | ||
|
|
d942e12954 | ||
|
|
c1783989c8 | ||
|
|
551fb0e2f7 | ||
|
|
d1839dfcf1 | ||
|
|
2b929b3d76 | ||
|
|
b28dcd8f21 | ||
|
|
da9ef8273c | ||
|
|
16ac9bfabd | ||
|
|
94f7b27ac1 | ||
|
|
3c0fee3e2d | ||
|
|
978e95fbff | ||
|
|
b78894736d | ||
|
|
cca17f54d5 |
No files matched your search
@@ -116,7 +116,7 @@ jobs:
|
||||
# macOS, the platform OpenRig runs on: several daemon and cli tests read macOS ps columns,
|
||||
# file-inode behaviour and release tags.
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
+735
-26
@@ -8,14 +8,723 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
|
||||
|
||||
---
|
||||
|
||||
## [0.7.10]
|
||||
|
||||
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 two problems that kept the OpenShell
|
||||
worker from working in 0.7.9 are fixed in the source, but the worker hasn't run
|
||||
end to end on 0.7.10: see [the OpenShell worker](#the-openshell-worker-in-0710).
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- **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 fixes, in the source, the two problems our release testing on 0.7.9
|
||||
found:
|
||||
- **The guest's CA settings reach its Claude.** The guest launcher built
|
||||
Claude's environment from scratch with only the provider settings, so a CA the
|
||||
OpenShell exec supplied never arrived, and API calls failed with
|
||||
`SELF_SIGNED_CERT_IN_CHAIN`. It now also passes `NODE_EXTRA_CA_CERTS`,
|
||||
`SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE` and `CURL_CA_BUNDLE`, each only when the
|
||||
exec supplies it. Nothing else is copied, and nothing turns TLS checks off.
|
||||
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. ([#2603](https://github.com/mvschwarz/openrig/pull/2603))
|
||||
|
||||
A verified export, a finished task and an exact restore haven't been shown.
|
||||
|
||||
### 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:** the two fixes haven't run end to end (above). A cleanup error
|
||||
that's thrown still omits its stage and whether its delete was sent, and the
|
||||
inventory is a snapshot, not an atomic read. 0.7.8's requirements, cost note
|
||||
and cleanup limits still apply, including "Never grant the guest access to the
|
||||
daemon's main API port (normally 7433)." A same-generation reconciled launch
|
||||
opens no viewer, so run `rig environment connect` yourself; non-text startup
|
||||
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
|
||||
and owned-delete inventory (fixed in the source, not yet run end to end), a
|
||||
restore request held after a managed compaction, `rig broadcast`'s `FAILED`
|
||||
for an uncertain recipient, the refusal for a seat handed over before 0.7.9,
|
||||
where to read what has run under the runner contract, and Codex seats'
|
||||
`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, 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
|
||||
|
||||
- **Restart the daemon after installing:**
|
||||
```bash
|
||||
npm install -g @openrig/cli@0.7.9
|
||||
rig daemon stop && rig daemon start
|
||||
```
|
||||
Four 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.
|
||||
([#2432](https://github.com/mvschwarz/openrig/pull/2432), [#2461](https://github.com/mvschwarz/openrig/pull/2461), [#2496](https://github.com/mvschwarz/openrig/pull/2496), [#2321](https://github.com/mvschwarz/openrig/pull/2321))
|
||||
- **Stream intake is on by default.** 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. The row wakes the queue worker like any queue row, and
|
||||
earlier stream history isn't replayed. **To keep 0.7.8's behaviour,** with no
|
||||
intake rows, run this after installing: `rig config set stream.intake off`.
|
||||
([#2321](https://github.com/mvschwarz/openrig/pull/2321))
|
||||
- **The bundled openrig-core plugin advances 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 that copy. ([#2420](https://github.com/mvschwarz/openrig/pull/2420), [#2554](https://github.com/mvschwarz/openrig/pull/2554))
|
||||
- **If you run an OpenShell environment, rebuild its guest image** with this
|
||||
release's helper (`daemon/assets/openshell/README.md`). An older helper
|
||||
rejects the new startup step, and the error names the rebuild. ([#2418](https://github.com/mvschwarz/openrig/pull/2418))
|
||||
|
||||
### Runner seats
|
||||
|
||||
- **A person's typed words reach the agent, and the ask says what they do.** 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 words come back whole in the read result as
|
||||
`said`. Section 7 of `docs/reference/harness-runner-contract.md` lets a runner
|
||||
queue them for the agent "as an ordinary prompt" after the current turn:
|
||||
"They're never the answer." A runner that drops them still keeps the contract.
|
||||
([#2363](https://github.com/mvschwarz/openrig/pull/2363), [#2561](https://github.com/mvschwarz/openrig/pull/2561))
|
||||
- **Each permit OpenRig grants by default is recorded,** once, as a stream item
|
||||
from the seat: when no one can be asked, and when an ask is closed without a
|
||||
choice (a row passed to an agent seat included). The record names the tool
|
||||
call, why no person answered (for a closed ask, who closed which row and how),
|
||||
the option the agent got with its kind, and the conversation and request or
|
||||
the ask. Read the records with `rig stream list --tag runner-permission-permit`,
|
||||
or `--source <seat>`. A record has no destination, and one that can't be
|
||||
written doesn't change the permit. Stream intake reads past it, so it opens no
|
||||
intake row and wakes no one. ([#2361](https://github.com/mvschwarz/openrig/pull/2361), [#2563](https://github.com/mvschwarz/openrig/pull/2563))
|
||||
- **A working beat keeps a long, quiet turn reading working.** A runner may post
|
||||
`active` / `liveness` while a prompt is outstanding and its agent is alive
|
||||
(section 4); one that sends no beats behaves as before. The Pi runner and the
|
||||
dsh example post it. ([#2370](https://github.com/mvschwarz/openrig/pull/2370), [#2336](https://github.com/mvschwarz/openrig/pull/2336), [#2399](https://github.com/mvschwarz/openrig/pull/2399), [#2388](https://github.com/mvschwarz/openrig/pull/2388))
|
||||
- **The conformance check** covers the beat, and no longer falsely fails a
|
||||
correct runner on a busy machine (cases 1, 2 and 4b).
|
||||
([#2368](https://github.com/mvschwarz/openrig/pull/2368), [#2375](https://github.com/mvschwarz/openrig/pull/2375), [#2388](https://github.com/mvschwarz/openrig/pull/2388), [#2530](https://github.com/mvschwarz/openrig/pull/2530))
|
||||
- **Section 8 names ACP's `session/resume`,** and the dsh example reads
|
||||
`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 in 0.7.9
|
||||
|
||||
**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
|
||||
needs a grant service that you supply and run; ordinary use of OpenRig doesn't
|
||||
need one. With `rig mcp serve --http --grant-service <url>`, a connected
|
||||
assistant can now 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. Serving
|
||||
MCP over HTTP behind your own tunnel "is like opening a port in your firewall."
|
||||
See `docs/reference/hosted-members.md`. ([#2384](https://github.com/mvschwarz/openrig/pull/2384))
|
||||
|
||||
### For client authors
|
||||
|
||||
- **New reads:** `GET /api/gateway/participants` (people and the default
|
||||
person), `GET /api/queue/open-human-updates` (a person's open updates, without
|
||||
Slack), `GET /api/queue/outbox/list?rig=<rig>` (a rig's messages in one
|
||||
request), and `GET /api/gateway/slack/channels` and `/users` (a page of public
|
||||
channels or of people, without the client holding the bot token).
|
||||
([#2495](https://github.com/mvschwarz/openrig/pull/2495), [#2497](https://github.com/mvschwarz/openrig/pull/2497), [#2496](https://github.com/mvschwarz/openrig/pull/2496), [#2501](https://github.com/mvschwarz/openrig/pull/2501))
|
||||
- **Settle exactly the decision a person reviewed:** `GET /api/queue/:qitemId`
|
||||
returns `decisionRevision`, and answer, resolve, approve and deny take an
|
||||
optional `expectedRevision`. A stale one gets 409 `decision_revision_changed`
|
||||
with nothing written; a caller that sends none acts as before. ([#2448](https://github.com/mvschwarz/openrig/pull/2448))
|
||||
- **A client reference:** `docs/reference/daemon-http-clients.md`, with a recipe
|
||||
for people, open updates and a rig's outbox. ([#2490](https://github.com/mvschwarz/openrig/pull/2490), [#2524](https://github.com/mvschwarz/openrig/pull/2524))
|
||||
|
||||
### Seats, delivery and the TUI
|
||||
|
||||
- **Delivery:**
|
||||
- a Claude prompt suggestion isn't taken for a person's draft;
|
||||
- a long line Claude wraps short of the box is still submitted;
|
||||
- a guarded wake recovers its own paste instead of holding later deliveries;
|
||||
- a wake isn't typed into Claude's open Theme picker, or refused because a
|
||||
reply quotes a permission question;
|
||||
- a seat that starts, or takes over a seat, with held messages is told about
|
||||
them; each watchdog job holds one reminder, and handoffs pass a full held
|
||||
quota;
|
||||
- a reminder that couldn't reach its seat, for example after a reboot, stays
|
||||
owed and retries;
|
||||
- a stalled tmux client is ended after 30 seconds.
|
||||
([#2435](https://github.com/mvschwarz/openrig/pull/2435), [#2436](https://github.com/mvschwarz/openrig/pull/2436), [#2447](https://github.com/mvschwarz/openrig/pull/2447), [#2514](https://github.com/mvschwarz/openrig/pull/2514), [#2520](https://github.com/mvschwarz/openrig/pull/2520), [#2437](https://github.com/mvschwarz/openrig/pull/2437), [#2442](https://github.com/mvschwarz/openrig/pull/2442), [#2438](https://github.com/mvschwarz/openrig/pull/2438), [#2478](https://github.com/mvschwarz/openrig/pull/2478), [#2452](https://github.com/mvschwarz/openrig/pull/2452))
|
||||
- **Status and receipts:**
|
||||
- an idle Claude with newer footer rows reads idle, and a busy Codex with a
|
||||
quiet window reads working;
|
||||
- window samples older than their bound no longer decide a seat's activity, so
|
||||
a seat whose pane can't be read reads unknown, not its last verdict;
|
||||
- `rig send --verify` and `rig walk` report unconfirmed when they can't see the
|
||||
result, and uncertain sends and broadcast recipients read `indeterminate`;
|
||||
- a broadcast that reached nobody doesn't say "Delivered";
|
||||
- replies to `human@kernel`, `human@host` and people forwarded from another
|
||||
host get a usable `rig queue create` hint.
|
||||
([#2470](https://github.com/mvschwarz/openrig/pull/2470), [#2500](https://github.com/mvschwarz/openrig/pull/2500), [#2558](https://github.com/mvschwarz/openrig/pull/2558), [#2509](https://github.com/mvschwarz/openrig/pull/2509), [#2510](https://github.com/mvschwarz/openrig/pull/2510), [#2505](https://github.com/mvschwarz/openrig/pull/2505), [#2507](https://github.com/mvschwarz/openrig/pull/2507), [#2512](https://github.com/mvschwarz/openrig/pull/2512), [#2359](https://github.com/mvschwarz/openrig/pull/2359), [#2493](https://github.com/mvschwarz/openrig/pull/2493), [#2504](https://github.com/mvschwarz/openrig/pull/2504))
|
||||
- **Guidance and handover:**
|
||||
- an edit to a culture or startup file reaches the next start, and a block you
|
||||
edited by hand is kept and named;
|
||||
- a shared `CLAUDE.md` or `AGENTS.md` stops growing by a line each launch;
|
||||
- a Claude seat loads openrig-core's skills once;
|
||||
- a fresh or rebuild handover successor gets its guidance back for every
|
||||
runtime and a list of its startup files, role first, and is marked when a
|
||||
required file is missing;
|
||||
- launched successors can be stopped, restarted and cleaned, and the rebuild
|
||||
receipt reports its lineage record and the old process truthfully;
|
||||
- a Claude launched through the native installer's symlink can be rebuilt, a
|
||||
slow classic resume under a non-exec launch shell no longer lands in
|
||||
attention, and a `local:` Claude seat gets its activity hooks back on a
|
||||
contained resume; restores use the seat's launch directory.
|
||||
([#2441](https://github.com/mvschwarz/openrig/pull/2441), [#2443](https://github.com/mvschwarz/openrig/pull/2443), [#2432](https://github.com/mvschwarz/openrig/pull/2432), [#2461](https://github.com/mvschwarz/openrig/pull/2461), [#2376](https://github.com/mvschwarz/openrig/pull/2376), [#2433](https://github.com/mvschwarz/openrig/pull/2433), [#2457](https://github.com/mvschwarz/openrig/pull/2457), [#2387](https://github.com/mvschwarz/openrig/pull/2387), [#2402](https://github.com/mvschwarz/openrig/pull/2402), [#2474](https://github.com/mvschwarz/openrig/pull/2474), [#2458](https://github.com/mvschwarz/openrig/pull/2458), [#2466](https://github.com/mvschwarz/openrig/pull/2466), [#2440](https://github.com/mvschwarz/openrig/pull/2440), [#2420](https://github.com/mvschwarz/openrig/pull/2420), [#2371](https://github.com/mvschwarz/openrig/pull/2371), [#2518](https://github.com/mvschwarz/openrig/pull/2518), [#2479](https://github.com/mvschwarz/openrig/pull/2479), [#2373](https://github.com/mvschwarz/openrig/pull/2373))
|
||||
- **Asks and answers:**
|
||||
- in a shared workspace, any registered person settles another's ask as
|
||||
themselves, and the first answer wins everywhere;
|
||||
- a person may have no connector binding, and is then asked on Steering and in
|
||||
the Feed only;
|
||||
- a click on a re-parked Slack ask records a new answer and wakes its asker;
|
||||
- a Slack ask's post shows it was handled once its row settles;
|
||||
- For You no longer offers Approve on an ask with questions.
|
||||
([#2386](https://github.com/mvschwarz/openrig/pull/2386), [#2419](https://github.com/mvschwarz/openrig/pull/2419), [#2421](https://github.com/mvschwarz/openrig/pull/2421), [#2472](https://github.com/mvschwarz/openrig/pull/2472), [#2415](https://github.com/mvschwarz/openrig/pull/2415))
|
||||
- **Compaction:** managed compaction reads a small Claude pane at a readable
|
||||
size; a manual `rig compact` that times out says whether the restore map was
|
||||
ready; a Pi seat shows a failed or cancelled compaction as such.
|
||||
([#2445](https://github.com/mvschwarz/openrig/pull/2445), [#2400](https://github.com/mvschwarz/openrig/pull/2400), [#2537](https://github.com/mvschwarz/openrig/pull/2537))
|
||||
- **Install, hosts and setup:**
|
||||
- the openrig-upgrade skill backs up without the sqlite3 CLI when Node has
|
||||
`node:sqlite`;
|
||||
- preflight warns when a Codex seat's login shell can't find `rig`;
|
||||
- the experiment config can be written where Node has no `process.getuid`;
|
||||
- bundle unpack restores archived owner bits;
|
||||
- the Pi Node check is bounded, and `rig doctor` no longer hangs on a port held
|
||||
by a listener that never replies;
|
||||
- a scoped launch on tmux 3.4 keeps its default command;
|
||||
- the kernel's template survives a daemon restart;
|
||||
- `rig host ls` reads your ssh config with `ssh -G`, which runs any
|
||||
`Match exec` command in it, and `rig host pair` names the bearer token the
|
||||
target needs;
|
||||
- the install docs describe the installer's step-4 outcome and the first
|
||||
kernel's provider choice.
|
||||
([#2394](https://github.com/mvschwarz/openrig/pull/2394), [#2522](https://github.com/mvschwarz/openrig/pull/2522), [#2449](https://github.com/mvschwarz/openrig/pull/2449), [#2475](https://github.com/mvschwarz/openrig/pull/2475), [#2451](https://github.com/mvschwarz/openrig/pull/2451), [#2549](https://github.com/mvschwarz/openrig/pull/2549), [#2557](https://github.com/mvschwarz/openrig/pull/2557), [#2471](https://github.com/mvschwarz/openrig/pull/2471), [#2488](https://github.com/mvschwarz/openrig/pull/2488), [#2491](https://github.com/mvschwarz/openrig/pull/2491), [#2551](https://github.com/mvschwarz/openrig/pull/2551), [#2560](https://github.com/mvschwarz/openrig/pull/2560))
|
||||
- **The CLI:**
|
||||
- unknown `--state`, `--kind` and `--priority` values warn and still run;
|
||||
- `rig workspace validate` prints a readable refusal, and `rig seat set-runtime`
|
||||
says what to do for a seat with no pane;
|
||||
- a cut `rig queue show` body says how to get the rest;
|
||||
- `rig queue update --closure-required-at` moves a claimed row's deadline;
|
||||
- `rig seat set-typing-guard` applies from an ordinary terminal, recorded as
|
||||
unattributed;
|
||||
- `rig scope` tells shipped, superseded and closed slices apart, flags a slice
|
||||
name two missions share, and skips fenced examples;
|
||||
- `rig context add <url>` installs into a new nested name;
|
||||
- a retained stuck-sweep recovery names its current reason, and a refused
|
||||
context-pressure setting says what it compared.
|
||||
([#2357](https://github.com/mvschwarz/openrig/pull/2357), [#2365](https://github.com/mvschwarz/openrig/pull/2365), [#2358](https://github.com/mvschwarz/openrig/pull/2358), [#2367](https://github.com/mvschwarz/openrig/pull/2367), [#2383](https://github.com/mvschwarz/openrig/pull/2383), [#2422](https://github.com/mvschwarz/openrig/pull/2422), [#2460](https://github.com/mvschwarz/openrig/pull/2460), [#2351](https://github.com/mvschwarz/openrig/pull/2351), [#2434](https://github.com/mvschwarz/openrig/pull/2434), [#2492](https://github.com/mvschwarz/openrig/pull/2492), [#2395](https://github.com/mvschwarz/openrig/pull/2395), [#2546](https://github.com/mvschwarz/openrig/pull/2546), [#2552](https://github.com/mvschwarz/openrig/pull/2552), [#2553](https://github.com/mvschwarz/openrig/pull/2553))
|
||||
- **Launch options:** `specs.builtins_visible: false` leaves built-in specs out
|
||||
of the inventory (`rig specs ls --include-builtins` still lists them).
|
||||
`plugin_channels` passes a Claude member's own plugin channels to
|
||||
`--channels`; beside `claude_mcp: none` it warns that no channel could
|
||||
register, and the member still launches. We haven't run it with a real channel
|
||||
plugin. ([#2379](https://github.com/mvschwarz/openrig/pull/2379), [#2439](https://github.com/mvschwarz/openrig/pull/2439))
|
||||
- **The TUI and web UI:**
|
||||
- body text reads on a light terminal, and the selected row shows in
|
||||
16-colour mode;
|
||||
- JUST FINISHED and RECENTLY FINISHED show what closed last, in the configured
|
||||
time zone, even on a busy fleet;
|
||||
- a fresh seat opened in tiles is sized to its tile, and grid fillers open your
|
||||
login shell;
|
||||
- in the web UI, the Topology filter no longer hangs, and the kernel card and
|
||||
maintenance notice display correctly.
|
||||
([#2499](https://github.com/mvschwarz/openrig/pull/2499), [#2506](https://github.com/mvschwarz/openrig/pull/2506), [#2392](https://github.com/mvschwarz/openrig/pull/2392), [#2393](https://github.com/mvschwarz/openrig/pull/2393), [#2459](https://github.com/mvschwarz/openrig/pull/2459), [#2464](https://github.com/mvschwarz/openrig/pull/2464), [#2465](https://github.com/mvschwarz/openrig/pull/2465), [#2411](https://github.com/mvschwarz/openrig/pull/2411), [#2413](https://github.com/mvschwarz/openrig/pull/2413), [#2412](https://github.com/mvschwarz/openrig/pull/2412))
|
||||
- **Also:**
|
||||
- transcript search scans a long line once instead of once per chunk, with the
|
||||
same results;
|
||||
- a Slack transport error has the bot token removed (see Known limits);
|
||||
- the hook-review check recognises a kernel Codex pane that runs under a shell;
|
||||
- the reference docs cover a Slack private-channel route, missed-message
|
||||
recovery, checked stops and slice status words, the spec scan, bundle hashes
|
||||
and the Pi selector path.
|
||||
([#2397](https://github.com/mvschwarz/openrig/pull/2397), [#2503](https://github.com/mvschwarz/openrig/pull/2503), [#2416](https://github.com/mvschwarz/openrig/pull/2416), [#2494](https://github.com/mvschwarz/openrig/pull/2494), [#2401](https://github.com/mvschwarz/openrig/pull/2401), [#2489](https://github.com/mvschwarz/openrig/pull/2489), [#2462](https://github.com/mvschwarz/openrig/pull/2462), [#2543](https://github.com/mvschwarz/openrig/pull/2543), [#2477](https://github.com/mvschwarz/openrig/pull/2477), [#2369](https://github.com/mvschwarz/openrig/pull/2369), [#2403](https://github.com/mvschwarz/openrig/pull/2403))
|
||||
|
||||
### Behaviour that changed
|
||||
|
||||
- **Stream intake is on by default** (above). `off` keeps 0.7.8's behaviour.
|
||||
- **Fresh Pi seats, and fresh Codex seats on 0.164.0 or later, get OpenRig's
|
||||
guidance outside the repository `AGENTS.md`:** Pi in its own agent folder
|
||||
(unless that file or folder is a link), Codex as launch developer
|
||||
instructions. Codex still uses the repository file for a resume, fork,
|
||||
restore or handover successor, an older or unknown version, or a config layer
|
||||
that sets `developer_instructions` or can't be read. If only your shell
|
||||
startup sets `CODEX_HOME`, OpenRig reads `~/.codex`, so a
|
||||
`developer_instructions` in your 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.
|
||||
- **`rig down`** removes OpenRig's own additions from a working folder once no
|
||||
live seat uses them.
|
||||
- **After upgrading, the Slack connector edits 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.
|
||||
- **A later answer to a settled ask** gets 409 `qitem_already_terminal` and
|
||||
records nothing.
|
||||
- **`rig send --verify`** reports unconfirmed and exits 1, not delivered, when it
|
||||
can't see what its Enter did.
|
||||
- **`rig export` with no `-o`** writes `rig-export.yaml` beside an existing
|
||||
`rig.yaml` instead of replacing it. ([#2356](https://github.com/mvschwarz/openrig/pull/2356))
|
||||
- **`rig workspace validate`** exits 1 for malformed optional frontmatter.
|
||||
([#2396](https://github.com/mvschwarz/openrig/pull/2396))
|
||||
- **An active rig name that matches more than one rig** makes `rig env down`,
|
||||
`status` and `logs` list the matching rig IDs and stop, and
|
||||
`rig doctor --spec` report conformance as unknown, instead of using the first
|
||||
match. ([#2548](https://github.com/mvschwarz/openrig/pull/2548), [#2550](https://github.com/mvschwarz/openrig/pull/2550))
|
||||
- **New default kernels** start in `<workspace.root>/kernel`. Existing kernels
|
||||
stay where they are; `docs/reference/agent-startup-guide.md` describes the
|
||||
optional move.
|
||||
|
||||
### Known limits
|
||||
|
||||
- **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, 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. ([#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** 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))
|
||||
- **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
|
||||
a re-parked ask, and recording a permit made when no one can be asked: a
|
||||
person's answer is recorded on the ask, and a default permit as the stream
|
||||
item above.
|
||||
- **The other 0.7.8 limits, and the 0.7.7 and 0.7.6 limits, below still apply.**
|
||||
|
||||
### Credits
|
||||
|
||||
Thanks to @porterchild, @Mohsintl, @Nikhi00718, @oodadoudou, @AdityaxDeore,
|
||||
@w4rc0n, @genius8267, @Aschenbath, @DeryFerd, @mohitdixit02, @Vaishnavi220506,
|
||||
@Nikhila-Dhevi-R, @natefaerber, @dajiaohuang, @Aummadour, @KM199 and @Totopo27
|
||||
for merged contributions, and to @todie and @seanmcn, whose fork work is carried
|
||||
in [#2439](https://github.com/mvschwarz/openrig/pull/2439) and [#2400](https://github.com/mvschwarz/openrig/pull/2400). Thanks to @ziadeh, @billbenetti, @eisenbergjerry,
|
||||
@ch-davidriott, @puniserv, @oneandonlydean, @sabnanikl-dev, @gifflen,
|
||||
@pvl-agent, @EZotoff, @adam-nectar, @bpamiri, @tgrundtvig, @aanestesia,
|
||||
@AdamWhitehurst and @eisenrigbot for reports carried into this release. Thanks
|
||||
also to @z4cc, @hitianqi, @jimallen, @m3ac-AllbrittenJ and @lkraider for reports
|
||||
and field feedback.
|
||||
|
||||
## [0.7.8]
|
||||
|
||||
**Not yet published.**
|
||||
Published 2026-11-05 as `@openrig/[email protected]`, built from the commit tagged
|
||||
`v0.7.8` (`12df562c`).
|
||||
|
||||
OpenRig 0.7.8 lets a person answer a runner seat's permission requests, with a
|
||||
conformance check and an example runner to build against. It adds a maintained
|
||||
way to run a Claude worker in an NVIDIA OpenShell sandbox, and includes 0.7.7's
|
||||
fixes plus repairs across seats, tmux, Slack and the TUI.
|
||||
conformance check and an example runner to build against. It includes 0.7.7's
|
||||
fixes plus repairs across seats, tmux, Slack and the TUI. It also includes a
|
||||
reference for running a Claude worker in an NVIDIA OpenShell sandbox, but that
|
||||
worker isn't usable in this release: see [the OpenShell reference](#the-openshell-reference-isnt-usable-in-078).
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
@@ -61,20 +770,15 @@ fixes plus repairs across seats, tmux, Slack and the TUI.
|
||||
- **A seat activity grant** lets a runner outside the host's hook setup report
|
||||
activity for one seat and launch, without the install-wide hook token. ([#2295](https://github.com/mvschwarz/openrig/pull/2295))
|
||||
|
||||
### Claude workers in OpenShell sandboxes
|
||||
### The OpenShell reference isn't usable in 0.7.8
|
||||
|
||||
- **`rig environment setup` installs a maintained reference** that binds a seat
|
||||
to one NVIDIA OpenShell sandbox (`rig environment create` or `attach`, then
|
||||
`rig up`).
|
||||
- **Context and results:** the seat's guidance, skills, plugins and startup
|
||||
files go into the sandbox, and verified JSON results come back through
|
||||
queue work.
|
||||
- **Stop and delete:** stopping the agent, stopping compute and deleting the
|
||||
sandbox are separate actions.
|
||||
- **Restore:** a recreated sandbox resumes the same conversation.
|
||||
|
||||
The steps are in `daemon/assets/openshell/README.md`.
|
||||
([#2296](https://github.com/mvschwarz/openrig/pull/2296), [#2300](https://github.com/mvschwarz/openrig/pull/2300))
|
||||
0.7.8 includes `rig environment` (`setup`, `create`, `attach`) and a reference
|
||||
for binding a seat to an NVIDIA OpenShell sandbox (`daemon/assets/openshell/README.md`).
|
||||
**In our release testing the worker never became usable:** after the sandbox was
|
||||
created and the native process started, `rig environment connect` failed when its
|
||||
check timed out after 5 seconds, so no team work ran. Don't rely on this path in
|
||||
0.7.8.
|
||||
([#2296](https://github.com/mvschwarz/openrig/pull/2296), [#2300](https://github.com/mvschwarz/openrig/pull/2300), [#2378](https://github.com/mvschwarz/openrig/pull/2378))
|
||||
|
||||
### Seats, delivery and the TUI
|
||||
|
||||
@@ -136,8 +840,13 @@ fixes plus repairs across seats, tmux, Slack and the TUI.
|
||||
|
||||
### 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."
|
||||
@@ -150,14 +859,13 @@ fixes plus repairs across seats, tmux, Slack and the TUI.
|
||||
- **The dsh example keeps one hold per seat:** "if dsh asks twice in one turn,
|
||||
settling one ask releases the hold while the other may still wait on a
|
||||
person."
|
||||
- **The OpenShell reference requires:**
|
||||
- NVIDIA OpenShell 0.1.6;
|
||||
- a Linux image;
|
||||
- an API-backed provider using `ANTHROPIC_API_KEY`. Subscription or OAuth
|
||||
credentials aren't supported.
|
||||
|
||||
- **The worker isn't usable** (above). The reference also requires NVIDIA
|
||||
OpenShell 0.1.6, a Linux image, and an API-backed provider using
|
||||
`ANTHROPIC_API_KEY`; subscription or OAuth credentials aren't supported.
|
||||
"Provider registration and image construction can incur costs." The worker's
|
||||
activity reads unknown, from pane sampling, never idle.
|
||||
- **The OpenShell README example uses `operator@kernel`;** the actual kernel
|
||||
operator is `operator-agent@kernel`.
|
||||
- **"Never grant the guest access to the daemon's main API port (normally
|
||||
7433)."** The guest-only listener is what confines a guest's reach. Its grant
|
||||
only authorizes requests for its own seat and launch.
|
||||
@@ -174,7 +882,8 @@ stand-in follows.
|
||||
|
||||
## [0.7.7]
|
||||
|
||||
**Not yet published.** 0.7.7 is a separate reliability patch for 0.7.6. Its three
|
||||
Published 2026-11-02 as `@openrig/[email protected]`, built from the commit tagged
|
||||
`v0.7.7` (`f3d8bac4`). 0.7.7 is a separate reliability patch for 0.7.6. Its three
|
||||
fixes are also in 0.7.8.
|
||||
|
||||
OpenRig 0.7.7 is 0.7.6 with three reliability fixes for managed Claude seats: an
|
||||
|
||||
@@ -26,9 +26,9 @@ use:
|
||||
|
||||
```sh
|
||||
# Claude Code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent claude-code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.10/skills/rigs --skill rigs --agent claude-code
|
||||
# Codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.10/skills/rigs --skill rigs --agent codex
|
||||
```
|
||||
|
||||
Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what you want built. The skill has your agent tell
|
||||
@@ -68,14 +68,14 @@ Requires Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude Co
|
||||
|
||||
### Install it yourself
|
||||
|
||||
This runs OpenRig's install script from the `v0.7.5` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
|
||||
This runs OpenRig's install script from the `v0.7.10` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.10/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.10/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
If a provider isn't signed in yet, the last step reports `FAILED [4/4]`, and `rig setup` lists the sign-in under "Some steps need attention". If the only remaining failures are provider sign-ins, the install steps finished; sign in to each selected provider as below and continue.
|
||||
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.
|
||||
|
||||
**Or step by step:**
|
||||
|
||||
|
||||
+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.6
|
||||
## Released in 0.7.8
|
||||
|
||||
[0.7.6 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6): follow a slice's work in one terminal workspace, plus steadier daemon start and delivery. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
|
||||
[0.7.8 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.8): it lets a person answer a runner seat's permission requests, with a conformance check and an example runner to build against. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
|
||||
|
||||
## How to help
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ resolve → project → deliver pre-launch files → persist replay context →
|
||||
## 2. Execution and projection types
|
||||
|
||||
The member's optional `claude_mcp: none` is normalized as `claudeMcp` and
|
||||
persisted independently in `nodes.claude_mcp` (migration 141). The
|
||||
persisted independently in `nodes.claude_mcp` (migration 142). The
|
||||
expansion HTTP route preserves both `claude_mcp` and `claudeMcp`, including
|
||||
invalid values for the canonical validator to reject. An authored relaunch
|
||||
updates the seat choice after launch eligibility and startup-source resolution;
|
||||
|
||||
@@ -461,6 +461,13 @@ in the current update window. Current-update coverage is independent of the
|
||||
receipt window, so an unavailable current reader cannot retain closed delivery
|
||||
history beyond a successful history read. Both reads leave queue state intact.
|
||||
|
||||
Clients that need only current FYIs can use `GET /api/queue/open-human-updates`.
|
||||
It returns `{items, limit, truncated}` with open human-addressed updates, ordered
|
||||
critical then urgent then routine, without requiring a delivery receipt. `limit`
|
||||
defaults to 20 and accepts integers from 1 to 100. Optional `view` follows the
|
||||
same person perspective as `/api/attention`; a perspective read scans at most
|
||||
1000 rows and signals `truncated` when the window may omit matching items.
|
||||
|
||||
- `/api/stream` (`server.ts:783`) — `POST /emit` (`routes/stream.ts:56`),
|
||||
`GET /list` (`:91`, including `sourceSession`, `hintDestination`, `hintTag`,
|
||||
`since`, `until` and `direction` filters), `GET /watch` + `/sse` SSE
|
||||
|
||||
@@ -25,7 +25,7 @@ below `rig` (including groups and the hidden `restore apply` command), and
|
||||
**1,304 explicitly registered option objects**, including the root version
|
||||
option. Aliases do not add command objects; short/long spellings of one
|
||||
option do not add option objects. Commander-generated help is additional.
|
||||
These are counts for that source snapshot, not a claim about a deployed release. The provider-switch and set-runtime rows include the later account-home options; those additions are not included in the snapshot counts.
|
||||
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.
|
||||
|
||||
## Reading this reference
|
||||
|
||||
@@ -350,10 +350,19 @@ the sections that were not read are `null`.
|
||||
| `rig host add` | — | `--id <id>` **required**<br>`--transport <transport>` **required**<br>`--target <target>`<br>`--user <user>`<br>`--url <url>`<br>`--bearer-env <name>`<br>`--bearer-file <path>`<br>`--notes <text>`<br>`--json` |
|
||||
| `rig host select <id>` | — | `--json` |
|
||||
| `rig host rename <name>` | — | `--json` |
|
||||
| `rig host edit <id>` | — | `--target <target>`<br>`--user <user>`<br>`--url <url>`<br>`--notes <text>`<br>`--json` |
|
||||
| `rig host remove <id>` | — | `--json` |
|
||||
| `rig host pair <url>` | — | `--id <id>`<br>`--timeout <seconds>`<br>`--human <address>`<br>`--json` |
|
||||
| `rig host list` | `ls` | `--json` |
|
||||
| `rig host doctor <id>` | — | `--posture <profile>`<br>`--public-addr <ip>`<br>`--json` |
|
||||
|
||||
`rig host pair` needs the target daemon to run with `OPENRIG_AUTH_BEARER_TOKEN` set,
|
||||
even when it is bound to a tailnet address and so starts without one: pairing hands
|
||||
over that token. Without it, the target's `POST /api/hosts/pair-request` answers 409
|
||||
`pair_target_no_bearer`; it answers 409 `pair_human_required` when no single
|
||||
registered person can approve (none, or several with no default; name one with
|
||||
`--human`) (`packages/daemon/src/routes/hosts.ts:167-186`).
|
||||
|
||||
Registration makes a host visible in topology; remote FEED attention remains opt-in.
|
||||
The human-readable success output of `host add` and `host pair` now includes the FEED
|
||||
next step alongside the existing `host doctor` suggestion. For an HTTP entry named
|
||||
@@ -377,7 +386,7 @@ do not establish connectivity to a live peer.
|
||||
|---|---|---|
|
||||
| `rig ps` | — | `--json`<br>`--no-cleanup`<br>`--resources`<br>`--nodes`<br>`--full`<br>`--verbose`<br>`--limit <n>`<br>`--fields <list>`<br>`--summary`<br>`--filter <key=value>`<br>`--active`<br>`--running`<br>`-A, --all-rigs`<br>`--rig <name>`<br>`--session <name>`<br>`--include-archived`<br>`--host <id>`<br>`--all-hosts`<br>`--hosts <ids>` |
|
||||
|
||||
- `--nodes --json` — each seat carries the daemon's served `needsAttention` flag; the CLI doesn't re-derive it, and it is absent when an older daemon doesn't serve it. `arbitratedNeedsInput` and `needsInputEvidence` (`{ rung, observedAt }` or null) come with `--full`, or by name through `--fields`.
|
||||
- `--nodes --json` — each seat carries the daemon's served `needsAttention` flag; the CLI doesn't re-derive it, and it is absent when an older daemon doesn't serve it. `arbitratedNeedsInput` and `needsInputEvidence` (`{ rung, observedAt }` or null) come with `--full`, or by name through `--fields`. `startedWithoutRequired` lists the required startup files a handover's successor started without, and the compact read keeps it.
|
||||
|
||||
### roster
|
||||
|
||||
@@ -408,6 +417,17 @@ do not establish connectivity to a live peer.
|
||||
| `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 |
|
||||
@@ -453,6 +473,11 @@ do not establish connectivity to a live peer.
|
||||
| `rig stream read` | — | `--limit <n>`<br>`--reader <session>`<br>`--json` |
|
||||
| `rig stream act <streamItemId>` | — | `--work <qitemId>`<br>`--decline <reason>`<br>`--duplicate <id>`<br>`--reader <session>`<br>`--json` |
|
||||
|
||||
Stream intake: the `stream.intake` setting (default `queue.worker`, or a canonical `<seat>@<rig>`) receives each emitted item.
|
||||
It opens an intake row for that seat, which wakes it, or adds a note to that seat's intake row still pending; a `critical`
|
||||
or interrupting item makes the row urgent. `rig config set stream.intake off` keeps emits passive. A runner's
|
||||
default-permit record (tag `runner-permission-permit`) opens no row.
|
||||
|
||||
### queue
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
@@ -461,7 +486,7 @@ do not establish connectivity to a live peer.
|
||||
| `rig queue create` | — | `--source <session>`<br>`--destination <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--body-context <ref>`<br>`--mission <id>`<br>`--slice <id>`<br>`--gate <role>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--expires-at <iso>`<br>`--id <qitemId>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--human-intent <intent>`<br>`--alert`<br>`--human-detail-file <path>`<br>`--reply-to <qitemId>`<br>`--human-questions-file <path>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--verify`<br>`--full`<br>`--json` |
|
||||
| `rig queue claim <qitemId>` | — | `--destination <session>`<br>`--full`<br>`--json` |
|
||||
| `rig queue unclaim <qitemId>` | — | `--destination <session>`<br>`--reason <text>`<br>`--full`<br>`--json` |
|
||||
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--full`<br>`--json` |
|
||||
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--closure-required-at <iso>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--full`<br>`--json` |
|
||||
| `rig queue block <qitemId>` | — | `--on <blocker>` **required**<br>`--actor <session>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--continuation <text>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--full`<br>`--json` |
|
||||
| `rig queue resolve <qitemId>` | — | `--decision <text>` **required**<br>`--actor <session>`<br>`--bearer <token>`<br>`--no-notify`<br>`--json` |
|
||||
| `rig queue handoff <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--alert`<br>`--host <id>`<br>`--no-nudge`<br>`--full`<br>`--json` |
|
||||
@@ -541,6 +566,7 @@ do not establish connectivity to a live peer.
|
||||
| `rig terminal open <view>` | — | `--provider <name>`<br>`--session <name>`<br>`--window`<br>`--expected-plan <id>`<br>`--json` |
|
||||
| `rig terminal views` | — | `--json` |
|
||||
| `rig terminal status` | — | `--provider <name>`<br>`--session <name>`<br>`--json` |
|
||||
| `rig terminal attach <session>` | — | `--read-only` |
|
||||
|
||||
See [named terminal sessions](../reference/terminal-sessions.md) to select a
|
||||
Herdr session per request, including session-bound previews and window reuse.
|
||||
@@ -628,7 +654,7 @@ Only Claude and Codex tenures can be woken.
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig specs` | — | — |
|
||||
| `rig specs ls` | — | `--kind <kind>`<br>`--json` |
|
||||
| `rig specs ls` | — | `--kind <kind>`<br>`--include-builtins`<br>`--json` |
|
||||
| `rig specs show <name-or-id>` | — | `--kind <kind>`<br>`--json` |
|
||||
| `rig specs preview <name-or-id>` | — | `--kind <kind>`<br>`--json` |
|
||||
| `rig specs add <path>` | — | `--json` |
|
||||
@@ -697,6 +723,24 @@ Only Claude and Codex tenures can be woken.
|
||||
| `rig community-harness` | — | — |
|
||||
| `rig community-harness list` | — | `--json` |
|
||||
|
||||
### environment
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig environment` | — | — |
|
||||
| `rig environment setup` | — | `--directory <path>` **required**<br>`--binding <id>`<br>`--seat <address>`<br>`--rig <name>`<br>`--mcp-url <url>`<br>`--guest-base-url <origin>`<br>`--artifact-root <path>`<br>`--json` |
|
||||
| `rig environment create <binding>` | — | `--seat <address>` **required**<br>`--node <id>`<br>`--gateway <ref>` **required**<br>`--workspace <ref>` **required**<br>`--name <name>` **required**<br>`--image <digest>` **required**<br>`--provider <ref>` **required**<br>`--policy <path>` **required**<br>`--guest-base-url <origin>` **required**<br>`--operation <id>`<br>`--json` |
|
||||
| `rig environment attach <binding>` | — | `--seat <address>` **required**<br>`--node <id>`<br>`--gateway <ref>` **required**<br>`--workspace <ref>` **required**<br>`--name <name>` **required**<br>`--image <digest>` **required**<br>`--provider <ref>` **required**<br>`--policy <path>` **required**<br>`--guest-base-url <origin>` **required**<br>`--operation <id>`<br>`--json`<br>`--resource-id <id>` **required**<br>`--fingerprint <value>` |
|
||||
| `rig environment inspect <binding>` | — | `--live`<br>`--json` |
|
||||
| `rig environment connect <binding>` | — | `--generation <id>` **required** |
|
||||
| `rig environment export <binding>` | — | `--request <path>` **required**<br>`--json` |
|
||||
| `rig environment checkpoint <binding>` | — | `--generation <id>` **required**<br>`--operation <id>` **required**<br>`--json` |
|
||||
| `rig environment recreate <binding>` | — | `--request <path>` **required**<br>`--json` |
|
||||
| `rig environment completion <binding>` | — | `--request <path>` **required**<br>`--json` |
|
||||
| `rig environment effect <binding>` | — | `--effect <name>` **required**<br>`--generation <id>`<br>`--resource-id <id>`<br>`--operation <id>` **required**<br>`--engine-socket <path>`<br>`--json` |
|
||||
|
||||
It drives the maintained OpenShell reference; see the [OpenShell example](../reference/community-harness-examples/openshell/README.md).
|
||||
|
||||
### fork
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|
||||
@@ -540,7 +540,7 @@ Before you start, two honest constraints:
|
||||
| registry | `host` | add/list/select/rename isolated entries that resolve to fixture endpoints. Pairing needs two container daemons. |
|
||||
| coordination | `parked` | An owed item with a stopped worker is reported. A held worker with a live wake is distinguished. |
|
||||
| coordination | `stream` | emit/list/show/archive an exact item. A restart and a bounded watch keep the order with no duplicates. |
|
||||
| coordination | `queue` | (partial) Next: handoff, block, resolve and unclaim, each with exact items and destinations. |
|
||||
| coordination | `queue` | (partial) handoff → successor owned by `--to` and source asserted closed (`handed-off`), block → parked blocked, resolve → unpark to in-progress (owner nudge suppressed), unclaim → pending with destination kept — each with exact items and destinations asserted via `expect queue` (`rig queue list --json --full --all`) in `queue-transitions-reach-exact-states.yaml`; baton restart coverage stays in `queue-baton-survives-restart.yaml`. |
|
||||
| coordination | `project` | Acquire a lease, classify one item twice, read one projection. The wrong holder cannot act. |
|
||||
| coordination | `view` | Register a view over known items. list/show return the exact filtered result after a mutation. |
|
||||
| coordination | `watchdog` | Register one short wake, see one response, stop it, prove no further deliveries. |
|
||||
|
||||
@@ -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,41 @@ 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.
|
||||
@@ -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
|
||||
@@ -373,7 +378,7 @@ version.
|
||||
| `skills` | `id`, `path` | Skill directories containing a SKILL.md. Projected by the runtime adapter. Claude and Codex seats get them in the seat's own storage, `<OpenRig home>/state/seats/<rig id>/<logical seat>/<runtime>/`, outside the project: Claude to `plugin/skills/<id>` in the seat's generated plugin, where they show as `openrig:<id>`, and Codex to `skills/<id>` beside the seat's skill catalog. A Claude or Codex seat without that storage gets `<cwd>/.claude/skills/<id>` or `<cwd>/.agents/skills/<id>`. Pi and OMP project to the seat's own agent directory under OpenRig's state. |
|
||||
| `guidance` | `id`, `path`, `target`*, `merge`* | Guidance files, merged as a managed block (see "Guidance Resources"). |
|
||||
| `subagents` | `id`, `path` | Subagent definitions. Claude copies them to `<cwd>/.claude/agents/`, Codex to `<cwd>/.agents/`; Pi and OMP don't project them. |
|
||||
| `plugins` | `id`, `source`, `plugin_type`* | Local plugin directories; selected with `uses.plugins`. `source.kind` must be `local`. `plugin_type` is `claude`, `codex` or `auto` (default): Claude projects a plugin to `<cwd>/.claude/plugins/<id>` when it is `claude`, or `auto` with a `.claude-plugin/plugin.json`; Codex projects to `<cwd>/.codex/plugins/<id>` when it is `codex`, or `auto` with a `.codex-plugin/plugin.json`. Pi and OMP don't project plugins. 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
|
||||
@@ -440,7 +455,7 @@ guidance:
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `id` | string | yes | — | Resource identifier. A `rig-role` guidance resource is never merged; deliver a role with a `send_text` startup file. |
|
||||
| `path` | string | yes | — | Relative path to the guidance file. |
|
||||
| `target` | string | no | — | Parsed but not used: the runtime decides the file. Claude merges into the rig's `managed_blocks` file (`CLAUDE.md` by default, or `CLAUDE.local.md`); Codex, Pi and OMP merge into `<cwd>/AGENTS.md`. |
|
||||
| `target` | string | no | — | Parsed but not used: the runtime decides the file. Claude merges into the rig's `managed_blocks` file (`CLAUDE.md` by default, or `CLAUDE.local.md`); Codex, Pi and OMP merge into `<cwd>/AGENTS.md`, except that a fresh Pi seat normally uses its own agent folder's `AGENTS.md` and a fresh Codex 0.164.0+ seat normally gets launch developer instructions; the conditions are in [Codex guidance at launch](agent-startup-guide.md#codex-guidance-at-launch) and [Runtime Differences](agent-startup-guide.md#runtime-differences). |
|
||||
| `merge` | string | no | `managed_block` | Only `managed_block` is applied. Any other value, such as `append`, merges nothing. |
|
||||
|
||||
---
|
||||
|
||||
@@ -214,7 +214,7 @@ After the files, OpenRig sends a session-identity message (rig, pod, member, log
|
||||
| Delivery Hint | When | How | Use For |
|
||||
|---------------|------|-----|---------|
|
||||
| `auto` | Depends on the file | Resolved per file: a `SKILL.md` (or content starting `# SKILL`) is `skill_install`, any other `.md` is `guidance_merge`, and any other file is `send_text` | Default — let OpenRig decide |
|
||||
| `guidance_merge` | Before harness boot | Merged as a managed block into `AGENTS.md` (Codex, Pi) or `CLAUDE.md` (Claude; `CLAUDE.local.md` with the rig's `managed_blocks: { claude-code: CLAUDE.local.md }`) | Role guidance, culture, project context |
|
||||
| `guidance_merge` | Before harness boot | Merged as a managed block into the repository `AGENTS.md` (Codex, OMP), a fresh Pi seat's own agent-directory `AGENTS.md` (see [Runtime Differences](#runtime-differences)), or `CLAUDE.md` (Claude; `CLAUDE.local.md` with the rig's `managed_blocks: { claude-code: CLAUDE.local.md }`). A fresh launch of Codex 0.164.0 or later can carry it as launch developer instructions instead (see [Codex guidance at launch](#codex-guidance-at-launch)) | Role guidance, culture, project context |
|
||||
| `skill_install` | Before harness boot | Copied to runtime's skill directory | Skills |
|
||||
| `send_text` | After harness is ready | Sent as text to agent's terminal via tmux | Boot-time grounding, identity hints, instructions to read skills |
|
||||
|
||||
@@ -367,7 +367,15 @@ Each startup file and action specifies when it applies:
|
||||
- `restore` — delivered when a seat is restored without its earlier conversation: a restore whose resume falls back to a fresh conversation, or a retried start
|
||||
- Default: `[fresh_start, restore]` (both)
|
||||
|
||||
**An exact resume delivers neither.** When a restore reopens the seat's saved conversation (`rig up <rig> --existing` with a resume token), OpenRig types no startup actions and delivers no startup files, `restore` ones included: the conversation already has them, and typing them again would add turns to it. The one exception: a managed guidance block missing from the guidance file is put back before the harness starts.
|
||||
**An exact resume delivers neither.** When a restore reopens the seat's saved conversation (`rig up <rig> --existing` with a resume token), OpenRig types no startup actions and delivers no startup files, `restore` ones included: the conversation already has them, and typing them again would add turns to it. The one exception: the guidance file's managed blocks are brought up to date before the harness starts. A missing block is put back, and a stale one is refreshed (see below).
|
||||
|
||||
**A seat handover does the same put-back.** Before a fresh, fork or rebuild successor starts, the handover puts the seat's missing managed guidance blocks back in its guidance file from the saved startup context. That's the rig's Claude file (`CLAUDE.md`, or `CLAUDE.local.md` when the rig uses it) for Claude, and `AGENTS.md` for Codex, Pi and omp. So a seat rebuilt into a new working directory keeps its guidance.
|
||||
|
||||
**A block you edited by hand is never overwritten.** OpenRig records what it last wrote in each managed block, so a launch, an exact resume, a handover or a runtime switch can refresh a block whose source file changed since. The record follows the file itself, so two links to one file share it. A block you've edited since OpenRig wrote it is left as you wrote it. The start's result names it (`guidance_block_edited`), and the seat is told in one line once it's ready, or in a handover's packet. A block OpenRig has no record of writing (any block written before these records were kept) that differs from its source works like this:
|
||||
- a launch replaces it, as launches always did, and names it (`guidance_block_refreshed_unrecorded`);
|
||||
- an exact resume or a handover leaves it, and names it as possibly stale (`guidance_block_may_be_stale`).
|
||||
|
||||
An exact resume doesn't type anything into the resumed conversation. So there, the restore result names the block (`guidanceNotes`), but the seat isn't told.
|
||||
|
||||
Use this to avoid re-sending context that a fresh conversation doesn't need twice. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both, so a seat that comes back fresh gets it again.
|
||||
|
||||
@@ -528,6 +536,58 @@ This separates provider files, not OS users or accounts. Project-local resources
|
||||
still use the selected project directory; shared project and system settings
|
||||
remain shared.
|
||||
|
||||
### Codex guidance at launch
|
||||
|
||||
On a fresh launch of Codex 0.164.0 or later, OpenRig passes a seat's managed guidance (OpenRig's start file and the
|
||||
rig's and project's context, culture and SOP files) with the launch, as `-c developer_instructions=…`, instead of
|
||||
merging it into the repository `AGENTS.md`. Neither the tracked `AGENTS.md` nor the Codex home is written for it, and
|
||||
both still load as usual. The text opens: "OpenRig seat guidance for this session follows. Where it conflicts with
|
||||
AGENTS.md guidance in the repository or the Codex home, follow AGENTS.md."
|
||||
|
||||
A launch value replaces a configured one, so this happens only when no Codex config layer OpenRig reads sets
|
||||
`developer_instructions`. OpenRig reads:
|
||||
|
||||
- `/etc/codex/config.toml` and `/etc/codex/managed_config.toml`;
|
||||
- the Codex home's `config.toml`, profiles included, and the selected profile's `<profile>.config.toml`;
|
||||
- each `.codex/config.toml` from the seat's working directory up.
|
||||
|
||||
The Codex home it reads is the seat's own (a `CODEX_HOME` in its `env:`, or a recorded account switch), else the
|
||||
daemon's `CODEX_HOME`, else `~/.codex`.
|
||||
|
||||
The guidance still goes to the repository `AGENTS.md`, as before, when:
|
||||
|
||||
- a layer OpenRig reads sets `developer_instructions`, or can't be read. A launch warning names the file;
|
||||
- the launch resumes or forks a conversation. Developer instructions on a resume haven't been tested, and an exact
|
||||
resume puts the guidance back in the repository `AGENTS.md`;
|
||||
- the seat is restored, even when the restore starts a new conversation. A restore keeps the guidance file its launch
|
||||
recorded, which for Codex is the repository `AGENTS.md`;
|
||||
- a handover successor starts (fresh, fork or rebuild). Its launch carries no guidance of its own, so the handover
|
||||
first puts the seat's missing blocks back in the repository `AGENTS.md` from its saved startup context;
|
||||
- Codex is older than 0.164.0, or its version is unknown.
|
||||
|
||||
A seat's role still arrives as startup text, and the seat-skills line, after the person's yes, still goes to
|
||||
`AGENTS.md`.
|
||||
|
||||
A launch that carries developer instructions writes no new blocks to the repository `AGENTS.md`. But blocks an earlier
|
||||
handover or resume left there are refreshed from their sources, by the same rule as every other managed block. That
|
||||
matters because the launch text asks Codex to follow `AGENTS.md` where the two differ.
|
||||
|
||||
**If your shell sets `CODEX_HOME`.** When neither the seat nor the daemon declares a `CODEX_HOME`, a seat's pane keeps
|
||||
its own, and OpenRig reads `~/.codex`. A `CODEX_HOME` that only your shell startup sets (in `~/.zshrc`, for example),
|
||||
with the daemon started without it, isn't seen. A `developer_instructions` set in that home is then replaced by the
|
||||
launch value for the session. To have it checked, declare that same absolute path as `CODEX_HOME` in the member's or
|
||||
rig's `env:` (see [Launch environment](rig-spec.md#launch-environment), "A seat's own account"). OpenRig then reads
|
||||
that home at the seat's next fresh launch, and a `developer_instructions` set there keeps the repository `AGENTS.md`.
|
||||
The seat keeps the same home and login.
|
||||
|
||||
**What it doesn't cover.** OpenRig doesn't read Codex's packaged defaults, or MDM- or enterprise-managed settings. A
|
||||
`developer_instructions` set there may be replaced for the session. Legacy MDM-managed config ranks above launch flags,
|
||||
so it can override OpenRig's guidance, and the seat then gets none of it.
|
||||
|
||||
**Not established.** The order Codex assembles developer instructions and `AGENTS.md` in, and how it settles every
|
||||
conflict, aren't established; the opening sentence asks it to follow `AGENTS.md`. The native behaviour was observed on
|
||||
Codex 0.164.0 alone, on fresh launches without tools.
|
||||
|
||||
### Runtime Differences
|
||||
|
||||
**Claude Code:**
|
||||
@@ -558,7 +618,10 @@ remain shared.
|
||||
- Can self-install dependencies from instructions but timer/recurring behavior is not reliably available
|
||||
|
||||
**Pi and OMP:**
|
||||
- Read `AGENTS.md` in the working directory; guidance is merged there, added to Pi's own system prompt
|
||||
- Read `AGENTS.md` in the working directory, added to Pi's own system prompt. Pi also reads the one in its agent
|
||||
directory: on a fresh launch, a Pi seat's guidance is merged there, under OpenRig's state, not into the
|
||||
repository's. When that file or its directory is a link, the repository `AGENTS.md` is used instead. A restore keeps
|
||||
the file the seat launched with. OMP keeps its guidance in the repository `AGENTS.md`
|
||||
- Selected skills are projected into the seat's own agent directory under OpenRig's state, not into a shared
|
||||
workspace folder
|
||||
|
||||
@@ -672,6 +735,29 @@ When authoring startup content, note which instructions are runtime-specific. Fo
|
||||
|
||||
---
|
||||
|
||||
## Files OpenRig writes into a project
|
||||
|
||||
These are the files OpenRig writes into a seat's working directory, and what `rig down` does with each. Paths are
|
||||
relative to that directory.
|
||||
|
||||
| Path | When OpenRig writes it | What `rig down` does |
|
||||
| --- | --- | --- |
|
||||
| Managed blocks in `CLAUDE.md` (or the rig's `managed_blocks` file, such as `CLAUDE.local.md`) or `AGENTS.md` | Each launch merges the seat's guidance blocks. A fresh Codex launch that takes developer instructions puts them in its launch text instead: in `AGENTS.md` it adds only the seat-skills line, after your yes, and refreshes blocks already there. A fresh Pi seat writes its own agent-directory `AGENTS.md` outside the project, except when that folder or file is a link: then its guidance stays in the repository `AGENTS.md`. | Removes OpenRig's blocks, current or legacy, and keeps everything else. When nothing else is left, it deletes the file if the file began with an OpenRig block, or held only blocks in an older format. A block-only file isn't proof that OpenRig created it. The seat-skills line is kept if you edited it. |
|
||||
| `.openrig/context-collector.cjs` | Claude seats, at launch. | Removes it while its bytes are still the shipped collector's; otherwise keeps it and says so. |
|
||||
| `.claude/settings.local.json` | Claude seats, at launch: OpenRig's status line and its activity hooks are merged in, separately. Your own status line is never replaced, but the activity hooks are still merged beside it. A file that doesn't parse gets neither. | Removes OpenRig's status line and hooks and keeps the file, even if `{}` is all that's left. |
|
||||
| `.claude/settings.local.json` fragments and `.mcp.json` | When a profile or spec declares `claude_settings_fragment` or `claude_mcp_fragment`. | Leaves the merged content in place. |
|
||||
| `.claude/agents/`, `.claude/plugins/<id>/`, `.claude/extensions/<id>/`; `.agents/<id>.yaml`, `.codex/plugins/<id>/`, `.agents/extensions/<id>/` | When the seat's projection includes subagents, plugins or runtime resources (Claude, then Codex). | Leaves them in place. |
|
||||
| `.claude/skills/<id>/`, `.agents/skills/<id>/` | Only for a seat without seat storage. New Claude and Codex seats keep their skills under `$OPENRIG_HOME/state/seats/`, outside the project. | Leaves them in place. |
|
||||
| The managed compaction preparation folder described in [Claude compaction preparation directory](#claude-compaction-preparation-directory), with its `.gitignore` of `*` and each attempt's `RESTORE-MAP.md` | Managed Claude compaction, unless a preparation root is configured elsewhere. | Leaves it in place. |
|
||||
| `.rigged-checkpoint.md` | A restore that didn't resume a seat's native conversation (by choice or not) and had a checkpoint to hand it. | Leaves it in place. |
|
||||
|
||||
`rig down` leaves a file alone while another seat still uses it: a running seat of the same rig, or any seat of
|
||||
another rig that isn't archived. That is decided file by file. A guidance file is kept while another seat's
|
||||
guidance targets the same file. The Claude settings and collector are kept while another Claude seat works in that
|
||||
directory. So another rig's Codex seat keeps `AGENTS.md` without keeping `CLAUDE.md` or the Claude settings. It
|
||||
removes `.openrig/` and `.claude/` only when they are empty.
|
||||
`rig down --delete` does nothing more to project files. OpenRig doesn't edit your project's own `.gitignore`.
|
||||
|
||||
## Patterns and Anti-Patterns
|
||||
|
||||
### Good Patterns
|
||||
@@ -741,6 +827,32 @@ rig uses it, so it stays until then. To remove it sooner, by hand:
|
||||
3. Keep a copy of the file, remove only those lines, and compare the result with
|
||||
the copy: nothing else in the file should differ.
|
||||
|
||||
**The kernel's own folder.** A newly created default kernel works in
|
||||
`<workspace.root>/kernel`, beside your projects (`<workspace.root>/projects` by
|
||||
default) rather than above them. Claude reads `CLAUDE.md` from a seat's folder
|
||||
and from every folder above it, so a kernel working in the workspace root put its
|
||||
guidance into every Claude project seat below it. If that folder can't be made,
|
||||
the new kernel works in the workspace root, as before.
|
||||
|
||||
A kernel that already exists keeps its folders, usually `workspace.root`.
|
||||
Upgrading moves no seat, changes no restored conversation and removes no file.
|
||||
To move an existing kernel yourself, if you want to:
|
||||
|
||||
1. Save the new folder for each kernel seat with `rig seat set-cwd <seat> --cwd
|
||||
<workspace.root>/kernel --reason <text>`, using the absolute path. It creates
|
||||
the folder ([details](#change-an-existing-seats-working-directory)).
|
||||
2. Choose when each seat adopts it. A seat keeps its current folder until it
|
||||
starts again, and the new start doesn't continue its current native
|
||||
conversation: `rig seat launch <seat> --fresh --stop --reason <text>` starts a
|
||||
blank one, and `rig handover <seat> --source rebuild --reason <text>` carries
|
||||
durable context into a new one. Nothing does this for you.
|
||||
3. Tidy the old `<workspace.root>/CLAUDE.md` (or `AGENTS.md`) by hand, once no
|
||||
running rig's seat still works in that folder: a seat there still reads those
|
||||
blocks, and `rig down` checks the same before it removes them. Remove only
|
||||
OpenRig's managed blocks, each from its `<!-- BEGIN OpenRig MANAGED BLOCK: … -->`
|
||||
line through its `END` line, as in the steps above, and keep everything else,
|
||||
including your own text. Don't move or delete the whole file.
|
||||
|
||||
**Separate project context from role**
|
||||
|
||||
Don't put project documentation inside the role guidance. The role is about the agent's function; project context is about what the agent is working on. Use rig-level or pod-level startup files for project context.
|
||||
|
||||
@@ -23,7 +23,10 @@ Only the activity axis is served today. The session and resumability values are
|
||||
`activity-taxonomy.ts` but no surface reads them: `rig ps --nodes --full` shows the raw session
|
||||
STATUS, both node views show a LIFECYCLE of `running`, `detached`, `recoverable` or `attention_required` (`run`, `det`, `rec`,
|
||||
`att`), and node inventory serves `occupantLifecycle` (`active`, `retiring`, `retired`,
|
||||
`context_walled`, `compacted`, `crashed`, `unknown`).
|
||||
`context_walled`, `compacted`, `crashed`, `unknown`). Node inventory's `sessionStatus` is the
|
||||
recorded session status, with two observed corrections: a running session whose pane is verified
|
||||
to have exited (identity reason `pane_exited`) reads `exited`, with `startupStatus:
|
||||
"attention_required"`, and a running session confirmed missing reads `detached`.
|
||||
|
||||
The axes never blend. Reachability is strict; revival optimism is a different field
|
||||
(omnigent's strict-liveness split). `unknown` is a first-class honest value — the oracle
|
||||
@@ -59,6 +62,21 @@ the existing live-hook fallback in effect; lifecycle, startup, held and error si
|
||||
can independently raise attention. A false attention flag means no known trigger, not
|
||||
proof that an unobserved input box is clear.
|
||||
|
||||
A handover whose successor started without a required file also raises attention. That's
|
||||
a projected guidance file or a guidance startup file marked `required` still missing from
|
||||
the guidance file, or, for a fresh or rebuild successor, a required `send_text` startup
|
||||
file that's missing. The seat serves `startedWithoutRequired` with each file's path, the
|
||||
table's reason column names each file, and the handover's own output says it's degraded.
|
||||
The mark belongs to that successor's session, so the seat's next launch or handover
|
||||
clears it. Time alone never does. Repairing the file in place doesn't clear it either,
|
||||
because no route delivers guidance into a running conversation.
|
||||
|
||||
A fresh or rebuild successor's first turn opens with the seat's `send_text` startup files,
|
||||
the ones a fresh start delivers, as absolute paths with the role first. A pointer asks the
|
||||
successor to read the file; it isn't the pasted text a start sends. A fork successor
|
||||
carries its parent's conversation and gets no list. `send_text` startup actions, such as
|
||||
an `after_ready` preload, aren't run for a successor.
|
||||
|
||||
The compact human table's ATTN column renders `yes`, `no`, or `?` when the flag is
|
||||
unavailable. Compact JSON retains `needsAttention`; use `--full` or
|
||||
`--fields needsAttention,arbitratedNeedsInput,needsInputEvidence` with `--json` for the
|
||||
@@ -116,7 +134,10 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
|
||||
PER-HARNESS when that harness's hook rung earns trust through a measured agreement
|
||||
window in production — never by fixture pass alone (symmetric admission: a fixture
|
||||
pass admits a rung to TRIAL, where it is measured but not consulted; production agreement
|
||||
promotes it). Undeclared and partial-coverage rungs are identity-only, which is also where
|
||||
promotes it). For Codex, this fallback also accepts a fresh working status adjacent to
|
||||
the composer, or an advancing counter across two fresh captures when output separates
|
||||
them. This positive evidence expires after five seconds; unrecognized, frozen, old or
|
||||
pre-occupant frames add nothing. It uses the existing pane capture. Undeclared and partial-coverage rungs are identity-only, which is also where
|
||||
a degraded rung goes, with one declared exception: for a pi or omp seat or a community
|
||||
harness, sampling is positive-only: a changing pane reads working, and a quiet one never reads idle on its
|
||||
own, since the pane can be quiet mid-turn.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Integration author contracts — DRAFT, published with 0.7.6
|
||||
# Integration author contracts — DRAFT, first published with 0.7.6
|
||||
|
||||
OpenRig composes agents into a team. An integration author supplies the pieces that let a new harness participate, or let an existing harness run in a new environment. This guide divides that work into two contracts:
|
||||
|
||||
@@ -9,16 +9,16 @@ These are draft author responsibilities. Some use implemented OpenRig interfaces
|
||||
|
||||
## Release and support status
|
||||
|
||||
This draft was published with **OpenRig 0.7.6** (tag [`v0.7.6`](https://github.com/mvschwarz/openrig/tree/v0.7.6), source `c00c6321`). Its implemented-interface baseline is commit [`b6bd4633`](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63). That base includes the runner declaration and `passes_model` changes. The published 0.7.6 adds release repairs and documentation on top of that base. References below use the immutable base instead of a moving branch.
|
||||
This draft was published with **OpenRig 0.7.6** (tag [`v0.7.6`](https://github.com/mvschwarz/openrig/tree/v0.7.6), source `c00c6321`). Its implemented-interface baseline is commit [`b6bd4633`](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63). That base includes the runner declaration and `passes_model` changes. The published 0.7.6 adds release repairs and documentation on top of that base. **OpenRig 0.7.8** adds seat activity grants, OpenRig's side of the answered permission path and the maintained OpenShell reference driver (`rig environment`); the rows below that say 0.7.8 describe it. References below use the immutable base instead of a moving branch, except the community preset reference, which is the in-tree copy.
|
||||
|
||||
| Area | Status at this baseline | What the status means |
|
||||
| Area | Status | What the status means |
|
||||
|---|---|---|
|
||||
| Community launcher presets, RigSpec members and AgentSpec role/startup declarations | Implemented interfaces | They compose configuration. A launcher must implement its declared effects; successful parsing does not prove delivery or native behavior. |
|
||||
| `runner: true` with `launch_result: true` | Released in 0.7.6 | The declaration is retained at launch, and Core uses the runner identity/activity path. It does not supply a runner executable. |
|
||||
| `passes_model: true` | Released in 0.7.6 | Passes the declared model through `OPENRIG_MODEL`. It does not observe or verify the effective native model. Cursor and Hermes examples leave it off pending native qualification. |
|
||||
| The dsh/ACP input, cancellation and answered permission recipe (a person answers, and the default is permit) | Proposed author implementation | No end-to-end dsh integration is established. 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 | Measured in a private wrapper; generic seam proposed | The platform chapter makes the responsibilities reusable. Core does not provide a generic platform driver or `DeploySpec` loader at this baseline. |
|
||||
| Environment preparation, projection, artifact export and owned-resource cleanup | OpenShell reference driver released in 0.7.8; generic seam proposed | The platform chapter makes the responsibilities reusable. Core ships one maintained reference driver, OpenShell 0.1.6, as `rig environment`. It doesn't provide a generic platform driver or `DeploySpec` loader. |
|
||||
| `HarnessSpec`, `DeploySpec`, generic lifecycle fields and wake-on-demand declarations | Proposed vocabulary or unsupported configuration | They are not new accepted YAML keys. Use existing composition and an explicit author implementation. |
|
||||
| Cross-host native resume, provider-wide cancellation or permission support | Unresolved unless qualified by an integration | Unsupported and untested are distinct from observed failure. Publish the actual boundary and version. |
|
||||
|
||||
@@ -50,7 +50,7 @@ The queue records who owns work. It does not prove that an old provider process
|
||||
|
||||
## Start from an existing example
|
||||
|
||||
For a terminal harness, begin with the pinned [community preset reference](https://github.com/mvschwarz/openrig/blob/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-presets.md) and the [Cursor](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/cursor) or [Hermes](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/hermes) directory. Their presence is source guidance, not an effective-model or complete integration guarantee.
|
||||
For a terminal harness, begin with the [community preset reference](community-harness-presets.md) and the pinned [Cursor](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/cursor) or [Hermes](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/hermes) directory. Their presence is source guidance, not an effective-model or complete integration guarantee.
|
||||
|
||||
For a structured runner, the [runner chapter](harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch) supplies a minimal preset, launch record and activity message. The executable named by the preset must still be implemented and installed by its author.
|
||||
|
||||
|
||||
@@ -8,4 +8,15 @@ Read `GET /api/seat/stop-precondition/:seatRef` immediately before an operator-c
|
||||
|
||||
Pass `expected` unchanged in the body of the existing `POST /api/seat/stop/:seatRef`, alongside `reason` and optional `operator`. The contract marker lets clients detect support. `expected` is optional; a request without it retains the existing stop behavior. A malformed explicit value returns HTTP 400. A null session ID can describe an empty seat, but stopping it returns `no_session`.
|
||||
|
||||
Checked stop requires the delivery guard. It refuses a changed node, latest session, binding or occupant before a terminal effect with HTTP 409 `selection_changed`. If the selection changes after client detach, the response reports `partialEffect: true` and no kill is sent. A kill failure or a failure to save stop state after a kill reports `stop_uncertain`; inspect the seat before retrying. Success retains the usual `seat`, `sessionName` and `sessionId` fields.
|
||||
Checked stop requires the delivery guard. It refuses a changed node, latest session, binding or occupant before a terminal effect with HTTP 409 `selection_changed`. A kill failure or a failure to save stop state after a kill reports `stop_uncertain`; inspect the seat before retrying. Success retains the usual `seat`, `sessionName` and `sessionId` fields.
|
||||
|
||||
A `selection_changed` or `stop_uncertain` response from a checked stop says how far the stop got in `stopPhase`. A refusal before the stop starts carries neither `stopPhase` nor `partialEffect`, and nothing was sent. The stop detaches the seat's clients, checks the selection again, then kills the session:
|
||||
|
||||
| `stopPhase` | What was sent to the seat |
|
||||
| --- | --- |
|
||||
| `none` | Nothing. |
|
||||
| `detach_attempted` | The client detach, which may not have finished. No kill. |
|
||||
| `detached` | The client detach. No kill. |
|
||||
| `kill_attempted` | The kill. |
|
||||
|
||||
`partialEffect` is `true` whenever `stopPhase` isn't `none`, so on its own it doesn't say whether the kill went out. `selection_changed` with `stopPhase: "kill_attempted"` is a change seen after the kill, when the stop state is saved: the session was killed, so re-read the seat rather than offering the stop again.
|
||||
@@ -5,8 +5,9 @@ It runs in the seat's pane, hosts `dsh --profile acp` over ACP on stdio, and doe
|
||||
section:
|
||||
- **identity:** the launch record, written once the conversation is open;
|
||||
- **one prompt per block:** each submitted block becomes one `session/prompt`, using bracketed paste in a TTY;
|
||||
- **turn boundaries:** reported as activity hooks;
|
||||
- **cancel:** `/abort` or Ctrl-C sends `session/cancel`, and a turn reads cancelled only when dsh says so;
|
||||
- **turn boundaries:** reported as activity hooks, with a working beat every 10 s through a long, quiet turn;
|
||||
- **cancel:** `/abort` or Ctrl-C sends `session/cancel`, and a turn reads cancelled only when dsh says so. Type `/abort`
|
||||
in the seat's pane: one sent with `rig send` arrives inside a From/To envelope, so it's an ordinary prompt;
|
||||
- **permissions:** each request goes to a person through OpenRig;
|
||||
- **resume:** it reopens the exact conversation, or says honestly that it couldn't.
|
||||
|
||||
@@ -21,13 +22,19 @@ own. [bpamiri/dsh-seat](https://github.com/bpamiri/dsh-seat) is a fuller dsh run
|
||||
`$OPENRIG_HOME/community-harnesses/`. Then declare a member with `runtime: dsh`.
|
||||
3. **Give the seat credentials.** Each seat gets its own dsh home, at `$OPENRIG_HOME/state/dsh/<node>/home`, because dsh
|
||||
rewrites its profile there on every start. Put a `.credentials.yaml` there, or put a provider key in the seat's
|
||||
`env:`. To use a home you prepared yourself, set `OPENRIG_DSH_HOME`. `OPENRIG_DSH` names another `dsh` binary.
|
||||
`env:`. To use a home you prepared yourself, set `DSH_SEAT_HOME` in the member's `env:`; `DSH_SEAT_BIN` there names
|
||||
another `dsh` binary. The runner doesn't take dsh's own `DSH_HOME` from its environment, because your shell's startup
|
||||
files run after the seat's `env:` and could set it. A member's `env:` can't set `OPENRIG_*` names, which OpenRig
|
||||
reserves, but the runner still reads `OPENRIG_DSH_HOME` and `OPENRIG_DSH` when something exports them to it, such as
|
||||
a preset `command` wrapper.
|
||||
|
||||
The runner's own defaults for dsh:
|
||||
- **Uploads:** the seat home gets a `cordis.patch.yml` that turns off dsh's session-log and plugin-list uploads to
|
||||
DeepSeek, and the runner starts dsh with `DSH_TELEMETRY_DISABLED=1`.
|
||||
- **Permission mode:** `DSH_PERMISSION_MODE` is `workspace-write` unless you set it.
|
||||
- **The hook token:** OpenRig's activity token isn't passed to dsh, whose shell tool inherits the runner's environment.
|
||||
- **Its version:** the launch record names dsh's version from `dsh --version`. If that fails or takes over 5 seconds, it
|
||||
names the version dsh's ACP handshake reports, marked as such: dsh 0.2.1-rc.2 reports 0.0.1 there.
|
||||
|
||||
## Permissions
|
||||
|
||||
@@ -37,6 +44,11 @@ The runner submits each request to OpenRig, which asks one person:
|
||||
|
||||
That person answers one of the options dsh offered, and the runner gives that answer to the same live request, once.
|
||||
|
||||
**When the person answers in their own words** rather than with a choice, a yes or a no, OpenRig permits by default and
|
||||
passes their words back as `said`. The runner gives dsh its option as usual, shows the words in the pane, and queues
|
||||
them like any other message, so dsh gets them after the current turn and after any message already waiting. The words
|
||||
are never the answer.
|
||||
|
||||
**When no one can be asked, the request is permitted** with dsh's own allow option: `allow_once` when dsh offers it,
|
||||
else `allow_always`. That's OpenRig's default. The runner does the same when OpenRig can't answer: an older OpenRig
|
||||
without this path, an OpenRig it can't reach, or a refusal other than `--deny`.
|
||||
@@ -53,8 +65,10 @@ OpenRig says the request isn't the seat's current occupant's (for example, after
|
||||
|
||||
## Before you declare resume
|
||||
|
||||
The preset leaves `resume` out. The runner reopens a conversation by the method dsh advertises: `session/resume`, or
|
||||
`session/load`. When dsh advertises neither, it tries `session/resume`. It never starts a new conversation on a resume.
|
||||
The preset leaves `resume` out, so a restore stops and asks, and a fresh launch starts a new conversation.
|
||||
`rig seat restart --keep-conversation` is for Codex seats only. The runner reopens a conversation by the method dsh
|
||||
advertises: `session/resume`, or `session/load`. When dsh advertises neither, it tries `session/resume`. It never starts
|
||||
a new conversation on a resume.
|
||||
|
||||
**Telling a missing conversation from one that won't reopen.** dsh `0.2.1-rc.2` answers `-32602` both for a
|
||||
conversation it doesn't have and for one already active elsewhere, so the runner never reads that error as missing.
|
||||
|
||||
@@ -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
|
||||
- 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
|
||||
- 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
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
//
|
||||
// It follows docs/reference/harness-runner-contract.md: it hosts `dsh --profile acp` over ACP on stdio, writes the
|
||||
// launch record once the conversation is open, turns each submitted block into one prompt, reports turn boundaries as
|
||||
// activity hooks, cancels on /abort or Ctrl-C, and sends permission requests to OpenRig: a person answers, or OpenRig
|
||||
// permits when no one can be asked, and so does the runner when OpenRig can't answer (refusing and holding needs input
|
||||
// only when the seat opted in to refusal). Copy it and maintain it as your own.
|
||||
// activity hooks with a working beat through a long, quiet turn, cancels on /abort or Ctrl-C, and sends permission
|
||||
// requests to OpenRig: a person answers, or OpenRig permits when no one can be asked, and so does the runner when OpenRig
|
||||
// can't answer (refusing and holding needs input only when the seat opted in to refusal). Copy it and maintain it as your
|
||||
// own.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -19,6 +20,9 @@ const RUNNER = "openrig-dsh-runner 1";
|
||||
const NAME = process.env.OPENRIG_RUNTIME || "dsh"; // the preset's name: OpenRig passes it, and checks the record against it
|
||||
const LIVENESS_SECONDS = 60;
|
||||
const BEAT_MS = 30_000;
|
||||
// The working beat (contract section 4): every 10 s, more often than half its 30 s lifetime.
|
||||
const WORK_LIVENESS_SECONDS = 30;
|
||||
const WORK_BEAT_MS = 10_000;
|
||||
const ACTIVE_EVERY_MS = 15_000;
|
||||
const POST_TIMEOUT_MS = 2_000;
|
||||
const WAIT_SECONDS = 25;
|
||||
@@ -203,10 +207,12 @@ const UPLOADS_OFF = [
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
/** Each seat gets its own writable dsh home (dsh rewrites its profile there on every start). OPENRIG_DSH_HOME names
|
||||
* one you prepared yourself, which is used as it is. */
|
||||
/** Each seat gets its own writable dsh home (dsh rewrites its profile there on every start). DSH_SEAT_HOME, which a rig
|
||||
* spec's env: can set, or OPENRIG_DSH_HOME names one you prepared yourself, which is used as it is. Not dsh's own
|
||||
* DSH_HOME: a person's shell startup files can export it, and they run after the seat's env:. */
|
||||
function seatHome() {
|
||||
if (process.env.OPENRIG_DSH_HOME) return process.env.OPENRIG_DSH_HOME;
|
||||
const prepared = process.env.OPENRIG_DSH_HOME || process.env.DSH_SEAT_HOME;
|
||||
if (prepared) return prepared;
|
||||
const seat = process.env.OPENRIG_NODE_ID || process.env.OPENRIG_SESSION_NAME || "default";
|
||||
const home = join(openrigHome(), "state", "dsh", seat, "home");
|
||||
mkdirSync(home, { recursive: true, mode: 0o700 });
|
||||
@@ -215,6 +221,17 @@ function seatHome() {
|
||||
return home;
|
||||
}
|
||||
|
||||
/** dsh's version as its CLI reports it, or null when it can't say. dsh 0.2.1-rc.2's ACP handshake reports 0.0.1.
|
||||
* SIGKILL at the timeout, because a synchronous call waits for the child to exit, and a child can ignore SIGTERM. */
|
||||
function cliVersion(bin, env) {
|
||||
try {
|
||||
const out = execFileSync(bin, ["--version"], { encoding: "utf8", env, timeout: 5_000, killSignal: "SIGKILL", stdio: ["ignore", "pipe", "ignore"] });
|
||||
return out.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?/)?.[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeLaunchRecord(fields) {
|
||||
const path = process.env.OPENRIG_LAUNCH_RESULT;
|
||||
if (!path) return;
|
||||
@@ -250,16 +267,23 @@ async function main() {
|
||||
agentEnv.DSH_PERMISSION_MODE ??= "workspace-write";
|
||||
if (agentEnv.DSH_TELEMETRY_DISABLED === undefined && agentEnv.DSH_TELEMETRY_MODE === undefined) agentEnv.DSH_TELEMETRY_DISABLED = "1";
|
||||
|
||||
const child = spawn(env.OPENRIG_DSH || "dsh", ["--profile", "acp"], { cwd, env: agentEnv, stdio: ["pipe", "pipe", "inherit"] });
|
||||
// DSH_SEAT_BIN, which a rig spec's env: can set, or OPENRIG_DSH names another dsh binary.
|
||||
const bin = env.OPENRIG_DSH || env.DSH_SEAT_BIN || "dsh";
|
||||
const version = cliVersion(bin, agentEnv);
|
||||
const child = spawn(bin, ["--profile", "acp"], { cwd, env: agentEnv, stdio: ["pipe", "pipe", "inherit"] });
|
||||
child.stdin.on("error", () => {});
|
||||
const peer = new AgentPeer(child, (line) => say(`[dsh] ${line}`));
|
||||
const state = { sessionId: null, turn: 0, running: false, cancelRequestedAt: null, holding: false, refused: false, queue: [], asks: new Set(), lastActive: 0 };
|
||||
const state = { sessionId: null, turn: 0, running: false, cancelRequestedAt: null, holding: false, refused: false, queue: [], asks: new Set(), lastActive: 0, asking: 0, waiting: 0, resolvedAsk: null };
|
||||
const inflight = new Set();
|
||||
const track = (promise) => { inflight.add(promise); promise.finally(() => inflight.delete(promise)); return promise; };
|
||||
let agentExited = false;
|
||||
let stopping = false;
|
||||
let heartbeat;
|
||||
let workBeat;
|
||||
let tty = false;
|
||||
// The turn may read working: it runs, nothing holds needs input, no permission request is being submitted or waits for its
|
||||
// permission_resolved, and dsh is alive. The beat and the agent's own reports both go by it.
|
||||
const working = () => state.running && !state.holding && state.asking === 0 && !state.resolvedAsk && !agentExited;
|
||||
|
||||
const restoreTty = () => {
|
||||
if (!tty) return;
|
||||
@@ -269,6 +293,7 @@ async function main() {
|
||||
};
|
||||
const finish = async (code) => {
|
||||
clearInterval(heartbeat);
|
||||
clearInterval(workBeat);
|
||||
restoreTty();
|
||||
await Promise.race([Promise.allSettled([...inflight, activity.flushed()]), sleep(2_000)]);
|
||||
process.exit(code);
|
||||
@@ -299,7 +324,7 @@ async function main() {
|
||||
peer.onNotification("session/update", (params) => {
|
||||
if (params.sessionId !== state.sessionId || !params.update) return;
|
||||
const update = params.update;
|
||||
const reporting = state.running && !state.holding; // while needs input is held, nothing may read working
|
||||
const reporting = working();
|
||||
if (update.sessionUpdate === "agent_message_chunk") {
|
||||
if (update.content?.type === "text") process.stdout.write(update.content.text);
|
||||
if (reporting && Date.now() - state.lastActive >= ACTIVE_EVERY_MS) { state.lastActive = Date.now(); activity.post("active", "agent_message"); }
|
||||
@@ -346,10 +371,17 @@ async function main() {
|
||||
const allow = options.find((option) => option.kind === "allow_once") ?? options.find((option) => option.kind === "allow_always");
|
||||
const permit = (why) => {
|
||||
if (!allow) return refuse(`${why}, and the agent offered no allow option`);
|
||||
state.holding = false;
|
||||
state.holding = state.refused || state.waiting > 0; // another ask may still wait on a person
|
||||
say(`[permission] permitted: ${why} (${allow.optionId})`);
|
||||
return { outcome: { outcome: "selected", optionId: allow.optionId } };
|
||||
};
|
||||
// A person who answered in their own words: OpenRig passes them back as `said`. They join the prompt queue like any
|
||||
// message, so dsh gets them after this turn and after any message already waiting. They're never the answer.
|
||||
const passOn = (said) => {
|
||||
if (typeof said !== "string" || !said.trim()) return;
|
||||
say(`[permission] the person also wrote: ${firstLine(said)}`);
|
||||
submit(`The person who answered your permission request (${toolCall.title}) also wrote:\n\n${said}`);
|
||||
};
|
||||
if (!askId) {
|
||||
if (submitted.body?.code === "denied_by_seat") return refuse("the seat's operator chose to refuse its permission requests");
|
||||
if (NOT_LIVE.includes(submitted.body?.code)) {
|
||||
@@ -360,7 +392,12 @@ async function main() {
|
||||
: submitted.status === 0 ? "OpenRig couldn't be reached" : `OpenRig couldn't ask anyone (${submitted.body?.code ?? submitted.status})`);
|
||||
}
|
||||
state.holding = true; // OpenRig recorded the seat's permission prompt when it opened the ask
|
||||
state.waiting += 1;
|
||||
say(`[permission] OpenRig asked a person (${askId}); waiting for their answer`);
|
||||
// This ask no longer waits on a person; the seat still needs input while another one does, or after its own refusal.
|
||||
const stopWaiting = () => { state.waiting -= 1; state.holding = state.refused || state.waiting > 0; };
|
||||
// The agent got an answer or a permit for this ask: once nothing else waits, permission_resolved says the turn goes on.
|
||||
const resolved = () => { state.resolvedAsk = { turn, askId }; };
|
||||
return new Promise((resolve) => {
|
||||
let done = false; // the agent has its answer
|
||||
let ended = false; // OpenRig was told the request is over
|
||||
@@ -371,7 +408,7 @@ async function main() {
|
||||
withdraw(askId, reason);
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (!state.refused) state.holding = false; // the turn ended the person's ask: no one is still being asked
|
||||
stopWaiting(); // the turn ended the person's ask
|
||||
resolve(CANCELLED);
|
||||
} };
|
||||
state.asks.add(ask);
|
||||
@@ -387,18 +424,23 @@ async function main() {
|
||||
// A person's option, or OpenRig's permit when they closed it without one.
|
||||
if ((answer.state === "answered" || answer.state === "permitted") && offered(answer.optionId) && live()) {
|
||||
state.asks.delete(ask);
|
||||
state.holding = false;
|
||||
stopWaiting();
|
||||
say(`[permission] ${answer.state}: ${answer.optionId}`);
|
||||
resolve({ outcome: { outcome: "selected", optionId: answer.optionId } });
|
||||
resolved();
|
||||
passOn(answer.said);
|
||||
} else if (live() && (read.status === 200 ? !ENDED.includes(answer.state) : !NOT_LIVE.includes(answer.code))) {
|
||||
// OpenRig stopped answering, or answered in a way the contract doesn't list: nobody can be asked. The ask stays
|
||||
// tracked, so the turn's end still withdraws it.
|
||||
stopWaiting();
|
||||
resolve(permit(read.status === 0 ? "OpenRig couldn't be reached" : `OpenRig couldn't answer (${answer.code ?? read.status})`));
|
||||
if (!state.refused) resolved(); // permitted, not refused for want of an allow option
|
||||
} else {
|
||||
state.asks.delete(ask);
|
||||
state.holding = false; // a person said no, or the request is over: no one is still waiting
|
||||
stopWaiting(); // a person said no, or the request is over
|
||||
say(`[permission] not answered (${answer.reason ?? answer.state ?? answer.code ?? read.status}); answered cancelled`);
|
||||
resolve(CANCELLED);
|
||||
passOn(answer.said);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -411,9 +453,19 @@ async function main() {
|
||||
const options = Array.isArray(params.options) ? params.options : [];
|
||||
say(`\n[permission] dsh asks: ${toolCall.title} (${toolCall.kind}); options ${options.map((option) => option.optionId).join(", ")}`);
|
||||
if (!state.running || state.cancelRequestedAt) return CANCELLED;
|
||||
return askOwner(requestId, toolCall, options);
|
||||
state.asking += 1; // no beat while a permission request is open: it may be waiting on a person
|
||||
return askOwner(requestId, toolCall, options).finally(() => { state.asking -= 1; setImmediate(settle); });
|
||||
});
|
||||
|
||||
// Once nothing waits on a person or is being submitted, the seat holds no refusal of its own and dsh is alive, say the turn
|
||||
// goes on. It goes out after the agent has its answer, and before the next beat.
|
||||
const settle = () => {
|
||||
const done = state.resolvedAsk;
|
||||
if (!done || state.holding || state.asking > 0) return;
|
||||
state.resolvedAsk = null;
|
||||
if (state.running && state.turn === done.turn && !agentExited) activity.post("active", "permission_resolved", { turnId: String(done.turn), askId: done.askId });
|
||||
};
|
||||
|
||||
const abort = () => {
|
||||
if (!state.running) { say("[openrig-dsh] no turn is running"); return; }
|
||||
if (state.cancelRequestedAt) return;
|
||||
@@ -429,6 +481,8 @@ async function main() {
|
||||
state.turn += 1;
|
||||
state.holding = false;
|
||||
state.refused = false;
|
||||
state.waiting = 0;
|
||||
state.resolvedAsk = null;
|
||||
state.cancelRequestedAt = null;
|
||||
state.lastActive = 0;
|
||||
const turnId = String(state.turn);
|
||||
@@ -472,7 +526,8 @@ async function main() {
|
||||
return fatal(`dsh didn't complete the ACP handshake (${error.message})`, 1);
|
||||
}
|
||||
const capabilities = init?.agentCapabilities ?? {};
|
||||
const producer = `${RUNNER}; dsh ${init?.agentInfo?.version ?? "(version not reported)"}; ACP ${init?.protocolVersion ?? "?"}`;
|
||||
const reported = init?.agentInfo?.version ? `${init.agentInfo.version} (as its ACP handshake reports it)` : "(version not reported)";
|
||||
const producer = `${RUNNER}; dsh ${version ?? reported}; ACP ${init?.protocolVersion ?? "?"}`;
|
||||
const resumeRef = env.OPENRIG_RESUME_REF;
|
||||
// Whether dsh's own session list names a conversation, following its pages: true, false when every page was read and
|
||||
// none names it, or undefined when the list fails, answers in another shape, or runs past LIST_PAGES pages. An ACP
|
||||
@@ -522,6 +577,12 @@ async function main() {
|
||||
say("[openrig-dsh] each submitted message is one prompt; /abort or Ctrl-C cancels the running turn");
|
||||
|
||||
heartbeat = setInterval(() => { if (!state.running && !state.holding && !agentExited) postIdle("idle_heartbeat"); }, BEAT_MS);
|
||||
// While a turn runs and nothing waits on a person: the turn is still outstanding and dsh is alive. Not progress.
|
||||
workBeat = setInterval(() => {
|
||||
if (working()) {
|
||||
activity.post("active", "liveness", { turnId: String(state.turn), livenessSeconds: WORK_LIVENESS_SECONDS });
|
||||
}
|
||||
}, WORK_BEAT_MS);
|
||||
for (const signal of ["SIGHUP", "SIGTERM"]) {
|
||||
process.on(signal, () => {
|
||||
if (state.running && !state.cancelRequestedAt) peer.notify("session/cancel", { sessionId: state.sessionId });
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# Writing a daemon client
|
||||
|
||||
What a program that talks to the OpenRig daemon over HTTP needs and can't read from `rig --help`: how to find the
|
||||
daemon, its health route, which bearer token each route family checks, the event streams, and some route
|
||||
details. It describes the daemon as it is. It adds no promise beyond what each section says, and route shapes not
|
||||
listed here can change.
|
||||
|
||||
## Finding the daemon
|
||||
|
||||
`rig status --json` is the discovery surface. Its `daemon` object gives `state` and `reachable`, and `pid` and
|
||||
`port` when they are known: a stopped daemon's response has no `port`, and a running daemon found through
|
||||
`OPENRIG_URL` has no `pid`. `reachable` is `true` when the daemon answered its health probe, `false` when it is
|
||||
positively down, and `null` when that isn't confirmed (see `rig status` in
|
||||
[the command reference](../as-built/cli-reference.md#status)). It reads the recorded state and probes; it never
|
||||
starts a daemon.
|
||||
|
||||
The CLI itself finds the daemon in these orders:
|
||||
|
||||
- **Data commands** (everything that sends a request): `OPENRIG_URL` (or the legacy `RIGGED_URL`); else
|
||||
`$OPENRIG_HOME/daemon.json` when it holds a valid `pid` and `port`; else the config keys `daemon.host` and
|
||||
`daemon.port` (environment `OPENRIG_HOST` and `OPENRIG_PORT`), which default to `127.0.0.1:7433`.
|
||||
- **Status** (`rig status`, `rig daemon status`): `OPENRIG_URL` (or `RIGGED_URL`) first, probed directly; else
|
||||
the state file below.
|
||||
- **Start and stop** (`rig daemon start`, `stop`): the state file, `$OPENRIG_HOME/daemon.json`, then the legacy
|
||||
`~/.rigged/daemon.json`.
|
||||
|
||||
`daemon.json` holds `pid`, `port`, `host`, `db` and `startedAt`. `rig daemon start` writes a new daemon's record
|
||||
only after its health check passes, so while a start is pending, the file holds no record of the new daemon: it
|
||||
may be missing, or hold an earlier daemon's stale record. It is the CLI's own state file, not a published
|
||||
contract: prefer `rig status --json`.
|
||||
|
||||
## `GET /healthz`
|
||||
|
||||
The liveness and identity read. It always returns `status: "ok"` and `pid`. Every other field is present only
|
||||
in the case named:
|
||||
|
||||
| Field | When it is present |
|
||||
| --- | --- |
|
||||
| `euid` | On platforms with effective user ids. A self-report for the CLI's owner check, not authentication. |
|
||||
| `databaseWarnings` | When the daemon opened its database with warnings. |
|
||||
| `semver`, `commit`, `dirty`, `builtAt` | On a stamped (released) build; a development run has none. |
|
||||
| `selfHostId`, `selfHostIdSource` | After the boot reconcile has set this host's id. |
|
||||
| `eventLoop` | When the event-loop monitor runs: `lagMeanMs`, `lagP99Ms`, `utilization` (0 to 1), `lastTickAgeMs` and `healthy`. |
|
||||
| `routeTimings` | With `eventLoop`. |
|
||||
| `slowOperations` | When the slow-operation recorder runs. |
|
||||
| `bind` | The bind plan: `mode` (`explicit` or `default`), `hosts` (the addresses bound), `tailscaleDetected`, and `ignoredRoutingHost` when a routing value was ignored. |
|
||||
| `stuckSweep`, `wakeLadder` | The heartbeats of the stuck sweep and the wake ladder, when they run. |
|
||||
|
||||
## Bearer tokens
|
||||
|
||||
The daemon reads two tokens from its environment at start:
|
||||
|
||||
- **`OPENRIG_AUTH_BEARER_TOKEN`** checks Mission Control's write routes (`POST /api/mission-control/action` and
|
||||
`/notifications/test`), the `/api/hosts` writes (`add`, `update`, `remove`, `pair`), and the `/api/rig-mode`
|
||||
binding writes. `rig host pair` hands this token to the paired machine, so a daemon that will be paired with
|
||||
needs it set (see `rig host pair` in [the command reference](../as-built/cli-reference.md)).
|
||||
- **`OPENRIG_TERMINAL_BEARER_TOKEN`** checks every `/api/transport` and `/api/compaction` route, `POST
|
||||
/api/terminal/follow` and `/viewer/ensure`, and every `GET /api/terminal/<name>` route, the terminal websocket
|
||||
included. When the daemon is bound to an explicit address (`rig daemon start --host`) that isn't loopback or a
|
||||
tailnet address, and this token isn't set, these routes check `OPENRIG_AUTH_BEARER_TOKEN` instead.
|
||||
|
||||
A route whose token isn't set lets every request through. Send a token as `Authorization: Bearer <token>`; the
|
||||
`GET /api/terminal/<name>` routes, the websocket included, also take `?token=<token>`. The CLI sends the terminal token from `OPENRIG_TERMINAL_BEARER_TOKEN`,
|
||||
or else from the file `$OPENRIG_HOME/terminal-token`, which the person writes; the daemon doesn't create it.
|
||||
|
||||
A wrong or missing token gets HTTP 401. The routes that check `OPENRIG_AUTH_BEARER_TOKEN`, and `/api/transport`
|
||||
and `/api/compaction`, answer `{ "error": "unauthorized", "message", "what_failed", "why_it_matters", "what_to_do" }`.
|
||||
The `GET /api/terminal/<name>` routes, the websocket included, answer `{ "error": "unauthorized", "hint" }`. A token
|
||||
doesn't get past the browser boundary's 403 refusals, which [Browser access](browser-access.md) describes.
|
||||
|
||||
## Event streams
|
||||
|
||||
All three are server-sent events (`text/event-stream`):
|
||||
|
||||
- **`GET /api/events`**: the daemon's event log. `?rigId=` limits it to one rig. Each event carries `id: <seq>`.
|
||||
Without `Last-Event-ID`, `?replay=<n>` first sends the newest `n` stored events (`0`: none, live events only);
|
||||
`?replay=all`, no `replay`, or any other value sends the whole log first. A `Last-Event-ID` header (what
|
||||
`EventSource` sends when it reconnects) sends the events after that id instead, whatever `replay` says.
|
||||
Live events that arrive during the replay follow it, without duplicates.
|
||||
- **`GET /api/activity/events`**: live only, no replay or ids. Each message is named after its event type:
|
||||
`seat.activity_changed`, `seat.rung_health`, `proof.judged` or `proof.sources_changed`. It tells a view to read
|
||||
again; it carries no derived state.
|
||||
- **`GET /api/queue/sse`** (alias `/api/queue/watch`): live only, with `id: <seq>`, for `queue.created`,
|
||||
`queue.handed_off`, `queue.claimed`, `queue.unclaimed`, `qitem.fallback_routed`, `qitem.closure_overdue`,
|
||||
`inbox.absorbed` and `inbox.denied`.
|
||||
|
||||
## People, asks and updates without Slack
|
||||
|
||||
`GET /api/gateway/participants` returns the same JSON projection as `rig gateway participants --json`:
|
||||
`{ ok, participants, default, organizations, groups, warnings, participantFile }`. Pick a person by `kind: "person"`
|
||||
and use their `id` for a Feed `view`, or their `address` for the queue's `destinationSession`. The effective
|
||||
`default` is `{ id, source }`, or `{ id: null, reason }`; it does not establish who is using the client.
|
||||
Participant records omit connector credential pointers. Check `warnings` and `participantFile.error` for a partial
|
||||
registry read; an unreadable human registry returns HTTP 503. See [Gateway participants](gateway-participants.md).
|
||||
|
||||
| Read | Meaning |
|
||||
| --- | --- |
|
||||
| `GET /api/queue/list?destinationSession=<address>&state=pending,in-progress,blocked&limit=100` | Open rows for exactly that address. `humanIntent: "update"` identifies an FYI; other values are asks. Each row has `qitemId`, `state`, `summary` and `body`. |
|
||||
| `GET /api/queue/open-human-updates?view=<person-id>&limit=20` | Open human FYIs, even when no Slack delivery receipt exists. Returns `{ items, limit, truncated, perspective? }`. |
|
||||
| `GET /api/queue/<qitemId>` and `/<qitemId>/transitions` | The current row and its history, including closed state and recorded outcome. Closing or acknowledging a row is different from delivering it. |
|
||||
| `GET /api/queue/human-updates?view=<person-id>&limit=20` | Existing delivered-update history: explicit FYIs with a recorded Slack posting receipt, including closed rows. Items add `deliveredAt` and `deliveryReceipt`. It is not the open-update inbox. |
|
||||
|
||||
Both update lists accept integer `limit` values from 1 to 100 (default 20); invalid values return HTTP 400.
|
||||
Open FYIs are ordered by priority, then newest first. `view=all` (also the default) shows all perspectives;
|
||||
`view=default` selects the effective default person, or all when no default is available. A named view that cannot
|
||||
resolve a person has `perspective.basis: "unknown"` and no items when a registry is available. With no registered
|
||||
people there is no perspective filter: inspect `perspective` rather than assuming the requested view was applied.
|
||||
A person's view also includes instance or unassigned items when that person is the default. These are views on a
|
||||
trusted daemon, not per-person authentication or access control; existing daemon and browser access rules still apply.
|
||||
|
||||
Always display `truncated: true` as incomplete, even if fewer than `limit` items arrive. A person-view scan examines
|
||||
at most 1,001 open updates (101 for delivered history) before filtering; older matching items may be outside that
|
||||
window. These endpoints have no pagination cursor. Reading an open update never posts it to Slack, acknowledges it,
|
||||
or manufactures a delivery receipt.
|
||||
|
||||
For example, this Node client takes a daemon URL, a registered person id and a rig name. It uses no Slack API and
|
||||
makes only reads; use the daemon discovery and access rules above. In an ES module, `process.argv.slice(1)` is the
|
||||
argument list for `node --input-type=module -e '<code>' <url> <person-id> <rig>`:
|
||||
|
||||
```js
|
||||
// client-people-example
|
||||
const [base, personId, rig] = process.argv.slice(1);
|
||||
async function get(path) {
|
||||
const response = await fetch(new URL(path, base));
|
||||
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
const registry = await get("/api/gateway/participants");
|
||||
const person = registry.participants.find(p => p.kind === "person" && p.id === personId);
|
||||
if (!person) throw new Error("Choose a registered person id");
|
||||
const rows = await get(`/api/queue/list?destinationSession=${encodeURIComponent(person.address)}&state=pending,in-progress,blocked&limit=100`);
|
||||
const updates = await get(`/api/queue/open-human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
|
||||
const receipts = await get(`/api/queue/human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
|
||||
const rigHistory = await get(`/api/queue/list?rig=${encodeURIComponent(rig)}&limit=100`);
|
||||
const messages = await get(`/api/queue/outbox/list?rig=${encodeURIComponent(rig)}&limit=100`);
|
||||
console.log(JSON.stringify({ participants: registry.participants, default: registry.default,
|
||||
warnings: registry.warnings, participantFile: registry.participantFile,
|
||||
asks: rows.filter(row => row.humanIntent !== "update"), updates, receipts, rigHistory, messages }, null, 2));
|
||||
```
|
||||
|
||||
The queue and outbox lists are bounded snapshots, not subscriptions. A full queue list may need a larger `limit`.
|
||||
An outbox entry's `deliveryState` reports the send attempt; only `delivered` confirms delivery. A queue transition,
|
||||
an open FYI, or a closed ask is not that receipt. The rig-wide reads and their limits are described below; the existing
|
||||
`outbox/list?senderSession=<session>` read remains available.
|
||||
|
||||
## Route details
|
||||
|
||||
- **A rig's queue history:** `GET /api/queue/list?rig=<rig>&updatedSince=<ISO time>&sort=updated` returns the
|
||||
rig's queue items (any whose source or destination seat is in the rig) updated at or after that time: open items
|
||||
(`pending`, `in-progress`, `blocked`) first, then the rest by latest update. `limit` defaults to 100 and has no
|
||||
upper cap. The list is cut after `limit` rows in that order, so when it comes back full, raise `limit` to read
|
||||
them all. Each item's own history is `GET /api/queue/<qitemId>/transitions`. This read scans the queue table, so
|
||||
read it occasionally rather than polling it. For live changes, treat `GET /api/queue/sse` events as hints to read
|
||||
`queue/list?rig=` again. Not every event names both seats (`queue.claimed` and `queue.unclaimed` carry only the
|
||||
destination), and not every state change is on that stream, so the events alone don't track a rig's items.
|
||||
- **A rig's messages:** `GET /api/queue/outbox/list?rig=<rig>` returns the messages sent from or to the rig's
|
||||
seats, newest first; `?senderSession=<session>` still returns one sender's. One of the two is required; with
|
||||
both, `senderSession` is used and `rig` is ignored. `limit` defaults to 100. No event is sent when a message is delivered, so read this list to see delivery
|
||||
states.
|
||||
- **`GET /api/queue/recent-transitions`** returns at most 20 rows. A positive `limit` above 20 is capped at 20;
|
||||
a missing, zero, negative or unparseable `limit` means 20.
|
||||
- **`POST /api/transport/send`** takes `urgent: true`, the same as `rig send --urgent`. The daemon records who
|
||||
marked it from the sender identity, not from the body.
|
||||
- **`POST /api/scope/approve`** is what `rig scope slice approve` and `rig scope mission approve` call. The body
|
||||
takes `scopeTier` (`slice` or `mission`), `scopePath`, and optionally `approvalScope` (`spec` or `delivery`, the
|
||||
default), `missionsRoot`, `onBehalfOf`, `reApprove`, `reason` and `lockedArtifacts`. The approver is the
|
||||
request's sender identity (`actorSession` in the body is recorded beside it, and a different value is
|
||||
superseded). Success is 201 with `scopeTier`, `scopeId`, `scopePath`, `approvalScope`, `approvedBy`,
|
||||
`approvedAt`, `onBehalfOf`, `actionId`, `freezeFired` and `reApproved`. Refusals include 400 for an invalid
|
||||
`scopeTier`, `scopePath` or `approvalScope`, or a missing actor (`actor_required`); 503 `action_log_unavailable`;
|
||||
and, from the approval itself, 404 `scope_not_found`, 409 `already_approved` or `nothing_to_reapprove`, 503
|
||||
`workspace_not_configured`, 500 `audit_write_failed`, and 400 for its other refusals. An unexpected error is 500
|
||||
`internal`.
|
||||
- **`POST /api/proof/judge`** is what `rig proof judge` calls. The body takes `scope`, `item`, `verdict`
|
||||
(`accept`, `reject` or `withdraw`), `reason`, `expectedRevision` and `expectedPrevious` (a judgment id or
|
||||
`null`), and optionally `evidence`, `expectedEvidence`, `subject`, `operationId`, `replace`, `project` and
|
||||
`projectRoot`. The judge is the request's sender identity. The response is `judgment`, the slice's `readiness`,
|
||||
`replayed` and `notification`: 201 for a new judgment, 200 when an `operationId` replays an earlier one.
|
||||
- **`POST /api/workflow/instantiate`** requires `specPath`, `rootObjective` and `createdBySession`, and takes
|
||||
`entryOwnerSession`, `targetRig` and `scope` (see [Project workspace](project-workspace.md) for `scope`).
|
||||
Success is 201 with `instance`, `spec`, `entryQitemId` and `entryOwnerSession`, any `advisories`, and
|
||||
`scopeIgnored` when a scope wasn't applied.
|
||||
- **`GET /api/provider/usage`** returns `{ hostUsage }`, this host's usage rows (`host: "local"`, a `provider`
|
||||
and a `state`). A Claude row's `windows` are the seats' status-line readings, one entry per reading, each with
|
||||
its `window`, `usedPercent`, `resetsAt`, `asOf` and `seatSession`. A Codex row has `windows: []`. Treating the
|
||||
readings as one account rests on the deployment assumption the row's `provenance` names (one account per
|
||||
host), not on a measured identity. Readings whose reset times disagree are reported as `anomalies`, and the
|
||||
row's `state` can then be `explicit_unknown`. Show the row's `state`, `anomalies` and each reading's seat and
|
||||
`asOf` as reported, rather than merging readings that happen to match.
|
||||
- **Slack channels and people:** `GET /api/gateway/slack/channels` and `GET /api/gateway/slack/users` each return
|
||||
one page from Slack, read with the daemon's configured bot token, which never leaves the daemon. Channels are
|
||||
public and unarchived, as `{ id, name, isMember }`; people are `{ id, name, realName, displayName, isBot,
|
||||
deleted }`, with no email. Pass `cursor` (the previous page's `nextCursor`, `null` on the last page) and
|
||||
`limit` (default 100, at most 200). Inbound delivery needn't be enabled. Failures: 503 `slack_not_configured`
|
||||
when no bot token resolves, which is different from an empty list; 403 `slack_missing_scope` with the scope
|
||||
Slack names in `needed` (an older app may lack `users:read`); 429 `slack_rate_limited` with Slack's
|
||||
`Retry-After`; otherwise 502. Each request is a live Slack call, so page through a list when someone opens a
|
||||
chooser rather than polling it. Private channels and direct messages aren't listed.
|
||||
- **Slices are found by folder name alone.** `GET /api/slices/:name`, `/:name/doc/*`, `/:name/proof-asset/*` and
|
||||
`GET /api/review/slice/:name` take only the name; the detail route reads `?mission` only together with
|
||||
`?project`. When two missions have a slice folder of the same name, the daemon keeps the first one it finds:
|
||||
that one is listed and served, and the other doesn't appear in `GET /api/slices` at all. Keep slice names unique
|
||||
across missions, as [Project workspace](project-workspace.md) says.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This chapter describes the placement and resource responsibilities of an integration author. The harness/runner chapter owns native protocol, model input, activity, attention, permission exchange and turn cancellation. This chapter owns where that harness runs, which files and credentials it can reach, how native state survives an environment change, and how results and resources return to their owners.
|
||||
|
||||
**Status:** a design contract for authors, with an existing-compatible composition and one measured private implementation. The measured baseline used OpenRig 0.7.4, OpenShell 0.1.6 and Claude Code 2.1.310. 0.7.6 published this draft; publishing it does not implement a generic environment driver, a `DeploySpec` loader or a cross-host native-resume guarantee. Proposed operations below are not commands or accepted configuration keys. Capability support must be stated for the actual integration and version.
|
||||
**Status:** a design contract for authors, with an existing-compatible composition and one measured private implementation. Since 0.7.8, OpenRig also ships one maintained reference driver, OpenShell 0.1.6, as `rig environment` (see the [OpenShell example](community-harness-examples/openshell/README.md)). The measured baseline used OpenRig 0.7.4, OpenShell 0.1.6 and Claude Code 2.1.310. 0.7.6 published this draft; neither it nor the reference driver implements a generic environment driver, a `DeploySpec` loader or a cross-host native-resume guarantee. Proposed operations below are not commands or accepted configuration keys. Capability support must be stated for the actual integration and version.
|
||||
|
||||
## Keep the identities and responsibilities separate
|
||||
|
||||
|
||||
@@ -5,15 +5,31 @@ OpenRig's gateway can register **several people** and **shared channels**, group
|
||||
OpenRig doesn't enforce it. This page covers the commands, what the default is for, and what upgrading and rolling back
|
||||
do.
|
||||
|
||||
For a remote client, `GET /api/gateway/participants` returns the same participant, organization, group, default and
|
||||
warning fields as `rig gateway participants --json`. Add `?organization=<id>` to filter the participant list; the default
|
||||
and organization and group maps still describe the whole registry. The result names the participant file and reports
|
||||
an unreadable side file without treating it as an empty one. If the human registry itself cannot be read, the route
|
||||
returns `503` with `error: "human_registry_unavailable"`. The result does not include connector secret pointers.
|
||||
|
||||
## Participants
|
||||
|
||||
A participant is either:
|
||||
- **a person**, registered with `rig gateway human add` as before, with their own Slack binding; or
|
||||
- **a person**, registered with `rig gateway human add`, with or without a connector binding; or
|
||||
- **a shared channel**: one vetted Slack channel registered as one participant, `<id>@external`, not a person per
|
||||
sender.
|
||||
|
||||
People and channels share one address space: a channel can't take a person's id.
|
||||
|
||||
A registered person is addressed as `<id>@external`, including without Slack: for example,
|
||||
`rig gateway human add alex --display-name "Alex" --delivery-class B` creates `alex@external`, reached in the Feed and Steering.
|
||||
`human@host` is the unnamed person at the terminal; `human@kernel` is also accepted as a human/gate address.
|
||||
These aliases (including `human-<name>@host` and `human-<name>@kernel`) are not agent sessions: reply with
|
||||
`rig queue create --destination human@host --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`,
|
||||
substituting the sender's address and your summary, evidence path and reply. A reply destination doesn't identify
|
||||
who acted or determine who may answer an ask. Clients acting as a registered person name that person's
|
||||
`actorSession`; a seat identity header takes precedence. The terminal's primary-user authority records the unnamed
|
||||
`human@host` actor, not the person whose queue is being viewed.
|
||||
|
||||
```bash
|
||||
# An organization and a group inside it (one command each, create or update)
|
||||
rig gateway org set acme --name "Acme" --policy "Answer Acme's customers within a working day. Never promise a date."
|
||||
@@ -59,30 +75,26 @@ never changes `workspace.operator_seat_name`, which keeps its own meaning.
|
||||
|
||||
## In the TUI: whose queue, and who acts
|
||||
|
||||
The person at the terminal is the **primary user**: the default person, or, with several people and none chosen, the
|
||||
person at the terminal unnamed. Whatever the TUI shows, its acts are theirs.
|
||||
The person at the terminal is the **primary user**. The TUI records their acts as unnamed (`human@host`),
|
||||
whatever person's queue it shows or who is configured as the default.
|
||||
- **Opening:** with a chosen default, the TUI opens on that person's Feed. With none chosen, including a one-person
|
||||
install that never chose one, it opens on the topology, as before.
|
||||
- **Whose queue:** `whose <person>` shows one person's asks, `whose all` everyone's, and `whose default` the default
|
||||
person's. The Feed's first line says whose queue it shows and who acts.
|
||||
- **Acting on someone else's ask** (answer, approve or resolve from the TUI) records both: the primary user as the actor,
|
||||
and the person asked in the audit notes and the transition. The asking seat reads, for example, "Answered by
|
||||
mike@external, the primary user at the terminal, on sarah@external's ask". With several people and none chosen, the
|
||||
actor is `human@host`.
|
||||
- **Viewing Sarah isn't acting as Sarah.** Only Sarah, from her own channel, answers as Sarah. Without the primary
|
||||
user's authority, one registered person can't answer, approve, deny or resolve what was asked of another; that's
|
||||
refused (`answer_not_recorded`) and nothing changes.
|
||||
- With no one registered, the TUI and its acts are as before, except on a shared channel's ask or a row parked on a
|
||||
channel: the TUI shows the act as `human@host`, the person at the terminal, and it's recorded that way, never as the
|
||||
channel.
|
||||
- **Acting on someone else's ask** (answer, approve or resolve from the TUI) records both: the unnamed terminal
|
||||
actor `human@host` and the person asked in the audit notes and transition. The asking seat reads, for example,
|
||||
"Unattributed (human@host) answered sarah@external's ask".
|
||||
- **Viewing Sarah isn't acting as Sarah.** A registered person may answer, approve, deny or resolve another person's
|
||||
ask as themselves; both the actor and person asked are recorded. No connector binding is required, and viewing
|
||||
someone else's queue doesn't make its viewer that person.
|
||||
- **A shared channel never acts.** Its messages are correspondence, so nothing it says answers, approves, denies or
|
||||
resolves an ask, and a customer's earlier message in an ask's thread is never taken as its answer. A request without
|
||||
the primary user's authority that acts as a channel, with any Mission Control verb (including drop, route, hold, hand
|
||||
off and annotate), is refused (`answer_not_recorded`) and nothing changes.
|
||||
- **Where the authority applies:** only on a request that carries no seat identity header, which is how the TUI calls
|
||||
the daemon. Seats and the CLI send that header, so their acts stay theirs. This is the existing trusted-network
|
||||
distinction, not an authentication check: a request that leaves the header out is taken as the terminal's, as any
|
||||
such request already is.
|
||||
- **Where the authority applies:** primary-user authority is accepted only on a request that carries no seat identity
|
||||
header, which is how the TUI calls the daemon. A headerless client can instead name a person's `actorSession` without
|
||||
that authority. Seats and the CLI send the identity header, so their acts stay theirs. This is the existing
|
||||
trusted-network distinction, not an authentication check.
|
||||
|
||||
## Shared-channel conversations
|
||||
|
||||
|
||||
@@ -55,7 +55,8 @@ together in that home; it needs its own working sign-in and doesn't relocate `~/
|
||||
> - Codex: hooks, workspace trust and team-selected config fragments (including MCP) in the selected
|
||||
> `CODEX_HOME/config.toml`, default `~/.codex/config.toml`. These affect other Codex sessions using that home.
|
||||
> - Claude Code: workspace trust in `~/.claude.json`, and activity hooks in the project's
|
||||
> `.claude/settings.local.json`, plus a status line unless you already have your own.
|
||||
> `.claude/settings.local.json`, plus a status line unless you already have your own. `rig down` takes the hooks
|
||||
> and status line back out once no Claude seat uses that directory.
|
||||
> - Discovery skills go in `~/.claude/skills` and `~/.agents/skills`.
|
||||
> - Each seat's `CLAUDE.md` or `AGENTS.md` gets OpenRig's managed blocks (culture, identity, onboarding). One more
|
||||
> line, pointing an agent at its seat skills, is added only on a yes: saved at setup, or set where the daemon runs
|
||||
@@ -75,8 +76,17 @@ You need Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude
|
||||
Code or Codex account: one is enough, since the kernel operator adapts the
|
||||
shipped `starter` team to whichever you have. On Linux, the distribution's own
|
||||
Node.js can be older (Ubuntu 24.04's is 18); install a supported version with
|
||||
[nvm](https://github.com/nvm-sh/nvm) (`nvm install 22`) or NodeSource. On a Mac
|
||||
with Apple silicon, use Node.js 22 (see the [compatibility
|
||||
[nvm](https://github.com/nvm-sh/nvm) (`nvm install 22`) or NodeSource.
|
||||
|
||||
For a tarball or manual Node install on Linux, put its bin directory on PATH in
|
||||
`~/.profile`, for example `export PATH="/path/to/node/bin:$PATH"`, rather than only
|
||||
in an interactive `~/.bashrc`. Bash reads the first of `~/.bash_profile`,
|
||||
`~/.bash_login` and `~/.profile`; if one of the first two exists, source `~/.profile`
|
||||
there. Other shells use their own login file (for example, zsh's `~/.zprofile`).
|
||||
Check `bash -lc 'command -v node rig'`: both paths must appear in the output.
|
||||
Use Codex's tool shell for this check if it differs from Bash.
|
||||
|
||||
On a Mac with Apple silicon, use Node.js 22 (see the [compatibility
|
||||
history](../releases/v0.5.15.md#known-compatibility-limitation)). On a Mac where
|
||||
you aren't an administrator, or where Homebrew belongs to another account (its
|
||||
`brew install` is refused), install Node.js in your own home folder with
|
||||
@@ -191,16 +201,17 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
|
||||
script will do and changes nothing:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.10/scripts/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
|
||||
The same command without `--dry-run`,
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh`, installs the
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.10/scripts/install.sh | sh`, installs the
|
||||
latest published `@openrig/cli` with `npm install -g`,
|
||||
runs the Node.js and SQLite check, then `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
|
||||
Codex and may install a missing one, as described below. A failed step prints
|
||||
`FAILED [n/4] <command or check> (exit <code>)`. Where a provider isn't signed in yet, step 4 ends that way and
|
||||
`rig setup` lists each sign-in under "Some steps need attention". If the only remaining failures are provider
|
||||
`FAILED [n/4] <command or check> (exit <code>)` for steps 1–3. If step 4 is not ready, it reports
|
||||
`INSTALLED, SETUP NOT COMPLETE` and points to the details from `rig setup` above. Follow that output and any Fix, then rerun
|
||||
`rig setup`; the CLI does not need reinstalling. If the only remaining failures are provider
|
||||
sign-ins, the install steps finished: sign in to each selected provider as below, then continue at
|
||||
[Start the kernel](#start-the-kernel-and-check-its-state). To install only what the selected providers need, go step
|
||||
by step instead:
|
||||
@@ -259,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.
|
||||
@@ -290,6 +312,17 @@ fine; if both accounts are authenticated, the kernel uses both even when your
|
||||
later project uses just one. A request to use only one provider for everything
|
||||
is a separate choice.
|
||||
|
||||
`/api/kernel/status` retains the chosen template (`rig.yaml`,
|
||||
`rig-claude-only.yaml` or `rig-codex-only.yaml`) across daemon restarts and
|
||||
recovery, including starts with `--no-kernel`. It reads the rig's latest rig-spec
|
||||
bootstrap record, including a partial launch, so an older kernel's recorded
|
||||
selection is also available. This is the original selection; the rig's live spec
|
||||
describes its current configuration. The saved source must match a template under
|
||||
the current installation’s built-in specs directory. A custom project path with
|
||||
the same directory suffix, an old source path that can no longer be verified
|
||||
(for example, after a Node version change moves the global install), or a kernel
|
||||
without bootstrap history reports an unknown variant.
|
||||
|
||||
**Started is not ready.** Read the `Kernel:` boot state in `rig status` and the
|
||||
individual seat state in `rig ps --nodes --rig kernel`. The view may open while
|
||||
agents finish starting; describe that state, without claiming they are ready.
|
||||
@@ -482,7 +515,7 @@ different points in the same journey:
|
||||
| What you see | Why and what to do next |
|
||||
| --- | --- |
|
||||
| Codex offers to switch to a cheaper model near a rate limit | This is Codex's own menu. Ask the person to choose in the seat's terminal; do not silently change the team's configured model. OpenRig disables Codex's startup update check for managed launches, but does not answer this model-choice menu. |
|
||||
| A missing Node.js, npm or tmux; an install download or command fails | The named prerequisite or install step did not finish. Read its error and the [platform and provider instructions](#install-and-sign-in), resolve that step, then recheck it. `FAILED [4/4]` with only selected-provider sign-ins outstanding means the package installed but those logins still need the person. |
|
||||
| A missing Node.js, npm or tmux; an install download or command fails | The named prerequisite or install step did not finish. Read its error and the [platform and provider instructions](#install-and-sign-in), resolve that step, then recheck it. `INSTALLED, SETUP NOT COMPLETE` means the CLI is installed but setup needs attention; follow its output and any Fix, then rerun `rig setup`. |
|
||||
| The installing harness refuses a tool call or asks for permission | Its native rules, sandbox or automatic permission decision apply before OpenRig can run. Claude Code's [auto mode can deny calls](https://code.claude.com/docs/en/auto-mode-config) as well as approve them. Show the specific refusal and ask the person to review it in the harness permission controls or run that named step themselves. Keep their chosen restrictions; a refused call does not prove the OpenRig command failed. |
|
||||
| Herdr or Ghostty installation is unavailable or declined | Herdr is optional for the view: `--window` uses plain tmux when Herdr is absent, and its install failure is a warning. On macOS, Terminal.app is the fallback when supported Ghostty is absent. An explicitly requested Ghostty install can fail setup; resolve that install or decline it with `--no-ghostty`, preserving earlier choices. Address separate required-tool or login failures. Existing cmux remains supported. |
|
||||
| macOS asks for Automation access, or the window action is denied | The command asks the desktop terminal app to open a new surface. Let the person answer the system prompt. Read the error and inspect any new window before retrying an uncertain action; do not silently switch apps and open a duplicate. |
|
||||
@@ -1128,8 +1161,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
|
||||
|
||||
@@ -3,17 +3,37 @@
|
||||
> **Status.** This is a draft for contributors.
|
||||
> - **Released in 0.7.6:** the `runner` field and OpenRig's side of declaration, identity and activity
|
||||
> (sections 2-4).
|
||||
> - **Proposed runner-author behavior:** the input, cancellation and permission recipe in sections 5-7, and the
|
||||
> runner-side shutdown obligation in section 9. A dsh port implementing this recipe is not qualified.
|
||||
> - **Released in 0.7.8:** seat activity grants (section 4), and OpenRig's side of the answered permission path
|
||||
> (section 7).
|
||||
> - **Runner-author behavior, not yet qualified:** the input, cancellation and permission recipe in sections 5-7, and
|
||||
> the runner-side shutdown obligation in section 9. Two runners have shown parts of it natively (below); no
|
||||
> runner is qualified beyond that.
|
||||
> - **Existing interfaces with author obligations:** launch-result identity, exact resume, first input and model
|
||||
> handoff. These interfaces do not establish that a particular runner implements them correctly.
|
||||
> - **Unchanged:** plain community presets and the built-in runtimes (Claude Code, Codex, Pi, OMP, terminal).
|
||||
>
|
||||
> No harness has been run end to end under this contract yet. 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](https://github.com/mvschwarz/openrig/blob/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-presets.md)
|
||||
> describes the implemented fields at that base; this chapter also labels proposed author behavior.
|
||||
> [community presets reference](community-harness-presets.md) describes the implemented fields, including the runner
|
||||
> preset's exception for approvals; this chapter also labels runner-author behavior that isn't qualified.
|
||||
|
||||
This chapter covers **which agent runs in a seat and how OpenRig talks to it**. *Where* that agent runs (a host, a
|
||||
sandbox, another machine) is the [execution platform chapter](execution-platform-contract.md). The two are separate on purpose:
|
||||
@@ -104,6 +124,8 @@ Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer
|
||||
|---|---|---|
|
||||
| You gave the agent the prompt for a submitted message | `UserPromptSubmit` / `accepted`, `turnId` = your prompt number | working |
|
||||
| The agent is working (message chunks, tool calls) | `active` / `agent_message`; `PreToolUse` / `<tool kind>` | working |
|
||||
| Optional working beat, from 0.7.9: the turn is still outstanding and the agent alive, with nothing new to report (a long tool or model call) | `active` / `liveness`, with `turnId`, `sequence` and `livenessSeconds` | working while beats keep coming |
|
||||
| From 0.7.9: you delivered an answer or permit to a request OpenRig had shown as needing input, and the agent continues the same turn (section 7) | `active` / `permission_resolved`, with `turnId` and `askId` | working |
|
||||
| You refused a permission request (section 7) | `Notification` / `permission_prompt` | needs input |
|
||||
| The turn ended | `Stop` / its stop reason (`end_turn`, `cancelled`, `max_tokens`, `refusal`) | idle. While you hold attention (section 7), don't post it: OpenRig reads any non-exit `Stop` as idle and clears needs input |
|
||||
| Optional idle heartbeat | `Stop` / `idle_heartbeat`, with `livenessSeconds` | idle while beats keep coming |
|
||||
@@ -114,6 +136,41 @@ Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer
|
||||
idle `Stop`, the turn's end as well as each beat. OpenRig stops reading the seat idle after 2 × `livenessSeconds` without
|
||||
a newer idle report. Without it, an idle report holds until your next post, even if your runner has died. Never send a
|
||||
non-exit `Stop` or a beat while holding permission attention.
|
||||
- **Working evidence lasts 15 seconds.** A working post (`UserPromptSubmit`, `active`, `PreToolUse`) decides the seat's
|
||||
activity for 15 s. After that the seat reads from its other evidence: the screen can still show work, but it never
|
||||
reads a runner seat idle, so with nothing else the seat reads unknown.
|
||||
- **The working beat (from 0.7.9)** keeps a long, quiet turn reading working. It says only that this turn's prompt is
|
||||
outstanding and the agent process you started is alive. It isn't progress.
|
||||
- **When to beat:** only while a prompt is outstanding and the agent is alive, more often than every
|
||||
`livenessSeconds` ÷ 2.
|
||||
- **When to stop:** stop at once on the turn's `Stop`, on a permission request that needs a person, or on the agent's
|
||||
exit, and post that boundary first.
|
||||
- **`livenessSeconds`** is how long one beat counts as working. OpenRig clamps it to 5-60 s, and uses 30 s when a beat
|
||||
has none. After that the beat stops counting, and the seat reads from its other evidence, as a working post's does.
|
||||
- **A beat counts only when all of these hold:**
|
||||
- it carries `generation`, equal to the seat's live occupant generation, and so did the `UserPromptSubmit` /
|
||||
`accepted` post that opened its turn;
|
||||
- its `turnId` is your latest `UserPromptSubmit` / `accepted` turn, and you haven't posted that turn's `Stop` or an
|
||||
exit;
|
||||
- it carries a `sequence`, greater than every earlier post's in the same generation that OpenRig counted, beats
|
||||
included;
|
||||
- the seat has no attention outstanding, such as a permission request waiting on a person.
|
||||
|
||||
So number every post with one `sequence` that rises across your whole runner process, beats included. OpenRig
|
||||
orders your posts by `occurredAt`, then `sequence`. A post older than one OpenRig already counted changes nothing:
|
||||
a late `Stop` for an earlier turn doesn't end the current one, and an older beat doesn't count. A post without a
|
||||
valid `occurredAt` is dated when it arrives, by OpenRig's clock, so it can read older than it is when your clock
|
||||
runs ahead. Give every post its `occurredAt`.
|
||||
- **After you deliver an answer or permit** to a request OpenRig had shown as needing input (section 7; it created an
|
||||
ask, and a permit at submit opens none), post `active` / `permission_resolved` with the turn's `turnId` and the
|
||||
`askId`, before your next beat. That clears the seat's attention. So does any other working or idle post OpenRig
|
||||
counts, and an operator's `rig seat clear-attention`. OpenRig doesn't match `permission_resolved` to its `askId`.
|
||||
Beats are ignored until attention clears.
|
||||
- **A beat that fails any of these is ignored.** The seat reads as it would without it. A beat never starts or extends
|
||||
a turn, never clears attention, and never stands in for a `Stop`.
|
||||
- **OpenRig keeps what it needs to judge beats in memory.** After a daemon restart, a beat already counted keeps its
|
||||
lifetime, but new beats are ignored until your next accepted prompt, and the seat reads as it would without them.
|
||||
- **A runner that sends no beats** behaves as before.
|
||||
- **Exit:** post `Stop` / `<preset name>_exited` only when you observe the agent's process exit.
|
||||
|
||||
- **The screen still counts,** but only as evidence of work. On its own it never reads a runner seat idle.
|
||||
@@ -136,13 +193,17 @@ Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer
|
||||
A refused post records nothing, so the seat's activity stays as it was, or unknown.
|
||||
|
||||
**The guest's boundary is its listener, not the grant.** A guest reaches OpenRig only through a guest-only listener that
|
||||
serves this one route and accepts only a grant. It must never reach the daemon's main port. The grant scopes activity
|
||||
posts only, and the main port's other routes don't check it.
|
||||
serves this route and section 7's three permission-request routes, and accepts only a grant. It must never reach the
|
||||
daemon's main port. The grant scopes activity posts and permission requests, and the main port's other routes don't
|
||||
check it.
|
||||
- The listener is off by default. Turn it on with `OPENRIG_GUEST_ACTIVITY_PORT`, which must differ from the daemon's
|
||||
main port or the daemon refuses to start.
|
||||
- It binds `OPENRIG_GUEST_ACTIVITY_HOST`, default `127.0.0.1`: set it to an address the guest can reach.
|
||||
|
||||
## 5. Input: one submitted message is one prompt
|
||||
|
||||
Sections 5-7 specify the proposed first dsh/ACP runner recipe. They do not change permission or input handling in
|
||||
existing built-in runtimes or require every possible runner protocol to use ACP.
|
||||
Sections 5-7 specify the first dsh/ACP runner recipe; no runner has been qualified against it yet. They do not change
|
||||
permission or input handling in existing built-in runtimes or require every possible runner protocol to use ACP.
|
||||
|
||||
- **Set up the pane:** put your TTY in raw mode and turn on bracketed paste (`\e[?2004h`).
|
||||
- **What arrives:** `rig send` and queue reminders paste the whole message, then press Enter. Everything up to that Enter
|
||||
@@ -162,6 +223,8 @@ existing built-in runtimes or require every possible runner protocol to use ACP.
|
||||
|
||||
- **On `/abort` or Ctrl-C:** send the agent's cancel (for ACP, the `session/cancel` notification, which has no reply),
|
||||
and record when you sent it.
|
||||
- A person cancels by typing `/abort` in the seat's pane. A `/abort` sent with `rig send` arrives inside a From/To
|
||||
envelope, so it's an ordinary prompt.
|
||||
- **Report "cancelled" only when the agent says so:** for ACP, when `session/prompt` returns `stopReason: "cancelled"`.
|
||||
Then post `Stop` / `cancelled`.
|
||||
- **Whether the provider stopped work or spend is unknown.** Don't claim it.
|
||||
@@ -198,7 +261,7 @@ that runs without asking gains no approval step. Ordinary conversation and `rig
|
||||
|---|---|
|
||||
| **There's a default person** (chosen with `rig gateway default`, or the only registered person), and no override | They get an ordinary ask in the Feed and on Slack. No setup is needed. |
|
||||
| **The seat's operator chose someone** (`rig seat set-permissions-to <seat> --to <person> --reason "…"`) | That person gets the ask instead. |
|
||||
| **No one can be asked** | **Permitted at once**, with the agent's own allow option. No ask is opened. That covers: no registered person; several and none the default; the chosen person no longer registered; the people unreadable; or options a person can't be shown (bad ids, or not 2-4). The agent's `allow_once` is used, else its `allow_always`. If the agent offers only `allow_always`, the harness then remembers a permission nobody chose. |
|
||||
| **No one can be asked** | **Permitted at once**, with the agent's own allow option. No ask is opened, and OpenRig records the permit (below). That covers: no registered person; several and none the default; the chosen person no longer registered; the people unreadable; or options a person can't be shown (bad ids, or not 2-4). The agent's `allow_once` is used, else its `allow_always`. If the agent offers only `allow_always`, the harness then remembers a permission nobody chose. |
|
||||
| **The seat's operator opted in to refusal** (`rig seat set-permissions-to <seat> --deny --reason "…"`) | Refused at once and held as needs input, as in the first version. |
|
||||
|
||||
**What the ask shows:**
|
||||
@@ -217,7 +280,7 @@ that runs without asking gains no approval step. Ordinary conversation and `rig
|
||||
| replies with just a no ("no", "deny", "reject", "stop" …), or denies it in the Feed | the agent's reject (`reject_once`, else `reject_always`), or, with no reject option, cancelled. A person's no is never a grant |
|
||||
| routes or hands it to another person | the ask waits for that person, who gets the same options; their answer counts as if they'd been asked. The handoff itself never permits |
|
||||
| routes or hands it to an agent seat | that seat gets no options. The request waits until that row is closed, by the seat or a person. Closed as done without a choice (including a drop), or as failed, is no answer, so it's permitted. Closed as denied (the Feed's deny, `--closure-reason denied`, or the state `denied`) is a no. A row set to the state `canceled` cancels the request, and a further handoff is followed; a person it reaches gets the same options. Or the request waits until the agent's turn ends |
|
||||
| closes it without choosing: drop, approve, or other typed words | no answer, so the default applies: **permitted** with the agent's allow |
|
||||
| closes it without choosing: drop, approve, or other typed words | no answer, so the default applies: **permitted** with the agent's allow, and OpenRig records the permit (below) |
|
||||
| doesn't answer | the request **stays pending and visible** (the seat reads needs input) until the agent's turn ends. It's never timed out into a grant. |
|
||||
|
||||
- **An answer reaches only the live request it was asked for,** once. A request from an earlier occupant reads
|
||||
@@ -225,6 +288,17 @@ that runs without asking gains no approval step. Ordinary conversation and `rig
|
||||
- **Text typed into the runner's pane, an agent's own text, and `rig send` are never an answer.** While a request waits
|
||||
on a person, the seat reads needs input, and sends are normally refused. A send that does get through reaches the
|
||||
agent as a prompt.
|
||||
- **From 0.7.9, each permit OpenRig decides by default is recorded,** once, as a stream item from the seat. That covers
|
||||
no one to ask, and an ask closed without a choice (the row passed to an agent seat included). The record names:
|
||||
- the tool call;
|
||||
- why no person answered, including, for a closed ask, who closed which row and how;
|
||||
- the option the agent got, with its kind. An `allow_always` option is marked as remembered by the agent's harness;
|
||||
- the conversation and request, or the ask.
|
||||
|
||||
For an ask opened before 0.7.9, the record may omit the option kind and whether the harness remembers it.
|
||||
|
||||
Read the records with `rig stream list --tag runner-permission-permit`, or `--source <seat>`. A record wakes no one,
|
||||
and one that can't be written doesn't change the permit.
|
||||
|
||||
**The runner's protocol:**
|
||||
1. **Submit** the agent's live request to `POST <base>/api/activity/permission-requests`, with the same token or seat
|
||||
@@ -252,11 +326,15 @@ that runs without asking gains no approval step. Ordinary conversation and `rig
|
||||
`refused` (with `reason`: `said_no` when the person said no and the agent offered no reject option, or
|
||||
`no_allow_offered` when it was closed without a choice and the agent offered no allow option), `withdrawn` or
|
||||
`stale`.
|
||||
- **From 0.7.9, `said`,** with `permitted` or `refused` (`no_allow_offered`), is the person's own words, when they
|
||||
answered in words that were neither a choice nor a plain yes or no. It never changes the outcome.
|
||||
- **A seat grant reads and withdraws only its own launch's requests.** Once its occupant is replaced, the grant is
|
||||
refused (`409 activity_grant_stale`), even during a read's wait.
|
||||
3. **Deliver** `{"outcome": {"outcome": "selected", "optionId": "<optionId>"}}` to the agent once, and only while that
|
||||
request is still its live one. For `refused`, `withdrawn` or `stale`, answer `cancelled` and hold no attention: no
|
||||
person is still waiting. A refusal of the read itself follows the refusals below.
|
||||
person is still waiting. A refusal of the read itself follows the refusals below. From 0.7.9, when the read carries
|
||||
`said`, queue those words for the agent as an ordinary prompt: they reach it after the current turn, behind any prompt
|
||||
already waiting. They're never the answer. A runner that drops them still keeps this contract.
|
||||
4. **Withdraw** when the turn is cancelled or ends, or the agent exits:
|
||||
`POST <base>/api/activity/permission-requests/<askId>/withdraw` with
|
||||
`{"reason": "turn_cancelled" | "turn_ended" | "exited"}`.
|
||||
@@ -280,20 +358,21 @@ answer. Only the seat's opt-in refuses and holds attention.
|
||||
|
||||
**When the runner can't get an answer from OpenRig,** nobody can be asked, so it permits with the agent's own allow
|
||||
option: `allow_once` when offered, else `allow_always`. Prefer `allow_once`, so the harness doesn't remember a
|
||||
permission nobody chose. That covers:
|
||||
permission nobody chose. Such a permit is the runner's own, so OpenRig has no record of it: the runner prints it in
|
||||
its pane. That covers:
|
||||
- an OpenRig without this path, which answers 404;
|
||||
- an OpenRig the runner can't reach, at submit or while reading (a runner may retry a read that got no answer a few
|
||||
times first);
|
||||
- a refusal the table marks "permits", and any status or code this section doesn't list.
|
||||
|
||||
**Limits in 0.7.8:**
|
||||
**Limits of this path:**
|
||||
- **The answer owner is a registered person.** Choosing a seat (another agent) to answer is a later change.
|
||||
- **A colleague's answer is followed on this host.** If the ask is handed to another host, the request waits, shown as
|
||||
needs input, until the agent's turn ends.
|
||||
- **An override or `--deny` is set with the command above, never in a rig spec.** A spec field is a possible later
|
||||
change.
|
||||
- **Permits and answers aren't recorded beyond the runner's pane and the ask itself.** A permit with no one to ask
|
||||
opens no ask.
|
||||
- **A person's answer is recorded on the ask itself,** and a default permit as the stream item above. That item lasts as
|
||||
long as stream retention keeps it. A permit the runner decides on its own is in its pane only.
|
||||
|
||||
## 8. Resume: exactly the same conversation, or say honestly that you couldn't
|
||||
|
||||
|
||||
@@ -107,6 +107,38 @@ OpenRig doesn't observe a hosted member between its calls, so it never reads idl
|
||||
|
||||
`rig mcp token revoke grok` refuses its next request. The rows it worked, and their answers, stay.
|
||||
|
||||
## Assistants connected through a grant service
|
||||
|
||||
With `rig mcp serve --http --grant-service <url>`, an outside service answers each connection's key with its grant
|
||||
(`openrig.grant/v1`): the seat it acts as, its rigs, and the object/action pairs it may use. Each pair offers one tool,
|
||||
and nothing else is offered:
|
||||
|
||||
| Pair | Tool |
|
||||
|---|---|
|
||||
| `queue`/`create`, `queue`/`show`, `queue`/`list` | `rig_queue_create`, `rig_queue_show`, `rig_queue_list` |
|
||||
| `queue`/`claim`, `queue`/`update` | `rig_queue_claim`, `rig_queue_update` |
|
||||
| `team`/`list` | `rig_team` |
|
||||
| `artifact`/`read` | `rig_artifact_read` |
|
||||
|
||||
- **One seat per connected assistant:** the service gives each connection its own seat and keeps it across that
|
||||
connection's reconnects. The seat must be one of the rig's nodes; a node created without a launch is enough. The caller
|
||||
is exactly that seat.
|
||||
- **What it reaches:**
|
||||
- It hands work to its rigs' other seats, never to a person or another host's seat.
|
||||
- It reads the items it sent and the items addressed to it from those seats, never the team's internal items.
|
||||
- With `queue`/`claim` and `queue`/`update`, it claims and closes only items addressed to its own seat that it can read,
|
||||
and waits (blocked) only on an item it names and can read. Its answers hide what reading the item hides.
|
||||
- **Two assistants together:** a grant with `team`/`list` sees its rigs' seats with `rig_team`, and may address and hear from
|
||||
another connected assistant's seat. A grant without it never reaches a seat in the service's caller form
|
||||
(`cus-<id>-<connection>@<rig>`), so an existing connection keeps the reach it had.
|
||||
- **Messages are items:** a message is an item the receiver reads and closes, and a reply is an item back to its sender.
|
||||
- **What the sender can tell, from the item it reads:**
|
||||
- **Queued:** the item exists and is `pending` with no claim.
|
||||
- **Picked up:** the receiver's claim (`in-progress`).
|
||||
- **Answered:** closed `done`, with the answer in `resolution`.
|
||||
- Reading or listing an item records nothing, and nothing wakes an assistant, so an item stays queued until its receiver
|
||||
claims it.
|
||||
|
||||
## Limits today
|
||||
|
||||
- **Webhook triggers only:** OpenRig rings a webhook. Email and other triggers aren't supported yet. A member without a trigger
|
||||
|
||||
@@ -110,6 +110,17 @@ limitation: use the live spec-library commands to determine where a spec is
|
||||
served from, and do not infer convergence merely because the canonical
|
||||
directory exists.
|
||||
|
||||
The spec library scans the built-in specs, `$OPENRIG_HOME/specs` and, when
|
||||
present, that legacy library. At any depth below them it skips directories
|
||||
named `.git`, `.worktrees`, `node_modules`, `dist`, `build`, `.turbo` and
|
||||
`.next`, which usually hold build output or dependencies (`SKIP_DIRS` in
|
||||
`packages/daemon/src/domain/spec-library-service.ts`). A spec inside one of
|
||||
them, such as `specs/build/rig.yaml`, isn't listed, and nothing reports that it
|
||||
was skipped. To list it, rename its directory and run `rig specs sync`.
|
||||
`rig specs add <directory>` copies a directory under its own name, so adding a
|
||||
directory named `build` doesn't help. `rig up <path-to-rig.yaml>` still launches
|
||||
a spec by its path, without the library.
|
||||
|
||||
Config updates publish a complete replacement file atomically where directory
|
||||
permissions and the filesystem allow it. On POSIX systems, replacement preserves
|
||||
the existing owner, group, and read/write/execute permission bits. If creating or
|
||||
|
||||
@@ -107,6 +107,28 @@ rig queue create --destination <entityId>@external --human-intent update \
|
||||
- **`posted` means the connector posted it, not that they read it.** Don't say they saw it.
|
||||
- **If delivery fails or is indeterminate,** inspect that task before anything else. Never resend blindly.
|
||||
|
||||
## If the timer cannot reach its recipient
|
||||
|
||||
A `periodic-reminder` occurrence that definitely fails before input remains owed. The watchdog
|
||||
retries through its existing loop, using `queue.wake_retry_interval_seconds` and
|
||||
`queue.wake_retry_cap` (by default, three retries five minutes apart). A failed attempt does not
|
||||
advance the reminder to its next regular cycle. A confirmed send advances the normal interval;
|
||||
recovery does not replay every elapsed cycle.
|
||||
|
||||
`rig watchdog show <jobId> --json` includes the failed attempt, its retained outbox IDs, retry
|
||||
time and next action. If input may have reached the recipient, that occurrence ends unconfirmed
|
||||
and is not automatically sent again. Inspect the recipient and exact outbox record before any
|
||||
manual replay. A recurring reminder keeps its next normal cycle under a new occurrence identity. A confirmed receipt survives a daemon restart, so
|
||||
finishing its accounting does not type the same occurrence twice.
|
||||
|
||||
Exhausted retries end only the failed occurrence, with its cause and next action; a recurring
|
||||
reminder keeps its next normal cycle. A permanent transport failure leaves a terminal job:
|
||||
correct the destination and register a replacement reminder. A one-shot park timer records its
|
||||
failed or unconfirmed wake on the parked row before it ends. A retryable failure is recorded
|
||||
there while the timer stays armed. Deliberately held
|
||||
input keeps its existing retained-message custody. Condition watches and parked-owner policy
|
||||
behavior are unchanged. This does not replay failed occurrences recorded by older versions.
|
||||
|
||||
## "What are you watching for me?"
|
||||
|
||||
List **every active state**, not just set-aside ones. A watch being checked can be in progress, and a new one can be
|
||||
|
||||
@@ -25,7 +25,8 @@ that restores nothing is not itself evidence of accidental loss.
|
||||
|
||||
## Choose the interface the seat actually has
|
||||
|
||||
Pi 1.4.0 includes an opt-in selector at `examples/extensions/tools.ts`. Its
|
||||
Pi 1.4.0 includes an opt-in selector at
|
||||
`packages/coding-agent/examples/extensions/tools.ts` in the Pi repository. Its
|
||||
`/tools` command remembers selection per branch and needs Pi's own TUI. Use it
|
||||
where that extension and native TUI are available. **Do not send `/tools` to an
|
||||
agent OpenRig drives over RPC**: that interface cannot operate the selector.
|
||||
|
||||
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
|
||||
```
|
||||
|
||||
For a new install, use the latest release's tag, for example `v0.7.5`. If OpenRig is already installed, use the tag
|
||||
For a new install, use the latest release's tag, for example `v0.7.10`. If OpenRig is already installed, use the tag
|
||||
that matches `rig --version`, or upgrade first. If the command fails with `npx: command not found`, Node.js isn't
|
||||
installed or isn't on your PATH yet (see [What you need](#what-you-need)). Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what
|
||||
you want built.
|
||||
@@ -90,7 +90,8 @@ Use `codex` or `both` in place of `claude` to match the coding agents you use. A
|
||||
- it adds an OpenRig block to `~/.tmux.conf` (mouse support and longer scrollback).
|
||||
|
||||
It doesn't start anything. It ends with a summary of what's done and what still needs you. A missing sign-in shows
|
||||
there: run `claude auth login` or `codex login`, then carry on.
|
||||
there: run `claude auth login` or `codex login`, then carry on. Setup also tries to record the selected providers
|
||||
for a new kernel's first boot; its summary warns if it could not save the choice.
|
||||
|
||||
### 3. Start OpenRig
|
||||
|
||||
@@ -100,12 +101,16 @@ rig status
|
||||
rig ps --nodes --rig kernel
|
||||
```
|
||||
|
||||
The daemon starts and boots the kernel with the coding agents you're signed in to, or both if you're signed in to both.
|
||||
On the kernel's first boot, the daemon uses the providers selected by `rig setup --providers` that are signed in.
|
||||
If no provider choice was recorded, it considers every signed-in provider. An existing kernel keeps its seats;
|
||||
changing the setup choice does not replace them. An `OPENRIG_PROVIDER_SELECTED` value in the daemon's environment
|
||||
takes precedence over the saved choice.
|
||||
|
||||
**What starting OpenRig changes, for the coding agents you use** (paths are the usual defaults):
|
||||
- **For Codex:** hooks and trust in `~/.codex/config.toml`, when OpenRig or a Codex agent starts.
|
||||
- **For Claude Code:** workspace trust in `~/.claude.json`, and activity hooks in the project's
|
||||
`.claude/settings.local.json`, plus a status line unless you already have your own.
|
||||
`.claude/settings.local.json`, plus a status line unless you already have your own. `rig down` takes the hooks and
|
||||
status line back out once no Claude seat uses that directory.
|
||||
- **For both:** small discovery skills in `~/.claude/skills` and `~/.agents/skills`.
|
||||
- **Kernel access:** by default the kernel gets broader permissions than a project team gets by default. On Codex
|
||||
that's full access with no approval prompts. On Claude Code, its file tools, web fetches and operational commands,
|
||||
|
||||
@@ -91,10 +91,10 @@ my-rig.rigbundle.sha256 — "a1b2c3d4..." (64-char hex SHA-256)
|
||||
The packer produces deterministic output:
|
||||
- Files are sorted alphabetically
|
||||
- A fixed mtime is used (`2026-01-01T00:00:00Z`)
|
||||
- Portable mode normalizes uid/gid/mode
|
||||
- Portable mode omits machine-specific owner metadata; file mode bits can be retained
|
||||
- Maximum gzip compression (level 9)
|
||||
|
||||
Identical staged bytes (including manifest timestamps and provenance) produce the same archive hash. Use the package digest to compare packaged file content independently of manifest metadata.
|
||||
Identical staged bytes and file modes (including identical manifest timestamps and provenance) produce the same archive hash. A mode change can change the archive hash even when the file contents are unchanged. Use the package digest to compare packaged file content independently of manifest metadata and file modes.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -180,7 +180,7 @@ edges:
|
||||
| `non_interruptive` | boolean | no | — | Pod-aware rig launch-warning default. With full-bypass Claude/Codex seats, true accepts or hides supported harness warnings using launch flags. Explicit CLI choices override this field; this field overrides the machine default. It does not grant permissions, change native settings files, or bypass login. |
|
||||
| `permission_policy` | string | no | — | Permission policy attached to the rig. Either a built-in (`builtin:locked`, `builtin:standard`, `builtin:open`, `builtin:yolo`, `builtin:auto`) or a safe relative path to a custom policy file (resolved from this spec's directory; no `..`, no absolute, no empty segments, each segment `[A-Za-z0-9][A-Za-z0-9._-]*`), or `none`, a recorded choice of the floor. Absent uses the default floor with the scoped team launch allowances described below. A bare built-in name such as `yolo` is refused ("use 'builtin:yolo'"), and so is an explicit `null`. A custom file that is missing, unreadable or invalid isn't a validation error: it resolves to the floor, and preflight warns. A member may set its own `permission_policy` (not on a terminal member), which takes precedence over the rig-level one; there is no pod level. See "Attaching a permission policy" below. |
|
||||
| `env` | map | no | — | Launch environment for every seat in the rig: `NAME: value` pairs. A member's own `env` overrides it name by name. Names match `^[A-Z_][A-Z0-9_]*$`, names starting `OPENRIG_` are reserved, and values must be non-empty strings (quote numbers and booleans). Values are not secret. See "Launch environment" below. |
|
||||
| `managed_blocks` | map | no | `CLAUDE.md` | File that receives OpenRig's managed instruction blocks for Claude Code members. Only the `claude-code` key is accepted, with `CLAUDE.md` or `CLAUDE.local.md`. Codex, Pi and OMP members use `AGENTS.md`. See "Choosing the Claude instruction file" below. |
|
||||
| `managed_blocks` | map | no | `CLAUDE.md` | File that receives OpenRig's managed instruction blocks for Claude Code members. Only the `claude-code` key is accepted, with `CLAUDE.md` or `CLAUDE.local.md`. Codex, Pi and OMP members use `AGENTS.md`, except that a fresh Pi seat normally uses its own agent folder's `AGENTS.md` and a fresh Codex 0.164.0+ seat normally gets launch developer instructions; a link, a conflicting or unreadable Codex config layer, or a resume keeps the repository file. See [Codex guidance at launch](agent-startup-guide.md#codex-guidance-at-launch) and [Runtime Differences](agent-startup-guide.md#runtime-differences). See "Choosing the Claude instruction file" below. |
|
||||
| `workspace` | object | no | — | The rig's workspace: `workspace_root` (required), `repos[]` of `{name, path, kind}` with `kind` one of `user`, `project`, `knowledge`, `lab` or `delivery` and unique names, an optional `default_repo` naming one of them, and an optional `knowledge_root`. Relative repo paths resolve against `workspace_root`. |
|
||||
| `docs` | Doc[] | no | — | Documentation files that should travel with the rig. Included in rig bundles. Each entry has a `path` field (safe relative path). The engine does not consume these — they are for humans and agents setting up the environment before launch. |
|
||||
| `startup` | StartupBlock | no | — | Rig-level startup files and actions. Applied to all members via the startup layering model. |
|
||||
@@ -740,7 +740,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`. |
|
||||
@@ -755,6 +755,7 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
|
||||
| `agent_file` | string | no | — | Claude members only. A Claude agent definition (a `.md` file with `name` in its frontmatter) for this seat. It's copied into the seat's own storage and launched as `--agent openrig:<name>`. A path beside the spec: no `..`, not absolute. |
|
||||
| `append_system_prompt_file` | string | no | — | Claude members only. A prompt file appended to Claude's system prompt for this seat. It's copied into the seat's own storage and passed as `--append-system-prompt-file`. A path beside the spec. |
|
||||
| `plugin_dirs` | list | no | — | Claude members only. Claude plugin folders for this seat, each copied into the seat's own storage and loaded through `CLAUDE_CODE_PLUGIN_DIRS`. Paths beside the spec. Each is copied under its folder name, so two folders with the same name are refused; an empty list is none. A missing file or folder fails that seat's launch by name. `rig bundle create` carries all three; `rig export` names a seat whose additions it doesn't carry. |
|
||||
| `plugin_channels` | list | no | — | Claude members only. Plugin channels this agent registers for, as `plugin:<name>@<marketplace>` entries, passed as one `--channels` on every launch; see [Claude plugin channels](#claude-plugin-channels-runtime-claude-code). |
|
||||
| `role` | string | no | — | The seat's role, letters, digits, `_`, `.` and `-`. Not valid on terminal members. |
|
||||
| `restore_policy` | string | no | the AgentSpec's | Restore behavior. One of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`. The default comes from the AgentSpec's `defaults.lifecycle` (else `resume_if_possible`) as narrowed by its profile; a member value may only narrow it further, in that order. |
|
||||
| `compaction_strategy` | string | no | — | `default-compaction`, `managed-compaction`, `handover` or `apprentice-handover`; `harness_native` and `pod_continuity` are accepted as deprecated aliases, and `custom_prompt` is refused. `managed-compaction` and `apprentice-handover` create their jobs only for `claude-code` seats; see [Codex compaction and sizing](agent-startup-guide.md#codex-compaction-and-sizing). |
|
||||
@@ -810,6 +811,52 @@ OpenRig keeps the requested opt-out flags, so startup and resume remain subject
|
||||
to that native refusal; it does not fall back to loading servers. Use a host
|
||||
without that managed configuration, or omit the opt-out to use the managed set.
|
||||
|
||||
### Claude plugin channels (`runtime: claude-code`)
|
||||
|
||||
Set `plugin_channels` on a Claude member to register that agent for channel
|
||||
notifications (messages a plugin's MCP server pushes into the session):
|
||||
|
||||
```yaml
|
||||
members:
|
||||
- id: support
|
||||
agent_ref: local:agents/support
|
||||
profile: default
|
||||
runtime: claude-code
|
||||
cwd: .
|
||||
plugin_channels:
|
||||
- plugin:example-channel@example-marketplace
|
||||
```
|
||||
|
||||
OpenRig passes `--channels plugin:example-channel@example-marketplace` to this
|
||||
agent's Claude on every launch, resume, fork, restore and handover. Several
|
||||
entries follow one `--channels`, space-separated, and OpenRig's own session
|
||||
options come after them. Claude Code 2.1.316 hides `--channels` from
|
||||
`claude --help`, so it isn't listed there. An agent without the field gets no
|
||||
`--channels`, and nothing is added for other agents or other runtimes; there is
|
||||
no rig-wide or environment default.
|
||||
|
||||
Each entry is `plugin:<name>@<marketplace>`, both names made of letters, digits,
|
||||
`.`, `_` and `-`, starting with a letter or digit. `server:` entries and Claude's
|
||||
`--dangerously-load-development-channels` aren't supported. Combining a non-empty
|
||||
channel list with `claude_mcp: none` produces a validation warning: that setting
|
||||
starts Claude without MCP servers (plugin servers included), so no channel could
|
||||
register. OpenRig keeps both settings and launches the member normally. Remove
|
||||
`claude_mcp: none` to use the channels, or remove `plugin_channels` to keep the MCP
|
||||
opt-out. OpenRig doesn't install plugins or marketplaces, or change the settings
|
||||
that admit a channel: whether each channel registers is Claude's decision (the plugin installed, and
|
||||
admitted by your organisation's channel settings or Claude's approved list).
|
||||
|
||||
Selecting a channel for one agent doesn't isolate what the plugin shares with
|
||||
other agents. A token or other credential the plugin keeps in its own
|
||||
configuration is the same for every agent that loads that plugin.
|
||||
|
||||
The selection is saved with the seat's other launch additions when the seat
|
||||
starts from the spec, and starting it from a spec without the field removes it.
|
||||
`rig seat launch --plan` and `rig skill loadout --seat` list it, a fork keeps it,
|
||||
and `rig bundle create` and `rig export` carry it in the member; a bundle's
|
||||
before-install view names each channel as a need. A running Claude process is
|
||||
unaffected.
|
||||
|
||||
### Claude Advisor (`runtime: claude-code`)
|
||||
|
||||
Set `advisor` independently of the primary `model`:
|
||||
@@ -1030,7 +1077,7 @@ file; missing optional files do not trigger that warning.
|
||||
| Hint | Behavior |
|
||||
|------|----------|
|
||||
| `auto` | System chooses based on file type and context. |
|
||||
| `guidance_merge` | Merged as a managed block into the runtime's guidance file: for Claude, the `managed_blocks` file (`CLAUDE.md` or `CLAUDE.local.md`); for Codex, Pi and OMP, `AGENTS.md`. Delivered before harness boot. |
|
||||
| `guidance_merge` | Merged as a managed block into the runtime's guidance file: for Claude, the `managed_blocks` file (`CLAUDE.md` or `CLAUDE.local.md`); for Codex, Pi and OMP, `AGENTS.md`, except that a fresh Pi seat normally uses its own agent folder's and a fresh Codex 0.164.0+ seat normally gets launch developer instructions (the conditions are in [Codex guidance at launch](agent-startup-guide.md#codex-guidance-at-launch) and [Runtime Differences](agent-startup-guide.md#runtime-differences)). Delivered before harness boot. |
|
||||
| `skill_install` | Installed as a skill in the runtime's skill directory. Delivered before harness boot. |
|
||||
| `send_text` | Sent as text to the agent's terminal after the harness is ready. Requires the agent TUI to be active. |
|
||||
|
||||
@@ -1274,3 +1321,15 @@ These are the built-in specs shipped with OpenRig. Read them as worked examples.
|
||||
Also shipped, in the same tree: `launch/kernel`, OpenRig's own team, which starts automatically. `first-project` is
|
||||
starter's old name and still resolves to it. `workshop`, the four-agent middle team, is a rig bundle rather than a
|
||||
built-in. List the built-ins with `rig specs ls --kind rig`.
|
||||
|
||||
To omit built-in rigs, agents and workflows from the shared spec inventory, run
|
||||
`rig config set specs.builtins_visible false`. This changes the CLI, TUI and other
|
||||
consumers of the library listing; user specs remain visible. Use
|
||||
`rig specs ls --include-builtins` to list them deliberately, and set the value
|
||||
back to `true` to restore the default. Explicit `show`, `preview`, and launch
|
||||
lookups can still find hidden built-ins. When built-ins are hidden and a user
|
||||
spec has the same name, the user spec wins the name lookup; use the built-in's
|
||||
ID from `rig specs ls --include-builtins --json` to select it. With built-ins visible,
|
||||
same-name matches remain ambiguous. This is an inventory preference, not an
|
||||
access-control boundary; other endpoints and on-disk files can still expose the
|
||||
built-ins.
|
||||
@@ -18,7 +18,7 @@ node check-runner.mjs --runner 'my-runner --agent-command {agent}' --name my-har
|
||||
- **`--wait-ms 20000`** waits longer for each step (default 8000), for a runner whose agent takes a while to start. The check's shorter settling times, and the fake agent's own delays and holds, grow in proportion.
|
||||
- **`--pass-env NAME`** passes one more variable through. Otherwise the runner gets only `PATH`, `HOME`, `USER`,
|
||||
`SHELL`, `LANG` and `TMPDIR`, plus the OpenRig variables.
|
||||
- **The exit status** is 0 when nothing fails, 1 when a case fails, and 2 for a usage or setup problem. An advisory result (7c) never changes it.
|
||||
- **The exit status** is 0 when nothing fails, 1 when a case fails, and 2 for a usage or setup problem. An advisory result (4c, 7c) never changes it.
|
||||
|
||||
In an installed OpenRig, the check is at `$(npm root -g)/@openrig/cli/daemon/docs/reference/runner-conformance/`.
|
||||
|
||||
@@ -51,11 +51,13 @@ In an installed OpenRig, the check is at `$(npm root -g)/@openrig/cli/daemon/doc
|
||||
| 1b | 3, 4 | A handshake that fails at `initialize` or `session/new` (an error or an exit) writes no conversation in a launch record, and posts no accepted prompt or idle report |
|
||||
| 2 | 5, 6 | One pasted block becomes exactly one `session/prompt`, byte for byte. A line reading `/abort` inside a block isn't control. A block sent mid-turn waits for the next turn. A block that is exactly `/abort`, or Ctrl-C outside a paste, sends one `session/cancel`. `Stop/cancelled` is posted only when the agent itself returns `cancelled` |
|
||||
| 3 | 4 | `UserPromptSubmit/accepted` with a `turnId`, then `Stop/end_turn`. Every post carries the contract's fields, the hook token, this seat's identity and a rising `sequence`. `livenessSeconds`, when given, is from 1 to 3600 |
|
||||
| 3b | 4 | **Optional, from 0.7.9; skipped when the runner sends no beat.** In a long, quiet turn, each `active/liveness` beat names the accepted turn, `sequence` rises with the runner's own `occurredAt` across every post, beats come more often than every `livenessSeconds` ÷ 2, and none follows the turn's `Stop`. While a person is asked, no beat is posted; after the answer, `active/permission_resolved` names the turn and the ask, before any beat. A permit at submit opens no ask, so the quiet turn's beats go on |
|
||||
| 4 | 7 | When no one can be asked, the request is permitted: an OpenRig without the answered path (404) at once, and one the runner can't reach, each get the agent's `allow_once` option, never its `allow_always` while `allow_once` is offered. The turn then reports as usual, with no `permission_prompt`. Only `denied_by_seat`, or a request with no allow option, is answered at once with a refusal, never an allow option. `Notification/permission_prompt` is then posted, and for `denied_by_seat` the pane shows what was asked (the tool call's title). No idle `Stop` or beat is posted while that attention is held, even when `/abort` then cancels the turn. The next prompt is accepted as usual |
|
||||
| 4b | 7 | The live request is submitted with the hook token, its `requestId` as the agent's JSON-RPC id, passed unchanged, the accepted prompt's `turnId`, this conversation and generation, and the agent's options as offered. The runner doesn't post `permission_prompt` itself for a created ask, and each read waits at most 25 s. The chosen option, allow or deny, reaches the agent once, and the turn then reports as usual. OpenRig's `permitted`, at submit or at a read, reaches the agent the same way. A refusal is read by its code, never its status: one section 7's table marks "permits" (`duplicate_request`, `activity_grant_pending`, `activity_grant_revoked`), a read that gets no answer, and a status or read state the section doesn't list (500, `deferred`) each give the agent its `allow_once` option. A refusal saying the request isn't the current occupant's (`stale_generation` at submit, `activity_grant_stale` on a read), and a `refused` (`said_no`), stale or withdrawn read, give the agent `cancelled`, and the turn then ends as usual, with nothing held. With no answer, the request stays pending with no idle report, and a pasted message isn't an answer. `/abort` withdraws it with `turn_cancelled`, and an answer after that isn't delivered. A turn's end or the agent's exit while it waits withdraws it with `turn_ended` or `exited`. Once the ask is withdrawn no person is still waiting, so the turn's end is reported as usual. An ask the runner permitted after a read got no answer, or an unlisted one, may still be open, so it's withdrawn at the turn's end too |
|
||||
| 4b | 7 | The live request is submitted with the hook token, its `requestId` as the agent's JSON-RPC id, passed unchanged, the accepted prompt's `turnId`, this conversation and generation, and the agent's options as offered. The runner doesn't post `permission_prompt` itself for a created ask, and each read waits at most 25 s. The chosen option, allow or deny, reaches the agent once, and the turn then reports as usual. OpenRig's `permitted`, at submit or at a read, reaches the agent the same way, and a read that carries the person's own words (`said`) still gives the agent the read's option. A refusal is read by its code, never its status: one section 7's table marks "permits" (`duplicate_request`, `activity_grant_pending`, `activity_grant_revoked`), a read that gets no answer, and a status or read state the section doesn't list (500, `deferred`) each give the agent its `allow_once` option. A refusal saying the request isn't the current occupant's (`stale_generation` at submit, `activity_grant_stale` on a read), and a `refused` (`said_no`), stale or withdrawn read, give the agent `cancelled`, and the turn then ends as usual, with nothing held. With no answer, the request stays pending with no idle report after the turn's accepted prompt, and a pasted message isn't an answer. `/abort` withdraws it with `turn_cancelled`, and an answer after that isn't delivered. A turn's end or the agent's exit while it waits withdraws it with `turn_ended` or `exited`. Once the ask is withdrawn no person is still waiting, so the turn's end is reported as usual. An ask the runner permitted after a read got no answer, or an unlisted one, may still be open, so it's withdrawn at the turn's end too |
|
||||
| 4c | 7 | **Advisory.** A read's `said`, the words of a person who answered in words, is queued to the agent as a prompt, after the turn and after any prompt already waiting. Section 7 lets a runner drop them and still keep the contract, so this result is shown as met or not met and never counts as a pass or a fail, or toward the exit status |
|
||||
| 5 | 4 | Every post carries `OPENRIG_OCCUPANT_GENERATION` |
|
||||
| 6 | 8 | A resume asks the agent to reopen `OPENRIG_RESUME_REF` in the launch's working folder, and records that conversation. The agent's own not-found becomes `error: "missing"`. Any other failure is neither missing nor resumed. No case starts a new conversation |
|
||||
| 7 | 4 | An agent that dies mid-turn gets `Stop/<preset>_exited`, and no idle report after it |
|
||||
| 7 | 4 | An agent that dies mid-turn gets `Stop/<preset>_exited`, and no idle report for that turn. Posts are taken in the runner's own order, as OpenRig orders them (`occurredAt` when it's a time string, otherwise the post's arrival, then `sequence` when both are positive whole numbers), so an idle report from before the turn began doesn't count, however late it arrives |
|
||||
| 7b | 4 | A failed `session/prompt` posts `Notification/runtime_error`, not a permission request |
|
||||
| 7c | 9 | **Advisory.** Closing the pane ends the agent process. Section 9 marks this duty as proposed, and lets a runner list in `limits` what may keep running, so this result is shown as met or not met and never counts as a pass or a fail, or toward the exit status. The fake agent outlives a hangup here, so the runner has to end it |
|
||||
| 8 | 7 | An unknown request from the agent gets JSON-RPC `-32601`, and the next prompt still goes through |
|
||||
|
||||
@@ -133,8 +133,8 @@ function askState(h, entry) {
|
||||
const script = h.asks.script;
|
||||
if (entry.withdrawn) return script.late ?? { state: "withdrawn" };
|
||||
if (script.answer && Date.now() >= entry.created + (script.answer.afterMs ?? 0)) {
|
||||
const { state, optionId, reason } = script.answer;
|
||||
return { state, ...(optionId ? { optionId } : {}), ...(reason ? { reason } : {}) };
|
||||
const { state, optionId, reason, said } = script.answer;
|
||||
return { state, ...(optionId ? { optionId } : {}), ...(reason ? { reason } : {}), ...(said ? { said } : {}) };
|
||||
}
|
||||
return { state: "pending" };
|
||||
}
|
||||
@@ -242,6 +242,19 @@ async function launch(label, { scenario = {}, env = {}, asks } = {}) {
|
||||
// An ordinary send arrives inside an envelope, so a line in it that reads /abort is still just text.
|
||||
const message = (h, body) => `From: checker@runner-check\nTo: ${h.session}\n---\n${body}`;
|
||||
const nonExitStops = (h, after = 0) => h.hooks("Stop").filter((p) => p.at >= after && !String(p.body?.subtype ?? "").endsWith("_exited"));
|
||||
// Whether post `a` comes before post `b` in the runner's own order, as OpenRig orders them: by `occurredAt` when it's a
|
||||
// non-blank string that reads as a time, otherwise by its arrival; then by `sequence`, when both carry a positive whole number.
|
||||
const postedAt = (p) => {
|
||||
const value = p?.body?.occurredAt;
|
||||
const t = typeof value === "string" && value.trim() ? Date.parse(value) : NaN;
|
||||
return Number.isFinite(t) ? t : p?.at ?? 0;
|
||||
};
|
||||
const sequenceOf = (p) => { const value = p?.body?.sequence; return Number.isSafeInteger(value) && value > 0 ? value : null; };
|
||||
const precedes = (a, b) => postedAt(a) !== postedAt(b) ? postedAt(a) < postedAt(b)
|
||||
: sequenceOf(a) !== null && sequenceOf(b) !== null && sequenceOf(a) < sequenceOf(b);
|
||||
// Non-exit Stops the runner posted after `post` in that order. An idle report from before the turn began (a runner may report
|
||||
// idle at startup) is never one made during it, however late its POST arrives.
|
||||
const stopsAfter = (h, post) => nonExitStops(h).filter((p) => !post || !precedes(p, post));
|
||||
const ready = (h, ms) => waitFor(() => h.record()?.nativeRef && h.record(), ms);
|
||||
|
||||
function result(id, title, { proposed = false } = {}) {
|
||||
@@ -297,7 +310,7 @@ const cases = {
|
||||
|
||||
async "2"() {
|
||||
const r = result("2", "Input framing, queued messages and cancel");
|
||||
const h = await launch("2", { scenario: { turns: [{}, { holdMs: scale(15000) }, { holdMs: scale(2500), ignoreCancel: true }, {}] } });
|
||||
const h = await launch("2", { scenario: { turns: [{}, { holdMs: scale(15000) }, { holdMs: scale(15000), ignoreCancel: true, endAfterCancelMs: scale(300) }, {}] } });
|
||||
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
|
||||
const block = message(h, "first line\n/abort\nthird line");
|
||||
h.paste(block);
|
||||
@@ -326,10 +339,12 @@ const cases = {
|
||||
r.check("one /abort sends one session/cancel", h.received("session/cancel").length === 1, `got ${h.received("session/cancel").length}`);
|
||||
r.check("the waiting block becomes the next prompt", await waitFor(() => h.prompts()[2] === queued, scale(4000)), `prompts: ${JSON.stringify(h.prompts().slice(2))}`);
|
||||
|
||||
// The agent keeps this turn open until the cancel arrives and then ends it end_turn, so a runner that handles Ctrl-C
|
||||
// late on a busy machine still has a turn to cancel. Only its delivery is timed, by the per-step bound.
|
||||
const interrupted = Date.now();
|
||||
await sleep(scale(200));
|
||||
h.keys("C-c");
|
||||
r.check("Ctrl-C outside a paste sends session/cancel", await waitFor(() => h.received("session/cancel").length >= 2, scale(3000)), "no second session/cancel");
|
||||
r.check("Ctrl-C outside a paste sends session/cancel", await waitFor(() => h.received("session/cancel").length >= 2), "no second session/cancel");
|
||||
const ended = await waitFor(() => nonExitStops(h, interrupted)[0], scale(6000));
|
||||
r.check("cancelled is reported only on the agent's own stop reason", ended?.body?.subtype === "end_turn",
|
||||
`the agent ended the turn end_turn; the runner reported ${ended ? `Stop/${ended.body.subtype}` : "no Stop"}`);
|
||||
@@ -364,6 +379,65 @@ const cases = {
|
||||
return r;
|
||||
},
|
||||
|
||||
// The optional working beat (section 4). When a runner posts active/liveness in a quiet turn, each beat names the open
|
||||
// turn it belongs to, keeps the post order, comes often enough for its own lifetime, stops at the turn's end, and never
|
||||
// runs while a person is asked; after an answer, active/permission_resolved comes before the next beat. Order is read
|
||||
// from the runner's own sequence and times, so a slow delivery never reads as a broken runner.
|
||||
async "3b"() {
|
||||
const r = result("3b", "Working beat (optional): beats name their open turn, keep order, and stop at its end and while a person is asked");
|
||||
const quiet = scale(22000);
|
||||
const seqOf = (p) => Number(p?.body?.sequence);
|
||||
const at = (p) => Date.parse(p.body.occurredAt);
|
||||
const h = await launch("3b", { scenario: { turns: [{ silent: true, holdMs: quiet }, {}] } });
|
||||
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
|
||||
h.paste(message(h, "a long, quiet turn"));
|
||||
const accepted = await waitFor(() => h.hooks("UserPromptSubmit", "accepted")[0]);
|
||||
const end = await waitFor(() => h.hooks("Stop", "end_turn")[0], quiet + opts.waitMs);
|
||||
await sleep(scale(11000)); // room for a beat a runner wrongly sends after the Stop
|
||||
const beats = h.hooks("active", "liveness");
|
||||
if (!beats.length) return r.skip("the runner sends no working beat in a quiet turn; the beat is optional (section 4)");
|
||||
r.check("each beat names the turn it belongs to", accepted && beats.every((b) => b.body.turnId === accepted.body.turnId),
|
||||
`turnIds: ${beats.map((b) => b.body.turnId).join(", ")}`);
|
||||
// In the runner's own time order, not delivery's: a post delivered late still has to fit where the runner sent it.
|
||||
const byTime = (a, b) => at(a) - at(b) || seqOf(a) - seqOf(b);
|
||||
const timed = h.posts().slice().sort(byTime);
|
||||
r.check("sequence rises with the runner's own time across every post, beats included",
|
||||
timed.every((p, i) => Number.isFinite(seqOf(p)) && Number.isFinite(at(p)) && (i === 0 || seqOf(p) > seqOf(timed[i - 1]))),
|
||||
`sequences in occurredAt order: ${timed.map(seqOf).join(", ")}`);
|
||||
const life = (b) => (Number.isFinite(b.body.livenessSeconds) ? b.body.livenessSeconds : 30);
|
||||
const inTurn = beats.filter((b) => !end || seqOf(b) < seqOf(end)).sort(byTime);
|
||||
const gaps = inTurn.map((b, i) => at(b) - at(i === 0 ? accepted : inTurn[i - 1]));
|
||||
r.check("beats come more often than every livenessSeconds / 2", inTurn.every((b, i) => gaps[i] <= life(b) * 500 + 1000),
|
||||
`gaps by the runner's own times: ${gaps.join(", ")} ms`);
|
||||
r.check("no beat after the turn's Stop", end && beats.every((b) => seqOf(b) < seqOf(end)),
|
||||
`beats after Stop/end_turn: ${beats.filter((b) => end && seqOf(b) > seqOf(end)).length}`);
|
||||
|
||||
// A person is asked first, and answers after a while; the agent then carries on, quietly, in the same turn.
|
||||
const p = await launch("3b-asked", { scenario: { turns: [{ permission: true, silent: true, holdMs: quiet }, {}] },
|
||||
asks: { answer: { afterMs: scale(12000), state: "answered", optionId: "allow-once" } } });
|
||||
if (!r.check("asked: the runner opens its conversation", await ready(p), "no launch record")) return r;
|
||||
p.paste(message(p, "edit the notes"));
|
||||
const submitted = await waitFor(() => p.permissionCalls("POST").find((c) => c.path === "/api/activity/permission-requests"), scale(5000));
|
||||
if (!r.check("asked: the request is submitted", submitted, "no submit")) return r;
|
||||
await waitFor(() => p.hooks("Stop", "end_turn")[0], scale(12000) + quiet + opts.waitMs);
|
||||
const resolvedPost = p.hooks("active", "permission_resolved")[0];
|
||||
const before = p.hooks("active", "liveness").filter((b) => at(b) > submitted.at && (!resolvedPost || seqOf(b) < seqOf(resolvedPost)));
|
||||
r.check("asked: no beat while the ask waits on a person", before.length === 0, `${before.length} beats while it waited`);
|
||||
r.check("asked: after the answer, active/permission_resolved names the turn and the ask, before any beat",
|
||||
resolvedPost && resolvedPost.body.askId && resolvedPost.body.turnId === p.hooks("UserPromptSubmit", "accepted")[0]?.body.turnId,
|
||||
resolvedPost ? `permission_resolved: ${JSON.stringify({ turnId: resolvedPost.body.turnId, askId: resolvedPost.body.askId })}` : "no permission_resolved");
|
||||
|
||||
// OpenRig permits at submit when no one can be asked: no ask opens, so nothing waits and the beats go on.
|
||||
const q = await launch("3b-permitted", { scenario: { turns: [{ permission: true, silent: true, holdMs: quiet }, {}] },
|
||||
asks: { submit: { permitted: true } } });
|
||||
if (!r.check("permitted at submit: the runner opens its conversation", await ready(q), "no launch record")) return r;
|
||||
q.paste(message(q, "edit the notes"));
|
||||
const qEnd = await waitFor(() => q.hooks("Stop", "end_turn")[0], quiet + opts.waitMs);
|
||||
const qBeats = q.hooks("active", "liveness").filter((b) => !qEnd || seqOf(b) < seqOf(qEnd));
|
||||
r.check("permitted at submit: the quiet turn's beats go on, with no ask to resolve", qBeats.length > 0, "no beat in the quiet turn after the permit");
|
||||
return r;
|
||||
},
|
||||
|
||||
// Section 7's default is permit: when no one can be asked, the agent gets its own allow option at once, allow_once before
|
||||
// allow_always. Only the seat's opt-in refusal (denied_by_seat), or a request with no allow option, is refused at once,
|
||||
// shown, and held as needs input until the next prompt, even when the turn is cancelled.
|
||||
@@ -449,7 +523,8 @@ const cases = {
|
||||
const submitted = await waitFor(() => submits(h)[0], scale(3000));
|
||||
if (!r.check(`${label}: the request is submitted to /api/activity/permission-requests`, submitted, "no submit")) continue;
|
||||
const asked = h.sent("session/request_permission")[0];
|
||||
const accepted = h.hooks("UserPromptSubmit", "accepted")[0];
|
||||
// The submit isn't one of the runner's ordered activity posts, so it can arrive before the accepted prompt does.
|
||||
const accepted = await waitFor(() => h.hooks("UserPromptSubmit", "accepted")[0], scale(3000));
|
||||
const sent = submitted.body ?? {};
|
||||
if (label === "allow") {
|
||||
r.check("the submit passes OpenRig's checks", h.asks.submitted[0]?.problems.length === 0, h.asks.submitted[0]?.problems.join("; "));
|
||||
@@ -486,6 +561,8 @@ const cases = {
|
||||
const outcomes = [
|
||||
["permitted at submit", { submit: { permitted: true } }, "allow-once"],
|
||||
["permitted at read", { answer: { afterMs: 300, state: "permitted", optionId: "allow-once" } }, "allow-once"],
|
||||
// A person's own words come back beside the outcome, and never change the option delivered.
|
||||
["permitted at read, with the person's words", { answer: { afterMs: 300, state: "permitted", optionId: "allow-once", said: "only the notes file" } }, "allow-once"],
|
||||
["duplicate_request", { submit: { status: 409, code: "duplicate_request" } }, "allow-once"],
|
||||
["activity_grant_pending", { submit: { status: 409, code: "activity_grant_pending" } }, "allow-once"],
|
||||
["activity_grant_revoked", { submit: { status: 401, code: "activity_grant_revoked" } }, "allow-once"],
|
||||
@@ -530,7 +607,8 @@ const cases = {
|
||||
await sleep(scale(1500));
|
||||
r.check("pending: the runner keeps reading the ask", h.permissionCalls("GET").length >= 1, "no read of the ask");
|
||||
r.check("pending: nothing is delivered while no one has answered", answers(h).length === 0, `got ${JSON.stringify(outcome(h))}`);
|
||||
r.check("pending: no idle Stop while it waits", nonExitStops(h).length === 0, nonExitStops(h).map((p) => p.body.subtype).join(", "));
|
||||
const turn = h.hooks("UserPromptSubmit", "accepted").at(-1);
|
||||
r.check("pending: no idle Stop while it waits", stopsAfter(h, turn).length === 0, stopsAfter(h, turn).map((p) => p.body.subtype).join(", "));
|
||||
h.paste(message(h, "allow it"));
|
||||
await sleep(scale(800));
|
||||
r.check("pending: a pasted message isn't an answer", answers(h).length === 0, `got ${JSON.stringify(outcome(h))}`);
|
||||
@@ -573,6 +651,22 @@ const cases = {
|
||||
return r;
|
||||
},
|
||||
|
||||
// Section 7: a person who answers in their own words gets them passed on. OpenRig carries them as the read's `said`, and
|
||||
// a runner queues them for the agent like any other prompt, after the turn, never as the answer. A runner that drops
|
||||
// them still keeps the contract, so the result is advisory.
|
||||
async "4c"() {
|
||||
const r = result("4c", "The person's words: a read's said is queued to the agent as a prompt (advisory)", { proposed: true });
|
||||
const said = "Only the notes file, please. Leave the rest alone.";
|
||||
const h = await launch("4c", { scenario: { turns: [{ permission: true }, {}] },
|
||||
asks: { answer: { afterMs: 300, state: "permitted", optionId: "allow-once", said } } });
|
||||
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
|
||||
h.paste(message(h, "edit the notes"));
|
||||
if (!r.check("the first turn ends", await waitFor(() => h.hooks("Stop", "end_turn")[0], scale(6000)), "no Stop/end_turn")) return r;
|
||||
const later = await waitFor(() => h.prompts().slice(1).find((prompt) => prompt?.includes(said)), scale(4000));
|
||||
r.check("the person's words are queued to the agent as a prompt, after the turn", later, `prompts: ${JSON.stringify(h.prompts())}`);
|
||||
return r;
|
||||
},
|
||||
|
||||
async "5"() {
|
||||
const r = result("5", "Generation: every post carries the launch's occupant generation");
|
||||
const h = await launch("5");
|
||||
@@ -630,7 +724,10 @@ const cases = {
|
||||
if (!r.check("the agent exits mid-turn", died, "the agent didn't exit")) return r;
|
||||
const exited = await waitFor(() => h.hooks("Stop", `${opts.name}_exited`)[0], scale(4000));
|
||||
r.check(`Stop/${opts.name}_exited is posted`, exited, `posts: ${h.posts().map((p) => `${p.body?.hookEvent}/${p.body?.subtype}`).join(", ")}`);
|
||||
r.check("no idle Stop after the exit", nonExitStops(h, died.at).length === 0, nonExitStops(h, died.at).map((p) => p.body.subtype).join(", "));
|
||||
// Any idle report for the turn the agent died in counts, before or after the exit; one from before the turn doesn't.
|
||||
const turn = h.hooks("UserPromptSubmit", "accepted")[0];
|
||||
const idle = turn ? stopsAfter(h, turn) : nonExitStops(h, died.at);
|
||||
r.check("no idle report for the turn it died in", idle.length === 0, idle.map((p) => p.body.subtype).join(", "));
|
||||
return r;
|
||||
},
|
||||
|
||||
@@ -686,7 +783,7 @@ const cases = {
|
||||
},
|
||||
};
|
||||
|
||||
const order = ["1", "1b", "2", "3", "4", "4b", "5", "6", "7", "7b", "7c", "8", "9"];
|
||||
const order = ["1", "1b", "2", "3", "3b", "4", "4b", "4c", "5", "6", "7", "7b", "7c", "8", "9"];
|
||||
const selected = opts.cases ?? order;
|
||||
const unknown = selected.filter((id) => !order.includes(id));
|
||||
if (unknown.length) { console.error(`unknown case ${unknown.join(", ")}; cases are ${order.join(", ")}`); process.exit(2); }
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
// stubborn keep running after a hangup or after input closes; only SIGTERM or SIGKILL ends it
|
||||
// turns one entry per `session/prompt`, in order (the last one repeats):
|
||||
// holdMs keep the turn open this long; `session/cancel` ends it early as "cancelled"
|
||||
// ignoreCancel on `session/cancel`, keep the turn open and end it with stopReason (default end_turn) anyway
|
||||
// silent send no message chunk for this turn, so a held turn is entirely quiet (a long tool or model call)
|
||||
// ignoreCancel on `session/cancel`, keep the turn open and end it with stopReason (default end_turn) anyway, when
|
||||
// holdMs runs out or, with endAfterCancelMs, that long after the first cancel if that's sooner
|
||||
// permission ask `session/request_permission` first, then carry on whatever the answer. As an object:
|
||||
// options the options offered (default: allow-once, allow_once; reject-once, reject_once)
|
||||
// waitMs stop waiting for the answer after this long, and end the turn
|
||||
@@ -65,7 +67,7 @@ async function prompt(id, params) {
|
||||
turn += 1;
|
||||
if (spec.exit) exit(3, `turn ${turn}`);
|
||||
if (spec.error) { fail(id, -32603, "fake agent: this turn fails"); return; }
|
||||
send({ jsonrpc: "2.0", method: "session/update",
|
||||
if (!spec.silent) send({ jsonrpc: "2.0", method: "session/update",
|
||||
params: { sessionId: params.sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: `turn ${turn}` } } } });
|
||||
let stopReason = spec.stopReason ?? "end_turn";
|
||||
if (spec.permission) {
|
||||
@@ -89,7 +91,12 @@ async function prompt(id, params) {
|
||||
if (spec.holdMs) {
|
||||
const cancelled = await new Promise((resolve) => {
|
||||
const timer = setTimeout(() => { cancelTurn = null; resolve(false); }, spec.holdMs);
|
||||
cancelTurn = () => { if (spec.ignoreCancel) return; clearTimeout(timer); cancelTurn = null; resolve(true); };
|
||||
cancelTurn = () => {
|
||||
if (!spec.ignoreCancel) { clearTimeout(timer); cancelTurn = null; resolve(true); return; }
|
||||
if (spec.endAfterCancelMs === undefined) return;
|
||||
cancelTurn = null;
|
||||
setTimeout(() => { clearTimeout(timer); resolve(false); }, spec.endAfterCancelMs);
|
||||
};
|
||||
});
|
||||
if (cancelled) stopReason = "cancelled";
|
||||
}
|
||||
|
||||
@@ -144,6 +144,23 @@ changes either. `rig scope` reads an `established` or `canonical` slice whose
|
||||
`verified:` is missing, has no source, names a tool-scaffolded placeholder, or is
|
||||
older than 90 days as `provisional`.
|
||||
|
||||
A slice's frontmatter may also carry `status:`. `GET /api/slices` reports the
|
||||
bucket each word falls in as the slice's `status`, and its
|
||||
`?filter=all|active|done|blocked` filters on that bucket:
|
||||
|
||||
| `status:` word | Bucket |
|
||||
| --- | --- |
|
||||
| `active`, `in-flight`, `ratified` | `active` |
|
||||
| `draft`, `draft-pending-orch-ratification`, `placeholder` | `draft` |
|
||||
| `done`, `shipped`, `promoted`, `closed` | `done` |
|
||||
| `blocked`, `parked-with-evidence` | `blocked` |
|
||||
|
||||
Words are matched ignoring case. Any other word is matched by what it contains:
|
||||
`done`, `ship` or `close` makes it `done`; `block` or `park`, `blocked`; `draft`
|
||||
or `pending`, `draft`. A word that matches none of these counts as `active`, so a
|
||||
slice marked `complete` counts as `active`. A slice with no `status:` counts as
|
||||
`draft`.
|
||||
|
||||
When the owning scope selects `proofPolicy.judges`, use `rig proof judge` to
|
||||
record an attributed accept, reject or withdraw decision on a contract item,
|
||||
and `rig proof show` to read derived readiness up through slice, mission and
|
||||
@@ -178,9 +195,11 @@ missions whose `mission.yaml` carries `metadata.status: active` or
|
||||
a mission does not change the project's readiness. A `planned` mission stays
|
||||
outside the rollup, even after its slices are accepted, until someone changes
|
||||
its status to `active` (or `release-candidate` for a release that is ready to
|
||||
judge). Nothing changes the status automatically. Missions created before this
|
||||
default carry no status and are not counted either; add the line by hand to
|
||||
include them. It also writes `metadata.name`, the mission's folder name, which
|
||||
judge). Nothing changes the status automatically. A mission can also carry
|
||||
`metadata.status: complete`, which only `POST /api/missions/:missionId/complete`
|
||||
writes (no `rig` verb does); a `complete` mission is outside the rollup too.
|
||||
Missions created before this default carry no status and are not counted either;
|
||||
add the line by hand to include them. It also writes `metadata.name`, the mission's folder name, which
|
||||
the project lifecycle compiler requires; a mission created without it refuses to
|
||||
compile with `lifecycle_field_missing` until you add that line.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ type: `openrig-urgent`, `urgent-only` and `sends-only`. Sibling seats keep their
|
||||
| `off` (default) | Existing delivery and prompt checks | Existing send result |
|
||||
| `draft-aware` | Paste into a recognized empty composer; check ownership again before Enter | Hold input and retain this attempt for inspection when bookkeeping is available |
|
||||
| `openrig-urgent` | OpenRig's own messages (watchdogs, scheduled wakes, notices) and anything marked urgent type, never over a draft | Other agents' messages are held, the sender is told, and held messages stay readable |
|
||||
| `urgent-only` | Only messages marked urgent type (`rig send --urgent`, or a queue item at priority urgent or critical), never over a draft | Everything else is held, the sender is told, and held messages stay readable |
|
||||
| `urgent-only` | Only messages marked urgent type (`rig send --urgent`, `urgent: true` on `POST /api/transport/send`, or a queue item at priority urgent or critical), never over a draft | Everything else is held, the sender is told, and held messages stay readable |
|
||||
| `sends-only` | Deliberate sends from people and agents type, as with `off`; a terminal member's typing isn't protected, because its shell input can't be read | OpenRig's own notices are held. A terminal member defaults to this level |
|
||||
| `hold` | No automatic terminal input, even at an empty prompt | Retain for inspection outside the pane |
|
||||
|
||||
@@ -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
|
||||
@@ -111,8 +174,18 @@ their existing behaviour.
|
||||
|
||||
### Terminal delivery
|
||||
|
||||
Run from a seat shell with `OPENRIG_SESSION_NAME` set; the daemon derives the
|
||||
audit actor from transport identity.
|
||||
`rig seat set-typing-guard` works from an ordinary terminal as well as from a
|
||||
seat's shell. The daemon takes the recorded actor from transport identity only:
|
||||
|
||||
- From a managed seat's shell (`OPENRIG_SESSION_NAME` set), the preference and
|
||||
its audit entry name that seat.
|
||||
- From a terminal with no seat identity, the change applies the same way, and
|
||||
the preference and its audit entry record the actor as
|
||||
`unattributed (no seat identity)`.
|
||||
|
||||
Neither case takes an actor from the request body or from the target seat's name.
|
||||
`rig seat retire-held-message` still records the seat that retired a message, so
|
||||
run it from a seat's shell.
|
||||
|
||||
~~~sh
|
||||
rig seat set-typing-guard dev-impl@my-rig --mode draft-aware \
|
||||
@@ -131,8 +204,21 @@ to finish. The API may return HTTP 202 with `pending: true`; wait for
|
||||
and effective modes and retains the text `Typing guard: on (hold)` for hold.
|
||||
|
||||
A held send immediately returns `outcome: "retained"`, `sent: false`, its exact
|
||||
reason (such as `draft_input_busy`) and the saved attempt's `outboxIds`. No automatic
|
||||
retry is scheduled. Each explicit draft-aware request is a new attempt, including
|
||||
reason and the saved attempt's `outboxIds`. No automatic
|
||||
retry is scheduled. The reasons are:
|
||||
|
||||
- `typing_guard_enabled`: the seat's mode holds this send (`hold`, or a level that
|
||||
holds this kind of message).
|
||||
- `runtime_switch_in_progress`: the seat is switching runtime.
|
||||
- In `draft-aware` mode, a refusal before any input was written:
|
||||
`draft_input_busy` (the input holds unsubmitted text), `draft_input_unknown` (the
|
||||
input can't be read), `draft_input_changed` (the input or its target changed during
|
||||
the delivery), `draft_wake_superseded`, `target_needs_input`,
|
||||
`wait_for_idle_timeout`, `target_activity_unknown`, `target_runtime_unverified`,
|
||||
`target_runtime_not_running`, `target_runtime_conflict`, `invalid_wait_for_idle`,
|
||||
`invalid_dangerously_interact`, `dangerously_interact_requires_reason`,
|
||||
`prompt_override_audit_unavailable`, `transport_unavailable`, `session_missing` or
|
||||
`tmux_unavailable`. Each explicit draft-aware request is a new attempt, including
|
||||
one with a reused `deliveryId`; the result warns older callers that this field no
|
||||
longer deduplicates input. The CLI does not generate a deduplication key.
|
||||
|
||||
@@ -230,7 +316,9 @@ refuses Enter and is not replayed. Unsupported provider layouts remain held.
|
||||
Use off when that tradeoff is unsuitable.
|
||||
|
||||
HTTP configuration uses `POST /api/seat/set-typing-guard/:seatRef` with
|
||||
`{mode, reason}` or `{enabled, reason}`. Status and held-message routes are
|
||||
`{mode, reason}` or `{enabled, reason}`. The `X-OpenRig-Session` header, when
|
||||
present, is the recorded actor; without it the change is recorded as
|
||||
`unattributed (no seat identity)`. Status and held-message routes are
|
||||
unchanged; no RigSpec field is required.
|
||||
|
||||
## Verification scope
|
||||
|
||||
@@ -99,8 +99,24 @@ So after installing, compare the granted scopes Slack shows for the app with all
|
||||
`rig slack manifest --json` lists.
|
||||
|
||||
The app subscribes to messages in public channels it is a member of (`message.channels`), to
|
||||
mentions of the app (`app_mention`), and to emoji reactions (`reaction_added`). It does not request
|
||||
direct-message or private-channel access.
|
||||
mentions of the app (`app_mention`), and to emoji reactions (`reaction_added`). The manifest does
|
||||
not request direct-message or private-channel access.
|
||||
|
||||
### Using a private channel
|
||||
|
||||
The connector doesn't check whether a channel is public or private, so a private channel can be
|
||||
the default channel or a mapped one once the app can read it. OpenRig doesn't test this setup. In
|
||||
the app's settings:
|
||||
|
||||
1. Under **OAuth & Permissions**, add the bot scopes `groups:history` (read messages in private
|
||||
channels the app is in) and `groups:read` (read a private channel's details; `rig slack verify`
|
||||
needs it to confirm the app is a member).
|
||||
2. Under **Event Subscriptions**, subscribe to the bot event `message.groups`.
|
||||
3. Reinstall the app to the workspace, then invite it to the private channel.
|
||||
4. To have `rig slack verify` require the new scopes too, run
|
||||
`rig slack setup --required-scopes chat:write,channels:history,channels:read,groups:history,groups:read`.
|
||||
|
||||
Direct messages are not supported.
|
||||
|
||||
A reaction added to an ask reaches the agent that asked, as a note naming who reacted and with
|
||||
which emoji; the agent decides what it means. Every message OpenRig posted for the ask counts:
|
||||
@@ -166,6 +182,9 @@ rig slack channel-map list
|
||||
rig slack channel-map remove pr@my-rig
|
||||
```
|
||||
|
||||
A client can offer the channels to choose from with `GET /api/gateway/slack/channels`, which lists the public
|
||||
channels the bot token can see, with their ids ([Writing a daemon client](daemon-http-clients.md#route-details)).
|
||||
|
||||
- **Which channel an item posts to.** The item's seat is matched exactly first (`pr@my-rig`),
|
||||
then its rig (`my-rig`). Anything unmapped posts to the default channel, so a config
|
||||
without a map behaves exactly as before. Aggregate delivery digests have no single seat and
|
||||
@@ -207,7 +226,7 @@ rig slack channel-map remove pr@my-rig
|
||||
their routing also keeps the conversations separate: new messages in a seat's own channel
|
||||
still go to the inbound destination unless its entry names `--inbound`. A conversation shared
|
||||
on purpose is fine.
|
||||
- Missed-message recovery (below) covers the default channel only.
|
||||
- Missed-message recovery (below) covers the default channel and every mapped channel, except a channel relayed to another machine.
|
||||
|
||||
Invite the app to every mapped channel (`/invite @OpenRig`). `rig slack verify` checks
|
||||
membership in the default channel and every mapped channel, and reports NOT ready until the
|
||||
@@ -221,8 +240,9 @@ configuration matches the file.
|
||||
|
||||
The map is stored as `channelMap` in `slack-connector.json`, as a list of `{ "match", "channel" }`
|
||||
entries, each with an optional `"inbound"` seat. Saving refuses any other field. A field written by a newer OpenRig is ignored when the
|
||||
connector reads the file, and `rig slack status` names it. Private channels and direct messages
|
||||
are not supported targets.
|
||||
connector reads the file, and `rig slack status` names it. A private channel can be a target once
|
||||
the app has the private-channel scopes ([Using a private channel](#using-a-private-channel)).
|
||||
Direct messages are not supported targets.
|
||||
|
||||
## Named bots (optional)
|
||||
|
||||
@@ -329,8 +349,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
|
||||
@@ -366,6 +385,36 @@ Quiet hours hold a person's routine asks while they're on, and post them once th
|
||||
|
||||
## Reconnect recovery and status
|
||||
|
||||
When a posted ask is answered, closed, canceled or replaced elsewhere, the connector edits its
|
||||
original primary message with the status, time, actor and closure note. Buttons become option
|
||||
text and the answer hint disappears; the remaining content and reconciliation marker stay as
|
||||
posted. Supplemental messages are unchanged. This adds no post or reaction and needs no agent
|
||||
turn. Separate resolution notices retain their existing behavior.
|
||||
|
||||
A deliberately reopened direct ask says **Open again**; its options remain as text. If it has
|
||||
no committed answer, it tells the person to reply in its thread. Otherwise it says **Earlier
|
||||
answer retained**: reopening does not accept a second answer. Earlier replaced parks remain
|
||||
historical; **Asked again** does not claim that the later ask has been posted. Edits reconcile
|
||||
the current state again after awaited Slack calls, so an obsolete close is not recorded as the
|
||||
current result after a reopen.
|
||||
|
||||
Edits follow committed queue changes, with a startup catch-up for posted asks on retained queue
|
||||
rows. Each primary costs one exact-message read and one `chat.update`. The serial edit worker
|
||||
uses the recovery budget helpers (four calls, 100 entries, 15 seconds) and waits for the next
|
||||
budget window when exhausted. Slack's per-method `Retry-After` is retained across restarts.
|
||||
Only an acknowledged edit gets a success receipt. `rig slack status` shows queued reconciliation
|
||||
and the last edit failure. Transient failures retry with increasing waits, capped at an hour,
|
||||
behind other queued work. Permanent Slack failures (such as missing scope, a deleted original,
|
||||
an archived channel or an uneditable message) remain unfinished and are retried only on the next
|
||||
connector start. They do not keep consuming edit capacity. These edit calls have their own
|
||||
budget, separate from missed-message scanning below.
|
||||
|
||||
The baseline already includes public-channel history access and `chat:write`. Private-channel
|
||||
reads may require `groups:history`, which the baseline does not grant; a refused read never
|
||||
counts as an edit. An unavailable/deleted original cannot be replaced by a new post. Startup
|
||||
catch-up cannot edit rows already removed by queue retention or posts without their original
|
||||
notification receipt and channel stamp.
|
||||
|
||||
The daemon scans available top-level messages and replies broadcast with **Also send to
|
||||
channel** in the channels this machine owns: its default channel and its channel map's, except a
|
||||
channel it relays to another machine. It also scans replies in the threads it tracks in those
|
||||
|
||||
@@ -115,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.
|
||||
@@ -300,13 +300,23 @@ small presentation provenance, separate from queue membership. Herdr display
|
||||
labels are not that provenance. Other providers/platforms remain unsupported
|
||||
for ensure-viewer until qualified; ordinary open retains its existing behavior.
|
||||
|
||||
Writable tile clients use `tmux attach -f ignore-size`; read-only clients keep
|
||||
`-r` (which also implies ignore-size). This can avoid contributing to sizing
|
||||
when other clients establish the agent window's dimensions. It is **not resize
|
||||
isolation**: opening a tile can change the agent's terminal size when it is the
|
||||
only sizing client. Observers of a Herdr terminal do not themselves resize it;
|
||||
A writable tile attaches with `tmux attach -f ignore-size` when its session
|
||||
already has a client that sizes it, so the tile doesn't fight that client over
|
||||
the agent window's dimensions. When the session has no sizing client, as with a
|
||||
freshly launched seat, the tile attaches without the flag and sizes the window
|
||||
itself: tmux applies ignore-size across the whole server, so a session viewed
|
||||
only by ignore-size clients would otherwise keep its 80x24 creation size while
|
||||
any other session has a sizing client. Read-only clients keep `-r` (which also
|
||||
implies ignore-size), and the printed manual attach commands keep
|
||||
`-f ignore-size`. This is **not resize isolation**: opening a tile can change
|
||||
the agent's terminal size. Observers of a Herdr terminal do not themselves resize it;
|
||||
a controlling browser can. No cross-version resize guarantee is implied.
|
||||
|
||||
To type into a seat without changing its size, send the text through OpenRig
|
||||
(`rig send`, or `POST /api/transport/send`): it is written to the pane with tmux
|
||||
buffers and keys, and no client attaches. There is no supported way to attach a
|
||||
writable client that is sure never to resize the agent's window.
|
||||
|
||||
|
||||
## Following a slice or mission's current work
|
||||
|
||||
|
||||
+472
-178
@@ -1,192 +1,486 @@
|
||||
# What changed in OpenRig 0.7.1
|
||||
# What changed in OpenRig 0.7.10
|
||||
|
||||
This note is for an agent on an install that was just upgraded to 0.7.1. It lists only what behaves differently from
|
||||
0.7.0 and what to do about it. For everything OpenRig can do, read the capability map:
|
||||
`rig context get onboarding-width/public-what-you-can-do.md`. Each release replaces this file, so
|
||||
`rig context get reference/whats-new.md` describes the installed version once the upgraded daemon is running. Without
|
||||
a running daemon, read `$OPENRIG_HOME/reference/whats-new.md`. The daemon refreshes that copy when it starts, so right
|
||||
after an upgrade it can still hold the previous version's note.
|
||||
This note is for an agent on an install that was just upgraded to 0.7.10. OpenRig
|
||||
0.7.10 adds `rig exec` for finite runs on Claude Code or Codex (not yet run with
|
||||
a real adapter), sends a held
|
||||
post-compaction step after two idle polls that can't read the input box, leaves
|
||||
every tmux mode before a delivery, and shows agent spec advisories at launch. It
|
||||
fixes in the source the two problems that stopped the OpenShell worker in 0.7.9,
|
||||
but that worker hasn't run end to end. If you upgraded from 0.7.8 or earlier,
|
||||
also read "What changed in OpenRig 0.7.9" below, and from 0.7.7 or earlier,
|
||||
"What changed in OpenRig 0.7.8" too. For the full capability map, read
|
||||
`rig context get onboarding-width/public-what-you-can-do.md`. After the upgraded
|
||||
daemon starts, `rig context get reference/whats-new.md` describes this version.
|
||||
Without a running daemon, read the packaged `daemon/docs/reference/whats-new.md`;
|
||||
`$OPENRIG_HOME/reference/whats-new.md` can still be the previous copy until
|
||||
startup.
|
||||
|
||||
## After the upgrade
|
||||
|
||||
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Eight migrations run on the first start (124, 125,
|
||||
127, 128, 129, 130, 131 and 135). Nine settings are added: `policies.claude_compaction.preparation_root` (unset),
|
||||
`skills.router_line.enabled` (off), `decisions.enabled` (false), `decisions.consumers`, `decisions.template_dir`,
|
||||
`retention.stream_days` (30), `retention.stream_max_items` (20000), `provider.selected` (empty) and
|
||||
`daemon.db_conflict` (`warn`). None is removed.
|
||||
- **Stream retention is on.** Stream items emitted after the upgrade are deleted after 30 days, or beyond the newest
|
||||
20,000, unless a record names them (a classification, duplicate link, attempt or disposition). History from before the
|
||||
upgrade is kept. Raise `retention.stream_days` or `retention.stream_max_items` to keep more.
|
||||
- **The bundled openrig-core plugin goes from 0.1.17 to 0.1.18.** Startup replaces the installed plugin's changed files:
|
||||
its Claude hook definitions, three hook scripts and the pre-compaction hook (so hooks start on a seat whose PATH has
|
||||
no Node), and the `openrig-user`, `messaging-the-human` and `forming-an-openrig-mental-model` skills. As in
|
||||
0.7.0, it first copies each file it replaces under `$OPENRIG_HOME/backups/managed-refresh/<id>/`, edited or not, and
|
||||
logs `[plugin-vendor] preserved previous contents of <file> at <copy> before managed refresh`. A copy doesn't mean
|
||||
anyone edited the file; if someone did, reapply the change from the copy.
|
||||
- **Claude seats need Claude Code 2.1.280 or newer to see their team skills,** which now arrive as a per-seat plugin
|
||||
through `CLAUDE_CODE_PLUGIN_DIRS` (below). OpenRig doesn't check the version. A running seat gets the new layout at its
|
||||
next launch, restore or handover.
|
||||
- **If a worktree's `.claude/settings.local.json` says `"defaultMode": "acceptEdits"` and the person didn't choose it,**
|
||||
an earlier OpenRig wrote it when it added or freshly launched a managed Claude seat. 0.7.1 no longer writes it but
|
||||
doesn't repair it; ask the person which mode they want and set it once.
|
||||
- **Restart the daemon** to load the new code: `rig daemon stop`, then
|
||||
`rig daemon start`. This release adds no migrations.
|
||||
- **The bundled openrig-core plugin goes from 0.1.25 to 0.1.26.** Its Claude
|
||||
hooks add a `PermissionRequest` hook that reports permission requests to seat
|
||||
status, and its refocus hook and the refocusing skill's trace script changed.
|
||||
Managed refresh preserves replaced files under
|
||||
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you still
|
||||
need from the preserved copy.
|
||||
- **Check agent specs before relaunching seats:** run `rig agent validate <path>`
|
||||
and read any `agent spec advisory` line. A profile's `uses.<category>` written
|
||||
as a single value now selects that resource; if the resource pool doesn't hold
|
||||
it, a member using that profile fails to resolve with "not found in resource
|
||||
pool". Ask the person before you change their spec.
|
||||
- **Codex hooks:** a Codex seat kept running across the upgrade can show "Hook
|
||||
failed — plugin contents changed; hook approval required" until relaunched; in
|
||||
0.7.9's upgrade test, one seat restarted with OpenRig's managed restart showed
|
||||
no review afterwards.
|
||||
- **OpenShell:** rebuild a guest image with this release's `guest.py` before
|
||||
using it; the CA repair is in that file, which the image carries.
|
||||
- **Codex tool commands use a non-login shell:** managed Codex seats now pass
|
||||
Codex `-c allow_login_shell=false`, so plain `rig` works however OpenRig was
|
||||
installed, with a Codex that supports `allow_login_shell` (checked in Codex's
|
||||
source at 0.162.1 and 0.166); not yet run with a live Codex seat. Setup that lives only in login or rc files, and shell functions and
|
||||
aliases, may be missing from tool commands, and a model request for
|
||||
`login: true` gets Codex's refusal, "login shell is disabled by config". To
|
||||
defer to Codex's own setting, run `rig config set launch.codex_login_shell
|
||||
true`; a login file that sets `PATH` outright can then hide `rig`. It applies
|
||||
at each seat's next launch, and a restart in place keeps the running choice.
|
||||
([#2613](https://github.com/mvschwarz/openrig/pull/2613))
|
||||
|
||||
## New things you can do
|
||||
## Finite runs: `rig exec`
|
||||
|
||||
- **Choose how much automatic input reaches a seat:** `rig seat set-typing-guard <seat> --mode <mode> --reason "…"`, where
|
||||
`<mode>` adds `openrig-urgent` (OpenRig's own notices and anything urgent) and `urgent-only` between `off` and `hold`.
|
||||
Mark a message urgent with `rig send --urgent`, or create the row at priority `urgent` or `critical`. Held input stays
|
||||
readable (`rig seat held-messages`) and is never replayed.
|
||||
- **Choose how far OpenRig and peers may act on a seat:** `rig seat set-terminal-policy <seat> --preset
|
||||
no-peer-interventions|help-within-instructions|full-control --reason "…"`, with `--helper <addresses…>`,
|
||||
`--rig-default` (seats added later), and exceptions such as `--peer-compaction deny` or `--openrig-model allow`.
|
||||
Managed compaction and saved model or effort changes are checked; a denial is `terminal_policy_refused`. Other
|
||||
interventions are guidance only.
|
||||
- **Read a seat's planned permissions:** `rig seat status <seat>` shows what its next fresh managed launch will pass and
|
||||
where each part comes from, and its terminal policy. `rig setup --router-line yes|no` decides whether OpenRig adds its
|
||||
one line to shared guidance files.
|
||||
- **Let a person choose whether every ask reaches Slack:** `rig gateway human set <id> slack-subscription
|
||||
every-ask|urgent-only|unset`. Every ask is on the TUI's Feed either way. Under `urgent-only`, only an ask
|
||||
created or handed off with `--alert` posts. An ask marked `--alert` stands out in Slack (an interrupt with a mention)
|
||||
under either setting, so use it only for something only they can do, or an approval whose wait costs now.
|
||||
- **Give one Claude member its own setup** in the rig spec: `agent_file`, `append_system_prompt_file`, `plugin_dirs` and
|
||||
`advisor` (an Advisor model). They're copied into that seat's storage, never the project.
|
||||
- **See a seat's own skills:** `rig skill loadout --seat <session|self>`.
|
||||
- **Reach the team from an assistant without a shell:** the MCP server has 34 tools, including `rig_whoami`,
|
||||
`rig_restore_status`, `rig_team`, `rig_telemetry_events`, following a handoff, reading a finished item's artifact and
|
||||
deciding inside a person's decision room. `rig mcp serve --http` serves them over HTTP; `rig mcp token add <name>
|
||||
--seat <session>` gives each caller its own seat.
|
||||
- **Hand a row's task to an outside agent:** `rig acp run <row>` for an ACP agent (Hermes, OpenClaw).
|
||||
`docs/reference/a2a-agents.md` covers A2A agents.
|
||||
- **Run a community harness that names, reports or reopens its conversations:** preset fields `launch_result`, `resume`,
|
||||
`names_conversation` and `startup_assisted`, with examples for Cursor, Hermes and OpenClaw under
|
||||
`docs/reference/community-harness-examples/`.
|
||||
- **Change one edge on a running rig:** `rig edge add <rig> <from> <to> --kind <kind>` and `rig edge remove <rig>
|
||||
<edge-id>` (`--plan`, `--json`). A `delegates_to` change says when it moves who work routes to.
|
||||
- **Run one spec as several rigs:** `rig up <spec> --name <rig>`.
|
||||
- **Retune a watchdog in place:** `rig watchdog update <jobId> [--interval-seconds n] [--message text] [--spec path]`
|
||||
keeps its id and attachments. The context watch takes `--threshold-tokens` or `--threshold-percent`.
|
||||
- **Put restore maps where seats may write:** `rig config set policies.claude_compaction.preparation_root <abs path>`.
|
||||
- **Pick a Herdr session:** `rig terminal open <view> --provider herdr --session <name>`.
|
||||
- **Close a row with its artifact:** `rig queue update <id> --state done --evidence-ref <path>`.
|
||||
- **File and read subscription move reports:** `rig provider moves report` and `rig provider moves`. `--host <id>` and
|
||||
`--codex-home <dir>` on `rig auth status|list|switch` and `rig seat restart` act on another machine's Codex home.
|
||||
- **Route stream observations with typed decisions (opt-in, off by default):** once `decisions.enabled` is true,
|
||||
`decisions.consumers` includes `stream` and the intake seat has `OPENROUTER_API_KEY`, `rig project wake --decide`
|
||||
gives each eligible stream observation one bounded typed decision during that seat's wake: create a row for a supplied
|
||||
owner, join supplied work, or record a duplicate or no action. Anything uncertain, refused or failed goes to the
|
||||
intake seat as its own disposition, and the model isn't asked again. `rig project decisions status` shows the state
|
||||
without making a call. Without `--decide`, or with decisions off, nothing changes; turning decisions off no longer
|
||||
strands an accepted create or join whose effect hadn't landed.
|
||||
- **Read the stream as a subscriber:** `rig stream subscribe <seat>` (`--tags`, `--type`; re-subscribing keeps the
|
||||
place), then `rig stream read` returns the next bounded page for the calling seat (at most 20 items and 64 KiB, with
|
||||
`more`, `unread` and any `gap`). `rig stream subscriptions` lists readers; `rig stream unsubscribe <seat>` stops one.
|
||||
`rig stream show <id>` says what became of an observation.
|
||||
- **Keep the kernel to the harnesses you chose:** `rig setup --providers <list>` is now saved as `provider.selected`, and
|
||||
the kernel starts seats only for those.
|
||||
- **Stop a second daemon on a live database:** `rig config set daemon.db_conflict stop` refuses a start whose database a
|
||||
matching live daemon already holds (the default `warn` only warns). `rig daemon start --allow-db-conflict` allows one
|
||||
deliberate start.
|
||||
`docs/reference/acp-agents.md` is the rule. Read it before handing a row to
|
||||
Claude Code or Codex.
|
||||
- **Install the adapters first:** `@agentclientprotocol/claude-agent-acp`
|
||||
0.101.0 and `@agentclientprotocol/codex-acp` 2.5.0, as that page shows.
|
||||
`rig exec harnesses` reports the adapter and native versions it finds.
|
||||
- **Run:** `rig exec run <row> --harness claude` (or `codex`). `--plan` shows
|
||||
what would run and starts nothing; `--wait` waits for the outcome. A connector
|
||||
owns the run, so leaving `--wait` or closing the terminal doesn't stop it.
|
||||
Running it again returns the row's run instead of sending the task twice;
|
||||
`--again` starts another turn once that run has ended.
|
||||
- **Read the outcome** with `rig exec status <runId|row>`. `interrupted` means
|
||||
the connector died without recording an outcome, so the outcome is unknown. A
|
||||
`completed` turn is the agent's report, not finished work: check the work
|
||||
before you close the row. `--wait` exits 0 for `completed`, 2 for any other
|
||||
outcome and 3 when you stop waiting.
|
||||
- **Cancel** with `rig exec cancel <runId>`, not `rig acp cancel`. An
|
||||
acknowledgement isn't a settled shutdown, and it says nothing about further
|
||||
spend.
|
||||
- **Usage and cost** the agent reported are on the row's outcome note, for
|
||||
`rig acp run` too, each with its source; what it didn't report reads unknown.
|
||||
|
||||
## Behaviour that changed
|
||||
## Compaction and delivery
|
||||
|
||||
- **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.
|
||||
- **A held post-compaction step goes in.** After two idle polls in a row that
|
||||
can't read a seat's input box, the next poll sends the step (the turn
|
||||
boundary, the restore request or the audit). `rig compact <seat> --state` and
|
||||
`rig seat status` say it went in over a box OpenRig couldn't read; they never
|
||||
call that box empty. A draft OpenRig can't recognise in that box may receive
|
||||
the restore text.
|
||||
- **Still held:** a box that reads as typed, with or without a suggestion; a
|
||||
seat whose draft-aware delivery or typing guard holds OpenRig's input; and the
|
||||
step after a `/compact` whose result was lost. To take over a held restore by
|
||||
hand, run `rig compact <seat> --cancel`, then send the seat a restore request
|
||||
naming its newest restore map, its restore marker and its packet. A step
|
||||
already being pasted can still land.
|
||||
- **tmux modes:** a send or wake now leaves every tmux mode in a seat's pane,
|
||||
including a chooser, customize or clock mode a person has open. If a send is
|
||||
still refused for a tmux mode, the refusal names why leaving failed.
|
||||
- **A scrolled pane with a stale record:** leaving the mode now goes through the
|
||||
seat's address, so a stale binding naming the same reused tmux pane, from
|
||||
another rig for example, no longer makes the send fail with nothing sent.
|
||||
- **Herdr views kept across an upgrade keep working,** on Linux Herdr 0.9.5 /
|
||||
protocol 22. A viewer or follow view kept from 0.7.5 through 0.7.9 now counts
|
||||
as the same attachment when only OpenRig's tile script changed, and moves to
|
||||
the current command, so the viewer stays confirmed and the follow view goes on
|
||||
adding and closing tiles. A kept viewer that's already running keeps its
|
||||
earlier sizing for as long as it runs, its own re-attaches included; writable
|
||||
viewers opened after the upgrade size their pane. A different session, tmux,
|
||||
server or read-only setting still holds the view, as before. Checked against
|
||||
the scripts those releases used, not yet with a live Herdr. It follows
|
||||
@natefaerber's approach in
|
||||
[#2467](https://github.com/mvschwarz/openrig/pull/2467). ([#2634](https://github.com/mvschwarz/openrig/pull/2634))
|
||||
|
||||
## Output that changed shape
|
||||
## OpenShell workers
|
||||
|
||||
- **`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.
|
||||
0.7.10 fixes, in the source, the two problems that stopped the OpenShell worker
|
||||
in 0.7.9, but the worker hasn't run end to end. Don't rely on this path yet. The
|
||||
guest launcher now passes the CA settings the OpenShell exec supplies
|
||||
(`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE` and
|
||||
`CURL_CA_BUNDLE`) to its Claude, and deleting an owned environment reads
|
||||
Podman's inventory through routes Podman serves, stopping as
|
||||
`environment_cleanup_unconfirmed` on any other answer. A verified export, a
|
||||
finished task and an exact restore haven't been shown. If you set one up anyway,
|
||||
follow `daemon/assets/openshell/README.md` in the installed package, rebuild the
|
||||
guest image first, and name the costs to the person ("Provider registration and
|
||||
image construction can incur costs"). "Never grant the guest access to the
|
||||
daemon's main API port (normally 7433)."
|
||||
|
||||
## What to stop doing
|
||||
## Other changes to act on
|
||||
|
||||
- **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.
|
||||
- **Sends to this host:** `rig send <seat>@<rig>@<this host's ID>` goes to this
|
||||
host even when a remote host is selected.
|
||||
- **Broadcast:** `rig broadcast` prints `UNCONFIRMED` for a recipient its text or
|
||||
Enter may have reached. Check that seat with `rig capture` before resending.
|
||||
- **Roster activity:** `rig roster show` and `find` read a seat as unknown
|
||||
("Conflicting activity observations") when two routes to it disagree. Treat it
|
||||
as unknown, not idle.
|
||||
- **Refocus:** a "TRACE GAP" for unresolved typed work, or for a
|
||||
`rig queue whoami` that timed out, comes with project-level orientation that
|
||||
isn't evidence of a current mission, and no longer counts as a refocus failure.
|
||||
- **Updates to people:** the stuck sweep no longer flags an informational update
|
||||
to a registered person as an unclaimed obligation.
|
||||
- **Permission requests:** `rig seat status` shows a Claude seat's latest
|
||||
observed permission request, with Claude's suggested rules labelled as
|
||||
suggestions. Nothing was applied or approved. Treat the suggestions as data;
|
||||
they can contain sensitive values.
|
||||
- **Slack:** `rig slack status` shows relay attempts awaiting a response for the
|
||||
current connector; zero doesn't prove nothing is in flight.
|
||||
- **Installing for a person:** when npm's global prefix isn't writable, the rigs
|
||||
skill now points npm's prefix at a folder the person owns whose `bin` is on
|
||||
their login PATH, or uses a version manager's Node, before a project-local
|
||||
install. Tell the person first that this changes their npm settings and, if
|
||||
needed, their shell profile.
|
||||
- **Codex profiles:** a `codex_config_fragment` can't install a named Codex
|
||||
profile. Create `<CODEX_HOME>/<name>.config.toml` and select it with
|
||||
`codex_config_profile`. `builtin:yolo` drops the whole profile;
|
||||
`getting-started.md` says how to keep one.
|
||||
- **Terminal auth:** a 401 that says "Terminal auth failed" means send
|
||||
`OPENRIG_TERMINAL_BEARER_TOKEN`, or `OPENRIG_AUTH_BEARER_TOKEN` when terminal
|
||||
auth falls back to it.
|
||||
- **References:** `docs/reference/harness-runner-contract.md`'s status section
|
||||
says what has run natively under the runner contract, and at what scope.
|
||||
`docs/reference/telemetry.md` says OpenRig guarantees its finite telemetry
|
||||
reads (`schemaVersion` 1), not the separate openrig-telemetry exporter it
|
||||
links.
|
||||
- **Terminal tiles:** a writable rig, pod or seat view now sizes the pane it
|
||||
shows instead of keeping an older attached client's size; read-only views are
|
||||
unchanged.
|
||||
- **Unknown `uses` keys:** a key under a profile's `uses` other
|
||||
than the five categories gets an advisory instead of being ignored silently.
|
||||
|
||||
## Known gaps in 0.7.1
|
||||
## Limits to keep in view
|
||||
|
||||
- **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`.
|
||||
- **OpenShell** hasn't run end to end on 0.7.10 (above). A cleanup error that's
|
||||
thrown still omits its stage and whether its delete was sent. A
|
||||
same-generation reconciled launch opens no viewer, so run
|
||||
`rig environment connect` yourself; non-text startup actions aren't supported
|
||||
on environment seats.
|
||||
- **`rig exec`:** no real adapter has run under it yet; one run per row,
|
||||
detached ownership and reconciling were checked against a stand-in agent.
|
||||
Concurrent runs, long or background tasks, recovery across a reboot, MCP
|
||||
servers and Windows aren't covered. A Ctrl-C just before the connector is
|
||||
launched isn't caught by `--wait`.
|
||||
- **Draft-aware delivery to Claude:** a narrow Claude pane showing
|
||||
`✔ Update installed · Restart to update` on its own line can still hold
|
||||
deliveries at an empty prompt, queue wakes included; 0.7.10 doesn't recognize
|
||||
that bare banner. A held message isn't replayed automatically by that path.
|
||||
- **Seats handed over before 0.7.9** keep their adopted origin until a later
|
||||
launched handover or managed launch records a new session. Until then
|
||||
`rig seat stop`, `clean` and in-place restart refuse them, and now say why.
|
||||
Don't use `rig unclaim` to get around it: that ends OpenRig's management of
|
||||
the seat.
|
||||
- **Permission requests in seat status** show only the latest one, held in
|
||||
memory for five minutes and lost on a daemon restart.
|
||||
- **Agent spec advisories** don't show for a native resume that continues a
|
||||
saved conversation.
|
||||
- **The other 0.7.9 limits below still apply:** open updates for a person, Slack,
|
||||
SSH host status, shared asks, permit records, existing seats, Codex hooks, and
|
||||
grant-service assistants not being woken. 0.7.9's limits on a restore request
|
||||
held after compaction, `rig broadcast`'s `FAILED`, what has run under the
|
||||
runner contract, and Codex seats' `rig: command not found` are fixed or
|
||||
changed. The 0.7.8 limits below that 0.7.9
|
||||
didn't fix or change, and the
|
||||
[0.7.7](https://github.com/mvschwarz/openrig/releases/tag/v0.7.7) and
|
||||
[0.7.6](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6) limits, still
|
||||
apply.
|
||||
|
||||
---
|
||||
|
||||
## What changed in OpenRig 0.7.9
|
||||
|
||||
The changes from 0.7.8 to 0.7.9, published on 2026-11-06, for an install
|
||||
upgrading from 0.7.8 or earlier.
|
||||
|
||||
### After the upgrade
|
||||
|
||||
- **Restart the daemon** to load the new code: `rig daemon stop`, then
|
||||
`rig daemon start`.
|
||||
- **Migrations 146 to 149** record a Pi seat's guidance file, what OpenRig last
|
||||
wrote in each managed guidance block, an index for reading a rig's outbox,
|
||||
and the stream intake position.
|
||||
- **Tell the person that stream intake is on.** Each `rig stream emit` now opens
|
||||
an intake row for the kernel queue worker (`queue.worker`), or adds a note to
|
||||
the one still unclaimed, and the row wakes the queue worker like any queue row.
|
||||
Earlier stream history isn't replayed. If they want 0.7.8's behaviour, with no
|
||||
intake rows, run `rig config set stream.intake off`.
|
||||
- **The bundled openrig-core plugin goes from 0.1.24 to 0.1.25.** Its
|
||||
seat-continuity-and-handover skill changed. Managed refresh preserves replaced
|
||||
files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits
|
||||
you still need from the preserved copy.
|
||||
- **Where seat guidance goes now:**
|
||||
- a fresh Pi seat gets OpenRig's guidance in its own agent folder's
|
||||
`AGENTS.md`, unless that file or folder is a link;
|
||||
- a fresh Codex seat on Codex 0.164.0 or later gets it as launch developer
|
||||
instructions, not in the repository `AGENTS.md`, when no config layer
|
||||
OpenRig reads sets `developer_instructions`;
|
||||
- a Codex resume, fork, restore or handover successor, an older or unknown
|
||||
Codex, and a config layer that sets `developer_instructions` or can't be read
|
||||
still use the repository `AGENTS.md`;
|
||||
- if only the person's shell startup sets `CODEX_HOME`, OpenRig reads
|
||||
`~/.codex`, so a `developer_instructions` set in their home is replaced for
|
||||
that session. Declare the same absolute path as `CODEX_HOME` in the member's
|
||||
or rig's `env:` to have it checked. `docs/reference/agent-startup-guide.md`
|
||||
("Codex guidance at launch") is the rule;
|
||||
- a managed block a person edited by hand is kept and named
|
||||
(`guidance_block_edited`), not replaced. Read the warning before you change
|
||||
the block.
|
||||
- **Slack:** on the first start after upgrading, the connector edits the asks
|
||||
that ended earlier to show they were handled, at Slack's edit rate: about an
|
||||
hour for 450 asks. `rig slack status` shows pending edits.
|
||||
- **OpenShell:** rebuild a guest image built for 0.7.8 with this release's
|
||||
helper; an older helper rejects the new startup step, and the error names the
|
||||
rebuild.
|
||||
|
||||
### Runner seats
|
||||
|
||||
Section 7 of `docs/reference/harness-runner-contract.md` is the rule for
|
||||
permissions, and section 4 for activity. Read them before writing or changing a
|
||||
runner.
|
||||
- **Typed words:** a reply of just yes or no counts as the agent's allow or
|
||||
reject. Any other words choose nothing: OpenRig's default permits the request
|
||||
when the agent offered an allow option, and the read result carries the words
|
||||
whole as `said`. A runner may queue them for the agent as an ordinary prompt
|
||||
after the current turn, never as the answer; one that drops them still keeps
|
||||
the contract. The ask now tells the person this.
|
||||
- **Permit records:** each permit OpenRig decides by default (no one to ask, or
|
||||
an ask closed without a choice, including a row passed to an agent seat) is
|
||||
recorded once, as a stream item from the seat, naming the tool call, why no
|
||||
person answered and the option given. Read them with
|
||||
`rig stream list --tag runner-permission-permit` or `--source <seat>`. A record
|
||||
has no destination and never changes the permit. Stream intake reads past it, so
|
||||
it opens no intake row and wakes no one.
|
||||
- **The working beat:** while a prompt is outstanding and the agent is alive, a
|
||||
runner may post `active` / `liveness` so a long call keeps reading working.
|
||||
After delivering an answer or permit, it posts `active` /
|
||||
`permission_resolved`. A runner that sends no beats behaves as before. The Pi
|
||||
runner and the dsh example post it.
|
||||
- **Check a runner** with
|
||||
`node daemon/docs/reference/runner-conformance/check-runner.mjs --runner '<command with {agent}>' --name <harness>`.
|
||||
It now covers the beat, and no longer falsely fails a correct runner on a busy
|
||||
machine.
|
||||
- **The dsh example** reads `DSH_SEAT_BIN` and `DSH_SEAT_HOME` from the member's
|
||||
`env:`, and section 8 names ACP's `session/resume`.
|
||||
|
||||
### OpenShell workers
|
||||
|
||||
0.7.9 includes a repair for the OpenShell worker that wasn't usable in 0.7.8,
|
||||
but the worker still doesn't work in 0.7.9. Don't rely on this path in 0.7.9.
|
||||
Startup reaches the guest through a hash-checked first-input file, and nothing
|
||||
is typed through the host viewer. Readiness checks the guest's own execution,
|
||||
and a failed owned delete reports the stage that failed. Release testing on
|
||||
0.7.9 then found two more problems: the guest's Claude couldn't reach its model
|
||||
(`SELF_SIGNED_CERT_IN_CHAIN`), because the guest launcher left out the CA
|
||||
settings the OpenShell exec supplies, and deleting an owned environment never
|
||||
sent the delete, because its inventory step misread a "Not Found" reply. 0.7.10's
|
||||
fixes for both are described above. "Never grant the guest access to the
|
||||
daemon's main API port (normally 7433)."
|
||||
|
||||
### Assistants connected through a grant service (experimental)
|
||||
|
||||
Experimental, unsupported, off by default, and at the person's own risk. With
|
||||
`rig mcp serve --http --grant-service <url>` and a grant service the person
|
||||
supplies and runs, a connected assistant can claim, update and park the rows
|
||||
addressed to its own seat, and see its team with `rig_team`, within the scope its
|
||||
existing tools have. Nothing wakes it: an item stays queued until its receiver
|
||||
claims it. Name the outside service, and the exposure of serving MCP over HTTP,
|
||||
to the person before you set this up. `docs/reference/hosted-members.md` is the
|
||||
rule.
|
||||
|
||||
### Other changes to act on
|
||||
|
||||
- **Held messages:** a seat that starts, or takes over a seat, with held
|
||||
messages is told how many there are. Read them with
|
||||
`rig seat held-messages <seat>`.
|
||||
- **Unconfirmed sends:** `rig send --verify` now reports unconfirmed and exits 1
|
||||
when it can't see what its Enter did, and `rig walk` reports unconfirmed
|
||||
instead of lost. Inspect the recipient before any resend.
|
||||
- **Deadlines:** to accept a re-estimate without unclaiming the work, run
|
||||
`rig queue update <id> --closure-required-at <ISO>`.
|
||||
- **Shared workspaces:** any registered person can settle another's ask as
|
||||
themselves, and the first committed answer wins; a later answer gets 409
|
||||
`qitem_already_terminal` and records nothing. A person registered with no
|
||||
connector binding is asked on Steering and in the Feed only.
|
||||
- **Handover:** a fresh or rebuild successor's first turn lists its startup
|
||||
files, role first. A successor that started without a required startup file is
|
||||
marked in the seat list.
|
||||
- **Ambiguous rig names:** when an active rig name matches more than one rig,
|
||||
`rig env down`, `status` and `logs` list the matching rig IDs and stop, and
|
||||
`rig doctor --spec` reports conformance as unknown. Use the rig ID.
|
||||
- **Seat activity:** window samples older than their bound no longer decide a
|
||||
seat's activity, so a seat whose pane can't be read reads unknown rather than
|
||||
its last verdict. Treat unknown as unknown, not idle.
|
||||
- **Launch options:** `specs.builtins_visible: false` hides built-in specs
|
||||
(`rig specs ls --include-builtins` still lists them); `plugin_channels` gives
|
||||
one Claude member its own `--channels`, untested with a real channel plugin.
|
||||
- **Files:** `rig down` removes OpenRig's own additions from a working folder
|
||||
once no live seat uses them. `rig export` with no `-o` writes
|
||||
`rig-export.yaml` beside an existing `rig.yaml`. `rig workspace validate` exits
|
||||
1 for malformed optional frontmatter.
|
||||
- **Kernels:** a new default kernel starts in `<workspace.root>/kernel`.
|
||||
Existing kernels stay where they are. `docs/reference/agent-startup-guide.md`
|
||||
explains the optional move; a fresh launch there starts a new native
|
||||
conversation, so ask the person before moving.
|
||||
- **PATH:** if preflight warns that a Codex seat's login shell can't find `rig`,
|
||||
follow its fix. `getting-started.md` explains the `~/.profile` line and how to
|
||||
check it with `bash -lc 'command -v node rig'`.
|
||||
|
||||
### Limits to keep in view
|
||||
|
||||
- **OpenShell:** the worker doesn't work in 0.7.9 (above). A same-generation
|
||||
reconciled launch opens no viewer, so run `rig environment connect` yourself;
|
||||
non-text startup actions aren't supported on environment seats.
|
||||
- **Open updates for a person** read a 1,001-row window, so a page can come back
|
||||
short, marked `truncated`.
|
||||
- **Slack:** a private channel needs the documented route, which isn't tested,
|
||||
and direct messages aren't supported. The channel and people reads haven't
|
||||
been run against a live Slack workspace. Redaction of the bot token from a
|
||||
transport error can still leave a malformed token fragment shorter than six
|
||||
characters, so review an error before sharing it.
|
||||
- **SSH host status** reads `unknown` behind a ProxyJump or ProxyCommand; that
|
||||
isn't proof the connection is broken. `rig host ls` reads the ssh config with
|
||||
`ssh -G`, which runs any `Match exec` command in it.
|
||||
- **Shared asks:** a click's option written before its answer row can stand
|
||||
beside or inside the closer's answer without the first clicker's authorship.
|
||||
- **Permit records** last as long as stream retention keeps them. A permit a
|
||||
runner makes on its own, when it can't reach OpenRig, is in its pane only. For
|
||||
an ask opened before 0.7.9, a record may omit the option's kind.
|
||||
- **Existing seats** pick up Codex launch instructions and the single
|
||||
openrig-core skill load at their next fresh launch. A `CLAUDE.md` or
|
||||
`AGENTS.md` that already grew a line per launch isn't trimmed.
|
||||
- **Codex hooks after an upgrade:** Codex sessions kept running across the
|
||||
upgrade show "Hook failed — plugin contents changed; hook approval required"
|
||||
until relaunched.
|
||||
- **0.7.8's limits** on the dsh `env:` names, conformance false failures, typed
|
||||
words, the README's operator address, a symlinked rebuild, the rebuild receipt,
|
||||
a re-parked click and unrecorded default permits are fixed or changed. The
|
||||
other 0.7.8 limits below, and the
|
||||
[0.7.7](https://github.com/mvschwarz/openrig/releases/tag/v0.7.7) and
|
||||
[0.7.6](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6) limits, still
|
||||
apply.
|
||||
|
||||
---
|
||||
|
||||
## What changed in OpenRig 0.7.8
|
||||
|
||||
The changes from 0.7.7 to 0.7.8, published on 2026-11-05, for an install
|
||||
upgrading from 0.7.7 or earlier.
|
||||
|
||||
### After the upgrade
|
||||
|
||||
- **Restart the daemon** to load the new code: `rig daemon stop`, then
|
||||
`rig daemon start`.
|
||||
- **Migrations 142 to 145** store a Claude seat's MCP opt-out, seat activity
|
||||
grants, OpenShell environment bindings and a seat's chosen permission person.
|
||||
- **The bundled openrig-core plugin goes from 0.1.23 to 0.1.24.** One skill line
|
||||
changed. Managed refresh preserves replaced files under
|
||||
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you still
|
||||
need from the preserved copy.
|
||||
|
||||
### Runner seats: a person answers permission requests
|
||||
|
||||
Section 7 of `docs/reference/harness-runner-contract.md` is the rule. Read it
|
||||
before writing or changing a runner.
|
||||
- **Who is asked:**
|
||||
- **the default person** (`rig gateway default`, or the only registered
|
||||
person) gets an ordinary ask in the Feed and on Slack;
|
||||
- **a chosen person,** after
|
||||
`rig seat set-permissions-to <seat> --to <person> --reason "…"`. `--clear`
|
||||
returns to the default;
|
||||
- **no one:** when no one can be asked, the request is "Permitted at once,
|
||||
with the agent's own allow option";
|
||||
- **refusal** is opt-in, with `--deny`. `rig seat status` shows the choice.
|
||||
- **What a person's answer does:** a click gives that option, a plain yes gives
|
||||
the allow, and a plain no gives the reject. "A person's no is never a grant."
|
||||
An unanswered request stays pending until the agent's turn ends.
|
||||
- **The runner's protocol:**
|
||||
- `POST /api/activity/permission-requests` submits the request;
|
||||
- `GET …/<askId>?waitSeconds=25` reads the answer (25 at most);
|
||||
- `POST …/<askId>/withdraw` withdraws it at the turn's end.
|
||||
|
||||
Deliver an answer once, only to the live request.
|
||||
- **Check a runner** with
|
||||
`node daemon/docs/reference/runner-conformance/check-runner.mjs --runner '<command with {agent}>' --name <harness>`
|
||||
from the installed package. It needs Node and tmux, and no daemon, model or
|
||||
network.
|
||||
- **The worked example** is the dsh runner under
|
||||
`daemon/docs/reference/community-harness-examples/dsh/`.
|
||||
- **A seat activity grant** lets a runner outside the host's hook setup report
|
||||
activity for one seat and launch, without the install-wide hook token.
|
||||
|
||||
### OpenShell workers
|
||||
|
||||
0.7.8 includes `rig environment` (`setup`, `create`, `attach`) and a reference
|
||||
for binding a seat to an NVIDIA OpenShell sandbox (`daemon/assets/openshell/README.md`).
|
||||
**In our release testing the worker never became usable:** after the sandbox was
|
||||
created and the native process started, `rig environment connect` failed when its
|
||||
check timed out after 5 seconds, so no team work ran. Don't rely on this path in
|
||||
0.7.8; the later repairs are described above.
|
||||
|
||||
**What it needs:**
|
||||
- NVIDIA OpenShell 0.1.6;
|
||||
- a Linux image;
|
||||
- an API-backed provider using `ANTHROPIC_API_KEY`. Subscription or OAuth
|
||||
credentials aren't supported.
|
||||
|
||||
"Provider registration and image construction can incur costs", so name them to
|
||||
the person before you start. "Never grant the guest access to the daemon's main
|
||||
API port (normally 7433)."
|
||||
|
||||
### Other changes to act on
|
||||
|
||||
- **Mission following:** `rig terminal open mission:<id> --follow --provider herdr`
|
||||
follows a mission's current owners, with the same Herdr path as slices.
|
||||
- **Launch choices:**
|
||||
- `claude_mcp: none` on a Claude member launches it without MCP servers;
|
||||
- `rig config set terminal.tmux_server isolated` gives this OpenRig home its
|
||||
own persistent tmux server.
|
||||
- **Hosts:** `rig host edit` and `rig host remove` correct or retire a host
|
||||
entry. Use them instead of editing `hosts.yaml` by hand.
|
||||
- **Another user's daemon:** when `rig doctor` warns that the port is held by
|
||||
another OS user's daemon, follow its fix (`rig config set daemon.port <free-port>`),
|
||||
or set `OPENRIG_URL` to share that daemon on purpose.
|
||||
- **tmux control mode:** with `OPENRIG_TMUX_CONTROL=1`, OpenRig starts a control
|
||||
client only on tmux 3.7c or newer, and uses spawned clients otherwise.
|
||||
- **Read-only tmux clients:** a submit key refused because of one goes through
|
||||
the paste route.
|
||||
- **Slack:** a typed reply to a decision parked on its person is recorded as the
|
||||
answer. Read the person's words; don't treat the close as approval.
|
||||
- **The TUI:** agent pages now show health findings and recently finished items,
|
||||
and an agent spec's "not loaded in this view" means it wasn't read, not that
|
||||
nothing runs it.
|
||||
|
||||
### Limits to keep in view
|
||||
|
||||
- ~~**No harness has run end to end under the runner contract yet.** The dsh
|
||||
example and the conformance check were checked against stand-ins.~~ *Corrected
|
||||
after release:* 0.7.8's release testing ran a real dsh 0.2.1-rc.2 through the
|
||||
shipped dsh example runner for a person's allow-once and reject-once, and
|
||||
through an outside runner, dsh-seat 0.3.0, for launch, queue work, multiline
|
||||
input, activity, cancel and exact resume. The conformance check itself runs
|
||||
against a stand-in agent.
|
||||
- **The answer owner is a registered person,** and a colleague's answer is
|
||||
followed on this host only. A request handed to another host waits as needs
|
||||
input until the turn ends.
|
||||
- **Permits and answers aren't recorded** beyond the runner's pane and the ask.
|
||||
If the agent offers only `allow_always`, a permit makes its harness remember a
|
||||
permission nobody chose.
|
||||
- **Coming as follow-ups:** recording permits made when no one can be asked, and
|
||||
passing typed words that aren't a plain yes or no to the agent.
|
||||
- **The dsh example keeps one hold per seat:** two asks in one turn can leave one
|
||||
waiting after the hold is released.
|
||||
- **An OpenShell worker's activity reads unknown,** sampled from the pane, never
|
||||
idle.
|
||||
- **The [0.7.7](https://github.com/mvschwarz/openrig/releases/tag/v0.7.7) and
|
||||
[0.7.6](https://github.com/mvschwarz/openrig/releases/tag/v0.7.6) limits still apply.**
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.10",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
@@ -7262,7 +7262,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.10",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -7300,7 +7300,7 @@
|
||||
},
|
||||
"packages/daemon": {
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.10",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
@@ -7342,7 +7342,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.10",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.10",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.10",
|
||||
"type": "module",
|
||||
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
|
||||
"keywords": [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -62,3 +62,16 @@ export function writeTextAtomically(path: string, content: string, label = "file
|
||||
if (owned) try { unlinkSync(temporary); } catch { /* Renamed or already removed. */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a file, failing with EEXIST rather than replacing anything already at the path, even a dangling link. */
|
||||
export function writeNewText(path: string, content: string): void {
|
||||
const fd = openSync(path, "wx", 0o666);
|
||||
try {
|
||||
writeFileSync(fd, content, "utf-8");
|
||||
} catch (error) {
|
||||
try { unlinkSync(path); } catch { /* Report the write failure. */ }
|
||||
throw error;
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import { findRunProcesses } from "../acp/run-processes.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
const runsDir = () => join(getOpenRigHome(), "acp", "runs");
|
||||
const newId = (prefix: string, bytes: number) => `${prefix}-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(bytes).toString("hex")}`;
|
||||
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 }
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -222,12 +222,16 @@ 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 {
|
||||
console.log(`${r.sessionName}: FAILED — ${r.error ?? "unknown error"}`);
|
||||
// A target that resolved to no sessions has no name to report.
|
||||
console.log(`${r.sessionName ? `${r.sessionName}: ` : ""}FAILED — ${r.error ?? "unknown error"}`);
|
||||
}
|
||||
if (typeof r.managedCompaction?.notice === "string") console.log(`Notice: ${r.managedCompaction.notice}`);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ Keys:
|
||||
topology.root the topology tree root (instance altitude at its top; default $OPENRIG_HOME/topology)
|
||||
context.root addressable context library for 'rig context add' (default $OPENRIG_HOME/context)
|
||||
context.system_world default System World, replacement manifest path, or disabled
|
||||
specs.builtins_visible show built-ins in spec inventory (default true)
|
||||
skills.root versioned skill catalog (default $OPENRIG_HOME/skills)
|
||||
skills.router_line.enabled the person's yes to the seat-skills line (one line plus OpenRig's markers) in AGENTS.md / CLAUDE.md (default false)
|
||||
onboarding.default_pack.enabled deliver the two-part fresh-seat mental-model pack (default on)
|
||||
@@ -103,6 +104,13 @@ Keys:
|
||||
retention.* enabled, transitions_days, watchdog_days,
|
||||
watchdog_keep_per_job, batch_size
|
||||
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.
|
||||
|
||||
@@ -194,6 +194,7 @@ async function installPackFromUrl(
|
||||
// Re-validate the on-disk staged pack, then publish atomically.
|
||||
validateContextPackManifestForInstall(join(staging, "manifest.yaml"));
|
||||
assertTreeHasNoSymlinks(staging);
|
||||
mkdirSync(dirname(targetDir), { recursive: true });
|
||||
renameSync(staging, targetDir);
|
||||
return { targetDir, installName };
|
||||
} catch (err) {
|
||||
|
||||
@@ -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 the port's /healthz body may take to arrive (default 2000 ms); after that its owner is unknown. */
|
||||
/** 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,
|
||||
@@ -301,6 +303,15 @@ export function runDoctorChecks(deps: DoctorDeps, providerChecks: DoctorCheck[]
|
||||
const daemonPort = config.daemon.port ?? DEFAULT_PORT;
|
||||
const daemonBase = `http://${formatDaemonHostForUrl(daemonHost)}:${daemonPort}`;
|
||||
const fetchFn = deps.fetch ?? globalThis.fetch;
|
||||
const healthHeaders = async (controller: AbortController) => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
fetchFn(`${daemonBase}/healthz`, { signal: controller.signal }),
|
||||
new Promise<null>(resolve => { timer = setTimeout(() => { resolve(null); controller.abort(); }, deps.ownerReportTimeoutMs ?? 2000); }),
|
||||
]);
|
||||
} finally { clearTimeout(timer); }
|
||||
};
|
||||
const portCheck = deps.checkPort(daemonPort, daemonHost).then(async (available): Promise<DoctorCheck> => {
|
||||
if (available) {
|
||||
return { name: "port", status: "pass", message: `Port ${daemonHost}:${daemonPort} available.` };
|
||||
@@ -308,7 +319,13 @@ export function runDoctorChecks(deps: DoctorDeps, providerChecks: DoctorCheck[]
|
||||
// Port in use — check if it's an OpenRig daemon via healthz, and whose.
|
||||
const owner = new AbortController();
|
||||
try {
|
||||
const res = await fetchFn(`${daemonBase}/healthz`, { signal: owner.signal });
|
||||
const res = await healthHeaders(owner);
|
||||
if (!res) return {
|
||||
name: "port", status: "warn",
|
||||
message: `Port ${daemonHost}:${daemonPort} is in use by an unknown listener.`,
|
||||
reason: "Its /healthz response headers did not arrive in time, so doctor cannot identify it.",
|
||||
fix: `Check the listener on port ${daemonPort} or configure a free port for the daemon.`,
|
||||
};
|
||||
if (res.ok) return await listenerOwnerCheck(`${daemonHost}:${daemonPort}`, daemonBase, res, () => owner.abort(), deps);
|
||||
} catch { /* not our daemon */ }
|
||||
finally { owner.abort(); }
|
||||
@@ -325,19 +342,27 @@ export function runDoctorChecks(deps: DoctorDeps, providerChecks: DoctorCheck[]
|
||||
const asyncChecks: Promise<DoctorCheck>[] = [portCheck];
|
||||
if (shellCmuxPassed) {
|
||||
const daemonCmuxCheck = (async (): Promise<DoctorCheck> => {
|
||||
const health = new AbortController();
|
||||
try {
|
||||
const healthRes = await fetchFn(`${daemonBase}/healthz`);
|
||||
if (!healthRes.ok) {
|
||||
const healthRes = await healthHeaders(health);
|
||||
if (!healthRes?.ok) {
|
||||
return { name: "cmux_daemon", status: "skipped", message: "Daemon healthz not ok. Skipping daemon cmux check." };
|
||||
}
|
||||
} catch {
|
||||
return { name: "cmux_daemon", status: "skipped", message: "Daemon not reachable. Skipping daemon cmux check." };
|
||||
}
|
||||
} 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." };
|
||||
}
|
||||
@@ -398,6 +423,12 @@ async function buildSpecConformanceCheck(deps: DoctorDeps): Promise<DoctorCheck>
|
||||
let liveIds: string[] | null;
|
||||
try { liveIds = deps.fetchLiveLogicalIds ? await deps.fetchLiveLogicalIds(rigName) : null; }
|
||||
catch (error) {
|
||||
if (error instanceof AmbiguousLiveRigError) return {
|
||||
name, status: "skipped",
|
||||
message: `Multiple live rigs match '${rigName}': ${error.ids.join(", ")}; conformance unknown.`,
|
||||
reason: "The spec name does not identify one live rig, so no topology was compared.",
|
||||
fix: "Inspect the matching rig IDs with rig ps --json; use rig ps --nodes -A --json to inspect nodes.",
|
||||
};
|
||||
if (error instanceof ForeignLocalDaemonError) return { name, status: "fail", message: error.message };
|
||||
throw error;
|
||||
}
|
||||
@@ -482,22 +513,43 @@ function readTmuxMouseMode(deps: DoctorDeps): "on" | "off" | null {
|
||||
}
|
||||
}
|
||||
|
||||
class AmbiguousLiveRigError extends Error {
|
||||
constructor(readonly ids: string[]) { super("Multiple live rigs share the spec name"); }
|
||||
}
|
||||
|
||||
/** Raw topology reads retain their existing headers; the owner policy is shared with the client. */
|
||||
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 rig = rigs.find((r) => r.name === rigName);
|
||||
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) throw error;
|
||||
if (error instanceof ForeignLocalDaemonError || error instanceof AmbiguousLiveRigError) throw error;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -529,7 +581,7 @@ export function doctorCommand(depsOverride?: DoctorDeps): Command {
|
||||
const base = `http://${formatDaemonHostForUrl(cfg.daemon.host ?? "127.0.0.1")}:${cfg.daemon.port ?? DEFAULT_PORT}`;
|
||||
return await readLiveLogicalIds(base, rigName);
|
||||
} catch (error) {
|
||||
if (error instanceof ForeignLocalDaemonError) throw error;
|
||||
if (error instanceof ForeignLocalDaemonError || error instanceof AmbiguousLiveRigError) throw error;
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -88,6 +88,8 @@ interface TeardownResult {
|
||||
keptSkills?: string[];
|
||||
/** Managed guidance blocks a person edited, kept in their file as `<file>: <block id>`. */
|
||||
keptGuidance?: string[];
|
||||
/** Files in a Claude seat's cwd that keep OpenRig's additions because down couldn't safely change them, as `<file>: <why>`. */
|
||||
keptFiles?: string[];
|
||||
/** Seats joined with `rig attach` as an external CLI, which OpenRig doesn't stop. */
|
||||
leftRunning?: string[];
|
||||
paneOnly?: string[];
|
||||
@@ -145,6 +147,11 @@ Before running it, check who is working (rig ps --nodes --rig <rig>) and that th
|
||||
don't stop a team you didn't start unless asked. OpenRig snapshots the rig first (best-effort), and
|
||||
rig up <rig> --existing brings it back, resuming each seat's conversation where its harness allows. With
|
||||
--delete the rig record is removed too, so it can't be brought back that way.
|
||||
Once no live seat uses a working folder, rig down also takes back what OpenRig added there: its
|
||||
guidance blocks, and in a Claude seat's folder the status line, activity hooks and context collector.
|
||||
It keeps and lists an edited skills-line block, edited team skill files, and settings or a collector
|
||||
that don't match what OpenRig wrote or that another seat shares by a link. Any other guidance block
|
||||
goes even if someone edited it. rig up <rig> --existing puts the guidance back.
|
||||
When your resolved seat belongs to this rig, confirm before stopping it. Without a terminal,
|
||||
the command warns and exits without stopping; re-run with --yes to confirm explicitly.`);
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
@@ -295,6 +302,10 @@ the command warns and exits without stopping; re-run with --yes to confirm expli
|
||||
console.log(`Kept ${result.keptGuidance.length} edited OpenRig block(s) in guidance files; remove them by hand if you don't want them:`);
|
||||
for (const block of result.keptGuidance) console.log(` ${block}`);
|
||||
}
|
||||
if (result.keptFiles && result.keptFiles.length > 0) {
|
||||
console.log(`Kept ${result.keptFiles.length} file(s) that still hold OpenRig's additions; check them by hand:`);
|
||||
for (const file of result.keptFiles) console.log(` ${file}`);
|
||||
}
|
||||
|
||||
if (result.leftRunning && result.leftRunning.length > 0) {
|
||||
console.log(`Not stopped: ${result.leftRunning.join(", ")}. Joined with rig attach as an external CLI, which OpenRig doesn't stop or check.`);
|
||||
|
||||
@@ -30,9 +30,14 @@ Examples:
|
||||
throw new Error(error ?? `Failed to list rigs (HTTP ${summaries.status})`);
|
||||
}
|
||||
if (!Array.isArray(summaries.data)) throw new Error("The daemon returned an invalid rig summary.");
|
||||
const match = summaries.data.find((r) => r.name === rigRef || r.id === rigRef);
|
||||
if (!match) throw new Error(`Rig '${rigRef}' not found`);
|
||||
return match.id;
|
||||
const exactId = summaries.data.find((r) => r.id === rigRef);
|
||||
if (exactId) return exactId.id;
|
||||
const matches = summaries.data.filter((r) => r.name === rigRef);
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`'${rigRef}' matches ${matches.length} rigs (${matches.map((r) => r.id).join(", ")}). Nothing was done; pass one of these ids instead.`);
|
||||
}
|
||||
if (matches.length === 0) throw new Error(`Rig '${rigRef}' not found`);
|
||||
return matches[0]!.id;
|
||||
}
|
||||
|
||||
// rig env status <rig>
|
||||
|
||||
@@ -128,8 +128,10 @@ export function environmentCommand(override?: EnvironmentCommandDeps): Command {
|
||||
.action(action(async (binding: string, opts: { live?: boolean }) => report(checked(await (await client()).get<Record<string, any>>(`/api/environments/${encodeURIComponent(binding)}${opts.live ? "?live=true" : ""}`)))));
|
||||
cmd.command("connect").argument("<binding>").requiredOption("--generation <id>").description("Open only the bound execution's viewer; closing it does not prove native or compute stopped")
|
||||
.action(action(async (id: string, opts: { generation: string }) => {
|
||||
const c = await client(), b = checked(await c.get<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}?live=true`));
|
||||
checked(await c.post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/validate`, { generation: opts.generation }));
|
||||
// Launch and the guest latch already verify all projected files. Opening a viewer needs only one live
|
||||
// resource lookup (the driver bounds it at 30s), not another serial validation of every startup file.
|
||||
const c = await client(), b = checked(await c.get<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}?live=true`, { timeoutMs: 35_000 }));
|
||||
if (b.generation !== opts.generation) throw new Error("The selected environment generation changed; no viewer was opened");
|
||||
const args = ["--gateway", b.placement.gateway, "--workspace", b.placement.workspace, "sandbox", "connect", b.placement.name];
|
||||
await new Promise<void>((resolveExit, reject) => {
|
||||
const child = spawn("openshell", args, { stdio: "inherit" });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import nodePath from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { Command } from "commander";
|
||||
import { writeTextAtomically } from "../atomic-text-write.js";
|
||||
import { writeNewText, writeTextAtomically } from "../atomic-text-write.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
@@ -10,6 +10,8 @@ import { rigIdForHandle } from "../rig-handle.js";
|
||||
|
||||
export interface ExportDeps extends StatusDeps {
|
||||
writeFile: (path: string, content: string) => void;
|
||||
/** Must fail with EEXIST when the path is taken. */
|
||||
writeNewFile?: (path: string, content: string) => void;
|
||||
exists?: (path: string) => boolean;
|
||||
}
|
||||
|
||||
@@ -37,18 +39,35 @@ function copyCulture(file: string, root: string, outDir: string): void {
|
||||
console.log(`Copied culture to ${target}`);
|
||||
}
|
||||
|
||||
/** rig.yaml is also the name of an authored spec, and an export doesn't carry all of one (comments, a summary, startup
|
||||
* it couldn't read back). So a default export takes the first free name, created so that it can't replace a file that
|
||||
* appeared after the daemon was asked. Only a path the person named with -o may replace a file. */
|
||||
function writeDefaultExport(write: (path: string, content: string) => void, first: string, content: string): string {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const path = attempt === 1 ? first : attempt === 2 ? "rig-export.yaml" : `rig-export-${attempt - 1}.yaml`;
|
||||
try {
|
||||
write(path, content);
|
||||
if (attempt > 1) console.warn(`${first} already exists and was left unchanged. Use -o ${first} to replace it.`);
|
||||
return path;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function exportCommand(depsOverride?: ExportDeps): Command {
|
||||
const cmd = new Command("export").description("Export a rig spec as YAML");
|
||||
const getDeps = (): ExportDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
writeFile: (p, content) => writeTextAtomically(p, content, "export"),
|
||||
writeNewFile: writeNewText,
|
||||
exists: (p) => fs.existsSync(p),
|
||||
};
|
||||
|
||||
cmd
|
||||
.argument("<rig>", "Rig name or ID to export")
|
||||
.option("-o, --output <path>", "Output file path", "rig.yaml")
|
||||
.option("-o, --output <path>", "Output file path, replacing any file there. Without it, an existing rig.yaml is kept and the export goes to an unused rig-export*.yaml", "rig.yaml")
|
||||
.action(async (rigId: string, opts: { output: string }) => {
|
||||
const deps = getDeps();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
@@ -67,8 +86,10 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
|
||||
console.error(`Export failed: ${res.data}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
deps.writeFile(opts.output, res.data);
|
||||
console.log(`Exported to ${opts.output}`);
|
||||
const written = cmd.getOptionValueSource("output") === "default"
|
||||
? writeDefaultExport(deps.writeNewFile ?? deps.writeFile, opts.output, res.data)
|
||||
: (deps.writeFile(opts.output, res.data), opts.output);
|
||||
console.log(`Exported to ${written}`);
|
||||
for (const line of String(res.data).split("\n")) {
|
||||
if (line.startsWith("# Not carried: ")) console.log(line.slice(2));
|
||||
if (line.startsWith("# Member startup not carried for ")) console.warn(`Warning: ${line.slice(2)}`);
|
||||
@@ -92,7 +113,7 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
|
||||
const parsed = parse(res.data) as Record<string, unknown> | null;
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if (parsed["name"] === "starter") {
|
||||
console.warn("Warning: this export still installs as 'starter'. Before sharing, rename the team's name in rig.yaml; changing the bundle name alone does not rename the installed rig.");
|
||||
console.warn(`Warning: this export still installs as 'starter'. Before sharing, rename the team's name in ${written}; changing the bundle name alone does not rename the installed rig.`);
|
||||
}
|
||||
const pods = parsed["pods"] as Array<{
|
||||
id?: string;
|
||||
|
||||
@@ -173,9 +173,9 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
|
||||
.command("add <entityId>")
|
||||
.description("Add a human fragment (verb-add-only; the fragment is truth, the registry is a GENERATED projection)")
|
||||
.requiredOption("--display-name <name>", "Human-readable display name")
|
||||
.requiredOption(
|
||||
.option(
|
||||
"--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>",
|
||||
"Connector binding (repeatable); role primary|secondary, EXACTLY ONE primary. connectorRef and secretsRef are config/vault labels (e.g. for Slack: slack:openrig-personal:file:<env-path>:primary:handle=<id>; tokens come from 'rig slack setup --secrets-env-file', never inline). Optional handle=<platform id> makes the binding inbound-resolvable (unique per kind); omit it for outbound-only.",
|
||||
"Connector binding (repeatable); leave it out for a person with no connector, who's reached in the Feed and Steering only. With bindings, role primary|secondary, EXACTLY ONE primary. connectorRef and secretsRef are config/vault labels (e.g. for Slack: slack:openrig-personal:file:<env-path>:primary:handle=<id>; tokens come from 'rig slack setup --secrets-env-file', never inline). Optional handle=<platform id> makes the binding inbound-resolvable (unique per kind); omit it for outbound-only.",
|
||||
(v: string, acc: string[] = []) => { acc.push(v); return acc; },
|
||||
)
|
||||
.requiredOption("--delivery-class <A|B|C|D>", "Notification loudness class (the notifications register selection)")
|
||||
@@ -188,7 +188,7 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
|
||||
.option("--policy <text>", "Readable policy for agents working with this person (text, not enforced)")
|
||||
.option("--policy-file <path>", "Read the policy text from a file")
|
||||
.option("--default", "Make this person the default: the one escalations, the notice digest and other unaddressed needs go to")
|
||||
.action(async (entityId: string, opts: { displayName: string; binding: string[]; deliveryClass: string; away?: boolean; replace?: boolean; reason: string; actor?: string; organization?: string; group?: string[]; policy?: string; policyFile?: string; default?: boolean }) => {
|
||||
.action(async (entityId: string, opts: { displayName: string; binding?: string[]; deliveryClass: string; away?: boolean; replace?: boolean; reason: string; actor?: string; organization?: string; group?: string[]; policy?: string; policyFile?: string; default?: boolean }) => {
|
||||
// LAZY import the narrow daemon surface at invocation (dep rail 2).
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const { addHumanFragment, parseBindingSpec } = registry as unknown as {
|
||||
@@ -209,7 +209,7 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
|
||||
if (unknownGroup) { console.error(`refused: no group "${unknownGroup}" — create it with rig gateway group set ${unknownGroup} --organization … --name …`); process.exitCode = 1; return; }
|
||||
}
|
||||
const bindings: Record<string, unknown>[] = [];
|
||||
for (const spec of opts.binding) {
|
||||
for (const spec of opts.binding ?? []) {
|
||||
const b = parseBindingSpec(spec);
|
||||
if (!b.ok) { console.error(`--binding: ${b.error}`); process.exitCode = 1; return; }
|
||||
bindings.push(b.binding);
|
||||
@@ -284,8 +284,9 @@ Example:
|
||||
if (opts.json) { console.log(JSON.stringify({ ok: true, humans })); return; }
|
||||
if (res.humans.length === 0) { console.log("no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"); return; }
|
||||
for (const h of humans) {
|
||||
const inbound = h.bindings.inboundResolvable ? "" : " [outbound-only]";
|
||||
console.log(`${h.entityId} "${h.displayName}" class=${h.deliveryClass} ${h.availability} bindings=${h.bindings.count} (primary ${h.bindings.primary.kind}:${h.bindings.primary.connectorRef}) delivery=${h.deliveryReadiness.state}${inbound}`);
|
||||
const inbound = h.bindings.inboundResolvable || !h.bindings.primary ? "" : " [outbound-only]";
|
||||
const primary = h.bindings.primary ? `primary ${h.bindings.primary.kind}:${h.bindings.primary.connectorRef}` : "no connector: Feed and Steering only";
|
||||
console.log(`${h.entityId} "${h.displayName}" class=${h.deliveryClass} ${h.availability} bindings=${h.bindings.count} (${primary}) delivery=${h.deliveryReadiness.state}${inbound}`);
|
||||
if (!h.deliveryReadiness.ready) console.log(` delivery: ${h.deliveryReadiness.reason}${h.deliveryReadiness.nextAction ? `; next: ${h.deliveryReadiness.nextAction}` : ""}`);
|
||||
console.log(` ${"unknown" in h.quiet ? `quiet: unknown (${h.quiet.unknown})` : describeQuiet(h.quiet)}`);
|
||||
}
|
||||
@@ -731,17 +732,16 @@ Examples:
|
||||
.option("--json", "Complete records as JSON, including organizations and groups")
|
||||
.action(async (opts: { organization?: string; json?: boolean }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
if (opts.json) {
|
||||
const result = registry.participantsJsonView(undefined, opts.organization);
|
||||
if (!result.ok) { console.error(`refused: ${result.error}`); process.exitCode = 1; return; }
|
||||
console.log(JSON.stringify(result));
|
||||
return;
|
||||
}
|
||||
const view = registry.participantsView();
|
||||
if (!view.ok) { console.error(`refused: ${view.error}`); process.exitCode = 1; return; }
|
||||
const shown = opts.organization ? view.participants.filter((p) => p.organization?.id === opts.organization) : view.participants;
|
||||
const defaultPerson = view.default.person ? { id: view.default.person.entityId, source: view.default.source } : { id: null, reason: view.default.source === "none" ? view.default.reason : null };
|
||||
if (opts.json) {
|
||||
const side = registry.readParticipantsFile();
|
||||
console.log(JSON.stringify({ ok: true, participants: shown, default: defaultPerson,
|
||||
organizations: side.ok ? side.file.organizations : null, groups: side.ok ? side.file.groups : null,
|
||||
warnings: view.warnings, participantFile: view.sideFile }));
|
||||
return;
|
||||
}
|
||||
if (!shown.length) console.log(opts.organization ? `no participants in organization "${opts.organization}"` : "no participants yet: rig gateway human add … registers a person, rig gateway channel add … a shared channel");
|
||||
for (const p of shown) {
|
||||
console.log(`${p.id} ${p.kind} "${p.displayName}" ${p.address}${p.channel ? ` channel ${p.channel.channelId}` : ""}`);
|
||||
|
||||
@@ -24,8 +24,9 @@ import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { hostname as osHostname, userInfo } from "node:os";
|
||||
import { connect } from "node:net";
|
||||
import { execFile } from "node:child_process";
|
||||
import { getOpenRigHome } from "../openrig-compat.js";
|
||||
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry } from "../host-registry.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";
|
||||
@@ -167,15 +168,35 @@ function defaultDoctorDeps(): DoctorDeps {
|
||||
};
|
||||
}
|
||||
|
||||
/** Where ssh would connect for this host, from `ssh -G`, which reads ssh config without connecting.
|
||||
* Null when ssh can't resolve the target or a ProxyJump/ProxyCommand sits in between, since no
|
||||
* direct dial stands for that transport. */
|
||||
export function resolveSshEndpoint(host: SshHostEntry, timeoutMs = 1500): Promise<{ hostname: string; port: number } | null> {
|
||||
const args = [...(host.user ? ["-l", host.user] : []), "-G", host.target];
|
||||
return new Promise((resolve) => {
|
||||
execFile("ssh", args, { timeout: timeoutMs }, (error, stdout) => {
|
||||
if (error) return resolve(null);
|
||||
const conf = new Map(stdout.split("\n").map((line) => {
|
||||
const i = line.indexOf(" ");
|
||||
return [line.slice(0, i).toLowerCase(), line.slice(i + 1).trim()] as const;
|
||||
}));
|
||||
const proxied = ["proxyjump", "proxycommand"].some((k) => (conf.get(k) ?? "none") !== "none");
|
||||
const hostname = conf.get("hostname");
|
||||
const port = Number(conf.get("port"));
|
||||
resolve(hostname && Number.isInteger(port) && !proxied ? { hostname, port } : null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** FR-1 doctor legs — each failing step maps to a DISTINCT actionable error:
|
||||
* "SSH works but daemon is down" ≠ "daemon works but registry is wrong" ≠
|
||||
* "remote rig binary missing/old". */
|
||||
/** OPR.0.4.6.MH1 FR-3 — the coarse ls status word: a BOUNDED tcp dial
|
||||
* against the host's transport endpoint (ssh: target:22; http: the
|
||||
* URL's host:port). Coarse by design (the kubectl STATUS / docker
|
||||
* context ls precedent): reachable | unreachable | unknown — never a
|
||||
* hang (hard timeout), never a guess (dial failure = unreachable,
|
||||
* probe error/unparseable endpoint = unknown). */
|
||||
* against the host's transport endpoint (ssh: the host and port ssh itself
|
||||
* resolves, see resolveSshEndpoint; http: the URL's host:port). Coarse by
|
||||
* design (the kubectl STATUS / docker context ls precedent): reachable |
|
||||
* unreachable | unknown — never a hang (hard timeout), never a guess (dial
|
||||
* failure = unreachable, probe error/unparseable endpoint = unknown). */
|
||||
export async function probeHostStatus(
|
||||
host: HostEntry,
|
||||
tcpProbe: DoctorDeps["tcpProbe"],
|
||||
@@ -185,8 +206,9 @@ export async function probeHostStatus(
|
||||
let target: string;
|
||||
let port: number;
|
||||
if (host.transport === "ssh") {
|
||||
target = host.target;
|
||||
port = 22;
|
||||
const endpoint = await resolveSshEndpoint(host, timeoutMs);
|
||||
if (!endpoint) return "unknown";
|
||||
({ hostname: target, port } = endpoint);
|
||||
} else {
|
||||
const u = new URL(host.url);
|
||||
target = u.hostname.replace(/^\[|\]$/g, "");
|
||||
@@ -560,6 +582,7 @@ function printHostNextSteps(host: HostEntry): void {
|
||||
|
||||
export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
const cmd = new Command("host").description("Manage the multi-host registry (~/.openrig/hosts.yaml)");
|
||||
const rewritesRegistry = "\nIt rewrites ~/.openrig/hosts.yaml from the fields it knows, so comments, formatting, unknown entry keys and other top-level keys aren't kept.";
|
||||
|
||||
cmd
|
||||
.command("add")
|
||||
@@ -573,6 +596,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
.option("--bearer-file <path>", "File PATH holding the bearer token — http transport (optional pointer, never a value; at most one bearer flag)")
|
||||
.option("--notes <text>", "Free-form operator note")
|
||||
.option("--json", "JSON output")
|
||||
.addHelpText("after", rewritesRegistry)
|
||||
.action((opts: { id: string; transport: string; target?: string; user?: string; url?: string; bearerEnv?: string; bearerFile?: string; notes?: string; json?: boolean }) => {
|
||||
const rawEntry: Record<string, unknown> = { id: opts.id, transport: opts.transport };
|
||||
if (opts.target !== undefined) rawEntry["target"] = opts.target;
|
||||
@@ -703,6 +727,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
.option("--url <url>", "Remote daemon base URL — http transport")
|
||||
.option("--notes <text>", "Free-form operator note (empty string clears the value)")
|
||||
.option("--json", "JSON output")
|
||||
.addHelpText("after", rewritesRegistry)
|
||||
.action((id: string, opts: { target?: string; user?: string; url?: string; notes?: string; json?: boolean }) => {
|
||||
const changes: Record<string, unknown> = {};
|
||||
if (opts.target !== undefined) changes["target"] = opts.target;
|
||||
@@ -738,6 +763,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
.description("Remove a host entry (deletes only the registry entry — credential files, the learned identity and the selection stay as they are)")
|
||||
.argument("<id>", "A registered host id")
|
||||
.option("--json", "JSON output")
|
||||
.addHelpText("after", rewritesRegistry)
|
||||
.action((id: string, opts: { json?: boolean }) => {
|
||||
const res = removeHostEntry(id.trim());
|
||||
if (!res.ok) {
|
||||
@@ -757,7 +783,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
cmd
|
||||
.command("pair")
|
||||
.description("Pair with a remote host from one pasted address — one approval on the target, done (FR-6)")
|
||||
.argument("<url>", "The target daemon's address (http[s]://host:port, or host:port)")
|
||||
.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("--human <address>", "Registered @external approval recipient on the target (required when several exist)")
|
||||
|
||||
@@ -42,7 +42,7 @@ export function setExperiment(file: string, enabled: boolean, options: Partial<E
|
||||
const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW, 0o600);
|
||||
try {
|
||||
const s = fs.fstatSync(fd);
|
||||
if (!s.isFile() || s.nlink !== 1 || s.uid !== process.getuid?.()) throw Error("experiment config must be an owned regular file");
|
||||
if (!s.isFile() || s.nlink !== 1 || (process.getuid !== undefined && s.uid !== process.getuid())) throw Error("experiment config must be an owned regular file");
|
||||
fs.ftruncateSync(fd, 0); fs.writeFileSync(fd, JSON.stringify(config, null, 2) + "\n");
|
||||
} finally { fs.closeSync(fd); }
|
||||
return config;
|
||||
|
||||
@@ -580,11 +580,16 @@ to a 0600 file holding the token>. Only the path is kept, as the seat's referenc
|
||||
so the token never passes through OpenRig. First the daemon checks that the file would apply and that nothing Claude Code ranks
|
||||
above the token is set (a cloud provider, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY, an apiKeyHelper), and refuses with each one
|
||||
and its remedy if it is. When the token applies, the launch also unsets those names for Claude's process, so the pane shell's
|
||||
own exports can't outrank it. It takes a snapshot and checks that the snapshot resumes the seat's conversation. Then, holding
|
||||
the seat so no other stop, launch or switch runs on it in between, it sets the reference and relaunches the seat from that
|
||||
snapshot in its own pane: Claude ends there, the pane and its scrollback stay, and restore resumes the same conversation in it.
|
||||
Work in flight when it stopped doesn't carry over. Only a refusal before Claude was touched reads as unchanged; a relaunch that
|
||||
doesn't report a resumed conversation reads unknown, with the snapshot's recovery command, and isn't retried.
|
||||
own exports can't outrank it. For a running seat, it takes a snapshot and checks that the snapshot resumes the seat's
|
||||
conversation. Then, holding the seat so no other stop, launch or switch runs on it in between, it sets the reference and
|
||||
relaunches the seat from that snapshot in its own pane: Claude ends there, the pane and its scrollback stay, and restore
|
||||
resumes the same conversation in it. Work in flight when it stopped doesn't carry over. Only a refusal before Claude was
|
||||
touched reads as unchanged; a relaunch that doesn't report a resumed conversation reads unknown, with the snapshot's recovery
|
||||
command, and isn't retried. The relaunch uses the seat's own effort choice (rig seat set-effort) when there is one; otherwise
|
||||
it asks to keep the running effort, and uses the seat's saved effort when it can't. The report's effort line says which.
|
||||
A stopped seat isn't relaunched, and no snapshot is taken: the reference is saved only when the seat's next launch would
|
||||
resume its conversation, and the report prints that launch (rig launch <rig> <seat> --snapshot-id <id>, or a plain rig launch
|
||||
when restore's periodic carry resumes it). Otherwise it refuses and nothing changes.
|
||||
Seats sharing the config dir keep their own login: Claude Code reads the token from that one process's environment and doesn't
|
||||
save it. That environment is Claude's, so the tools, hooks and MCP servers it starts inherit the token, and one that prints its
|
||||
environment writes it into the transcript. A token-authenticated seat can make model requests but can't use claude.ai
|
||||
|
||||
@@ -99,6 +99,8 @@ interface NodeEntry {
|
||||
blockedWorkCount?: number;
|
||||
/** OPR.0.3.4.11 — held reason from node.held event. */
|
||||
heldReason?: string | null;
|
||||
/** #2456: the required startup files this occupant's handover started it without. */
|
||||
startedWithoutRequired?: string[];
|
||||
agentActivity?: {
|
||||
state: "running" | "needs_input" | "idle" | "unknown";
|
||||
reason: string;
|
||||
@@ -238,6 +240,7 @@ const ALLOWED_NODE_FIELDS = new Set([
|
||||
"needsInputEvidence",
|
||||
"contextUsage",
|
||||
"heldReason",
|
||||
"startedWithoutRequired",
|
||||
"storedSessionStatus",
|
||||
"identityVerdict",
|
||||
]);
|
||||
@@ -705,6 +708,11 @@ export function formatDeclaredModel(model: string | null | undefined): string {
|
||||
return m.length > 0 ? m : "not-declared";
|
||||
}
|
||||
|
||||
/** #2456: the attention reason for an occupant started without a required startup file. */
|
||||
function startedWithoutLine(files: string[]): string {
|
||||
return `missing required ${files.map((file) => file.split("/").pop()).join(", ")}`;
|
||||
}
|
||||
|
||||
export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string, unknown>> {
|
||||
return nodes.map((n) => {
|
||||
const keepDiagnostics = n.needsAttention !== false;
|
||||
@@ -768,6 +776,7 @@ export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string,
|
||||
}
|
||||
// held/attention reason (short) when present.
|
||||
if (n.heldReason) compact.heldReason = n.heldReason;
|
||||
if (n.startedWithoutRequired?.length) compact.startedWithoutRequired = n.startedWithoutRequired;
|
||||
if (keepDiagnostics && n.latestError) {
|
||||
compact.latestError = n.latestError;
|
||||
}
|
||||
@@ -926,7 +935,7 @@ intentionallyStopped, startupStatus, restoreOutcome, lifecycleState, tmuxAttachC
|
||||
resumeCommand, latestError, terminalActive, hasAssignedWork,
|
||||
assignedWorkCount, pendingWorkCount, inProgressWorkCount, blockedWorkCount,
|
||||
agentActivity, needsAttention, arbitratedNeedsInput, needsInputEvidence,
|
||||
contextUsage, heldReason, storedSessionStatus, identityVerdict.
|
||||
contextUsage, heldReason, startedWithoutRequired, storedSessionStatus, identityVerdict.
|
||||
|
||||
--host runs on a remote host declared in ~/.openrig/hosts.yaml (no current-rig
|
||||
default applied; the remote host's rigs are shown).
|
||||
@@ -1358,7 +1367,8 @@ async function handleNodes(
|
||||
console.log(padCompactNodeRow("RIG", "SESSION", "LIFECYCLE", "ACTIVITY", "WORK", "REASON", "ATTN"));
|
||||
for (const n of humanList as NodeEntry[]) {
|
||||
const reason = missingSessionGuidance(n) ?? (n.needsAttention !== false || hasCurrentUsageLimit(n)
|
||||
? (n.latestError ? truncate(n.latestError, 40) : n.agentActivity?.reason ?? "—")
|
||||
? (n.latestError ? truncate(n.latestError, 40) : n.startedWithoutRequired?.length ? truncate(startedWithoutLine(n.startedWithoutRequired), 40)
|
||||
: n.agentActivity?.reason ?? "—")
|
||||
: livenessUnconfirmed(n) ? "liveness unconfirmed" : "—");
|
||||
console.log(padCompactNodeRow(
|
||||
n.rigName,
|
||||
@@ -1395,6 +1405,7 @@ async function handleNodes(
|
||||
formatContextUsage(n.contextUsage),
|
||||
n.restoreOutcome,
|
||||
missingSessionGuidance(n) ?? (n.latestError ? truncate(n.latestError, 30) : n.heldReason ? `held: ${truncate(n.heldReason, 25)}`
|
||||
: n.startedWithoutRequired?.length ? truncate(startedWithoutLine(n.startedWithoutRequired), 30)
|
||||
: livenessUnconfirmed(n) ? "liveness unconfirmed" : "—"),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -356,6 +356,17 @@ function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
|
||||
// Priority is matched exactly where it matters (the founder view, attention ranks), so "Critical" would quietly file
|
||||
// the row as ordinary work. The daemon stores what it's given, so a documented value is normalized here. Other values
|
||||
// are kept as given: `high`, `normal` and `background` are in real use, and the mission view ranks some of them.
|
||||
const QUEUE_PRIORITIES = ["routine", "urgent", "critical"];
|
||||
function queuePriorityArg(value: string): string {
|
||||
const lower = value.toLowerCase();
|
||||
if (QUEUE_PRIORITIES.includes(lower)) return lower;
|
||||
console.error(`Note: --priority '${value}' is outside the documented choices (${QUEUE_PRIORITIES.join(", ")}); keeping it as given.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** #2276: the daemon's checks of a new row's human-facing fields (missing summary or evidence for a person,
|
||||
* notification, questions, reply-to). The queue repository raises them before the row's first write, inside the
|
||||
* create's transaction. A remote host's refusal arrives as remote_queue_write_failed instead, so it isn't one of these. */
|
||||
@@ -639,6 +650,18 @@ async function printQueueItemResult(
|
||||
// IMPL-SPEC §2.3-2.4.
|
||||
const SHOW_BODY_PREVIEW_MAX_CODEPOINTS = 512;
|
||||
|
||||
// A misspelled state ("in_progress") would otherwise read as an empty queue, not as a mistake: the list route matches
|
||||
// names as given. Each name is checked and trimmed the way the route trims it. This warns rather than refuses, so the
|
||||
// CLI's copy of the list doesn't stop it working with a newer daemon that knows more states.
|
||||
const QUEUE_STATE_NAMES = ["pending", "in-progress", "done", "blocked", "failed", "denied", "canceled", "handed-off"];
|
||||
function warnIfUnknownStates(value?: string): void {
|
||||
const names = (value ?? "").split(",").map((name) => name.trim()).filter(Boolean);
|
||||
const unknown = names.filter((name) => !QUEUE_STATE_NAMES.includes(name));
|
||||
if (unknown.length === 0) return;
|
||||
const effect = unknown.length === names.length ? " Nothing is filed under these names, so an empty result doesn't mean an empty queue." : "";
|
||||
console.error(`Warning: unrecognized --state '${unknown.join("', '")}'. Supported states: ${QUEUE_STATE_NAMES.join(", ")}.${effect}`);
|
||||
}
|
||||
|
||||
// Commander option parser. InvalidArgumentError (like positiveIntArg/enumArg) is what Commander
|
||||
// renders as its usual "option ... argument ... is invalid" error; a plain Error would escape parse.
|
||||
const WAKE_DURATION_FORMAT = "must be a positive integer with an optional s, m or h suffix (for example 90s, 15m, 168h)";
|
||||
@@ -680,6 +703,12 @@ export function previewBody(
|
||||
};
|
||||
}
|
||||
|
||||
// Mark the cut inside the body so readers that discard metadata or stderr can request the complete brief.
|
||||
// Counts use code points, like the preview.
|
||||
export function previewCutMarker(qitemId: string, shownCodePoints: number, totalCodePoints: number): string {
|
||||
return `…[cut at ${shownCodePoints} of ${totalCodePoints} chars; rig queue show ${shellQuote(qitemId)} --full]`;
|
||||
}
|
||||
|
||||
function isRecordWithStringBody(v: unknown): v is Record<string, unknown> & { body: string } {
|
||||
return (
|
||||
typeof v === "object" &&
|
||||
@@ -911,7 +940,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--mission <id>", "First-class mission scope; translated to a mission:<id> tag (composes with --tags)")
|
||||
.option("--slice <id>", "First-class slice scope; translated to a slice:<id> tag (composes with --tags)")
|
||||
.option("--gate <role>", "OPR.0.4.3.16: mark this as a gate qitem; translated to a gate:<role> tag (role e.g. guard | spec-review | pm-lead | qa | human). The idle-gate watchdog reads this predicate. Composes with --tags.")
|
||||
.option("--priority <priority>", "Priority: routine | urgent | critical", "routine")
|
||||
.option("--priority <priority>", "Priority: routine | urgent | critical", queuePriorityArg, "routine")
|
||||
.option("--tier <tier>", "Tier (e.g. fast, routine, deep, critical) — drives SLA")
|
||||
.option("--tags <tags>", "Comma-separated tags (composes with --mission and --slice)")
|
||||
.option("--expires-at <iso>", "ISO timestamp at which the qitem expires")
|
||||
@@ -1211,6 +1240,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--reopen", "Explicitly acknowledge a deliberate terminal-to-active repair; requires --state and --note")
|
||||
.option("--closure-reason <reason>", "Required for state=done. --state canceled takes no reason, except 'superseded' (with --closure-target = the successor), which records a supersession, distinct from an abandoned cancel")
|
||||
.option("--closure-target <target>", "Required for handed_off_to, escalation, and superseded; blocked_on park records use state=blocked with --blocked-on")
|
||||
.option("--closure-required-at <iso>", "Accept a revised closure deadline on an in-progress item; keeps the claim and records the old/new time and accepting actor")
|
||||
.option("--resolution <text>", "The outcome, stored on the row and shown by rig queue show. Needs --state done, canceled, denied, failed or handed-off, and non-blank text. Stored when the update closes an open row, after anything the row already has (a fallback line, an earlier outcome); a row that is already closed keeps what it has")
|
||||
.option("--blocked-on <blocker>", "For state=blocked: the blocker — a qitem id (must exist and be live), a human seat (FR-6 park; requires summary + evidence_ref), or a typed non-qitem gate 'fold:<what>' / 'auth:<what>' / 'external:<what>'")
|
||||
.option("--wake-watchdog <jobId>", "For state=blocked: attach an existing live watchdog id targeting the row owner")
|
||||
@@ -1226,6 +1256,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
reopen?: boolean;
|
||||
closureReason?: string;
|
||||
closureTarget?: string;
|
||||
closureRequiredAt?: string;
|
||||
resolution?: string;
|
||||
blockedOn?: string;
|
||||
wakeWatchdog?: string;
|
||||
@@ -1262,6 +1293,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
reopen: opts.reopen,
|
||||
closureReason: opts.closureReason,
|
||||
closureTarget: opts.closureTarget,
|
||||
...(opts.closureRequiredAt !== undefined ? { closureRequiredAt: opts.closureRequiredAt } : {}),
|
||||
resolution: opts.resolution,
|
||||
blockedOn: opts.blockedOn,
|
||||
wakeWatchdogId: opts.wakeWatchdog,
|
||||
@@ -1385,7 +1417,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
.option("--body <text>", "New qitem body inline (use - to read from stdin; mutually exclusive with --body-file). Omit both to keep the source body.")
|
||||
.option("--body-file <path>", "Read the new qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class.")
|
||||
.option("--note <text>", "Transition note")
|
||||
.option("--priority <priority>", "Override priority for the new qitem")
|
||||
.option("--priority <priority>", "Override priority for the new qitem: routine | urgent | critical", queuePriorityArg)
|
||||
.option("--tier <tier>", "Override tier for the new qitem")
|
||||
.option("--tags <tags>", "Comma-separated tags for the new qitem")
|
||||
.option("--gate <role>", "OPR.0.4.3.16: mark the new qitem as gate work; translated to a gate:<role> tag (e.g. guard | spec-review). The idle-gate watchdog reads this predicate. Composes with --tags.")
|
||||
@@ -1489,7 +1521,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
.option("--body <text>", "New qitem body inline (use - to read from stdin; mutually exclusive with --body-file). Omit both to keep the source body.")
|
||||
.option("--body-file <path>", "Read the new qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class.")
|
||||
.option("--note <text>", "Transition note")
|
||||
.option("--priority <priority>", "Override priority for the new qitem")
|
||||
.option("--priority <priority>", "Override priority for the new qitem: routine | urgent | critical", queuePriorityArg)
|
||||
.option("--tier <tier>", "Override tier for the new qitem")
|
||||
.option("--tags <tags>", "Comma-separated tags for the new qitem")
|
||||
.option("--gate <role>", "OPR.0.4.3.16: mark the new qitem as gate work; translated to a gate:<role> tag (e.g. guard | spec-review). The idle-gate watchdog reads this predicate. Composes with --tags.")
|
||||
@@ -1652,7 +1684,10 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
...(closingTransition === undefined ? [] : [omittedReadField("closingTransition", closingTransition)]),
|
||||
];
|
||||
const view = readView(item, fullCommand, omitted);
|
||||
const transformed = { ...record, body: preview, bodyBytes, bodyTruncated, readView: view };
|
||||
const shownBody = bodyTruncated
|
||||
? `${preview}${previewCutMarker(qitemId, Array.from(preview).length, Array.from(item.body).length)}`
|
||||
: preview;
|
||||
const transformed = { ...record, body: shownBody, bodyBytes, bodyTruncated, readView: view };
|
||||
// Slice 15 P2: with output.compact on, the empty fields are left out and counted.
|
||||
const compact = compactOutputEnabled() ? compactShow(transformed) : null;
|
||||
printResult(json, compact ? compact.item : transformed, res.status);
|
||||
@@ -1772,6 +1807,7 @@ Examples:
|
||||
}
|
||||
if (opts.destination) params.set("destinationSession", opts.destination);
|
||||
if (opts.source) params.set("sourceSession", opts.source);
|
||||
warnIfUnknownStates(opts.state);
|
||||
if (opts.state) params.set("state", opts.state);
|
||||
if (opts.targetRepo) params.set("targetRepo", opts.targetRepo);
|
||||
if (opts.limit) params.set("limit", String(opts.limit));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -213,7 +213,7 @@ function buildSliceLsCommand(): Command {
|
||||
const cmd = new Command("ls")
|
||||
.description("List slices in a mission (or across all missions)")
|
||||
.option("--mission <name>", "Restrict to a single mission")
|
||||
.option("--state <state>", "Filter: active | closed | shipped | all", "active")
|
||||
.option("--state <state>", "Filter: active (in flight, blocked included) | shipped (delivered in place: shipped or done) | closed (in closed/, or superseded, retired or closed in place) | all", "active")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (opts, command) => {
|
||||
const out = makeStdout();
|
||||
@@ -727,12 +727,13 @@ function buildMissionLsCommand(): Command {
|
||||
id: m.id,
|
||||
path: m.absPath,
|
||||
activeSliceCount: m.activeSliceCount,
|
||||
shippedSliceCount: m.shippedSliceCount,
|
||||
closedSliceCount: m.closedSliceCount,
|
||||
}));
|
||||
emit(out, { ok: true, count: rows.length, missions: rows }, json,
|
||||
rows.length === 0
|
||||
? ["(no missions)"]
|
||||
: rows.map((r) => `${r.name} ${r.id ?? "—"} active=${r.activeSliceCount} closed=${r.closedSliceCount}`),
|
||||
: rows.map((r) => `${r.name} ${r.id ?? "—"} active=${r.activeSliceCount} shipped=${r.shippedSliceCount} closed=${r.closedSliceCount}`),
|
||||
);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
@@ -764,6 +765,7 @@ function buildMissionShowCommand(): Command {
|
||||
id: mission.id,
|
||||
path: mission.absPath,
|
||||
activeSliceCount: mission.activeSliceCount,
|
||||
shippedSliceCount: mission.shippedSliceCount,
|
||||
closedSliceCount: mission.closedSliceCount,
|
||||
frontmatter: mission.frontmatter,
|
||||
trust,
|
||||
@@ -778,6 +780,7 @@ function buildMissionShowCommand(): Command {
|
||||
out.write(` id: ${mission.id ?? "—"}\n`);
|
||||
out.write(formatTrustLine(trust));
|
||||
out.write(` active slices: ${mission.activeSliceCount}\n`);
|
||||
out.write(` shipped slices: ${mission.shippedSliceCount}\n`);
|
||||
out.write(` closed slices: ${mission.closedSliceCount}\n`);
|
||||
out.write(` path: ${mission.absPath}\n`);
|
||||
if (readme) {
|
||||
@@ -1100,6 +1103,7 @@ function buildAuditCommand(): Command {
|
||||
frontmatter: {},
|
||||
id: null,
|
||||
activeSliceCount: 0,
|
||||
shippedSliceCount: 0,
|
||||
closedSliceCount: 0,
|
||||
};
|
||||
const graph = buildMissionDependencyGraph(auditMission);
|
||||
@@ -1232,6 +1236,8 @@ function buildAuditCommand(): Command {
|
||||
for (const sr of sliceResults) {
|
||||
const shadow = shadowedNodeFileFinding(path.join(slicesDir, sr.name), "slice");
|
||||
if (shadow) sr.result.findings.push(shadow);
|
||||
const shared = sharedSliceNameFinding(missionsRoot, missionName, sr.name);
|
||||
if (shared) sr.result.findings.push(shared);
|
||||
}
|
||||
|
||||
const allFindings = [
|
||||
@@ -2152,6 +2158,34 @@ function shadowedNodeFileFinding(dir: string, level: "mission" | "slice"): {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Advisory-only: another mission has a slice folder of the same name. The slice list and the routes that
|
||||
* find a slice by name keep one slice per name, so the others are hidden there with no sign they exist.
|
||||
* Twin of the daemon's finding of the same name.
|
||||
*/
|
||||
function sharedSliceNameFinding(missionsRoot: string, missionName: string, sliceName: string): {
|
||||
kind: "slice_name_shared"; severity: "low"; path: string; message: string; remediation: string;
|
||||
} | null {
|
||||
let others: string[];
|
||||
try {
|
||||
others = fs.readdirSync(missionsRoot, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== missionName
|
||||
&& fs.statSync(path.join(missionsRoot, e.name, "slices", sliceName), { throwIfNoEntry: false })?.isDirectory() === true)
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (others.length === 0) return null;
|
||||
return {
|
||||
kind: "slice_name_shared",
|
||||
severity: "low",
|
||||
path: path.join(missionsRoot, missionName, "slices", sliceName),
|
||||
message: `Mission ${others.join(", ")} also ${others.length === 1 ? "has" : "have"} a slice named "${sliceName}". The slice list (GET /api/slices) and the routes that find a slice by name keep one slice per name, so all but one of these slices are hidden there.`,
|
||||
remediation: "Rename one of the slice folders so each slice name is unique across missions. Advisory only — nothing is blocked.",
|
||||
};
|
||||
}
|
||||
|
||||
export function scopeCommand(): Command {
|
||||
const cmd = new Command("scope")
|
||||
.description("Scope tree primitive: missions, slices, sub-slices (per conventions/scope-and-versioning)")
|
||||
|
||||
@@ -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;
|
||||
@@ -159,19 +163,31 @@ interface SeatHandoverMutationResult {
|
||||
currentStatus: SeatHandoverPlan["currentStatus"];
|
||||
handoverAt: string;
|
||||
eventSeq: number;
|
||||
/** What the successor was told on taking the seat, such as its held messages. */
|
||||
warnings?: string[];
|
||||
/** OPR.0.5.5.5 — per-source execution record (wire mirror of the daemon shape). */
|
||||
sourceOutcome?:
|
||||
| { mode: "fork"; forkedFrom: string; forkId?: "captured" | "pending"; limit?: string }
|
||||
| { mode: "rebuild"; primedArtifacts: Array<{ address: string; label: string }>; gaps: string[]; emptyChainReason?: string; permissions?: { state: string; detail: string } };
|
||||
/** The restore packet was pasted and Enter pressed, but its submission couldn't be confirmed. */
|
||||
restorePacket?: { status: "unconfirmed"; reason: string };
|
||||
/** #2456: the required startup files the successor started without, where a start would have failed. */
|
||||
startedWithoutRequired?: string[];
|
||||
sideEffects: {
|
||||
departingSessionKilled: false;
|
||||
/** Absent from an older daemon, whose receipt reported both side effects as a fixed false. */
|
||||
departingProcess?: "ended-graceful" | "ended-forced" | "not-touched";
|
||||
startupContextDelivered: boolean;
|
||||
provenanceRecordWritten: false;
|
||||
provenanceRecordWritten: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const DEPARTING_PROCESS_LINES = {
|
||||
"ended-graceful": "The previous occupant's process exited on TERM; its tmux session was kept, and the successor runs in the same pane.",
|
||||
"ended-forced": "The previous occupant's process didn't exit on TERM and was killed; its tmux session was kept, and the successor runs in the same pane.",
|
||||
"not-touched": "The previous occupant's process wasn't touched: the successor was an existing session.",
|
||||
} as const;
|
||||
|
||||
interface SeatSwitchClientResponse {
|
||||
seat_ref: string;
|
||||
session: string;
|
||||
@@ -196,6 +212,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}`);
|
||||
@@ -231,6 +254,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}`);
|
||||
@@ -315,7 +356,11 @@ function printHumanHandoverPlan(plan: SeatHandoverPlan): void {
|
||||
}
|
||||
|
||||
function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
|
||||
console.log(`Seat handover complete: ${result.seat.ref}`);
|
||||
// #2456: a successor that started without a required startup file isn't a plain success; a start would have failed.
|
||||
const withoutRequired = result.startedWithoutRequired ?? [];
|
||||
console.log(`Seat handover complete${withoutRequired.length > 0 ? ", degraded" : ""}: ${result.seat.ref}`);
|
||||
for (const file of withoutRequired) console.log(`The successor started without required file ${file}.`);
|
||||
if (withoutRequired.length > 0) console.log("rig ps --nodes shows the seat as needing attention until it's next launched.");
|
||||
console.log(`Rig: ${result.seat.rigName}`);
|
||||
console.log(`Logical ID: ${result.seat.logicalId}`);
|
||||
console.log(`Source: ${result.source.mode}${result.source.ref ? `:${result.source.ref}` : ""}`);
|
||||
@@ -325,6 +370,14 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
|
||||
console.log(`Current occupant: ${result.currentOccupant}`);
|
||||
console.log(`Handover result: ${display(result.currentStatus.handoverResult)}`);
|
||||
console.log("Seat binding and inventory provenance were updated.");
|
||||
for (const warning of result.warnings ?? []) console.warn(`Warning: ${warning}`);
|
||||
// An older daemon reports both side effects as a fixed false: say nothing rather than misreport them.
|
||||
if (result.sideEffects.departingProcess) {
|
||||
console.log(result.sideEffects.provenanceRecordWritten
|
||||
? "The handover's lineage record (occupant tenure) was written."
|
||||
: "The handover's lineage record (occupant tenure) was NOT written; check the daemon log, which names the first such failure since the daemon started. The seat is already bound to the successor, so don't run the handover again.");
|
||||
console.log(DEPARTING_PROCESS_LINES[result.sideEffects.departingProcess]);
|
||||
}
|
||||
// OPR.0.5.5.5 — the per-source execution record: what actually carried context.
|
||||
if (result.sourceOutcome?.mode === "fork") {
|
||||
console.log(`Native fork of ${result.sourceOutcome.forkedFrom}: the successor carries the incumbent conversation from its first byte.`);
|
||||
@@ -349,18 +402,18 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
|
||||
// live successor agent.
|
||||
console.log("The captured startup context (restore packet) was delivered to the successor.");
|
||||
}
|
||||
console.log("No conversation continuity, provenance markdown, or session stop was performed.");
|
||||
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.");
|
||||
console.log("No conversation continuity, provenance markdown, or session stop was performed.");
|
||||
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.");
|
||||
} else {
|
||||
// discovered handover: the operator-prepared successor is already live, so no
|
||||
// separate context delivery is performed.
|
||||
console.log("No conversation continuity, startup context delivery, provenance markdown, or session stop was performed.");
|
||||
console.log("OpenRig delivered no startup context and carried no conversation: the discovered successor was already running.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,6 +498,11 @@ when the current input contains a draft or cannot be read. It returns the exact
|
||||
reason and held message ID immediately, without automatic retry.
|
||||
Off (--enabled false) permits new automatic sends.
|
||||
|
||||
This works from an ordinary terminal as well as from a seat's shell. From a
|
||||
managed seat's shell (OPENRIG_SESSION_NAME set), the change is recorded under
|
||||
that seat. From a terminal with no seat identity, it applies the same way and is
|
||||
recorded as "unattributed (no seat identity)".
|
||||
|
||||
Between off (always interrupt) and hold (full quiet):
|
||||
openrig-urgent OpenRig's own messages (watchdogs, scheduled wakes, notices) and
|
||||
anything marked urgent type; other agents' messages are held.
|
||||
@@ -521,7 +579,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;
|
||||
}
|
||||
@@ -998,6 +1056,8 @@ effort, Codex profile and native permission choice are cleared, not mapped; two
|
||||
processes run until cutover; messages sent to the seat during the switch are
|
||||
held, not typed, and need sending again afterwards.
|
||||
Codex and Claude Code, single-pane seats only. --dry-run lists the details.
|
||||
A seat that has never started has no pane to switch: declare the other runtime for it in its rig spec and bring the team
|
||||
up again from that spec. That's a new generation of the seat, without this seat's edges, settings or queued work.
|
||||
--clear-account-home returns to configured member/rig/daemon/native selection; it does not restore historical credentials.
|
||||
It uses a fresh successor and clears an account-home token reference only after confirmation. Do not combine it with
|
||||
--account-home or a new token reference. Anthropic's global active/default profile is not isolated by CLAUDE_CONFIG_DIR.
|
||||
@@ -1368,7 +1428,9 @@ Examples:
|
||||
.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
|
||||
session is refused (use rig unclaim). 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.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isHumanSeatSessionRef } from "../session-name.js";
|
||||
import { Command } from "commander";
|
||||
import { resolveEffectiveHost } from "../host-selection.js";
|
||||
import { DaemonClient, DaemonConnectionError, terminalAuthHeaders } from "../client.js";
|
||||
@@ -90,6 +91,9 @@ export function wrapSendBody(
|
||||
// renders EXACTLY AS RECEIVED — bare member@rig locally (copy-paste reply hint), and a
|
||||
// cross-host caller constructs the origin triple at the forwarding boundary before wrapping.
|
||||
const senderLabel = sender && sender.trim().length > 0 ? sender : SENDER_FALLBACK;
|
||||
// Only reply classification uses the bare address; From and the destination retain the origin host.
|
||||
const senderParts = senderLabel.split("@");
|
||||
const personAddress = senderParts.length === 3 && senderParts[2] ? senderParts.slice(0, 2).join("@") : senderLabel;
|
||||
const header = [`From: ${senderLabel}`, renderToLine(recipient, meta?.scope)];
|
||||
if (meta?.stampISO) {
|
||||
// GHOST-STAGE (g): the sender's occupant generation rides the Sent: line as a short suffix
|
||||
@@ -102,7 +106,9 @@ export function wrapSendBody(
|
||||
}
|
||||
const reply = senderLabel.endsWith("@external")
|
||||
? `↩ Reply if needed: rig queue create --destination ${senderLabel}${meta?.replyToQitem ? ` --human-intent update --reply-to ${meta.replyToQitem}` : ""} --summary "<one line>" --body "..." --verify`
|
||||
: `↩ Reply: rig send ${senderLabel} "..."`;
|
||||
: 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} "..."`;
|
||||
return [...header, "---", body, "---", reply].join("\n");
|
||||
}
|
||||
|
||||
@@ -236,7 +242,7 @@ export function sendCommand(depsOverride?: SendDeps): Command {
|
||||
.option("--to <sessions>", "Multi-recipient: comma-list or repeated (--to a,b or --to a --to b)", collectSessions, [] as string[])
|
||||
.option("--pod <name>", "Send to every seat in a pod (fan-out, per-recipient results)")
|
||||
.option("--rig <name>", "Send to every seat in a rig (fan-out, per-recipient results)")
|
||||
.option("--verify", "Verify pane only delivery by checking content after send")
|
||||
.option("--verify", "Verify pane only delivery by checking content after send. Exit 1 can mean unconfirmed, not failed: the message may have arrived, so inspect with rig capture before any resend")
|
||||
.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)")
|
||||
@@ -385,10 +391,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;
|
||||
@@ -461,14 +467,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;
|
||||
@@ -565,7 +567,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
|
||||
const envelope = !effect
|
||||
? res.data
|
||||
: effectUnresolved(effect)
|
||||
? { ...res.data, verified: false, outcome: "staged-not-consumed", effectCheck: effect }
|
||||
? { ...res.data, verified: false, outcome: effectSubmissionUnconfirmed(effect) ? "submission-unconfirmed" : "staged-not-consumed", effectCheck: effect }
|
||||
: { ...res.data, effectCheck: effect };
|
||||
console.log(JSON.stringify(envelope));
|
||||
if (res.status >= 400) process.exitCode = res.status >= 500 ? 2 : 1;
|
||||
@@ -632,7 +634,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
|
||||
* recheck, then stops. Pure classification: no printing here.
|
||||
*/
|
||||
type EffectCheck =
|
||||
| { checked: true; state: "staged"; remedy: "submitted-cleared" | "submitted-still-staged" | "submit-refused"; detail?: string }
|
||||
| { checked: true; state: "staged"; remedy: "submitted-cleared" | "submitted-still-staged" | "submit-refused" | "submit-unconfirmed"; detail?: string }
|
||||
| { checked: true; state: "no-staged-residual" }
|
||||
| { checked: false; why: string };
|
||||
|
||||
@@ -683,16 +685,26 @@ async function classifyDeliveryEffect(
|
||||
const probe = await detectStagedAtPrompt(client, session, identity);
|
||||
if (probe.state === "unchecked") return { checked: false, why: probe.why };
|
||||
if (probe.state === "not-staged") return { checked: true, state: "no-staged-residual" };
|
||||
const enter = await client.post<Record<string, unknown>>("/api/transport/send", {
|
||||
session,
|
||||
submitOnly: true,
|
||||
expectedStagedText: identity.expectedStagedText,
|
||||
expectedStagedLineCount: identity.expectedLines,
|
||||
}, transportRequestOptions(waitForIdleMs));
|
||||
let enter: { status: number; data: Record<string, unknown> };
|
||||
try {
|
||||
enter = await client.post<Record<string, unknown>>("/api/transport/send", {
|
||||
session,
|
||||
submitOnly: true,
|
||||
expectedStagedText: identity.expectedStagedText,
|
||||
expectedStagedLineCount: identity.expectedLines,
|
||||
}, transportRequestOptions(waitForIdleMs));
|
||||
} catch (err) {
|
||||
return { checked: true, state: "staged", remedy: "submit-unconfirmed", detail: (err as Error).message };
|
||||
}
|
||||
if (enter.status >= 400) {
|
||||
return { checked: true, state: "staged", remedy: "submit-refused", detail: (enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}` };
|
||||
const phase = enter.data?.["inputPhase"];
|
||||
const reason = enter.data?.["reason"];
|
||||
const unconfirmed = phase === "attempted" || phase === "written"
|
||||
|| (phase !== "not_reached" && enter.data?.["sent"] !== false && (reason === "submit_failed" || enter.status >= 500));
|
||||
return { checked: true, state: "staged", remedy: unconfirmed ? "submit-unconfirmed" : "submit-refused", detail: (enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}` };
|
||||
}
|
||||
const recheck = await detectStagedAtPrompt(client, session, identity);
|
||||
if (recheck.state === "unchecked") return { checked: true, state: "staged", remedy: "submit-unconfirmed", detail: recheck.why };
|
||||
return recheck.state === "staged"
|
||||
? { checked: true, state: "staged", remedy: "submitted-still-staged" }
|
||||
: { checked: true, state: "staged", remedy: "submitted-cleared" };
|
||||
@@ -705,13 +717,17 @@ function effectHumanLines(effect: EffectCheck, session: string): string[] {
|
||||
return [`Note: the pane-effect check could not run (${effect.why}); the verdict above is transport-level only.`];
|
||||
}
|
||||
if (effect.state === "no-staged-residual") return [];
|
||||
if (effect.remedy === "submit-unconfirmed") return [
|
||||
`Delivery: submission unconfirmed — the text was staged before one guarded Enter attempt, but the result could not be confirmed (${effect.detail}). Inspect with: rig capture ${session}`,
|
||||
];
|
||||
if (effect.remedy === "submit-refused") return [
|
||||
`Delivery: unconfirmed — the guarded Enter was refused (${effect.detail}); the earlier staged observation may have changed. Inspect with: rig capture ${session}`,
|
||||
];
|
||||
const out = ["Delivery: staged, not consumed (checked: post-send pane capture; observed: the sent text is still at the prompt — typed, never submitted)"];
|
||||
if (effect.remedy === "submitted-cleared") {
|
||||
out.push("Remedy: one guarded Enter submitted — the staged text left the prompt.");
|
||||
} else if (effect.remedy === "submitted-still-staged") {
|
||||
out.push(`Remedy: one guarded Enter submitted, but the text is STILL at the prompt — not consumed; stopping here (one submit is the contract). Inspect with: rig capture ${session}`);
|
||||
} else {
|
||||
out.push(`Remedy: the single guarded Enter (submit path) was refused (${effect.detail}); stopping here — one submit is the contract. Inspect with: rig capture ${session}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -721,6 +737,10 @@ function effectUnresolved(effect: EffectCheck | undefined): boolean {
|
||||
return !!effect && effect.checked && effect.state === "staged" && effect.remedy !== "submitted-cleared";
|
||||
}
|
||||
|
||||
function effectSubmissionUnconfirmed(effect: EffectCheck | undefined): boolean {
|
||||
return !!effect && effect.checked && effect.state === "staged" && (effect.remedy === "submit-unconfirmed" || effect.remedy === "submit-refused");
|
||||
}
|
||||
|
||||
/**
|
||||
* S3 (OPR.0.5.4.6) — the staged-at-prompt detector, walk's staged-evidence
|
||||
* primitive generalized to plain sends. POSITIVE evidence only: text rendered
|
||||
@@ -754,7 +774,7 @@ async function detectStagedAtPrompt(
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (PROMPT_LINE_PATTERN.test(lines[i]!)) { lastPrompt = i; break; }
|
||||
}
|
||||
if (lastPrompt === -1) return { state: "not-staged" };
|
||||
if (lastPrompt === -1) return { state: "unchecked", why: "no input prompt was visible in the capture" };
|
||||
const inputRegionRaw = lines.slice(lastPrompt).join("\n").replace(PROMPT_LINE_PATTERN, "");
|
||||
// IDENTITY FIRST (round-2 F2, desk-binding): the literal-residual match uses
|
||||
// the daemon submit-precheck's own normalization (strip ALL whitespace,
|
||||
@@ -1083,6 +1103,7 @@ async function runFanOutSend(params: {
|
||||
}
|
||||
const effectBySeat = new Map((effects ?? []).map((e) => [e.sessionName, e.effect]));
|
||||
const unresolvedCount = (effects ?? []).filter((e) => effectUnresolved(e.effect)).length;
|
||||
const unconfirmedCount = (effects ?? []).filter((e) => effectSubmissionUnconfirmed(e.effect)).length;
|
||||
|
||||
if (opts.json) {
|
||||
// Round-2 F1: ONE verdict per recipient — a staged-unresolved recipient's
|
||||
@@ -1090,7 +1111,7 @@ async function runFanOutSend(params: {
|
||||
const rawResults = (res.data["results"] as Array<{ sessionName: string; ok: boolean; outcome?: string }> | undefined) ?? [];
|
||||
const encodedResults = rawResults.map((r) =>
|
||||
effectUnresolved(effectBySeat.get(r.sessionName))
|
||||
? { ...r, ok: false, verified: false, outcome: "staged-not-consumed", error: "staged, not consumed (pane effect); the one guarded submit did not clear it" }
|
||||
? { ...r, ok: false, verified: false, outcome: effectSubmissionUnconfirmed(effectBySeat.get(r.sessionName)) ? "submission-unconfirmed" : "staged-not-consumed", error: effectSubmissionUnconfirmed(effectBySeat.get(r.sessionName)) ? "delivery unconfirmed; inspect the recipient after the guarded submit attempt" : "staged, not consumed (pane effect); the one guarded submit did not clear it" }
|
||||
: r
|
||||
);
|
||||
// Round-3 (r2 row 00fb3a68): the machine-readable AGGREGATE derives from
|
||||
@@ -1133,6 +1154,8 @@ async function runFanOutSend(params: {
|
||||
if (ec && ec.checked && ec.state === "staged") {
|
||||
if (ec.remedy === "submitted-cleared") {
|
||||
console.log(`${r.sessionName}: sent — staged at the prompt, cleared by one guarded Enter (consumed)`);
|
||||
} else if (effectSubmissionUnconfirmed(ec)) {
|
||||
console.log(`${r.sessionName}: ${effectHumanLines(ec, r.sessionName)[0]}`);
|
||||
} else {
|
||||
console.log(`${r.sessionName}: staged, not consumed — ${effectHumanLines(ec, r.sessionName).slice(-1)[0]}`);
|
||||
}
|
||||
@@ -1145,7 +1168,7 @@ async function runFanOutSend(params: {
|
||||
}
|
||||
// The delivered count never includes a staged-unresolved recipient.
|
||||
const deliveredCount = Math.max(0, ((data["sent"] as number) ?? 0) - unresolvedCount);
|
||||
console.log(`${deliveredCount}/${data["total"]} delivered${unresolvedCount > 0 ? `, ${unresolvedCount} staged-not-consumed` : ""}`);
|
||||
console.log(`${deliveredCount}/${data["total"]} delivered${unresolvedCount - unconfirmedCount > 0 ? `, ${unresolvedCount - unconfirmedCount} staged-not-consumed` : ""}${unconfirmedCount > 0 ? `, ${unconfirmedCount} submission-unconfirmed` : ""}`);
|
||||
// S2 (OPR.0.5.4.3): surface additive advisories (e.g. the unknown-sender
|
||||
// sign-it notice) — an env-less operator must see it, not "sent" lines alone.
|
||||
const fanoutAdvisory = data["warning"] as string | undefined;
|
||||
|
||||
@@ -251,7 +251,8 @@ export function slackCommand(deps: SlackDeps = {}): Command {
|
||||
log(`Daemon: ${observation.state}${observation.reason ? ` (${observation.reason})` : ""}`);
|
||||
const connector = observation.connector as { configurationDigest?: string; inboundReady?: boolean; outboundReady?: boolean;
|
||||
inboundRouting?: { fallback: string; destination: string };
|
||||
deadLetterBacklog?: number | null; deadLetterBacklogState?: string; deadLetterBacklogReason?: string;
|
||||
settlement?: { pending: number; lastFailure: string | null };
|
||||
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;
|
||||
@@ -261,6 +262,10 @@ export function slackCommand(deps: SlackDeps = {}): Command {
|
||||
backoff?: Record<string, number>; incomplete?: Record<string, number> };
|
||||
limits?: string[] } } | undefined;
|
||||
if (connector) {
|
||||
if (connector.settlement) {
|
||||
log(` Ask edits: ${connector.settlement.pending} queued for reconciliation`);
|
||||
if (connector.settlement.lastFailure) log(` Last ask-edit failure: ${connector.settlement.lastFailure}`);
|
||||
}
|
||||
if (connector.inboundRouting?.fallback === "local") log(` Configured routing fallback: local to ${connector.inboundRouting.destination} (after forwarding, thread and channel routes)`);
|
||||
log(` Socket: ${connector.inbound?.state ?? "unknown"}; generation ${connector.inbound?.generation ?? "unknown"}; last event ${connector.inbound?.lastEventAt ?? "unknown"}`);
|
||||
for (const line of socketDeliveryLines(connector.inbound)) log(` ${line}`);
|
||||
@@ -286,6 +291,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
|
||||
+ `${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") {
|
||||
|
||||
@@ -157,21 +157,25 @@ export async function resolveLibrarySpec(
|
||||
nameOrId: string,
|
||||
opts?: { kind?: string },
|
||||
): Promise<LibraryEntry> {
|
||||
const res = await client.get<LibraryEntry[]>(
|
||||
opts?.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library",
|
||||
);
|
||||
const rawEntries = res.data ?? [];
|
||||
const entries =
|
||||
const url = opts?.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library";
|
||||
const res = await client.get<LibraryEntry[]>(url);
|
||||
const filterKind = (entries: LibraryEntry[]) =>
|
||||
opts?.kind !== undefined && !KNOWN_SPEC_KINDS.includes(opts.kind as (typeof KNOWN_SPEC_KINDS)[number])
|
||||
? rawEntries.filter((e) => e.kind === opts.kind)
|
||||
: rawEntries;
|
||||
|
||||
// Try exact ID match first
|
||||
? entries.filter((entry) => entry.kind === opts.kind)
|
||||
: entries;
|
||||
const visibleEntries = filterKind(res.data ?? []);
|
||||
// An exact ID always wins, including when a visible name matches a hidden ID.
|
||||
const visibleId = visibleEntries.find((entry) => entry.id === nameOrId);
|
||||
if (visibleId) return visibleId;
|
||||
const separator = opts?.kind !== undefined ? "&" : "?";
|
||||
const all = await client.get<LibraryEntry[]>(`${url}${separator}include_builtins=true`);
|
||||
const entries = filterKind(all.data ?? []);
|
||||
const byId = entries.find((e) => e.id === nameOrId);
|
||||
if (byId) return byId;
|
||||
|
||||
// Try name match
|
||||
const byName = entries.filter((e) => e.name === nameOrId);
|
||||
// Visible names take precedence over hidden built-ins with the same name.
|
||||
const visibleNames = visibleEntries.filter((e) => e.name === nameOrId);
|
||||
const byName = visibleNames.length ? visibleNames : entries.filter((e) => e.name === nameOrId);
|
||||
if (byName.length === 1) return byName[0]!;
|
||||
|
||||
if (byName.length > 1) {
|
||||
@@ -230,12 +234,16 @@ Examples:
|
||||
cmd.command("ls")
|
||||
.description("List library rigs, agents, and managed apps")
|
||||
.option("--kind <kind>", "Filter by kind (rig, agent, or workflow)")
|
||||
.option("--include-builtins", "Include built-ins hidden by the visibility setting")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (opts: { kind?: string; json?: boolean }) => {
|
||||
.action(async (opts: { kind?: string; json?: boolean; includeBuiltins?: boolean }) => {
|
||||
try {
|
||||
warnIfUnknownKind(opts.kind);
|
||||
const client = await getClient();
|
||||
const url = opts.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library";
|
||||
const params = new URLSearchParams();
|
||||
if (opts.kind !== undefined) params.set("kind", opts.kind);
|
||||
if (opts.includeBuiltins) params.set("include_builtins", "true");
|
||||
const url = `/api/specs/library${params.size ? `?${params}` : ""}`;
|
||||
const res = await client.get<LibraryEntry[]>(url);
|
||||
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
|
||||
const rawEntries = res.data ?? [];
|
||||
|
||||
@@ -104,7 +104,7 @@ export function streamCommand(depsOverride?: StreamDeps): Command {
|
||||
.description("Append a stream item")
|
||||
.requiredOption("--source <session>", "Source session (e.g. dev-build@starter)")
|
||||
.requiredOption("--body <text>", "Stream item body")
|
||||
.option("--hint-destination <session>", "Hint at intended destination seat")
|
||||
.option("--hint-destination <session>", "Hint at intended destination seat (the queue worker routes by this hint)")
|
||||
.option("--hint-type <type>", "Hint type (e.g. review, handoff, idea)")
|
||||
.option("--hint-urgency <urgency>", "Hint urgency (routine, urgent, critical)")
|
||||
.option("--hint-tags <tags>", "Comma-separated hint tags")
|
||||
@@ -112,6 +112,11 @@ export function streamCommand(depsOverride?: StreamDeps): Command {
|
||||
.option("--interrupt", "Mark item as interrupting")
|
||||
.option("--id <streamItemId>", "Idempotent stream_item_id (skip if not provided)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Stream intake is on by default: the seat named by stream.intake (queue.worker unless set)
|
||||
gets an intake row for each item, which wakes it, or a note on its intake row still pending.
|
||||
--hint-urgency critical or --interrupt makes that row urgent. The reply names only the stream item.
|
||||
To keep emits passive: rig config set stream.intake off`)
|
||||
.action(async (opts: {
|
||||
source: string;
|
||||
body: string;
|
||||
|
||||
@@ -11,7 +11,7 @@ interface AgentLibraryEntry {
|
||||
/** Resolve the runtime's terminal seat or the shipped general-purpose agent. */
|
||||
export async function resolveDefaultAgentRef(client: DaemonClient, runtime: string): Promise<string> {
|
||||
if (runtime === "terminal") return "builtin:terminal";
|
||||
const res = await client.get<AgentLibraryEntry[]>("/api/specs/library?kind=agent");
|
||||
const res = await client.get<AgentLibraryEntry[]>("/api/specs/library?kind=agent&include_builtins=true");
|
||||
const entry = res.data?.find(
|
||||
(candidate) =>
|
||||
candidate.kind === "agent"
|
||||
|
||||
@@ -273,7 +273,7 @@ initial record is explicitly reported as unverified delivery.`)
|
||||
|
||||
let verdict = await pollConsumed();
|
||||
if (verdict === "generation-rolled") {
|
||||
failPiece(i, piece.label, "the seat's generation rolled mid-walk (a re-prime); the walk cannot continue into a different generation.");
|
||||
failPiece(i, piece.label, "the seat's generation rolled mid-walk (a re-prime); delivery is unconfirmed. Inspect the recipient before any resend; the walk cannot continue into a different generation.");
|
||||
return;
|
||||
}
|
||||
if (verdict === "timeout") {
|
||||
@@ -289,23 +289,39 @@ initial record is explicitly reported as unverified delivery.`)
|
||||
/\[Pasted text #\d+ \+\d+ lines\]/.test(pane)
|
||||
);
|
||||
if (stagedEvidence) {
|
||||
const enter = await client.post<Record<string, unknown>>("/api/transport/send", {
|
||||
session: seat,
|
||||
submitOnly: true,
|
||||
expectedStagedText: piece.content, // FULL bytes — the transport checks the rendered literal residual for contiguous containment
|
||||
expectedStagedLineCount: piece.content.split("\n").length,
|
||||
}, { headers: terminalAuthHeaders() });
|
||||
let enter: { status: number; data: Record<string, unknown> };
|
||||
try {
|
||||
enter = await client.post<Record<string, unknown>>("/api/transport/send", {
|
||||
session: seat,
|
||||
submitOnly: true,
|
||||
expectedStagedText: piece.content, // FULL bytes — the transport checks the rendered literal residual for contiguous containment
|
||||
expectedStagedLineCount: piece.content.split("\n").length,
|
||||
}, { headers: terminalAuthHeaders() });
|
||||
} catch (error) {
|
||||
failPiece(i, piece.label, `the guarded submit result is unconfirmed (${(error as Error).message}); inspect the recipient before any resend.`);
|
||||
return;
|
||||
}
|
||||
if (enter.status >= 400) {
|
||||
failPiece(i, piece.label, `typed but not consumed; the single submit retry was refused (${(enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}`}).`);
|
||||
const phase = enter.data?.["inputPhase"], reason = enter.data?.["reason"];
|
||||
const unconfirmed = phase === "attempted" || phase === "written"
|
||||
|| (phase !== "not_reached" && enter.data?.["sent"] !== false && (reason === "submit_failed" || enter.status >= 500));
|
||||
const detail = (enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}`;
|
||||
failPiece(i, piece.label, unconfirmed
|
||||
? `the guarded submit result is unconfirmed (${detail}); inspect the recipient before any resend.`
|
||||
: `the single guarded submit was refused (${detail}); the earlier staged observation may have changed. Inspect the recipient before any resend.`);
|
||||
return;
|
||||
}
|
||||
verdict = await pollConsumed();
|
||||
if (verdict === "generation-rolled") {
|
||||
failPiece(i, piece.label, "the seat's generation rolled after the guarded submit; delivery is unconfirmed. Inspect the recipient before any resend.");
|
||||
return;
|
||||
}
|
||||
if (verdict !== "consumed") {
|
||||
failPiece(i, piece.label, `typed and staged, but not consumed even after the single submit retry — the piece never entered the seat's conversation record. Not re-sending (one retry is the contract).`);
|
||||
failPiece(i, piece.label, "no matching conversation record was observed within the verification window after the single submit retry; delivery is unconfirmed. Inspect the recipient before any resend.");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
failPiece(i, piece.label, `send reported ${sendOutcome === "ok" ? "success" : "a transport error"} but the piece is neither consumed in the generation record nor staged in the pane — delivery lost; consumption verification fails this walk closed.`);
|
||||
failPiece(i, piece.label, `send reported ${sendOutcome === "ok" ? "success" : "a transport error"}, but no matching record or staged input was observed within the verification window. Delivery and consumption are unconfirmed; inspect the recipient before any resend.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
// truth at ~/.openrig/config.json. Resolution stays env > file > default.
|
||||
|
||||
export interface RiggedConfig {
|
||||
launch: { nonInterruptive: boolean };
|
||||
specs: { builtinsVisible: boolean };
|
||||
launch: { nonInterruptive: boolean; codexLoginShell: boolean };
|
||||
daemon: { port: number; host: string; dbConflict?: "warn" | "stop" };
|
||||
// OPR.0.4.6.MH1 FR-1 — the persisted host-selection pointer.
|
||||
// OPR.0.4.6.MH1 FR-4 — the own-host display name (default "localhost").
|
||||
@@ -229,6 +230,10 @@ export interface RiggedConfig {
|
||||
wakeSwapGraceSeconds: number;
|
||||
wakeHumanRung: string;
|
||||
};
|
||||
// Issue #1856 — stream intake setting
|
||||
stream: {
|
||||
intake: string;
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
|
||||
@@ -262,7 +267,7 @@ const DEFAULT_CLAUDE_COMPACTION_EXTRA_INSTRUCTION_FILE_PATH = getDefaultOpenRigP
|
||||
);
|
||||
|
||||
const DEFAULTS = {
|
||||
launch: { nonInterruptive: false },
|
||||
launch: { nonInterruptive: false, codexLoginShell: false },
|
||||
daemon: { port: 7433, host: "127.0.0.1", dbConflict: "warn" },
|
||||
// OPR.0.4.6.MH1 FR-1 — "local" ≡ no remote selection (LOCAL_HOST_ID).
|
||||
// FR-4 — own-host display name; default "localhost" (PRD-named).
|
||||
@@ -283,6 +288,7 @@ const DEFAULTS = {
|
||||
topology: { root: getDefaultOpenRigPath("topology") },
|
||||
// OPR.0.5.9.5 Wave B — canonical addressable context library.
|
||||
context: { root: getDefaultOpenRigPath("context"), systemWorld: "default" },
|
||||
specs: { builtinsVisible: true },
|
||||
skills: { root: getDefaultOpenRigPath("skills"), routerLine: { enabled: false } },
|
||||
onboarding: { defaultPack: { enabled: true } },
|
||||
health: { contextPressure: { warningPercent: 95, criticalPercent: 99 } },
|
||||
@@ -388,6 +394,10 @@ const DEFAULTS = {
|
||||
consumers: "",
|
||||
templateDir: "",
|
||||
},
|
||||
// Issue #1856 — stream intake default queue.worker; read live by the stream intake coordinator.
|
||||
stream: {
|
||||
intake: "queue.worker",
|
||||
},
|
||||
// OPR.0.4.6.FS-1 W2 — retention defaults (twin of daemon getDefaultValue).
|
||||
retention: {
|
||||
enabled: true,
|
||||
@@ -454,11 +464,13 @@ export const VALID_KEYS = [
|
||||
// OPR.0.5.9.5 Wave B — canonical context library; old key is refused.
|
||||
"context.root",
|
||||
"context.system_world",
|
||||
"specs.builtins_visible",
|
||||
"skills.root",
|
||||
// OPR.0.7.0.13 (router): the person's recorded yes to OpenRig's one-line seat-skills bootstrap in AGENTS.md / CLAUDE.md.
|
||||
"skills.router_line.enabled",
|
||||
"onboarding.default_pack.enabled",
|
||||
"launch.non_interruptive",
|
||||
"launch.codex_login_shell",
|
||||
"health.context_pressure.warning_percent",
|
||||
"health.context_pressure.critical_percent",
|
||||
"files.allowlist",
|
||||
@@ -537,6 +549,7 @@ export const VALID_KEYS = [
|
||||
// Slice 22 outcome 9 — stream retention: unreferenced items older than N days or beyond the newest N.
|
||||
"retention.stream_days",
|
||||
"retention.stream_max_items",
|
||||
"stream.intake",
|
||||
"retention.batch_size",
|
||||
// S04 — pickup-receipt stall threshold; lockstep with the daemon settings-store twin.
|
||||
"queue.pickup_stall_threshold_minutes",
|
||||
@@ -581,10 +594,12 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
"topology.root": { primary: "OPENRIG_TOPOLOGY_ROOT" },
|
||||
"context.root": { primary: "OPENRIG_CONTEXT_ROOT" },
|
||||
"context.system_world": { primary: "OPENRIG_CONTEXT_SYSTEM_WORLD" },
|
||||
"specs.builtins_visible": { primary: "OPENRIG_SPECS_BUILTINS_VISIBLE" },
|
||||
"skills.root": { primary: "OPENRIG_SKILLS_ROOT" },
|
||||
"skills.router_line.enabled": { primary: "OPENRIG_SKILLS_ROUTER_LINE_ENABLED" },
|
||||
"onboarding.default_pack.enabled": { primary: "OPENRIG_ONBOARDING_DEFAULT_PACK_ENABLED" },
|
||||
"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 /
|
||||
@@ -652,6 +667,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
"retention.watchdog_keep_per_job": { primary: "OPENRIG_RETENTION_WATCHDOG_KEEP_PER_JOB" },
|
||||
"retention.stream_days": { primary: "OPENRIG_RETENTION_STREAM_DAYS" },
|
||||
"retention.stream_max_items": { primary: "OPENRIG_RETENTION_STREAM_MAX_ITEMS" },
|
||||
"stream.intake": { primary: "OPENRIG_STREAM_INTAKE" },
|
||||
"retention.batch_size": { primary: "OPENRIG_RETENTION_BATCH_SIZE" },
|
||||
"queue.pickup_stall_threshold_minutes": { primary: "OPENRIG_QUEUE_PICKUP_STALL_THRESHOLD_MINUTES" },
|
||||
"queue.stuck_sweep_interval_seconds": { primary: "OPENRIG_QUEUE_STUCK_SWEEP_INTERVAL_SECONDS" },
|
||||
@@ -688,10 +704,12 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"topology.root": ["topology", "root"],
|
||||
"context.root": ["context", "root"],
|
||||
"context.system_world": ["context", "systemWorld"],
|
||||
"specs.builtins_visible": ["specs", "builtinsVisible"],
|
||||
"skills.root": ["skills", "root"],
|
||||
"skills.router_line.enabled": ["skills", "routerLine", "enabled"],
|
||||
"onboarding.default_pack.enabled": ["onboarding", "defaultPack", "enabled"],
|
||||
"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"],
|
||||
@@ -750,6 +768,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"retention.watchdog_keep_per_job": ["retention", "watchdogKeepPerJob"],
|
||||
"retention.stream_days": ["retention", "streamDays"],
|
||||
"retention.stream_max_items": ["retention", "streamMaxItems"],
|
||||
"stream.intake": ["stream", "intake"],
|
||||
"retention.batch_size": ["retention", "batchSize"],
|
||||
"queue.pickup_stall_threshold_minutes": ["queue", "pickupStallThresholdMinutes"],
|
||||
"queue.stuck_sweep_interval_seconds": ["queue", "stuckSweepIntervalSeconds"],
|
||||
@@ -1113,6 +1132,12 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
|
||||
throw new Error(`Invalid value for retention.stream_max_items: must be an integer >= 1, got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"stream.intake": (raw) => {
|
||||
const val = (raw ?? "").trim();
|
||||
if (val !== "off" && val !== "queue.worker" && !/^[a-zA-Z0-9\-_.]+@[a-zA-Z0-9\-_.]+$/.test(val)) {
|
||||
throw new Error(`Invalid value for stream.intake: must be "queue.worker", a canonical session (<seat>@<rig>), or "off", got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"retention.batch_size": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
|
||||
throw new Error(`Invalid value for retention.batch_size: must be an integer >= 1, got "${raw}"`);
|
||||
@@ -1259,6 +1284,7 @@ export class ConfigStore {
|
||||
root: v("context.root") as string,
|
||||
systemWorld: v("context.system_world") as string,
|
||||
},
|
||||
specs: { builtinsVisible: v("specs.builtins_visible") as boolean },
|
||||
skills: {
|
||||
root: v("skills.root") as string,
|
||||
routerLine: { enabled: v("skills.router_line.enabled") as boolean },
|
||||
@@ -1306,7 +1332,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: {
|
||||
@@ -1385,6 +1411,9 @@ export class ConfigStore {
|
||||
wakeSwapGraceSeconds: v("queue.wake_swap_grace_seconds") as number,
|
||||
wakeHumanRung: v("queue.wake_human_rung") as string,
|
||||
},
|
||||
stream: {
|
||||
intake: v("stream.intake") as string,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1525,7 +1554,7 @@ export class ConfigStore {
|
||||
const critical = getNestedValue(fileConfig, KEY_TO_PATH["health.context_pressure.critical_percent"])
|
||||
?? getDefaultValue("health.context_pressure.critical_percent", workspaceRoot);
|
||||
if ((warning as number) >= (critical as number)) {
|
||||
throw new Error(`Invalid context-pressure policy: warning (${warning}) must be less than critical (${critical})`);
|
||||
throw new Error(`Invalid context-pressure policy for the config-file pair: warning (${warning}) must be less than critical (${critical}). Unset file values use defaults; environment overrides apply when each process resolves configuration and are not part of this check.`);
|
||||
}
|
||||
}
|
||||
mkdirSync(dirname(this.configPath), { recursive: true });
|
||||
@@ -1577,7 +1606,7 @@ export class ConfigStore {
|
||||
const critical = getNestedValue(fileConfig, KEY_TO_PATH["health.context_pressure.critical_percent"])
|
||||
?? getDefaultValue("health.context_pressure.critical_percent", workspaceRoot);
|
||||
if ((warning as number) >= (critical as number)) {
|
||||
throw new Error(`Invalid context-pressure policy: warning (${warning}) must be less than critical (${critical})`);
|
||||
throw new Error(`Invalid context-pressure policy for the config-file pair: warning (${warning}) must be less than critical (${critical}). Unset file values use defaults; environment overrides apply when each process resolves configuration and are not part of this check.`);
|
||||
}
|
||||
}
|
||||
pruneEmptyAncestors(fileConfig, parentParts);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -34,6 +34,7 @@ import type { GatewayCommandDeps } from "./commands/gateway.js";
|
||||
import { parkedCommand } from "./commands/parked.js";
|
||||
import { mcpCommand } from "./commands/mcp.js";
|
||||
import { acpCommand } from "./commands/acp.js";
|
||||
import { execCommand, type ExecDeps } from "./commands/exec.js";
|
||||
import { agentCommand, type AgentDeps } from "./commands/agent.js";
|
||||
import { rigCommand, type RigDeps } from "./commands/rig.js";
|
||||
import { transcriptCommand } from "./commands/transcript.js";
|
||||
@@ -120,6 +121,7 @@ export interface ProgramDeps {
|
||||
psDeps?: StatusDeps;
|
||||
mcpDeps?: StatusDeps;
|
||||
acpDeps?: StatusDeps;
|
||||
execDeps?: ExecDeps;
|
||||
agentDeps?: AgentDeps;
|
||||
rigDeps?: RigDeps;
|
||||
transcriptDeps?: StatusDeps;
|
||||
@@ -206,6 +208,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(rosterCommand());
|
||||
program.addCommand(mcpCommand(depsOverride?.mcpDeps));
|
||||
program.addCommand(acpCommand(depsOverride?.acpDeps));
|
||||
program.addCommand(execCommand(depsOverride?.execDeps));
|
||||
program.addCommand(agentCommand(depsOverride?.agentDeps));
|
||||
program.addCommand(rigCommand(depsOverride?.rigDeps));
|
||||
program.addCommand(transcriptCommand(depsOverride?.transcriptDeps));
|
||||
|
||||
@@ -42,6 +42,7 @@ export const COMMAND_LOADERS: ReadonlyMap<string, Load> = new Map<string, Load>(
|
||||
["roster", async () => (await import("./commands/roster.js")).rosterCommand()],
|
||||
["mcp", async () => (await import("./commands/mcp.js")).mcpCommand()],
|
||||
["acp", async () => (await import("./commands/acp.js")).acpCommand()],
|
||||
["exec", async () => (await import("./commands/exec.js")).execCommand()],
|
||||
["agent", async () => (await import("./commands/agent.js")).agentCommand()],
|
||||
["spec", async () => (await import("./commands/rig.js")).rigCommand()],
|
||||
["transcript", async () => (await import("./commands/transcript.js")).transcriptCommand()],
|
||||
|
||||
@@ -62,6 +62,31 @@ function bodyStartIndex(lines: string[]): number {
|
||||
return 0; // unterminated frontmatter — treat as no frontmatter
|
||||
}
|
||||
|
||||
/** Keep source indices while hiding code examples from the progress grammar. */
|
||||
function progressLines(lines: string[]): string[] {
|
||||
let fence: { marker: string; length: number } | null = null;
|
||||
const start = bodyStartIndex(lines);
|
||||
return lines.map((line, i) => {
|
||||
if (i < start) return "";
|
||||
const match = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
|
||||
if (match) {
|
||||
const marker = match[1]![0]!;
|
||||
const length = match[1]!.length;
|
||||
const tail = match[2]!;
|
||||
if (fence) {
|
||||
if (marker === fence.marker && length >= fence.length && /^[ \t\r]*$/.test(tail)) {
|
||||
fence = null;
|
||||
return line; // A new row may be inserted after the closing fence.
|
||||
}
|
||||
} else if (marker !== "`" || !tail.includes("`")) {
|
||||
fence = { marker, length };
|
||||
}
|
||||
return "";
|
||||
}
|
||||
return fence ? "" : line;
|
||||
});
|
||||
}
|
||||
|
||||
export interface ProgressEditResult {
|
||||
content: string;
|
||||
changed: boolean;
|
||||
@@ -92,32 +117,35 @@ export function addProgressRow(
|
||||
const newRow = `- [${statusIndicator(opts.status)}] ${text}`;
|
||||
const lines = content.split("\n");
|
||||
const start = bodyStartIndex(lines);
|
||||
const visible = progressLines(lines);
|
||||
|
||||
// Locate the target section heading.
|
||||
let sectionIdx = -1;
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const h = lines[i]!.match(HEADING_RE);
|
||||
const h = visible[i]!.match(HEADING_RE);
|
||||
if (h && h[1] === "##" && h[2]!.trim() === section) { sectionIdx = i; break; }
|
||||
}
|
||||
|
||||
if (sectionIdx === -1) {
|
||||
// Create the section, appended after the last existing content.
|
||||
let end = lines.length;
|
||||
while (end > start && lines[end - 1]!.trim() === "") end--;
|
||||
while (end > start && visible[end - 1]!.trim() === "") end--;
|
||||
const head = lines.slice(0, end);
|
||||
const rebuilt = [...head, "", `## ${section}`, "", newRow, ""].join("\n");
|
||||
const suffix = lines.slice(end);
|
||||
const rebuilt = [...head, "", `## ${section}`, "", newRow, "",
|
||||
...(suffix.some(line => line.trim() !== "") ? suffix : [])].join("\n");
|
||||
return { content: rebuilt, changed: true };
|
||||
}
|
||||
|
||||
// Section block = [sectionIdx+1, nextHeadingOrEOF).
|
||||
let blockEnd = lines.length;
|
||||
for (let i = sectionIdx + 1; i < lines.length; i++) {
|
||||
if (HEADING_RE.test(lines[i]!)) { blockEnd = i; break; }
|
||||
if (HEADING_RE.test(visible[i]!)) { blockEnd = i; break; }
|
||||
}
|
||||
|
||||
// Idempotency / conflict scan within the section.
|
||||
for (let i = sectionIdx + 1; i < blockEnd; i++) {
|
||||
const cb = lines[i]!.match(ROW_RE);
|
||||
const cb = visible[i]!.match(ROW_RE);
|
||||
if (cb && cb[3]!.trim() === text) {
|
||||
const current = indicatorToStatus(cb[2]!);
|
||||
if (current === opts.status) return { content, changed: false };
|
||||
@@ -132,7 +160,7 @@ export function addProgressRow(
|
||||
// Insert after the last non-blank line in the section block.
|
||||
let insertAt = sectionIdx + 1;
|
||||
for (let i = sectionIdx + 1; i < blockEnd; i++) {
|
||||
if (lines[i]!.trim() !== "") insertAt = i + 1;
|
||||
if (visible[i]!.trim() !== "") insertAt = i + 1;
|
||||
}
|
||||
lines.splice(insertAt, 0, newRow);
|
||||
return { content: lines.join("\n"), changed: true };
|
||||
@@ -155,10 +183,11 @@ export function setProgressRow(
|
||||
const text = opts.text.trim();
|
||||
const lines = content.split("\n");
|
||||
const start = bodyStartIndex(lines);
|
||||
const visible = progressLines(lines);
|
||||
|
||||
const matches: number[] = [];
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const cb = lines[i]!.match(ROW_RE);
|
||||
const cb = visible[i]!.match(ROW_RE);
|
||||
if (cb && cb[3]!.trim() === text) matches.push(i);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,10 @@ export type FindingKind =
|
||||
// SPEC.md compatibility — a node carrying BOTH authored files. Advisory by
|
||||
// construction (low severity never flips the exit code): SPEC.md wins, and the
|
||||
// shadowed README.md is a state to notice, never a failure to gate on.
|
||||
| "shadowed_node_file";
|
||||
| "shadowed_node_file"
|
||||
// Another mission has a slice folder of the same name, which the slice list and the name-only routes
|
||||
// hide. Advisory (low severity): renaming is the person's call.
|
||||
| "slice_name_shared";
|
||||
|
||||
export interface AuditFinding {
|
||||
kind: FindingKind;
|
||||
|
||||
@@ -362,31 +362,23 @@ export function listMissions(missionsRoot: string): MissionInfo[] {
|
||||
const hasReadme = readmePath !== null;
|
||||
if (!hasReadme) continue;
|
||||
const frontmatter = readFrontmatter(readmePath);
|
||||
const slicesDir = path.join(absPath, "slices");
|
||||
const closedDir = path.join(absPath, "closed");
|
||||
const activeSliceCount = countSliceDirs(slicesDir);
|
||||
const closedSliceCount = countSliceDirs(closedDir);
|
||||
const id = pickIdFromFrontmatter(frontmatter);
|
||||
out.push({
|
||||
name: entry.name,
|
||||
absPath,
|
||||
readmePath: hasReadme ? readmePath : null,
|
||||
frontmatter,
|
||||
id,
|
||||
activeSliceCount,
|
||||
closedSliceCount,
|
||||
});
|
||||
out.push(withSliceCounts({ name: entry.name, absPath, readmePath, frontmatter, id }));
|
||||
}
|
||||
// Stable order: by mission name.
|
||||
out.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return out;
|
||||
}
|
||||
|
||||
function countSliceDirs(dir: string): number {
|
||||
if (!fs.existsSync(dir)) return 0;
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && SLICE_DIRNAME_RE.test(e.name))
|
||||
.length;
|
||||
/** The mission with each of its slices counted once, by its standing (the same one `slice ls` filters on). */
|
||||
function withSliceCounts(base: Omit<MissionInfo, "activeSliceCount" | "shippedSliceCount" | "closedSliceCount">): MissionInfo {
|
||||
const mission: MissionInfo = { ...base, activeSliceCount: 0, shippedSliceCount: 0, closedSliceCount: 0 };
|
||||
for (const { standing } of slicesWithStanding(mission)) {
|
||||
if (standing === "active") mission.activeSliceCount++;
|
||||
else if (standing === "shipped") mission.shippedSliceCount++;
|
||||
else mission.closedSliceCount++;
|
||||
}
|
||||
return mission;
|
||||
}
|
||||
|
||||
function pickIdFromFrontmatter(fm: Record<string, unknown>): string | null {
|
||||
@@ -425,15 +417,7 @@ export function findMission(missionsRoot: string, identifier: string): MissionIn
|
||||
function buildMissionInfo(missionsRoot: string, absPath: string): MissionInfo {
|
||||
const readmePath = resolveNodeFile(absPath);
|
||||
const frontmatter = readmePath ? readFrontmatter(readmePath) : {};
|
||||
return {
|
||||
name: path.basename(absPath),
|
||||
absPath,
|
||||
readmePath,
|
||||
frontmatter,
|
||||
id: pickIdFromFrontmatter(frontmatter),
|
||||
activeSliceCount: countSliceDirs(path.join(absPath, "slices")),
|
||||
closedSliceCount: countSliceDirs(path.join(absPath, "closed")),
|
||||
};
|
||||
return withSliceCounts({ name: path.basename(absPath), absPath, readmePath, frontmatter, id: pickIdFromFrontmatter(frontmatter) });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -444,35 +428,35 @@ export function listSlices(
|
||||
mission: MissionInfo,
|
||||
state: SliceState,
|
||||
): SliceInfo[] {
|
||||
const dirs: Array<{ root: string; bucket: "active" | "closed" }> = [];
|
||||
if (state === "active" || state === "shipped" || state === "all") {
|
||||
dirs.push({ root: path.join(mission.absPath, "slices"), bucket: "active" });
|
||||
}
|
||||
if (state === "closed" || state === "all") {
|
||||
dirs.push({ root: path.join(mission.absPath, "closed"), bucket: "closed" });
|
||||
}
|
||||
const out: SliceInfo[] = [];
|
||||
for (const { root } of dirs) {
|
||||
return slicesWithStanding(mission)
|
||||
.filter(({ standing }) => state === "all" || standing === state)
|
||||
.map(({ slice }) => slice);
|
||||
}
|
||||
|
||||
/** Where a slice stands. In closed/, it's closed. In place: shipped (or done) is delivered; a closed or superseded
|
||||
* status, or a superseded or retired stage, is an exit, so closed; anything else, blocked work included, is active. */
|
||||
export function sliceStanding(slice: SliceInfo, inClosedFolder: boolean): Exclude<SliceState, "all"> {
|
||||
if (inClosedFolder) return "closed";
|
||||
const status = (slice.status ?? "").toLowerCase();
|
||||
const stage = typeof slice.frontmatter.stage === "string" ? slice.frontmatter.stage.toLowerCase() : "";
|
||||
if (status === "done" || status.startsWith("shipped")) return "shipped";
|
||||
if (status.startsWith("closed") || status === "superseded" || stage === "superseded" || stage === "retired") return "closed";
|
||||
return "active";
|
||||
}
|
||||
|
||||
/** Every slice in the mission (slices/ and closed/), in order, each with its standing. */
|
||||
function slicesWithStanding(mission: MissionInfo): Array<{ slice: SliceInfo; standing: Exclude<SliceState, "all"> }> {
|
||||
const out: Array<{ slice: SliceInfo; standing: Exclude<SliceState, "all"> }> = [];
|
||||
for (const [folder, inClosedFolder] of [["slices", false], ["closed", true]] as const) {
|
||||
const root = path.join(mission.absPath, folder);
|
||||
if (!fs.existsSync(root)) continue;
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const m = SLICE_DIRNAME_RE.exec(entry.name);
|
||||
if (!m) continue;
|
||||
const sliceInfo = buildSliceInfo(mission, root, entry.name);
|
||||
out.push(sliceInfo);
|
||||
if (!entry.isDirectory() || !SLICE_DIRNAME_RE.test(entry.name)) continue;
|
||||
const slice = buildSliceInfo(mission, root, entry.name);
|
||||
out.push({ slice, standing: sliceStanding(slice, inClosedFolder) });
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => (a.nn ?? Number.MAX_SAFE_INTEGER) - (b.nn ?? Number.MAX_SAFE_INTEGER) || a.name.localeCompare(b.name));
|
||||
if (state === "active") {
|
||||
// active = not in closed/, and not already delivered in-place.
|
||||
return out.filter((s) => {
|
||||
const st = (s.status ?? "").toLowerCase();
|
||||
return st !== "done" && !st.startsWith("closed") && !st.startsWith("shipped");
|
||||
});
|
||||
}
|
||||
if (state === "shipped") {
|
||||
return out.filter((s) => (s.status ?? "").toLowerCase().startsWith("shipped"));
|
||||
}
|
||||
out.sort((a, b) => (a.slice.nn ?? Number.MAX_SAFE_INTEGER) - (b.slice.nn ?? Number.MAX_SAFE_INTEGER) || a.slice.name.localeCompare(b.slice.name));
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,10 @@ export interface MissionInfo {
|
||||
/** Minted dot-ID for this mission (read from frontmatter if present,
|
||||
* otherwise inferred from the folder name pattern). */
|
||||
id: string | null;
|
||||
/** Slices in flight (blocked included); delivered in place; and closed (closed/, or an exit in place). Each slice is
|
||||
* counted once, by the standing `slice ls --state` filters on. */
|
||||
activeSliceCount: number;
|
||||
shippedSliceCount: number;
|
||||
closedSliceCount: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -336,10 +336,11 @@ function scopeFor(access: McpAccess, seatOf: () => string | undefined): Scope {
|
||||
};
|
||||
}
|
||||
|
||||
/** The grant service names an external caller's seat cus-<id>-caller@<rig>. v1 marks no seat as a caller, so an item
|
||||
* from a seat of this shape is never the team's question to an external caller, and an external caller can't hand a
|
||||
* task to one. The shape never grants anything. */
|
||||
const CALLER_SHAPED = /^cus-.+-caller@[^@]+$/;
|
||||
/** The grant service names each connected assistant's seat cus-<id>-<connection>@<rig> (an existing connection's is
|
||||
* cus-<id>-caller@<rig>). v1 marks no seat as a caller, so for a grant without team/list an item from a seat of this
|
||||
* shape is never the team's question to it, and it can't hand a task to one. The shape only refuses; it never grants
|
||||
* anything, and a grant with team/list doesn't read it. */
|
||||
const CALLER_SHAPED = /^cus-[^@]+-[^@]+@[^@]+$/;
|
||||
|
||||
/** OpenRig's own tags that decide what a person asked about a row sees of its body: what Slack ingestion records about where
|
||||
* the row came from and which parts of its body OpenRig wrote (the sender's channel, the attachments block, the body's
|
||||
@@ -349,11 +350,15 @@ const CALLER_SHAPED = /^cus-.+-caller@[^@]+$/;
|
||||
const OPENRIG_PROVENANCE_TAG = /^(?:founder-slack|inbound|shared-channel|gate)$|^participant:|^slack-/;
|
||||
|
||||
/** An external grant (openrig.grant/v1): a requester, narrower than a work grant. The grant's own seat is the
|
||||
* only caller. It may hand tasks to its rigs' team seats (never a caller-shaped seat, a person or another host's
|
||||
* seat), read the items it sent and the team's questions to it (never team-internal or another caller's items), follow
|
||||
* a handed-on item only where it came back to it, and read the artifacts of its own items under its named roots. */
|
||||
* only caller. It may hand tasks to its rigs' other seats (never a person or another host's seat, and without team/list
|
||||
* never a caller-shaped seat), read the items it sent and those addressed to it from those seats (never team-internal
|
||||
* items or other callers' items between themselves), change only the items addressed to its own seat (with the claim and
|
||||
* update pairs), follow a handed-on item only where it came back to it, and read the artifacts of its own items under
|
||||
* its named roots. */
|
||||
function externalScope(access: McpAccess, seatOf: () => string | undefined): Scope {
|
||||
const rigs = new Set(access.rigs);
|
||||
// team/list makes the caller one of its rigs' participants: it may reach another connected assistant's seat.
|
||||
const participant = access.external?.objects.team?.includes("list") === true;
|
||||
const caller = (value: unknown): boolean => typeof value === "string" && value !== "" && value === seatOf();
|
||||
// A seat of one of its rigs on this instance: member@rig, never a person or another host's seat.
|
||||
const member = (value: unknown): boolean => {
|
||||
@@ -361,17 +366,20 @@ function externalScope(access: McpAccess, seatOf: () => string | undefined): Sco
|
||||
const parts = value.split("@");
|
||||
return parts.length === 2 && parts[0] !== "" && parts[1] !== "external" && rigs.has(parts[1]!);
|
||||
};
|
||||
const team = (value: unknown): boolean => member(value) && !caller(value) && !CALLER_SHAPED.test(value as string);
|
||||
const team = (value: unknown): boolean => member(value) && !caller(value) && (participant || !CALLER_SHAPED.test(value as string));
|
||||
const field = (row: unknown, name: "sourceSession" | "destinationSession"): unknown => ((row ?? {}) as Record<string, unknown>)[name];
|
||||
const item = (row: unknown): boolean => caller(field(row, "sourceSession"))
|
||||
|| (caller(field(row, "destinationSession")) && team(field(row, "sourceSession")));
|
||||
return {
|
||||
session: (value) => caller(value) || team(value),
|
||||
item,
|
||||
owner: () => false,
|
||||
// Only an item addressed to its exact seat, and one it may read, is its own to change; the daemon's claim also checks
|
||||
// the claimant.
|
||||
owner: (row) => caller(field(row, "destinationSession")) && item(row),
|
||||
rig: (name) => typeof name === "string" && rigs.has(name),
|
||||
root: (name) => typeof name === "string" && access.roots.includes(name),
|
||||
// A task goes to the team: never to the caller itself or to another caller-shaped seat, which v1 can't tell apart.
|
||||
// A task goes to another seat of its rigs: never to the caller itself, and without team/list never to another
|
||||
// caller-shaped seat, which v1 can't tell apart.
|
||||
destination: team,
|
||||
successor: caller,
|
||||
artifact: (row) => caller(field(row, "sourceSession")),
|
||||
@@ -480,7 +488,8 @@ export type McpAccess = {
|
||||
|
||||
/** The tool each external object/action pair offers. A pair not listed here offers nothing. */
|
||||
const EXTERNAL_TOOLS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
|
||||
queue: { create: "rig_queue_create", show: "rig_queue_show", list: "rig_queue_list" },
|
||||
queue: { create: "rig_queue_create", show: "rig_queue_show", list: "rig_queue_list", claim: "rig_queue_claim", update: "rig_queue_update" },
|
||||
team: { list: "rig_team" },
|
||||
artifact: { read: "rig_artifact_read" },
|
||||
};
|
||||
|
||||
@@ -564,6 +573,16 @@ const SHARED_INSTRUCTIONS = [
|
||||
];
|
||||
const INSTRUCTIONS = [...SHARED_INSTRUCTIONS, MEMBER_WORKFLOW].join("\n");
|
||||
const EXTERNAL_INSTRUCTIONS = SHARED_INSTRUCTIONS.join("\n");
|
||||
/** For an external grant that names claim and update: how it works the items addressed to it, messages included. */
|
||||
const EXTERNAL_INBOX_INSTRUCTIONS = [...SHARED_INSTRUCTIONS,
|
||||
"Items addressed to your seat are yours: rig_queue_list with owned set lists them. For each, claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update); the sender reads that answer on the same item. A message is an item too: read it, close it done, and reply, when it needs one, with a new item to its sender. Nothing promises to wake you, so check your items again.",
|
||||
].join("\n");
|
||||
|
||||
function instructionsFor(access: McpAccess | undefined): string {
|
||||
if (!access?.external) return INSTRUCTIONS;
|
||||
const tools = externalTools(access.external.objects);
|
||||
return tools.has("rig_queue_claim") && tools.has("rig_queue_update") ? EXTERNAL_INBOX_INSTRUCTIONS : EXTERNAL_INSTRUCTIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a DaemonResponse to MCP tool result.
|
||||
@@ -616,7 +635,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
const server = new McpServer({
|
||||
name: "openrig",
|
||||
version: CLI_VERSION,
|
||||
}, { instructions: options.access?.external ? EXTERNAL_INSTRUCTIONS : INSTRUCTIONS });
|
||||
}, { instructions: instructionsFor(options.access) });
|
||||
const { access } = options;
|
||||
const rostersFolder = options.rostersFolder ?? (() => nodePath.join(new ConfigStore().get("workspace.root") as string, "rosters"));
|
||||
const presenceFile = options.presenceFile ?? (() => mcpPresenceFilePath());
|
||||
@@ -1345,7 +1364,10 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
try {
|
||||
const denied = await itemOutOfScope(qitemId);
|
||||
if (denied) return denied;
|
||||
return mapResult(await client.post(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {}), undefined, { structured: true });
|
||||
const res = await client.post(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {});
|
||||
// An external caller's claimed item goes through the same projection as show and list.
|
||||
const view = externalView(scope);
|
||||
return mapResult(view && res.status < 400 ? { ...res, data: projectForExternal(res.data, view) } : res, undefined, { structured: true });
|
||||
} catch (err) {
|
||||
return caught(err);
|
||||
}
|
||||
@@ -1376,9 +1398,18 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
try {
|
||||
const denied = await itemOutOfScope(qitemId, "owner");
|
||||
if (denied) return denied;
|
||||
return mapResult(await client.post(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
|
||||
// An external caller waits only on an item it names and can read: parking on a person would ask them, parking on
|
||||
// another item would say whether it exists, and a park that names nothing would restore a blocker the team set.
|
||||
if (access?.external && (blockedOn || state === "blocked")) {
|
||||
const blocker = blockedOn?.startsWith("qitem-") ? await client.get(`/api/queue/${encodeURIComponent(blockedOn)}`) : null;
|
||||
if (!blocker || blocker.status >= 400 || !scope?.item(blocker.data)) return outOfScope(`Blocker ${blockedOn ?? "(none named)"}`);
|
||||
}
|
||||
const res = await client.post(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
|
||||
state, closureReason, closureTarget, summary, evidenceRef, blockedOn, wakeAfterSeconds, transitionNote: note, resolution, reopen,
|
||||
}), undefined, { structured: true });
|
||||
});
|
||||
// An external caller's updated item goes through the same projection as show and list.
|
||||
const view = externalView(scope);
|
||||
return mapResult(view && res.status < 400 ? { ...res, data: projectForExternal(res.data, view) } : res, undefined, { structured: true });
|
||||
} catch (err) {
|
||||
return caught(err);
|
||||
}
|
||||
@@ -1491,6 +1522,8 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
},
|
||||
async ({ rigId }) => {
|
||||
try {
|
||||
// An external caller names one of its grant's rigs, so a lookup never answers about another workspace's rig.
|
||||
if (access?.external && !scope?.rig(rigId)) return outOfScope(`Rig ${rigId}`);
|
||||
const rig = await rigIdFor(client, rigId);
|
||||
if ("content" in rig) return rig;
|
||||
const id = encodeURIComponent(rig.id);
|
||||
@@ -1511,7 +1544,9 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
async (seat) => {
|
||||
const rows = await client.get(`/api/queue/list?${new URLSearchParams({ destinationSession: seat, compact: "1", limit: String(PRESENCE_ROWS) }).toString()}`)
|
||||
.then((res) => (res.status < 400 ? res.data : null), () => null);
|
||||
return { lastCallAt: readPresence(presenceFile())[seat]?.lastCallAt ?? null, lastWake: newestWake(rows, seat),
|
||||
// An external caller sees a ring only on an item it may read, so another pair's item isn't named.
|
||||
const visible = access?.external && Array.isArray(rows) ? rows.filter((row) => scope?.item(row)) : rows;
|
||||
return { lastCallAt: readPresence(presenceFile())[seat]?.lastCallAt ?? null, lastWake: newestWake(visible, seat),
|
||||
lastWakeScope: `the latest ring among its ${PRESENCE_ROWS} most recent rows` };
|
||||
});
|
||||
return mapResult({ status: 200, data: { ...view, hostedMembers: hosted.members, ...(hosted.note ? { hostedMembersNote: hosted.note } : {}) } }, undefined, { structured: true });
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface SeatLaunchAdditionsView {
|
||||
agentPromptReplacesDefault?: boolean;
|
||||
appendSystemPromptFile?: string;
|
||||
pluginDirs: string[];
|
||||
/** #2044: absent from a daemon before it, and when the seat has none. */
|
||||
pluginChannels?: string[];
|
||||
missing: string[];
|
||||
refused: string[];
|
||||
}
|
||||
@@ -17,6 +19,7 @@ export function seatLaunchAdditionLines(additions: SeatLaunchAdditionsView | nul
|
||||
...(additions.agent ? [`agent ${additions.agent} (--agent${additions.agentPromptReplacesDefault ? "; its prompt replaces Claude's default system prompt" : ""})`] : []),
|
||||
...(additions.appendSystemPromptFile ? [`prompt ${additions.appendSystemPromptFile} (--append-system-prompt-file)`] : []),
|
||||
...additions.pluginDirs.map((dir) => `plugin dir ${dir} (CLAUDE_CODE_PLUGIN_DIRS)`),
|
||||
...(additions.pluginChannels ?? []).map((entry) => `channel ${entry} (--channels)`),
|
||||
...additions.missing.map((file) => `missing ${file}: not passed to the launch`),
|
||||
...additions.refused.map((reason) => `refused ${reason}: not passed to the launch`),
|
||||
];
|
||||
|
||||
@@ -220,6 +220,40 @@ describe("rig acp run", () => {
|
||||
expect(readFileSync(run.logPath, "utf8")).not.toContain("session/new");
|
||||
});
|
||||
|
||||
it("reads a load error as missing only by the harness's own rule, and never starts a fresh session", async () => {
|
||||
vi.stubEnv("FAKE_ACP_LOAD", "1");
|
||||
vi.stubEnv("FAKE_ACP_MISSING", "claude");
|
||||
const claudeRule = (error: { code: number; data?: unknown }, id: string) => error.code === -32002 && (error.data as { sessionId?: string })?.sessionId === id;
|
||||
const q = memoryQueue("scenario:reply PONG");
|
||||
const run = options({ loadSession: "sess-gone", missingSession: claudeRule });
|
||||
expect(await runAcpAssignment(q.queue, run)).toMatchObject({ kind: "missing" });
|
||||
expect(outcomeNote(q)).toContain('outcome missing: session sess-gone doesn\'t exist for this agent (session/load returned -32002 Resource not found {"sessionId":"sess-gone"}); no new session was started');
|
||||
expect(readFileSync(run.logPath, "utf8")).not.toContain("session/new");
|
||||
|
||||
// The same agent error with no rule, or a rule it doesn't match, reads failed with the agent's own code and text.
|
||||
vi.stubEnv("FAKE_ACP_MISSING", "codex");
|
||||
for (const extra of [{}, { missingSession: claudeRule }]) {
|
||||
const other = memoryQueue("scenario:reply PONG");
|
||||
const outcome = await runAcpAssignment(other.queue, options({ loadSession: "sess-gone", ...extra }));
|
||||
expect(outcome.kind).toBe("failed");
|
||||
expect(outcome.summary).toBe('session/load returned error -32603 Internal error {"details":"no rollout found for thread id sess-gone"}');
|
||||
}
|
||||
});
|
||||
|
||||
it("puts the agent's reported usage and cost on the outcome with where each came from, and unknown when absent", async () => {
|
||||
vi.stubEnv("FAKE_ACP_COST", "1");
|
||||
vi.stubEnv("FAKE_ACP_PROMPT_USAGE", "1");
|
||||
const q = memoryQueue("scenario:reply PONG");
|
||||
const outcome = await runAcpAssignment(q.queue, options());
|
||||
expect(outcome.usage).toEqual({ tokens: { total: 1500, input: 1200, output: 300, thought: undefined, cachedRead: undefined, cachedWrite: undefined }, cost: { amount: 0.00199, currency: "USD" } });
|
||||
expect(outcomeNote(q)).toContain("Usage as the agent reported it: 1500 tokens (1200 in, 300 out), from the agent's prompt response; cost 0.00199 USD, the agent's own cumulative figure for the session (usage_update).");
|
||||
|
||||
vi.unstubAllEnvs();
|
||||
const none = memoryQueue("scenario:reply PONG");
|
||||
expect((await runAcpAssignment(none.queue, options())).usage).toEqual({});
|
||||
expect(outcomeNote(none)).toContain("Usage as the agent reported it: tokens unknown (the agent's prompt response reported none); cost unknown (the agent reported none).");
|
||||
});
|
||||
|
||||
it("reports a process the agent launched that outlives the connection, without stopping it", async () => {
|
||||
const q = memoryQueue("scenario:reply PONG");
|
||||
const run = options();
|
||||
|
||||
@@ -70,7 +70,11 @@ function createMockDaemon() {
|
||||
let body = "";
|
||||
req.on("data", (c: Buffer) => { body += c.toString(); });
|
||||
req.on("end", () => {
|
||||
if (body.includes("INVALID")) {
|
||||
if (body.includes("ADVISED")) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
const invalid = body.includes("ADVISED-INVALID");
|
||||
res.end(JSON.stringify({ valid: !invalid, errors: invalid ? ["name is required"] : [], advisories: ['defaults.effort: non-string value "3" ignored; effort must be a text value'] }));
|
||||
} else if (body.includes("INVALID")) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ valid: false, errors: ["name is required", "missing runtime"] }));
|
||||
} else {
|
||||
@@ -128,6 +132,16 @@ describe("rig agent", () => {
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("agent validate prints each advisory, valid or not, and an advisory never fails it", async () => {
|
||||
for (const [content, exit] of [["name: my-agent\nversion: 1.2.0\n# ADVISED\n", undefined], ["# ADVISED-INVALID\n", 1]] as const) {
|
||||
const program = new Command();
|
||||
program.addCommand(agentCommand(agentDeps(content)));
|
||||
const { logs, exitCode } = await captureLogs(() => program.parseAsync(["node", "rig", "agent", "validate", "agent.yaml"]));
|
||||
expect(logs).toContain('⚠ agent spec advisory: defaults.effort: non-string value "3" ignored; effort must be a text value');
|
||||
expect(exitCode).toBe(exit);
|
||||
}
|
||||
});
|
||||
|
||||
// T2: rig agent validate invalid spec -> exit 1, output contains error
|
||||
it("agent validate invalid spec: prints errors, exitCode 1", async () => {
|
||||
const deps = agentDeps("INVALID");
|
||||
|
||||
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import http from "node:http";
|
||||
import { Command } from "commander";
|
||||
import { writeTextAtomically } from "../src/atomic-text-write.js";
|
||||
import { writeNewText, writeTextAtomically } from "../src/atomic-text-write.js";
|
||||
import { exportCommand } from "../src/commands/export.js";
|
||||
import { STATE_FILE } from "../src/daemon-lifecycle.js";
|
||||
import { allowFetchTarget, resetFetchAllowlist } from "./fetch-guard.js";
|
||||
@@ -35,6 +35,23 @@ it("preserves an existing export and removes staging after a partial failed writ
|
||||
expect(fs.readFileSync(target, "utf8")).toBe("name: original\n");
|
||||
expect(fs.readdirSync(dir)).toEqual(["rig.yaml"]);
|
||||
});
|
||||
it("writeNewText creates a file but never replaces one, even a dangling link", () => {
|
||||
dir = fs.mkdtempSync(join(tmpdir(), "export-atomic-"));
|
||||
const target = join(dir, "rig.yaml"), link = join(dir, "link.yaml");
|
||||
writeNewText(target, "name: first\n");
|
||||
expect(fs.readFileSync(target, "utf8")).toBe("name: first\n");
|
||||
expect(() => writeNewText(target, "name: second\n")).toThrow(expect.objectContaining({ code: "EEXIST" }));
|
||||
expect(fs.readFileSync(target, "utf8")).toBe("name: first\n");
|
||||
fs.symlinkSync(join(dir, "missing.yaml"), link);
|
||||
expect(() => writeNewText(link, "name: second\n")).toThrow(expect.objectContaining({ code: "EEXIST" }));
|
||||
expect(fs.existsSync(join(dir, "missing.yaml"))).toBe(false);
|
||||
});
|
||||
it("writeNewText leaves nothing behind after a failed write", () => {
|
||||
dir = fs.mkdtempSync(join(tmpdir(), "export-atomic-"));
|
||||
failure.active = true;
|
||||
expect(() => writeNewText(join(dir, "rig.yaml"), "name: new\n")).toThrow(/no space left/);
|
||||
expect(fs.readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
it("retains export symlinks and target permissions on successful replacement", () => {
|
||||
dir = fs.mkdtempSync(join(tmpdir(), "export-atomic-"));
|
||||
const target = join(dir, "saved.yaml"), link = join(dir, "rig.yaml");
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("bootstrap source paths respect the daemon target", () => {
|
||||
// The responder reads relative paths from a different root, like an already-running daemon.
|
||||
server = http.createServer(async (req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
if (req.url === "/api/specs/library") {
|
||||
if (req.url === "/api/specs/library" || req.url === "/api/specs/library?include_builtins=true") {
|
||||
catalogReads++;
|
||||
res.end(JSON.stringify(catalog));
|
||||
return;
|
||||
@@ -184,7 +184,7 @@ describe("bootstrap source paths respect the daemon target", () => {
|
||||
("preserves catalog source paths (explicit URL=%s, plan=%s)", async (explicitUrl, plan) => {
|
||||
if (explicitUrl) vi.stubEnv("OPENRIG_URL", daemonUrl);
|
||||
await run("bootstrap", "catalog-rig", [plan ? "--plan" : "--yes"]);
|
||||
expect(catalogReads).toBe(1);
|
||||
expect(catalogReads).toBe(2);
|
||||
expect(requests[0]?.sourceRef).toBe(catalog[0]!.sourcePath);
|
||||
expect(requests[0]?.content).toBe("name: daemon\n");
|
||||
});
|
||||
@@ -192,7 +192,7 @@ describe("bootstrap source paths respect the daemon target", () => {
|
||||
it.each([false, true])("preserves unresolved bare-name fallback (explicit URL=%s)", async (explicitUrl) => {
|
||||
if (explicitUrl) vi.stubEnv("OPENRIG_URL", daemonUrl);
|
||||
await run("bootstrap", "unlisted", ["--plan"]);
|
||||
expect(catalogReads).toBe(1);
|
||||
expect(catalogReads).toBe(2);
|
||||
expect(requests[0]?.sourceRef).toBe("unlisted");
|
||||
});
|
||||
|
||||
@@ -208,7 +208,7 @@ describe("bootstrap source paths respect the daemon target", () => {
|
||||
if (explicitUrl) vi.stubEnv("OPENRIG_URL", daemonUrl);
|
||||
catalog.push({ ...catalog[0]!, id: "other-id", sourcePath: path.join(callerDir, "rig.yaml") });
|
||||
await run("bootstrap", "catalog-rig", ["--plan"]);
|
||||
expect(catalogReads).toBe(1);
|
||||
expect(catalogReads).toBe(2);
|
||||
expect(requests).toEqual([]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
@@ -400,7 +400,7 @@ describe("Bootstrap CLI", () => {
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer) => { body += chunk; });
|
||||
req.on("end", () => {
|
||||
if (url === "/api/specs/library") {
|
||||
if (url === "/api/specs/library" || url === "/api/specs/library?include_builtins=true") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify([{ id: "lib1", name: "my-lib-rig", sourcePath: "/specs/my-lib-rig.yaml", kind: "rig", version: "0.2", sourceType: "user_file" }]));
|
||||
} else if (url.includes("/api/bootstrap")) {
|
||||
|
||||
@@ -70,6 +70,19 @@ describe("Broadcast CLI", () => {
|
||||
{ ok: false, sessionName: "c@notice-rig", error: "send failed", managedCompaction: { stage: "compacting", observed: "before_send", notice: "c@notice-rig was being compacted: /compact was submitted." } },
|
||||
],
|
||||
}));
|
||||
} else if (parsed.rig === "phase-rig") {
|
||||
// #2507: a recipient whose input was attempted or written is recorded indeterminate, not failed.
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
total: 5, sent: 1, failed: 4,
|
||||
results: [
|
||||
{ ok: true, sessionName: "a@phase-rig", inputPhase: "written", sent: true },
|
||||
{ ok: false, sessionName: "b@phase-rig", inputPhase: "not_reached", sent: false, error: "target_needs_input" },
|
||||
{ ok: false, sessionName: "c@phase-rig", inputPhase: "attempted", sent: false, error: "paste lost" },
|
||||
{ ok: false, sessionName: "d@phase-rig", inputPhase: "written", sent: false, error: "enter unconfirmed" },
|
||||
{ ok: false, sessionName: "e@phase-rig", sent: true, error: "receipt lost" },
|
||||
],
|
||||
}));
|
||||
} else if (parsed.rig === "warn-rig") {
|
||||
// S2 (OPR.0.5.4.3): an unattributed broadcast's response carries the
|
||||
// sign-it notice as an additive warning; the renderer must surface it.
|
||||
@@ -156,6 +169,22 @@ describe("Broadcast CLI", () => {
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("broadcast prints UNCONFIRMED, not FAILED, for a recipient whose input was attempted or written; exit code unchanged", async () => {
|
||||
const { stdout, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "broadcast", "--rig", "phase-rig", "hello"]);
|
||||
});
|
||||
const check = "the text may have reached it, so check with rig capture before resending";
|
||||
expect(stdout).toEqual([
|
||||
"a@phase-rig: sent",
|
||||
"b@phase-rig: FAILED — target_needs_input",
|
||||
`c@phase-rig: UNCONFIRMED — paste lost; ${check}`,
|
||||
`d@phase-rig: UNCONFIRMED — enter unconfirmed; ${check}`,
|
||||
`e@phase-rig: UNCONFIRMED — receipt lost; ${check}`,
|
||||
"1/5 delivered",
|
||||
]);
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("broadcast --rig prints per-target summary", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "broadcast", "--rig", "my-rig", "hello"]);
|
||||
@@ -326,6 +355,7 @@ describe("Broadcast CLI", () => {
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("No running sessions found");
|
||||
expect(output).toContain("0/0 delivered");
|
||||
expect(logs).toContainEqual(expect.stringMatching(/^FAILED — No running sessions found/));
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { it, expect, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ConfigStore } from "../src/config-store.js";
|
||||
import { SettingsStore } from "../../daemon/src/domain/user-settings/settings-store.js";
|
||||
it("launch.codex_login_shell defaults off in both stores, with file and env precedence", () => {
|
||||
const home=mkdtempSync(join(tmpdir(),"codex-login-shell-")), file=join(home,"config.json");
|
||||
const cli=new ConfigStore(file), daemon=new SettingsStore(file);
|
||||
try {
|
||||
vi.stubEnv("OPENRIG_LAUNCH_CODEX_LOGIN_SHELL",undefined);
|
||||
expect(cli.resolveWithSource("launch.codex_login_shell")).toMatchObject({value:false,source:"default"});
|
||||
expect(daemon.resolveOne("launch.codex_login_shell")).toMatchObject({value:false,source:"default"});
|
||||
writeFileSync(file, JSON.stringify({launch:{codexLoginShell:true}}));
|
||||
expect(cli.resolveWithSource("launch.codex_login_shell")).toMatchObject({value:true,source:"file"});
|
||||
expect(daemon.resolveOne("launch.codex_login_shell")).toMatchObject({value:true,source:"file"});
|
||||
vi.stubEnv("OPENRIG_LAUNCH_CODEX_LOGIN_SHELL","false");
|
||||
expect(cli.resolveWithSource("launch.codex_login_shell")).toMatchObject({value:false,source:"env"});
|
||||
expect(daemon.resolveOne("launch.codex_login_shell")).toMatchObject({value:false,source:"env"});
|
||||
} finally { vi.unstubAllEnvs(); rmSync(home,{recursive:true,force:true}); }
|
||||
});
|
||||
@@ -138,10 +138,12 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
"topology.root",
|
||||
"context.root",
|
||||
"context.system_world",
|
||||
"specs.builtins_visible",
|
||||
"skills.root",
|
||||
"skills.router_line.enabled",
|
||||
"onboarding.default_pack.enabled",
|
||||
"launch.non_interruptive",
|
||||
"launch.codex_login_shell",
|
||||
"health.context_pressure.warning_percent",
|
||||
"health.context_pressure.critical_percent",
|
||||
"files.allowlist", "progress.scan_roots",
|
||||
@@ -211,6 +213,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
"retention.watchdog_keep_per_job",
|
||||
"retention.stream_days", // slice 22 outcome 9: stream retention
|
||||
"retention.stream_max_items",
|
||||
"stream.intake",
|
||||
"retention.batch_size",
|
||||
"queue.pickup_stall_threshold_minutes",
|
||||
// S02 — standing-stuck-sweep cadence + unclaimed-obligation age.
|
||||
@@ -234,6 +237,16 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
expect(() => store.set("daemon.db_conflict", "always")).toThrow(/warn or stop/);
|
||||
});
|
||||
|
||||
it("defaults stream.intake to queue.worker and validates destinations", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(store.get("stream.intake")).toBe("queue.worker");
|
||||
store.set("stream.intake", "off");
|
||||
expect(store.get("stream.intake")).toBe("off");
|
||||
store.set("stream.intake", "lead@kernel");
|
||||
expect(store.get("stream.intake")).toBe("lead@kernel");
|
||||
expect(() => store.set("stream.intake", "invalid-bare-seat")).toThrow(/Invalid value for stream.intake/);
|
||||
});
|
||||
|
||||
it("resolves context.root from the canonical default and OPENRIG_CONTEXT_ROOT", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(store.resolveWithSource("context.root")).toMatchObject({
|
||||
@@ -376,6 +389,37 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
expect(store.resolve().health.contextPressure).toEqual({ warningPercent: 95, criticalPercent: 99 });
|
||||
});
|
||||
|
||||
it("explains that saved context-pressure pair validation excludes environment overrides", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
process.env.OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT = "50";
|
||||
expect(() => store.set("health.context_pressure.critical_percent", "80"))
|
||||
.toThrow(/config-file pair: warning \(95\).*critical \(80\).*each process resolves configuration/);
|
||||
expect(existsSync(configPath)).toBe(false);
|
||||
store.set("health.context_pressure.critical_percent", "100");
|
||||
store.set("health.context_pressure.warning_percent", "99");
|
||||
const before = readFileSync(configPath, "utf8");
|
||||
expect(() => store.reset("health.context_pressure.critical_percent"))
|
||||
.toThrow(/config-file pair: warning \(99\).*critical \(99\).*each process resolves configuration/);
|
||||
expect(readFileSync(configPath, "utf8")).toBe(before);
|
||||
});
|
||||
|
||||
it("shows the file-pair explanation through the config command", async () => {
|
||||
process.env.OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT = "50";
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const oldExitCode = process.exitCode;
|
||||
try {
|
||||
process.exitCode = undefined;
|
||||
await configCommand(configPath, { probeDaemonRunning: async () => false })
|
||||
.parseAsync(["node", "rig", "set", "health.context_pressure.critical_percent", "80"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(error.mock.calls.flat().join(" ")).toContain("config-file pair: warning (95)");
|
||||
expect(existsSync(configPath)).toBe(false);
|
||||
} finally {
|
||||
error.mockRestore();
|
||||
process.exitCode = oldExitCode;
|
||||
}
|
||||
});
|
||||
|
||||
it("S01 wake ladder exposes retry cadence, cap, unconfirmed window, and swap grace defaults", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(store.resolveWithSource("queue.wake_retry_interval_seconds" as never)).toMatchObject({
|
||||
|
||||
@@ -63,6 +63,14 @@ describe("ConfigStore", () => {
|
||||
});
|
||||
|
||||
// Test 1
|
||||
it("persists the built-in inventory preference as a validated boolean", () => {
|
||||
const store = new ConfigStore(join(tmpDir, "config.json"));
|
||||
expect(store.resolveOne("specs.builtins_visible").value).toBe(true);
|
||||
store.set("specs.builtins_visible", "false");
|
||||
expect(store.resolve().specs.builtinsVisible).toBe(false);
|
||||
expect(() => store.set("specs.builtins_visible", "no")).toThrow();
|
||||
});
|
||||
|
||||
it("resolve() returns defaults when no config file and no env", () => {
|
||||
const store = new ConfigStore(join(tmpDir, "config.json"));
|
||||
const config = store.resolve();
|
||||
|
||||
@@ -529,6 +529,21 @@ files:
|
||||
|
||||
const R4_MANIFEST = "name: url-pack\nversion: 1.0.0\ntaxonomy: world\nfiles:\n - path: SKILL.md\n role: instruction\n";
|
||||
|
||||
it("add <url>: creates a missing namespace for a path-like install name", async () => {
|
||||
const pack = await startPackServer({ "manifest.yaml": R4_MANIFEST, "SKILL.md": "# nested\n" });
|
||||
try {
|
||||
await withContextRoot(async (root) => {
|
||||
const { exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "context", "add", `${pack.baseUrl}manifest.yaml`, "--name", "packs/example"]);
|
||||
});
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(readFileSync(join(root, "packs", "example", "SKILL.md"), "utf-8")).toBe("# nested\n");
|
||||
});
|
||||
} finally {
|
||||
pack.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("add <url>: installs into the OPENRIG_CONTEXT_ROOT landing zone (config-resolved, no hardcoded path)", async () => {
|
||||
const pack = await startPackServer({ "manifest.yaml": R4_MANIFEST, "SKILL.md": "# hello\nbody\n" });
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
describe.skipIf(process.platform === "win32")("doctor with a listener holding response headers", () => {
|
||||
it("returns an unknown listener result through the built CLI", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "doctor-held-headers-"));
|
||||
const requests: string[] = [];
|
||||
const server = createServer(req => { requests.push(req.url ?? ""); });
|
||||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
const port = (server.address() as { port: number }).port;
|
||||
const cli = fileURLToPath(new URL("../dist/bin-wrapper.js", import.meta.url));
|
||||
const output = await new Promise<string>((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [cli, "doctor", "--json"], {
|
||||
env: { HOME: root, OPENRIG_HOME: path.join(root, "openrig"), OPENRIG_PORT: String(port), PATH: "/usr/bin:/bin", TMPDIR: root },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", chunk => { stdout += chunk; });
|
||||
child.stderr.resume();
|
||||
const timer = setTimeout(() => { child.kill(); reject(new Error("doctor did not finish")); }, 30_000);
|
||||
child.on("error", error => { clearTimeout(timer); reject(error); });
|
||||
child.on("close", () => { clearTimeout(timer); resolve(stdout); });
|
||||
});
|
||||
const result = JSON.parse(output) as { checks: Array<{ name: string; status: string; message: string }> };
|
||||
const check = result.checks.find(item => item.name === "port");
|
||||
expect(requests).toContain("/healthz");
|
||||
expect(check?.status).toBe("warn");
|
||||
expect(check?.message).toContain("unknown listener");
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 40_000);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
describe.skipIf(process.platform === "win32")("doctor --spec with duplicate live names", () => {
|
||||
it("reports both IDs without fetching either topology through the built CLI", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "doctor-ambiguous-cli-"));
|
||||
const spec = path.join(root, "rig.yaml");
|
||||
writeFileSync(spec, "version: '0.2'\nname: twin\npods:\n - namespace: orch\n members:\n - name: lead\n");
|
||||
const requests: string[] = [];
|
||||
const server = createServer((req, res) => {
|
||||
requests.push(req.url ?? "");
|
||||
res.setHeader("content-type", "application/json");
|
||||
if (req.url === "/api/rigs") res.end(JSON.stringify([{ id: "first", name: "twin" }, { id: "second", name: "twin" }]));
|
||||
else if (req.url === "/healthz") res.end(JSON.stringify({ ok: true, pid: process.pid, euid: process.geteuid?.() }));
|
||||
else { res.statusCode = 404; res.end("{}"); }
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
const port = (server.address() as { port: number }).port;
|
||||
const cli = fileURLToPath(new URL("../dist/bin-wrapper.js", import.meta.url));
|
||||
const output = await new Promise<string>((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [cli, "doctor", "--spec", spec, "--json"], {
|
||||
env: { HOME: root, OPENRIG_HOME: path.join(root, "openrig"), OPENRIG_PORT: String(port), PATH: "/usr/bin:/bin", TMPDIR: root },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", chunk => { stdout += chunk; });
|
||||
child.stderr.resume();
|
||||
const timer = setTimeout(() => { child.kill(); reject(new Error("doctor did not finish")); }, 30_000);
|
||||
child.on("error", error => { clearTimeout(timer); reject(error); });
|
||||
child.on("close", () => { clearTimeout(timer); resolve(stdout); });
|
||||
});
|
||||
const result = JSON.parse(output) as { checks: Array<{ name: string; status: string; message: string }> };
|
||||
const check = result.checks.find(item => item.name === "spec_live_conformance");
|
||||
expect(check?.status).toBe("skipped");
|
||||
expect(check?.message).toContain("first, second");
|
||||
expect(requests.filter(url => url === "/api/rigs")).toHaveLength(1);
|
||||
expect(requests.some(url => url.includes("/nodes"))).toBe(false);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 40_000);
|
||||
});
|
||||
@@ -7,8 +7,8 @@
|
||||
// is not given it must SKIP WITH THE REASON rather than pass — a check that cannot find its input
|
||||
// and stays quiet is indistinguishable from one that looked and found nothing wrong.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { runDoctorChecks, type DoctorDeps } from "../src/commands/doctor.js";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { readLiveLogicalIds, runDoctorChecks, type DoctorDeps } from "../src/commands/doctor.js";
|
||||
|
||||
const SPEC_3_8 = `version: "0.2"
|
||||
name: v-openrig-build
|
||||
@@ -52,6 +52,23 @@ async function conformanceCheck(deps: DoctorDeps) {
|
||||
}
|
||||
|
||||
describe("rig doctor — spec vs live conformance", () => {
|
||||
it("does not choose a topology when multiple rigs share the spec name", async () => {
|
||||
const fetch = vi.fn(async (_url: string) => ({ ok: true, json: async () => [
|
||||
{ id: "first", name: "v-openrig-build" }, { id: "second", name: "v-openrig-build" },
|
||||
] }));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
try {
|
||||
const owner = { assertLocalOwner: async () => {} };
|
||||
const check = await conformanceCheck(baseDeps({ specPath: "/spec", readFile: () => SPEC_3_8,
|
||||
fetchLiveLogicalIds: name => readLiveLogicalIds("http://fixture", name, owner as never) }));
|
||||
expect(check!.status).toBe("skipped");
|
||||
expect(check!.message).toContain("first, second");
|
||||
expect(check!.fix).toContain("rig ps --json");
|
||||
const urls = fetch.mock.calls.map(([url]) => url);
|
||||
expect(urls.filter(url => url.endsWith("/api/rigs"))).toHaveLength(1);
|
||||
expect(urls.some(url => url.includes("/nodes"))).toBe(false);
|
||||
} finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
it("SKIPS with the reason when no --spec is given — it must not read as a pass", async () => {
|
||||
const check = await conformanceCheck(baseDeps());
|
||||
expect(check).toBeDefined();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createProgram } from "../src/index.js";
|
||||
import { Command } from "commander";
|
||||
import { doctorCommand, runDoctorChecks, type DoctorDeps } from "../src/commands/doctor.js";
|
||||
import { doctorCommand, runDoctorChecks, readLiveLogicalIds, type DoctorDeps } from "../src/commands/doctor.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { resolveCmuxSettingsPath } from "../src/cmux-config.js";
|
||||
|
||||
const CMUX_SETTINGS_PATH = resolveCmuxSettingsPath();
|
||||
@@ -565,11 +566,11 @@ describe("runDoctorChecks", () => {
|
||||
|
||||
// A listener that sends an OK header and never finishes its body must not hold doctor, or setup, before anything prints.
|
||||
it("settles a /healthz body that never finishes as owner unknown within its bound, and releases the request", async () => {
|
||||
// The port check's request carries a signal; doctor's other /healthz reads (the cmux check) don't.
|
||||
const signals: AbortSignal[] = [];
|
||||
const deps = makeDeps({ checkPort: async () => false, callerUid: 1002, ownerReportTimeoutMs: 50,
|
||||
fetch: async (_url: string, init?: { signal?: AbortSignal }) => {
|
||||
if (init?.signal) signals.push(init.signal);
|
||||
fetch: async (url: string, init?: { signal?: AbortSignal }) => {
|
||||
// This owner-body cell measures the two health probes; status has its own deadline cells below.
|
||||
if (init?.signal && url.endsWith("/healthz")) signals.push(init.signal);
|
||||
return { ok: true, json: () => new Promise<unknown>(() => {}) };
|
||||
} });
|
||||
const started = Date.now();
|
||||
@@ -578,8 +579,8 @@ describe("runDoctorChecks", () => {
|
||||
expect(result.status).toBe("warn");
|
||||
expect(result.message).toContain("OS user is unknown");
|
||||
expect(result.reason).toContain("didn't finish");
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0]!.aborted).toBe(true);
|
||||
expect(signals).toHaveLength(2);
|
||||
expect(signals.every(signal => signal.aborted)).toBe(true);
|
||||
});
|
||||
|
||||
it("settles a real listener's held /healthz body the same way, through the real fetch", async () => {
|
||||
@@ -599,6 +600,28 @@ describe("runDoctorChecks", () => {
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds a real listener that never sends health response headers", async () => {
|
||||
const { createServer } = await import("node:http");
|
||||
const server = createServer(() => {});
|
||||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||
const port = (server.address() as { port: number }).port;
|
||||
try {
|
||||
const deps = makeDeps({ checkPort: async () => false, ownerReportTimeoutMs: 100,
|
||||
configStore: { resolve: () => ({ ...defaultConfig, daemon: { port, host: "127.0.0.1" } }) } as DoctorDeps["configStore"] });
|
||||
delete deps.fetch;
|
||||
const checks = runDoctorChecks(deps);
|
||||
const result = await checks.portCheck;
|
||||
expect(result.status).toBe("warn");
|
||||
expect(result.message).toContain("unknown listener");
|
||||
expect(result.reason).toContain("headers did not arrive");
|
||||
const all = await Promise.all(checks.asyncChecks);
|
||||
expect(all.find(check => check.name === "cmux_daemon")?.status).toBe("skipped");
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig doctor", () => {
|
||||
@@ -775,3 +798,117 @@ describe("rig doctor", () => {
|
||||
expect(portArgs).toContain(7433);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("doctor JSON HTTP deadlines", () => {
|
||||
it.each(["cmux headers", "cmux body", "healthy"] as const)("returns an honest diagnostic when a real listener holds %s", async (held) => {
|
||||
const { createServer } = await import("node:http");
|
||||
const requests: string[] = [];
|
||||
const server = createServer((req, res) => {
|
||||
requests.push(req.url!);
|
||||
if (req.url === "/healthz") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, pid: 4242, euid: 1002 }));
|
||||
} else if (held === "healthy") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ available: true }));
|
||||
} else if (held === "cmux body") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.write('{"available":');
|
||||
}
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||
const port = (server.address() as { port: number }).port;
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const priorExitCode = process.exitCode;
|
||||
const program = new Command();
|
||||
program.addCommand(doctorCommand(makeDeps({ checkPort: async () => false, callerUid: 1002,
|
||||
ownerReportTimeoutMs: 100, fetch: (url, init) => globalThis.fetch(url, init),
|
||||
configStore: { resolve: () => ({ ...defaultConfig, daemon: { host: "127.0.0.1", port } }) },
|
||||
})));
|
||||
const task = program.parseAsync(["node", "rig", "doctor", "--json"]);
|
||||
let observationTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const outcome = await Promise.race([task.then(() => "settled"), new Promise<string>(resolve => {
|
||||
observationTimer = setTimeout(() => resolve("still-waiting"), 1500);
|
||||
})]);
|
||||
expect(["darwin", "linux"]).toContain(process.platform);
|
||||
expect(requests).toContain("/healthz");
|
||||
expect(requests).toContain("/api/adapters/cmux/status");
|
||||
console.info("[doctor HTTP deadline]", JSON.stringify({ platform: process.platform, held, requests, outcome }));
|
||||
expect(outcome).toBe("settled");
|
||||
const result = JSON.parse(String(log.mock.calls.at(-1)![0]));
|
||||
const portCheck = result.checks.find((c: { name: string }) => c.name === "port");
|
||||
const cmux = result.checks.find((c: { name: string }) => c.name === "cmux_daemon");
|
||||
expect(portCheck.status).toBe("pass");
|
||||
expect(cmux.status).toBe(held === "healthy" ? "pass" : "warn");
|
||||
} finally {
|
||||
clearTimeout(observationTimer);
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
await Promise.allSettled([task]);
|
||||
log.mockRestore(); process.exitCode = priorExitCode;
|
||||
expect(server.listening).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("doctor --spec topology HTTP deadlines", () => {
|
||||
it.each(["rig headers", "rig body", "nodes headers", "nodes body", "healthy"] as const)(
|
||||
"returns unknown or measured conformance when the topology listener is %s", async held => {
|
||||
const { createServer } = await import("node:http");
|
||||
const requests: string[] = [];
|
||||
const heldPath = held.startsWith("rig") ? "/api/rigs" : "/api/rigs/rig-id/nodes";
|
||||
const server = createServer((req, res) => {
|
||||
requests.push(req.url!);
|
||||
if (held !== "healthy" && req.url === heldPath) {
|
||||
if (held.endsWith("body")) { res.writeHead(200, { "content-type": "application/json" }); res.write("["); }
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(req.url === "/healthz" ? { ok: true, pid: 4242, euid: 1002 }
|
||||
: req.url === "/api/rigs" ? [{ id: "rig-id", name: "fixture" }]
|
||||
: req.url === "/api/rigs/rig-id/nodes" ? [{ logicalId: "dev.worker" }] : { available: true }));
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||
const port = (server.address() as { port: number }).port;
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
vi.stubEnv("OPENRIG_TRANSPORT_LOCAL_USER_GUARD", "true");
|
||||
vi.stubEnv("OPENRIG_URL", ""); vi.stubEnv("RIGGED_URL", "");
|
||||
const ownerProbe = vi.fn(async () => ({ state: "unknown" as const, reason: "fixture-unconfirmed" }));
|
||||
const ownerClient = new DaemonClient(base, { ownerProbe });
|
||||
const specPath = "/fixture/rig.yaml";
|
||||
const spec = 'version: "0.2"\nname: fixture\npods:\n - id: dev\n label: Development\n members:\n - id: worker\n';
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const priorExitCode = process.exitCode;
|
||||
const program = new Command().addCommand(doctorCommand(makeDeps({ callerUid: 1002,
|
||||
checkPort: async () => false, specPath, readFile: path => path === specPath ? spec : null,
|
||||
configStore: { resolve: () => ({ ...defaultConfig, daemon: { host: "127.0.0.1", port } }) },
|
||||
fetch: (url, init) => globalThis.fetch(url, init),
|
||||
fetchLiveLogicalIds: name => readLiveLogicalIds(base, name, ownerClient),
|
||||
})));
|
||||
const task = program.parseAsync(["node", "rig", "doctor", "--json", "--spec", specPath]);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const outcome = await Promise.race([task.then(() => "settled"), new Promise<string>(resolve => {
|
||||
timer = setTimeout(() => resolve("still-waiting"), 4500);
|
||||
})]);
|
||||
expect(["darwin", "linux"]).toContain(process.platform);
|
||||
expect(requests).toContain(held === "healthy" ? "/api/rigs/rig-id/nodes" : heldPath);
|
||||
expect(ownerProbe).toHaveBeenCalled();
|
||||
console.info("[doctor spec HTTP deadline]", JSON.stringify({ platform: process.platform, held, requests, outcome }));
|
||||
expect(outcome).toBe("settled");
|
||||
const result = JSON.parse(String(log.mock.calls.at(-1)![0]));
|
||||
const check = result.checks.find((c: { name: string }) => c.name === "spec_live_conformance");
|
||||
expect(check.status).toBe(held === "healthy" ? "pass" : "skipped");
|
||||
if (held === "healthy") expect(ownerProbe).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
clearTimeout(timer); server.closeAllConnections();
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
await Promise.allSettled([task]);
|
||||
log.mockRestore(); process.exitCode = priorExitCode; vi.unstubAllEnvs();
|
||||
expect(server.listening).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -344,6 +344,23 @@ describe("Down CLI", () => {
|
||||
expect(logs).toContain(" /work/project/CLAUDE.md: openrig-seat-skills.md");
|
||||
});
|
||||
|
||||
it("names each file in a Claude seat's cwd that still holds OpenRig's additions, and keeps the exit code", async () => {
|
||||
const kept = "/work/project/.openrig/context-collector.cjs: not the shipped collector's bytes, so it was kept";
|
||||
responseOverride = {
|
||||
status: 200,
|
||||
body: {
|
||||
rigId: "rig-1", sessionsKilled: 1, snapshotId: null, deleted: false, deleteBlocked: false, alreadyStopped: false, errors: [],
|
||||
keptFiles: [kept],
|
||||
},
|
||||
};
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "down", "rig-1"]);
|
||||
});
|
||||
expect(logs).toContain("Kept 1 file(s) that still hold OpenRig's additions; check them by hand:");
|
||||
expect(logs).toContain(` ${kept}`);
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ rigName: "workshop", isUniqueName: false },
|
||||
{},
|
||||
|
||||
@@ -47,12 +47,14 @@ describe("rig env", () => {
|
||||
let probeStatus = "fresh";
|
||||
let probeError: string | undefined;
|
||||
let kept: string | undefined;
|
||||
const requests: string[] = [];
|
||||
|
||||
const rigSummary = [{ id: "rig-1", name: "my-rig", nodeCount: 2 }];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
const url = req.url ?? "";
|
||||
requests.push(`${req.method} ${url}`);
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer) => { body += chunk; });
|
||||
req.on("end", () => {
|
||||
@@ -201,6 +203,28 @@ describe("rig env", () => {
|
||||
expect(logs.join("\n")).toContain("Services stopped");
|
||||
});
|
||||
|
||||
it.each(["status", "logs", "down"])("env %s refuses an ambiguous name before contacting services", async (action) => {
|
||||
rigSummary.push({ id: "rig-2", name: "my-rig", nodeCount: 0 });
|
||||
requests.length = 0;
|
||||
try {
|
||||
const { logs, exitCode } = await captureLogs(() => makeCmd().parseAsync(["node", "rig", "env", action, "my-rig"]).then(() => {}));
|
||||
expect(exitCode).toBe(1);
|
||||
expect(logs.join("\n")).toContain("'my-rig' matches 2 rigs");
|
||||
expect(requests.filter((request) => request.includes("/env"))).toEqual([]);
|
||||
} finally { rigSummary.pop(); }
|
||||
});
|
||||
|
||||
it("prefers an exact ID over another rig's matching name", async () => {
|
||||
rigSummary.unshift({ id: "rig-2", name: "rig-1", nodeCount: 0 });
|
||||
requests.length = 0;
|
||||
try {
|
||||
const { exitCode } = await captureLogs(() => makeCmd().parseAsync(["node", "rig", "env", "down", "rig-1"]).then(() => {}));
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(requests).toContain("POST /api/rigs/rig-1/env/down");
|
||||
expect(requests).not.toContain("POST /api/rigs/rig-2/env/down");
|
||||
} finally { rigSummary.shift(); }
|
||||
});
|
||||
|
||||
it("env down explains an archived predecessor's retained project", async () => {
|
||||
kept = "kept project shared: still used by rig successor (rig-2)";
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { spawn } from "node:child_process";
|
||||
import { environmentCommand } from "../src/commands/environment.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
vi.mock("node:child_process", async original => ({ ...await original<typeof import("node:child_process")>(), spawn: vi.fn() }));
|
||||
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); process.exitCode = undefined; });
|
||||
it.each(["slow-get", "slow-validate", "changed-generation"])("the viewer bounds its live lookup without the redundant validation deadline: %s", async mode => {
|
||||
vi.useFakeTimers(); const generation = mode === "changed-generation" ? "changed-generation" : "generation-one";
|
||||
const paths: string[] = [];
|
||||
const client = new DaemonClient("http://127.0.0.1:1", { sessionName: "worker@test", ownerProbe: async () => ({ state: "unknown" }) as never,
|
||||
fetchImpl: (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
paths.push(String(url)); const validation = init?.method === "POST";
|
||||
if (validation && mode === "changed-generation") return new Response(JSON.stringify({ error: "The prepared environment belongs to another generation" }), { status: 409 });
|
||||
if ((mode === "slow-get" && !validation) || (mode === "slow-validate" && validation)) {
|
||||
await new Promise<void>((resolve, reject) => { const timer = setTimeout(resolve, 6000);
|
||||
init?.signal?.addEventListener("abort", () => { clearTimeout(timer); reject(init.signal?.reason); }, { once: true }); });
|
||||
}
|
||||
return new Response(JSON.stringify({ generation, placement: { gateway: "gateway", workspace: "workspace", name: "worker" } }), { status: 200 });
|
||||
}) as typeof fetch });
|
||||
const lifecycleDeps: LifecycleDeps = { spawn: vi.fn(), fetch: vi.fn(async () => ({ ok: true })), kill: vi.fn(() => true),
|
||||
readFile: vi.fn((path: string) => path === STATE_FILE ? JSON.stringify({ pid: 123, port: 7433, db: "test.sqlite", startedAt: "2026-04-20T00:00:00Z" }) : null),
|
||||
writeFile: vi.fn(), removeFile: vi.fn(), exists: vi.fn((path: string) => path === STATE_FILE), mkdirp: vi.fn(), openForAppend: vi.fn(() => 3), isProcessAlive: vi.fn(() => true) };
|
||||
vi.mocked(spawn).mockImplementation(() => { const child = new EventEmitter(); queueMicrotask(() => child.emit("exit", 0)); return child as ReturnType<typeof spawn>; });
|
||||
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const run = environmentCommand({ lifecycleDeps, clientFactory: () => client }).parseAsync(["node", "rig", "connect", "binding-one", "--generation", "generation-one"]);
|
||||
await vi.advanceTimersByTimeAsync(6500); await run;
|
||||
if (mode !== "changed-generation") {
|
||||
expect(paths).toEqual(["http://127.0.0.1:1/api/environments/binding-one?live=true"]);
|
||||
expect(errors).not.toHaveBeenCalled(); expect(spawn).toHaveBeenCalledTimes(1);
|
||||
} else { expect(spawn).not.toHaveBeenCalled(); expect(errors).toHaveBeenCalledWith(expect.stringContaining("generation")); }
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { processStart } from "../src/commands/acp.js";
|
||||
import { claimRow, HARNESSES, resolveHarness, rowRun, type ExecRun } from "../src/commands/exec.js";
|
||||
import { readRows, writeRows } from "./fixtures/exec-file-queue.js";
|
||||
|
||||
// `rig exec` end to end: each `rig exec …` is its own process that exits as the CLI does, the connector it starts is
|
||||
// real and detached, and the adapters are fake installed packages whose program is the fake ACP agent.
|
||||
|
||||
const FAKE_AGENT = fileURLToPath(new URL("./fixtures/fake-acp-agent.mjs", import.meta.url));
|
||||
const RUNNER = fileURLToPath(new URL("./fixtures/exec-cli-runner.ts", import.meta.url));
|
||||
const CONNECTOR_ENTRY = fileURLToPath(new URL("./fixtures/exec-connector-entry.ts", import.meta.url));
|
||||
const SEAT = "builder@studio";
|
||||
const cleanup: Array<() => void> = [];
|
||||
afterEach(() => { for (const fn of cleanup.splice(0)) fn(); });
|
||||
|
||||
function install(root: string, pkg: string, version: string, bin?: string, extra: Record<string, unknown> = {}) {
|
||||
const dir = join(root, "node_modules", pkg);
|
||||
mkdirSync(join(dir, "dist"), { recursive: true });
|
||||
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: pkg, version, ...extra }));
|
||||
if (!bin) return;
|
||||
const program = join(dir, "dist", "index.mjs");
|
||||
writeFileSync(program, `#!/usr/bin/env node\nawait import(${JSON.stringify(pathToFileURL(FAKE_AGENT).href)});\n`);
|
||||
chmodSync(program, 0o755);
|
||||
mkdirSync(join(root, "node_modules", ".bin"), { recursive: true });
|
||||
symlinkSync(program, join(root, "node_modules", ".bin", bin));
|
||||
}
|
||||
|
||||
/** An OpenRig home with both adapters installed and one row owned by this seat, whose body picks the agent's scenario. */
|
||||
function setup(body = "scenario:reply PONG", env: Record<string, string> = {}) {
|
||||
const home = mkdtempSync(join(tmpdir(), "rig-exec-"));
|
||||
cleanup.push(() => rmSync(home, { recursive: true, force: true }));
|
||||
install(home, "@agentclientprotocol/claude-agent-acp", "0.101.0", "claude-agent-acp");
|
||||
install(home, "@anthropic-ai/claude-agent-sdk", "0.3.318", undefined, { claudeCodeVersion: "2.1.318" });
|
||||
install(home, "@agentclientprotocol/codex-acp", "2.5.0", "codex-acp");
|
||||
install(home, "@openai/codex", "0.165.0");
|
||||
const queueFile = join(home, "queue.json");
|
||||
writeRows(queueFile, { "qitem-task": { qitemId: "qitem-task", state: "pending", destinationSession: SEAT, body, resolution: null, tags: ["mission:demo"], notes: [] } });
|
||||
const runEnv: NodeJS.ProcessEnv = {
|
||||
...process.env, OPENRIG_HOME: home, OPENRIG_SESSION_NAME: SEAT, EXEC_QUEUE_FILE: queueFile,
|
||||
PATH: [join(home, "node_modules", ".bin"), dirname(process.execPath), process.env.PATH].join(delimiter), ...env,
|
||||
};
|
||||
for (const name of ["FAKE_ACP_LOAD", "FAKE_ACP_MISSING", "FAKE_ACP_COMPANION_PID_FILE", "FAKE_ACP_COST", "FAKE_ACP_PROMPT_USAGE", "FAKE_ACP_IGNORE_EOF", "EXEC_TEST_CONNECTOR", "EXEC_TEST_WINNER", "EXEC_TEST_HOOK", "EXEC_TEST_STALL_MS", "OPENRIG_EXEC_CONNECTOR"]) if (!(name in env)) delete runEnv[name];
|
||||
return { home, env: runEnv, queueFile };
|
||||
}
|
||||
type Home = ReturnType<typeof setup>;
|
||||
|
||||
function start(h: Home, args: string[], options: { group?: boolean } = {}) {
|
||||
const child: ChildProcess = spawn(process.execPath, ["--import", "tsx", RUNNER, ...args], { env: h.env, stdio: ["ignore", "pipe", "pipe"], detached: options.group });
|
||||
let out = "";
|
||||
child.stdout!.on("data", (data: Buffer) => { out += data.toString(); });
|
||||
child.stderr!.on("data", (data: Buffer) => { out += data.toString(); });
|
||||
const exited = new Promise<number | null>((resolve) => child.once("exit", (code) => resolve(code)));
|
||||
cleanup.push(() => { if (child.exitCode === null) child.kill("SIGKILL"); });
|
||||
return { child, exited, out: () => out };
|
||||
}
|
||||
async function rig(h: Home, args: string[]) {
|
||||
const run = start(h, args);
|
||||
const code = await run.exited;
|
||||
return { code, out: run.out() };
|
||||
}
|
||||
|
||||
async function until<T>(check: () => T | undefined, timeoutMs = 20_000): Promise<T> {
|
||||
for (const deadline = Date.now() + timeoutMs; Date.now() < deadline; await new Promise((r) => setTimeout(r, 50))) {
|
||||
const value = check();
|
||||
if (value !== undefined) return value;
|
||||
}
|
||||
throw new Error("timed out");
|
||||
}
|
||||
const runFile = (h: Home, runId: string) => JSON.parse(readFileSync(join(h.home, "acp", "runs", `${runId}.json`), "utf8")) as ExecRun;
|
||||
const ended = (h: Home, runId: string) => until(() => { const run = runFile(h, runId); return run.endedAt ? run : undefined; });
|
||||
const notes = (h: Home) => readRows(h.queueFile)["qitem-task"]!.notes;
|
||||
const runIds = (h: Home) => readdirSync(join(h.home, "acp", "runs")).filter((f) => /^exec-[^.]+\.json$/.test(f)).map((f) => f.slice(0, -5));
|
||||
const startedId = (out: string) => /\[rig exec\] (exec-\S+) for qitem-task/.exec(out)?.[1];
|
||||
const withEnv = (h: Home, env: Record<string, string>): Home => ({ ...h, env: { ...h.env, ...env } });
|
||||
function withHome<T>(h: Home, fn: () => T): T {
|
||||
const previous = process.env.OPENRIG_HOME;
|
||||
process.env.OPENRIG_HOME = h.home;
|
||||
try { return fn(); } finally { if (previous === undefined) delete process.env.OPENRIG_HOME; else process.env.OPENRIG_HOME = previous; }
|
||||
}
|
||||
/** A run record as rig exec writes one, for the states a test needs. */
|
||||
function record(h: Home, runId: string, fields: Partial<ExecRun>): string {
|
||||
const runs = join(h.home, "acp", "runs");
|
||||
mkdirSync(runs, { recursive: true });
|
||||
const file = join(runs, `${runId}.json`);
|
||||
writeFileSync(file, JSON.stringify({
|
||||
runId, qitemId: "qitem-task", harness: "claude", agent: ["claude-agent-acp"], cwd: h.home, self: SEAT, pollMs: 1000, native: "x",
|
||||
state: "started", pid: 1, requestedAt: "2026-11-06T00:00:00Z", transcript: join(runs, `${runId}.jsonl`), connectorLog: join(runs, `${runId}.connector.log`), ...fields,
|
||||
}));
|
||||
return readFileSync(file, "utf8");
|
||||
}
|
||||
|
||||
describe("rig exec", () => {
|
||||
it("finds each harness's adapter and native versions, and names the install when it's missing", () => {
|
||||
const h = setup();
|
||||
const claude = resolveHarness(HARNESSES.claude!, { PATH: h.env.PATH });
|
||||
expect(claude).toMatchObject({ adapterVersion: "0.101.0", native: "Claude Code 2.1.318, from @anthropic-ai/claude-agent-sdk 0.3.318" });
|
||||
expect(claude.command).toBe(join(h.home, "node_modules", ".bin", "claude-agent-acp"));
|
||||
expect(resolveHarness(HARNESSES.claude!, { PATH: h.env.PATH, CLAUDE_CODE_EXECUTABLE: "/opt/claude" }).native)
|
||||
.toBe("the Claude Code at /opt/claude (CLAUDE_CODE_EXECUTABLE; its version isn't read here), not Claude Code 2.1.318, from @anthropic-ai/claude-agent-sdk 0.3.318");
|
||||
expect(resolveHarness(HARNESSES.codex!, { PATH: h.env.PATH })).toMatchObject({ adapterVersion: "2.5.0", native: "Codex 0.165.0 (@openai/codex)" });
|
||||
expect(resolveHarness(HARNESSES.codex!, { PATH: "/nonexistent" }).problem)
|
||||
.toBe("codex-acp isn't on PATH; install it with npm install --prefix <dir> @agentclientprotocol/[email protected] and put <dir>/node_modules/.bin on PATH");
|
||||
});
|
||||
|
||||
it("reads a missing session only from each adapter's own evidence, never from a code alone", () => {
|
||||
const { claude, codex } = HARNESSES;
|
||||
const rollout = { code: -32603, message: "Internal error", data: { details: "no rollout found for thread id s1" } };
|
||||
expect(claude!.missingSession({ code: -32002, message: "Resource not found", data: { sessionId: "s1" } }, "s1")).toBe(true);
|
||||
expect(claude!.missingSession({ code: -32002, message: "Resource not found" }, "s1")).toBe(false);
|
||||
expect(claude!.missingSession({ code: -32002, message: "Resource not found", data: { sessionId: "s2" } }, "s1")).toBe(false);
|
||||
expect(claude!.missingSession(rollout, "s1")).toBe(false);
|
||||
expect(codex!.missingSession(rollout, "s1")).toBe(true);
|
||||
expect(codex!.missingSession(rollout, "s9")).toBe(false);
|
||||
expect(codex!.missingSession({ code: -32603, message: "Internal error", data: { details: "context length exceeded" } }, "s1")).toBe(false);
|
||||
expect(codex!.missingSession({ code: -32603, message: "Internal error" }, "s1")).toBe(false);
|
||||
expect(codex!.missingSession({ code: -32002, message: "Resource not found", data: { sessionId: "s1" } }, "s1")).toBe(true);
|
||||
});
|
||||
|
||||
it("--plan shows the versions and what would run, and starts nothing", async () => {
|
||||
const h = setup();
|
||||
const plan = await rig(h, ["run", "qitem-task", "--harness", "claude", "--plan"]);
|
||||
expect(plan.code).toBe(0);
|
||||
expect(plan.out).toContain(`claude: @agentclientprotocol/claude-agent-acp 0.101.0 at ${join(h.home, "node_modules", ".bin", "claude-agent-acp")}`);
|
||||
expect(plan.out).toContain("runs: Claude Code 2.1.318, from @anthropic-ai/claude-agent-sdk 0.3.318");
|
||||
expect(plan.out).toContain("answers: a reject refuses that one action, and the turn continues");
|
||||
expect(plan.out).toContain("would run:");
|
||||
expect(existsSync(join(h.home, "acp", "runs"))).toBe(false);
|
||||
expect(notes(h)).toEqual([]);
|
||||
}, 30_000);
|
||||
|
||||
it("keeps running after the calling CLI exits, and records the outcome as the agent's report", async () => {
|
||||
const h = setup("scenario:slow");
|
||||
const started = await rig(h, ["run", "qitem-task", "--harness", "claude"]);
|
||||
expect(started.code).toBe(0);
|
||||
const runId = startedId(started.out)!;
|
||||
expect(started.out).toContain(`${runId} for qitem-task on claude: started`);
|
||||
const live = runFile(h, runId);
|
||||
expect(live.state).toBe("started");
|
||||
expect(() => process.kill(live.pid, 0)).not.toThrow(); // the connector outlives the CLI that started it
|
||||
const run = await ended(h, runId);
|
||||
expect(run).toMatchObject({ state: "finished", outcome: "completed", summary: "the agent ended its turn (end_turn)" });
|
||||
expect(run.settle).toContain("settled: no process carrying this run's marker remains");
|
||||
expect(readRows(h.queueFile)["qitem-task"]!.state).toBe("in-progress"); // accepting the work stays with the custodian
|
||||
expect(notes(h).join("\n")).toMatch(/executor fake-acp-agent 0\.0\.1 \(protocol 1, loadSession not advertised\), session fake-session-1 new, custodian builder@studio/);
|
||||
const status = await rig(h, ["status", "qitem-task"]);
|
||||
expect(status.out).toContain("finished, outcome completed: the agent ended its turn (end_turn)");
|
||||
expect(status.out).toContain("The outcome is the agent's report, not acceptance: the row stays open for its custodian to judge and close.");
|
||||
expect(status.out).toContain("Usage as the agent reported it: tokens unknown");
|
||||
}, 40_000);
|
||||
|
||||
it("leaving --wait, even by a Ctrl-C to its whole process group, stops only the wait", async () => {
|
||||
const h = setup("scenario:slow");
|
||||
const waiter = start(h, ["run", "qitem-task", "--harness", "codex", "--wait"], { group: true });
|
||||
await until(() => (/for qitem-task on codex: started/.test(waiter.out()) ? true : undefined));
|
||||
process.kill(-waiter.child.pid!, "SIGINT"); // what a terminal's Ctrl-C sends: the connector is in its own session
|
||||
expect(await waiter.exited).toBe(3);
|
||||
expect(waiter.out()).toMatch(/stopped waiting; exec-\S+ keeps running/);
|
||||
const run = await ended(h, startedId(waiter.out())!);
|
||||
expect(run.outcome).toBe("completed");
|
||||
expect(notes(h).join("\n")).not.toContain("cancel requested");
|
||||
}, 40_000);
|
||||
|
||||
it("a Ctrl-C that lands just after the run is announced, while the CLI is stalled there, still stops only the wait", async () => {
|
||||
const h = withEnv(setup("scenario:slow"), { EXEC_TEST_STALL_MS: "1500" });
|
||||
const waiter = start(h, ["run", "qitem-task", "--harness", "codex", "--wait"], { group: true });
|
||||
await until(() => (/for qitem-task on codex: started/.test(waiter.out()) ? true : undefined));
|
||||
process.kill(-waiter.child.pid!, "SIGINT"); // arrives during the stall, before the CLI's next step
|
||||
expect(await waiter.exited).toBe(3);
|
||||
expect(waiter.out()).toMatch(/stopped waiting; exec-\S+ keeps running/);
|
||||
expect((await ended(h, startedId(waiter.out())!)).outcome).toBe("completed");
|
||||
}, 40_000);
|
||||
|
||||
it("starts a row's task once: a second start or a retry returns the same run, and --again starts another after it ends", async () => {
|
||||
const h = setup();
|
||||
const args = ["run", "qitem-task", "--harness", "claude"];
|
||||
const [a, b] = await Promise.all([rig(h, args), rig(h, args)]);
|
||||
expect(runIds(h)).toHaveLength(1);
|
||||
const [runId] = runIds(h);
|
||||
expect(`${a.out}\n${b.out}`).toMatch(/started by another caller|already has a run, so the task isn't sent twice/);
|
||||
await ended(h, runId!);
|
||||
const retry = await rig(h, args);
|
||||
expect(retry.out).toContain(`qitem-task already has a run, so the task isn't sent twice:\n${runId} for qitem-task on claude`);
|
||||
expect(runIds(h)).toEqual([runId]);
|
||||
expect(notes(h).filter((note) => /^ACP run \S+: executor/.test(note))).toHaveLength(1);
|
||||
|
||||
const again = await rig(h, [...args, "--again"]);
|
||||
const second = startedId(again.out)!;
|
||||
expect(second).not.toBe(runId);
|
||||
await ended(h, second);
|
||||
expect(notes(h).filter((note) => /^ACP run \S+: executor/.test(note))).toHaveLength(2);
|
||||
}, 60_000);
|
||||
|
||||
it("claims a row for one run only: a second claim loses, and a replacement must name the run it replaces", () => {
|
||||
const h = setup();
|
||||
const previous = process.env.OPENRIG_HOME;
|
||||
process.env.OPENRIG_HOME = h.home;
|
||||
try {
|
||||
expect(claimRow("qitem-task", "exec-a")).toBe(true);
|
||||
expect(claimRow("qitem-task", "exec-b")).toBe(false);
|
||||
expect(rowRun("qitem-task")).toBe("exec-a");
|
||||
expect(claimRow("qitem-task", "exec-c", "exec-b")).toBe(false);
|
||||
expect(claimRow("qitem-task", "exec-c", "exec-a")).toBe(true);
|
||||
expect(claimRow("qitem-task", "exec-d", "exec-a")).toBe(false);
|
||||
expect(rowRun("qitem-task")).toBe("exec-c");
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.OPENRIG_HOME; else process.env.OPENRIG_HOME = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it("lets only one of two callers that read the same current run claim the row, and a fresh start never claims during a replacement", () => {
|
||||
withHome(setup(), () => {
|
||||
expect(claimRow("qitem-task", "exec-p")).toBe(true);
|
||||
let inWindow: boolean | undefined;
|
||||
// A and B both read exec-p as the row's run; B publishes inside A's window, so A's publish fails.
|
||||
expect(claimRow("qitem-task", "exec-a", "exec-p", () => { inWindow = claimRow("qitem-task", "exec-b", "exec-p"); })).toBe(false);
|
||||
expect(inWindow).toBe(true);
|
||||
expect(rowRun("qitem-task")).toBe("exec-b");
|
||||
// A fresh start inside a replacement's window still sees the row's run, so it never claims.
|
||||
expect(claimRow("qitem-task", "exec-c", "exec-b", () => { inWindow = claimRow("qitem-task", "exec-x"); })).toBe(true);
|
||||
expect(inWindow).toBe(false);
|
||||
expect(rowRun("qitem-task")).toBe("exec-c");
|
||||
// Two fresh starts on a row with no run: the one that publishes first wins.
|
||||
expect(claimRow("qitem-new", "exec-1", undefined, () => { inWindow = claimRow("qitem-new", "exec-2"); })).toBe(false);
|
||||
expect(inWindow).toBe(true);
|
||||
expect(rowRun("qitem-new")).toBe("exec-2");
|
||||
});
|
||||
});
|
||||
|
||||
it("exits by the run's outcome on every path: an ended run returned again, a run that ended before the first look, and a losing caller", async () => {
|
||||
const failed = setup("scenario:error");
|
||||
const first = startedId((await rig(failed, ["run", "qitem-task", "--harness", "claude"])).out)!;
|
||||
expect((await ended(failed, first)).outcome).toBe("failed");
|
||||
const again = await rig(failed, ["run", "qitem-task", "--harness", "claude", "--wait"]);
|
||||
expect(again.out).toContain("qitem-task already has a run, so the task isn't sent twice");
|
||||
expect(again.code).toBe(2);
|
||||
expect((await rig(failed, ["run", "qitem-task", "--harness", "claude"])).code).toBe(2);
|
||||
|
||||
const quick = await rig(withEnv(setup(), { EXEC_TEST_CONNECTOR: "instant" }), ["run", "qitem-task", "--harness", "claude"]);
|
||||
expect(quick.out).toMatch(/for qitem-task on claude: ended, outcome completed/);
|
||||
expect(quick.code).toBe(0);
|
||||
|
||||
for (const [outcome, code] of [["failed", 2], ["completed", 0]] as const) {
|
||||
const lost = withEnv(setup(), { EXEC_TEST_WINNER: outcome });
|
||||
const loser = await rig(lost, ["run", "qitem-task", "--harness", "claude", "--wait"]);
|
||||
expect(loser.out).toContain("qitem-task was started by another caller as exec-winner, so the task isn't sent twice");
|
||||
expect(loser.out).toContain(`finished, outcome ${outcome}`);
|
||||
expect(loser.code).toBe(code);
|
||||
expect(runIds(lost)).toEqual(["exec-winner"]); // the losing caller's own record is gone, and nothing else started
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it("ends a run interrupted when its connector dies before recording itself, even while a waiter or requester lives", async () => {
|
||||
const h = setup();
|
||||
const died = await rig(withEnv(h, { EXEC_TEST_CONNECTOR: "die" }), ["run", "qitem-task", "--harness", "claude", "--wait"]);
|
||||
expect(died.code).toBe(2);
|
||||
expect(died.out).toContain("interrupted: its connector exited (code 3, signal none) and never recorded itself, so no turn was sent");
|
||||
expect(runFile(h, startedId(died.out)!)).toMatchObject({ state: "interrupted", outcome: "unknown" });
|
||||
|
||||
// A requested record names its requester; a live requester with no connector is not a live run.
|
||||
const other = setup();
|
||||
record(other, "exec-orphan", { state: "requested", pid: process.pid, processStart: processStart(process.pid) });
|
||||
const status = await rig(other, ["status", "exec-orphan"]);
|
||||
expect(status.out).toContain("interrupted: its connector isn't running and never recorded itself, so no turn was sent");
|
||||
}, 40_000);
|
||||
|
||||
it("settles a run's start and its end exclusively against a concurrent reconciler, in either order", async () => {
|
||||
const connectorLog = (h: Home, runId: string) => readFileSync(runFile(h, runId).connectorLog, "utf8");
|
||||
const binding = (h: Home) => notes(h).filter((note) => /^ACP run \S+: executor/.test(note));
|
||||
|
||||
// The reconciler first: it settles the run after the connector's guard and before its start, so no turn is sent.
|
||||
const first = withEnv(setup(), { EXEC_TEST_HOOK: "after-guard" });
|
||||
const a = await rig(first, ["run", "qitem-task", "--harness", "claude", "--wait"]);
|
||||
const aId = startedId(a.out)!;
|
||||
expect(a.code).toBe(2);
|
||||
expect(runFile(first, aId)).toMatchObject({ state: "interrupted", outcome: "unknown", summary: "interrupted by a test reconciler after-guard" });
|
||||
await until(() => (/no turn is sent/.test(connectorLog(first, aId)) ? true : undefined));
|
||||
expect(connectorLog(first, aId)).toContain("reconciler after-guard: won");
|
||||
expect(binding(first)).toEqual([]);
|
||||
expect(notes(first).filter((note) => note.startsWith("test reconciler"))).toHaveLength(1);
|
||||
|
||||
// The connector first: the reconciler still holding the requested copy loses, and the turn completes.
|
||||
const second = withEnv(setup(), { EXEC_TEST_HOOK: "after-start" });
|
||||
const bId = startedId((await rig(second, ["run", "qitem-task", "--harness", "claude"])).out)!;
|
||||
expect(await ended(second, bId)).toMatchObject({ state: "finished", outcome: "completed" });
|
||||
expect(connectorLog(second, bId)).toContain("reconciler after-start: lost");
|
||||
expect(binding(second)).toHaveLength(1);
|
||||
expect(notes(second).some((note) => note.startsWith("test reconciler"))).toBe(false);
|
||||
|
||||
// The end: a reconciler that settles the started run before the connector records its end wins, and the
|
||||
// connector's own outcome is told to the row without replacing that record.
|
||||
const third = withEnv(setup(), { EXEC_TEST_HOOK: "before-end" });
|
||||
const cId = startedId((await rig(third, ["run", "qitem-task", "--harness", "claude"])).out)!;
|
||||
await until(() => (notes(third).some((note) => note.includes("that record stands")) ? true : undefined));
|
||||
expect(runFile(third, cId)).toMatchObject({ state: "interrupted", summary: "interrupted by a test reconciler before-end" });
|
||||
expect((await rig(third, ["status", cId])).out).toContain("interrupted by a test reconciler before-end");
|
||||
expect(notes(third).join("\n")).toContain(`rig exec run ${cId}: its connector finished with outcome completed (the agent ended its turn (end_turn)) after the run had been recorded interrupted; that record stands`);
|
||||
}, 60_000);
|
||||
|
||||
it("recovers a run from its slots when a crash left the copy behind", async () => {
|
||||
const h = setup();
|
||||
const gone = spawn(process.execPath, ["-e", ""]);
|
||||
await new Promise((resolve) => gone.once("exit", resolve));
|
||||
const runs = join(h.home, "acp", "runs");
|
||||
// The connector claimed its start and died before writing the copy, which still says requested.
|
||||
record(h, "exec-crashed", { state: "requested", pid: process.pid, processStart: processStart(process.pid) });
|
||||
writeFileSync(join(runs, "exec-crashed.start.json"), JSON.stringify({ by: "connector", pid: gone.pid, processStart: "Thu Jan 1 00:00:00 2026", startedAt: "2026-11-06T00:00:01Z" }));
|
||||
const status = await rig(h, ["status", "exec-crashed"]);
|
||||
expect(status.out).toContain("interrupted: its connector ended without recording an outcome, so the outcome is unknown");
|
||||
// The connector recorded its end and died before writing the copy, which still says started.
|
||||
record(h, "exec-ended", { state: "started", pid: gone.pid, processStart: "Thu Jan 1 00:00:00 2026" });
|
||||
writeFileSync(join(runs, "exec-ended.start.json"), JSON.stringify({ by: "connector", pid: gone.pid, startedAt: "2026-11-06T00:00:01Z" }));
|
||||
writeFileSync(join(runs, "exec-ended.end.json"), JSON.stringify({ by: "connector", ended: { state: "finished", outcome: "completed", summary: "the agent ended its turn (end_turn)", endedAt: "2026-11-06T00:00:09Z" } }));
|
||||
expect((await rig(h, ["status", "exec-ended"])).out).toContain("finished, outcome completed");
|
||||
expect(notes(h).filter((note) => note.startsWith("rig exec run exec-ended"))).toEqual([]);
|
||||
}, 40_000);
|
||||
|
||||
it("the connector entry runs only the requested run it was started for, and leaves any other record untouched", async () => {
|
||||
const h = setup();
|
||||
const done = record(h, "exec-done", { state: "finished", outcome: "completed", endedAt: "2026-11-06T00:00:05Z" });
|
||||
const waiting = record(h, "exec-waiting", { state: "requested" });
|
||||
const connector = async (runId: string, marker?: string) => {
|
||||
const child = spawn(process.execPath, ["--import", "tsx", CONNECTOR_ENTRY, runId], { env: { ...h.env, ...(marker ? { OPENRIG_EXEC_CONNECTOR: marker } : {}) }, stdio: "ignore" });
|
||||
return new Promise<number | null>((resolve) => child.once("exit", (code) => resolve(code)));
|
||||
};
|
||||
expect(await connector("exec-done", "exec-done")).toBe(1);
|
||||
expect(await connector("exec-waiting")).toBe(1);
|
||||
expect(await connector("exec-waiting", "exec-other")).toBe(1);
|
||||
expect(readFileSync(join(h.home, "acp", "runs", "exec-done.json"), "utf8")).toBe(done);
|
||||
expect(readFileSync(join(h.home, "acp", "runs", "exec-waiting.json"), "utf8")).toBe(waiting);
|
||||
expect(notes(h)).toEqual([]);
|
||||
}, 40_000);
|
||||
|
||||
it("reads a run whose connector died, or whose pid now names another process, as interrupted, with one note", async () => {
|
||||
const h = setup();
|
||||
const gone = spawn(process.execPath, ["-e", ""]);
|
||||
await new Promise((resolve) => gone.once("exit", resolve));
|
||||
const other = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
|
||||
cleanup.push(() => other.kill("SIGKILL"));
|
||||
const runs = join(h.home, "acp", "runs");
|
||||
mkdirSync(runs, { recursive: true });
|
||||
const record = (runId: string, pid: number) => writeFileSync(join(runs, `${runId}.json`), JSON.stringify({
|
||||
runId, qitemId: "qitem-task", harness: "claude", agent: ["claude-agent-acp"], cwd: h.home, self: SEAT, pollMs: 1000, native: "x",
|
||||
state: "started", pid, processStart: "Thu Jan 1 00:00:00 2026", requestedAt: "2026-11-06T00:00:00Z", startedAt: "2026-11-06T00:00:01Z",
|
||||
transcript: join(runs, `${runId}.jsonl`), connectorLog: join(runs, `${runId}.connector.log`),
|
||||
}));
|
||||
record("exec-dead", gone.pid!);
|
||||
record("exec-reused", other.pid!);
|
||||
for (const runId of ["exec-dead", "exec-reused"]) {
|
||||
const status = await rig(h, ["status", runId]);
|
||||
expect(status.out).toContain("interrupted: its connector ended without recording an outcome, so the outcome is unknown; no process carrying the run's marker remains");
|
||||
expect(runFile(h, runId)).toMatchObject({ state: "interrupted", outcome: "unknown" });
|
||||
await rig(h, ["status", runId]);
|
||||
expect(notes(h).filter((note) => note.startsWith(`rig exec run ${runId} interrupted`))).toHaveLength(1);
|
||||
}
|
||||
expect(other.exitCode).toBeNull();
|
||||
expect(other.signalCode).toBeNull(); // the process that reused the pid was never signalled
|
||||
}, 40_000);
|
||||
|
||||
it("cancel asks the agent to stop; the acknowledgement and what still runs are kept apart", async () => {
|
||||
const h = setup("scenario:slow");
|
||||
const runId = startedId((await rig(h, ["run", "qitem-task", "--harness", "claude"])).out)!;
|
||||
await until(() => (notes(h).some((note) => note.includes("executor")) ? true : undefined));
|
||||
const cancel = await rig(h, ["cancel", runId]);
|
||||
expect(cancel.code).toBe(0);
|
||||
expect(cancel.out).toContain("An acknowledgement isn't settlement");
|
||||
const run = await ended(h, runId);
|
||||
expect(run.outcome).toBe("cancelled");
|
||||
expect(run.summary).toMatch(/^cancel requested at \S+, acknowledged by the agent at \S+ \(stopReason cancelled\); upstream spend is unknown$/);
|
||||
expect(run.settle).toContain("settled: no process carrying this run's marker remains");
|
||||
expect((await rig(h, ["cancel", runId])).code).toBe(1); // an ended run isn't signalled again
|
||||
}, 40_000);
|
||||
|
||||
it("records a failed turn as failed, and a completed turn that left a process running as both", async () => {
|
||||
const failed = setup("scenario:error");
|
||||
const failedId = startedId((await rig(failed, ["run", "qitem-task", "--harness", "codex"])).out)!;
|
||||
expect(await ended(failed, failedId)).toMatchObject({ outcome: "failed", summary: 'session/prompt returned error -32603 Internal error {"details":"context length exceeded"}' });
|
||||
|
||||
const pidFile = join(tmpdir(), `rig-exec-companion-${process.pid}`);
|
||||
const leftBehind = setup("scenario:reply PONG", { FAKE_ACP_COMPANION_PID_FILE: pidFile });
|
||||
const runId = startedId((await rig(leftBehind, ["run", "qitem-task", "--harness", "claude"])).out)!;
|
||||
const run = await ended(leftBehind, runId);
|
||||
const companion = Number(readFileSync(pidFile, "utf8"));
|
||||
cleanup.push(() => { try { process.kill(companion, "SIGKILL"); } catch { /* gone */ } rmSync(pidFile, { force: true }); });
|
||||
expect(run.outcome).toBe("completed");
|
||||
expect(run.settle).toContain(`incomplete: 1 process(es) carrying this run's marker still run (${companion})`);
|
||||
const status = await rig(leftBehind, ["status", runId]);
|
||||
expect(status.out).toContain("finished, outcome completed");
|
||||
expect(status.out).toContain(`After the turn: agent exited`);
|
||||
expect(status.out).toContain(`still run (${companion})`);
|
||||
}, 60_000);
|
||||
|
||||
it("a session the adapter says doesn't exist reads missing, and no fresh session is started", async () => {
|
||||
const h = setup("scenario:reply PONG", { FAKE_ACP_LOAD: "1", FAKE_ACP_MISSING: "codex" });
|
||||
const runId = startedId((await rig(h, ["run", "qitem-task", "--harness", "codex", "--load-session", "sess-gone"])).out)!;
|
||||
const run = await ended(h, runId);
|
||||
expect(run.outcome).toBe("missing");
|
||||
expect(run.summary).toContain("session sess-gone doesn't exist for this agent (session/load returned -32603 Internal error");
|
||||
expect(readFileSync(run.transcript, "utf8")).not.toContain("session/new");
|
||||
}, 40_000);
|
||||
});
|
||||
@@ -262,6 +262,67 @@ describe("rig export + import", () => {
|
||||
expect(writeFile).toHaveBeenCalledWith("rig.yaml", expect.any(String));
|
||||
});
|
||||
|
||||
describe("export: a default export never replaces a file", () => {
|
||||
const occupied = (...taken: string[]) => vi.fn((p: string) => {
|
||||
if (taken.includes(p)) throw Object.assign(new Error(`EEXIST: ${p}`), { code: "EEXIST" });
|
||||
});
|
||||
const run = async (deps: Partial<ExportDeps>, ...args: string[]) => {
|
||||
const program = new Command();
|
||||
program.addCommand(exportCommand(exportDeps(deps)));
|
||||
return captureLogs(() => program.parseAsync(["node", "rig", "export", "rig-1", ...args]));
|
||||
};
|
||||
|
||||
it("the first export writes rig.yaml", async () => {
|
||||
const writeNewFile = occupied();
|
||||
const logs = await run({ writeNewFile });
|
||||
expect(writeNewFile.mock.calls.map((c) => c[0])).toEqual(["rig.yaml"]);
|
||||
expect(logs).toContain("Exported to rig.yaml");
|
||||
expect(logs.join("\n")).not.toContain("left unchanged");
|
||||
});
|
||||
|
||||
it("an occupied rig.yaml sends the export to a sibling, and says which", async () => {
|
||||
const writeFile = vi.fn();
|
||||
const writeNewFile = occupied("rig.yaml");
|
||||
const exitCode = process.exitCode;
|
||||
const logs = await run({ writeFile, writeNewFile });
|
||||
expect(writeFile).not.toHaveBeenCalled();
|
||||
expect(writeNewFile).toHaveBeenLastCalledWith("rig-export.yaml", expect.any(String));
|
||||
expect(logs).toContain("Exported to rig-export.yaml");
|
||||
expect(logs.join("\n")).toContain("rig.yaml already exists and was left unchanged");
|
||||
expect(process.exitCode).toBe(exitCode);
|
||||
});
|
||||
|
||||
it("occupied siblings are kept too", async () => {
|
||||
const writeNewFile = occupied("rig.yaml", "rig-export.yaml", "rig-export-2.yaml");
|
||||
const logs = await run({ writeNewFile });
|
||||
expect(writeNewFile.mock.calls.map((c) => c[0])).toEqual(["rig.yaml", "rig-export.yaml", "rig-export-2.yaml", "rig-export-3.yaml"]);
|
||||
expect(logs).toContain("Exported to rig-export-3.yaml");
|
||||
});
|
||||
|
||||
it("-o replaces the file it names, including rig.yaml", async () => {
|
||||
const writeFile = vi.fn();
|
||||
const writeNewFile = occupied("rig.yaml");
|
||||
const logs = await run({ writeFile, writeNewFile }, "-o", "rig.yaml");
|
||||
expect(writeFile).toHaveBeenCalledWith("rig.yaml", expect.any(String));
|
||||
expect(writeNewFile).not.toHaveBeenCalled();
|
||||
expect(logs).toContain("Exported to rig.yaml");
|
||||
});
|
||||
|
||||
it("a real write failure stays a failure, with no success reported", async () => {
|
||||
const writeNewFile = vi.fn(() => { throw Object.assign(new Error("no space"), { code: "ENOSPC" }); });
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
try {
|
||||
const program = new Command();
|
||||
program.addCommand(exportCommand(exportDeps({ writeNewFile })));
|
||||
await expect(program.parseAsync(["node", "rig", "export", "rig-1"])).rejects.toThrow("no space");
|
||||
expect(writeNewFile).toHaveBeenCalledTimes(1);
|
||||
expect(log).not.toHaveBeenCalledWith(expect.stringContaining("Exported to"));
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("export: warns when culture_file or startup files do not resolve relative to output directory", async () => {
|
||||
const writeFile = vi.fn();
|
||||
const exists = vi.fn((p: string) => !p.includes("missing-culture.md") && !p.includes("missing-start.md") && !p.includes("optional-start.md"));
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
// One `rig exec …` invocation in its own process, which then exits as the CLI does, over the test's file queue.
|
||||
// EXEC_TEST_CONNECTOR=die starts a connector that exits before recording itself; =instant one that records a completed
|
||||
// outcome before the CLI's first look. EXEC_TEST_WINNER=<outcome> has another caller claim the row, with a run that
|
||||
// ended with that outcome, between this caller's claim read and its publish. EXEC_TEST_STALL_MS stalls the CLI right
|
||||
// after it announces the run, as a process descheduled there would be.
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { claimRow, execCommand } from "../../src/commands/exec.js";
|
||||
import { fileQueue } from "./exec-file-queue.js";
|
||||
|
||||
const entry = fileURLToPath(new URL("./exec-connector-entry.ts", import.meta.url));
|
||||
const instant = fileURLToPath(new URL("./exec-instant-connector.ts", import.meta.url));
|
||||
const queue = fileQueue(process.env.EXEC_QUEUE_FILE!);
|
||||
const connector = process.env.EXEC_TEST_CONNECTOR;
|
||||
const winner = process.env.EXEC_TEST_WINNER;
|
||||
const stall = Number(process.env.EXEC_TEST_STALL_MS ?? 0);
|
||||
await execCommand({
|
||||
queue: async () => queue,
|
||||
connectorCommand: (runId) => connector === "die" ? [process.execPath, "-e", "process.exit(3)"]
|
||||
: [process.execPath, "--import", "tsx", connector === "instant" ? instant : entry, runId],
|
||||
pollMs: 100,
|
||||
startWaitMs: 20_000,
|
||||
afterAnnounce: stall ? () => { for (const until = Date.now() + stall; Date.now() < until;) { /* busy: no timer or signal callback runs */ } } : undefined,
|
||||
beforeClaim: winner ? () => {
|
||||
const runs = join(process.env.OPENRIG_HOME!, "acp", "runs");
|
||||
writeFileSync(join(runs, "exec-winner.json"), JSON.stringify({
|
||||
runId: "exec-winner", qitemId: "qitem-task", harness: "claude", agent: ["claude-agent-acp"], cwd: runs, self: "builder@studio",
|
||||
pollMs: 1000, native: "x", state: "finished", pid: 1, requestedAt: "2026-11-06T00:00:00Z", endedAt: "2026-11-06T00:00:05Z",
|
||||
outcome: winner, summary: `the winner's run ended ${winner}`, transcript: join(runs, "exec-winner.jsonl"), connectorLog: join(runs, "exec-winner.connector.log"),
|
||||
}));
|
||||
claimRow("qitem-task", "exec-winner");
|
||||
} : undefined,
|
||||
}).parseAsync(["node", "rig", ...process.argv.slice(2)]);
|
||||
Loaded 100 of 516 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user