mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
235a46c143 | ||
|
|
a48d959ba0 | ||
|
|
4191955389 |
No files matched your search
@@ -3,8 +3,7 @@ name: Tests
|
||||
# Builds, type-checks and runs every test suite on a clean GitHub runner, and records the file
|
||||
# list each published package would contain. Each suite is its own job so one failure does not
|
||||
# hide the others. No provider credentials or operator configuration are supplied.
|
||||
# Package suites and the Linux seat-environment check deny external networking;
|
||||
# scenario runtime has no external route.
|
||||
# Package suites deny external networking; scenario runtime has no external route.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -77,40 +76,6 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npm run test:repo
|
||||
|
||||
linux-seat-environment:
|
||||
if: ${{ github.event_name != 'push' }}
|
||||
# This integration test reads /proc/<pid>/environ and is skipped on macOS.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- name: Install the real terminal and staging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes tmux rsync
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- name: Check seat environments without ambient credentials or external network
|
||||
run: |
|
||||
set -euo pipefail
|
||||
suite_root=$(mktemp -d "$RUNNER_TEMP/opr-seat-env.XXXXXX")
|
||||
mkdir -p "$suite_root/home"
|
||||
# Only loopback exists in this namespace. Drop back to the runner user
|
||||
# before the CLI, private daemon and private tmux server are started.
|
||||
sudo unshare --net -- /bin/bash -s -- "$(id -un)" "$suite_root" "$PATH" <<'SUITE'
|
||||
set -euo pipefail
|
||||
ip link set lo up
|
||||
exec runuser -u "$1" -- env -i HOME="$2/home" PATH="$3" TMPDIR="$2" TMUX_TMPDIR="$2" \
|
||||
CI=true TERM=xterm-256color \
|
||||
npm run test -w packages/daemon -- test/seat-env-parent-session.integration.test.ts --maxWorkers=1 --reporter=verbose
|
||||
SUITE
|
||||
|
||||
package-tests:
|
||||
if: ${{ github.event_name != 'push' }}
|
||||
# macOS, the platform OpenRig runs on: several daemon and cli tests read macOS ps columns,
|
||||
|
||||
+3
-4
@@ -290,10 +290,9 @@ packages.
|
||||
package, and the daemon copies its Markdown files to `$OPENRIG_HOME/reference/` when it
|
||||
starts. Files in `docs/as-built/` carry a `last-verified-against-source` commit in their
|
||||
frontmatter.
|
||||
- **Internal-leak guard** (`scripts/internal-leak-scanner.mjs`). Its term lists come from an
|
||||
optional local file that `OPENRIG_LEAK_GUARD_RULES` names (`scripts/leak-guard-rules.mjs`);
|
||||
without one the term checks are skipped and every other check runs, and the release-time
|
||||
substance gate requires it. It runs in `generate-context-packs.mjs` (static pack sources, on every
|
||||
- **Internal-leak guard** (`scripts/internal-leak-scanner.mjs`, with rules in
|
||||
`scripts/internal-tokens.generated.json`). It refuses maintainer-internal names and paths in
|
||||
content that ships. It runs in `generate-context-packs.mjs` (static pack sources, on every
|
||||
`npm test`), in the full skill mirror apply (each public skill), in `scripts/build-package.sh`
|
||||
(the daemon `specs/` tree before it is copied into the package), and in the release-time
|
||||
substance gate (`scripts/check-substance-gate.mjs`, `npm run gate:substance`) over the packed
|
||||
|
||||
+4
-760
@@ -8,745 +8,7 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
|
||||
|
||||
---
|
||||
|
||||
## [0.7.4]
|
||||
|
||||
OpenRig 0.7.4 lets an assistant you already use work as a member of your team.
|
||||
An assistant connected over MCP can be listed on its rig's team as a hosted
|
||||
member, learns from the connection how to take, finish and hand on work, can
|
||||
ask a person while keeping the item, and can be rung by a webhook when work
|
||||
lands for it; when nothing can wake it, its person is told instead.
|
||||
`rig mcp serve --http` can check each remote caller's key with an external
|
||||
grant service, offer OAuth discovery, record whose grant acted, and serve MCP
|
||||
clients on protocol revision 2026-07-28 beside 2025's. You can list the
|
||||
conversations a seat could resume, and a client can resume the one a person
|
||||
reviewed. Daemon start now brings back only a kernel that this start found
|
||||
down. It is built from the commit tagged `v0.7.4`. The changes since 0.7.3:
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- **Restart the daemon after installing.** One database update applies on its
|
||||
first start (migration 140). It adds a column, empty on earlier rows, that
|
||||
records which grant's caller acted, to queue transitions and their archive,
|
||||
inbox and outbox entries, and stream items.
|
||||
```bash
|
||||
npm install -g @openrig/cli@0.7.4
|
||||
rig daemon stop && rig daemon start
|
||||
```
|
||||
- **The bundled openrig-core plugin goes from 0.1.20 to 0.1.21.** The
|
||||
`queue-handoff` skill gains writing guidance, and the Claude hooks add
|
||||
`StopFailure`, which reports a stop caused by a rate limit. As before,
|
||||
startup first copies each file it replaces under
|
||||
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; if you edited one, reapply
|
||||
your change from the copy. A Claude seat gets the new hook at its next
|
||||
launch, resume, restore or handover. Codex sees its openrig-core plugin as
|
||||
changed: if a Codex seat stops at "Hooks need review", review the hooks in
|
||||
Codex.
|
||||
- **Going back to 0.7.3** leaves migration 140's column in place, unread.
|
||||
|
||||
### What's new and fixed
|
||||
|
||||
**Assistants as team members**
|
||||
|
||||
- **A rig's team shows the members that live outside a terminal.** A roster
|
||||
entry can mark a member as hosted (`"hosted": true`), such as an assistant
|
||||
connected over MCP. `rig_team` lists a rig's hosted members with their
|
||||
declared capabilities and caveat, no pane, and activity `unknown`: OpenRig
|
||||
doesn't observe a hosted member between its calls, so it never reads idle.
|
||||
A roster that can't be read gives a note, never "no members". `rig roster
|
||||
show` and `find` say the member is hosted.
|
||||
([#2095](https://github.com/mvschwarz/openrig/pull/2095))
|
||||
- **The connection teaches the assistant how to work.** The MCP server's
|
||||
instructions say that its inbox is the items addressed to its seat, that a
|
||||
wake only points at the inbox, and to claim an item, do it, then close it
|
||||
with the answer or hand it on. When an item needs a person's approval first,
|
||||
it keeps the item and asks: it blocks the item on that person with the
|
||||
question as the summary, and the answer returns the item in progress. A
|
||||
hosted member can call `rig_whoami` to learn the seat it acts as.
|
||||
([#2095](https://github.com/mvschwarz/openrig/pull/2095),
|
||||
[#2127](https://github.com/mvschwarz/openrig/pull/2127))
|
||||
- **When work waits for a member nothing can wake, the person is told.** The
|
||||
stuck sweep used to address its finding to that same member. Now it goes to
|
||||
the person, on Steering and the TUI's Feed, and to Slack under the default
|
||||
subscription, saying to ask the member to check or to fix the address.
|
||||
([#2110](https://github.com/mvschwarz/openrig/pull/2110))
|
||||
- **A hosted member can be rung when work lands for it.** Its roster entry
|
||||
declares a webhook trigger: `wake.webhook` with `url_ref`,
|
||||
`authorization_ref` and optionally `secrets_env_file`. These are secret
|
||||
names, read from the daemon's environment or a 0600 env file; values never
|
||||
go in rosters or rows. OpenRig POSTs a pointer to the row (the event
|
||||
`work-waiting`, the row id, destination, summary and sender, never the row's
|
||||
body), waits up to 10 seconds and doesn't follow redirects. A 2xx is
|
||||
recorded as the ring being accepted, not as the member waking. If the member
|
||||
doesn't claim the work by the stuck sweep's unclaimed age, its person is
|
||||
told. ([#2115](https://github.com/mvschwarz/openrig/pull/2115))
|
||||
- **The team sees when a hosted member last showed up.** `rig_team` shows when
|
||||
each hosted member last called the team's MCP server, and its latest ring
|
||||
and how that went; `rig roster show` and `find` show the last call. With no
|
||||
evidence it says unknown, never idle, and a claim on a row is still the only
|
||||
pickup. ([#2121](https://github.com/mvschwarz/openrig/pull/2121))
|
||||
|
||||
**Remote MCP callers and grants**
|
||||
|
||||
- **`rig mcp serve --http --grant-service <url>`** checks each request's key
|
||||
with an external grant service instead of the local token file, on every
|
||||
request, with nothing cached. `--grant-service-header-file <path>` sends one
|
||||
`Name: value` header with each lookup.
|
||||
([#2096](https://github.com/mvschwarz/openrig/pull/2096),
|
||||
[#2141](https://github.com/mvschwarz/openrig/pull/2141))
|
||||
- **OAuth discovery:** `--oauth-resource <url>` with `--oauth-issuer <url>`
|
||||
publishes OAuth protected-resource metadata (RFC 9728), so an MCP client
|
||||
that signs in with OAuth can find out how to authenticate, and a request
|
||||
with no credential or a bad one gets a standard challenge. The key and its
|
||||
grant still decide what the caller may do.
|
||||
([#2135](https://github.com/mvschwarz/openrig/pull/2135))
|
||||
- **Whose grant acted is recorded.** The rows a granted caller's actions write
|
||||
record the caller's principal (`principal:v1`): its source, its grant name
|
||||
or id and, for a grant service, the grant's version. It's attribution, not
|
||||
authentication, and never a credential.
|
||||
([#2098](https://github.com/mvschwarz/openrig/pull/2098),
|
||||
[#2141](https://github.com/mvschwarz/openrig/pull/2141))
|
||||
- **A caller's seat that was created but never launched** (for example with
|
||||
`rig import --materialize-only`) is served, instead of every request being
|
||||
refused with `seat_unavailable`.
|
||||
([#2125](https://github.com/mvschwarz/openrig/pull/2125))
|
||||
- **A caller granted through a grant service** hands its tasks to the team it
|
||||
was granted, never to its own caller seat or another caller's
|
||||
([#2132](https://github.com/mvschwarz/openrig/pull/2132)), and can't attach
|
||||
tags that claim OpenRig's own Slack provenance or a workflow's internal
|
||||
sign-off, so a person asked about its task sees its whole text
|
||||
([#2126](https://github.com/mvschwarz/openrig/pull/2126)).
|
||||
|
||||
**MCP protocol 2026-07-28**
|
||||
|
||||
- `rig mcp serve`, over stdio or `--http`, serves clients on MCP protocol
|
||||
revision 2026-07-28, which has no `initialize` handshake, sends its version
|
||||
on every request and may open with `server/discover`. They reach the same
|
||||
tools and the same work loop. Clients that open with 2025's `initialize`
|
||||
keep working unchanged, on the same endpoint and process.
|
||||
([#2112](https://github.com/mvschwarz/openrig/pull/2112))
|
||||
|
||||
**Resuming the conversation you chose**
|
||||
|
||||
- **`rig seat history --rig <id> --node-id <id>`** lists the recorded Claude and Codex
|
||||
conversations this exact node could resume, read-only and without native
|
||||
tokens (`--json`; `--snapshot-id` with `--history-session-id` checks one
|
||||
reference it returned). The rig ID is `rigId` in `rig ps --nodes --json`, and the
|
||||
node ID is `identity.nodeId` in `rig whoami --session <seat> --json`. ([#2100](https://github.com/mvschwarz/openrig/pull/2100))
|
||||
- **A client can resume the history a person reviewed** for an exact node,
|
||||
through the daemon's startup review. Applying it rechecks the choice and the
|
||||
settings through the existing restore path, and refuses, rather than
|
||||
substituting another conversation or starting fresh, if the history changed
|
||||
or became unavailable. A running or unverified terminal is left alone. With
|
||||
0.7.3's saved seat settings by node id
|
||||
([#2051](https://github.com/mvschwarz/openrig/pull/2051)), this completes
|
||||
the three stages agreed in
|
||||
[#1837](https://github.com/mvschwarz/openrig/issues/1837).
|
||||
([#2109](https://github.com/mvschwarz/openrig/pull/2109))
|
||||
|
||||
**Task views**
|
||||
|
||||
- `rig terminal open` and the daemon's terminal preview accept `qitem:<id>`: the current local
|
||||
owners of that task, meaning its pending, in-progress and blocked rows
|
||||
through explicitly linked successors, without closed predecessors or past
|
||||
senders. Stopped, missing, external and unresolved owners are named, and
|
||||
incomplete lineage is reported rather than cut short. It's a read-only
|
||||
membership view: it doesn't follow the task as it moves, and an explicit
|
||||
open still has the provider's usual workspace effects. Preview and open
|
||||
responses now carry `viewDescriptor` (`kind`, `name`).
|
||||
([#2131](https://github.com/mvschwarz/openrig/pull/2131))
|
||||
|
||||
**The kernel at daemon start**
|
||||
|
||||
- **Startup brings back a kernel only when this start found it down,** which
|
||||
is what a reboot leaves. A kernel that was already down before this start
|
||||
stays down, with its old conversations. `rig start` and `rig daemon start
|
||||
--wait-for-kernel` say why and how to bring it back (`rig up kernel
|
||||
--existing`); `--wait-for-kernel` no longer prints a timeout and an
|
||||
authentication hint for a kernel skipped on purpose, and still exits 1.
|
||||
([#2146](https://github.com/mvschwarz/openrig/pull/2146),
|
||||
[#2152](https://github.com/mvschwarz/openrig/pull/2152),
|
||||
[#2156](https://github.com/mvschwarz/openrig/pull/2156))
|
||||
|
||||
**Seats and runtimes**
|
||||
|
||||
- **A Claude seat stopped by a rate limit is recognised** from Claude's
|
||||
`StopFailure` hook, even when its status line shows no usage window, and
|
||||
pending handoffs use the existing wake ladder and retry timer. A delayed
|
||||
older Claude hook no longer replaces the current state in send readiness,
|
||||
attention, review telemetry or the wake scheduler.
|
||||
([#1908](https://github.com/mvschwarz/openrig/pull/1908),
|
||||
[#2113](https://github.com/mvschwarz/openrig/pull/2113))
|
||||
- **`rig handover --source fresh` on Claude** reports the restore packet
|
||||
delivered only when Claude's composer shows it submitted. If it's still
|
||||
staged after one retried Enter, the handover fails with
|
||||
`context_delivery_failed`.
|
||||
([#1863](https://github.com/mvschwarz/openrig/pull/1863))
|
||||
- **A periodic snapshot between separate named-agent launches** keeps a down
|
||||
agent's resume source, including its restore policy and community harness
|
||||
selection, without claiming it was running.
|
||||
([#2123](https://github.com/mvschwarz/openrig/pull/2123), fixes
|
||||
[#1822](https://github.com/mvschwarz/openrig/issues/1822))
|
||||
- **A resumed Pi seat** no longer prints `Entry not found` after `READY` on
|
||||
every restart: a saved history cursor Pi doesn't hold is recovered.
|
||||
([#2122](https://github.com/mvschwarz/openrig/pull/2122))
|
||||
|
||||
**People, parks and reviews**
|
||||
|
||||
- **A park on a registered person's own address** (`<id>@external`) resolves
|
||||
only from a Slack reply written after its question was posted
|
||||
([#2106](https://github.com/mvschwarz/openrig/pull/2106)), keeps its
|
||||
`--summary` and `--evidence-ref`
|
||||
([#2107](https://github.com/mvschwarz/openrig/pull/2107)), and shows in
|
||||
NEEDS YOU in the slice, mission and rig reviews, with asks to that address,
|
||||
instead of the person being listed as an agent holding work
|
||||
([#2108](https://github.com/mvschwarz/openrig/pull/2108)).
|
||||
- **The reviews' agents band** counts only open rows as held, and a seat with
|
||||
work in progress reads as working even when it also has an older blocked
|
||||
row. ([#2136](https://github.com/mvschwarz/openrig/pull/2136), fixes
|
||||
[#2129](https://github.com/mvschwarz/openrig/issues/2129))
|
||||
|
||||
**TUI and CLI**
|
||||
|
||||
- `rig` or `rig tui` started in a terminal that reports 0 rows or columns
|
||||
waits and draws on the next resize, instead of crashing on its first frame.
|
||||
([#2099](https://github.com/mvschwarz/openrig/pull/2099))
|
||||
- The TUI lists a host's rigs by name, in `rig ps`'s order.
|
||||
([#2134](https://github.com/mvschwarz/openrig/pull/2134), fixes
|
||||
[#2130](https://github.com/mvschwarz/openrig/issues/2130))
|
||||
- Opening a seat whose runtime doesn't report effort no longer shows a failed
|
||||
read in the TUI header. ([#2102](https://github.com/mvschwarz/openrig/pull/2102))
|
||||
- `rig whoami`, `rig down`, `rig ask` and `rig health` in a tmux pane without
|
||||
`OPENRIG_SESSION_NAME` no longer print tmux's `invalid option` errors.
|
||||
([#2103](https://github.com/mvschwarz/openrig/pull/2103))
|
||||
|
||||
**Also:** an artifact whose frontmatter header isn't closed no longer wakes an
|
||||
artifact-pool worker as ready
|
||||
([#2016](https://github.com/mvschwarz/openrig/pull/2016)); with Pi cost
|
||||
reporting on, a seat that now runs Claude or Codex shows its current cost, and
|
||||
a charge lookup retried during a re-post finishes
|
||||
([#2120](https://github.com/mvschwarz/openrig/pull/2120)); the
|
||||
`queue-handoff` skill says to write a handoff that can be acted on after one
|
||||
read, as guidance and not a filter
|
||||
([#2154](https://github.com/mvschwarz/openrig/pull/2154),
|
||||
[#2155](https://github.com/mvschwarz/openrig/pull/2155)); the harness preset
|
||||
guide says to check that a harness answers on its own first
|
||||
([#2116](https://github.com/mvschwarz/openrig/pull/2116)); seat environments
|
||||
are exercised on Linux in CI
|
||||
([#2101](https://github.com/mvschwarz/openrig/pull/2101)); and portability and
|
||||
test fixes ([#2147](https://github.com/mvschwarz/openrig/pull/2147),
|
||||
[#2148](https://github.com/mvschwarz/openrig/pull/2148),
|
||||
[#2149](https://github.com/mvschwarz/openrig/pull/2149)).
|
||||
|
||||
### Behaviour that changed
|
||||
|
||||
- A kernel that was already down before daemon start is no longer restored on
|
||||
that start; see "The kernel at daemon start".
|
||||
- `rig daemon start --wait-for-kernel` for a kernel skipped on purpose prints
|
||||
the reason and the way back instead of a timeout header and an
|
||||
authentication fix. It still exits 1.
|
||||
- Work waiting for a member nothing can wake is reported to its person, not
|
||||
to that member.
|
||||
- A caller granted through a grant service can't address its own or another
|
||||
caller seat, or set OpenRig's Slack provenance or a workflow's internal
|
||||
sign-off tags.
|
||||
- A park on a registered person's address resolves only from a reply written
|
||||
after its question.
|
||||
|
||||
### Known issues
|
||||
|
||||
- **A webhook ring proves only that the endpoint accepted it.** Whether the
|
||||
member woke and took the work is its claim on the row. An ordinary chat app
|
||||
can't receive unsolicited events, so a hosted assistant without a
|
||||
webhook-capable runner sees work only when it checks.
|
||||
([#2115](https://github.com/mvschwarz/openrig/pull/2115))
|
||||
- **A recorded principal is attribution, not authentication.** The daemon
|
||||
records what the MCP adapter tells it about the caller it checked.
|
||||
([#2098](https://github.com/mvschwarz/openrig/pull/2098))
|
||||
- **The task view (`qitem:<id>`) is membership only.** It doesn't follow a
|
||||
task as it moves, keep a reusable viewer, or change who may type into a
|
||||
seat. ([#2131](https://github.com/mvschwarz/openrig/pull/2131))
|
||||
- **Resuming a reviewed history has no CLI yet.** `rig seat history` lists
|
||||
and checks; applying a choice is through the daemon's startup review, for
|
||||
clients. Choices are limited to this node's eligible Claude and Codex
|
||||
histories in the current compatible snapshot.
|
||||
([#2100](https://github.com/mvschwarz/openrig/pull/2100),
|
||||
[#2109](https://github.com/mvschwarz/openrig/pull/2109))
|
||||
- **The kernel's restore evidence lasts one daemon process.** If the first
|
||||
daemon after a reboot exits before the kernel's restore starts a seat, the
|
||||
next start finds it already down and leaves it down, saying so. Bring it
|
||||
back with `rig up kernel --existing`. (0.7.3's issue of a long-gone kernel
|
||||
returning on daemon start is fixed by
|
||||
[#2146](https://github.com/mvschwarz/openrig/pull/2146).)
|
||||
- **OpenClaw's interrupted local turns are still not recovered.** The seat
|
||||
shows as needing attention, and a send submits a new turn: inspect the pane
|
||||
with `rig capture` first. For an unattended seat, use the Gateway preset.
|
||||
([#2092](https://github.com/mvschwarz/openrig/pull/2092))
|
||||
- **Whether processes a community harness starts outside its pane survive is
|
||||
still not exercised**, and `rig down` stops only the pane.
|
||||
([#2088](https://github.com/mvschwarz/openrig/pull/2088),
|
||||
[#2077](https://github.com/mvschwarz/openrig/pull/2077))
|
||||
- **Hermes:** as in 0.7.3, the example preset doesn't declare `resume`, and
|
||||
whether a seat's Hermes gateway exits once idle is unknown, although
|
||||
`docs/reference/community-harness-presets.md` says it exits. Check with `ps`
|
||||
before relying on it.
|
||||
- **A Codex seat's own permission settings** were enforced natively in 0.7.3's
|
||||
test with Codex 0.164.0; other Codex versions weren't tested.
|
||||
([#2048](https://github.com/mvschwarz/openrig/pull/2048))
|
||||
- **Upgrading doesn't restore Codex hook approvals that an earlier OpenRig
|
||||
removed.** If Codex asks for hook review, approve the hooks you mean to
|
||||
enable. ([#2035](https://github.com/mvschwarz/openrig/pull/2035))
|
||||
- **After the last managed session ends, an empty tmux server may stay
|
||||
running.** That alone doesn't mean an agent is still running.
|
||||
- **Slack threads from before 0.7.3** keep 0.7.3's limits: a reply on an ask
|
||||
parked before the fix can still reach the person asked, and a message
|
||||
received before 0.7.3 can still show OpenRig's routing footer, policy text
|
||||
and attachment paths when an agent asks a person about it.
|
||||
([#2119](https://github.com/mvschwarz/openrig/pull/2119),
|
||||
[#2124](https://github.com/mvschwarz/openrig/pull/2124))
|
||||
- **Shared channels are Slack only**, and the primary user's authority in the
|
||||
TUI is attribution, not authentication.
|
||||
([#1992](https://github.com/mvschwarz/openrig/pull/1992),
|
||||
[#2006](https://github.com/mvschwarz/openrig/pull/2006))
|
||||
- **The cmux path wasn't tried in a real cmux**, and a remote host's rigs are
|
||||
read over HTTP only. ([#2080](https://github.com/mvschwarz/openrig/pull/2080),
|
||||
[#2052](https://github.com/mvschwarz/openrig/pull/2052))
|
||||
- **Window sizing:** a browser that closes while the daemon is down leaves the
|
||||
window at the browser's size until the next web-terminal visit, and a
|
||||
`--readable` capture isn't atomic with a person's own edits.
|
||||
([#2065](https://github.com/mvschwarz/openrig/pull/2065),
|
||||
[#2075](https://github.com/mvschwarz/openrig/pull/2075))
|
||||
- **The runtime-switch handover change** hasn't been shown to cure the
|
||||
unsent-handover case that prompted it.
|
||||
([#2054](https://github.com/mvschwarz/openrig/pull/2054))
|
||||
- The community harness field keeps its name (`communityHarness`) and its
|
||||
original database update; renaming them would break a return to 0.7.2.
|
||||
- Not in this release: verified task viewers
|
||||
([#2133](https://github.com/mvschwarz/openrig/pull/2133)), switching a seat
|
||||
to an isolated account home
|
||||
([#2138](https://github.com/mvschwarz/openrig/issues/2138),
|
||||
[#2143](https://github.com/mvschwarz/openrig/pull/2143)), source evidence for
|
||||
resolving stuck findings
|
||||
([#2153](https://github.com/mvschwarz/openrig/pull/2153)), the stale-pane
|
||||
repair message ([#2157](https://github.com/mvschwarz/openrig/pull/2157)),
|
||||
current launch evidence for fresh handovers
|
||||
([#2159](https://github.com/mvschwarz/openrig/pull/2159)), and a warning for
|
||||
out-of-range MCP daemon ports
|
||||
([#2160](https://github.com/mvschwarz/openrig/pull/2160)). Hermes resume and
|
||||
measured idle exit, and OpenClaw interrupted-turn recovery, aren't in this
|
||||
release either. The limits listed for 0.7.3 still apply unless a change
|
||||
above names them fixed.
|
||||
|
||||
### Credits
|
||||
|
||||
Thank you to everyone whose work is in this release: @Aschenbath (#1908,
|
||||
#2101, #2113), @Nikhi00718 (#1863, #2016, #2106, #2107, #2108), @bradvin
|
||||
(#2100, #2109), @dajiaohuang (#2123) and @seanmcn (#2147, #2148, #2149). And
|
||||
thank you to @pr0xc3nt4ur1 (#1822), @duncanleung (#2130), @ziadeh (#2129),
|
||||
@alperta (#99), @smota (discussion #2117) and @oSquashBlossomo, whose reports
|
||||
and reviews led to fixes here.
|
||||
|
||||
## [0.7.3]
|
||||
|
||||
OpenRig 0.7.3 lets a team work with several people. You can register more than
|
||||
one person and choose a default, add a Slack shared channel whose members reach
|
||||
the team with each message labelled by who sent it, give organizations and
|
||||
groups a policy the agents can read, and see in the TUI's Feed whose queue you
|
||||
are looking at. Community harness authors get a written contract and tests, and
|
||||
an OpenClaw seat whose last local turn was refused now shows as needing
|
||||
attention. Codex seats can keep their own sandbox and approval settings, and
|
||||
Codex's own trust and config edits survive OpenRig's next launch. It is built
|
||||
from the commit tagged `v0.7.3`. The changes since 0.7.2:
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- **Restart the daemon after installing.** One database update applies on its
|
||||
first start: it adds a column for a Codex seat's own permission settings.
|
||||
```bash
|
||||
npm install -g @openrig/cli@0.7.3
|
||||
rig daemon stop && rig daemon start
|
||||
```
|
||||
- **Startup replaces OpenRig's own plugin and skill files** (openrig-core goes
|
||||
from 0.1.19 to 0.1.20; the `messaging-the-human` skill changed). As in 0.7.2,
|
||||
it first copies each file it replaces under
|
||||
`$OPENRIG_HOME/backups/managed-refresh/` and logs where, edited or not. If you
|
||||
did edit one, reapply your change from the copy. The shared `openrig-herdr`
|
||||
skill that seats can load also changed.
|
||||
- **If you register a second person, choose a default person** with
|
||||
`rig gateway default <id>`. With several people registered and no default, the
|
||||
last escalation step posts to nobody and the notice digest waits. An install
|
||||
with one person is unaffected.
|
||||
([#1992](https://github.com/mvschwarz/openrig/pull/1992))
|
||||
- **If a seat relied on inheriting the daemon's own environment**
|
||||
(`ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL` or `CLAUDE_CODE_USE_BEDROCK`, for
|
||||
example), declare the variable in the rig's or member's `env:`. The first seat
|
||||
on a new tmux server used to get the daemon's whole environment; it now gets
|
||||
the same scoped environment as every other seat.
|
||||
([#2047](https://github.com/mvschwarz/openrig/pull/2047))
|
||||
- **If you might go back to 0.7.2, know what it ignores.** 0.7.2 loads every
|
||||
registered person, but not the default person, shared channels,
|
||||
organizations, groups or policy kept in
|
||||
`$OPENRIG_HOME/gateway/participants.yaml`, and its guard against another
|
||||
person's reply is weaker. It keeps a Codex seat's recorded permission settings
|
||||
but doesn't read them, so its launches of that seat use the earlier posture,
|
||||
which can be broader.
|
||||
([#1992](https://github.com/mvschwarz/openrig/pull/1992),
|
||||
[#2048](https://github.com/mvschwarz/openrig/pull/2048))
|
||||
|
||||
### What's new and fixed
|
||||
|
||||
**Several people, shared channels and a default person**
|
||||
- More than one person can be registered: `rig gateway human add <id>
|
||||
--display-name <name> --binding …` now works for a second and third person.
|
||||
Choose the default with `rig gateway default <id>` (or `--default` on
|
||||
`human add`; `--clear` removes it). The default, or the only registered
|
||||
person, receives the last escalation step, the notice digest, Mission
|
||||
Control's my-queue view, workflow fallbacks and pairing approvals. For a workflow's
|
||||
human step, `workspace.operator_seat_name` is still read first. Everything new
|
||||
lives in `$OPENRIG_HOME/gateway/participants.yaml`, and each person's existing
|
||||
file keeps the keys an older daemon reads.
|
||||
([#1992](https://github.com/mvschwarz/openrig/pull/1992))
|
||||
- **Slack shared channels.** `rig gateway channel add <id> --display-name
|
||||
<name> --binding slack:<connectorRef>:<secretsRef>:<channelId>` registers a
|
||||
Slack channel as one participant, `<id>@external`. Anyone writing in that
|
||||
channel reaches the team, whether or not they are a registered person and
|
||||
even when nobody is registered. Each row ends with a block OpenRig adds after
|
||||
the message: the channel, its organization and groups, who sent it (a
|
||||
registered person, or a Slack user who isn't one) and the policy that applies
|
||||
(cut at 6,000 characters; the full text is in `rig gateway participants
|
||||
--json`). ([#1992](https://github.com/mvschwarz/openrig/pull/1992),
|
||||
[#1998](https://github.com/mvschwarz/openrig/pull/1998))
|
||||
- A row sent to a shared channel is posted as a notice, and `--reply-to`
|
||||
answers in the sender's Slack thread. An ask to a channel is accepted with an
|
||||
advisory that nothing there will answer it, and its questions show as a plain
|
||||
list. A channel's messages never answer, close or resume an ask, and a channel
|
||||
never acts on a row. `rig gateway channel remove <id>` refuses while open rows
|
||||
are addressed to it, unless you pass `--force`.
|
||||
([#1998](https://github.com/mvschwarz/openrig/pull/1998),
|
||||
[#2068](https://github.com/mvschwarz/openrig/pull/2068))
|
||||
- **Organizations, groups and readable policy.** `rig gateway org set <id>
|
||||
--name <name> [--policy <text> | --policy-file <path>]` and `rig gateway group
|
||||
set <id> --organization <id> --name <name> [--policy …]`. Policy on an
|
||||
organization, group, person or channel is context for the agents' judgment:
|
||||
`rig gateway participants [--organization <id>] [--json]` and `rig gateway
|
||||
human show --json` list each line with where it comes from. Nothing enforces
|
||||
it. ([#1992](https://github.com/mvschwarz/openrig/pull/1992))
|
||||
- **Whose queue the Feed shows.** The TUI's `whose <person|all|default>` command
|
||||
chooses it, and the Feed's first line says whose queue it is and who acts.
|
||||
With a default person, the TUI opens on their Feed. Answering, approving or
|
||||
resolving someone else's ask from the TUI records the primary user (the
|
||||
default person, or the only one registered) as acting, and the note keeps the
|
||||
person who was asked. This is attribution on a trusted network, not a login.
|
||||
([#2006](https://github.com/mvschwarz/openrig/pull/2006))
|
||||
- A park addressed to a registered person (`<id>@external`) can be resolved.
|
||||
([#2061](https://github.com/mvschwarz/openrig/pull/2061))
|
||||
- `rig slack post-open-asks` posts the asks that are still open, for example
|
||||
after `rig slack enable`, which now points to it when there are some.
|
||||
([#2086](https://github.com/mvschwarz/openrig/pull/2086), suggested by
|
||||
@rakoort in [#1679](https://github.com/mvschwarz/openrig/issues/1679))
|
||||
- The reply hint on a person's message now carries `--reply-to`, so an agent
|
||||
following it answers in the person's Slack thread. This was a known issue in
|
||||
0.7.2. ([#2055](https://github.com/mvschwarz/openrig/pull/2055))
|
||||
|
||||
**Community harnesses**
|
||||
- `docs/reference/community-harness-presets.md` says what a community harness
|
||||
gets from OpenRig and what it doesn't (no turn-cancel verb, no approvals
|
||||
reaching OpenRig), how to write one in five steps, and how promotion is
|
||||
judged. Tests now cover the coordination and resume contract.
|
||||
([#2078](https://github.com/mvschwarz/openrig/pull/2078),
|
||||
[#2088](https://github.com/mvschwarz/openrig/pull/2088))
|
||||
- **OpenClaw:** when OpenClaw refuses its last local turn ("Session … changed
|
||||
while starting work. Retry."), `rig ps`, the seat list and the TUI show the
|
||||
seat as needing attention (`OPENCLAW_TURN_NOT_RECOVERED`), and a send to it
|
||||
says it submits a new turn. OpenRig doesn't recover the refused turn (see
|
||||
Known issues). ([#2092](https://github.com/mvschwarz/openrig/pull/2092))
|
||||
- `rig down` names the community harness seats whose own processes may keep
|
||||
running after their pane stops (`Pane stopped: …`, and `paneOnly` in
|
||||
`--json`). ([#2077](https://github.com/mvschwarz/openrig/pull/2077))
|
||||
- **Hermes:** the example's docs now say a Hermes gateway runs per Hermes home,
|
||||
so one per seat with the example launcher. A two-seat run showed separate
|
||||
gateways, homes and sockets, and stopping one seat left the other running.
|
||||
([#2097](https://github.com/mvschwarz/openrig/pull/2097))
|
||||
|
||||
**Codex seats**
|
||||
- A Codex seat can keep its own sandbox and approval settings: `rig seat
|
||||
set-permissions <seat> --codex-permissions <file>` (`sandbox`, `approval`, and
|
||||
for `workspace-write` also `network_access`, `writable_roots`,
|
||||
`exclude_tmpdir_env_var` and `exclude_slash_tmp`), or `codex_permissions` in
|
||||
the spec. `--codex-profile <name|inherit>` sets an existing seat's Codex
|
||||
profile. They're passed as explicit flags on every managed launch, resume,
|
||||
restore and handover; seats without either launch as before.
|
||||
([#2048](https://github.com/mvschwarz/openrig/pull/2048))
|
||||
- A launch finds the trust tables Codex saves with single-quoted keys, instead
|
||||
of adding a duplicate that stopped every Codex session on the machine from
|
||||
starting. ([#2034](https://github.com/mvschwarz/openrig/pull/2034), reported
|
||||
by @borfast in [#2029](https://github.com/mvschwarz/openrig/issues/2029))
|
||||
- After "Trust all" in Codex, the trust survives OpenRig's next launch, and
|
||||
OpenRig no longer adds its hook groups again when Codex has moved them.
|
||||
([#2035](https://github.com/mvschwarz/openrig/pull/2035))
|
||||
- OpenRig finds its own Codex config tables wherever Codex moved them, keeps a
|
||||
table of yours that Codex printed between its markers, and refuses a write
|
||||
that would change anything beyond its own tables.
|
||||
([#2046](https://github.com/mvschwarz/openrig/pull/2046))
|
||||
- A Codex resume command now puts the conversation id right after `resume`, so
|
||||
a directory with a space in it can't be read as the id.
|
||||
([#2038](https://github.com/mvschwarz/openrig/pull/2038))
|
||||
- A verified Codex seat started under a shell or launcher gets its generation
|
||||
record; a real conversation mismatch is refused with its own message.
|
||||
([#2084](https://github.com/mvschwarz/openrig/pull/2084) by @Aschenbath,
|
||||
diagnosed by @fsafey in [#1799](https://github.com/mvschwarz/openrig/issues/1799);
|
||||
[#2094](https://github.com/mvschwarz/openrig/pull/2094))
|
||||
|
||||
**Terminals, capture and the TUI**
|
||||
- `rig capture --readable` (120x40) or `--size <WxH>` reads a small seat at a
|
||||
readable size and then gives its window back. `--json` adds `readable`.
|
||||
([#2075](https://github.com/mvschwarz/openrig/pull/2075))
|
||||
- After someone watches a seat in the web terminal, or follows capture's resize
|
||||
hint, the window gets its own size and sizing mode back.
|
||||
([#2065](https://github.com/mvschwarz/openrig/pull/2065))
|
||||
- A TUI running inside cmux opens terminal views in cmux, and a
|
||||
`cmux_unavailable` answer names cmux's automation setting.
|
||||
([#2080](https://github.com/mvschwarz/openrig/pull/2080), reported and traced
|
||||
by @R14014 in [#1069](https://github.com/mvschwarz/openrig/issues/1069))
|
||||
- The TUI keeps a registered remote host in the topology tree on every tab, and
|
||||
drilling in lists its rigs.
|
||||
([#2052](https://github.com/mvschwarz/openrig/pull/2052), reported by
|
||||
@AdamWhitehurst in [#2049](https://github.com/mvschwarz/openrig/issues/2049))
|
||||
- `rig setup` and `rig doctor` check that tmux can capture a pane, on a
|
||||
throwaway server. No tmux version is refused.
|
||||
([#2081](https://github.com/mvschwarz/openrig/pull/2081), from
|
||||
[#980](https://github.com/mvschwarz/openrig/issues/980) by @MichelMabinuola)
|
||||
- New seats keep tmux's `TERM_PROGRAM` and `TERM_PROGRAM_VERSION`, which Claude
|
||||
Code uses for Shift+Enter and image paste in tmux, and the first seat on a new
|
||||
tmux server gets the scoped environment too.
|
||||
([#2043](https://github.com/mvschwarz/openrig/pull/2043),
|
||||
[#2047](https://github.com/mvschwarz/openrig/pull/2047), reported by
|
||||
@AdamWhitehurst in [#2041](https://github.com/mvschwarz/openrig/issues/2041))
|
||||
|
||||
**Sending and the queue**
|
||||
- `rig send --wait-for-idle` longer than 300 seconds no longer fails at 300
|
||||
seconds, and `rig skill loadout --apply --wait-for-idle` waits as long as asked.
|
||||
([#2020](https://github.com/mvschwarz/openrig/pull/2020),
|
||||
[#2024](https://github.com/mvschwarz/openrig/pull/2024))
|
||||
- A local `rig send` whose connection drops says delivery is unconfirmed,
|
||||
not that the message wasn't sent.
|
||||
([#2060](https://github.com/mvschwarz/openrig/pull/2060))
|
||||
- Claude's "Resume session" picker reads as waiting for a person, and a send
|
||||
there refuses with `target_needs_input` instead of typing into its search box.
|
||||
([#2040](https://github.com/mvschwarz/openrig/pull/2040))
|
||||
- `rig workflow project` past an unanswered human gate says, in its advisories
|
||||
and the transition note, that it doesn't record the person's consent, and the
|
||||
`messaging-the-human` skill says a response isn't consent.
|
||||
([#2093](https://github.com/mvschwarz/openrig/pull/2093), refs
|
||||
[#1710](https://github.com/mvschwarz/openrig/issues/1710) by @george661)
|
||||
- The delivery digest says why it sent nothing.
|
||||
([#2063](https://github.com/mvschwarz/openrig/pull/2063) by @Coder8124, part
|
||||
of [#800](https://github.com/mvschwarz/openrig/issues/800) by @d3fvxl;
|
||||
[#2066](https://github.com/mvschwarz/openrig/pull/2066))
|
||||
- `rig config set feed.subscriptions.<host>.enabled` accepts a host id with
|
||||
dots. ([#2062](https://github.com/mvschwarz/openrig/pull/2062), reported by
|
||||
@AdamWhitehurst in [#2057](https://github.com/mvschwarz/openrig/issues/2057))
|
||||
|
||||
**Seats, launch, export and workspaces**
|
||||
- A seat's saved settings (permissions, policy, model, effort) can be targeted
|
||||
by the node id a client showed; if that node is gone, a same-named
|
||||
replacement isn't chosen in its place. `docs/reference/seat-node-targeting.md`
|
||||
describes it. ([#2051](https://github.com/mvschwarz/openrig/pull/2051) by
|
||||
@bradvin, the first part of
|
||||
[#1837](https://github.com/mvschwarz/openrig/issues/1837))
|
||||
- A runtime switch's handover to Claude Code waits for Claude's input box, and
|
||||
sends Enter again while the handover is still in it.
|
||||
([#2054](https://github.com/mvschwarz/openrig/pull/2054))
|
||||
- `rig export` keeps each pod's startup files.
|
||||
([#2087](https://github.com/mvschwarz/openrig/pull/2087) by @Aschenbath)
|
||||
- Applying a pod, launching a member or retrying a first start resolves launch
|
||||
profiles off the daemon's request thread.
|
||||
([#2067](https://github.com/mvschwarz/openrig/pull/2067) by @Aschenbath, refs
|
||||
[#1653](https://github.com/mvschwarz/openrig/issues/1653) by @VltrnOne)
|
||||
- `rig scope --workspace <path> mission create` creates the first mission in
|
||||
that workspace, and refuses a path that doesn't exist; `slice create` no
|
||||
longer falls back to the global missions root.
|
||||
([#1093](https://github.com/mvschwarz/openrig/pull/1093) by @Coder8124,
|
||||
reported by @kainne44 in [#70](https://github.com/mvschwarz/openrig/issues/70))
|
||||
- The execution view skips pickup history it doesn't use for blocked rows, and
|
||||
large candidate reads let other daemon requests run in between. No overall
|
||||
speed-up is claimed. ([#2042](https://github.com/mvschwarz/openrig/pull/2042),
|
||||
[#2039](https://github.com/mvschwarz/openrig/pull/2039))
|
||||
|
||||
**Messages and docs**
|
||||
- A Slack channel-map change says to restart the daemon, not to disable and
|
||||
enable Slack, which would mark waiting alerts as already posted.
|
||||
([#2072](https://github.com/mvschwarz/openrig/pull/2072))
|
||||
- A queue verb run outside a managed seat says to set `OPENRIG_SESSION_NAME`,
|
||||
instead of naming a flag it ignores.
|
||||
([#2074](https://github.com/mvschwarz/openrig/pull/2074))
|
||||
- New or corrected docs: what a seat is set to versus what it runs, and a
|
||||
newcomer's reading order
|
||||
([#2079](https://github.com/mvschwarz/openrig/pull/2079)); an exact resume
|
||||
delivers no startup actions, and a managed Claude seat keeps its session name
|
||||
([#2085](https://github.com/mvschwarz/openrig/pull/2085)); separate work goes
|
||||
in a queue row, and `--wait-for-idle` sends nothing if the wait ends busy
|
||||
([#2089](https://github.com/mvschwarz/openrig/pull/2089)); the A2A walkthrough
|
||||
([#2082](https://github.com/mvschwarz/openrig/pull/2082)); one continuing
|
||||
Slack conversation keeps its earlier context, whoever writes
|
||||
([#2083](https://github.com/mvschwarz/openrig/pull/2083)); two cases where
|
||||
Herdr reads a waiting seat as idle
|
||||
([#2056](https://github.com/mvschwarz/openrig/pull/2056)); the account and
|
||||
permission choices in the CLI reference
|
||||
([#2073](https://github.com/mvschwarz/openrig/pull/2073)); and which scope
|
||||
commands refuse a missing `--workspace` tree
|
||||
([#2064](https://github.com/mvschwarz/openrig/pull/2064)).
|
||||
- For contributors: mocked startup tests no longer wait in real time
|
||||
([#2091](https://github.com/mvschwarz/openrig/pull/2091) by @sn1thy), the
|
||||
first-run reference check works with Windows paths
|
||||
([#2050](https://github.com/mvschwarz/openrig/pull/2050) by @DeryFerd), and
|
||||
test cleanup waits for private tmux children
|
||||
([#2070](https://github.com/mvschwarz/openrig/pull/2070)).
|
||||
|
||||
### Behaviour that changed
|
||||
|
||||
- A registered person can't settle an ask that was asked of someone else: it's
|
||||
refused with `answer_not_recorded`. Using a terminal no longer overrides a
|
||||
different registered person's identity; from the TUI, the primary user can.
|
||||
([#2061](https://github.com/mvschwarz/openrig/pull/2061),
|
||||
[#2006](https://github.com/mvschwarz/openrig/pull/2006))
|
||||
- A shared channel is refused for every action on a row, not only for answers
|
||||
and approvals, and an act on a channel's ask is recorded as the person at the
|
||||
terminal. ([#2068](https://github.com/mvschwarz/openrig/pull/2068))
|
||||
- With several people registered and no default, the last escalation step reads
|
||||
`undeliverable:no-default-person`, and the notice digest waits until a default
|
||||
is chosen. ([#1992](https://github.com/mvschwarz/openrig/pull/1992))
|
||||
- The first seat on a new tmux server no longer inherits the daemon's whole
|
||||
environment. ([#2047](https://github.com/mvschwarz/openrig/pull/2047))
|
||||
- `rig scope --workspace <path> mission create` refuses a path that doesn't
|
||||
exist instead of creating it.
|
||||
([#1093](https://github.com/mvschwarz/openrig/pull/1093))
|
||||
- A send to a Claude seat at its "Resume session" picker refuses
|
||||
(`target_needs_input`), and startup there reports `attention_required` at
|
||||
once instead of waiting out its timeout.
|
||||
([#2040](https://github.com/mvschwarz/openrig/pull/2040))
|
||||
- A Codex resume command reads `codex resume <id> --add-dir …`; older process
|
||||
lines are still read. ([#2038](https://github.com/mvschwarz/openrig/pull/2038))
|
||||
- `rig capture`'s small-pane hint points to `--readable`.
|
||||
([#2075](https://github.com/mvschwarz/openrig/pull/2075))
|
||||
- In the execution view, a blocked row no longer carries a last-activity time.
|
||||
([#2042](https://github.com/mvschwarz/openrig/pull/2042))
|
||||
|
||||
### Known issues
|
||||
|
||||
- **OpenClaw's interrupted local turns are not recovered.** OpenRig shows the
|
||||
seat as needing attention and a send submits a new turn, but it doesn't
|
||||
repair or retry the refused one: inspect the pane with `rig capture` first.
|
||||
Recognition covers OpenClaw 2026.10.6's local layout with its complete
|
||||
two-rule editor; a cropped, changed or ambiguous layout isn't recognised, and
|
||||
any other full-width rule in the captured lines leaves a real refusal reading
|
||||
unknown. For an unattended seat, use the Gateway preset
|
||||
(`openclaw-gateway.yaml`), within what that preset declares. This was checked
|
||||
against a retained OpenClaw screen, not yet against a running OpenClaw.
|
||||
([#2092](https://github.com/mvschwarz/openrig/pull/2092))
|
||||
- **Whether processes a community harness starts outside its pane survive is
|
||||
not exercised** by the contract tests. `rig down` stops only the pane.
|
||||
([#2088](https://github.com/mvschwarz/openrig/pull/2088),
|
||||
[#2077](https://github.com/mvschwarz/openrig/pull/2077))
|
||||
- **Hermes:** the example preset doesn't declare `resume`, because reopening a
|
||||
named session (and a missing one) hasn't been checked with a real Hermes. The
|
||||
remaining real-Hermes checks weren't run, whether a seat's Hermes gateway
|
||||
exits once idle is unknown, and the pane-only stop hasn't been checked again
|
||||
on 0.7.3. ([#2077](https://github.com/mvschwarz/openrig/pull/2077),
|
||||
[#2097](https://github.com/mvschwarz/openrig/pull/2097))
|
||||
- **`docs/reference/community-harness-presets.md` says a seat's Hermes gateway
|
||||
exits once idle** ("Stopping"). That hasn't been checked: whether it exits is
|
||||
unknown. Check with `ps` that a stopped seat's gateway has gone before relying
|
||||
on it.
|
||||
- **Shared channels are Slack only.** A binding must be `slack:…`. If
|
||||
`participants.yaml` can't be read, no shared channel is known: no channel
|
||||
sender is admitted, and `rig slack post-open-asks` can miss asks to channels.
|
||||
([#1992](https://github.com/mvschwarz/openrig/pull/1992),
|
||||
[#1998](https://github.com/mvschwarz/openrig/pull/1998),
|
||||
[#2086](https://github.com/mvschwarz/openrig/pull/2086))
|
||||
- **The primary user's authority is attribution, not authentication.** Only a
|
||||
current TUI sends it (a request without a seat identity); an older TUI's
|
||||
answer on someone else's ask is refused.
|
||||
([#2006](https://github.com/mvschwarz/openrig/pull/2006))
|
||||
- **A Codex seat's permission settings are passed as launch flags; that Codex
|
||||
enforces them was not proven natively.** Going back to an older OpenRig keeps
|
||||
them unread (see Before you upgrade). The Codex trust fixes stop new duplicate
|
||||
hook groups but don't remove existing ones.
|
||||
([#2048](https://github.com/mvschwarz/openrig/pull/2048),
|
||||
[#2035](https://github.com/mvschwarz/openrig/pull/2035))
|
||||
- **The cmux path wasn't tried in a real cmux**, and a remote host's rigs are
|
||||
read over HTTP only: an SSH host stays listed, but drilling in can't read it.
|
||||
([#2080](https://github.com/mvschwarz/openrig/pull/2080),
|
||||
[#2052](https://github.com/mvschwarz/openrig/pull/2052))
|
||||
- **`rig` or `rig tui` started in a terminal that reports 0 rows or columns
|
||||
crashes on its first frame** (a detached, headless or just-resizing pane).
|
||||
It's present since 0.7.2 and fixed on main by
|
||||
[#2099](https://github.com/mvschwarz/openrig/pull/2099) for the next release.
|
||||
Start the TUI in a terminal with a size.
|
||||
- **Window sizing:** a browser that closes while the daemon is down leaves the
|
||||
window at the browser's size until the next web-terminal visit, and a
|
||||
`--readable` capture isn't atomic with a person's own edits to the window.
|
||||
([#2065](https://github.com/mvschwarz/openrig/pull/2065),
|
||||
[#2075](https://github.com/mvschwarz/openrig/pull/2075))
|
||||
- **The runtime-switch handover change** hasn't been shown to cure the
|
||||
unsent-handover case that prompted it.
|
||||
([#2054](https://github.com/mvschwarz/openrig/pull/2054))
|
||||
- The community harness field keeps its name (`communityHarness`) and its
|
||||
original database update; renaming them would break a same-database return
|
||||
to 0.7.2.
|
||||
- Not in this release: per-instance tmux isolation
|
||||
([#1260](https://github.com/mvschwarz/openrig/pull/1260)), the read-only
|
||||
client submit retry ([#2090](https://github.com/mvschwarz/openrig/pull/2090)),
|
||||
the prime-agent runtime
|
||||
([#2069](https://github.com/mvschwarz/openrig/pull/2069)), hosted members on
|
||||
a team ([#2095](https://github.com/mvschwarz/openrig/pull/2095)) and grants
|
||||
from an external grant service for `rig mcp serve --http`
|
||||
([#2096](https://github.com/mvschwarz/openrig/pull/2096)). The limits listed
|
||||
for 0.7.2 still apply unless a change above names them fixed.
|
||||
|
||||
### Credits
|
||||
|
||||
Thank you to everyone whose work is in this release: @Aschenbath, @Coder8124,
|
||||
@bradvin, @sn1thy and @DeryFerd.
|
||||
|
||||
Thanks also to @AdamWhitehurst, who reported
|
||||
[#2041](https://github.com/mvschwarz/openrig/issues/2041) (addressed by
|
||||
[#2043](https://github.com/mvschwarz/openrig/pull/2043) and
|
||||
[#2047](https://github.com/mvschwarz/openrig/pull/2047)),
|
||||
[#2049](https://github.com/mvschwarz/openrig/issues/2049) (addressed by
|
||||
[#2052](https://github.com/mvschwarz/openrig/pull/2052)) and
|
||||
[#2057](https://github.com/mvschwarz/openrig/issues/2057) (addressed by
|
||||
[#2062](https://github.com/mvschwarz/openrig/pull/2062)); @borfast, who reported
|
||||
[#2029](https://github.com/mvschwarz/openrig/issues/2029) (addressed by
|
||||
[#2034](https://github.com/mvschwarz/openrig/pull/2034)) and the repeated trust
|
||||
prompt in [#17](https://github.com/mvschwarz/openrig/issues/17), which @jimallen
|
||||
opened (addressed further by
|
||||
[#2035](https://github.com/mvschwarz/openrig/pull/2035)); @R14014, who reported
|
||||
[#1069](https://github.com/mvschwarz/openrig/issues/1069) and traced it through
|
||||
the code (partly addressed by
|
||||
[#2080](https://github.com/mvschwarz/openrig/pull/2080)); @kainne44, who
|
||||
reported [#70](https://github.com/mvschwarz/openrig/issues/70) (addressed by
|
||||
[#1093](https://github.com/mvschwarz/openrig/pull/1093)); @VltrnOne, who
|
||||
reported [#1653](https://github.com/mvschwarz/openrig/issues/1653) (worked on in
|
||||
[#2067](https://github.com/mvschwarz/openrig/pull/2067)); @d3fvxl, who reported
|
||||
[#800](https://github.com/mvschwarz/openrig/issues/800) (part addressed by
|
||||
[#2063](https://github.com/mvschwarz/openrig/pull/2063)); @fsafey, who reported and diagnosed
|
||||
[#1799](https://github.com/mvschwarz/openrig/issues/1799) (addressed by
|
||||
[#2084](https://github.com/mvschwarz/openrig/pull/2084)); @MichelMabinuola, who
|
||||
reported [#980](https://github.com/mvschwarz/openrig/issues/980) (addressed by
|
||||
[#2081](https://github.com/mvschwarz/openrig/pull/2081)); @rakoort, whose
|
||||
suggestion in [#1679](https://github.com/mvschwarz/openrig/issues/1679) became
|
||||
[#2086](https://github.com/mvschwarz/openrig/pull/2086) (the issue's main
|
||||
report is still open); @george661, whose
|
||||
[#1710](https://github.com/mvschwarz/openrig/issues/1710) led to
|
||||
[#2093](https://github.com/mvschwarz/openrig/pull/2093).
|
||||
|
||||
---
|
||||
|
||||
## [0.7.2] - 2026-10-25
|
||||
|
||||
Published as `@openrig/[email protected]`, built from the commit tagged `v0.7.2`
|
||||
(`8b180689`). This entry carries the corrected public notes from the
|
||||
[GitHub Release](https://github.com/mvschwarz/openrig/releases/tag/v0.7.2).
|
||||
## [0.7.2]
|
||||
|
||||
OpenRig 0.7.2 gives each seat more of its own setup. A seat can run on its own
|
||||
Claude or Codex account, move to another login or to the other runtime without
|
||||
@@ -1045,7 +307,8 @@ and fork launches enforce them. See Known issues for what isn't claimed.
|
||||
([#2014](https://github.com/mvschwarz/openrig/pull/2014), @Nikhi00718); the
|
||||
web dashboard reads a skipped kernel boot as "Boot not tracked", not
|
||||
"Unknown" ([#1917](https://github.com/mvschwarz/openrig/pull/1917),
|
||||
@Aschenbath, reported by @Kero-66); the compaction-and-restore scenario runs through a launch script, and the test-system docs state the CI job's real counts
|
||||
@Aschenbath, reported by @Kero-66); the scenario runner binds compaction and
|
||||
restore, and the test-system docs state the CI job's real counts
|
||||
([#1924](https://github.com/mvschwarz/openrig/pull/1924),
|
||||
[#1925](https://github.com/mvschwarz/openrig/pull/1925), @DeryFerd).
|
||||
- Guidance: the installer guide says what setup and starting Codex seats write
|
||||
@@ -1165,10 +428,7 @@ and fork launches enforce them. See Known issues for what isn't claimed.
|
||||
switch starts a new native conversation (only the handover carries over),
|
||||
clears the source runtime's model, effort and Codex profile, and doesn't apply
|
||||
Claude launch additions such as an agent file or run startup slash commands.
|
||||
Selected separate-home restart/restore and useful conversation-continuity
|
||||
cases passed. Runtime switching worked in both directions in a minimal setup;
|
||||
one direction required an extra Enter. See Verification and remaining limits
|
||||
below for the untested paths.
|
||||
None of these was run with real accounts before this release.
|
||||
- **A new Claude agent's startup text can stay unsent in its input box.** Press
|
||||
Enter in its terminal.
|
||||
- **Claude seats on a macOS native install can read as needing attention after a
|
||||
@@ -1322,22 +582,6 @@ and @haichongfu and @vadym-abstract-factory, who reported
|
||||
[#14](https://github.com/mvschwarz/openrig/issues/14) (worked on again in
|
||||
[#1921](https://github.com/mvschwarz/openrig/pull/1921); still open).
|
||||
|
||||
Thanks also to @reco for the runtime-switch request in [#589](https://github.com/mvschwarz/openrig/issues/589); @pr0xc3nt4ur1 for the edge-preservation and update case in [#1193](https://github.com/mvschwarz/openrig/issues/1193); and @m3ac-AllbrittenJ and @lev-gpt for the stall-reporting and age evidence in [#331](https://github.com/mvschwarz/openrig/issues/331).
|
||||
|
||||
## Verification and remaining limits
|
||||
|
||||
This release's selected checks found no demonstrated regression against published 0.7.1 or data loss. This is not an all-paths pass. The sealed package, installed checks and a real 0.7.1 upgrade passed at their recorded scope; both first-user journeys completed.
|
||||
|
||||
- Slack replies can land outside the originating thread when an agent follows the existing reply hint without --reply-to. This round trip failed in testing. The hint predates 0.7.2; the later fix #2055 is not in this release.
|
||||
- Abrupt power-loss recovery on 0.7.2 was not tested: the fixture hit its disk-growth cap before its power cut. The 0.7.1 comparison passed data and native-conversation recovery, but its separate terminal-team restore was partial. That baseline is not proof of 0.7.2 recovery.
|
||||
- Native account, home and runtime coverage is partial. Selected separate-home restart/restore and useful conversation-continuity cases passed. Runtime switching worked in both directions in a minimal setup; one direction required an extra Enter. The full home matrix, a second Claude subscription token, same-token handover and stored-home-change cases, native switch edge cases and full-kernel coverage, some Slack button cases, and the right-side agents' useful answers after resume remain untested. Controlled restart cases do not substitute for the full native matrix.
|
||||
- The named-risk managed compaction and restore exercise completed with useful continuation after about 46 minutes of work, but the adopted Claude home had no OpenRig hooks. Marker, packet and trace paths that depend on those hooks were not exercised by that run.
|
||||
- Comparable release-level speed, resource-use and provider usage/charge proof remain unavailable. No across-the-board performance improvement is claimed.
|
||||
|
||||
The long-paste issue #14 remains open; the Codex model-picker limitation #2010 and ordinary Codex resume/restore with a space in a workspace or queue-state path remain. The fixed in-place restart path is not proof for ordinary restore. Existing Linux session-bus, Claude member-rule, Pi recognition and other documented limits still apply.
|
||||
|
||||
Hermes, Cursor's plug-in work and the Codex member-permission counterpart are not included. Follow-up coverage review is scheduled for 2026-10-26 at 12:00 UTC; that is a review date, not a promise that every gap will be closed.
|
||||
|
||||
## [0.7.1] - 2026-10-25
|
||||
|
||||
Published 2026-10-25 as `@openrig/[email protected]`, built from the commit tagged
|
||||
|
||||
@@ -26,9 +26,9 @@ use:
|
||||
|
||||
```sh
|
||||
# Claude Code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/skills/rigs --skill rigs --agent claude-code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.2/skills/rigs --skill rigs --agent claude-code
|
||||
# Codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/skills/rigs --skill rigs --agent codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.2/skills/rigs --skill rigs --agent codex
|
||||
```
|
||||
|
||||
Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what you want built. The skill has your agent tell
|
||||
@@ -68,11 +68,11 @@ Requires Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude Co
|
||||
|
||||
### Install it yourself
|
||||
|
||||
This runs OpenRig's install script from the `v0.7.4` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
|
||||
This runs OpenRig's install script from the `v0.7.2` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.2/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.2/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.
|
||||
|
||||
+15
-4
@@ -6,11 +6,22 @@ This isn't a schedule or a promise: we release often, and an item ships when a g
|
||||
To see what's being worked on, by release and theme, open the [OpenRig roadmap board](https://github.com/users/mvschwarz/projects/1).
|
||||
Issues labelled `good first issue` are also listed on the [contribute page](https://github.com/mvschwarz/openrig/contribute).
|
||||
|
||||
## Released in 0.7.4
|
||||
## Planned for 0.7.2
|
||||
|
||||
[0.7.4 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.4): a first, experimental step toward every
|
||||
assistant you use working on one team. See its release notes for what shipped, the experimental and security notes for
|
||||
hosted members, upgrade instructions, contributor credits and the remaining testing gaps.
|
||||
- **Outside assistants over MCP (if ready):** Assistants such as ChatGPT and Claude Desktop find your team, delegate
|
||||
work, follow it and get the results through a maintained MCP server, locally or over an authenticated network
|
||||
connection.
|
||||
- **More harnesses as seats (if ready):** One plug-in point for other coding-agent CLIs, with Hermes, OpenClaw and
|
||||
Cursor running as normal seats.
|
||||
- **Accounts (if ready):** Each seat can use its own provider account, and an agent can move one seat to another account
|
||||
and keep its conversation.
|
||||
- **Subscriptions (if ready):** One Fleet agent manages two registered Codex subscriptions across two Linux machines, following
|
||||
your standing preferences.
|
||||
- **Member permissions (if ready):** Each seat gets its own permission rules, even when seats share a project folder.
|
||||
- **Stream decisions (if ready):** Opt in, and observations on a rig's stream reach the work they belong to, with
|
||||
visible results and effects you can recover.
|
||||
- **Named people in Slack (if ready):** Agents can address several named people in one Slack workspace, and each person
|
||||
sees and answers their own requests.
|
||||
|
||||
## How to help
|
||||
|
||||
|
||||
@@ -281,34 +281,6 @@ resume launches (`codex-runtime-adapter.ts:378`, `codex-resume.ts:103`).
|
||||
`no_conversation_found` (`:429–434`) and with `recovery:
|
||||
"attention_required"` plus pane evidence on an `attention_required` probe
|
||||
(`:444–450`) — the adapter never relaunches on its own.
|
||||
- **Fresh in-place handover readiness.** `assessFreshInPlaceProbe` qualifies an
|
||||
otherwise ready history probe only for the matching fresh launch, binding and
|
||||
launch generation. The current pane must show a prompt, and `assessManagedProbe`
|
||||
must confirm the new launch's exact session token in the same foreground process
|
||||
across two observations, with the pane and launch still current. It reuses the
|
||||
existing managed identity proof, including its process fingerprint. A process
|
||||
label alone, old scrollback, an unavailable screen, an unreadable process list
|
||||
or an unidentified Claude process cannot admit the handover. Those cases stay
|
||||
`awaiting_runtime`; a positive identity conflict and visible approval or login
|
||||
gates keep their specific results. The handover waits within its existing
|
||||
readiness budget before sending the context packet.
|
||||
|
||||
The fresh path requires the last `❯` prompt to sit inside Claude's complete
|
||||
input frame: its top rule directly above the prompt and a bottom rule below.
|
||||
A shell's echoed launch command cannot borrow an earlier header or input
|
||||
frame, even when the matching Claude process is already running. A header
|
||||
and prompt alone, or a partially drawn frame, remain pending. This may delay
|
||||
readiness until the full frame is visible; other launch paths keep their
|
||||
existing screen predicates.
|
||||
|
||||
Previously, unavailable observations and ambiguous headerless prompts could
|
||||
retain the old ready result. `claude-fresh-in-place-scope.test.ts` now checks
|
||||
missing observations, unknown or changing process identity, retained UI above
|
||||
direct and script launch commands, partial frames, recovery and normal prompt
|
||||
controls. `claude-handover-readiness.test.ts` exercises the real handover
|
||||
service with migrated SQLite, modeled pane/process observations and one synthetic
|
||||
clock. Readiness and the later packet-submission receipt remain separate checks;
|
||||
these tests do not run a live Claude account.
|
||||
|
||||
### CodexRuntimeAdapter (`codex-runtime-adapter.ts:65`)
|
||||
|
||||
|
||||
@@ -119,37 +119,6 @@ them imports Hono: **0**
|
||||
- `startup-resolver.ts` — additive startup layering.
|
||||
- `projection-planner.ts` — runtime resource projection planning.
|
||||
|
||||
### Profile resolution during launch
|
||||
|
||||
Preflight already resolves profiles in `profile-resolution-worker.ts`. Previously,
|
||||
the pod-aware apply path, materialized-member launch and first-start retry called
|
||||
the synchronous resolver again on the daemon's request thread. A fallback skill
|
||||
scan or managed-catalog inspection could therefore delay every other request even
|
||||
after preflight had finished ([#1653](https://github.com/mvschwarz/openrig/issues/1653)).
|
||||
Those three launch paths now await the same resolver in a worker before projecting
|
||||
resources or starting the member. Configuration precedence and launch order still
|
||||
come from the existing resolver and instantiator.
|
||||
|
||||
An apply operation reuses one lazy worker across its members and terminates it on
|
||||
success or failure. Unlike preflight, launch supplies no `scanScope`: each member
|
||||
reads current files, including changes made by an earlier member's startup.
|
||||
Standalone member launches and first-start retries use a short-lived worker for
|
||||
their resolution. This adds worker startup and context-copy costs; it does not
|
||||
reuse the preflight result or cache configuration between requests. The helper's
|
||||
existing synchronous fallback for a deleted process working directory remains.
|
||||
|
||||
Worker errors become configuration-failure results. Apply can then follow the
|
||||
existing all-members-failed cleanup, including service rollback and restoration
|
||||
of replaced generations. Materialized launches report a failed member, and
|
||||
first-start retries refuse before writing retained state.
|
||||
|
||||
`profile-resolution-worker.test.ts` holds a real fallback scan open with a FIFO,
|
||||
requires an HTTP health response while that FIFO is still blocked, then checks
|
||||
that the selected skill reaches projection. It covers all three launch paths and
|
||||
a skill edit between members. These fixtures use SQLite and the real instantiator
|
||||
with a simulated runtime; they do not establish native provider behavior or
|
||||
responsiveness during other synchronous parts of startup.
|
||||
|
||||
## 4. Startup orchestration (the spec-startup contract)
|
||||
|
||||
`StartupOrchestrator.startNode` (`startup-orchestrator.ts:172`, a thin wrapper
|
||||
|
||||
@@ -178,58 +178,15 @@ the destination's orchestrator when one is derivable, otherwise to the
|
||||
destination itself, and that seat may be dead. Overdue claims go to their
|
||||
destination. Repeated detections reuse the existing finding without changing
|
||||
its route; when the source condition resolves, the sweep closes its own finding.
|
||||
Previously, falling out of a reporting query could itself close a delivery or
|
||||
unclaimed finding. An unconfirmed retry, a fresh human-notification post window,
|
||||
an unreadable human registry or a larger reporting-age setting could therefore
|
||||
look like successful recovery. These findings now require source evidence:
|
||||
closure, pickup or parking of the work, a verified/accepted wake, or a posted
|
||||
notification for the current human recipient and episode, as appropriate to the
|
||||
finding. `delivered-ack-pending`, `indeterminate:…`, missing results and unrelated
|
||||
legacy human receipts do not prove resolution. Uncertainty keeps an existing
|
||||
finding open without itself sending another wake or creating another finding.
|
||||
A verified wake resolves a wake failure, not an unclaimed obligation: delivery
|
||||
does not prove pickup.
|
||||
|
||||
A legacy human notice with no owner-notification history can still resolve from
|
||||
the existing item-scoped posted ledger when its recipient is confirmed human.
|
||||
This covers older pending asks and parks without inventing an episode that was
|
||||
never recorded. A registered alias needs a readable registry to establish its
|
||||
human recipient; reserved human references remain known during an outage.
|
||||
Legacy receipts carry no recipient key, so a destination reroute requires a new
|
||||
matching notification episode. A legacy park is accepted only when no earlier
|
||||
notice or reroute predates it, and later history contains only receipts or
|
||||
asking-agent reroutes within that park. A re-park, an exit from the park or an
|
||||
ambiguous append cannot establish the same recipient without owner history;
|
||||
the finding waits for a new matching episode or other source resolution.
|
||||
Rerouting only the asking agent within an otherwise unchanged park preserves
|
||||
its human recipient (`blockedOn`).
|
||||
Modern notifications still require their current episode, and an unrelated
|
||||
legacy human receipt cannot resolve an agent wake.
|
||||
|
||||
A known hold still leaves recovery with the existing mechanism: an ask on
|
||||
Steering without an alert, a wake held by the seat typing guard, or a live
|
||||
quiet-release job. Its closure note names that disposition rather than claiming
|
||||
delivery or pickup. An unreadable quiet hold is not positive closure evidence.
|
||||
Claimed work in either `pending` or `in-progress` uses the same pickup derivation;
|
||||
returning a row to `pending` while retaining its claim no longer hides a later
|
||||
stall or closes its existing stalled-claim finding. A retained failed wake cannot
|
||||
override that pickup evidence. The agent's claim still does not establish delivery
|
||||
of a separate notification to a person.
|
||||
|
||||
That automatic closure records `stuck-sweep@system` as the actor in both the
|
||||
transition and the `queue.updated` audit event, with the source evidence in the
|
||||
delivery and unclaimed-obligation closure notes. Previously it credited the finding's source agent
|
||||
with the detector's action. The finding's source,
|
||||
transition and the `queue.updated` audit event. Previously it credited the
|
||||
finding's source agent with the detector's action. The finding's source,
|
||||
destination and claim remain its existing ownership records; the underlying work
|
||||
is not reassigned, and existing transition history is not rewritten.
|
||||
|
||||
`queue-stuck-sweep-resolution.test.ts` checks unconfirmed retries, retained
|
||||
claims, current human-notification episodes and registry outages with migrated
|
||||
SQLite, including positive recovery controls and no repeated send on an
|
||||
unresolved retry. `quiet-hold-custody.test.ts` checks existing findings with
|
||||
readable and unreadable quiet custody. `queue-stuck-sweep.test.ts` checks that
|
||||
pending and claimed findings close once, name the sweep in their transition and
|
||||
event, and keep their ownership. The posted-human-notice case also checks the actor while leaving
|
||||
`queue-stuck-sweep.test.ts` checks this with migrated SQLite: pending and claimed
|
||||
findings close once, their transition and event name the sweep, and ownership
|
||||
stays intact. The posted-human-notice case also checks the actor while leaving
|
||||
the person's original ask open. These checks exercise the detector and queue
|
||||
repository, not a live agent or connector.
|
||||
|
||||
|
||||
@@ -73,26 +73,10 @@ All in `packages/daemon/src/domain/types.ts`. The spec/projection types live in
|
||||
first three; `not_attempted` is set when pre-restore validation fails
|
||||
(`:281`).
|
||||
- **SnapshotData** (`types.ts:351`) — the serialized snapshot payload. It has
|
||||
**8** optional fields, kept optional so older snapshots still load
|
||||
**7** optional fields, kept optional so older snapshots still load
|
||||
(`sed -n '/^export interface SnapshotData/,/^}/p' packages/daemon/src/domain/types.ts | grep -c '?:'`):
|
||||
`activeSessionIdByNode?`, `activeOccupantsByNode?`, `periodicResumeSourcesByNode?`,
|
||||
`topologyRoster?`, `pods?`, `continuityStates?`, `nodeStartupContext?`, `envReceipt?`.
|
||||
Periodic snapshots retain a down agent's whole authoritative resume-source
|
||||
session in `periodicResumeSourcesByNode`, without marking it running. A named
|
||||
single-agent launch using automatic periodic selection can resume that exact
|
||||
carried source; an explicit snapshot or whole-rig restore still asks about an
|
||||
absent agent with resumable history. Tokenless and non-resume-policy agents
|
||||
keep their existing fresh path. Only the node's newest session with a token
|
||||
and `resume_if_possible` policy is carried; a current fresh-successor relation
|
||||
must agree. Named launch rechecks the live newest session, resume policy and
|
||||
fresh-successor relation under its lifecycle lease, before changing any seat
|
||||
state; a source retired after capture stops and asks without launching.
|
||||
Idle-shell carry advice uses automatic selection again, not an
|
||||
explicit snapshot that disables carry. The selected source is shared by preflight and post-launch
|
||||
restore; explicit reviewed-history selection keeps its existing checks.
|
||||
Ranking, periodic cadence and `--fresh` are unchanged. Retention is best-effort
|
||||
if the earlier-source lookup fails: capture still records current crash
|
||||
insurance and logs that the resume sources were unavailable.
|
||||
`activeSessionIdByNode?`, `activeOccupantsByNode?`, `topologyRoster?`,
|
||||
`pods?`, `continuityStates?`, `nodeStartupContext?`, `envReceipt?`.
|
||||
- **NodeStartupSnapshot** (`types.ts:344`) — persisted restore replay input:
|
||||
classification-free projection entries, resolved startup files, startup
|
||||
actions, runtime.
|
||||
@@ -405,20 +389,6 @@ daemon's `/api/transcripts/<session>/full`, `:156`), `read <packet-dir> [--json]
|
||||
cross-runtime v0 standard — Claude Code and Codex transcripts both supported
|
||||
via runtime parsers + redaction (`packages/cli/src/restore-packet/`).
|
||||
|
||||
### Shared history inspection
|
||||
|
||||
`GET /api/startup/:rigId/nodes/:nodeId/history` lists captured histories for one durable node in the current, policy-ranked restore snapshot. `POST` to the same path plus `/validate` with exactly `{ "snapshotId": "…", "historySessionId": "…" }` rechecks one listed choice. Both routes use startup's terminal bearer authentication. The CLI exposes the same server decisions with `rig seat history --rig <rig-id> --node-id <node-id> --json`; pass both `--snapshot-id` and `--history-session-id` to validate. These calls perform no snapshot capture, token refresh, launch, or database write.
|
||||
|
||||
The list includes captured Claude and Codex rows that the current restore policy and native resume adapter can structurally attempt. Codex requires an explicit `codex_id` and token; implicit `codex_last` cannot name a particular history. Claude accepts `claude_id` and `claude_name` with tokens. The server checks exact rig and node membership, the snapshot's intended roster and runtime, and the node's current occupant compatibility. A change to a sibling node does not retarget or invalidate this node's list. A changed or pruned snapshot, removed history row, same-name replacement node, ambiguous current occupant, or incompatible source is refused; validation never substitutes another history.
|
||||
|
||||
`tokenState` (`present`, `stale`, or `unverified` for listed rows) describes the saved verification record and its age. Eligibility means a future launch may **attempt** native resume. A stored row can share a native token with another row, and neither listing nor validation proves that a native conversation still exists.
|
||||
|
||||
### Selected history startup
|
||||
|
||||
`GET /api/startup/:rigId/nodes/:nodeId?snapshotId=<listed-id>&historySessionId=<listed-id>` returns a read-only review for one listed choice: the selected row's forecast, current terminal observation, consumed launch setting sources (including a member permission envelope or Claude rules), and an opaque revision. The permission view uses restore's pure policy resolution: a readable custom policy's current launch posture can supersede its persisted cache, and a changed resolved decision requires re-review. It refuses a captured row whose native type/token, provenance, restore policy, or harness no longer agrees with the live registry row. The response contains no native token. `POST /api/startup/:rigId/nodes/:nodeId` accepts exactly `{ "action": "resume", "snapshotId": "…", "historySessionId": "…", "revision": "…" }`. Both routes require startup's terminal bearer token. The selected route accepts no fresh, continue, arbitrary token, or alternate target. Review does not capture a snapshot or prepare a provider. `rig seat history` also uses a strictly read-only daemon status check, so inspection does not remove stale daemon state.
|
||||
|
||||
Apply revalidates the same current compatible source, durable node and saved launch settings after prerequisite and presence checks. The existing narrow restore engine carries that exact historical row through legacy and pod-aware native resume, with a second identity check at native dispatch after preparation. Before the new registry row is committed, selected startup also checks that no other attachment or running occupant claimed the node during terminal creation or pane observation; a collision leaves that claimant intact and reports the terminal outcome for inspection. An occupied or uncertain terminal blocks the attempt. No alternate history or fresh conversation is selected. The response projects exact typed references and outcome fields; only diagnostic text is redacted, so even a short native name cannot corrupt an ID. It names the selected source row and the newly registered result row when one exists; only the engine's joined native continuity proof produces success. If a change occurs after shell creation, the result reports a partial or uncertain attempt for inspection. The existing engine handles native prerequisites, compensation and proof. This surface adds no separate per-agent prelaunch probe; native checks remain at execution. Physical tmux cleanup still addresses sessions by name, so it cannot prove ownership against an external same-name replacement outside the selected registry guard.
|
||||
|
||||
## See also
|
||||
|
||||
- `agent-spec-and-startup.md` — StartupOrchestrator persists the replay
|
||||
|
||||
@@ -462,40 +462,6 @@ agent IS the LLM. The one exception is the explicit CLI flag `rig ask --wake`
|
||||
Claude or Codex session (`claude -p --resume` / `codex exec resume`,
|
||||
`packages/cli/src/ask-wake.ts:58`–`60`) to answer one question.
|
||||
|
||||
### Current-generation records for `rig walk`
|
||||
|
||||
`rig walk` checks a delivered piece against the current conversation through
|
||||
`GET /api/sessions/:sessionName/generation-record` (`routes/sessions.ts`).
|
||||
Without `sinceBytes`, the route returns record identity and size; with it, the
|
||||
route also reads a bounded suffix from the same open file. This terminal-class
|
||||
endpoint uses the terminal bearer gate.
|
||||
|
||||
For Codex, the current occupant must have a verified identity for its bound
|
||||
pane. The route compares tmux's current root PID with the identity's
|
||||
`observedPanePid`, falling back to `observedPid` for older verdicts that only
|
||||
recorded one PID. Previously it always compared the root with the native Codex
|
||||
PID, refusing a verified Codex running beneath a shell or launcher. Recording
|
||||
both PIDs lets that wrapper pass without accepting a changed pane root or
|
||||
changing the identity schema.
|
||||
|
||||
For dual-PID verdicts, the selected thread must also be the thread resolved for
|
||||
`observedPid`. All candidate threads still participate in the ambiguity check;
|
||||
another process's resolvable thread cannot stand in for a missing or
|
||||
unresolvable verified native process. This reuses the same process observation
|
||||
and thread lookups, without an additional census.
|
||||
|
||||
The existing thread lookup still requires a Codex descendant with one
|
||||
resolvable thread, a thread-table record path and a matching rollout header.
|
||||
The route rechecks occupant generation, pane binding and root PID after the
|
||||
thread lookup. These are bounded observations, not an atomic process snapshot.
|
||||
An unresolved identity still returns 409; `rig walk` reports consumption as
|
||||
unverified instead of claiming it read the record. Route regressions in
|
||||
`packages/daemon/test/walk-consumption-primitives.test.ts` cover shell and
|
||||
launcher wrappers, legacy verdicts, root changes, replaced or unresolved native
|
||||
processes, and changes during thread resolution with synthetic process
|
||||
observations and actual SQLite/JSONL
|
||||
fixtures; they do not exercise a live Codex conversation.
|
||||
|
||||
## 5. Chat flow
|
||||
|
||||
1. `rig chatroom send <rig> "message"` → `POST
|
||||
|
||||
@@ -9,20 +9,20 @@ applies-when: |
|
||||
the source that implements a command. Use command help for invocation details.
|
||||
siblings: [README.md, codemap.md]
|
||||
prerequisite-reads: [../reference/help.md]
|
||||
last-verified-against-source: 34fdcb80543903b054a982cc48bb302ae6871682
|
||||
last-updated: 2026-10-26
|
||||
last-verified-against-source: 71b9c2c072ac5d27a23fb9fb404faa7033cb7e42
|
||||
last-updated: 2026-10-24
|
||||
---
|
||||
|
||||
# CLI Reference — Registered Commands and Options
|
||||
|
||||
Verified against source commit `34fdcb80543903b054a982cc48bb302ae6871682`.
|
||||
Verified against source commit `71b9c2c072ac5d27a23fb9fb404faa7033cb7e42`.
|
||||
The inventory below comes from the actual Commander tree returned by
|
||||
[`createProgram()`](../../packages/cli/src/index.ts), not a grep of command
|
||||
strings or an installed CLI from a different commit.
|
||||
|
||||
There are **90 top-level registrations**, **410 registered command objects**
|
||||
There are **90 top-level registrations**, **401 registered command objects**
|
||||
below `rig` (including groups and the hidden `restore apply` command), and
|
||||
**1,304 explicitly registered option objects**, including the root version
|
||||
**1,274 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.
|
||||
@@ -188,7 +188,7 @@ the sections that were not read are `null`.
|
||||
|---|---|---|
|
||||
| `rig gateway` | — | — |
|
||||
| `rig gateway human` | — | — |
|
||||
| `rig gateway human add <entityId>` | — | `--display-name <name>` **required**<br>`--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>` **required** (repeatable)<br>`--delivery-class <A\|B\|C\|D>` **required**<br>`--away`<br>`--replace`<br>`--reason <reason>`<br>`--actor <actor>`<br>`--organization <id>`<br>`--group <id>` (repeatable)<br>`--policy <text>`<br>`--policy-file <path>`<br>`--default` |
|
||||
| `rig gateway human add <entityId>` | — | `--display-name <name>` **required**<br>`--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>` **required** (repeatable)<br>`--delivery-class <A\|B\|C\|D>` **required**<br>`--away`<br>`--replace`<br>`--reason <reason>`<br>`--actor <actor>` |
|
||||
| `rig gateway human list` | — | `--json` |
|
||||
| `rig gateway human quiet` | — | — |
|
||||
| `rig gateway human quiet set <entityId>` | — | `--daily <window>`<br>`--until <time>`<br>`--tz <zone>`<br>`--actor <actor>`<br>`--json` |
|
||||
@@ -208,23 +208,7 @@ the sections that were not read are `null`.
|
||||
| `rig gateway human show <entityId>` | — | `--json` |
|
||||
| `rig gateway human set <entityId> <field> <value>` | — | `--reason <reason>`<br>`--actor <actor>` |
|
||||
| `rig gateway human remove <entityId>` | — | `--force` |
|
||||
| `rig gateway channel` | — | — |
|
||||
| `rig gateway channel add <id>` | — | `--display-name <name>` **required**<br>`--binding <slack:connectorRef:secretsRef:channelId>` **required**<br>`--organization <id>`<br>`--group <id>` (repeatable)<br>`--policy <text>`<br>`--policy-file <path>`<br>`--replace` |
|
||||
| `rig gateway channel remove <id>` | — | `--force` |
|
||||
| `rig gateway org` | — | — |
|
||||
| `rig gateway org set <id>` | — | `--name <name>`<br>`--policy <text>`<br>`--policy-file <path>` |
|
||||
| `rig gateway group` | — | — |
|
||||
| `rig gateway group set <id>` | — | `--organization <id>`<br>`--name <name>`<br>`--policy <text>`<br>`--policy-file <path>` |
|
||||
| `rig gateway participants` | — | `--organization <id>`<br>`--json` |
|
||||
| `rig gateway default [entityId]` | — | `--clear` |
|
||||
|
||||
- Several people may be registered. Organizations, groups, readable policy, shared channels and the default person are
|
||||
kept in `$OPENRIG_HOME/gateway/participants.yaml`, never in a person's fragment, so an older daemon still loads every
|
||||
person after a rollback. `human set <entityId> organization|groups|policy <value>` writes there (`unset` clears;
|
||||
`@<path>` reads a policy file). `participants --json` carries each participant's `kind`, `address`, `organization`,
|
||||
`groups`, `default` and `policy` (a list of `{ source, text }`, organization first), plus `default`, `organizations`,
|
||||
`groups`, `warnings` and `participantFile`. `default` with no argument prints the current default and its source
|
||||
(`chosen` or `only-person`), or `null` with the reason. See `docs/reference/gateway-participants.md`.
|
||||
- `human set <entityId> slack-subscription <value>` — sets the person's Slack subscription: `every-ask` (the default) posts every ask to them to Slack; `urgent-only` posts only asks created with `--alert`, and other asks wait on Steering and the TUI; `unset` removes the authored subscription so the default applies. `human show` prints the value with its origin, `authored` or `default` (`slack-subscription: every-ask (default)`); `--json` carries it as `record.slackSubscription` `{ value, source }`.
|
||||
|
||||
### parked
|
||||
@@ -354,23 +338,6 @@ the sections that were not read are `null`.
|
||||
| `rig host list` | `ls` | `--json` |
|
||||
| `rig host doctor <id>` | — | `--posture <profile>`<br>`--public-addr <ip>`<br>`--json` |
|
||||
|
||||
Registration makes a host visible in topology; remote FEED attention remains opt-in.
|
||||
The human-readable success output of `host add` and `host pair` now includes the FEED
|
||||
next step alongside the existing `host doctor` suggestion. For an HTTP entry named
|
||||
`laptop`, run this on the local instance to include its attention items:
|
||||
|
||||
```sh
|
||||
rig config set feed.subscriptions.laptop.enabled true
|
||||
```
|
||||
|
||||
The key uses the registry id, including any dots, and defaults to `false`. Registration
|
||||
does not change that setting. SSH entries instead explain that FEED needs an HTTP entry;
|
||||
ids reserved by feed settings (such as `progress`) get a different-id suggestion rather
|
||||
than an invalid command. `--json` keeps its single JSON result without these hints.
|
||||
The host CLI tests execute the advertised command against an isolated config and cover
|
||||
registration, reserved names and JSON output; pairing responses are simulated, so they
|
||||
do not establish connectivity to a live peer.
|
||||
|
||||
### ps
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
@@ -393,7 +360,7 @@ do not establish connectivity to a live peer.
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig mcp` | — | — |
|
||||
| `rig mcp serve` | — | `--port <port>`<br>`--http`<br>`--bind <host:port>`<br>`--grant-service <url>`<br>`--grant-service-header-file <path>`<br>`--oauth-resource <url>`<br>`--oauth-issuer <url>` |
|
||||
| `rig mcp serve` | — | `--port <port>`<br>`--http`<br>`--bind <host:port>` |
|
||||
| `rig mcp token` | — | — |
|
||||
| `rig mcp token add <name>` | — | `--seat <session>` **required**<br>`--rig <name...>`<br>`--root <name...>`<br>`--workspace`<br>`--owner` |
|
||||
| `rig mcp token list` | — | `--json` |
|
||||
@@ -495,7 +462,6 @@ do not establish connectivity to a live peer.
|
||||
| `rig slack channel-map remove <match>` | — | `--reason <reason>`<br>`--actor <actor>` |
|
||||
| `rig slack enable` | — | `--reason <reason>`<br>`--actor <actor>` |
|
||||
| `rig slack disable` | — | `--reason <reason>` **required**<br>`--actor <actor>` |
|
||||
| `rig slack post-open-asks` | — | `--reason <reason>`<br>`--wait <seconds>`<br>`--actor <actor>`<br>`--json` |
|
||||
| `rig slack outbound` | — | `--json` |
|
||||
| `rig slack inbound` | — | — |
|
||||
|
||||
@@ -586,7 +552,7 @@ Herdr session per request, including session-bound previews and window reuse.
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig capture [session]` | — | `--rig <name>`<br>`--pod <name>`<br>`--lines <n>`<br>`--history-plus-pane`<br>`--readable`<br>`--size <WxH>`<br>`--host <id>`<br>`--json` |
|
||||
| `rig capture [session]` | — | `--rig <name>`<br>`--pod <name>`<br>`--lines <n>`<br>`--history-plus-pane`<br>`--host <id>`<br>`--json` |
|
||||
|
||||
### broadcast
|
||||
|
||||
@@ -794,8 +760,8 @@ Only Claude and Codex tenures can be woken.
|
||||
| `rig provider accounts` | — | `--json`<br>`--provider <p>`<br>`--account <a>` |
|
||||
| `rig provider bindings` | — | `--json`<br>`--provider <p>`<br>`--account <a>` |
|
||||
| `rig provider signals` | — | `--json`<br>`--provider <p>`<br>`--account <a>` |
|
||||
| `rig provider precheck` | — | `--seat <s>` **required**<br>`--to-account <a>`<br>`--token-file <path>`<br>`--stored-login`<br>`--json` |
|
||||
| `rig provider switch` | — | `--seat <s>` **required**<br>`--to-account <a>`<br>`--token-file <path>`<br>`--stored-login`<br>`--force-unsafe`<br>`--json` |
|
||||
| `rig provider precheck` | — | `--seat <s>` **required**<br>`--to-account <a>` **required**<br>`--json` |
|
||||
| `rig provider switch` | — | `--seat <s>` **required**<br>`--to-account <a>` **required**<br>`--force-unsafe`<br>`--json` |
|
||||
| `rig provider moves` | — | `--seat <seat>`<br>`--limit <n>`<br>`--json` |
|
||||
| `rig provider moves report` | — | `--file <path>` **required**<br>`--to <seat>` **required**<br>`--follows <qitemId>`<br>`--evidence-ref <ref>`<br>`--json` |
|
||||
|
||||
@@ -973,7 +939,6 @@ for input and receipt examples, rollback, and snapshot behavior.
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig seat` | — | — |
|
||||
| `rig seat history` | — | `--rig <id>` **required**<br>`--node-id <id>` **required**<br>`--snapshot-id <id>`<br>`--history-session-id <id>`<br>`--json` |
|
||||
| `rig seat set-typing-guard <seat>` | — | `--enabled <boolean>`<br>`--mode <mode>`<br>`--reason <text>` **required**<br>`--json` |
|
||||
| `rig seat held-messages <seat>` | — | `--limit <n>`<br>`--offset <n>`<br>`--id <id>`<br>`--json` |
|
||||
| `rig seat retire-held-message <seat> <id>` | — | `--reason <text>` **required**<br>`--json` |
|
||||
@@ -982,7 +947,7 @@ for input and receipt examples, rollback, and snapshot behavior.
|
||||
| `rig seat switch-client <seat>` | — | `--to-window <n>`<br>`--client <id>`<br>`--json` |
|
||||
| `rig seat clear-attention <session>` | — | `--reason <text>`<br>`--json` |
|
||||
| `rig seat set-terminal-policy <seat>` | — | `--preset <preset>` **required**<br>`--helper <addresses...>`<br>`--rig-default`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json`<br>`--peer-compaction <decision>`<br>`--peer-model <decision>`<br>`--self-compaction <decision>`<br>`--self-model <decision>`<br>`--openrig-compaction <decision>`<br>`--openrig-model <decision>` |
|
||||
| `rig seat set-permissions <seat>` | — | `--mode <mode>`<br>`--rules <file>`<br>`--codex-permissions <file>`<br>`--codex-profile <name>`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
|
||||
| `rig seat set-permissions <seat>` | — | `--mode <mode>` **required**<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
|
||||
| `rig seat set-effort <seat>` | — | `--effort <level>`<br>`--clear`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
|
||||
| `rig seat effort <seat>` | — | `--json` |
|
||||
| `rig seat set-model <seat>` | — | `--model <id>` **required**<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
|
||||
|
||||
@@ -88,11 +88,6 @@ are not yet runnable checks.
|
||||
credentials, and outbound network blocked except to localhost (see below). A test that reads
|
||||
your real home directory, a running daemon, the network or a credential can pass on your
|
||||
machine and fail in CI.
|
||||
- **Mixed fixture clocks.** `compaction-command-submission.test.ts` uses one clock for the
|
||||
enforcer, the transport's injected `now` and sleeps, and its `Date.now` idle deadline. Previously,
|
||||
real-time transport waits could exhaust the fixture's 20 ms budget during a scheduler pause
|
||||
even though the modeled seat was idle. Restore the `Date.now` spy after each test; keep real
|
||||
deadline behavior covered by the transport tests. This changes the fixture, not production timeouts.
|
||||
- **Docs location.** `check-docs-guard` fails if a tracked file under `docs/` sits outside
|
||||
`docs/as-built/`, `docs/reference/` or `docs/releases/`. `docs/DESIGN.md` is the one allowed
|
||||
exception.
|
||||
@@ -109,17 +104,7 @@ credentials are supplied.
|
||||
| `typecheck` | ubuntu | `npm run lint` |
|
||||
| `repo-checks` | ubuntu | `npm run test:repo` |
|
||||
| `package-tests` | macOS, matrix `daemon`, `cli`, `tui`, `ui` (fail-fast off) | Installs tmux, runs `npm ci` and `npm run build`. Then runs `npm run test -w packages/<pkg>` under `env -i` with a scratch `HOME`/`TMPDIR`/`TMUX_TMPDIR` and a `sandbox-exec` profile that blocks outbound network except localhost and the job's own socket directory. `scripts/ci-macos-process-probe.mjs` runs first. |
|
||||
| `linux-seat-environment` | ubuntu | Builds all packages, then runs `seat-env-parent-session.integration.test.ts` with real CLI, daemon, tmux and stub seats. A network namespace has loopback only; tests run as the unprivileged runner user under `env -i` with scratch `HOME`/`TMPDIR`/`TMUX_TMPDIR`. Runs on pull requests and manual dispatch, like `package-tests`. |
|
||||
| `installed-scenario` | ubuntu | `bash scripts/run-pr-scenarios.sh` (fifteen container runs, described below). Uploads `dist/pr-scenarios/` as `installed-scenario-evidence`, including on failure. |
|
||||
|
||||
The Linux seat-environment job covers a gap in the macOS matrix: the integration test
|
||||
reads `/proc/<pid>/environ`, so macOS skips it. It checks both a new tmux server and an
|
||||
existing server carrying a parent's session identity. It verifies the scoped environment,
|
||||
the runtime directory and disabled session-bus default, and tmux's terminal identity in
|
||||
the launched panes; the existing owner session stays unchanged. This adds one focused
|
||||
Linux job to the existing suite. Stub seats prove the environment passed to the pane;
|
||||
native Claude image paste and Shift+Enter handling still need separate verification
|
||||
(see [#2041](https://github.com/mvschwarz/openrig/issues/2041)).
|
||||
| `installed-scenario` | ubuntu | `bash scripts/run-pr-scenarios.sh` (eight container runs, described below). Uploads `dist/pr-scenarios/` as `installed-scenario-evidence`, including on failure. |
|
||||
|
||||
A second workflow, `.github/workflows/portability-report.yml` (job `portability-report`),
|
||||
lists lines your PR adds that contain machine-specific values: credentials, home or temp
|
||||
@@ -158,7 +143,7 @@ Exit codes: 0 pass, 1 a leg failed, 2 lane busy, 3 the runner itself errored.
|
||||
| Piece | Where |
|
||||
|---|---|
|
||||
| `runtime: stub` adapter | `packages/daemon/src/adapters/stub-runtime-adapter.ts`, with `stub-runner.ts` (the process that runs in the pane), `stub-script.ts` (the launch-script format) and `stub-compaction.ts` / `stub-restore.ts` |
|
||||
| Scenario format and runner | `packages/daemon/test/helpers/scenario-*.ts`. Step verbs: `up`, `down`, `send`, `restart`, `daemon`, `seed_regression` are bound. `restore`, `mutate`, `policy` are rejected at load time (`UNBOUND_STEP_VERB`) because the runner has no shipped binding for them; `emit` parses but only `refocus` is bound. Assertion surfaces: `ps`, `queue`, `stream`, `scope`, `pane`, `transcript`, `tui_socket`, `policy_provenance`. |
|
||||
| Scenario format and runner | `packages/daemon/test/helpers/scenario-*.ts`. Step verbs: `up`, `down`, `send`, `restart`, `daemon`, `seed_regression` are bound. `restore`, `emit`, `mutate`, `policy` parse but are unbound. Assertion surfaces: `ps`, `queue`, `stream`, `scope`, `pane`, `transcript`, `tui_socket`, `policy_provenance`. |
|
||||
| Scenario library | `packages/test-system/scenarios/`: 20 scenarios, 7 stub topologies, `agents/`, `culture.md`, per-seat stub scripts in `scripts/` |
|
||||
| Runner fixtures | `packages/daemon/test/fixtures/scenarios/` (`scenario-01-per-seat-scripts`, `scenario-02-baton`, `scenario-10-one-view-state`) |
|
||||
| Host runner | `packages/daemon/scripts/run-scenarios.mjs` |
|
||||
@@ -190,12 +175,10 @@ node --import tsx packages/daemon/scripts/run-scenarios.mjs packages/daemon/test
|
||||
|
||||
In CI the `installed-scenario` job runs `scripts/run-pr-scenarios.sh` with no arguments:
|
||||
both baton cases (`fixture` = `scenario-02-baton.yaml`, `library` = `queue-baton-survives-restart.yaml`),
|
||||
each run three times as healthy, then lost-baton, then healthy (six containers); the
|
||||
`refocus-parked` effect case runs healthy, seeded, then healthy (three containers); and the
|
||||
six passing-only cases (`transcript` = `transcript-reads-addressed-seat.yaml`, `capture` =
|
||||
`capture-returns-addressed-seat.yaml`, `daemon-restart`, `refocus-active`,
|
||||
`refocus-parked-off`, `prompt-state`) run once each, healthy only (six containers). That is
|
||||
fifteen fresh containers.
|
||||
each run three times as healthy, then lost-baton, then healthy. Then the two stub-script
|
||||
cases (`transcript` = `transcript-reads-addressed-seat.yaml`, `capture` =
|
||||
`capture-returns-addressed-seat.yaml`) run once each, healthy only. That is eight fresh
|
||||
containers.
|
||||
|
||||
To run a case before you push, point `DOCKER_HOST` at a Docker engine you control over SSH:
|
||||
|
||||
@@ -205,13 +188,10 @@ DOCKER_HOST=ssh://<your-host> bash scripts/run-pr-scenarios.sh \
|
||||
--remote --case library --mode healthy --out dist/scenario-check
|
||||
```
|
||||
|
||||
- Options: `--case fixture|library|transcript|capture|daemon-restart|refocus-parked|
|
||||
refocus-active|refocus-parked-off|prompt-state` (default: all nine), `--mode
|
||||
healthy|lost-baton|seeded` (default: healthy, lost-baton, healthy) and `--out <dir>` (default
|
||||
`dist/pr-scenarios`). `--mode` applies to the baton cases (`fixture` and `library`) and to
|
||||
the `refocus-parked` effect case; the six passing-only cases (`transcript`, `capture`,
|
||||
`daemon-restart`, `refocus-active`, `refocus-parked-off`, `prompt-state`) always run once
|
||||
each, healthy. `--mode lost-baton` leaves them out of a full run, and
|
||||
- Options: `--case fixture|library|transcript|capture` (default: all four), `--mode
|
||||
healthy|lost-baton` (default: healthy, lost-baton, healthy) and `--out <dir>` (default
|
||||
`dist/pr-scenarios`). `--mode` applies to `fixture` and `library`; `transcript` and `capture`
|
||||
always run once, healthy. `--mode lost-baton` leaves them out of a full run, and
|
||||
`--case transcript|capture --mode lost-baton` is refused with exit 2. Use a fresh `--out`
|
||||
directory for each run you want to keep.
|
||||
- `--remote` accepts only `ssh://` values for `DOCKER_HOST`, and it refuses if
|
||||
@@ -278,7 +258,7 @@ means:
|
||||
| Queue pickup and wake | No stub worker reacts to a nudge by claiming an item, working on it and handing it back. | The baton scenarios prove the claim *survives a restart*. They don't prove delivery, pickup or wake. |
|
||||
| Reboot / tmux reset | `daemon: {op: restart}` restarts only the scenario daemon, and the tmux server keeps running. No step resets tmux. | Recycled pane IDs and stale bindings after a real reboot (the class behind #141) are not covered by a daemon restart. |
|
||||
| Multi-rig and same-name identities | The `up` step always uses the scenario's top-level `topology` and ignores a per-step override. No step archives or removes a rig. | Cross-rig scope, same-name generations and archive/remove routing (the class behind #174) need runner work before they can be tested. |
|
||||
| Step-time restore, compaction, mutation, policy | `restore`, `mutate` and `policy` steps are refused at load time (`UNBOUND_STEP_VERB`); `emit` parses but a non-`refocus` behavior throws `UnboundActionError` when the runner reaches it. Behaviours run only from a per-seat launch script (`env.stub_scripts`). A seat `restart` step maps to `rig launch`, which is not native resume or fork. | Step-time compaction and restore, and native resume and fork, are not covered. |
|
||||
| Step-time restore, compaction, mutation, policy | `restore`, `emit`, `mutate` and `policy` parse, but the runner throws `UnboundActionError` when it reaches them. Behaviours run only from a per-seat launch script (`env.stub_scripts`). A seat `restart` step maps to `rig launch`, which is not native resume or fork. | Step-time compaction and restore, and native resume and fork, are not covered. |
|
||||
| Skill projection | The stub projects skills into `<cwd>/.openrig/stub/skills/`. Claude Code projects a team seat's skills into the seat's generated plugin in OpenRig's state, and into `<cwd>/.claude/skills/` only for a seat without one. | A stub green says nothing about provider-specific projection (the class behind #159). |
|
||||
| Model and provider | No model, no tokens. Stub scripts refuse `usage_limit` because it only exists on real runtimes. | Model behaviour, provider usage limits and real context usage need live evals or a real runtime. |
|
||||
| Platform | CI's scenario job runs in a Linux container. | macOS process behaviour is covered by the macOS `package-tests` jobs, not by scenarios. |
|
||||
@@ -319,9 +299,9 @@ of them. The other nine scenarios are authored but not yet runnable as meaningfu
|
||||
| `send-verify-means-rendered` | The `send` step drops `verify`, and the stub has no input loop | Build the input-consuming stub and assert a response derived from the nonce |
|
||||
| `home-divergence-preseed-visible` | The pipeline ignores `env.pre_existing_tmux`, and the default script never prints the expected `trust reached` | Recreate the real HOME split and whatever consumes it |
|
||||
| `stream-emit-durable-replay-live` | Step-time `emit` is unbound | Assert the actual stream contract |
|
||||
| `policy-posture-survives-structural-edit` | The validator refuses its `policy` and `mutate` steps at load time (`UNBOUND_STEP_VERB` — no shipped runner binding) | Rewrite around current per-seat permission selection and an observed launch |
|
||||
| `locked-restore-never-permissive` | The validator refuses its `policy` and `restore` steps at load time (`UNBOUND_STEP_VERB` — no shipped runner binding) | Keep the invariant (the selection survives a restore) and use current modes |
|
||||
| `one-view-state-after-mutation-storm` | The validator rejects its bare-list `equals` (`EQUALS_NOT_DECLARATIVE`) and refuses its `mutate` steps at load time (`UNBOUND_STEP_VERB`) | Declare the comparable fields and real mutations |
|
||||
| `policy-posture-survives-structural-edit` | `policy` and `mutate` are unbound | Rewrite around current per-seat permission selection and an observed launch |
|
||||
| `locked-restore-never-permissive` | `policy` and `restore` are unbound | Keep the invariant (the selection survives a restore) and use current modes |
|
||||
| `one-view-state-after-mutation-storm` | The validator rejects its bare-list `equals` (`EQUALS_NOT_DECLARATIVE`), and `mutate` is unbound | Declare the comparable fields and real mutations |
|
||||
| `kill-daemon-mid-handoff` | A pane `send` doesn't create the queue item the next assertion expects, and later assertions use a shape the queue reader doesn't return | Rebuild the setup before treating a red result as meaningful |
|
||||
|
||||
In most of these files, `seed_regression` steps that come *after* the assertions are
|
||||
|
||||
@@ -55,17 +55,8 @@ transitions before trying again. Do not silently send the task a second time.
|
||||
|
||||
You need the peer's base URL and a row assigned to your seat. For a no-model peer, the official
|
||||
[Hello World sample](https://github.com/a2aproject/a2a-samples/tree/6603ba3f2c31a7ef33e70b9d8b5b5f8be42ac9a3/samples/python/agents/helloworld)
|
||||
returns an echo artifact. Fetch that revision:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/a2aproject/a2a-samples.git
|
||||
git -C a2a-samples checkout --detach 6603ba3f2c31a7ef33e70b9d8b5b5f8be42ac9a3
|
||||
```
|
||||
|
||||
Start the peer yourself in a separate terminal, following `a2a-samples/samples/python/agents/helloworld/README.md`.
|
||||
Its default URL is `http://127.0.0.1:9999`, used below; it is available only while you run the sample.
|
||||
The sample does not implement the input-required and cancellation controls described later. Use a peer that
|
||||
supports those operations when testing them.
|
||||
returns an echo artifact. Its README explains startup; it does not implement the input-required and cancellation
|
||||
controls described later. Use a peer that supports those operations when testing them.
|
||||
|
||||
Save as `a2a_client.py`. This client also works without an OpenRig row, for the inbound recipe below.
|
||||
Streaming reads have no deadline so quiet worker waits keep their stream; connection, write and pool waits are bounded to 10 seconds.
|
||||
@@ -137,8 +128,7 @@ async def main():
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
When `A2A_TEXT` is unset, the row body becomes the peer's message verbatim. Write the body as the peer's input,
|
||||
or set `A2A_TEXT` to the exact text you want to send. Run it from your seat, replacing the row and peer URL:
|
||||
Run it from your seat, replacing the row and peer URL:
|
||||
|
||||
```sh
|
||||
export A2A_URL=http://127.0.0.1:9999
|
||||
@@ -270,7 +260,7 @@ export A2A_SEAT=<worker-session-from-rig-ps>
|
||||
# Client terminal, in the folder with the example scripts and venv:
|
||||
unset A2A_ROW A2A_TASK A2A_CONTEXT
|
||||
export A2A_URL=http://127.0.0.1:19092
|
||||
export A2A_TEXT='Return exactly these words: "hello from the receiving seat".'
|
||||
export A2A_TEXT='Return the words hello from the receiving seat.'
|
||||
.venv/bin/python a2a_client.py
|
||||
```
|
||||
|
||||
@@ -287,8 +277,6 @@ rig queue update "$ROW" --state done --closure-reason no-follow-on --resolution
|
||||
The client should receive that exact resolution as an artifact, followed by completed. Compare the artifact with
|
||||
`rig queue show "$ROW" --full --json`, including the task/context IDs in the row body. The sample uses `--no-nudge`:
|
||||
the worker must be watching its queue; merely starting the front does not start or wake an agent.
|
||||
To wake a model worker after the row appears, run `rig send <worker-session> 'Check your queue and process the new A2A task.'`
|
||||
from the custodian's environment, or remove `--no-nudge` from the front's queue-create call to request the normal queue wake.
|
||||
|
||||
For input-required, the worker instead records `--note 'A2A question: <question>'` and leaves the row in progress.
|
||||
The client reruns with the same `A2A_TASK`, `A2A_CONTEXT` and its answer as `A2A_TEXT`. The worker reads the
|
||||
|
||||
@@ -244,15 +244,6 @@ interactive"), and the post-launch files aren't sent. A Codex fork that the rig
|
||||
`session_source.mode: fork`, or an agent image's fork) is the exception: its timeout ends as `attention_required`,
|
||||
with its startup context kept for `rig seat continue` (see below). A seat handover's fork has its own readiness check.
|
||||
|
||||
**Fresh Claude handovers.** `rig handover --source fresh` keeps the previous agent's scrollback. Before sending the
|
||||
handover context, OpenRig waits for the latest prompt inside Claude's complete input frame, and confirms that the
|
||||
process in the pane belongs to the new launch. A shell's `❯` prompt or echoed launch command is not enough, even
|
||||
with an old Claude header or input frame above it and Claude already running. A header alone or a partially drawn
|
||||
input frame also keeps the check pending. If the current screen or process cannot be read, or the process identity is unconfirmed, the handover keeps
|
||||
waiting within the readiness window and fails if that window expires. Old screen text or the `claude` process label
|
||||
alone cannot complete this check. A healthy agent can therefore remain unconfirmed while those observations are
|
||||
unavailable; inspect its pane before deciding how to recover.
|
||||
|
||||
**Claude submission check.** For each startup message to a Claude seat, OpenRig makes a bounded check that the prompt
|
||||
was submitted. When the capture can't tell, it records the submission as unverified and carries on. When it sees the
|
||||
same prompt still sitting in the input, it presses Enter once more; if it's still there, it warns "Startup prompt still
|
||||
@@ -364,37 +355,10 @@ declaration isn't changed either; update it separately if a future recreation sh
|
||||
Each startup file and action specifies when it applies:
|
||||
- `fresh_start` — delivered when a new conversation starts: the first launch, `rig seat launch --fresh`,
|
||||
`rig seat continue`, and seats restored with `--fresh`
|
||||
- `restore` — delivered when a seat is restored without its earlier conversation: a restore whose resume falls back to a fresh conversation, or a retried start
|
||||
- `restore` — delivered when a seat is restored, including a restore whose resume falls back to a fresh conversation
|
||||
- Default: `[fresh_start, restore]` (both)
|
||||
|
||||
**An exact resume delivers neither.** When a restore reopens the seat's saved conversation (`rig up <rig> --existing` with a resume token), OpenRig types no startup actions and delivers no startup files, `restore` ones included: the conversation already has them, and typing them again would add turns to it. The one exception: a managed guidance block missing from the guidance file is put back before the harness starts.
|
||||
|
||||
Use this to avoid re-sending context that a fresh conversation doesn't need twice. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both, so a seat that comes back fresh gets it again.
|
||||
|
||||
---
|
||||
|
||||
## What a seat is set to, and what it's running
|
||||
|
||||
A seat's settings can disagree with its running agent: what's saved for the next launch, what the running process
|
||||
actually uses, and what its conversation has already read are three separate things. OpenRig reports each one
|
||||
separately. Without live evidence it says unknown (or not verified), never the saved value. A saved change reaches the
|
||||
running seat only through the action in the last column.
|
||||
|
||||
| What | Saved for the next launch | What the running seat has | How the running seat gets it |
|
||||
|---|---|---|---|
|
||||
| Model | `rig seat set-model` | `rig ps --nodes` shows the configured model beside the last observed one, or `UNKNOWN (<reason>)` | The next managed resume or successor launch |
|
||||
| Reasoning effort | `rig seat set-effort` | `rig seat effort <seat>`: requested, saved, sent at launch, and what the session recorded, which is not verified until it takes a turn | Its next managed continuation; nothing restarts |
|
||||
| Permissions | `rig seat set-permissions` (`--mode`, `--rules`, or for Codex `--codex-permissions` or `--codex-profile`), shown by `rig seat status` | What it launched with; a status read doesn't prove the running process enforces the selection | Its next managed launch or resume ([per-seat permission mode](getting-started.md#per-seat-permission-mode)) |
|
||||
| Working directory | `rig seat set-cwd`, shown by `rig seat status` as **Working directory for future launches** | The directory it started in | `rig seat launch --fresh --stop` (a blank conversation) or `rig handover --source rebuild` (durable context); [details](#change-an-existing-seats-working-directory) |
|
||||
| Startup files and skills | The spec's startup files, skills and actions | What its conversation read when it started; editing a file afterwards doesn't reach it | A fresh launch or a handover delivers them again; for the running seat, send or walk the change (`rig walk <seat> --through <files>`) |
|
||||
| Tools (MCP servers, plugins) | Selected fragments, merged into the harness's own config (`.mcp.json` for Claude, `config.toml` for Codex) | Unknown to OpenRig: registered isn't active, Claude asks before it uses a new server, and no OpenRig command reports which tools a running harness loaded | Check in the harness itself (Claude Code's `/mcp`); a running session may need a restart to pick up newly written config ([runtime config disclosure](#runtime-config-disclosure)) |
|
||||
|
||||
Per harness: Claude Code's and Codex's permission choices are in the setup reference
|
||||
([Claude Code](getting-started.md#claude-code-a-different-launch-flag),
|
||||
[Codex](getting-started.md#codex-select-sandbox-and-approvals-together)), Codex homes are
|
||||
[below](#separate-codex-homes-for-separate-installations), community harnesses' limits are in
|
||||
[their presets](community-harness-presets.md), and moving a live seat to the other runtime is in the
|
||||
[setup reference](getting-started.md#move-a-seat-to-the-other-runtime).
|
||||
Use this to avoid re-sending context that the agent already has from its resumed conversation. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both.
|
||||
|
||||
---
|
||||
|
||||
@@ -434,11 +398,6 @@ Types are `send_text`, `slash_command` and `startup_proof` (see "Startup proof"
|
||||
`idempotent` is required, and an action that isn't idempotent must not list `restore` in `applies_on`. Both phases
|
||||
run after the harness is ready: `after_files` (the default) after the post-launch files, then `after_ready`.
|
||||
|
||||
**Don't rename a managed Claude seat.** OpenRig starts every Claude seat with `--name <session>`, on a fresh start and on
|
||||
every resume, and its own reads of the seat use that name: context usage and managed compaction read the status-line
|
||||
record Claude saves under it, and the input box is recognized by the name on its border. A startup `/rename` to another
|
||||
name breaks those reads until the next resume puts the session name back.
|
||||
|
||||
### Current Support Matrix
|
||||
|
||||
| Capability | Status | Notes |
|
||||
|
||||
@@ -41,13 +41,6 @@ state. Attention/alarm values likewise stay in their own machinery (`attentionCo
|
||||
Both herdr and omnigent converged on this exclusion, with warning comments on the
|
||||
collision in both codebases.
|
||||
|
||||
The legacy `agentActivity` field distinguishes a typed provider interruption from
|
||||
a request for a person: fresh Codex `at_limit` and Claude `StopFailure/rate_limit`
|
||||
events carry `state: unknown` and `reason: usage_limit`, never a `needs_input`
|
||||
hook. A permission prompt can independently supply human-input evidence. See
|
||||
[Claude usage-limit detection](claude-status-line.md#claude-usage-limit-detection)
|
||||
for the separate provider signal and queue timer.
|
||||
|
||||
### Per-seat attention in the node inventory
|
||||
|
||||
`rig ps --nodes` carries the daemon's `needsAttention` result, using the same predicate
|
||||
@@ -102,7 +95,7 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
|
||||
- **r3 — Claude `sessions/<pid>.json` self-report** (busy/shell/idle/waiting, since
|
||||
v2.1.139): standing, above hooks for working/idle. Undocumented internal — unreadable
|
||||
means fall down the ladder, never error.
|
||||
- **r2 — lifecycle hooks** (Claude SessionStart/UserPromptSubmit/PostToolBatch/Stop/StopFailure/Notification;
|
||||
- **r2 — lifecycle hooks** (Claude SessionStart/UserPromptSubmit/Stop/Notification;
|
||||
Codex SessionStart/UserPromptSubmit/Stop/PermissionRequest): standing for Claude; Codex's
|
||||
hook rung starts at trial and is promoted after 50 agreeing observations over at least an
|
||||
hour. Exactly-once turn boundaries; SubagentStop filtered (it
|
||||
@@ -139,26 +132,6 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
|
||||
idle. Silence never clears a pending attention prompt, and a runner that states no interval (omp, an older pi
|
||||
runner) keeps the hold-until-the-next-boundary behaviour.
|
||||
|
||||
Claude permission notifications raise needs-input immediately. A visible permission can clear
|
||||
after it has been observed and then disappears. A notification whose dialog was never observed
|
||||
can also clear when Claude reports `PostToolBatch`: every tool in that actor's batch has resolved,
|
||||
so its earlier permission requests are no longer pending. The relay carries the native actor
|
||||
identity; a subagent or in-process teammate finishing its batch cannot clear the main session's
|
||||
request, and overlapping actors remain separate. An individual `PostToolUse` is insufficient
|
||||
because another tool in the batch may still be waiting for permission.
|
||||
|
||||
Batch completion must come from the current occupant and have a later source timestamp than
|
||||
the matching notification. Missing actor attribution from an older relay, equal or older
|
||||
timestamps, and another actor's completion retain attention. An unrecognized or missing screen
|
||||
does not expire attention by age. `Stop` and `UserPromptSubmit` remain the turn-boundary fallback.
|
||||
The oracle remembers each actor's latest completed batch for the current occupant, so a strictly
|
||||
earlier notification arriving late cannot restore the mark, even if the completion arrived first.
|
||||
A notification with an equal or later timestamp remains eligible; swapping or retiring the
|
||||
occupant discards its remembered completions.
|
||||
Completing a batch changes the needs-input signal without inferring that the seat is idle or
|
||||
overwriting a later elicitation. The hook contract is documented in
|
||||
[Claude's hooks reference](https://code.claude.com/docs/en/hooks#posttoolbatch).
|
||||
|
||||
Partial-coverage honesty: a source with partial lifecycle coverage gets identity-only
|
||||
trust, unless its inventory declares it positive-only. A positive-only source can report
|
||||
working and nothing else; its silence reads unknown, never idle. The ladder is also the migration path — new oracles slot in above existing
|
||||
|
||||
@@ -67,45 +67,3 @@ directory: its timestamp should advance and its `session_id` should match the
|
||||
current Claude session. A visible custom status line alone does not verify
|
||||
collection or end-to-end adoption/handover recovery. The wrapper above suppresses
|
||||
collector errors; remove its stderr redirection temporarily when diagnosing it.
|
||||
|
||||
## Claude usage-limit detection
|
||||
|
||||
The status line supplies subscription usage windows and their reset timestamps.
|
||||
OpenRig also registers Claude's [`StopFailure` hook](https://code.claude.com/docs/en/hooks#stopfailure),
|
||||
available since Claude Code 2.1.78. Its exact `error: "rate_limit"` value reports a
|
||||
provider interruption even when no status line window is available. This path
|
||||
requires the updated OpenRig hook configuration and the current seat's runtime
|
||||
and occupant generation. Unknown identity or an expired event cannot trigger a
|
||||
usage-limit pause; a hook with a newer source timestamp supersedes the event,
|
||||
regardless of arrival order. The event expires after five minutes in any case.
|
||||
|
||||
A helper Claude started from a seat's shell inherits that seat's environment,
|
||||
so its rate-limit hook is attributed to that seat too. This is appropriate when
|
||||
the helper uses the same login, since the quota belongs to the account. A
|
||||
helper on a different login is attributed the same way, so its rate limit can
|
||||
hold pending handoffs to every seat in that seat's Claude pool for one retry
|
||||
interval, even when the seat's own login still has room. This attribution does
|
||||
not bind the hook to Claude's own native session.
|
||||
|
||||
The event carries no usage percentage, account identity or reset timestamp.
|
||||
It marks the Claude provider pool as limited and preserves a current
|
||||
`agentActivity.reason` of `usage_limit` over a cached idle reading. It does not
|
||||
count as a request for human input; a newer permission prompt still takes
|
||||
precedence in the seat's activity display. Conflicting seat windows retain the
|
||||
existing `explicit_unknown` host diagnosis.
|
||||
|
||||
Pending work handled by the wake ladder uses the existing local Claude pool
|
||||
(the one-account-per-host assumption), with separate pools for seats that
|
||||
declare different Claude config directories. If reset windows conflict within
|
||||
a proposed shared pool, only the seats reporting exhaustion are held; another
|
||||
seat's pending handoffs can still retry. An exhausted status line window with
|
||||
a known reset supplies the timer. Otherwise a typed failure uses
|
||||
`queue.wake_retry_interval_seconds` (default 300) from the event time, plus the
|
||||
existing wake jitter, and records `config-fallback`. That is a retry time, not a
|
||||
claim that the provider's quota will reset then. The timer releases the shared
|
||||
blocker once and retains the existing durable wake delivery behavior.
|
||||
|
||||
Authentication, billing and overloaded errors are not classified as usage
|
||||
limits. Claude limit banners are not scraped, and older runtimes or failures
|
||||
that do not emit this hook still depend on status line data. Without either
|
||||
source, provider usage remains unknown.
|
||||
@@ -1,8 +1,7 @@
|
||||
# Merge this into each Hermes seat's config.yaml, in that seat's Hermes home ($OPENRIG_HOME/state/hermes/<node id> with
|
||||
# the example launcher), before the seat's first start.
|
||||
#
|
||||
# Hermes runs every tool command in its gateway (one per Hermes home, so one per seat with the example launcher), with
|
||||
# the gateway's environment. For each session it swaps
|
||||
# Hermes runs every tool command in one gateway per machine, with the gateway's environment. For each session it swaps
|
||||
# in the client's own values only for its toolchain names and the names listed here. Without this list, `rig` inside
|
||||
# Hermes runs as whichever seat started the gateway, or as no seat. With it, each seat runs `rig` as itself.
|
||||
# These are the seat's own OpenRig variables; none is a secret, and Hermes refuses credential-like names anyway.
|
||||
|
||||
@@ -5,7 +5,7 @@ command: openrig-hermes-launch # no model, provider or --max-turns here: they
|
||||
maintainer: Your name or team
|
||||
limits:
|
||||
- resume not declared until reopening a named session, and a missing one, have been checked with a real Hermes
|
||||
- closing a seat's pane detaches it; Hermes finishes any accepted turn; whether its gateway exits when idle is unchecked
|
||||
- closing a seat's pane detaches it; Hermes finishes any accepted turn, and its gateway exits when idle
|
||||
- creation settings (model, provider, max turns) come from the seat's Hermes config.yaml, not from flags
|
||||
- no guidance, skills or startup files are placed
|
||||
- needs-input and approval prompts are read from the screen only
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
#!/bin/sh
|
||||
# A reference launcher for Pi's own terminal UI (interactive `pi`) as an OpenRig community harness. Copy it, own it, and
|
||||
# change it: OpenRig doesn't ship or run it. It's written for Pi 1.4.0 from its source and not yet checked with a real Pi.
|
||||
# It follows docs/reference/community-harness-presets.md (`names_conversation`, `first_input`):
|
||||
# - a new launch (OPENRIG_CONVERSATION set): Pi creates the session whose id is that name, in the seat's own session
|
||||
# folder, and takes the first input as its first prompt;
|
||||
# - a resume (OPENRIG_RESUME_REF set): the launcher opens the one session file whose id is that name, and starts nothing
|
||||
# when there isn't exactly one. Pi's --session-id creates a new session for an id it doesn't find, and --session with
|
||||
# an id can open another session whose id starts the same way, so neither is used to resume.
|
||||
set -eu
|
||||
|
||||
pi="${OPENRIG_PI:-pi}"
|
||||
state="${OPENRIG_HOME:-$HOME/.openrig}/state/pi-tui/${OPENRIG_NODE_ID:-${OPENRIG_SESSION_NAME:-default}}"
|
||||
sessions="$state/sessions"
|
||||
|
||||
# Each seat gets its own Pi agent folder (settings, logins, skills) unless PI_CODING_AGENT_DIR is already set, so seats
|
||||
# don't share settings and a person's ~/.pi/agent isn't touched. Give the seat its provider key in its environment, or its
|
||||
# login and settings in this folder, before its first start.
|
||||
PI_CODING_AGENT_DIR="${PI_CODING_AGENT_DIR:-$state/agent}"
|
||||
# Pi expands a leading ~ in this folder; do the same, so the skills land where Pi looks.
|
||||
case "$PI_CODING_AGENT_DIR" in
|
||||
"~") PI_CODING_AGENT_DIR="$HOME" ;;
|
||||
"~/"*) PI_CODING_AGENT_DIR="$HOME/${PI_CODING_AGENT_DIR#"~/"}" ;;
|
||||
esac
|
||||
export PI_CODING_AGENT_DIR
|
||||
mkdir -p "$PI_CODING_AGENT_DIR/skills" "$sessions"
|
||||
|
||||
# Copy the installed OpenRig's coordination skills in at each launch, so an upgrade reaches the seat. Pi lists the skills
|
||||
# in <agent folder>/skills/ by itself; the seat's first input should still name queue-handoff (see the recipe).
|
||||
skills_from="${OPENRIG_PI_SKILLS_FROM:-${OPENRIG_HOME:-$HOME/.openrig}/plugins/openrig-core/skills}"
|
||||
set -f
|
||||
for skill in ${OPENRIG_PI_SKILLS:-queue-handoff}; do
|
||||
case "$skill" in
|
||||
*[!a-z0-9-]*|-*) echo "openrig-pi-tui-launch: skipping '$skill': not a skill name" >&2; continue ;;
|
||||
esac
|
||||
if [ -d "$skills_from/$skill" ]; then
|
||||
rm -rf "$PI_CODING_AGENT_DIR/skills/$skill"
|
||||
cp -Rp "$skills_from/$skill" "$PI_CODING_AGENT_DIR/skills/"
|
||||
else
|
||||
echo "openrig-pi-tui-launch: no $skill skill in $skills_from; starting without it" >&2
|
||||
fi
|
||||
done
|
||||
set +f
|
||||
|
||||
# Pi's session ids: letters, digits, '.', '_' and '-', starting and ending with a letter or digit.
|
||||
valid_id() {
|
||||
case "$1" in
|
||||
"" | *[!A-Za-z0-9._-]* | [!A-Za-z0-9]* | *[!A-Za-z0-9]) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The first input goes over as Pi's first prompt, byte for byte, after `--`. OpenRig's starts with its identity-context
|
||||
# line; Pi would read one starting with @ as a file even after `--`, and may run one starting with / as a command.
|
||||
set --
|
||||
if [ -n "${OPENRIG_FIRST_INPUT:-}" ]; then
|
||||
first_input=$(cat "$OPENRIG_FIRST_INPUT" && printf '.')
|
||||
first_input="${first_input%.}"
|
||||
if [ -n "$first_input" ]; then set -- -- "$first_input"; fi
|
||||
fi
|
||||
|
||||
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
|
||||
id="$OPENRIG_RESUME_REF"
|
||||
if ! valid_id "$id"; then
|
||||
echo "openrig-pi-tui-launch: '$id' isn't a Pi session id; starting nothing" >&2
|
||||
exit 2
|
||||
fi
|
||||
# Pi names a session file <time>_<id>.jsonl, but the file's first line, its header, is what says which session it is:
|
||||
# its top-level type and id, read as JSON as Pi reads it. Pi writes the file only once the conversation has a message, so
|
||||
# a session that never got one can't be reopened. Pi runs on Node, so node reads the header.
|
||||
node="${OPENRIG_PI_NODE:-node}"
|
||||
if ! command -v "$node" >/dev/null 2>&1; then
|
||||
echo "openrig-pi-tui-launch: $node isn't on PATH to read Pi's session headers; starting nothing" >&2
|
||||
exit 1
|
||||
fi
|
||||
found="" count=0
|
||||
for file in "$sessions"/*_"$id".jsonl; do
|
||||
[ -s "$file" ] || continue
|
||||
if head -n 1 "$file" | "$node" -e '
|
||||
let text = ""; process.stdin.on("data", c => { text += c; }).on("end", () => {
|
||||
try { const h = JSON.parse(text); process.exit(h && h.type === "session" && h.id === process.argv[1] ? 0 : 1); }
|
||||
catch { process.exit(1); }
|
||||
});' "$id"; then
|
||||
found="$file" count=$((count + 1))
|
||||
fi
|
||||
done
|
||||
if [ "$count" -ne 1 ]; then
|
||||
echo "openrig-pi-tui-launch: expected one Pi session $id in $sessions, found $count; starting nothing" >&2
|
||||
exit 1
|
||||
fi
|
||||
exec "$pi" --session-dir "$sessions" --session "$found" "$@"
|
||||
fi
|
||||
if [ -n "${OPENRIG_CONVERSATION:-}" ]; then
|
||||
if ! valid_id "$OPENRIG_CONVERSATION"; then
|
||||
echo "openrig-pi-tui-launch: '$OPENRIG_CONVERSATION' isn't a Pi session id; starting nothing" >&2
|
||||
exit 2
|
||||
fi
|
||||
exec "$pi" --session-dir "$sessions" --session-id "$OPENRIG_CONVERSATION" "$@"
|
||||
fi
|
||||
echo "openrig-pi-tui-launch: OpenRig passed no conversation name; is names_conversation set in the preset?" >&2
|
||||
exit 2
|
||||
@@ -1,15 +0,0 @@
|
||||
# Install as $OPENRIG_HOME/community-harnesses/pi-tui.yaml, with openrig-pi-tui-launch on the seat's PATH.
|
||||
# "pi" itself is the built-in managed Pi runtime, so this preset for Pi's own terminal UI takes another name.
|
||||
format: 1
|
||||
name: pi-tui
|
||||
command: openrig-pi-tui-launch # no model or provider here: they go in the seat's Pi settings or environment
|
||||
maintainer: Your name or team
|
||||
limits:
|
||||
- checked once with Pi 1.4.0 and OpenRig 0.7.4 (one run, one model); other Pi versions aren't checked
|
||||
- resume not declared until reopening a named session, and a missing one, have been checked with a real Pi
|
||||
- with the default typing guard, a wake submits a person's half-typed draft in Pi's editor together with the wake; run the seat unattended, not where a person also types
|
||||
- a send while a Pi dialog OpenRig doesn't recognize is open, such as /settings, types into the dialog
|
||||
- how Pi takes a wake that arrives while it's busy isn't shown
|
||||
- only the listed openrig-core skills are copied into the seat's Pi agent folder; no guidance or startup files are placed
|
||||
names_conversation: true
|
||||
first_input: true
|
||||
@@ -4,33 +4,6 @@ A community harness is a coding harness an outside contributor maintains. You in
|
||||
rig member runs it by naming it as its `runtime`. OpenRig doesn't test these harnesses. It shows them as community
|
||||
harnesses, and it reads their activity only from what the screen shows.
|
||||
|
||||
## What a community harness gets
|
||||
|
||||
What OpenRig does for a community-harness seat, capability by capability. **Partly** and **no** mean OpenRig shows that it doesn't know;
|
||||
it never reads an unsupported capability as idle, working, ready or verified.
|
||||
|
||||
| Capability | Supported | How it works, or what you see instead |
|
||||
|---|---|---|
|
||||
| Launch | yes | OpenRig types the preset's `command` into the seat's pane, with its variables passed through `/usr/bin/env`. The seat reads as ready once something other than a shell runs in the pane; that's a process check, not proof of which conversation it runs. With `startup_assisted`, the seat reports attention required until a person checks it. |
|
||||
| Delivery | yes | `rig send` and queue wakes paste the text into the pane and press Enter, as for any seat. A pane that's back at a shell is refused (`target_runtime_unverified`) and nothing is typed. OpenRig doesn't confirm the harness took the message; `rig send --verify` checks the pane afterwards. |
|
||||
| Identity | when declared | With `launch_result`, the conversation is shown as reported by its launcher; with `names_conversation`, as named by OpenRig. Neither is observed by OpenRig. Without either, the identity is unavailable. |
|
||||
| Resume | when declared | `resume: true` with one of the two above (see [Reopening a conversation](#reopening-a-conversation-resume-true)). Without it, a resume says `Resume is unsupported: community harness "<name>" is launch-only`, and a restore stops and asks. |
|
||||
| Activity | partly | Read from the screen only; there are no activity hooks. Work and attention the screen shows are kept, but a screen that looks idle reads `unknown`, never idle, because a quiet prompt can be an approval or a pause. |
|
||||
| Attention | partly | Read from the screen: a permission question or numbered choice that OpenRig recognizes reads as needing attention, and `rig send` won't type into it. A prompt it doesn't recognize reads `unknown`. |
|
||||
| Approval | no | Approvals stay in the harness's own pane. OpenRig relays none, so a person answers them there. |
|
||||
| Cancel | no | OpenRig has no verb that cancels a running turn. `rig seat stop` and `rig down` end the seat's tmux session; anything the harness runs outside its pane, such as a gateway, is its own, and the preset's `limits` should say what keeps running. Restarting in place and switching harness are refused. |
|
||||
| Transport | yes | The seat is a tmux pane like any other, so `rig capture`, `rig send` and `--host` work as they do for any seat. |
|
||||
|
||||
**Not placed:** guidance, skills and startup files (a launch warns that they weren't delivered), and the model: set it in the harness's own
|
||||
configuration.
|
||||
|
||||
**Check that the harness answers on its own first.** Before writing a preset, start the harness in a terminal outside OpenRig, with the
|
||||
same configuration and credentials your launcher will give it, and send it one prompt. Use the folder your launcher gives the seat, not
|
||||
the harness's default one: the example launchers keep one per seat under `$OPENRIG_HOME/state/<harness>/` (`HERMES_HOME`,
|
||||
`OPENCLAW_STATE_DIR` or `CURSOR_CONFIG_DIR`), so a check with your own defaults can pass while the seat's setup fails. If no answer comes
|
||||
back, the problem is in the harness's own setup, such as its model, provider or login, and relaunching an OpenRig seat won't fix it. Fix
|
||||
it in the harness, then connect it.
|
||||
|
||||
## The preset
|
||||
|
||||
One YAML file per harness: `$OPENRIG_HOME/community-harnesses/<name>.yaml`. `rig community-harness list` shows what's
|
||||
@@ -155,9 +128,7 @@ Agent's CLI.
|
||||
flags and then reopens it, refusing any creation flag (model, provider, `--max-turns`) that differs. So the preset's
|
||||
command carries none, and each seat's `config.yaml`, in its Hermes home, sets them before the seat's first start:
|
||||
the model and provider, and `agent.max_turns` if you want a cap (nothing else sets it once the flag is gone).
|
||||
- **Let Hermes run `rig` as the seat:** Hermes runs every tool command in its gateway, one per Hermes home, so with
|
||||
this launcher one per seat (a two-seat run showed separate gateways, homes and sockets, and stopping one seat left
|
||||
the other's running). It runs them with that gateway's
|
||||
- **Let Hermes run `rig` as the seat:** Hermes runs every tool command in one gateway per machine, with that gateway's
|
||||
environment, and swaps in a session's own values only for its toolchain names and the names its profile lists in
|
||||
`terminal.client_env_passthrough`. So put the seat's OpenRig variables in that list, in each seat's `config.yaml`
|
||||
([`config.yaml.example`](community-harness-examples/hermes/config.yaml.example)). Without it, `rig` inside Hermes
|
||||
@@ -168,8 +139,7 @@ Agent's CLI.
|
||||
- **The preset:** it declares `names_conversation` but not `resume`, until reopening a named session, and a missing one,
|
||||
have been checked with a real Hermes.
|
||||
- **Stopping:** Hermes's CLI is a client of a gateway, so closing a seat's pane detaches it. Hermes finishes any turn it
|
||||
accepted. Whether the seat's gateway then exits once idle hasn't been checked: check with `ps` that a stopped seat's
|
||||
gateway has gone before relying on it. The preset says so in its limits.
|
||||
accepted, and the seat's gateway exits once idle. The preset says so in its limits.
|
||||
|
||||
Copy them and maintain them as your own.
|
||||
|
||||
@@ -181,18 +151,13 @@ OpenClaw's CLI.
|
||||
- **The conversation:** both examples use only agent `main`, mapping OpenRig's name to the full native key `agent:main:<OpenRig name>`. Each seat has its own client state folder.
|
||||
- **Its resume check:** local mode checks `sessions --agent main --json --limit all`; Gateway mode calls the selected Gateway's `sessions.describe`. Both require the exact canonical key. An unavailable lookup stops the launch; a confirmed missing key is never recreated. A client's local cache cannot authorize Gateway resume.
|
||||
- **Stopping:** a Gateway client's pane closing detaches it. The Gateway and an accepted turn can keep running; stopping that service is separate. Local mode runs its embedded agent in the pane.
|
||||
- **Unattended seats:** prefer Gateway mode within the capabilities qualified for your installed version. Local interrupted-turn recovery is not supported by these launchers.
|
||||
- **Local interruption:** Finish the current local turn before stopping; an interrupted local turn may retain native recovery work and refuse the next request. This launcher does not repair or retry it. OpenRig recognizes the recorded local “changed while starting work” error frame as needing attention: “OpenClaw turn not recovered; inspect the pane before retrying”. Use `rig capture <session>` to inspect the native conversation. An ordinary send still submits a new turn, with an advisory that the last local turn was not recovered; it is not an automatic replay of that turn. It does not clear native recovery records, repeat the request or start a fresh conversation; a successful process launch is not proof that the turn recovered.
|
||||
- **Local interruption:** Finish the current local turn before stopping; an interrupted local turn may retain native recovery work and refuse the next request. This launcher does not repair or retry it.
|
||||
- **Local startup after resume:** OpenClaw may run its database integrity check during startup. A reported resumed process does not confirm readiness for the next request; check that the TUI has finished starting before sending work.
|
||||
- **Resets must stay off:** automatic session resets can create a new conversation under the same key. Typing `/new` also moves to a fresh key while the seat keeps the name OpenRig gave it.
|
||||
- **The presets:** both declare `names_conversation`, `resume` and `first_input`. The launcher passes the generated first startup text through OpenClaw's `--message` option. OpenRig labels the saved name as passed, not observed by its adapter.
|
||||
|
||||
Copy the launcher and the preset for your mode, and maintain them as your own.
|
||||
|
||||
**Stopping a community seat stops its pane.** `rig down` names those seats ("Pane stopped: …"), since a harness's own
|
||||
processes, a gateway say, may keep running after it. OpenRig doesn't stop them, and whether they survive isn't exercised
|
||||
by OpenRig's tests, so check the harness's own processes after `rig down`. Each preset's limits say what to expect.
|
||||
|
||||
**Not supported for community harnesses:** restarting a seat in place (`rig seat restart` is for Codex seats) and
|
||||
switching harness on a running seat. Each says so before anything changes.
|
||||
|
||||
@@ -214,33 +179,6 @@ CLI.
|
||||
|
||||
Copy both and maintain them as your own.
|
||||
|
||||
## An example: Pi's terminal UI
|
||||
|
||||
[`community-harness-examples/pi-tui/`](community-harness-examples/pi-tui/) has a preset and a small launcher that run Pi's
|
||||
own terminal UI as a community harness. `pi` is the built-in managed Pi runtime, so the preset is named `pi-tui`. OpenRig
|
||||
delivers to this seat as to any community seat (the Delivery row above), not as to a plain `runtime: terminal` seat, which
|
||||
queue wakes don't reach. Checked once, on Pi 1.4.0 with OpenRig 0.7.4 and one model: a parked row's wake reached Pi in
|
||||
the same session (the same session file) and Pi resumed the work, and a handoff reached a second `pi-tui` seat, which
|
||||
picked it up unattended. The first seat's task named the commands to use, so this shows a wake reaching Pi and Pi acting
|
||||
on it, not Pi choosing by itself to park or hand work on.
|
||||
- **The launcher:** each seat gets its own Pi agent folder (`$OPENRIG_HOME/state/pi-tui/<node>/agent`, unless
|
||||
`PI_CODING_AGENT_DIR` is set) and its own session folder. At each launch it copies the installed `openrig-core` skills
|
||||
it's given (`queue-handoff` by default) into that agent folder. A new launch starts `pi --session-id <the name OpenRig
|
||||
gave>` and passes the first input as Pi's first prompt, byte for byte.
|
||||
- **Resume isn't declared.** The launcher can reopen a named session: it opens the one session file whose header has that
|
||||
id, and starts nothing when there isn't exactly one, because Pi's own `--session-id` creates a session for an id it
|
||||
doesn't find. Declare `resume: true` only after a real Pi has reopened a named session and refused a missing one.
|
||||
- **Its limits:** run a `pi-tui` seat unattended, not where a person also types into it.
|
||||
- With the seat's [typing guard](seat-delivery.md) at its default, a wake submits a person's half-typed draft in Pi's
|
||||
editor together with the wake. The guard's draft-aware mode isn't a known fix for this.
|
||||
- A send while a Pi dialog OpenRig doesn't recognize is open, such as `/settings`, types into the dialog.
|
||||
- Not shown: how Pi takes a wake that arrives while it's busy (a message sent during a busy turn was answered after
|
||||
it), and other Pi versions.
|
||||
- **Give the seat a model** before its first start: its provider key in its environment, or its login and settings in its
|
||||
agent folder.
|
||||
|
||||
Copy both and maintain them as your own.
|
||||
|
||||
The launcher is the contributor's code, in the contributor's package. OpenRig runs no code from a preset and loads no
|
||||
module into its daemon. A preset that sets `launch_result`, `names_conversation`, `startup_assisted` or `first_input` is refused by an older
|
||||
OpenRig that doesn't know the field.
|
||||
@@ -254,98 +192,3 @@ The contributor-owned launcher reads the file and uses its harness’s supported
|
||||
OpenRig records `node.startup_first_input_passed` with the path and `submission: unconfirmed`. This says the input was passed at launch, not that the native harness submitted or read it. An uncertain launch is not permission to replay it. Readiness remains process-only. `startup_assisted` is independent: when declared, startup still requires a person’s acknowledgement, including when the first input was passed at launch.
|
||||
|
||||
Presets without `first_input` and built-in runtimes keep their existing behavior. Older OpenRig versions reject this new field as unknown. The private input file remains for diagnostics; it can contain private startup context. A launcher that turns its contents into one argument is subject to the platform’s argument-size limit; the launcher must report a launch error rather than silently truncate the turn. The examples’ argument transport is covered with stand-ins; native unattended startup still requires qualification with the installed harness.
|
||||
|
||||
## Interactive Pi in a terminal seat: the coordination skills
|
||||
|
||||
This is for a seat that runs interactive `pi` from a `runtime: terminal` member's startup, or from a community preset's
|
||||
launcher. It isn't for a managed `runtime: pi` seat, which already copies its selected skill directories into the seat's own
|
||||
Pi agent directory (`$OPENRIG_HOME/state/pi/<session>/agent/skills/`), or for Workshop, whose Pi profiles carry pinned skill
|
||||
copies (openrig-world's `PI-SKILLS.md`). The [`pi-tui` example](#an-example-pis-terminal-ui) above does step 1 for its
|
||||
seats in its launcher.
|
||||
|
||||
OpenRig installs no skills for a terminal or community seat: it skips their resources and startup files, and `rig skill
|
||||
loadout` covers Claude Code and Codex only. Without the coordination skills, the agent there doesn't know the queue's habits,
|
||||
such as parking a row that waits on CI instead of leaving it in progress, or handing work on so the next row names its
|
||||
source. The seat's setup does two things.
|
||||
|
||||
1. **Make the released skills readable.** The installed OpenRig's own plugin carries them, matching the installed release:
|
||||
`rig plugin list` shows `openrig-core` and its path (usually `$OPENRIG_HOME/plugins/openrig-core`), and the skills are in
|
||||
its `skills/` folder. Copy whole skill directories, keeping their helper files and modes, into the `skills/` folder of the
|
||||
Pi agent directory that seat uses (`PI_CODING_AGENT_DIR`, or `~/.pi/agent` by default). At least `queue-handoff`:
|
||||
|
||||
```bash
|
||||
CORE="$(rig plugin list | awk '$1 == "openrig-core" {print $NF}')"
|
||||
PI_AGENT_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
|
||||
mkdir -p "$PI_AGENT_DIR/skills"
|
||||
cp -Rp "$CORE/skills/queue-handoff" "$PI_AGENT_DIR/skills/"
|
||||
```
|
||||
|
||||
Copy again after each OpenRig upgrade: a copy doesn't follow the plugin.
|
||||
2. **Point the agent at it when it starts.** Don't count on Pi finding the skill by itself; name it in the seat's first turn.
|
||||
For example:
|
||||
|
||||
> Before you work your queue, read `<Pi agent directory>/skills/queue-handoff/SKILL.md` in full: it's how this team hands
|
||||
> work on, parks it and waits. When you wait on CI or a long job, park the row with `rig queue block <id> --on
|
||||
> external:<what> --wake-after <duration> --continuation "<what resumes>"`.
|
||||
|
||||
- A community preset with `first_input: true` carries it in the seat's first input when the file is declared with
|
||||
`delivery_hint: send_text` (see "Pass the first startup input at launch" above). A `.md` file left at the `auto` hint
|
||||
counts as guidance, which a community seat doesn't take.
|
||||
- A plain terminal seat can pass it as Pi's initial message in the command its startup runs. Check `pi --help` for that
|
||||
argument in your Pi version.
|
||||
- `first_input` carries text, not files: it doesn't install the skills.
|
||||
|
||||
**In a plain terminal seat, a queue wake doesn't reach Pi.** OpenRig doesn't treat a `runtime: terminal` seat as an agent
|
||||
seat. When a parked row's wake fires, OpenRig doesn't type it into Pi, and a handoff to that seat records a failed wake
|
||||
(`failed:terminal-seat`). Parking still records the wait, but `--wake-after` won't bring Pi back. Check the seat's parked
|
||||
rows yourself (`rig queue whoami --session <seat>`, or `rig queue show <id>`), or tell Pi to continue the named row.
|
||||
|
||||
**What this shows, and what it doesn't.** A copied folder and a startup line put the text in front of the agent. They don't
|
||||
show that Pi discovered the skill by itself, or that the agent follows it. Check those separately: that the agent read the
|
||||
whole file (its tool trace), and what its rows record, such as a CI wait parked on `external:` with a wake, and a handoff
|
||||
made with `handoff-and-complete`, whose next row names it as its source.
|
||||
|
||||
## Write your own
|
||||
|
||||
Start from the example closest to your harness, and change it. First check that the harness answers a prompt on its own
|
||||
([above](#what-a-community-harness-gets)).
|
||||
|
||||
1. **Choose how the conversation is named.**
|
||||
- **Your harness creates an id you can read before it starts,** as Cursor's `create-chat` does: declare `launch_result: true`, and the
|
||||
launcher writes the record.
|
||||
- **Your harness takes a name from its caller and creates or reopens it,** as Hermes's `-c <name>` and OpenClaw's session key do: declare
|
||||
`names_conversation: true`, and the launcher passes `OPENRIG_CONVERSATION` on.
|
||||
- **Neither:** leave both out. The seat starts and works, its identity shows as unavailable, and it can't be resumed.
|
||||
2. **Write the launcher:** a small script your package installs on the seat's `PATH`. It reads OpenRig's variables, starts the harness with
|
||||
`exec`, and does nothing else.
|
||||
- `OPENRIG_CONVERSATION` with `names_conversation`, or `OPENRIG_LAUNCH_RESULT` and `OPENRIG_LAUNCH_ATTEMPT` with `launch_result`.
|
||||
- **On a resume,** `OPENRIG_RESUME_REF`: reopen exactly that conversation. Check that it exists first, and when it doesn't, end with an
|
||||
error (or report `error: "missing"`) instead of creating one.
|
||||
- **With `first_input: true`,** `OPENRIG_FIRST_INPUT`: pass the file's exact bytes through your harness's own first-prompt route.
|
||||
- **Keep each seat's harness state apart** (Cursor's config folder, Hermes's home, OpenClaw's state folder), so two seats never share a
|
||||
conversation.
|
||||
3. **Write the preset** at `$OPENRIG_HOME/community-harnesses/<name>.yaml`, and check it with `rig community-harness list`.
|
||||
- Declare `resume` only after checking, with the real harness, that a reopen works and that a missing conversation is refused.
|
||||
- List what doesn't work under `limits`.
|
||||
4. **Try it in a rig:** give a member `runtime: <name>`, then go through the checks in the promotion test below.
|
||||
5. **Publish the preset and the launcher in your own package,** and maintain them. OpenRig runs no code from a preset, so the launcher is
|
||||
yours.
|
||||
|
||||
## The promotion test
|
||||
|
||||
A community harness stays a community harness: you maintain it, and OpenRig shows it as one. **Promotion** means OpenRig takes a harness on
|
||||
as a built-in runtime that it maintains and tests itself. **Promotion is decided by the OpenRig maintainers, on evidence from OpenRig's own
|
||||
testing.** Passing these checks isn't automatic promotion, and it isn't a promise of upstream support.
|
||||
|
||||
What the maintainers look at:
|
||||
1. **Everything the preset declares works on a real install,** end to end through OpenRig:
|
||||
- a seat starts and takes its first turn (`rig up`);
|
||||
- a message arrives (`rig send`), and the reply can be read (`rig capture`);
|
||||
- the seat stops (`rig down`) without leaving work running that the person can't see, or the preset's `limits` say what keeps running;
|
||||
- with `resume`, `rig up <rig> --existing` reopens the same conversation, and a missing one is refused, never replaced.
|
||||
2. **What it doesn't support reads as not known,** never as idle, working, ready or verified.
|
||||
3. **Its `limits` are true,** and name anything a person would otherwise be surprised by.
|
||||
4. **The built-in runtimes are unchanged.**
|
||||
5. **It's maintained:** a named maintainer, and a launcher that keeps working with the harness's current release.
|
||||
|
||||
There's no score or threshold. The maintainers weigh the evidence.
|
||||
@@ -1,8 +1,7 @@
|
||||
# Edge Types Reference
|
||||
|
||||
Historical whole-document validation: 2026-10-07, at the 0.6.6 cut `2620dea8` (whole document at `fcaf1f8e`).
|
||||
The fallback escalation section and deferred-target exceptions below describe #605.
|
||||
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/rigspec-instantiator.ts`, `packages/daemon/src/domain/queue-owner.ts`, `packages/daemon/src/domain/escalation-owner.ts`
|
||||
Last validated against code: 2026-10-07, at the 0.6.6 cut `2620dea8` (whole document at `fcaf1f8e`; its sources unchanged in edge behaviour since then)
|
||||
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/rigspec-instantiator.ts`, `packages/daemon/src/domain/queue-owner.ts`
|
||||
|
||||
---
|
||||
|
||||
@@ -17,7 +16,8 @@ See `docs/reference/rig-spec.md` for the YAML syntax.
|
||||
|
||||
## Edge Kinds
|
||||
|
||||
Five edge kinds are accepted by both the pod-aware and legacy flat-node validators:
|
||||
Five edge kinds are accepted by the pod-aware validator (the legacy flat-node format accepts only `delegates_to`,
|
||||
`spawned_by` and `can_observe`):
|
||||
|
||||
| Kind | Accepted | Has Runtime Behavior | Description |
|
||||
|------|----------|---------------------|-------------|
|
||||
@@ -25,13 +25,13 @@ Five edge kinds are accepted by both the pod-aware and legacy flat-node validato
|
||||
| `spawned_by` | yes | **yes — affects launch order** | Source was spawned by target |
|
||||
| `can_observe` | yes | no | Source can observe target's output |
|
||||
| `collaborates_with` | yes | no | Peer collaboration |
|
||||
| `escalates_to` | yes | **yes — fallback queue escalation** | Source escalates to target |
|
||||
| `escalates_to` | yes | no | Source escalates to target |
|
||||
|
||||
## What Edges Actually Do Today
|
||||
|
||||
### Launch ordering
|
||||
|
||||
Only `delegates_to` and `spawned_by` constrain the order in which nodes are launched:
|
||||
Only `delegates_to` and `spawned_by` affect runtime behavior. They constrain the order in which nodes are launched:
|
||||
|
||||
- **`delegates_to`**: source launches BEFORE target. The delegator must be up before the delegate.
|
||||
- **`spawned_by`**: target (parent) launches BEFORE source (child). The parent must be up before the child it spawned.
|
||||
@@ -46,42 +46,13 @@ All other edge kinds (`can_observe`, `collaborates_with`, `escalates_to`) do NOT
|
||||
wake ladder escalates an unacknowledged row to that orchestrator, and stuck-row findings and recovery go to it. A seat
|
||||
with no incoming `delegates_to` edge has no default orchestrator. `spawned_by` plays no part here.
|
||||
|
||||
### Fallback escalation across rigs
|
||||
|
||||
A seat with **no declared `delegates_to` parent** may declare one `escalates_to` destination. The wake ladder and
|
||||
stuck sweep use it for new recovery rows; they do not move the original obligation or reassign an already-open recovery. A stopped or unbound declared
|
||||
parent still takes precedence, preserving the existing parent route and its fallback.
|
||||
|
||||
```yaml
|
||||
edges:
|
||||
- kind: escalates_to
|
||||
from: work.lead
|
||||
to: review-advisor@coord
|
||||
```
|
||||
|
||||
The target is a canonical same-daemon seat address (`pod-member@rig`), or an existing local member using the usual
|
||||
edge syntax. Named targets are stored without requiring the other rig to exist and resolved when needed. A matching
|
||||
physical session name takes precedence over a derived canonical address. Deferred declarations survive export and
|
||||
snapshots; they don't constrain launch order or make the rigs depend on each other's lifecycle.
|
||||
|
||||
Delivery is one hop. Recovery rows carry `escalation-route:one-hop`, so a destination's own `escalates_to` declaration
|
||||
does not forward the recovery again. A missing or stopped destination, multiple matches or declarations, self route,
|
||||
or declared cycle preserves the existing fallback: the wake ladder proceeds to its operator rung; the sweep retains
|
||||
its existing owner fallback. The recovery body/transition explains the result. Cycle inspection includes stopped
|
||||
seats and delegation parents; an unresolved downstream endpoint is reported as unknown, never as proof of no cycle.
|
||||
Queue address admission is unchanged. Cross-host and synthetic destinations are not supported.
|
||||
|
||||
On an existing rig, use `rig edge add <rig> work.lead review-advisor@coord --kind escalates_to`; `--plan` writes nothing.
|
||||
The receipt's target node ID is null for a deferred address. Remove it with the exact edge ID returned by add.
|
||||
A rig-local graph omits deferred targets, since those seats are outside that graph; the raw topology and export retain them.
|
||||
|
||||
### Graph visualization
|
||||
|
||||
Local-node edges are rendered in the UI topology graph; deferred cross-rig targets are omitted. They create visual connections between nodes, helping the operator understand the team structure. Pod-local edges appear within the pod group; cross-pod edges connect across groups.
|
||||
All edges are rendered in the UI topology graph. They create visual connections between nodes, helping the operator understand the team structure. Pod-local edges appear within the pod group; cross-pod edges connect across groups.
|
||||
|
||||
### Identity projection
|
||||
|
||||
Local-node edges appear in `rig whoami --json` output under `edges.outgoing` and `edges.incoming`, with their kind and the connected peer's identity. This lets an agent know its relationship to other members — but the agent interprets this information, it's not enforced by the runtime.
|
||||
All edges appear in `rig whoami --json` output under `edges.outgoing` and `edges.incoming`, with their kind and the connected peer's identity. This lets an agent know its relationship to other members — but the agent interprets this information, it's not enforced by the runtime.
|
||||
|
||||
### Attach hint heuristic
|
||||
|
||||
@@ -93,7 +64,7 @@ node, in creation order, whose session is running, or else the first node with a
|
||||
Edges do NOT currently:
|
||||
|
||||
- **Route messages** — `rig send` can target any session, regardless of edges (queue escalation, above, is the one
|
||||
daemon path that follows `delegates_to` or fallback `escalates_to`)
|
||||
daemon path that follows `delegates_to`)
|
||||
- **Enforce delegation** — an agent can communicate with any peer, not just its edge targets
|
||||
- **Control permissions** — there's no edge-based access control
|
||||
- **Affect transport** — `rig capture`, `rig broadcast`, etc. work based on session identity, not edge topology
|
||||
@@ -157,7 +128,7 @@ If none fit, `can_observe` is the safest default — it documents the relationsh
|
||||
## Validation Rules
|
||||
|
||||
1. Edge kinds must be one of: `delegates_to`, `spawned_by`, `can_observe`, `collaborates_with`, `escalates_to`
|
||||
2. Pod-local edges use unqualified member IDs — both endpoints must exist in the same pod, except a named `escalates_to` target as above.
|
||||
3. Cross-pod edges use `pod.member` format — both endpoints must resolve to actual members, except a named `escalates_to` target as above.
|
||||
2. Pod-local edges use unqualified member IDs — both `from` and `to` must exist in the same pod
|
||||
3. Cross-pod edges use `pod.member` format — both endpoints must resolve to actual members
|
||||
4. Cross-pod edges must reference different pods (same-pod edges should use the pod-local syntax)
|
||||
5. Self-edges are not validated at the schema level but are not meaningful
|
||||
@@ -1,165 +0,0 @@
|
||||
# Gateway participants: several people, shared channels, organizations and policy
|
||||
|
||||
OpenRig's gateway can register **several people** and **shared channels**, group them into **organizations** and
|
||||
**groups**, give each readable **policy** text, and name a **default person**. Agents read the policy and apply it;
|
||||
OpenRig doesn't enforce it. This page covers the commands, what the default is for, and what upgrading and rolling back
|
||||
do.
|
||||
|
||||
## Participants
|
||||
|
||||
A participant is either:
|
||||
- **a person**, registered with `rig gateway human add` as before, with their own Slack binding; or
|
||||
- **a shared channel**: one vetted Slack channel registered as one participant, `<id>@external`, not a person per
|
||||
sender.
|
||||
|
||||
People and channels share one address space: a channel can't take a person's id.
|
||||
|
||||
```bash
|
||||
# An organization and a group inside it (one command each, create or update)
|
||||
rig gateway org set acme --name "Acme" --policy "Answer Acme's customers within a working day. Never promise a date."
|
||||
rig gateway group set support --organization acme --name "Support" --policy-file ./support-policy.md
|
||||
|
||||
# People: the second and later ones are allowed now
|
||||
rig gateway human add sarah --display-name "Sarah" \
|
||||
--binding "slack:main:file:/etc/openrig/slack.env:primary:handle=U0SARAH" --delivery-class B \
|
||||
--organization acme --group support --policy "Sarah approves refunds up to 500." --default
|
||||
|
||||
# A shared channel
|
||||
rig gateway channel add acme-customers --display-name "Acme customers" \
|
||||
--binding "slack:main:file:/etc/openrig/slack.env:C0ACME" --organization acme
|
||||
|
||||
# See everyone, with organization, groups, the default and each participant's policy
|
||||
rig gateway participants
|
||||
rig gateway participants --organization acme --json
|
||||
rig gateway human show sarah
|
||||
```
|
||||
|
||||
Change a person's placement with `rig gateway human set <id> organization|groups|policy <value>` (`unset` clears it;
|
||||
for policy, `@<path>` reads a file). Remove a channel with `rig gateway channel remove <id>`, which refuses while
|
||||
non-terminal rows are addressed to it unless you pass `--force`.
|
||||
|
||||
## Policy is context, not enforcement
|
||||
|
||||
A participant's **effective policy** is its organization's text, then each of its groups', then its own, each labelled
|
||||
with where it came from. Agents read it (`rig gateway participants --json`, `rig gateway human show --json`) and apply
|
||||
it with judgment. It doesn't grant or block anything by itself, and a participant's name never implies a policy.
|
||||
|
||||
## The default person
|
||||
|
||||
Some things need a person without naming one: an escalation of stuck work, the digest of notices, Mission Control's
|
||||
my-queue, a workflow's human step when no person is configured for it, and a pairing approval without `--human`. They
|
||||
go to:
|
||||
1. the default person, when one is chosen (`rig gateway default <id>`, or `--default` on `human add`);
|
||||
2. otherwise the only registered person, so a one-person install needs no choice;
|
||||
3. otherwise nobody, and each says so: an escalation reports `undeliverable:no-default-person`, the notice digest
|
||||
waits, and the others name the command to run.
|
||||
|
||||
`rig gateway default` shows the current default; `rig gateway default --clear` clears the choice. Choosing a default
|
||||
never changes `workspace.operator_seat_name`, which keeps its own meaning.
|
||||
|
||||
## In the TUI: whose queue, and who acts
|
||||
|
||||
The person at the terminal is the **primary user**: the default person, or, with several people and none chosen, the
|
||||
person at the terminal unnamed. Whatever the TUI shows, its acts are theirs.
|
||||
- **Opening:** with a chosen default, the TUI opens on that person's Feed. With none chosen, including a one-person
|
||||
install that never chose one, it opens on the topology, as before.
|
||||
- **Whose queue:** `whose <person>` shows one person's asks, `whose all` everyone's, and `whose default` the default
|
||||
person's. The Feed's first line says whose queue it shows and who acts.
|
||||
- **Acting on someone else's ask** (answer, approve or resolve from the TUI) records both: the primary user as the actor,
|
||||
and the person asked in the audit notes and the transition. The asking seat reads, for example, "Answered by
|
||||
mike@external, the primary user at the terminal, on sarah@external's ask". With several people and none chosen, the
|
||||
actor is `human@host`.
|
||||
- **Viewing Sarah isn't acting as Sarah.** Only Sarah, from her own channel, answers as Sarah. Without the primary
|
||||
user's authority, one registered person can't answer, approve, deny or resolve what was asked of another; that's
|
||||
refused (`answer_not_recorded`) and nothing changes.
|
||||
- With no one registered, the TUI and its acts are as before, except on a shared channel's ask or a row parked on a
|
||||
channel: the TUI shows the act as `human@host`, the person at the terminal, and it's recorded that way, never as the
|
||||
channel.
|
||||
- **A shared channel never acts.** Its messages are correspondence, so nothing it says answers, approves, denies or
|
||||
resolves an ask, and a customer's earlier message in an ask's thread is never taken as its answer. A request without
|
||||
the primary user's authority that acts as a channel, with any Mission Control verb (including drop, route, hold, hand
|
||||
off and annotate), is refused (`answer_not_recorded`) and nothing changes.
|
||||
- **Where the authority applies:** only on a request that carries no seat identity header, which is how the TUI calls
|
||||
the daemon. Seats and the CLI send that header, so their acts stay theirs. This is the existing trusted-network
|
||||
distinction, not an authentication check: a request that leaves the header out is taken as the terminal's, as any
|
||||
such request already is.
|
||||
|
||||
## Shared-channel conversations
|
||||
|
||||
A registered shared channel lets people who aren't registered, such as a customer, talk to a seat, without registering
|
||||
each of them.
|
||||
|
||||
**Setup:**
|
||||
1. Invite the OpenRig Slack app to the channel.
|
||||
2. Register the channel: `rig gateway channel add acme-customers --display-name "Acme customers" --binding
|
||||
"slack:main:file:/etc/openrig/slack.env:C0ACME" --organization acme --policy-file ./acme-customers.md`.
|
||||
3. Send its new messages to the support seat: `rig slack channel-map set support-rig C0ACME --inbound
|
||||
support@support-rig`. Without a map, they go to the inbound destination.
|
||||
|
||||
A channel works on its own: no registered person is needed for it to converse.
|
||||
|
||||
**What the seat sees:** each message lands as a row from the channel (`acme-customers@external`). Its body ends with a
|
||||
labelled block: the channel, its organization and groups, who wrote (the Slack user, marked as not a registered person),
|
||||
and the channel's effective policy. The row is tagged `participant:acme-customers`, `shared-channel` and
|
||||
`slack-sender:<user id>`. A registered person writing in the channel stays themselves; the block only says where they
|
||||
wrote.
|
||||
|
||||
The channel's policy is the labelled block OpenRig adds at the end of the row, after the message. Anything above it is
|
||||
the sender's own words, even if it looks like policy. `rig gateway participants --json` is the source.
|
||||
|
||||
**Answering:** reply into the customer's thread with an update to the channel:
|
||||
|
||||
```bash
|
||||
rig queue create --destination acme-customers@external --human-intent update --reply-to <their row's id> \
|
||||
--summary "Your invoice" --body "It's on your account page."
|
||||
```
|
||||
|
||||
A later update with the same `--reply-to` lands in the same thread. An update without `--reply-to` posts to the channel
|
||||
on its own. Posts to a channel mention nobody.
|
||||
|
||||
**What a channel can't do:** its messages are correspondence. They never answer, approve, close or resume anyone's ask.
|
||||
You can still send an ask to the channel; the queue accepts it with a warning that nothing there will settle it, so read
|
||||
the replies and close it yourself. Its questions show in the channel as a list, without buttons, and ask for a reply in
|
||||
the thread.
|
||||
|
||||
## Where it's stored
|
||||
|
||||
- **People** stay exactly as before: one file per person under `$OPENRIG_HOME/gateway/humans/`, plus the generated
|
||||
`humans.generated.yaml`. Nothing new is written into them.
|
||||
- **Everything else** (the default, organizations, groups, each participant's placement and policy, and shared
|
||||
channels) is in `$OPENRIG_HOME/gateway/participants.yaml`, written by the commands above.
|
||||
- **A problem in `participants.yaml` never disables the person registry.** People still receive and answer their asks.
|
||||
The commands above report the problem, and with several people no default is chosen until it's fixed.
|
||||
- **A person removed by hand** leaves a warning in `rig gateway participants`, not a broken file. `rig gateway human
|
||||
remove` also removes the person's placement, and the default if it was them.
|
||||
|
||||
## Upgrading
|
||||
|
||||
For a one-person install, nothing to do: it keeps working as before, that person stays the default, pending rows keep
|
||||
their addresses, and no new file is written until you use one of the commands above.
|
||||
|
||||
A TUI older than the daemon still works: it sends the actor the daemon serves, the person asked, which is still their
|
||||
own act. On a shared channel's ask the daemon serves `human@host`. An older TUI's approve there is recorded as
|
||||
`human@host`, and its answer is refused (`answer_not_recorded`), because answering someone else's ask takes the primary
|
||||
user's authority, which only a current TUI sends.
|
||||
|
||||
**If several people are already registered** (earlier versions listed hand-added people with an advisory), choose a
|
||||
default with `rig gateway default <id>` after upgrading. Until you do, escalations report
|
||||
`undeliverable:no-default-person` instead of reaching whoever's id sorted first, and the notice digest waits.
|
||||
|
||||
## Rolling back to 0.7.2 or earlier
|
||||
|
||||
Older versions don't read `participants.yaml`, and the person files carry nothing they don't understand, so:
|
||||
- **every registered person still loads**, and each can still answer their own asks from Slack;
|
||||
- **another person's reply is guarded less on 0.7.1 and 0.7.0:** there, a different person's Slack reply in the thread
|
||||
of a question parked on someone else can resume that question, and 0.7.0 can record such a reply to a pending ask as
|
||||
an answer row (it still can't close the ask);
|
||||
- **there's no default:** escalations and the notice digest go to the first person by id, Mission Control's my-queue
|
||||
has no default person, and pairing needs `--human`;
|
||||
- **the TUI** opens on the topology and records a terminal answer as the person asked, as before. Rows already
|
||||
answered by the primary user keep their notes;
|
||||
- **shared channels are unknown:** their customers' messages are refused again, as from any unregistered sender, and
|
||||
rows addressed to a channel become undeliverable on the older version;
|
||||
- **organizations, groups and policy aren't shown.**
|
||||
|
||||
Upgrading again restores all of it, because the older version never changes `participants.yaml`.
|
||||
@@ -191,11 +191,11 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
|
||||
script will do and changes nothing:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.2/scripts/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
|
||||
The same command without `--dry-run`,
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh`, installs the
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.2/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
|
||||
@@ -331,16 +331,6 @@ every seat while those seats still run the sessions it started, reports that
|
||||
restore's outcome instead of starting another. Explicitly repeating the saved
|
||||
`--non-interruptive` or `--no-non-interruptive` choice also joins that restore.
|
||||
|
||||
`rig start --last` restores a rig unless every seat it launched was stopped on
|
||||
purpose with `rig down` or `rig seat stop`. A rig whose stop was never recorded
|
||||
counts as last running: one taken down with `rig down` before OpenRig 0.6.12, or
|
||||
one whose seats exited or were lost without `rig down`. So does a rig with no snapshot
|
||||
whose saved state can be restored. To keep such a rig down, run `rig down <rig>`
|
||||
once, by rig id when names are shared; it records the stop, and `--last` then
|
||||
leaves the rig down. `--last` never selects the kernel. Under `--no-kernel`
|
||||
(`OPENRIG_NO_KERNEL=1`) the daemon skips the kernel's automatic restore too;
|
||||
bring it back with `rig up kernel --existing`.
|
||||
|
||||
An explicit `rig up kernel --existing --fresh <seat...>` waits for startup's
|
||||
restore to finish, then replaces only the named seats that still hold the exact
|
||||
sessions startup launched. Other seats keep running. An operator's conversation
|
||||
@@ -708,11 +698,9 @@ partial terminal view is not a healthy team. Each `--provider herdr` open adds
|
||||
a new workspace, numbered when the name is taken; return to the one already open
|
||||
when you want to preserve it. From inside Herdr, `rig terminal open <view> --window`
|
||||
focuses a matching space instead of adding one. Herdr's own status for a seat can
|
||||
read idle or unknown while OpenRig shows it waiting (`needs-input`), for example in
|
||||
a narrow tile that wraps Claude's footer, or in a tile that shows the inner tmux
|
||||
status line (with `terminal.status_bar` on, or for a seat session OpenRig
|
||||
adopted rather than created). Use OpenRig's view or the Feed to find waiting
|
||||
seats. This is terminal integration, not native plugin enrollment.
|
||||
read idle or unknown while OpenRig shows it waiting (`needs-input`); use OpenRig's
|
||||
view or the Feed to find waiting seats. This is terminal integration, not native
|
||||
plugin enrollment.
|
||||
|
||||
For team views with interactive panes, the open result distinguishes the dashboard
|
||||
overview from the team's lead conversation and asks whether you can see the team.
|
||||
@@ -851,16 +839,14 @@ the lifecycle API without booting a new kernel, then stop the remaining rigs.
|
||||
| Shared terminal is absent | Inspect the existing kernel binding and recovery state; use standalone `rig tui` while resolving it. |
|
||||
| Viewing terminal was closed | Open the conversations again with `rig terminal open saved:kernel --window`; `rig tui --shared` is only the dashboard fallback if that window cannot open. Do not relaunch the team. |
|
||||
| Daemon restarted but tmux survived | Re-read `rig status` and the existing queue; a daemon restart is not a fresh project. |
|
||||
| Host reboot lost tmux sessions | Starting the daemon restores the kernel by itself when that start finds all of its seats lost; a kernel that was already down before the reboot stays down, and `rig start` names `rig up kernel --existing`. Check `rig status`, and if it reports the restore failed, run `rig up kernel --existing`. For other rigs, open `rig`, start the daemon if needed (press **S** if it opens on the work views), and select the existing rig and seats. Resume is the default; a fresh conversation needs a separate decision. |
|
||||
| Host reboot lost tmux sessions | Starting the daemon restores the kernel by itself when all of its seats were lost; check `rig status`, and if it reports the restore failed, run `rig up kernel --existing`. For other rigs, open `rig`, start the daemon if needed (press **S** if it opens on the work views), and select the existing rig and seats. Resume is the default; a fresh conversation needs a separate decision. |
|
||||
| Launch reports no usable snapshot | Inspect the existing rig and retained project files, then follow the same-seat recovery below. |
|
||||
| Work is waiting on a prompt or decision | Read the row, transition and named prompt; preserve the obligation until the missing decision arrives. |
|
||||
|
||||
**When the kernel's seats all fail at the first start.** A report on Oracle Linux 10
|
||||
([#980](https://github.com/mvschwarz/openrig/issues/980)) traced this to the distribution's packaged tmux, whose
|
||||
`tmux -V` prints `next-3.4`: its server exited on every `capture-pane -p`, also outside OpenRig. `rig setup` (its tmux
|
||||
step) and `rig doctor` (`tmux_capture` in `rig doctor --json`) run this check for you on a throwaway server and name the
|
||||
fix when capture fails; they test what tmux does, not its version. To run it by hand, without touching your own
|
||||
sessions:
|
||||
`tmux -V` prints `next-3.4`: its server exited on every `capture-pane -p`, also outside OpenRig. To check your tmux on
|
||||
a throwaway socket, without touching your own sessions:
|
||||
|
||||
```sh
|
||||
tmux -L "openrig-check-$$" -f /dev/null new-session -d -s check
|
||||
|
||||
@@ -139,9 +139,7 @@ issue's current status; a similar symptom alone is not a diagnosis.
|
||||
|
||||
- Claude seats reported as needing attention after a restore: [#86](https://github.com/mvschwarz/openrig/issues/86),
|
||||
[#273](https://github.com/mvschwarz/openrig/issues/273).
|
||||
- Claude usage limits may not be detected when neither a status line window nor a current
|
||||
`StopFailure/rate_limit` hook is available: [#99](https://github.com/mvschwarz/openrig/issues/99).
|
||||
See [Claude usage-limit detection](claude-status-line.md#claude-usage-limit-detection) for the inputs and retry behavior.
|
||||
- Claude usage limits may not be detected: [#99](https://github.com/mvschwarz/openrig/issues/99).
|
||||
- Codex prompt variants not recognised as idle: [#79](https://github.com/mvschwarz/openrig/issues/79).
|
||||
- Codex asks about hook trust at first launch: [#17](https://github.com/mvschwarz/openrig/issues/17), not reproduced
|
||||
by the team.
|
||||
@@ -149,9 +147,8 @@ issue's current status; a similar symptom alone is not a diagnosis.
|
||||
daemon, when its Codex configuration sets network access off, a managed requirement could restrict it, or Codex
|
||||
doesn't answer OpenRig's configuration read in time: [#275](https://github.com/mvschwarz/openrig/issues/275).
|
||||
- Kernel seats fail within a second of the first start on a distribution tmux whose server exits on `capture-pane`
|
||||
(`tmux -V` prints `next-3.4`): [#980](https://github.com/mvschwarz/openrig/issues/980). `rig doctor` checks for
|
||||
it (`tmux_capture`); the manual check and recovery are in
|
||||
[Incomplete setup and restart](getting-started.md#incomplete-setup-and-restart).
|
||||
(`tmux -V` prints `next-3.4`): [#980](https://github.com/mvschwarz/openrig/issues/980). The check and recovery
|
||||
are in [Incomplete setup and restart](getting-started.md#incomplete-setup-and-restart).
|
||||
|
||||
For everything else, search [open issues](https://github.com/mvschwarz/openrig/issues).
|
||||
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
# Hosted members: a tool outside the terminal on your team
|
||||
|
||||
A hosted member is a tool that lives outside a terminal, such as an assistant, a chat bot or a cloud routine, and works as a member of a rig. You
|
||||
address work to it like any seat. It reaches the team through OpenRig's MCP server under a grant you issue and can revoke. It never gets a pane.
|
||||
|
||||
Assistants that run on your machine can reach OpenRig's MCP server locally. Many assistants live in a vendor's cloud, and those can only reach a server on the internet.
|
||||
|
||||
> **Experimental and unsupported. Off by default.** Nothing in OpenRig listens on the internet unless you set it up yourself. You serve `rig mcp serve --http`, put it behind your own TLS tunnel or proxy, and issue a token. Doing that is like opening a port in your firewall. Anyone with the URL and a valid token can act as that member within its grant. Treat the token like a password, give the member the narrowest grant, and revoke it when you're done (`rig mcp token revoke <name>`). We don't support, and take no responsibility for, an OpenRig reachable from the internet, just as with running OpenRig itself on the open internet. Use at your own risk.
|
||||
|
||||
## Set one up
|
||||
|
||||
1. **Pick its address** on an existing rig: `<member>@<rig>`, for example `grok@research`. No node is added; the queue takes rows for it.
|
||||
2. **Say what it's good at, and its limits,** in a roster: an entry in `<workspace.root>/rosters/<file>.json` with its seat, this machine's host id
|
||||
(`hostSelfId` from `rig ps --nodes -A --json`), `capabilities`, `use_when`, `why`, a `caveat` for its limits, and `"hosted": true` (see
|
||||
[Rosters](rosters.md)).
|
||||
3. **Issue its grant:**
|
||||
|
||||
```sh
|
||||
rig mcp token add grok --seat grok@research --rig research
|
||||
```
|
||||
|
||||
It prints the token once. The member acts as `grok@research`: every row it creates or changes is attributed to that seat, and it reaches only
|
||||
that rig's seats and work.
|
||||
4. **Serve the MCP server over HTTP** behind a tunnel or proxy that adds TLS: `rig mcp serve --http`. See `rig mcp serve --help` for binding and
|
||||
for how authentication works. If the proxy runs browser checks (such as Cloudflare's Browser Integrity Check), skip them for `/mcp`:
|
||||
MCP clients aren't browsers.
|
||||
5. **Add the server in the tool:** its `https://…/mcp` URL and the token as a bearer credential.
|
||||
|
||||
## How it works its rows
|
||||
|
||||
- **Hand it work** as you would any seat: `rig queue create --destination grok@research …`.
|
||||
- **The member works it** through the MCP tools:
|
||||
- `rig_queue_list` with `owned` lists its rows;
|
||||
- `rig_queue_claim` takes one;
|
||||
- `rig_queue_update` closes it done with the answer in `resolution`, or `rig_queue_handoff` hands it on to a seat in its rig.
|
||||
- **It can send work too:** `rig_queue_create` to its rig's seats.
|
||||
- **Its rows outlive the conversation.** A new conversation with the same grant reaches the same work.
|
||||
- **`rig_whoami`** answers the seat its grant names, though the member has no session.
|
||||
|
||||
## Asking a person while keeping the work
|
||||
|
||||
When a row needs a person's approval before the member can finish it, the member keeps the row and asks:
|
||||
- **The row came from a person:** the member sets it `blocked` with `blockedOn` that person, and puts the question, with a short form of
|
||||
what it will do, in the row's `summary`. The person is asked with the summary and the row; a transition note stays in the row's history
|
||||
and isn't part of the ask. A longer draft goes at an https link in `evidenceRef`, which the ask carries. The person's answer returns the
|
||||
row to the member in progress.
|
||||
- **The row came from a seat, or from a shared channel:** the member asks as a new row, to that seat, or for a channel's row to its rig's
|
||||
lead (a shared channel takes correspondence and can't answer an ask). Then it sets its own row `blocked` on the new row's id. When the
|
||||
question closes, the member's row returns to pending.
|
||||
- **Keep the question and action summary short.** A long row body continues as numbered thread replies, within the existing delivery
|
||||
limits. Use an https `evidenceRef` for a longer approval draft. The summary and framing can still be too large, so nothing promises
|
||||
unlimited delivery.
|
||||
|
||||
Either way the row reads blocked, not done, while it waits, and the member finishes that same row. Nothing promises to wake the
|
||||
member, so it checks its rows again.
|
||||
|
||||
The server tells the member this in its MCP instructions. The text is versioned, so you can check which version a tool received:
|
||||
|
||||
> Member workflow (member-workflow/v2). Your inbox is the items addressed to your seat: rig_queue_list with owned set. It's yours, not the team's queue. A wake, a ring or a message only points at it: list your inbox, then act. For each item: claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update), or hand it on (rig_queue_handoff). Listing or reading an item doesn't take it. Say what you can reach from your grant, not from the tool list: a listed tool isn't access. rig_artifact_read's answer for an item names the shared roots you may read, and none means no shared files. If the person connected you, that's basically authorized: claim the items in your inbox and do them without asking whether you may. When an item needs a person's approval before you can finish it, don't close it and don't take the action: keep the item and ask. If it came from a person (an address ending in @external that isn't a shared channel; the item's 'Shared channel:' line names one), set it blocked with blockedOn that person and, as its summary, the question with a short form of what you'll do: the person sees the summary and the item, not your note, which keeps the record. Link a longer draft as an https evidenceRef. Their answer returns the item to you in progress. Otherwise ask as a new item, to the seat it came from or, for a shared channel's item, to your rig's lead (rig_team shows it), with your question and draft. Then set yours blocked with blockedOn that new qitemId: when that item closes, yours returns to pending, to claim again. Nothing promises to wake you, so check your inbox again. Then finish the same item; don't open a new one.
|
||||
|
||||
## Ring it when work lands
|
||||
|
||||
If the tool has a trigger that starts it from outside, such as a webhook on a scheduled routine, add it to the member's roster
|
||||
entry. The entry holds secret names, never values:
|
||||
|
||||
```json
|
||||
"wake": { "webhook": { "url_ref": "GROK_WAKE_URL", "authorization_ref": "GROK_WAKE_AUTH", "secrets_env_file": "/abs/path/grok-wake.env" } }
|
||||
```
|
||||
|
||||
- **The secrets:**
|
||||
- `url_ref` names the secret holding the webhook URL.
|
||||
- `authorization_ref`, when the tool needs one, names the secret holding the `Authorization` header value.
|
||||
- Credentials in the URL itself (`https://user:pass@host/…`) are sent as Basic `Authorization` instead, unless
|
||||
`authorization_ref` is set: then its value is sent, and the URL's credentials are dropped.
|
||||
- The daemon reads each from its environment (the name, or `OPENRIG_<NAME>`), else from `secrets_env_file`, an env file kept at mode 0600.
|
||||
- **The ring:** when a row lands for the member, the daemon POSTs
|
||||
`{"source":"openrig.queue","schema_version":1,"event":"work-waiting","qitem_id":…,"destination":…,"summary":…,"from":…}`.
|
||||
- It's a pointer, not the row's body: the member reads the row under its grant.
|
||||
- A receiver should tolerate a repeated `qitem_id`. A ring accepted just before the daemon stops, before it's recorded,
|
||||
is rung again on recovery, the same at-least-once window a terminal wake has.
|
||||
- It waits up to 10 seconds and doesn't follow redirects.
|
||||
- **What the trigger should do:** start a run that lists the member's rows (`rig_queue_list` with `owned`) and claims its work.
|
||||
|
||||
## What the record says, and doesn't
|
||||
|
||||
These facts stay separate on the row:
|
||||
- **Wake:** with a trigger, the row's wake result is `wake-accepted:webhook (<url_ref>) answered <status>` when the trigger took the
|
||||
ring, or `failed:webhook …`. Accepted means the tool's trigger answered, not that the member read the row.
|
||||
- With no trigger, the result is `unroutable`, and OpenRig tells the person: the operator seat when it's a person, else whoever
|
||||
created the row.
|
||||
- It tells the person too when the member was rung but hasn't claimed the work by the stuck sweep's unclaimed age
|
||||
(`queue.stuck_sweep_unclaimed_age_minutes`, 60 by default).
|
||||
- The member sees new work when it checks: when it's rung, when its person asks it to, or on a schedule the tool runs.
|
||||
- **Pickup:** its claim, a transition by its seat. Listing or reading a row isn't pickup.
|
||||
- **Result:** the closing transition and its resolution, or the handoff.
|
||||
- **Notification:** where a person asked, Slack's posted receipt. A posted notice isn't the person's acknowledgment.
|
||||
|
||||
**On the team:** `rig_team` lists the rig's hosted members (roster entries marked hosted that aren't one of its nodes), with their capabilities and caveat, no pane, and activity `unknown`.
|
||||
OpenRig doesn't observe a hosted member between its calls, so it never reads idle. `rig roster find` shows it the same way.
|
||||
- **Presence:** each entry carries `presence`, which says what OpenRig last saw of the member. It isn't pickup, and `null` means unknown:
|
||||
- `lastCallAt`: its last authenticated call to this host's `rig mcp serve --http`, recorded at most once a minute in
|
||||
`<OPENRIG_HOME>/mcp-http-presence.json`, beside the token file;
|
||||
- `lastWake`: its latest ring and the result, among its 50 most recent rows.
|
||||
- `rig roster show` and `find` show the last call; the last ring is in `rig_team`.
|
||||
|
||||
## Revoke
|
||||
|
||||
`rig mcp token revoke grok` refuses its next request. The rows it worked, and their answers, stay.
|
||||
|
||||
## Limits today
|
||||
|
||||
- **Webhook triggers only:** OpenRig rings a webhook. Email and other triggers aren't supported yet. A member without a trigger
|
||||
isn't rung, and its person is told instead.
|
||||
- **It can't act as a person:** it works rows addressed to its seat, a person's request to it included. It can't settle an ask addressed to a person, or hand work to one.
|
||||
@@ -120,11 +120,6 @@ or **"Not tested by OpenRig"**, which means exactly that. You're welcome to run
|
||||
|
||||
## 6. Submit it to openrig.dev/rigs
|
||||
|
||||
Before you submit, run `rig bundle check` on the folder at the commit you'll submit. openrig-registry's registry check
|
||||
runs the same check, with the latest published OpenRig, on the folder at your `ref`, and any finding fails it. A team
|
||||
made with `rig export` has no README of its own: add one and declare it in `rig.yaml` as `docs: [{ path: README.md }]`
|
||||
(see [section 1](#1-lay-out-the-folder)).
|
||||
|
||||
Open a pull request to [mvschwarz/openrig-registry](https://github.com/mvschwarz/openrig-registry) that adds one file,
|
||||
`registry/submissions/<your-team>.yaml`, with three fields: `repository` (your GitHub repository URL), `folder` (the
|
||||
folder holding `rig.yaml`, or `.` for the repository root) and `ref` (a branch, tag or commit). No pull requests? Open
|
||||
|
||||
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
|
||||
```
|
||||
|
||||
For a new install, use the latest release's tag, for example `v0.7.4`. If OpenRig is already installed, use the tag
|
||||
For a new install, use the latest release's tag, for example `v0.7.2`. If OpenRig is already installed, use the tag
|
||||
that matches `rig --version`, or upgrade first. If the command fails with `npx: command not found`, Node.js isn't
|
||||
installed or isn't on your PATH yet (see [What you need](#what-you-need)). Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what
|
||||
you want built.
|
||||
@@ -193,18 +193,6 @@ it as the step to finish: sign in, then continue at step 3.
|
||||
otherwise, or when the width can't be measured, the dashboard is on its own page. `saved:kernel` in
|
||||
`rig terminal open saved:kernel` is the name of this saved view.
|
||||
|
||||
## Learn it in this order
|
||||
|
||||
1. **How OpenRig works:** [the world and its purpose](../../packages/daemon/assets/onboarding/01-world-and-purpose.md),
|
||||
the first page a new agent in a team gets. It's written to agents, and it's the shortest account of rigs, seats and
|
||||
queued work. Your install has the same page under `$(npm root -g)/@openrig/cli/daemon/assets/onboarding/`.
|
||||
2. **First use:** this page, and [what these words mean](#what-these-words-mean).
|
||||
3. **One command:** `rig <command> --help` is the exact answer for the version you have installed, and `rig --help`
|
||||
lists every command.
|
||||
4. **Every command and flag:** the [CLI reference](../as-built/cli-reference.md).
|
||||
It describes the source commit it names, so where it and your installed `--help` disagree, `--help` is right for
|
||||
your version.
|
||||
|
||||
## More
|
||||
|
||||
- **Every detail of setup:** troubleshooting, permissions, headless and SSH use, and what can stop the view from
|
||||
|
||||
@@ -290,77 +290,6 @@ managed-launch path. Other version pairs and native enforcement need their own c
|
||||
The launch still uses inline JSON, subject to the operating system's argument-size
|
||||
limits.
|
||||
|
||||
### Per-member Codex permissions
|
||||
|
||||
Codex members can record their own sandbox and approval choice, even when they share a project folder:
|
||||
|
||||
```yaml
|
||||
codex_permissions:
|
||||
sandbox: workspace-write # workspace-write or read-only
|
||||
approval: never # never, on-request or untrusted
|
||||
network_access: false # workspace-write only
|
||||
writable_roots: ["/abs/lease"] # workspace-write only; absolute paths
|
||||
exclude_tmpdir_env_var: false # workspace-write only
|
||||
exclude_slash_tmp: false # workspace-write only
|
||||
```
|
||||
|
||||
OpenRig emits this envelope as explicit `-s`, `-a` and `sandbox_workspace_write.*` flags on
|
||||
every managed launch and resume of the seat (fresh, both resume paths, restore and same-seat
|
||||
handover), so a named `codex_config_profile` or a policy posture doesn't override those fields.
|
||||
Omitted workspace-write fields are recorded as `false` or `[]` and emitted as such. Full access
|
||||
is not an envelope: it stays the `full_bypass` mode. The envelope is the seat's explicit choice,
|
||||
so team and kernel launch defaults don't apply to it, and status names the policy posture or
|
||||
ambient `OPENRIG_YOLO` it replaces. With workspace-write, OpenRig still adds its queue-state root
|
||||
and, on a fresh launch, git metadata folders; with read-only it adds no writable roots. It adds
|
||||
no network setting of its own. Codex's implicit roots (the working directory and temporary
|
||||
folders) still apply: this is a native sandbox and approval selection, not a complete
|
||||
filesystem or credential-isolation guarantee. OpenRig's tests prove these launch flags, not
|
||||
that Codex enforces them, so check the seat's actual behaviour before relying on a narrower
|
||||
envelope. An OpenRig older than 0.7.3 doesn't read a recorded envelope, so after a downgrade
|
||||
the seat launches without it. The field is also supported on legacy flat Codex nodes; other
|
||||
runtimes reject it.
|
||||
|
||||
For an existing seat, put the envelope in a JSON file, or change its profile, with the existing
|
||||
audited permission command:
|
||||
|
||||
```sh
|
||||
rig seat set-permissions dev-impl@team --codex-permissions envelope.json --reason "Narrow the implementer"
|
||||
rig seat set-permissions dev-impl@team --codex-profile lease-impl --reason "Use the lease profile"
|
||||
rig seat status dev-impl@team --json
|
||||
```
|
||||
|
||||
Choose one of `--mode`, `--rules`, `--codex-permissions` or `--codex-profile` per call. A seat
|
||||
keeps one explicit permission choice: setting an envelope replaces a recorded mode, and setting
|
||||
a mode (including `inherit`) clears the envelope, in the same audited write that names both.
|
||||
A runtime switch keeps the envelope while the seat runs Claude Code and applies it again when
|
||||
`rig seat set-runtime` brings the seat back to Codex. A switch back to Codex with an explicit
|
||||
`--permission-mode` replaces the envelope when the switch commits, and the switch's plan and
|
||||
event name it. A switch that doesn't commit leaves it in place.
|
||||
A profile name is separate from the envelope and from approval: a resumed conversation keeps
|
||||
its saved approval unless an envelope sets one. `--codex-profile inherit` clears the profile.
|
||||
|
||||
The profile command checks the profile in the config home this seat's launches use: the seat's
|
||||
own `CODEX_HOME` when its `env:` declares one, else the daemon's explicit `CODEX_HOME`. There it
|
||||
reports an absent file (Codex loads it as an empty layer, so base config applies), records the
|
||||
file's hash so a later check can report a change, and refuses a profile that fails to load,
|
||||
keeping the previous one. When neither sets a `CODEX_HOME`, a seat's pane keeps its own, and the
|
||||
command reports the profile as unverified rather than checking a different home. Every launch
|
||||
still runs the native profile check.
|
||||
|
||||
Updates leave the running process and history unchanged; they apply at the next separately
|
||||
authorized managed launch or resume. Restore reads the seat's current record, so an older
|
||||
snapshot doesn't bring back a replaced profile. A fork copies the source's current profile and
|
||||
envelope independently. Export contains the current record. A new generation from `rig up
|
||||
<spec>` starts from the spec. An invalid or unreadable envelope refuses that seat's launch, fork
|
||||
or export with a repair message rather than launching broader. Without the field or a recorded
|
||||
update, launch arguments are unchanged.
|
||||
|
||||
**Downgrade:** an older OpenRig keeps the stored envelope but doesn't read it, so its launches
|
||||
of the seat use the earlier posture decision, which can be broader. This is not
|
||||
restriction-preserving. Keep a copy of the intended envelope and establish equivalent native
|
||||
restrictions before using an older managed-launch path. Native enforcement must be checked on
|
||||
the Codex version and configuration being used.
|
||||
|
||||
### Team launch defaults
|
||||
|
||||
A non-kernel Claude or Codex seat with no authored permission policy, explicit
|
||||
@@ -511,9 +440,9 @@ pods:
|
||||
**Where the declared names reach, by runtime.** Declared values are handed to the seat's terminal as tmux environment, so most seats take them as given:
|
||||
|
||||
- Plain Claude Code (no permission mode selected), Codex and terminal seats take a spec `HOME`, `PATH` or `CLAUDE_CONFIG_DIR` as given.
|
||||
- The cleared environment (`env -i`) belongs only to a managed Claude launch, which is the one used when a permission mode is selected for the seat (for example `permission_policy: builtin:auto`). It forwards the declared names through the cleared environment, except `PATH`, `HOME` and `CLAUDE_CONFIG_DIR`, which OpenRig sets for that launch itself, and `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`, for which it sets its own renderer setting. A spec value for any of those four is not forwarded as such; an absolute `CLAUDE_CONFIG_DIR` becomes the config dir OpenRig sets (see "A seat's own account" below). A seat that selected the shipped `openrig-core` plugin also has `CLAUDE_CODE_PLUGIN_DIRS` (the vendored plugin, added after a list the seat declares itself) among its declared names, so it is forwarded like the others and the plugin's hooks load in a seat with a permission mode as in one without; a seat that didn't select the plugin has no such name added. Beyond the declared names, the launch also forwards, by name and only when the seat's terminal has them set, the terminal and locale variables (`TERM`, `COLORTERM`, `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LANG` and the `LC_*` names) and the person's editor and display session: `EDITOR`, `VISUAL`, `DISPLAY`, `XAUTHORITY` and `WAYLAND_DISPLAY`. They let Claude's external-editor action open the person's editor and let image paste reach the clipboard, without a value in a spec. `WAYLAND_DISPLAY` is passed as an absolute path: an absolute value is kept, and a relative one is joined to the terminal's `XDG_RUNTIME_DIR`; if the terminal has no `XDG_RUNTIME_DIR` for it, the name is left out. The runtime directory and its `disabled:` bus address (see "The runtime directory, on Linux" above) are forwarded together, and the runtime directory is also forwarded beside a declared `DBUS_SESSION_BUS_ADDRESS`. A terminal's own `XDG_RUNTIME_DIR` or `DBUS_SESSION_BUS_ADDRESS`, from a shell startup file or tmux's `update-environment`, is not, because D-Bus clients look for the session bus at `$XDG_RUNTIME_DIR/bus` when no address is set; declare it in `env:` to forward it. `XDG_SESSION_TYPE` and `XDG_CURRENT_DESKTOP` are not forwarded. The SSH agent socket (`SSH_AUTH_SOCK`) is forwarded when OpenRig itself was started with one, so git over SSH works in the seat; a socket only the seat's terminal has stays out. To check, run `ssh-add -l` inside the seat. If it can't reach an SSH agent, start the OpenRig daemon from a terminal where `ssh-add -l` works. A running seat picks the names up at its next managed Claude launch, not before: `rig seat launch <seat> --fresh --stop --reason "<why>"` for that seat alone (a new conversation), or a restore of the whole rig (`rig down <rig>`, then `rig up <rig> --existing`, which resumes each conversation but restarts every seat in the rig), or a handover. A seat that is up keeps the environment it started with.
|
||||
- The cleared environment (`env -i`) belongs only to a managed Claude launch, which is the one used when a permission mode is selected for the seat (for example `permission_policy: builtin:auto`). It forwards the declared names through the cleared environment, except `PATH`, `HOME` and `CLAUDE_CONFIG_DIR`, which OpenRig sets for that launch itself, and `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`, for which it sets its own renderer setting. A spec value for any of those four is not forwarded as such; an absolute `CLAUDE_CONFIG_DIR` becomes the config dir OpenRig sets (see "A seat's own account" below). A seat that selected the shipped `openrig-core` plugin also has `CLAUDE_CODE_PLUGIN_DIRS` (the vendored plugin, added after a list the seat declares itself) among its declared names, so it is forwarded like the others and the plugin's hooks load in a seat with a permission mode as in one without; a seat that didn't select the plugin has no such name added. Beyond the declared names, the launch also forwards, by name and only when the seat's terminal has them set, the terminal and locale variables (`TERM`, `COLORTERM`, `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LANG` and the `LC_*` names) and the person's editor and display session: `EDITOR`, `VISUAL`, `DISPLAY`, `XAUTHORITY` and `WAYLAND_DISPLAY`. They let Claude's external-editor action open the person's editor and let image paste reach the clipboard, without a value in a spec. `WAYLAND_DISPLAY` is passed as an absolute path: an absolute value is kept, and a relative one is joined to the terminal's `XDG_RUNTIME_DIR`; if the terminal has no `XDG_RUNTIME_DIR` for it, the name is left out. The runtime directory and its `disabled:` bus address (see "The runtime directory, on Linux" above) are forwarded together, and the runtime directory is also forwarded beside a declared `DBUS_SESSION_BUS_ADDRESS`. A terminal's own `XDG_RUNTIME_DIR` or `DBUS_SESSION_BUS_ADDRESS`, from a shell startup file or tmux's `update-environment`, is not, because D-Bus clients look for the session bus at `$XDG_RUNTIME_DIR/bus` when no address is set; declare it in `env:` to forward it. `XDG_SESSION_TYPE`, `XDG_CURRENT_DESKTOP` and `SSH_AUTH_SOCK` are not forwarded. A running seat picks the names up at its next managed Claude launch, not before: `rig seat launch <seat> --fresh --stop --reason "<why>"` for that seat alone (a new conversation), or a restore of the whole rig (`rig down <rig>`, then `rig up <rig> --existing`, which resumes each conversation but restarts every seat in the rig), or a handover. A seat that is up keeps the environment it started with.
|
||||
- Codex seats run OpenRig's own Codex executable whatever a spec `PATH` says. They use the seat's own `CODEX_HOME` when its `env:` declares an absolute one, else the daemon's when the daemon has one.
|
||||
- On Pi and OMP seats the values reach the terminal. The agent process gets only what the runner passes on, so a spec or one-off value reaches it only for the names the runner always passes: `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL` and `TMPDIR`. Pi's agent process receives the runner's `HOME`, so a spec or one-off `HOME` value that reaches the runner is passed to Pi; OMP's agent process gets its seat state directory (`$OPENRIG_HOME/state/omp/<seat>`, see Oh My Pi below) as `HOME`, so a `HOME` value does not reach it. The selected provider's variables are passed separately, as described under each runtime.
|
||||
- On Pi and OMP seats the values reach the terminal. The agent process gets only what the runner passes on, so a spec or one-off value reaches it only for the names the runner always passes: `PATH`, `HOME`, `USER`, `LOGNAME`, `TERM`, `LANG`, `LC_ALL`, `SHELL` and `TMPDIR`. Pi's agent process receives the runner's `HOME`, so a spec or one-off `HOME` value that reaches the runner is passed to Pi; OMP's agent process gets its seat state directory (`$OPENRIG_HOME/state/omp/<seat>`, see Oh My Pi below) as `HOME`, so a `HOME` value does not reach it. The selected provider's variables are passed separately, as described under each runtime.
|
||||
|
||||
**A seat's own account.** Claude Code keeps its login in its config dir and Codex in its home, so a seat whose `env:`
|
||||
declares an absolute `CLAUDE_CONFIG_DIR` (Claude Code) or `CODEX_HOME` (Codex) runs on whatever login that directory
|
||||
@@ -596,7 +525,6 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
|
||||
| `agent_ref` | string | yes | — | Reference to an AgentSpec. Must start with `local:` (relative) or `path:` (absolute). Exception: `builtin:terminal` for infrastructure nodes. |
|
||||
| `profile` | string | yes | — | Profile name from the referenced AgentSpec. Use `default` for the default profile. Exception: `none` for terminal nodes. |
|
||||
| `codex_config_profile` | string | no | — | Codex-only native profile passed as `-p <name>`; letters, numbers, `_`, `.`, `-`. Separate from the AgentSpec `profile`. With the normal launch mode, this replaces OpenRig's explicit workspace-write sandbox flag. A full-bypass policy instead emits danger-full-access and omits this profile argument. |
|
||||
| `codex_permissions` | mapping | no | — | Codex-only sandbox and approval envelope, emitted as explicit flags on every managed launch and resume; see [per-member Codex permissions](#per-member-codex-permissions). |
|
||||
| `runtime` | string | yes | — | Agent runtime. Current supported values: `claude-code`, `codex`, `pi`, `omp`, `terminal`, `stub`. `stub` is a deterministic test harness, not an agent: a Node runner in the seat's tmux pane goes through the normal launch, skill projection, startup-file and readiness path, follows `<cwd>/.openrig/stub/script.json` when present, and calls no model. See "What a stub can and cannot prove" in `docs/as-built/test-layers.md`. |
|
||||
| `cwd` | string | yes | — | Working directory for the agent. A relative path resolves against the rig root (the directory containing the rig spec). If that path exists as written, it is used. Otherwise a leading `~` or `~/` expands to the home directory (`~user` does not) and `$VAR` / `${VAR}` expand from the daemon host's environment; a relative result still resolves against the rig root. A reference to an unset variable stays as written, so the cwd is that literal path and works only if it exists. Use `"."` for the rig root itself. Can be overridden at launch time with `rig up --cwd`. A cwd inside the OpenRig installation fails preflight unless `--cwd` is given. Editing this declaration does not update an existing seat; use [`rig seat set-cwd`](agent-startup-guide.md#change-an-existing-seats-working-directory) to save its future launch directory. |
|
||||
| `label` | string | no | — | Human-readable member name. Shown in UI when present. |
|
||||
@@ -653,7 +581,7 @@ own defaults/settings apply. Other runtimes ignore this field.
|
||||
- **State:** Each seat uses `$OPENRIG_HOME/state/pi/<session>/agent` as its Pi agent directory, with `sessions/` beside it, instead of your default `~/.pi/agent`. The runner sets `PI_CODING_AGENT_DIR` to that directory. `<session>` is the seat's session name, unchanged; for a pod member it is `{podId}-{memberId}@{rigName}`. `$OPENRIG_HOME` defaults to `~/.openrig`. OpenRig creates these directories at launch and does not copy anything from `~/.pi/agent`.
|
||||
- **Thinking level:** A Pi seat's effort (the choice saved with `rig seat set-effort`, else the spec's `effort`) is sent to Pi as `--thinking <level>` on every managed launch and resume: fresh launch, successor and restore. Pi accepts `off`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`; OpenRig passes the value as written and does not translate it, so Codex's `none` is not mapped to `off`. Only a value of the shape `set-effort` accepts (a letter, then up to 31 letters, digits, `-` or `_`) is sent; any other spec value, such as one with a space or one that starts with `--`, is left out, and `rig seat effort` says so. An unfamiliar level gets an advisory (a warning from `set-effort`), never a refusal, and Pi itself ignores an unknown level with a warning and keeps the level it would use without the flag: a model suffix, the resumed session's level, or its default. With no effort resolved, no flag is sent. The effort wins over a model suffix, and the model string is never changed. `rig seat effort` reports the level sent at launch and, on Pi 0.99.0 or later, the `thinkingLevel` Pi records on this launch's latest successful assistant turn (error and aborted turns are skipped); that is the session's level after Pi's own cap, not proof the provider applied it, and it is shown apart from the level sent when they differ. On older Pi it is NOT VERIFIED. Without an effort, you can still set the level as a model suffix, for example `model: openrouter/x-ai/grok-4:high`. Pi first looks for a model whose id is the whole pin, and uses it when there is one, suffix included. Only when there is none does Pi read the suffix as the level and record the model without it. Any other suffix, such as OpenRouter's `:free`, stays part of the model id. The model-divergence check reads the pin literally when the seat's `models.json` defines a model with that exact id; otherwise it accepts the model with or without the suffix. If the seat's `models.json` can't be read, a pin with a suffix stays unchecked, with the usual warning, when the recorded model matches the whole pin or the pin without its suffix. A record that matches neither is reported.
|
||||
- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi/<session>/agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file. A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`.
|
||||
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
|
||||
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
|
||||
|
||||
- **Extension dialogs:** The RPC runner shows extension `select`, `confirm`, `input` and `editor` requests in the pane and cancels them because the managed pane cannot answer them. The seat needs attention through the end of the turn, until a new agent run starts. Review the cancelled request and send an instruction the extension can complete without a dialog. Extension `notify` text is shown without raising attention or sending a response.
|
||||
|
||||
@@ -718,11 +646,7 @@ This is human-authored (you choose the pod/member IDs) and system-validated (the
|
||||
| `spawned_by` | Source was spawned by target; the target launches first. Constrains launch order. | Child → parent in hierarchical topologies |
|
||||
| `can_observe` | Source can observe target's output. Does NOT constrain launch order. | Reviewer → implementer, monitor → worker |
|
||||
| `collaborates_with` | Peer collaboration relationship. Does NOT constrain launch order. | Co-equal peers working together |
|
||||
| `escalates_to` | Fallback queue escalation for a seat without a declared delegation parent. Does NOT constrain launch order. | Local member or same-daemon `pod-member@rig` address |
|
||||
|
||||
A named `escalates_to` target may be in another rig on the same daemon and need not exist at import time.
|
||||
See [fallback escalation across rigs](edge-types.md#fallback-escalation-across-rigs) for precedence, single-hop delivery,
|
||||
cycle detection and visible fallback outcomes.
|
||||
| `escalates_to` | Source escalates to target for decisions. Does NOT constrain launch order. | Worker → lead for escalation |
|
||||
|
||||
### Pod-Local Edges
|
||||
|
||||
@@ -805,20 +729,6 @@ the modified topology, run `rig snapshot <rig-id>` and use its returned ID with
|
||||
|
||||
Startup blocks can appear at three levels: rig, pod, and member. They are merged additively via the startup layering model (see [the layering model](agent-startup-guide.md#the-layering-model)). The rig's `culture_file` joins as a required file with the `auto` hint in the culture layer, on both fresh start and restore.
|
||||
|
||||
`rig export` includes the rig's saved culture file and startup block, and now also
|
||||
writes each pod's saved startup files under that pod. The file order, delivery
|
||||
hints, required flags, orientation and `applies_on` selections are retained.
|
||||
Previously the saved pod file lists were omitted. Pods without a recorded file
|
||||
list, including older rigs, keep their existing export behavior; the exporter
|
||||
does not reconstruct declarations from a member's combined startup context.
|
||||
|
||||
File paths remain relative to the exported spec's folder. When exporting to a
|
||||
different folder, supply the referenced startup files there or import with the
|
||||
original `--rig-root`. The CLI warns once per missing required rig or pod file;
|
||||
missing optional files do not trigger that warning. Pod startup actions and
|
||||
member startup blocks remain outside this export; keep the source spec when you
|
||||
need those declarations.
|
||||
|
||||
### Files
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
@@ -827,7 +737,7 @@ need those declarations.
|
||||
| `orientation` | string | no | — | Set to `role` to identify a per-seat role file for `rig queue whoami --json` and refocus. Does not change delivery or replay startup. |
|
||||
| `delivery_hint` | string | no | `auto` | How the file is delivered. One of: `auto`, `guidance_merge`, `skill_install`, `send_text`. |
|
||||
| `required` | boolean | no | `true` | Whether startup fails if this file cannot be delivered. |
|
||||
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this file is delivered. Subset of: `fresh_start`, `restore`. An exact resume of the saved conversation delivers none, apart from putting back a missing managed guidance block. |
|
||||
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this file is delivered. Subset of: `fresh_start`, `restore`. |
|
||||
|
||||
#### Delivery Hints
|
||||
|
||||
@@ -845,8 +755,8 @@ need those declarations.
|
||||
| `type` | string | yes | — | Action type. One of: `slash_command`, `send_text`, `startup_proof`. Note: `shell` is explicitly NOT supported in v1. |
|
||||
| `value` | string | yes | — | Command/text to send, or `authenticated` / `none` for `startup_proof`. |
|
||||
| `phase` | string | no | `after_files` | When to execute text/commands. One of: `after_files` (after startup files are delivered), `after_ready` (after harness readiness check passes). Proof selection is resolved before projection regardless of phase. |
|
||||
| `idempotent` | boolean | yes | — | Whether this action is safe to run again on a restore without the earlier conversation or a retried start. **Required field.** Non-idempotent actions must NOT include `restore` in `applies_on`. |
|
||||
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this action runs. Subset of: `fresh_start`, `restore`. An exact resume of the saved conversation runs none (see the startup guide). |
|
||||
| `idempotent` | boolean | yes | — | Whether this action is safe to replay on restore. **Required field.** Non-idempotent actions must NOT include `restore` in `applies_on`. |
|
||||
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this action runs. Subset of: `fresh_start`, `restore`. |
|
||||
|
||||
### Startup proof selection
|
||||
|
||||
|
||||
@@ -86,9 +86,3 @@ UNKNOWN is `{ "status": "unknown", "reason": "..." }`; older daemons may yield
|
||||
`null`. `--full` and `--fields modelObservation` retain the full object, including
|
||||
its status and occupant generation. Human output and roster reports use the full
|
||||
labelled observation.
|
||||
|
||||
A member marked `"hosted": true` is a hosted member: a tool outside the terminal that reaches the team over MCP, with no node or pane.
|
||||
`rig_team` lists it with its capabilities and caveat, no pane and activity unknown, unless its seat is one of the rig's nodes.
|
||||
`show` and `find` say it's hosted rather than unmatched. See [Hosted members](hosted-members.md).
|
||||
Its entry can declare `wake.webhook`, the secret names for a trigger OpenRig rings when work lands for it
|
||||
(see [Ring it when work lands](hosted-members.md#ring-it-when-work-lands)).
|
||||
@@ -165,12 +165,6 @@ commit. Evidence capture (`proof add`), policy acceptance, higher outcome
|
||||
judgment and publication are distinct. This optional component does not select
|
||||
Part B or add a mandatory review gate.
|
||||
|
||||
A judgment binds the slice's path, the item's text and the policy. Moving or
|
||||
renaming a slice (`rig scope slice move`, or by hand) reopens its judged items:
|
||||
`rig proof show` reports each as `unknown`, and each needs a new judgment on its
|
||||
current revision. `rig scope audit` checks structure only and never reads proof
|
||||
readiness, so a passing audit doesn't mean the proof still stands.
|
||||
|
||||
The project readiness that `rig proof show --project` reports counts only
|
||||
missions whose `mission.yaml` carries `metadata.status: active` or
|
||||
`metadata.status: release-candidate`. `rig scope mission create` writes
|
||||
|
||||
@@ -16,28 +16,13 @@ type: `openrig-urgent`, `urgent-only` and `sends-only`. Sibling seats keep their
|
||||
|
||||
Lifecycle work is refused at `openrig-urgent` and `urgent-only`, as at `hold`.
|
||||
|
||||
A terminal member (`runtime: terminal`) defaults to `sends-only`, which holds OpenRig's own notices. Set one
|
||||
to `off` and those notices, such as park reminders and other watchdog messages, type into whatever the pane
|
||||
is running, and a shell prompt runs each line as a command, From and To lines included: OpenRig doesn't check
|
||||
a terminal pane's foreground process. `openrig-urgent` and `draft-aware` type only into a recognized empty agent composer, so
|
||||
a shell prompt holds them. Keep the default for a seat that may fall back to a shell.
|
||||
|
||||
Runtime permissions are separate. Recipient and interactive-prompt checks still
|
||||
apply. `--raw` and `--force` do not bypass the typing guard. A deliberate
|
||||
`--dangerously-interact --reason` remains available at every interruption level
|
||||
and uses the existing-input check below. Direct human input remains available.
|
||||
|
||||
A message sent to a busy Codex seat joins its running turn and may redirect it.
|
||||
For separate work, create a queue row instead:
|
||||
`rig queue create --destination <seat> --summary "<one line>" --body-file <file>`.
|
||||
A row that's saved keeps the work apart from its wake, the short pointer typed
|
||||
into the seat. The wake is still typed input, and it isn't proof that the seat
|
||||
claimed or finished the work: the row's state says that.
|
||||
|
||||
`rig send <seat> "<message>" --wait-for-idle <seconds>` suits a sender who
|
||||
watches the result. If the seat is still busy, or its activity is unknown, when
|
||||
the wait ends, nothing is sent (exit 1, "No text was sent.") and OpenRig keeps
|
||||
nothing to send later.
|
||||
For separate work, use `rig send <seat> "<message>" --wait-for-idle <seconds>`.
|
||||
|
||||
## Configure and inspect
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# Seat settings by node ID
|
||||
|
||||
Clients that present a specific seat can save its later-launch settings using its durable `nodeId`. The node ID is distinct from the current session and native conversation. These routes never launch, stop, or resume the seat.
|
||||
|
||||
| Read or save | Node-addressed route |
|
||||
| --- | --- |
|
||||
| Status | `GET /api/seat/status/node/:nodeId` |
|
||||
| Native permissions | `POST /api/seat/set-permissions/node/:nodeId` |
|
||||
| Permission policy | `POST /api/seat/set-policy/node/:nodeId` |
|
||||
| Model | `POST /api/seat/set-model/node/:nodeId` |
|
||||
| Effort | `POST /api/seat/set-effort/node/:nodeId` |
|
||||
|
||||
The POST bodies and responses follow the corresponding name-addressed routes. A successful save's `seat.nodeId` and `seat.rigId` identify the node actually updated. The exact status response adds `node_id`, `contract: "seat-node.v1"`, and `supported_actions` listing the four setters. Existing name status responses retain their shape.
|
||||
|
||||
An unknown, deleted, or archived node returns `seat_not_found` (HTTP 404) at node resolution. After awaited validation of a native Claude permission mode or Codex profile returns, the exact node-ID path checks availability again before saving; an unavailable node then returns `seat_not_found` without a setting or audit write. Existing native validation may refuse a changed target before this check: for example, Claude's managed context guard can report `permission_selection_refused` during preparation. The daemon never resolves a node ID through a reusable seat name. Setting writes have no general session precondition; a changed occupant on the same available node can receive a later save.
|
||||
|
||||
For example, `POST /api/seat/set-model/node/<nodeId>` accepts the existing `{ "model": "...", "reason": "...", "operator": "..." }` body. The existing audit actor, reason, native validation, permission precedence, and terminal policy rules still apply. The older `/:seatRef` routes remain available, including their name ambiguity behavior.
|
||||
@@ -58,15 +58,6 @@ not fill in; if so, follow the form.
|
||||
one, it is refused.
|
||||
10. Run `rig slack setup --channel <channel-id> --secrets-env-file <path>`, then
|
||||
`rig slack verify`, then `rig slack enable`.
|
||||
11. Enabling treats the asks already open as seen, so none of them post. To see them in Slack, run
|
||||
`rig slack post-open-asks`. Each goes through ordinary delivery at the level it was asked at
|
||||
(quiet hours, digest and the posting floor apply), and the command says what became of each:
|
||||
posted, already posted, queued awaiting its receipt, held, below the posting floor, no longer
|
||||
active, superseded, recipient changed, state unavailable, transport failed or uncertain. An ask
|
||||
asked at RECORD stays on Steering and the Feed without a post. Running it again reuses each ask's
|
||||
one decision rather than making another, so an ask whose post is confirmed isn't posted again. As
|
||||
with any Slack post, one whose earlier attempt Slack couldn't confirm may post once more, marked as
|
||||
a possible repeat. `--wait <seconds>` (default 10) is how long it waits for Slack receipts.
|
||||
|
||||
## What the app asks for
|
||||
|
||||
@@ -191,14 +182,6 @@ rig slack channel-map remove pr@my-rig
|
||||
- **Answers come back to where the person asked.** A person's new message is the root of its
|
||||
answer: when the seat it reached replies with `--reply-to` that message's row, the answer and
|
||||
later updates land in the person's thread, in the channel they used.
|
||||
- **One conversation, whoever writes.** Messages routed to the same continuing agent
|
||||
conversation can use that conversation's earlier context, whoever sent them. A queue row
|
||||
records who sent a message and which seat owns it; it doesn't start a separate agent
|
||||
conversation. If people need separate conversations, route them to separate seats
|
||||
(`--inbound` names the seat a channel's new messages reach). Separate channels help only when
|
||||
their routing also keeps the conversations separate: new messages in a seat's own channel
|
||||
still go to the inbound destination unless its entry names `--inbound`. A conversation shared
|
||||
on purpose is fine.
|
||||
- Missed-message recovery (below) covers the default channel only.
|
||||
|
||||
Invite the app to every mapped channel (`/invite @OpenRig`). `rig slack verify` checks
|
||||
@@ -207,8 +190,7 @@ app is in all of them. A saved change reaches a running connector when it next r
|
||||
daemon restart does (`rig daemon stop`, then `rig daemon start`). `rig slack disable --reason
|
||||
"<why>"` then `rig slack enable` rewires too, but alerts still waiting to be posted when it's
|
||||
re-enabled are treated as already seen and aren't posted. Only an earlier post that failed or
|
||||
didn't finish still posts: an alert whose row is still open, a decision-resolved notice, or a digest. `rig slack post-open-asks`
|
||||
then shows the asks still open. The TUI's Connections page says whether the running
|
||||
didn't finish still posts: an alert whose row is still open, a decision-resolved notice, or a digest. The TUI's Connections page says whether the running
|
||||
configuration matches the file.
|
||||
|
||||
The map is stored as `channelMap` in `slack-connector.json`, as a list of `{ "match", "channel" }`
|
||||
@@ -276,7 +258,7 @@ reaches a running connector when it next rewires, which a daemon restart does (`
|
||||
then `rig daemon start`). `rig slack disable --reason "<why>"` then `rig slack enable` rewires too,
|
||||
but alerts still waiting to be posted when it's re-enabled are treated as already seen and aren't
|
||||
posted. Only an earlier post that failed or didn't finish still posts: an alert whose row is still
|
||||
open, a decision-resolved notice, or a digest. `rig slack post-open-asks` then shows the asks still open.
|
||||
open, a decision-resolved notice, or a digest.
|
||||
|
||||
## One app across machines (optional)
|
||||
|
||||
|
||||
@@ -30,44 +30,16 @@ With neither a provider nor a session, the daemon's open and preview APIs try
|
||||
a live Herdr first, then a live cmux. An explicit session always selects Herdr
|
||||
and skips that fallback, including when the daemon's default Herdr endpoint is down.
|
||||
|
||||
Session paths use the daemon's `XDG_CONFIG_HOME` when set, or `HOME/.config`
|
||||
when unset. Relative paths (including an empty value) resolve from the daemon's
|
||||
startup directory, matching Herdr's configuration selection. Named sessions live under
|
||||
`herdr/sessions/<name>/herdr.sock`; `default` uses `herdr/herdr.sock` directly.
|
||||
These settings are captured when the daemon starts.
|
||||
|
||||
If the requested name equals the daemon's `HERDR_SESSION`, it keeps that
|
||||
session's explicit `HERDR_SOCKET_PATH`. A different requested name uses the
|
||||
configuration-tree path, without reusing the override or inferring a session
|
||||
from an arbitrary socket path. With an explicit socket but no configured
|
||||
session name, omit `--session` to keep that exact endpoint. A missing selected
|
||||
socket never falls back to another session. Viewer and follow operations carry
|
||||
that exact socket to the CLI through `HERDR_SOCKET_PATH`, without an explicit
|
||||
CLI `--session` that would override it. They check that the CLI reports the
|
||||
selected socket before changing its panes. Daemon-side CLI calls retain the
|
||||
startup environment; desktop follow uses the socket confirmed by the daemon.
|
||||
|
||||
The selection belongs to the request. Saved views still describe membership
|
||||
and layout, so pass `--session` again when reopening the view in that session.
|
||||
An explicit session resolves under the daemon user's Herdr session directory;
|
||||
it takes precedence over that daemon's default socket override. The selection
|
||||
belongs to the request. Saved views still describe membership and layout, so
|
||||
pass `--session` again when reopening the view in that session.
|
||||
|
||||
The existing view rules apply: saved views, rig and pod views, read-only mission
|
||||
and slice views, pagination, absent seats and partial results all use the same
|
||||
composer. Selecting a session changes where those tiles are placed. It does
|
||||
not relaunch the seats' tmux sessions or move queue ownership.
|
||||
|
||||
## Open results
|
||||
|
||||
For Herdr, a successful layout request alone does not count its seats as opened.
|
||||
OpenRig checks the returned tab's pane list. A missing tab identity or an
|
||||
unreadable, empty or unattributable listing puts the affected seats in
|
||||
`degraded`, with `code: "pane_unconfirmed"`, instead of `opened`. The CLI and
|
||||
TUI name those seats as unconfirmed. Inspect the terminal before retrying:
|
||||
the layout may have taken effect even though its panes could not be confirmed.
|
||||
Confirmed seats on other pages still count; with none confirmed, the command
|
||||
returns a nonzero exit status. Reusing an existing workspace keeps its separate
|
||||
reuse result. This pane check does not prove desktop visibility or create a
|
||||
verified viewer mapping.
|
||||
|
||||
## Replacing a seat's session
|
||||
|
||||
New tiles keep their place when a seat's tmux session is replaced. They wait up
|
||||
@@ -167,142 +139,3 @@ session keeps the normal HTTP 200 provider result with `ok: false`, its selected
|
||||
`session`, an error code and direct-attach guidance.
|
||||
The `kernel_seats_unavailable` result also keeps the selected session, so the CLI
|
||||
reports that known failure instead of replacing it with an unconfirmed-session error.
|
||||
|
||||
## Task terminal views
|
||||
|
||||
`rig terminal open qitem:<id> --provider herdr --json` opens a derived, read-only
|
||||
view of the current owners of a local queue episode. The episode includes the
|
||||
requested row and explicitly linked descendants; only pending, in-progress and
|
||||
blocked obligations contribute owners. It does not include historical senders,
|
||||
closed predecessors, parent episodes or siblings. Membership is not input
|
||||
permission.
|
||||
|
||||
The existing daemon endpoints accept the same view:
|
||||
|
||||
```text
|
||||
GET /api/terminal/preview?view=qitem:<id>&provider=herdr
|
||||
POST /api/terminal/open {"view":"qitem:<id>","provider":"herdr","expectedPlan":"<preview planId>"}
|
||||
```
|
||||
|
||||
Preview and open include `task: {qitemId, name, complete, issues, members}`. Each member
|
||||
names its structured `{hostId, nodeId}` key (null when unresolved), seat,
|
||||
contributing `obligations`, `availability` and `viewerStatus`. Stopped, missing,
|
||||
external and unresolved remote owners remain visible. Remote queues are not
|
||||
crawled and no remote tmux socket is guessed. A bounded or damaged lineage is
|
||||
explicitly incomplete; it is never silently presented as the whole team.
|
||||
|
||||
The local lineage read has work limits: 50,000 scanned rows (plus one overflow
|
||||
sentinel), 8 MiB of encoded lineage, 100,000 links, and 16 KiB per chain record.
|
||||
It reads one snapshot from the requested task forward, then probes earlier active
|
||||
obligations using the queue's existing state index and follows references by exact
|
||||
ID, including closed intermediaries. A child may arrive before its referenced root.
|
||||
All these reads share the work limits; unrelated older closed history is not scanned.
|
||||
Only the requested episode is expanded, up to 256 rows. Caller-supplied task IDs
|
||||
are supported.
|
||||
Hitting a work limit preserves known members and reports incomplete membership;
|
||||
it does not claim that an owner outside the scanned history is absent.
|
||||
|
||||
Preview and open also include `viewDescriptor: {kind, name}`: `following` for
|
||||
derived views and `fixed` for a saved layout. A task uses its row summary as
|
||||
its name, falling back to the qitem ID. The existing preview `view` string is
|
||||
unchanged. A following view derives membership when requested; it does not
|
||||
mean an open workspace updates itself.
|
||||
|
||||
Preview is read-only. A member without owned viewer provenance is
|
||||
`not-provisioned`; the ensure operation below can establish a reusable provider
|
||||
mapping. An explicit open retains the provider's existing workspace behavior;
|
||||
it does not continuously reconcile the view or change an agent's lifecycle.
|
||||
|
||||
## Following one task
|
||||
|
||||
`rig terminal open qitem:<id> --follow` keeps one workspace following the
|
||||
task's current obligation owners. A joining member gets a verified local
|
||||
viewer tile without taking focus. A departing member loses only its viewer,
|
||||
after a grace period; the agent and its conversation keep running. The
|
||||
explanation strip names joins, departures, unavailable terminals and limits.
|
||||
The first workspace on an empty provider may become focused.
|
||||
|
||||
This increment supports the qualified **Linux Herdr 0.9.5/protocol 22**
|
||||
endpoint. Other platforms/providers and other selector kinds retain ordinary
|
||||
one-shot open. Use `--provider herdr --json` to operate without opening a
|
||||
desktop window, and `--session <name>` to select that endpoint. Opening a tile
|
||||
can change the agent's terminal size; `ignore-size` does not guarantee isolation.
|
||||
|
||||
```bash
|
||||
rig terminal open qitem:example --follow --provider herdr --json
|
||||
rig terminal open qitem:example --follow --provider herdr --pin reviewer@team
|
||||
rig terminal open qitem:example --follow --provider herdr --max-tiles 12
|
||||
rig terminal open qitem:example --follow --provider herdr --suppress reviewer@team --suppression-ms 60000
|
||||
rig terminal open qitem:example --no-follow
|
||||
```
|
||||
|
||||
`--unpin` and `--unsuppress` reverse those presentation choices. The default
|
||||
cap is eight tiles; explicitly raising `--max-tiles` overrides it. Pins can
|
||||
exceed the cap rather than being evicted. A departed tile retained for typed or
|
||||
unconfirmed input does not consume a slot for joining members. Disabling removes
|
||||
the saved following selector and keeps the existing layout intact.
|
||||
|
||||
The default departure grace is four seconds (`--grace-ms`). A focused leaver
|
||||
gets another twelve seconds for reading (`--focus-retention-ms`); before its
|
||||
tile closes, focus moves to a verified surviving member, or to the explanation
|
||||
strip when none remains. Typed or unconfirmed input keeps the tile, labeled
|
||||
as no longer participating. Explicit suppression also retains a typed draft.
|
||||
Pi, terminal and community harness input may be unreadable to the composer
|
||||
inspector: those tiles remain while input is unconfirmed, even after unpinning.
|
||||
Each input-retained departure gets one delayed recheck after thirty seconds.
|
||||
After that, queue or node/session lifecycle events, or an explicit repeat of
|
||||
the follow command, can reconsider it. Activity hooks do not wake following,
|
||||
and input is not repeatedly polled. Draft text is never saved.
|
||||
|
||||
Following consumes Core events and one-shot retention/suppression deadlines,
|
||||
not repeated CLI membership queries. Complete empty membership drains the
|
||||
tiles and preserves the workspace. Once no retained tiles remain, following
|
||||
stops and the selector releases its active slot; new work does not automatically
|
||||
restart it. Run `--follow` again to resume the same workspace. An incomplete source, failed provider read
|
||||
or changed pane/terminal generation retains the previous view and reports
|
||||
the uncertainty. It never authorizes a blind workspace rebuild or close.
|
||||
`rig terminal views --json` and `rig terminal status --json` include the saved
|
||||
active selectors and latest following status. Active selectors and owned viewer provenance
|
||||
survive daemon restarts; task membership is always re-derived.
|
||||
|
||||
The owner-authorized HTTP operation is `POST /api/terminal/follow` with
|
||||
`{view:"qitem:<id>",provider:"herdr",session?,enabled:true,maxTiles?,pin?,unpin?,
|
||||
suppress?,unsuppress?,suppressionMs?,graceMs?,focusRetentionMs?,expectedPlan?}`.
|
||||
Seat controls are arrays of names from the current/retained view. It uses the
|
||||
existing terminal bearer boundary, like ensure-viewer. The response includes
|
||||
the stable `workspaceId`, normal open partitions, `viewDescriptor`, and
|
||||
`following:{id,enabled,state,maxTiles,tiles,events,overflow,nextAt?}`. Presentation
|
||||
provenance grants no membership or input authority. This is a local native
|
||||
viewer operation; browser input and remote-host provisioning are separate.
|
||||
|
||||
## Ensuring an owner viewer
|
||||
|
||||
The owner-facing HTTP operation `POST /api/terminal/viewer/ensure` accepts
|
||||
`{view:"qitem:<id>", member:{hostId,nodeId}, provider:"herdr", session,
|
||||
expectedPlan}`. Use a current preview's member key and plan. This explicit
|
||||
operation uses the daemon's existing terminal bearer and browser boundary;
|
||||
a bridge must retain its signed-in owner, Origin and expiry controls. It does
|
||||
not grant authority based on task membership or accept a caller terminal ID.
|
||||
|
||||
On the qualified Linux Herdr 0.9.5/protocol 22 endpoint, it creates one viewer
|
||||
or reuses an owned, verified `{workspaceId,paneId,terminalId,readOnly}` mapping.
|
||||
A repeated/concurrent request does not create another viewer. A lost create
|
||||
reply is reconciled before starting anything else; a lost run reply is checked
|
||||
against the actual foreground process, never blindly repeated. Unknown or
|
||||
changed terminal identity remains unconfirmed. The operation does not relayout,
|
||||
close departed viewers, take over control, focus existing panes or launch/stop
|
||||
agents. A first workspace on an otherwise empty Herdr server may become focused.
|
||||
|
||||
Preview remains passive and names `not-provisioned`, `verified` or `unconfirmed`
|
||||
viewer status. Studio can ensure a new member, add its browser frame, and retain
|
||||
a pinned leaver labeled as no longer participating. The provider mapping is
|
||||
small presentation provenance, separate from queue membership. Herdr display
|
||||
labels are not that provenance. Other providers/platforms remain unsupported
|
||||
for ensure-viewer until qualified; ordinary open retains its existing behavior.
|
||||
|
||||
Writable tile clients use `tmux attach -f ignore-size`; read-only clients keep
|
||||
`-r` (which also implies ignore-size). This can avoid contributing to sizing
|
||||
when other clients establish the agent window's dimensions. It is **not resize
|
||||
isolation**: opening a tile can change the agent's terminal size when it is the
|
||||
only sizing client. Observers of a Herdr terminal do not themselves resize it;
|
||||
a controlling browser can. No cross-version resize guarantee is implied.
|
||||
+264
-151
@@ -1,165 +1,288 @@
|
||||
# What changed in OpenRig 0.7.1
|
||||
# What changed in OpenRig 0.7.2
|
||||
|
||||
This note is for an agent on an install that was just upgraded to 0.7.1. It lists only what behaves differently from
|
||||
0.7.0 and what to do about it. For everything OpenRig can do, read the capability map:
|
||||
This note is for an agent on an install that was just upgraded to 0.7.2. It lists only what behaves differently from
|
||||
0.7.1 and what to do about it. For everything OpenRig can do, read the capability map:
|
||||
`rig context get onboarding-width/public-what-you-can-do.md`. Each release replaces this file, so
|
||||
`rig context get reference/whats-new.md` describes the installed version once the upgraded daemon is running. Without
|
||||
a running daemon, read `$OPENRIG_HOME/reference/whats-new.md`. The daemon refreshes that copy when it starts, so right
|
||||
after an upgrade it can still hold the previous version's note.
|
||||
after an upgrade it can still hold the previous version's note. The capability map hasn't been
|
||||
updated for 0.7.2 yet, so this note is where 0.7.2's new commands are listed.
|
||||
|
||||
## 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
|
||||
- **Restart the daemon** (`rig daemon stop`, then `rig daemon start`). Six migrations run on the first start (132, 133,
|
||||
134, 136, 138 and 139). They add records for runtime switches, stream reader actions, a seat's launch recipe, a Claude
|
||||
seat's token reference and its tool rules, and rebuild the provider usage readings table to hold labelled Claude
|
||||
token readings, keeping existing readings. No setting is added or removed.
|
||||
- **The bundled openrig-core plugin goes from 0.1.18 to 0.1.19.** Only two skills changed: `delegating-work` (its
|
||||
description now also covers deciding how far to trust a delegated result) and `openrig-user` (reading a queue write,
|
||||
a seat's own Claude or Codex account, moving one seat to another login, and the MCP handoff and unclaim tools). As in
|
||||
0.7.1, startup 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.
|
||||
- **Startup also refreshes the person-facing `rigs` skill** in `~/.claude/skills` and `~/.agents/skills`, with the same
|
||||
copy first, when OpenRig installed it (a copy installed another way is left as it is). It now says what setup and
|
||||
Codex seats write to a Codex home, offers the operator conversation to a Codex user who declined Codex's remembered
|
||||
command rule, and covers Rex.
|
||||
- **Codex seats:** OpenRig's Codex plugin is openrig-core, so Codex sees it as changed. As in 0.7.1, OpenRig renews
|
||||
its own hook approval only where the installed plugin folder matches what shipped. If a Codex seat stops at Codex's
|
||||
"Hooks need review" after the upgrade, that folder held other files: review the hooks in Codex.
|
||||
- **On Linux, check seats that use your session bus** (the keyring, notifications). From their next launch, restore or
|
||||
handover they get `DBUS_SESSION_BUS_ADDRESS=disabled:` unless their `env:` sets an address (see Known gaps).
|
||||
- **Launch fixes reach a seat at its next launch, resume, restore or handover,** not a running process: the Read
|
||||
grants a managed Claude restore needs, a Codex seat's writable git and topology folders, a seat's own account home,
|
||||
the Linux runtime directory and member tool rules.
|
||||
- **Close and reopen terminal views opened before the upgrade** (Herdr, cmux or tmux). Only views opened on 0.7.2 keep
|
||||
their tiles when a seat's session is replaced.
|
||||
- **If a seat runs on a home given by `rig up --env CLAUDE_CONFIG_DIR=…` or `--env CODEX_HOME=…`,** declare it in the
|
||||
member's or rig's `env:` instead. 0.7.2 leaves both names out of `--env`, with a note.
|
||||
|
||||
## New things you can do
|
||||
|
||||
- **Choose how much automatic input reaches a seat:** `rig seat set-typing-guard <seat> --mode <mode> --reason "…"`, where
|
||||
`<mode>` adds `openrig-urgent` (OpenRig's own notices and anything urgent) and `urgent-only` between `off` and `hold`.
|
||||
Mark a message urgent with `rig send --urgent`, or create the row at priority `urgent` or `critical`. Held input stays
|
||||
readable (`rig seat held-messages`) and is never replayed.
|
||||
- **Choose how far OpenRig and peers may act on a seat:** `rig seat set-terminal-policy <seat> --preset
|
||||
no-peer-interventions|help-within-instructions|full-control --reason "…"`, with `--helper <addresses…>`,
|
||||
`--rig-default` (seats added later), and exceptions such as `--peer-compaction deny` or `--openrig-model allow`.
|
||||
Managed compaction and saved model or effort changes are checked; a denial is `terminal_policy_refused`. Other
|
||||
interventions are guidance only.
|
||||
- **Read a seat's planned permissions:** `rig seat status <seat>` shows what its next fresh managed launch will pass and
|
||||
where each part comes from, and its terminal policy. `rig setup --router-line yes|no` decides whether OpenRig adds its
|
||||
one line to shared guidance files.
|
||||
- **Let a person choose whether every ask reaches Slack:** `rig gateway human set <id> slack-subscription
|
||||
every-ask|urgent-only|unset`. Every ask is on the TUI's Feed either way. Under `urgent-only`, only an ask
|
||||
created or handed off with `--alert` posts. An ask marked `--alert` stands out in Slack (an interrupt with a mention)
|
||||
under either setting, so use it only for something only they can do, or an approval whose wait costs now.
|
||||
- **Give one Claude member its own setup** in the rig spec: `agent_file`, `append_system_prompt_file`, `plugin_dirs` and
|
||||
`advisor` (an Advisor model). They're copied into that seat's storage, never the project.
|
||||
- **See a seat's own skills:** `rig skill loadout --seat <session|self>`.
|
||||
- **Reach the team from an assistant without a shell:** the MCP server has 34 tools, including `rig_whoami`,
|
||||
`rig_restore_status`, `rig_team`, `rig_telemetry_events`, following a handoff, reading a finished item's artifact and
|
||||
deciding inside a person's decision room. `rig mcp serve --http` serves them over HTTP; `rig mcp token add <name>
|
||||
--seat <session>` gives each caller its own seat.
|
||||
- **Hand a row's task to an outside agent:** `rig acp run <row>` for an ACP agent (Hermes, OpenClaw).
|
||||
`docs/reference/a2a-agents.md` covers A2A agents.
|
||||
- **Run a community harness that names, reports or reopens its conversations:** preset fields `launch_result`, `resume`,
|
||||
`names_conversation` and `startup_assisted`, with examples for Cursor, Hermes and OpenClaw under
|
||||
`docs/reference/community-harness-examples/`.
|
||||
- **Change one edge on a running rig:** `rig edge add <rig> <from> <to> --kind <kind>` and `rig edge remove <rig>
|
||||
<edge-id>` (`--plan`, `--json`). A `delegates_to` change says when it moves who work routes to.
|
||||
- **Run one spec as several rigs:** `rig up <spec> --name <rig>`.
|
||||
- **Retune a watchdog in place:** `rig watchdog update <jobId> [--interval-seconds n] [--message text] [--spec path]`
|
||||
keeps its id and attachments. The context watch takes `--threshold-tokens` or `--threshold-percent`.
|
||||
- **Put restore maps where seats may write:** `rig config set policies.claude_compaction.preparation_root <abs path>`.
|
||||
- **Pick a Herdr session:** `rig terminal open <view> --provider herdr --session <name>`.
|
||||
- **Close a row with its artifact:** `rig queue update <id> --state done --evidence-ref <path>`.
|
||||
- **File and read subscription move reports:** `rig provider moves report` and `rig provider moves`. `--host <id>` and
|
||||
`--codex-home <dir>` on `rig auth status|list|switch` and `rig seat restart` act on another machine's Codex home.
|
||||
- **Route stream observations with typed decisions (opt-in, off by default):** once `decisions.enabled` is true,
|
||||
`decisions.consumers` includes `stream` and the intake seat has `OPENROUTER_API_KEY`, `rig project wake --decide`
|
||||
gives each eligible stream observation one bounded typed decision during that seat's wake: create a row for a supplied
|
||||
owner, join supplied work, or record a duplicate or no action. Anything uncertain, refused or failed goes to the
|
||||
intake seat as its own disposition, and the model isn't asked again. `rig project decisions status` shows the state
|
||||
without making a call. Without `--decide`, or with decisions off, nothing changes; turning decisions off no longer
|
||||
strands an accepted create or join whose effect hadn't landed.
|
||||
- **Read the stream as a subscriber:** `rig stream subscribe <seat>` (`--tags`, `--type`; re-subscribing keeps the
|
||||
place), then `rig stream read` returns the next bounded page for the calling seat (at most 20 items and 64 KiB, with
|
||||
`more`, `unread` and any `gap`). `rig stream subscriptions` lists readers; `rig stream unsubscribe <seat>` stops one.
|
||||
`rig stream show <id>` says what became of an observation.
|
||||
- **Keep the kernel to the harnesses you chose:** `rig setup --providers <list>` is now saved as `provider.selected`, and
|
||||
the kernel starts seats only for those.
|
||||
- **Stop a second daemon on a live database:** `rig config set daemon.db_conflict stop` refuses a start whose database a
|
||||
matching live daemon already holds (the default `warn` only warns). `rig daemon start --allow-db-conflict` allows one
|
||||
deliberate start.
|
||||
- **Move a live seat between Codex and Claude Code:** `rig seat set-runtime <seat> --runtime <claude-code|codex>
|
||||
--reason "…"`, with `--dry-run` first to see the target's settings, what is cleared and the costs. The successor
|
||||
starts beside the current occupant, reads a handover (the seat's recap, its open rows and the predecessor's screen)
|
||||
and confirms with `rig seat set-runtime <seat> --confirm <switch-id> --note "…"`. Only then does the seat move, keeping
|
||||
its address, connections, rows and claims. Without a confirmation in 180 seconds (`--confirm-timeout`) nothing
|
||||
changes; `--recover` finishes an interrupted switch. The switch starts a new conversation and isn't written back to
|
||||
the team file (`docs/reference/getting-started.md`, "Move a seat to the other runtime").
|
||||
- **Run a seat on its own Claude or Codex account:** sign a home in once natively (`CLAUDE_CONFIG_DIR=<home> claude`
|
||||
then `/login`, or `CODEX_HOME=<home> codex login`), then declare that absolute path as `CLAUDE_CONFIG_DIR` or
|
||||
`CODEX_HOME` in the member's or rig's `env:`. Launch, resume, restore, handover, OpenRig's managed Codex config and
|
||||
hooks, usage limits and status follow it. `rig seat status` and `rig provider status` show the home and where it came
|
||||
from; a relative value is ignored and shown.
|
||||
- **Move one seat to another login on the same conversation:** for a Codex seat on Linux, `rig provider precheck --seat
|
||||
<seat> --to-account <name>`, then `rig provider switch --seat <seat> --to-account <name>`, using a login saved in that
|
||||
seat's Codex home (`rig auth save`). It saves the outgoing login, activates the chosen one, restarts the seat in place
|
||||
and prints a restart command for every peer on that home. For a Claude seat, `--to-account <label> --token-file
|
||||
<absolute path>` moves it to its own subscription token (from `claude setup-token`, in a file only the user can read);
|
||||
OpenRig keeps the path and label, never the token. `--stored-login` moves it back.
|
||||
- **Read a labelled Claude token's usage:** `rig provider status --refresh --provider claude --claude-token-file
|
||||
<absolute path> --credential-label <label>`. Each refresh spends one model turn; status and the TUI show only the
|
||||
cached reading. The label is yours, not an account Claude reported.
|
||||
- **Give each Claude member its own tool rules,** even in a shared folder: `claude_permissions: { allow: [...], deny:
|
||||
[...] }` on the member, or `rig seat set-permissions <seat> --rules <json-file> --reason "…"` for an existing seat.
|
||||
The update is audited, applies at the next managed launch or resume, and leaves the permission mode alone. Rules join
|
||||
OpenRig's one `--settings` value and never go into the project's Claude settings. They're native tool rules, not OS
|
||||
isolation, and an allow list doesn't withdraw broader inherited grants. Limits are under Known gaps.
|
||||
- **Say what you did about a stream item:** `rig stream act <id>` with exactly one of `--work <qitem>` (work you opened
|
||||
or joined through the queue), `--decline "<reason>"` or `--duplicate <qitem|stream id>`. Actions are append-only, the
|
||||
poster's `rig stream show` lists each reader's current action, and retention keeps an item a reader acted on. A
|
||||
reader who links different work from someone else is told about theirs.
|
||||
- **Pass held work on over MCP:** `rig_queue_handoff` closes an item as handed off and opens its successor in one step,
|
||||
and `rig_queue_unclaim` puts a claim back. The MCP server has 36 tools.
|
||||
- **Release a row parked on a typed blocker:** `rig queue resolve <id> --decision "<evidence>"` now also unparks a row
|
||||
blocked on `external:`, `fold:` or `auth:`, and records the evidence as a wait resolution, not a person's decision.
|
||||
- **Open views in Rex:** from a Rex pane, `rig terminal open <view> --window` or the TUI opens the view as a new Rex
|
||||
session and switches the Rex app to it. `--provider rex` picks it explicitly. Outside Rex the default stays Herdr,
|
||||
then cmux (`docs/reference/terminal-sessions.md`). Not run on Linux.
|
||||
- **Hand a community harness its first turn at launch:** a preset's `first_input: true` writes the first startup text
|
||||
to a private file and passes `OPENRIG_FIRST_INPUT=<path>`; the launcher hands it to the harness. The Cursor example
|
||||
uses it and now sets `startup_assisted: false`. OpenClaw gains a Gateway-client example (`openclaw-gateway.yaml`)
|
||||
beside the local one, and both resume a named conversation.
|
||||
- **Start one seat of a materialized team:** after `rig import <spec> --materialize-only`, `rig launch <rig> <logical
|
||||
id>` (for example `dev.lead`) starts only that never-started seat. Teams materialized by older daemons aren't covered.
|
||||
- **Clear a prompt a hook still names:** once `rig capture <seat>` shows it's gone, `rig seat clear-attention <seat>
|
||||
--reason "<what you saw>"` clears a retained permission or waiting hook, so sends and wakes go through. A refusal that
|
||||
rests on such a hook now names this step.
|
||||
|
||||
## Behaviour that changed
|
||||
|
||||
- **Team skills live in each seat's own storage** (`$OPENRIG_HOME/state/seats/<rig id>/<logical seat>/<runtime>/`), not
|
||||
in the project's `.claude/skills` or `.agents/skills`. Claude seats get them as a generated plugin (`openrig:<id>`),
|
||||
Codex seats as their own catalog. If a link sits in that path, the seat starts without them and the result names
|
||||
what wasn't delivered.
|
||||
- **Urgent wakes are labelled:** an urgent or critical row's wake starts with `Urgent (marked by …):` at every seat. A
|
||||
seat on `hold` gets one held notice per wake instead of re-nudges, and lifecycle work is refused at the middle levels as
|
||||
at `hold`.
|
||||
- **A daemon restart picks up a managed compaction where it stopped.** An owed restore step resumes, a `/compact` that
|
||||
may have been sent is never sent again, and `rig compact --state` shows anything stranded or owed.
|
||||
- **A Claude seat whose startup turn is refused** (an expired login, say) holds the rest of its startup context as owed.
|
||||
A startup input sent before the refusal is noticed can still be submitted (see Known issues in the
|
||||
[0.7.1 release notes](https://github.com/mvschwarz/openrig/releases/tag/v0.7.1)).
|
||||
- **`rig send` refuses with `target_needs_input`** when a Claude seat's hook has just reported it running and its screen
|
||||
shows an MCP enablement menu, a question or a permission prompt.
|
||||
- **`rig down` asks before stopping the caller's own rig.** Without a terminal it exits 1, stops nothing and prints the
|
||||
`--yes` command.
|
||||
- **`rig up --plan` doesn't start a stopped daemon:** it exits 1 with `daemon_not_running` and names `rig daemon start`
|
||||
and `--no-kernel`.
|
||||
- **`rig daemon start`** waits up to 20 seconds for a slow daemon, and starts with a warning when only a secondary
|
||||
listener, such as Tailscale's, can't be verified.
|
||||
- **A seat joined with `rig attach` as an outside app** (Claude Desktop) stays attached through `rig down` and restore
|
||||
(`left_attached`). To replace it, `rig unclaim <name>`, then launch.
|
||||
- **New seats get the daemon's `OPENRIG_DB` and `TMUX_TMPDIR`.**
|
||||
- **A rebuilt Codex seat keeps its observed permissions,** and a read-only Codex profile launches without OpenRig's
|
||||
writable roots.
|
||||
- **A seat's own role** goes only to that seat, never into a shared `CLAUDE.md` or `AGENTS.md`, and `rig down` returns
|
||||
guidance files byte for byte.
|
||||
- **Workflow actions** (`project`, `route`, `resume`, `abort`) record the calling agent; a request with neither identity
|
||||
nor actor gets the shared `actor_required` error.
|
||||
- **`rig provider moves` is back** for subscription move reports. It switches nothing.
|
||||
- **`--dangerously-interact` into a seat's input box stops first** when the box already holds typed or pasted text: it
|
||||
shows that text (apart from suggestions) and types nothing. Clear the box and retry, or pass `--type-without-checking`,
|
||||
which clears nothing. Keys for a recognized native menu are sent as before.
|
||||
- **A terminal seat gets exactly the text sent to it,** without the From/To lines (the result says `envelopeOmitted`).
|
||||
A queue wake to a terminal seat types nothing and fails `failed:terminal-seat`; the row stays queued.
|
||||
- **A daemon restart that can't see the seats' tmux server** no longer detaches seats whose recorded process still
|
||||
runs, so it doesn't relaunch them beside themselves.
|
||||
- **Provider credentials and the activity-hook token stay off tmux, shell and `env` command lines** at launch and
|
||||
handover. A launch whose staging fails stops instead of falling back.
|
||||
- **An answer retried from Slack or Steering** after an interrupted close reuses the first reply and finishes the
|
||||
request once.
|
||||
- **One bad row no longer stops the stuck sweep** from flagging the others; **a malformed unrelated diagnosis row** no
|
||||
longer stops health diagnoses from listing.
|
||||
- **A working Claude seat that quotes approval text** in its output no longer refuses sends and wakes as if it were at
|
||||
a prompt, including when its input box is drawn high in a tall pane. The permission dialog, AskUserQuestion and the
|
||||
MCP menu still refuse.
|
||||
- **A Claude draft whose first line is blank** reads as a draft, so a send doesn't type into it or submit it.
|
||||
- **A draft-aware paste to a Claude seat** is no longer read as edited when Claude swaps its footer for "paste again to
|
||||
expand", so OpenRig no longer withholds Enter for that reason. It isn't confirmed to fix #14 (see Known gaps).
|
||||
- **A send no longer answers a Pi dialog.** At an adopted Pi TUI's selector (a confirmation), an ordinary `rig send`
|
||||
used to type and press Enter, choosing the highlighted answer; it now refuses with `target_needs_input` and types
|
||||
nothing (#2015). #2026 extends this to Pi v1.4.0 extension input and editor dialogs, including help wrapped over up to
|
||||
three rows in an 80-column pane. The audited deliberate-interaction route is unchanged.
|
||||
- **Runner-managed Pi seats show and cancel an extension dialog** in the pane, so nothing is approved, and need
|
||||
attention until their next agent run. On Pi and OMP seats, a second startup block joins the running turn instead of
|
||||
failing with "Agent is already processing".
|
||||
- **A send to a busy Codex seat** warns that the message joins the running turn and may redirect it, and names
|
||||
`--wait-for-idle <seconds>` for separate work.
|
||||
- **A seat with another pane in its tmux session** gets sends and wakes in its recorded pane instead of a refusal.
|
||||
- **A wake refused at an approval prompt** retries once when the seat's attention clears, instead of waiting for the
|
||||
stuck sweep.
|
||||
- **A seat held at a choice prompt that no hook announces** (a usage-limit dialog, say) now escalates once the refusal
|
||||
has held for one retry interval (`queue.wake_retry_interval_seconds`, 300 seconds by default). Choice-prompt alerts
|
||||
follow `queue.wake_human_rung`: under `explicit-only` they stay with the operator seat unless that seat is a person.
|
||||
Permission-prompt alerts post as before.
|
||||
- **The parked-owner reminder waits 5 minutes** after a seat starts waiting, so a seat that just handed work off or
|
||||
asked a person isn't reminded at once; a seat at an approval or question prompt gets no grace. The reminder tells a
|
||||
seat waiting on a person to hold the row (`rig queue block <id> --on <who> --wake-after <duration>`), not ask again.
|
||||
- **A dead seat's stuck-sweep finding goes to someone who can act:** the operator agent (`agents.operator_session`)
|
||||
when set, then the operator seat (`workspace.operator_seat_name`), then the row's creator, skipping any whose session
|
||||
is confirmed missing. `workspace.operator_seat_name` still decides where approvals go.
|
||||
- **Rows OpenRig creates itself** (stuck-sweep findings, workflow escalations, continuity batons, diagnoses run with
|
||||
`--apply`) wake from OpenRig's own sender, with no reply line, instead of from the row's creator. When the sweep
|
||||
closes its own finding, the actor is `stuck-sweep@system`.
|
||||
- **A cross-host queue write that times out reads INDETERMINATE,** not failed, and the CLI waits 15 seconds for the
|
||||
daemon's answer. When `rig queue create` gets no answer, it reads the row back once by its ID and reports an exact
|
||||
match as created. It never sends the create twice.
|
||||
- **Codex seats keep write access to their git metadata** after resume, fork and a restore without a pod, so they can
|
||||
still commit, and to their own topology folder, so they keep `LEARNED.md` and their recap. `rig context recap-write`
|
||||
falls back to the daemon when the seat's sandbox refuses the write.
|
||||
- **Recap writes refuse a link** at `RECAP.md` or `recap-superseded/` with `recap_write_refused`, a layout that used to
|
||||
work. Replace the link with a real file or folder.
|
||||
- **`rig seat restart --keep-conversation` on a Codex seat** keeps a writable root whose path holds a space, proves the
|
||||
resume in the `CODEX_HOME` the running Codex uses, and reads complete when a directory it was given has a space. When
|
||||
the resume isn't proven, it says the pane restarted, not "nothing was restarted".
|
||||
- **A Codex seat at Codex 0.164.0's sign-in chooser or API-key entry, or 0.162.1's chooser,** reports
|
||||
`attention_required` with `login_required` instead of a readiness timeout. Nothing is typed into the login screen.
|
||||
- **Resume recognition:** a resumed Codex whose footer shows a display name with a reasoning label (`GPT-6.1 Sol
|
||||
medium`) or a non-`gpt` model reads as resumed, so `rig up --existing` doesn't stop. A Claude or Codex resume chooser
|
||||
drawn high in a tall pane reads as a chooser, not as resumed.
|
||||
- **Codex hooks:** when a Codex home has a `hooks.json`, OpenRig puts its activity hooks there at the next Codex seat
|
||||
launch and moves its own old block out of `config.toml`. Your hooks, their order, trust and disables stay; your own
|
||||
inline hooks stay in `config.toml`. Inline hook trust is keyed by the group's actual position.
|
||||
- **A managed Claude restore reads its own files without a Read approval:** the seat's marker and restore map, the
|
||||
generic or configured extra-instruction file, the restore skill files, restore maps under a configured
|
||||
`preparation_root` and the packet files are granted at launch and resume, at both the named and the real path (so
|
||||
macOS `/tmp` works).
|
||||
- **After managed compaction,** a Claude seat's restore starts on the next poll instead of waiting for the screen to
|
||||
change. `rig compact --state` and seat status show an owed restore stage and its last delivery result; repeated
|
||||
failures open one queue item for the delegation owner.
|
||||
- **A Claude startup refused late** (an expired login) no longer gets Enter on its next startup input: OpenRig checks
|
||||
again before pressing it and reports attention. The text may stay in the input box; don't resend it.
|
||||
- **A Claude seat with the model alias `haiku` and `plan` mode** gets a line in its launch, restore and `rig seat
|
||||
status` results: Claude Code answers plan turns with Sonnet; set a full Haiku id with `rig seat set-model` to keep
|
||||
Haiku. Nothing about the launch changes.
|
||||
- **The model-divergence monitor** no longer flags a Claude seat pinned to an undated id that answers with its dated
|
||||
snapshot, or one pinned to a Bedrock cross-Region profile id such as `global.anthropic.<model>`.
|
||||
- **A pod seat that never launched** starts fresh from its saved launch recipe after a daemon restart, instead of
|
||||
sitting at a shell. That starts the harness and can use provider usage. Seats materialized before 0.7.2 have no recipe
|
||||
and report `attention_required`, keeping the shell.
|
||||
- **`rig down` after a team's tmux server died** treats tmux's "no server running" as absence and records the seats
|
||||
stopped, so `rig archive` and `rig up` go ahead. A team of plain terminal seats restores as `fully_restored`, and
|
||||
`rig down` names the terminal sessions it stops.
|
||||
- **A launch whose tmux made no session** fails with `session_not_created` or `session_unconfirmed` instead of reporting
|
||||
the seat launched. The message says which directory must be writable.
|
||||
- **Terminal tiles survive a seat's session replacement:** a tile waits up to 30 seconds for the same session and seat
|
||||
identity, then reattaches. A fresh replacement's result says how to reopen the view. Input still queued when a
|
||||
terminal client disconnects is dropped, not typed.
|
||||
- **Reopening a Herdr view** with `--provider herdr` adds a workspace each time. From inside Herdr, `rig terminal open
|
||||
<view> --window` focuses a matching one. The `openrig-herdr` skill now says so.
|
||||
- **A service bundle install without Docker Compose v2** stops with `compose_missing` before writing the target or
|
||||
creating the team. `rig bundle install --plan` warns instead.
|
||||
- **`rig export`** copies the declared culture file beside the YAML for a local daemon, and names agent refs that leave
|
||||
the folder and a rig still named `starter`.
|
||||
- **`rig daemon start`:** a backlog of queue wakes no longer holds startup past the CLI's readiness wait. When the
|
||||
database-conflict check can't tell (no `lsof`, a timeout, a listener that doesn't answer), startup warns that
|
||||
ownership is unknown, under either `daemon.db_conflict` setting.
|
||||
- **Proof and scope:** proof-source monitoring watches the missions root, not the whole workspace parent. `rig proof
|
||||
add <absolute path>` finds a slice in another catalogued project. `rig scope mission create` writes
|
||||
`metadata.name`; an older mission without it needs the line added before it compiles. `rig queue whoami` resolves a
|
||||
project-tagged row against its catalogued project.
|
||||
- **Slack:** a registered person's reply to a question parked on someone else reaches the agent as their own message
|
||||
and leaves the question parked.
|
||||
- **`rig context get reference/acp-agents.md`** now returns the guide `rig acp run --help` names.
|
||||
- **TUI and dashboard:** the focused Table and Overview pages load health findings instead of staying `Not assessed`,
|
||||
the activity stream keeps its connection state across pages, and the dashboard shows "boot not tracked" for a skipped
|
||||
kernel boot.
|
||||
- **The kernel operator's guidance** puts a project task on the chosen team's queue rather than doing it itself.
|
||||
- **`rig auth switch` and `rig provider switch`'s printed undo** say a restored Codex login isn't verified and may need
|
||||
a fresh sign-in.
|
||||
|
||||
## Output that changed shape
|
||||
|
||||
- **`rig ps --nodes`** separates every cell with one space (the default row is 5 columns wider), adds an `ATTN` column
|
||||
(`yes`, `no` or `?`) before `REASON`, and marks a `running` seat whose tmux couldn't be reached as unconfirmed.
|
||||
- **`rig queue show --json`** no longer has `lastNudgeInputBox`, and send results no longer describe the input box.
|
||||
- **`rig queue show` and `rig queue whoami`** print their human notices on stderr, so stdout is the JSON.
|
||||
- **A claimed row whose owner waits on a person** reads pickup `needs-input`, with the reason.
|
||||
- **`rig down --json`** adds `leftRunning`; `rig reconcile-session` says whether resume metadata was recorded.
|
||||
- **`rig compact-plan`** labels its thresholds as its own and says whether automatic compaction is on.
|
||||
- **A send held for a draft** in the seat's input box reports `inputPhase: "not_reached"`: it was never typed.
|
||||
- **A stream body over 16 KiB** is stored truncated, ending `[truncated by OpenRig: kept N of M bytes]`, and reads mark it
|
||||
`bodyTruncated`. `rig stream list --after <sortKey>` from a pruned item returns 410 `stream_cursor_expired`, naming
|
||||
`--since` to resume, instead of an empty list.
|
||||
- **`rig capture` and the MCP capture tool** add `promptPane: { width, height, hint }` when a seat at a prompt has a
|
||||
pane smaller than 80x24, and the CLI prints the resize hint on stderr.
|
||||
- **`rig seat status`** shows the seat's runtime home and where it came from, any ignored home, a Claude token
|
||||
reference and member tool rules. `--json` always has `permissions.account.advisories`, empty unless a note applies.
|
||||
- **`rig stream show`** lists each reader's current action, flags `parallelWork`, and reads `readers: "unavailable"`
|
||||
from an older daemon. The expired-cursor 410 says how `--since` resumes.
|
||||
- **Slack posts start with their sender** (`From <seat>`, with ` on <machine>` when known), and a wake-rung escalation
|
||||
reads `Waiting on <seat>: <summary>`. Rows from Slack read `<address> via Slack: …`.
|
||||
- **A wake from a row OpenRig created** ends with "↩ Sent by OpenRig itself, not by a seat. Act on the row it names."
|
||||
- **Stuck-sweep findings** name the rule that chose their recovery seat, and a stalled-after-claim age counts from the
|
||||
stall, not from the claim.
|
||||
- **The parked diagnosis** reports `activity.since`.
|
||||
- **Cross-host queue writes:** a timed-out forward's 502 carries `outcome: "indeterminate"`, the CLI's headline reads
|
||||
"queue request got no answer in time (routing to host '…')", and a read-back reports "The create committed after all:
|
||||
row … (read back by its ID)".
|
||||
- **`rig queue update --help` and the `closure_fields_not_admitted` refusal** say how to cancel a row.
|
||||
- **`rig ps --nodes --json`** carries `login_required` for a Codex seat at sign-in; a restore result marks a plain
|
||||
terminal seat `noConversation`.
|
||||
- **A send to a seat mid-switch** is held at once: `outcome: retained`, `reason: runtime_switch_in_progress`.
|
||||
- **New codes:** `permission_rules_unavailable` (a fork whose source rules can't be read), `occupant_retired_by_switch`
|
||||
and `switch_not_committed` (the two processes of a runtime switch), `runtime_unchanged` and the `runtime_switch_*`
|
||||
refusals, `session_not_created`, `session_unconfirmed`, `compose_missing`, `rex_outcome_unknown`,
|
||||
`invalid_claude_token_selection`, and input checks on `rig stream act`.
|
||||
- **Help:** 14 subcommands that had no summary have one, and `rig skill audit` ends by saying it doesn't judge whether
|
||||
a refresh is safe, naming the read-only `rig skill loadout` check.
|
||||
|
||||
## What to stop doing
|
||||
|
||||
- **Don't read `lastNudgeInputBox`.** It's gone.
|
||||
- **Don't parse `rig ps --nodes` by column position.** Use `--json`, with `--fields` to pick fields.
|
||||
- **Don't look for a team seat's skills in the project folders.** Use `rig skill loadout --seat`.
|
||||
- **Don't stop and re-register a watchdog to change it.** Use `rig watchdog update`.
|
||||
- **Don't take a rig down to fix one edge.** Use `rig edge add` or `remove`.
|
||||
- **Don't print `OPENRIG_ACTIVITY_*` values** while looking for identity. Use `rig whoami`.
|
||||
- **Don't use `--dangerously-interact` to type over a half-written input box.** It stops and shows the text; choose
|
||||
explicitly, or use `rig send --urgent` where the seat's interruption level allows it.
|
||||
- **Don't pass `CLAUDE_CONFIG_DIR` or `CODEX_HOME` with `rig up --env`.** Declare the home in `env:`.
|
||||
- **Don't rebuild a team to move one seat** to the other runtime or another login. Use `rig seat set-runtime` or
|
||||
`rig provider switch --seat`.
|
||||
- **Don't close and re-create to pass work on over MCP.** Use `rig_queue_handoff`.
|
||||
- **Don't cancel with `--state canceled --closure-reason canceled`.** Use `--state done --closure-reason canceled`.
|
||||
- **Don't take the first `qitem-…` from `rig queue create`'s merged output.** The request ID on stderr isn't a row:
|
||||
read `qitemId` from stdout and check the exit code.
|
||||
- **Don't retry a cross-host queue write that timed out.** Reconcile by its ID first.
|
||||
- **Don't reopen a Herdr view with `--provider herdr` to get back to it.** Focus it, or use `--window` from inside Herdr.
|
||||
- **Don't put one member's tool rules in a shared project's Claude settings.** Use `claude_permissions`.
|
||||
|
||||
## Known gaps in 0.7.1
|
||||
## Known gaps in 0.7.2
|
||||
|
||||
- **On Linux, a seat now gets the daemon's runtime directory with a session bus address that can't connect** (#2036).
|
||||
When the daemon has an absolute `XDG_RUNTIME_DIR`, a seat launched, restored or handed over gets it with
|
||||
`DBUS_SESSION_BUS_ADDRESS=disabled:`, so its D-Bus clients don't find your session bus on their own. This is a
|
||||
default, not isolation: `systemctl --user` reaches your user service manager from a seat. A shell startup file that
|
||||
sets only `XDG_RUNTIME_DIR` now leaves the seat with `disabled:`; to use a session bus, set the address in the rig's
|
||||
or member's `env:`. A Codex seat's stdio MCP server still needs `env_vars = ["XDG_RUNTIME_DIR"]` in Codex's
|
||||
`config.toml` to see the variable. macOS, a daemon started without `XDG_RUNTIME_DIR` and
|
||||
`terminal.inherit_tmux_environment` on are unchanged.
|
||||
- **Per-member Claude tool rules** (#1914): member denies aren't claimed to hold in bypass mode (`full_bypass`).
|
||||
Handover and restore carry the rules, but aren't proven to keep the same conversation. Checked natively: two seats in
|
||||
one folder each enforce their own rules, a managed policy wins, and fresh, resume and fork launches enforce them.
|
||||
- **Going back to 0.7.1:** like any version before #1914, it drops the stored member rules from launch, so set
|
||||
equivalent native rules first (`docs/reference/rig-spec.md`, "Downgrade"). It also refuses a community preset that
|
||||
sets `first_input`. No other way back to 0.7.1 has been tested.
|
||||
- **Pi dialog recognition is a bounded screen check.** #2015 recognizes Pi's selector by its own chrome; with more than
|
||||
six non-blank rows after its border, or under a widget that draws its own border, a send goes through as before.
|
||||
#2026 adds Pi v1.4.0's extension input and editor dialogs; clipped or incomplete help, help wrapped over more than
|
||||
three rows and custom extension screens keep their earlier reading, and a draft that copies a dialog's whole frame
|
||||
reads as that dialog. Each was checked natively on its own cases (#2015 its confirmation dialog, #2026 the input and
|
||||
editor dialogs on its own build), not on every layout. Check the pane before sending to an adopted Pi seat that may be
|
||||
at a dialog. Neither
|
||||
check was repeated on the final 0.7.2 build.
|
||||
- **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. 0.7.2 treats the "paste again to expand" hint as part of
|
||||
the composer, but the original failure didn't reproduce in its native check, so this isn't confirmed fixed; #14
|
||||
remains open.
|
||||
- **An idle Claude seat whose recent output quotes prompt text** can still read as at a prompt and refuse sends, as can
|
||||
a seat with a draft, a mode bar OpenRig doesn't recognise or an unframed input box. Check the pane.
|
||||
- **A Claude startup refusal that arrives after OpenRig's last check can still race Enter.** If a seat with an expired
|
||||
login shows staged text in its input box, sign in, then clear or send that text yourself.
|
||||
- **A managed Claude restore can still ask for a Read approval** when `policies.claude_compaction.message_file_path` is
|
||||
relative, or for a packet file after a resume changed the conversation id. The session transcript isn't granted. A native
|
||||
check on Claude 2.1.309 passed the Read grants with no approval prompt; a complete restore with no help isn't claimed.
|
||||
- **`rig seat set-runtime`:** Claude launch additions such as an agent file aren't applied to a switched seat, startup
|
||||
slash commands aren't run, and a person typing into the old pane of a draft-aware seat stops the switch (the current
|
||||
occupant keeps the seat). A message sent during a switch is held and must be sent again.
|
||||
- **A seat's own account:** a conversation saved under a seat's earlier home doesn't resume in its new one, and live
|
||||
usage refresh (`rig provider status --refresh`) still reads only the daemon's own logins. `rig provider switch --seat`
|
||||
moves a Codex seat on Linux only. Other seats on that Codex home keep the old login until their next token refresh,
|
||||
then stop: run the restart command the switch printed for each.
|
||||
- **Seat account and runtime moves weren't run with real accounts before this release** (#1895, #1937, #1972, #1980).
|
||||
The tests use stubs; check a moved seat's account and conversation yourself after the first move.
|
||||
- **A Codex seat whose queue-state folder or team workspace path holds a space** can still read as not resumed after an
|
||||
ordinary resume or restore. An in-place restart is fixed.
|
||||
- **Choice-prompt alerts go to a person one at a time per machine.** While the first one's seat stays blocked, later
|
||||
choice alerts wait unposted; they're visible as rows.
|
||||
- **Herdr's own status for a seat can read idle or unknown while OpenRig shows it waiting** (`needs-input`). Use
|
||||
OpenRig's view or the Feed to find waiting seats.
|
||||
- **Proof-source watching still recurses through a large missions tree,** and the reported Linux daemon stalls (#1813)
|
||||
weren't reproduced to confirm that the narrower watch helps.
|
||||
- **A Claude seat signed in with a long-lived token (`claude setup-token`) gets no automatic usage reading;** the reader
|
||||
reports `not_subscription_login`. Read one on demand with `rig provider status --refresh --provider claude
|
||||
--claude-token-file <path> --credential-label <label>`, which spends a model turn.
|
||||
- **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
|
||||
@@ -172,21 +295,11 @@ after an upgrade it can still hold the previous version's note.
|
||||
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`.
|
||||
--provider herdr`, which adds a new workspace; close the old one.
|
||||
- **Delegated decisions:** the ask is created before the decision, so without quiet hours it can post to the person
|
||||
first. Pair a decision room with quiet hours, or decide before asking.
|
||||
- **`restore-check` still treats a missing startup file as rig-wide.** Trust `rig up --existing`'s per-seat result.
|
||||
- **Managed Claude launch requires `/usr/bin/env` support for `-0` and `-u`** (option support checked on GNU coreutils
|
||||
and macOS 15; BusyBox and older macOS unverified). Without the required option support, launch stops instead of
|
||||
falling back to credential-bearing command-line arguments.
|
||||
- **In draft-aware mode, an agent's multi-line message to a Claude seat can remain unsubmitted when the paste changes
|
||||
Claude's footer hint.** OpenRig withholds Enter, leaving the text in the composer and later sends waiting. Observed on
|
||||
Claude 2.1.309 with both 0.7.0 and the 0.7.1 pre-cut build; #14 remains open.
|
||||
- **A Claude seat signed in with a long-lived token (`claude setup-token`) gets no usage reading.** The reader accepts
|
||||
only claude.ai subscription logins and reports `not_subscription_login`.
|
||||
+33
-8
@@ -1,15 +1,38 @@
|
||||
# Release Notes
|
||||
|
||||
This directory holds one note per release for OpenRig v0.1.12 through v0.6.3, plus v0.6.12 (for example `v0.1.12.md`).
|
||||
This directory is the lightweight release history for OpenRig.
|
||||
|
||||
Notes for later releases are in [`CHANGELOG.md`](../../CHANGELOG.md) and on the [GitHub Releases page](https://github.com/mvschwarz/openrig/releases).
|
||||
It is intentionally simpler than a monolithic `CHANGELOG.md`.
|
||||
|
||||
Each shipped release gets its own note:
|
||||
|
||||
- `v0.1.12.md`
|
||||
- `v0.2.0.md`
|
||||
- `v0.3.0.md`
|
||||
- and so on
|
||||
|
||||
## Why This Exists
|
||||
|
||||
We want a practical release-management pattern that works with how OpenRig is actually being shipped today:
|
||||
|
||||
- npm package release
|
||||
- optional Git tag
|
||||
- optional GitHub Release
|
||||
- short, human-written summary of what is included
|
||||
|
||||
This keeps release notes:
|
||||
|
||||
- easy to author
|
||||
- easy to link in GitHub Releases
|
||||
- easy to paste into announcements
|
||||
- durable in the repo
|
||||
|
||||
## Minimal Process
|
||||
|
||||
For each release:
|
||||
|
||||
1. Add the release's section to [`CHANGELOG.md`](../../CHANGELOG.md).
|
||||
2. Write the release's GitHub Release notes. No new file is added to this directory.
|
||||
1. Copy `_template.md` to `vX.Y.Z.md`.
|
||||
2. Fill in the release summary, included changes, operator notes, known limitations, and verification performed.
|
||||
3. Run the **substance gate** at the exact release cut. The gate derives every
|
||||
file under the shipped pack sources, verifies a hash-bound human judgment
|
||||
and an individual disposition for each mechanical candidate, scans the
|
||||
@@ -56,16 +79,16 @@ For each release:
|
||||
git push origin vX.Y.Z
|
||||
```
|
||||
|
||||
5. Create the GitHub Release with the notes from step 2:
|
||||
5. Create a GitHub Release using the same file:
|
||||
|
||||
```bash
|
||||
gh release create vX.Y.Z \
|
||||
--repo mvschwarz/openrig \
|
||||
--title "OpenRig X.Y.Z" \
|
||||
--notes-file <release-notes.md>
|
||||
--title "OpenRig vX.Y.Z" \
|
||||
--notes-file docs/releases/vX.Y.Z.md
|
||||
```
|
||||
|
||||
6. Publish the npm package.
|
||||
6. Publish the npm package if that is part of the release flow.
|
||||
|
||||
## Guidance
|
||||
|
||||
@@ -78,3 +101,5 @@ For each release:
|
||||
## Scope
|
||||
|
||||
This directory is a release-note archive, not a full historical changelog taxonomy.
|
||||
|
||||
If OpenRig later wants a curated `CHANGELOG.md`, it can be generated or summarized from the release notes here.
|
||||
@@ -260,7 +260,7 @@ Operators can install `gstack` (45 skills) or `obra-superpowers`
|
||||
- TypeScript checks clean on all touched files.
|
||||
- Code review (review-lead, advisory-clean) on every merge in the
|
||||
release window.
|
||||
- Founder-walk VM at `http://192.0.2.10:17459/` walked end-to-end
|
||||
- Founder-walk VM at `http://100.95.124.51:17459/` walked end-to-end
|
||||
on host-main `4d88d98d` with realistic demo seed (8 seeded queue
|
||||
items, 8 feed events, 4 verified `/for-you` action scenarios
|
||||
including the inline error path).
|
||||
|
||||
Generated
+91
-371
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.2",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.2",
|
||||
"type": "module",
|
||||
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
|
||||
"keywords": [
|
||||
@@ -47,8 +47,7 @@
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hono/node-ws": "^1.3.1",
|
||||
"@modelcontextprotocol/node": "^2.1.2",
|
||||
"@modelcontextprotocol/server": "^2.4.0",
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"ajv": "^8.18.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
@@ -63,8 +62,6 @@
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modelcontextprotocol/client": "^2.4.0",
|
||||
"@modelcontextprotocol/sdk": "^1.32.2",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
|
||||
+24
-60
@@ -3,9 +3,8 @@ import { assessLocalDaemonOwner, ForeignLocalDaemonError, type LocalDaemonOwner
|
||||
export { ForeignLocalDaemonError } from "./local-daemon-owner.js";
|
||||
import path from "node:path";
|
||||
import { ConfigStore } from "./config-store.js";
|
||||
import { getOpenRigHome, readOpenRigEnv, readOpenRigUrl } from "./openrig-compat.js";
|
||||
import { getOpenRigHome, readOpenRigEnv } from "./openrig-compat.js";
|
||||
import { fetchWithTimeout, FetchTimeoutError } from "./fetch-with-timeout.js";
|
||||
import { FETCH_HEADERS_TIMEOUT_MS, nodeHttpFetch } from "./long-request-fetch.js";
|
||||
|
||||
export function terminalAuthHeaders(): Record<string, string> {
|
||||
const token = resolveTerminalToken();
|
||||
@@ -147,15 +146,9 @@ interface DaemonRequestOptions {
|
||||
interface DaemonClientOptions {
|
||||
ownerProbe?: (baseUrl: string, timeoutMs?: number) => Promise<LocalDaemonOwner>;
|
||||
fetchImpl?: typeof fetch;
|
||||
/** For a request allowed FETCH_HEADERS_TIMEOUT_MS or longer, which fetch would cut; default: fetchImpl when given, else
|
||||
* node:http. */
|
||||
longFetchImpl?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
/** The seat this client acts as, for a client built per authenticated caller; default: the process env. */
|
||||
sessionName?: string;
|
||||
/** With sessionName: the caller whose grant the MCP adapter checked, sent as X-OpenRig-Principal (JSON of its source,
|
||||
* id and, for a grant-service grant, grant id and version; never a credential). */
|
||||
principal?: { source: "file" | "grant-service"; id: string; grant?: string; version?: number | string };
|
||||
}
|
||||
|
||||
/** Routing state published by daemon start, not a health check or sibling-home discovery. */
|
||||
@@ -177,7 +170,6 @@ function localDaemonUrl(): string | undefined {
|
||||
export class DaemonClient {
|
||||
readonly baseUrl: string;
|
||||
private fetchImpl: typeof fetch = fetch;
|
||||
private longFetchImpl: typeof fetch = nodeHttpFetch;
|
||||
private timeoutMs = 5_000;
|
||||
private ownerProbe: (baseUrl: string, timeoutMs?: number) => Promise<LocalDaemonOwner>;
|
||||
/** Set by remoteDaemonClient; absent origin is labelled explicitly on remote requests. */
|
||||
@@ -186,62 +178,36 @@ export class DaemonClient {
|
||||
private identity?: Promise<Record<string, string>>;
|
||||
private originUnknown = false;
|
||||
private readonly sessionName?: string;
|
||||
private readonly principal?: DaemonClientOptions["principal"];
|
||||
|
||||
private async identityHeaders(): Promise<Record<string, string>> {
|
||||
const { headers, local } = await this.senderHeaders();
|
||||
// Only beside a seat this client was built for, and only to this host's own daemon: the endpoint itself must answer
|
||||
// as this host's instance, whichever way it was chosen (OPENRIG_URL, or an explicit URL such as rig mcp serve --port,
|
||||
// which can be a forwarded port). The daemon records it only on a direct, attributed request.
|
||||
if (!this.principal || this.sessionName === undefined || !local || !(await this.endpointIsThisHost())) return headers;
|
||||
// A header value can't carry characters beyond Latin-1 (fetch throws), and a grant name may hold any: everything past
|
||||
// ASCII travels as JSON's own \u escapes, which the daemon's JSON.parse reads back.
|
||||
const principal = JSON.stringify(this.principal).replace(/[\u0080-\uffff]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
||||
return { ...headers, "X-OpenRig-Principal": principal };
|
||||
}
|
||||
|
||||
/** Whether this client's endpoint answers /healthz with the id this host's own daemon minted. */
|
||||
private async endpointIsThisHost(): Promise<boolean> {
|
||||
if (this.remoteTarget) return false;
|
||||
// Imported here, not at the top: it loads better-sqlite3 and yaml.
|
||||
const origin = this.originSelfHostId ?? (await import("./local-origin.js")).readLocalOrigin();
|
||||
return !!origin && (await this.endpointSelfHostId()) === origin;
|
||||
}
|
||||
|
||||
/** The endpoint's own instance id from /healthz, or undefined when it doesn't answer within a second. */
|
||||
private async endpointSelfHostId(): Promise<unknown> {
|
||||
const controller = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
(async () => {
|
||||
const res = await this.fetchImpl(`${this.baseUrl.replace(/\/+$/, "")}/healthz`, { signal: controller.signal });
|
||||
return res.ok ? (await res.json() as { selfHostId?: unknown }).selfHostId : undefined;
|
||||
})(),
|
||||
new Promise<undefined>(resolve => { timer = setTimeout(() => resolve(undefined), Math.min(this.timeoutMs, 1000)); }),
|
||||
]);
|
||||
} catch { return undefined; }
|
||||
finally { clearTimeout(timer); controller.abort(); }
|
||||
}
|
||||
|
||||
/** The sender headers, and whether the endpoint is this host's own daemon: no direct endpoint is configured, or the
|
||||
* endpoint answered with this host's own instance id. */
|
||||
private async senderHeaders(): Promise<{ headers: Record<string, string>; local: boolean }> {
|
||||
const localHeaders = senderIdentityHeaders(undefined, this.sessionName);
|
||||
const session = localHeaders[SENDER_IDENTITY_HEADER];
|
||||
if (!session || session.split("@").length >= 3) return { headers: localHeaders, local: false };
|
||||
const directTarget = readOpenRigUrl();
|
||||
if (!this.remoteTarget && !directTarget) return { headers: localHeaders, local: true };
|
||||
if (!session || session.split("@").length >= 3) return localHeaders;
|
||||
const directTarget = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
|
||||
if (!this.remoteTarget && !directTarget) return localHeaders;
|
||||
// Imported here, not at the top: it loads better-sqlite3 and yaml, which only a seat on a direct endpoint needs.
|
||||
const origin = this.originSelfHostId ?? (await import("./local-origin.js")).readLocalOrigin();
|
||||
if (!origin) {
|
||||
this.originUnknown = true;
|
||||
return { headers: { ...localHeaders, "X-OpenRig-Origin-Unknown": "true" }, local: false };
|
||||
return { ...localHeaders, "X-OpenRig-Origin-Unknown": "true" };
|
||||
}
|
||||
// Loopback may be a forwarded endpoint. Only matching instance identity proves locality; unproved, the sender carries
|
||||
// the known origin, never the endpoint's identity.
|
||||
if (!this.remoteTarget && (await this.endpointSelfHostId()) === origin) return { headers: localHeaders, local: true };
|
||||
return { headers: senderIdentityHeaders(origin, this.sessionName), local: false };
|
||||
if (!this.remoteTarget) {
|
||||
// Loopback may be a forwarded endpoint. Only matching instance identity proves locality.
|
||||
const controller = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const target = await Promise.race([
|
||||
(async () => {
|
||||
const res = await this.fetchImpl(`${this.baseUrl.replace(/\/+$/, "")}/healthz`, { signal: controller.signal });
|
||||
return res.ok ? (await res.json() as { selfHostId?: unknown }).selfHostId : undefined;
|
||||
})(),
|
||||
new Promise<undefined>(resolve => { timer = setTimeout(() => resolve(undefined), Math.min(this.timeoutMs, 1000)); }),
|
||||
]);
|
||||
if (target === origin) return localHeaders;
|
||||
} catch { /* Unproved locality: carry the known origin, never the endpoint's identity. */ }
|
||||
finally { clearTimeout(timer); controller.abort(); }
|
||||
}
|
||||
return senderIdentityHeaders(origin, this.sessionName);
|
||||
}
|
||||
|
||||
/** The sender this client stamps on its requests (the X-OpenRig-Session value, absent without a seat), and whether it marked
|
||||
@@ -256,7 +222,7 @@ export class DaemonClient {
|
||||
if (baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
} else {
|
||||
const envUrl = readOpenRigUrl();
|
||||
const envUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
|
||||
if (envUrl) {
|
||||
this.baseUrl = envUrl;
|
||||
} else {
|
||||
@@ -273,11 +239,9 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
this.fetchImpl = options?.fetchImpl ?? fetch;
|
||||
this.longFetchImpl = options?.longFetchImpl ?? options?.fetchImpl ?? nodeHttpFetch;
|
||||
this.ownerProbe = options?.ownerProbe ?? ((url, timeoutMs) => assessLocalDaemonOwner(url, { fetchImpl: this.fetchImpl, timeoutMs }));
|
||||
this.timeoutMs = options?.timeoutMs ?? 5_000;
|
||||
this.sessionName = options?.sessionName;
|
||||
this.principal = options?.principal;
|
||||
}
|
||||
|
||||
/** Shared with raw CLI data readers. Constructor URLs alone do not establish explicit intent. */
|
||||
@@ -362,7 +326,7 @@ export class DaemonClient {
|
||||
let responseStatus: number | undefined;
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
timeoutMs >= FETCH_HEADERS_TIMEOUT_MS ? this.longFetchImpl : this.fetchImpl,
|
||||
this.fetchImpl,
|
||||
`${this.baseUrl}${path}`,
|
||||
init,
|
||||
{
|
||||
|
||||
@@ -94,7 +94,7 @@ Ctrl-C (or rig acp cancel <runId>) asks the agent to cancel the turn; a second C
|
||||
Ending the run closes the agent's stdin and reports whether any process carrying the run's marker remains.
|
||||
|
||||
Example:
|
||||
rig acp run <qitem-id> --cwd ~/work/app -- openclaw acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token
|
||||
rig acp run qitem-20261023-abc --cwd ~/work/app -- openclaw acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token
|
||||
|
||||
Guide: rig context get reference/acp-agents.md`)
|
||||
.action(async (qitemId: string, agent: string[], opts: { cwd: string; permissionsTo?: string; loadSession?: string; pollSeconds: string }) => {
|
||||
|
||||
@@ -13,11 +13,6 @@ function shellQuote(value: string): string {
|
||||
|
||||
const defaultTmuxExec: TmuxExecFn = (cmd: string) => execSync(cmd, { encoding: "utf-8" }).trim();
|
||||
|
||||
// tmux writes a control character in its output as "_", so a newline between fields can't separate them, and a
|
||||
// session name may hold spaces or underscores. Ask for the two fixed-shape ids first; the rest of the line is the name.
|
||||
const TMUX_CONTEXT_FORMAT = "#{pane_id} #{window_id} #{session_name}";
|
||||
const TMUX_CONTEXT_LINE = /^(%\d+) (@\d+) (.+)$/;
|
||||
|
||||
export function resolveAttachContext(tmuxExec: TmuxExecFn = defaultTmuxExec):
|
||||
| { attachmentType: "external_cli" }
|
||||
| { attachmentType: "tmux"; tmuxSession: string; tmuxWindow?: string; tmuxPane: string } {
|
||||
@@ -27,11 +22,15 @@ export function resolveAttachContext(tmuxExec: TmuxExecFn = defaultTmuxExec):
|
||||
}
|
||||
|
||||
try {
|
||||
const output = tmuxExec(`tmux display-message -p -t ${shellQuote(tmuxPane)} ${shellQuote(TMUX_CONTEXT_FORMAT)}`);
|
||||
const match = TMUX_CONTEXT_LINE.exec(output.trim());
|
||||
if (match) {
|
||||
const [, resolvedPane, tmuxWindow, tmuxSession] = match;
|
||||
return { attachmentType: "tmux", tmuxSession: tmuxSession!, tmuxWindow, tmuxPane: resolvedPane! };
|
||||
const output = tmuxExec(`tmux display-message -p -t ${JSON.stringify(tmuxPane)} "#{session_name}\n#{window_id}\n#{pane_id}"`);
|
||||
const [tmuxSession, tmuxWindow, resolvedPane] = output.split("\n").map((part) => part.trim());
|
||||
if (tmuxSession && resolvedPane) {
|
||||
return {
|
||||
attachmentType: "tmux",
|
||||
tmuxSession,
|
||||
tmuxWindow: tmuxWindow || undefined,
|
||||
tmuxPane: resolvedPane,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall back to external_cli if tmux metadata cannot be resolved.
|
||||
|
||||
@@ -26,8 +26,6 @@ interface CaptureOpts {
|
||||
host?: string;
|
||||
json?: boolean;
|
||||
historyPlusPane?: boolean;
|
||||
readable?: boolean;
|
||||
size?: string;
|
||||
/** --lines was given on the command line (not the "20" default). */
|
||||
linesGiven?: boolean;
|
||||
/** Set when --lines N was given (and --history-plus-pane wasn't): the output is cut to the last N lines. */
|
||||
@@ -46,30 +44,6 @@ function exactLineCount(opts: CaptureOpts, linesFromCli: boolean): number | unde
|
||||
return n > 0 ? n : undefined;
|
||||
}
|
||||
|
||||
/** How long a readable capture request may take: the daemon's 8 s for readable reads (CAPTURE_READABLE_BUDGET_MS, shared
|
||||
* across a fan-out), its 1 s for input-box reads, and the client's usual 5 s for the rest. */
|
||||
export const READABLE_REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** `--readable` reads at 120x40; `--size WxH` at that size. Null when neither was given; an error for a bad --size. */
|
||||
/** What happened to the window after a readable read, as far as OpenRig knows. */
|
||||
export function readableRelease(release: string | undefined): string {
|
||||
switch (release) {
|
||||
case "given-back": return "its window was then given back its size and its own window-size";
|
||||
case "kept-by-another-holder": return "a web terminal opened on it meanwhile and holds its window now; it gives the window back when it closes";
|
||||
case "failed": return "giving its window back failed; a later readable capture or web terminal visit gives it back";
|
||||
default: return "its window's record had changed, so OpenRig left the window as it found it";
|
||||
}
|
||||
}
|
||||
|
||||
export function readableSize(opts: Pick<CaptureOpts, "readable" | "size">): { width: number; height: number } | null | { error: string } {
|
||||
if (opts.size === undefined) return opts.readable ? { width: 120, height: 40 } : null;
|
||||
const match = /^(\d{1,4})x(\d{1,4})$/.exec(opts.size);
|
||||
const width = match ? Number(match[1]) : 0;
|
||||
const height = match ? Number(match[2]) : 0;
|
||||
if (!match || width < 1 || height < 1 || width > 1000 || height > 1000) return { error: `--size wants WIDTHxHEIGHT, each from 1 to 1000 (got ${JSON.stringify(opts.size)})` };
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* The last `n` lines of a capture. The blank rows of the visible pane below its content are dropped first: on a
|
||||
* short shell pane the bottom rows are empty, and the last N rows would otherwise be all blank.
|
||||
@@ -118,14 +92,6 @@ function renderCapture(data: Record<string, unknown>): void {
|
||||
// A prompt in a pane too small to show it: say so, with the resize command, rather than leave a reader guessing.
|
||||
const pane = r["promptPane"] as { hint?: string } | undefined;
|
||||
if (pane && typeof pane.hint === "string") console.error(`[rig capture: ${r["sessionName"]}: ${pane.hint}]`);
|
||||
// --readable: the size it was read at, or why the window was left as it was.
|
||||
const readable = r["readable"] as { resized?: boolean; width?: number; height?: number; reason?: string; release?: string; sizeBack?: string } | undefined;
|
||||
if (readable?.resized === true) {
|
||||
const after = readable.release === "given-back" && readable.sizeBack
|
||||
? `its own window-size was given back, but ${readable.sizeBack}`
|
||||
: readableRelease(readable.release);
|
||||
console.error(`[rig capture: ${r["sessionName"]}: read at ${readable.width}x${readable.height}; ${after}]`);
|
||||
} else if (readable?.resized === false) console.error(`[rig capture: ${r["sessionName"]}: not resized for reading: ${readable.reason}]`);
|
||||
};
|
||||
|
||||
// Multi-target result
|
||||
@@ -164,8 +130,6 @@ export function captureCommand(depsOverride?: CaptureDeps): Command {
|
||||
.option("--pod <name>", "Capture all sessions in a pod")
|
||||
.option("--lines <n>", "Show the last N lines of the pane (without it: 20 history lines plus the whole visible pane)", "20")
|
||||
.option("--history-plus-pane", "With --lines N, show N history lines plus the whole visible pane, as before --lines was exact")
|
||||
.option("--readable", "Read a seat whose pane is smaller than 120x40 at that size, then give its window back")
|
||||
.option("--size <WxH>", "With or without --readable: read at this size instead (for example 160x50)")
|
||||
.option("--host <id>", "Capture on a remote host declared in ~/.openrig/hosts.yaml (ssh hosts shell out; http hosts go CLI-direct to the remote daemon)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
@@ -173,7 +137,6 @@ Examples:
|
||||
rig capture dev-impl@my-rig
|
||||
rig capture dev-impl@my-rig --lines 50
|
||||
rig capture dev-impl@my-rig --lines 5 --history-plus-pane
|
||||
rig capture dev-impl@my-rig --readable
|
||||
rig capture --rig my-rig
|
||||
rig capture --pod dev --rig my-rig
|
||||
rig capture --rig my-rig --json
|
||||
@@ -188,15 +151,8 @@ Supported notes:
|
||||
suggestion regions on the visible screen (zero-based rows and columns), evidence and hint.
|
||||
- When the captured screen is at a prompt and the seat's pane is smaller than 80x24, a
|
||||
"[rig capture: ...: pane WxH is smaller than 80x24, ...]" note on stderr says the prompt may be
|
||||
cut off and suggests --readable. --json has it as promptPane: width, height and hint.
|
||||
- --readable (or --size WxH) reads a seat whose pane is smaller than that: it resizes the seat's
|
||||
window, waits for the seat to redraw, captures, then puts the window back, both its size and
|
||||
its own window-size setting, so Herdr, tmux attach and other clients size it as before. The
|
||||
seat redraws twice, and anyone watching it sees the resize briefly. A window that's already big
|
||||
enough is read as it is, and so is one whose size is pinned (window-size manual, as while the
|
||||
web terminal shows it), one whose sizing can't be read, and one with several panes. With --rig
|
||||
or --pod, small seats are read at that size for up to 8 seconds in all, and the rest as they
|
||||
are. A stderr note says which; --json has it as readable.
|
||||
cut off and gives the tmux resize-window command that makes it readable. --json has it as
|
||||
promptPane: width, height and hint.
|
||||
- --lines N shows at most the last N lines, counted up from the last non-blank line. When lines
|
||||
above them are left out, a "[rig capture: ...]" note on stderr says how many; --json reports
|
||||
lines (returned), requestedLines and omittedLines. Trailing rows that are entirely whitespace
|
||||
@@ -229,12 +185,6 @@ Supported notes:
|
||||
const deps = getDeps();
|
||||
opts.linesGiven = cmd.getOptionValueSource("lines") === "cli";
|
||||
opts.exactLines = exactLineCount(opts, opts.linesGiven);
|
||||
const size = readableSize(opts);
|
||||
if (size && "error" in size) {
|
||||
console.error(size.error);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// OPR.0.4.6.MH4 §4 — `agent@rig@host` target sugar (session operand
|
||||
// only; --rig/--pod values are names, never sugar-parsed). Suffix must
|
||||
@@ -265,18 +215,14 @@ Supported notes:
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const lines = parseInt(opts.lines ?? "20", 10);
|
||||
// The capture default is 20: a missing or non-positive count reads as 20, never 0 or negative.
|
||||
const requestedLines = Number.isNaN(lines) || lines < 1 ? 20 : lines;
|
||||
|
||||
const body: Record<string, unknown> = { lines: requestedLines };
|
||||
const body: Record<string, unknown> = { lines: isNaN(lines) ? 20 : lines };
|
||||
if (opts.rig) body.rig = opts.rig;
|
||||
if (opts.pod) body.pod = opts.pod;
|
||||
if (session) body.session = session;
|
||||
body.inputBox = true; // the input box note; a daemon without it ignores the field
|
||||
if (size) body.readable = size; // a daemon without it ignores the field and captures as before
|
||||
|
||||
const res = await client.post<Record<string, unknown>>("/api/transport/capture", body,
|
||||
{ headers: terminalAuthHeaders(), ...(size ? { timeoutMs: READABLE_REQUEST_TIMEOUT_MS } : {}) });
|
||||
const res = await client.post<Record<string, unknown>>("/api/transport/capture", body, { headers: terminalAuthHeaders() });
|
||||
if (res.status < 400) res.data = withExactLines(res.data, opts.exactLines);
|
||||
|
||||
if (opts.json) {
|
||||
@@ -336,16 +282,8 @@ async function runCrossHostCapture(
|
||||
if (opts.rig) argv.push("--rig", opts.rig);
|
||||
if (opts.pod) argv.push("--pod", opts.pod);
|
||||
// Only an explicit --lines is forwarded: the remote's own default stays the default.
|
||||
// A value that parses as a positive count goes over as given (the remote decides);
|
||||
// only a missing, zero, negative or non-numeric count sends the documented default.
|
||||
if (opts.linesGiven || opts.historyPlusPane) {
|
||||
const given = opts.lines ?? "20";
|
||||
const parsed = parseInt(given, 10);
|
||||
argv.push("--lines", Number.isNaN(parsed) || parsed < 1 ? "20" : given);
|
||||
}
|
||||
if (opts.linesGiven || opts.historyPlusPane) argv.push("--lines", opts.lines ?? "20");
|
||||
if (opts.historyPlusPane) argv.push("--history-plus-pane");
|
||||
if (opts.readable) argv.push("--readable");
|
||||
if (opts.size !== undefined) argv.push("--size", opts.size);
|
||||
if (opts.json) argv.push("--json");
|
||||
|
||||
const result = await runner(host, argv);
|
||||
@@ -383,17 +321,13 @@ async function runHttpHostCapture(
|
||||
hint?: string,
|
||||
): Promise<void> {
|
||||
const lines = parseInt(opts.lines ?? "20", 10);
|
||||
const requestedLines = Number.isNaN(lines) || lines < 1 ? 20 : lines;
|
||||
const body: Record<string, unknown> = { lines: requestedLines };
|
||||
const body: Record<string, unknown> = { lines: isNaN(lines) ? 20 : lines };
|
||||
if (opts.rig) body.rig = opts.rig;
|
||||
if (opts.pod) body.pod = opts.pod;
|
||||
if (session) body.session = session;
|
||||
body.inputBox = true;
|
||||
const size = readableSize(opts);
|
||||
if (size && !("error" in size)) body.readable = size;
|
||||
|
||||
const result = await runRemoteHttpOp(host.id, "POST", "/api/transport/capture", body, deps,
|
||||
body.readable ? { timeoutMs: READABLE_REQUEST_TIMEOUT_MS } : {});
|
||||
const result = await runRemoteHttpOp(host.id, "POST", "/api/transport/capture", body, deps, {});
|
||||
if (result.ok) result.data = withExactLines(result.data, opts.exactLines);
|
||||
|
||||
if (opts.json) {
|
||||
|
||||
@@ -4,24 +4,7 @@ import { readOpenRigEnv } from "../openrig-compat.js";
|
||||
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
/** tmux's arguments for the session that runs `rig chatroom watch`. The watcher goes to tmux as separate arguments, so
|
||||
* tmux runs it without a shell and the rig name arrives as one argument; `--` keeps a name that starts with `-` from
|
||||
* reading as an option. tmux reads the `-s` value as a format, where `##` is one `#` and a run of `#` before `[` is a
|
||||
* style it keeps as written, so every other `#` is doubled and the session is `chatroom@<rig>`. Some tmux versions
|
||||
* still change a session name (3.5a writes `_` for `:` and `.`, and escapes a backslash or a control character such
|
||||
* as a newline or tab), so `-P -F` prints the name tmux used. */
|
||||
export function chatroomWatchTmuxArgs(rig: string): string[] {
|
||||
const session = `chatroom@${rig}`.replace(/#+(\[)?/g, (run, style?: string) => style ? run : run + run);
|
||||
return ["new-session", "-d", "-P", "-F", "#{session_name}", "-s", tmuxArg(session),
|
||||
"rig", "chatroom", "watch", "--", tmuxArg(rig)];
|
||||
}
|
||||
|
||||
/** tmux ends a command at an argument that ends in `;`, unless a `\` comes before it, so a final `;` gets that `\`. */
|
||||
function tmuxArg(value: string): string {
|
||||
return value.endsWith(";") ? `${value.slice(0, -1)}\\;` : value;
|
||||
}
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
interface RigSummary {
|
||||
id: string;
|
||||
@@ -192,11 +175,9 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
|
||||
if (opts.tmux) {
|
||||
const sessionName = `chatroom@${rig}`;
|
||||
try {
|
||||
// The name tmux used, which differs from sessionName when tmux changed it (a `.` in a rig name, for example).
|
||||
const created = execFileSync("tmux", chatroomWatchTmuxArgs(rig), { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
|
||||
.trim() || sessionName;
|
||||
console.log(`Started watch in tmux session: ${created}`);
|
||||
console.log(`Attach with: tmux attach -t ${created}`);
|
||||
execSync(`tmux new-session -d -s ${JSON.stringify(sessionName)} "rig chatroom watch ${JSON.stringify(rig)}"`, { stdio: "ignore" });
|
||||
console.log(`Started watch in tmux session: ${sessionName}`);
|
||||
console.log(`Attach with: tmux attach -t ${sessionName}`);
|
||||
} catch {
|
||||
console.error(`Failed to create tmux session '${sessionName}'. It may already exist.`);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Command } from "commander";
|
||||
import { copyFileSync, lstatSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { getOpenRigHome, readOpenRigUrl } from "../openrig-compat.js";
|
||||
import { getOpenRigHome } from "../openrig-compat.js";
|
||||
import { join } from "node:path";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { formatDaemonHostForUrl } from "../client.js";
|
||||
@@ -54,14 +54,6 @@ function existsOrThrow(path: string): boolean {
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
|
||||
}
|
||||
|
||||
/** The evidence probe's URL: OPENRIG_URL with one trailing slash trimmed, as the detector trims it, else daemon.json's
|
||||
* host and port. The shared reader leaves a query or fragment URL as written, so the trim stays here. */
|
||||
export function crashCartEvidenceUrl(openrigUrl: string | undefined, state: { host?: string; port: number } | undefined): string {
|
||||
return openrigUrl
|
||||
? `${openrigUrl.replace(/\/$/, "")}/healthz`
|
||||
: `http://${formatDaemonHostForUrl(state?.host ?? "127.0.0.1")}:${state?.port ?? 7433}/healthz`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The real verdict: LAZY-imports the narrow @openrig/daemon/crash-cart subpath at invocation (dep
|
||||
* rail 2 — rig startup + other verbs never load the daemon module) and composes the shipped detector
|
||||
@@ -71,7 +63,7 @@ export function crashCartEvidenceUrl(openrigUrl: string | undefined, state: { ho
|
||||
async function realEmit(): Promise<CrashCartEmit> {
|
||||
const cc = await import("@openrig/daemon/crash-cart");
|
||||
const home = getOpenRigHome();
|
||||
const openrigUrl = readOpenRigUrl();
|
||||
const openrigUrl = process.env.OPENRIG_URL;
|
||||
const probeClassified = (url: string) =>
|
||||
cc.probeHealthz(url, { fetch: (u, init) => fetch(u, init as RequestInit), timeoutMs: 700 });
|
||||
const detectDeps = {
|
||||
@@ -91,7 +83,9 @@ async function realEmit(): Promise<CrashCartEmit> {
|
||||
? `alive (pid ${state.pid})`
|
||||
: `dead (pid ${state.pid})`
|
||||
: "no daemon.json";
|
||||
const url = crashCartEvidenceUrl(openrigUrl, state);
|
||||
const url = openrigUrl
|
||||
? `${openrigUrl.replace(/\/$/, "")}/healthz`
|
||||
: `http://${formatDaemonHostForUrl(state?.host ?? "127.0.0.1")}:${state?.port ?? 7433}/healthz`;
|
||||
const probeResult = await probeClassified(url);
|
||||
return { pidState, probeResult, failedSignal: `healthz ${probeResult} at ${url}` };
|
||||
},
|
||||
|
||||
@@ -264,7 +264,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
// know whether the kernel itself reached ready. Operators who
|
||||
// need a kernel-ready signal opt in here.
|
||||
if (opts.waitForKernel) {
|
||||
const { waitForKernelReady, skippedKernelMessage } = await import("../daemon-lifecycle.js");
|
||||
const { waitForKernelReady } = await import("../daemon-lifecycle.js");
|
||||
const timeoutMs = opts.waitForKernelMs && /^\d+$/.test(opts.waitForKernelMs)
|
||||
? parseInt(opts.waitForKernelMs, 10)
|
||||
: 60_000;
|
||||
@@ -272,10 +272,6 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
const result = await waitForKernelReady(baseUrl, timeoutMs);
|
||||
if (result.ok) {
|
||||
console.log(`Kernel ${result.kernelState}; variant=${result.variant ?? "(none)"}`);
|
||||
} else if (result.kernelState === "skipped") {
|
||||
// Skipped on purpose, so neither a timeout nor an auth problem: say why, and the way back.
|
||||
console.error(skippedKernelMessage(result.detail));
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
// Honest 3-part error per banked discipline.
|
||||
console.error(
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
readCmuxSocketControlModeFromText,
|
||||
resolveCmuxSettingsPath,
|
||||
} from "../cmux-config.js";
|
||||
import { buildTmuxCaptureFailure, buildTmuxControlFailure, probeTmuxCapture, probeTmuxControl } from "../tmux-health.js";
|
||||
import { buildTmuxControlFailure, probeTmuxControl } from "../tmux-health.js";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { compareSpecToLive, topologyFromRigSpec, topologyFromLiveLogicalIds } from "@openrig/daemon/spec-conformance";
|
||||
import { classifyNodeVersion } from "../node-support.js";
|
||||
@@ -31,8 +31,7 @@ export interface DoctorDeps {
|
||||
exists: (p: string) => boolean;
|
||||
baseDir: string;
|
||||
readFile: (path: string) => string | null;
|
||||
/** `timeoutMs` bounds one command; without it a command runs to completion, as before. */
|
||||
exec: (cmd: string, opts?: { timeoutMs?: number }) => string;
|
||||
exec: (cmd: string) => string;
|
||||
checkPort: (port: number, host: string) => Promise<boolean>;
|
||||
configStore: Pick<ConfigStore, "resolve">;
|
||||
platform?: NodeJS.Platform;
|
||||
@@ -129,16 +128,6 @@ export function runDoctorChecks(deps: DoctorDeps, providerChecks: DoctorCheck[]
|
||||
});
|
||||
} else if (tmuxProbe.available && tmuxProbe.version) {
|
||||
checks.push({ name: "tmux", status: "pass", message: tmuxProbe.version });
|
||||
// #980: a tmux whose server exits on `capture-pane -p` fails every seat at launch.
|
||||
const capture = probeTmuxCapture((cmd, opts) => deps.exec(cmd, opts));
|
||||
if (capture.status === "fail") {
|
||||
const failure = buildTmuxCaptureFailure(tmuxProbe.version, capture.detail);
|
||||
checks.push({ name: "tmux_capture", status: "fail", message: failure.message, reason: failure.reason, fix: failure.fix });
|
||||
} else {
|
||||
checks.push(capture.status === "pass"
|
||||
? { name: "tmux_capture", status: "pass", message: "tmux can capture a pane (checked on a throwaway server)." }
|
||||
: { name: "tmux_capture", status: "skipped", message: `Couldn't start a throwaway tmux server to check capture-pane: ${capture.detail}` });
|
||||
}
|
||||
if (platform === "darwin") {
|
||||
const mouseMode = readTmuxMouseMode(deps);
|
||||
if (mouseMode === "on") {
|
||||
@@ -395,12 +384,6 @@ function buildCmuxDaemonWarning(deps: DoctorDeps, platform: NodeJS.Platform): Do
|
||||
};
|
||||
}
|
||||
|
||||
/** The standalone doctor's command runner. A command with `timeoutMs` is bounded (the tmux capture probe passes one); others
|
||||
* run to completion, as before. */
|
||||
export function doctorExec(c: string, opts?: { timeoutMs?: number }): string {
|
||||
return execSync(c, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], ...(opts?.timeoutMs ? { timeout: opts.timeoutMs } : {}) });
|
||||
}
|
||||
|
||||
function readTmuxMouseMode(deps: DoctorDeps): "on" | "off" | null {
|
||||
try {
|
||||
const mode = deps.exec("tmux show-options -gqv mouse").trim().toLowerCase();
|
||||
@@ -444,7 +427,7 @@ export function doctorCommand(depsOverride?: DoctorDeps): Command {
|
||||
exists: existsSync,
|
||||
baseDir: import.meta.dirname,
|
||||
readFile: (p: string) => { try { return readFileSync(p, "utf-8"); } catch { return null; } },
|
||||
exec: doctorExec,
|
||||
exec: (c: string) => execSync(c, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }),
|
||||
checkPort: defaultCheckPort,
|
||||
configStore: new ConfigStore(),
|
||||
mkdirp: (dirPath: string) => mkdirSync(dirPath, { recursive: true }),
|
||||
|
||||
@@ -90,7 +90,6 @@ interface TeardownResult {
|
||||
keptGuidance?: string[];
|
||||
/** Seats joined with `rig attach` as an external CLI, which OpenRig doesn't stop. */
|
||||
leftRunning?: string[];
|
||||
paneOnly?: string[];
|
||||
}
|
||||
|
||||
interface DownAgentEntry {
|
||||
@@ -299,9 +298,6 @@ the command warns and exits without stopping; re-run with --yes to confirm expli
|
||||
if (result.leftRunning && result.leftRunning.length > 0) {
|
||||
console.log(`Not stopped: ${result.leftRunning.join(", ")}. Joined with rig attach as an external CLI, which OpenRig doesn't stop or check.`);
|
||||
}
|
||||
if (result.paneOnly && result.paneOnly.length > 0) {
|
||||
console.log(`Pane stopped: ${result.paneOnly.join(", ")}. A community harness's own processes may keep running after its pane; see its preset's limits.`);
|
||||
}
|
||||
|
||||
// Exit code: errors first, then deleted, then alreadyStopped
|
||||
if (result.errors.length > 0) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { StatusDeps } from "./status.js";
|
||||
|
||||
interface EdgeEnd {
|
||||
logicalId: string;
|
||||
nodeId: string | null;
|
||||
nodeId: string;
|
||||
}
|
||||
|
||||
interface EdgeSuccess {
|
||||
@@ -39,8 +39,7 @@ function report(result: EdgeOpResult, opts: { json?: boolean }): void {
|
||||
switch (result.outcome) {
|
||||
case "would_add":
|
||||
console.log(`plan add ${line} in rig ${result.rigId}`);
|
||||
console.log(` from node ${edge.from.nodeId}, to ${edge.to.nodeId === null
|
||||
? `${edge.to.logicalId} (resolved when used)` : `node ${edge.to.nodeId}`}`);
|
||||
console.log(` from node ${edge.from.nodeId}, to node ${edge.to.nodeId}`);
|
||||
console.log(" nothing written (--plan)");
|
||||
break;
|
||||
case "added":
|
||||
@@ -125,10 +124,10 @@ export function edgeCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
cmd
|
||||
.command("add")
|
||||
.description("Ensure one typed edge; an existing edge is unchanged")
|
||||
.description("Ensure one edge between two existing seats; an existing edge is unchanged")
|
||||
.argument("<rig>", "Target rig name or ID")
|
||||
.argument("[from]", "Existing source seat logical ID, such as orch.lead")
|
||||
.argument("[to]", "Target logical ID, or a same-daemon address for escalates_to")
|
||||
.argument("[from]", "Source seat logical ID, such as orch.lead")
|
||||
.argument("[to]", "Target seat logical ID, such as review.reviewer")
|
||||
.option("--kind <kind>", "delegates_to, spawned_by, can_observe, collaborates_with or escalates_to")
|
||||
.option("--plan", "Validate the request and show the change without writing")
|
||||
.option("--json", "JSON output for agents")
|
||||
@@ -136,12 +135,8 @@ export function edgeCommand(depsOverride?: StatusDeps): Command {
|
||||
Examples:
|
||||
rig edge add my-rig orch.lead review.reviewer --kind delegates_to
|
||||
rig edge add my-rig orch.lead review.reviewer --kind delegates_to --plan --json
|
||||
rig edge add my-rig work.lead review-advisor@coord --kind escalates_to
|
||||
|
||||
Use existing, fully qualified logical IDs in <rig> for both same-pod and
|
||||
cross-pod edges. For escalates_to only, the target may instead be a canonical
|
||||
same-daemon seat address (pod-member@rig), resolved when used; that rig or seat
|
||||
need not exist yet. Cross-host and synthetic destinations are unsupported.
|
||||
Endpoints are fully qualified logical IDs for both same-pod and cross-pod edges.
|
||||
Run it once per edge. A repeat reports the existing edge and its ID, and writes
|
||||
nothing. No seats or sessions are changed.`)
|
||||
.action(async (rig: string, from: string | undefined, to: string | undefined, opts: { kind?: string; plan?: boolean; json?: boolean }) => {
|
||||
|
||||
@@ -13,10 +13,6 @@ export interface ExportDeps extends StatusDeps {
|
||||
exists?: (path: string) => boolean;
|
||||
}
|
||||
|
||||
interface ExportedStartupBlock {
|
||||
files?: Array<{ path?: string; required?: boolean }>;
|
||||
}
|
||||
|
||||
/** Copy only the declared local culture, preserving any recipient file. */
|
||||
function copyCulture(file: string, root: string, outDir: string): void {
|
||||
if (nodePath.isAbsolute(file) || file.split(/[\\/]/).includes("..")) throw new Error("culture path must stay inside the rig folder");
|
||||
@@ -91,11 +87,7 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
|
||||
if (parsed["name"] === "starter") {
|
||||
console.warn("Warning: this export still installs as 'starter'. Before sharing, rename the team's name in rig.yaml; changing the bundle name alone does not rename the installed rig.");
|
||||
}
|
||||
const pods = parsed["pods"] as Array<{
|
||||
id?: string;
|
||||
startup?: ExportedStartupBlock;
|
||||
members?: Array<{ id?: string; agent_ref?: string }>;
|
||||
}> | undefined;
|
||||
const pods = parsed["pods"] as Array<{ id?: string; members?: Array<{ id?: string; agent_ref?: string }> }> | undefined;
|
||||
for (const pod of Array.isArray(pods) ? pods : []) {
|
||||
for (const member of Array.isArray(pod.members) ? pod.members : []) {
|
||||
const local = typeof member.agent_ref === "string" && member.agent_ref.startsWith("local:") ? nodePath.normalize(member.agent_ref.slice(6)) : null;
|
||||
@@ -121,16 +113,11 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
|
||||
filesToCheck.push(parsed["culture_file"]);
|
||||
}
|
||||
|
||||
const startupBlocks = [
|
||||
parsed["startup"] as ExportedStartupBlock | undefined,
|
||||
...(Array.isArray(pods) ? pods.map((pod) => pod.startup) : []),
|
||||
];
|
||||
for (const startup of startupBlocks) {
|
||||
if (startup && Array.isArray(startup.files)) {
|
||||
for (const f of startup.files) {
|
||||
if (typeof f?.path === "string" && f.required !== false && !filesToCheck.includes(f.path)) {
|
||||
filesToCheck.push(f.path);
|
||||
}
|
||||
const startup = parsed["startup"] as { files?: Array<{ path?: string; required?: boolean }> } | undefined;
|
||||
if (startup && Array.isArray(startup.files)) {
|
||||
for (const f of startup.files) {
|
||||
if (typeof f?.path === "string" && f.required !== false && !filesToCheck.includes(f.path)) {
|
||||
filesToCheck.push(f.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Command } from "commander";
|
||||
import { resolveSenderSession, SENDER_FALLBACK } from "../sender-identity.js";
|
||||
// The human registry is home-state owned by the daemon; the verb LAZY-imports the narrow
|
||||
@@ -183,30 +182,32 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
|
||||
.option("--replace", "Explicitly replace an existing human (no silent overwrite)")
|
||||
.option("--reason <reason>", "Reason recorded with connector binding changes", "register human delivery")
|
||||
.option("--actor <actor>", "Named operator when outside a managed seat")
|
||||
.option("--organization <id>", "The person's organization (create it first with rig gateway org set)")
|
||||
.option("--group <id>", "A group the person belongs to (repeatable; create it first with rig gateway group set)", (v: string, acc: string[] = []) => { acc.push(v); return acc; })
|
||||
.option("--policy <text>", "Readable policy for agents working with this person (text, not enforced)")
|
||||
.option("--policy-file <path>", "Read the policy text from a file")
|
||||
.option("--default", "Make this person the default: the one escalations, the notice digest and other unaddressed needs go to")
|
||||
.action(async (entityId: string, opts: { displayName: string; binding: string[]; deliveryClass: string; away?: boolean; replace?: boolean; reason: string; actor?: string; organization?: string; group?: string[]; policy?: string; policyFile?: string; default?: boolean }) => {
|
||||
.action(async (entityId: string, opts: { displayName: string; binding: string[]; deliveryClass: string; away?: boolean; replace?: boolean; reason: string; actor?: string }) => {
|
||||
// LAZY import the narrow daemon surface at invocation (dep rail 2).
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const { addHumanFragment, parseBindingSpec } = registry as unknown as {
|
||||
addHumanFragment: typeof AddHumanFragment;
|
||||
parseBindingSpec: (spec: string) => { ok: true; binding: Record<string, unknown> } | { ok: false; error: string };
|
||||
};
|
||||
// Several people may be registered. Their organization, groups, policy and the default go in the participant side
|
||||
// file, never the fragment, so an older daemon still loads every person after a rollback.
|
||||
const policy = readPolicyOption(opts);
|
||||
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
|
||||
const placed = opts.organization !== undefined || opts.group !== undefined || policy.value !== undefined;
|
||||
if (placed) {
|
||||
// Check the organization and groups exist before writing anything, so a typo doesn't leave a half-placed person.
|
||||
const side = registry.readParticipantsFile();
|
||||
if (!side.ok) { console.error(`refused: ${side.error}`); process.exitCode = 1; return; }
|
||||
if (opts.organization && !side.file.organizations[opts.organization]) { console.error(`refused: no organization "${opts.organization}" — create it with rig gateway org set ${opts.organization} --name …`); process.exitCode = 1; return; }
|
||||
const unknownGroup = (opts.group ?? []).find((group) => !side.file.groups[group]);
|
||||
if (unknownGroup) { console.error(`refused: no group "${unknownGroup}" — create it with rig gateway group set ${unknownGroup} --organization … --name …`); process.exitCode = 1; return; }
|
||||
// Fix-r1 F1 (R2 blocking, A1/R5): the add VERB is the single-human boundary. A second
|
||||
// DISTINCT human is refused BEFORE any parse or write — the several-fragment state A1
|
||||
// describes as hand-authored must never be produced by the product's own surface.
|
||||
// Fail closed: an unverifiable registry refuses too (never add into an unknown state).
|
||||
const existing = registry.listHumans();
|
||||
if (!existing.ok) {
|
||||
console.error(`refused: cannot verify the single-human boundary — ${existing.error}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (existing.humans.length > 0 && !existing.humans.some((h) => h.entityId === entityId)) {
|
||||
const ids = existing.humans.map((h) => h.entityId).join(", ");
|
||||
console.error(
|
||||
`refused: a human is already configured (${ids}). OpenRig supports one configured human, so \`rig gateway human add\` manages one; ` +
|
||||
`to register someone else, remove the existing one first (\`rig gateway human remove <entityId>\`). Fragments hand-authored under ${registry.humansDir()} ` +
|
||||
`are displayed as found, with an advisory, but no command manages several.`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const bindings: Record<string, unknown>[] = [];
|
||||
for (const spec of opts.binding) {
|
||||
@@ -235,19 +236,7 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
|
||||
return { value: res, after: { digest }, effect: before.ok && registry.channelStateDigest(before.record.connectorBindings.map(({ inboundResolvable: _derived, ...binding }) => binding)) === digest ? "no-op" : "applied" };
|
||||
},
|
||||
});
|
||||
// The person is registered; their placement and the default are a second, separate write.
|
||||
const followUps: Record<string, unknown> = {};
|
||||
if (placed) {
|
||||
const res = registry.setPersonPlacement(entityId, { organization: opts.organization, groups: opts.group, policy: policy.value });
|
||||
followUps.placement = res.ok ? { path: res.path } : { error: res.error };
|
||||
}
|
||||
if (opts.default) {
|
||||
const res = registry.setDefaultPerson(entityId);
|
||||
followUps.default = res.ok ? { path: res.path } : { error: res.error };
|
||||
}
|
||||
const failed = Object.values(followUps).some((value) => "error" in (value as object));
|
||||
console.log(JSON.stringify({ ok: !failed, entityId, path: result.value.path, receipt: result.receipt, ...followUps }));
|
||||
if (failed) { console.error(`the person was registered, but part of their placement wasn't saved; fix it with rig gateway human set ${entityId} … or rig gateway default ${entityId}`); process.exitCode = 1; }
|
||||
console.log(JSON.stringify({ ok: true, entityId, path: result.value.path, receipt: result.receipt }));
|
||||
} catch (error) { console.error(`refused: ${(error as Error).message}`); process.exitCode = 1; }
|
||||
})
|
||||
.addHelpText("after", `
|
||||
@@ -270,7 +259,7 @@ Example:
|
||||
|
||||
human
|
||||
.command("list")
|
||||
.description("List the registered people, with delivery readiness and quiet hours (rig gateway participants adds organizations, groups, policy and shared channels)")
|
||||
.description("Show the configured human (OpenRig supports one; extra hand-authored fragments are listed with an advisory)")
|
||||
.option("--json", "Complete record(s) as JSON")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const { listHumans } = await import("@openrig/daemon/gateway-human-registry");
|
||||
@@ -281,7 +270,7 @@ Example:
|
||||
return { ...record, deliveryReadiness: await humanReadiness(record.entityId),
|
||||
quiet: quiet.ok ? quiet.quiet : { unknown: `quiet hours could not be read from the daemon: ${quiet.reason}` } };
|
||||
}));
|
||||
if (opts.json) { console.log(JSON.stringify({ ok: true, humans })); return; }
|
||||
if (opts.json) { console.log(JSON.stringify({ ok: true, humans, ...(res.advisory ? { advisory: res.advisory } : {}) })); return; }
|
||||
if (res.humans.length === 0) { console.log("no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"); return; }
|
||||
for (const h of humans) {
|
||||
const inbound = h.bindings.inboundResolvable ? "" : " [outbound-only]";
|
||||
@@ -289,6 +278,7 @@ Example:
|
||||
if (!h.deliveryReadiness.ready) console.log(` delivery: ${h.deliveryReadiness.reason}${h.deliveryReadiness.nextAction ? `; next: ${h.deliveryReadiness.nextAction}` : ""}`);
|
||||
console.log(` ${"unknown" in h.quiet ? `quiet: unknown (${h.quiet.unknown})` : describeQuiet(h.quiet)}`);
|
||||
}
|
||||
if (res.advisory) console.log(`advisory: ${res.advisory}`);
|
||||
});
|
||||
|
||||
const quiet = human.command("quiet").description("Quiet hours: while on, routine asks to this person are held, not posted; urgent ones (tagged escalation) still reach them. Held asks post once, each on its own, when quiet ends");
|
||||
@@ -530,22 +520,13 @@ Examples:
|
||||
.description("Show the effective record: fragment values + which defaults filled the rest, with provenance")
|
||||
.option("--json", "Complete record as JSON")
|
||||
.action(async (entityId: string, opts: { json?: boolean }) => {
|
||||
const { showHuman, participantsView } = await import("@openrig/daemon/gateway-human-registry");
|
||||
const { showHuman } = await import("@openrig/daemon/gateway-human-registry");
|
||||
const res = showHuman(entityId);
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
// The person's organization, groups, policy and whether they're the default come from the participant side file.
|
||||
const view = participantsView();
|
||||
const placement = view.ok ? view.participants.find((p) => p.id === entityId) : undefined;
|
||||
const participant = placement
|
||||
? { organization: placement.organization, groups: placement.groups, default: placement.default, policy: placement.policy }
|
||||
: { unavailable: view.ok ? "not found" : view.error };
|
||||
const sideFileError = view.ok ? view.sideFile.error : undefined;
|
||||
const r = { ...res.record, participant, ...(sideFileError ? { participantFileError: sideFileError } : {}), deliveryReadiness: await humanReadiness(entityId) };
|
||||
const r = { ...res.record, deliveryReadiness: await humanReadiness(entityId) };
|
||||
if (opts.json) { console.log(JSON.stringify({ ok: true, record: r })); return; }
|
||||
console.log(`${r.entityId} (${r.address}) — "${r.displayName}"`);
|
||||
console.log(` fragment: ${r.fragmentPath}`);
|
||||
if (placement) for (const line of describePlacement(placement)) console.log(line);
|
||||
if (sideFileError) console.log(` participant file: ${sideFileError}`);
|
||||
console.log(` delivery-class: ${r.prefs.deliveryClass.value} (${r.prefs.deliveryClass.source})`);
|
||||
console.log(` away: ${r.prefs.away.value} (${r.prefs.away.source})`);
|
||||
console.log(` slack-subscription: ${r.slackSubscription.value} (${r.slackSubscription.source})`);
|
||||
@@ -558,26 +539,11 @@ Examples:
|
||||
|
||||
human
|
||||
.command("set <entityId> <field> <value>")
|
||||
.description("Edit one field through the verb (same validation as add; re-projection immediate). Fields: display-name, delivery-class, away, availability (available|focus|away|off, or unset), slack-subscription (the person's Slack subscription: every-ask, the default, posts every ask to Slack too; urgent-only posts only asks created with --alert, the rest wait on Steering and the TUI; or unset), binding.<n>; and, kept in the participant side file: organization (an id, or unset), groups (comma-separated ids, or unset), policy (text, @<path> to read a file, or unset)")
|
||||
.description("Edit one field through the verb (same validation as add; re-projection immediate). Fields: display-name, delivery-class, away, availability (available|focus|away|off, or unset), slack-subscription (the person's Slack subscription: every-ask, the default, posts every ask to Slack too; urgent-only posts only asks created with --alert, the rest wait on Steering and the TUI; or unset), binding.<n>")
|
||||
.option("--reason <reason>", "Reason recorded with connector binding changes", "update human binding")
|
||||
.option("--actor <actor>", "Named operator when outside a managed seat")
|
||||
.action(async (entityId: string, field: string, value: string, opts: { reason: string; actor?: string }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
if (field === "organization" || field === "groups" || field === "policy") {
|
||||
const clear = value === "unset";
|
||||
let input: Record<string, unknown>;
|
||||
if (field === "organization") input = { organization: clear ? null : value };
|
||||
else if (field === "groups") input = { groups: clear ? null : value.split(",").map((g) => g.trim()).filter(Boolean) };
|
||||
else {
|
||||
const policy = clear ? { ok: true as const, value: null } : value.startsWith("@") ? readPolicyOption({ policyFile: value.slice(1) }) : { ok: true as const, value };
|
||||
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
|
||||
input = { policy: policy.value };
|
||||
}
|
||||
const res = registry.setPersonPlacement(entityId, input);
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
console.log(JSON.stringify({ ok: true, entityId, field, path: res.path }));
|
||||
return;
|
||||
}
|
||||
if (field.startsWith("binding.")) {
|
||||
const before = registry.showHuman(entityId);
|
||||
if (!before.ok) { console.error(`refused: ${before.error}`); process.exitCode = 1; return; }
|
||||
@@ -634,141 +600,7 @@ Examples:
|
||||
];
|
||||
const res = removeHumanFragment(entityId, { force: !!opts.force, inflight });
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
// Their placement and, if it was them, the default go too; a later read would ignore them anyway, with a warning.
|
||||
const { removeParticipantEntry } = await import("@openrig/daemon/gateway-human-registry");
|
||||
const side = removeParticipantEntry(entityId, undefined, { kind: "person" });
|
||||
console.log(JSON.stringify({ ok: true, removed: res.removed, archivedPath: res.archivedPath, ...(res.orphanRecordPath ? { orphanRecordPath: res.orphanRecordPath } : {}),
|
||||
participantFile: side.ok ? { path: side.path } : { error: side.error } }));
|
||||
});
|
||||
|
||||
// ── Participants beyond people (0.7.3): shared channels, organizations, groups, readable policy and the default. All of
|
||||
// it lives in gateway/participants.yaml, which older daemons don't read, so a rollback still loads every person.
|
||||
|
||||
const channel = cmd.command("channel").description("Shared-channel participants: one vetted Slack channel is one participant (<id>@external), not a person per sender");
|
||||
channel
|
||||
.command("add <id>")
|
||||
.description("Register a shared channel")
|
||||
.requiredOption("--display-name <name>", "Human-readable name, e.g. \"Acme customers\"")
|
||||
.requiredOption("--binding <slack:connectorRef:secretsRef:channelId>", "The channel: connector kind (slack), connector label, vault pointer for its secrets (never the secret), and the Slack channel id")
|
||||
.option("--organization <id>", "Its organization (create it first with rig gateway org set)")
|
||||
.option("--group <id>", "A group it belongs to (repeatable)", (v: string, acc: string[] = []) => { acc.push(v); return acc; })
|
||||
.option("--policy <text>", "Readable policy for agents answering this channel (text, not enforced)")
|
||||
.option("--policy-file <path>", "Read the policy text from a file")
|
||||
.option("--replace", "Explicitly replace an existing channel (no silent overwrite)")
|
||||
.action(async (id: string, opts: { displayName: string; binding: string; organization?: string; group?: string[]; policy?: string; policyFile?: string; replace?: boolean }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const policy = readPolicyOption(opts);
|
||||
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
|
||||
const parts = opts.binding.split(":");
|
||||
if (parts.length < 4 || parts[0] !== "slack") { console.error("refused: --binding must be slack:<connectorRef>:<secretsRef>:<channelId>"); process.exitCode = 1; return; }
|
||||
const res = registry.setSharedChannel(id, {
|
||||
displayName: opts.displayName,
|
||||
channel: { connector: "slack", connectorRef: parts[1]!, secretsRef: parts.slice(2, -1).join(":"), channelId: parts[parts.length - 1]! },
|
||||
organization: opts.organization, groups: opts.group, policy: policy.value,
|
||||
}, { replace: !!opts.replace });
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
console.log(JSON.stringify({ ok: true, id, address: `${id}@${registry.ADDRESS_DOMAIN}`, path: res.path }));
|
||||
});
|
||||
channel
|
||||
.command("remove <id>")
|
||||
.description("Remove a shared channel: refuses while non-terminal rows are addressed to it (--force removes anyway)")
|
||||
.option("--force", "Remove despite KNOWN non-terminal rows")
|
||||
.action(async (id: string, opts: { force?: boolean }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const side = registry.readParticipantsFile();
|
||||
if (!side.ok) { console.error(`refused: ${side.error}`); process.exitCode = 1; return; }
|
||||
if (side.file.participants[id]?.kind !== "shared-channel") { console.error(`refused: no shared channel "${id}"`); process.exitCode = 1; return; }
|
||||
const address = `${id}@${registry.ADDRESS_DOMAIN}`;
|
||||
const rowsRes = await queueRows(address);
|
||||
if (!rowsRes.ok) { console.error(`refused: the queue could not be checked for rows addressed to ${address} (${rowsRes.error}); --force doesn't override an unchecked board`); process.exitCode = 1; return; }
|
||||
if (rowsRes.rows.length && !opts.force) {
|
||||
console.error(`refused: ${rowsRes.rows.length} non-terminal row(s) are addressed to ${address} (${rowsRes.rows.map((r) => r.id).join(", ")}); pass --force to remove anyway`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const res = registry.removeParticipantEntry(id, undefined, { kind: "shared-channel" });
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
console.log(JSON.stringify({ ok: true, removed: id, path: res.path, ...(rowsRes.rows.length ? { orphanedRows: rowsRes.rows.map((r) => r.id) } : {}) }));
|
||||
});
|
||||
|
||||
const org = cmd.command("org").description("Organizations: the outer layer of participants, with readable policy agents apply");
|
||||
org
|
||||
.command("set <id>")
|
||||
.description("Create or update an organization")
|
||||
.option("--name <name>", "Display name (required when new)")
|
||||
.option("--policy <text>", "Readable policy for agents working with this organization (text, not enforced; \"\" clears it)")
|
||||
.option("--policy-file <path>", "Read the policy text from a file")
|
||||
.action(async (id: string, opts: { name?: string; policy?: string; policyFile?: string }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const policy = readPolicyOption(opts);
|
||||
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
|
||||
const res = registry.setOrganization(id, { name: opts.name, ...(policy.value !== undefined ? { policy: policy.value || null } : {}) });
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
console.log(JSON.stringify({ ok: true, organization: id, path: res.path }));
|
||||
});
|
||||
|
||||
const group = cmd.command("group").description("Groups inside an organization, with readable policy agents apply");
|
||||
group
|
||||
.command("set <id>")
|
||||
.description("Create or update a group")
|
||||
.option("--organization <id>", "Its organization (required when new)")
|
||||
.option("--name <name>", "Display name (required when new)")
|
||||
.option("--policy <text>", "Readable policy for agents working with this group (text, not enforced; \"\" clears it)")
|
||||
.option("--policy-file <path>", "Read the policy text from a file")
|
||||
.action(async (id: string, opts: { organization?: string; name?: string; policy?: string; policyFile?: string }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const policy = readPolicyOption(opts);
|
||||
if (!policy.ok) { console.error(`refused: ${policy.error}`); process.exitCode = 1; return; }
|
||||
const res = registry.setGroup(id, { organization: opts.organization, name: opts.name, ...(policy.value !== undefined ? { policy: policy.value || null } : {}) });
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
console.log(JSON.stringify({ ok: true, group: id, path: res.path }));
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("participants")
|
||||
.description("List people and shared channels, with organization, groups, policy and the default")
|
||||
.option("--organization <id>", "Only this organization's participants")
|
||||
.option("--json", "Complete records as JSON, including organizations and groups")
|
||||
.action(async (opts: { organization?: string; json?: boolean }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const view = registry.participantsView();
|
||||
if (!view.ok) { console.error(`refused: ${view.error}`); process.exitCode = 1; return; }
|
||||
const shown = opts.organization ? view.participants.filter((p) => p.organization?.id === opts.organization) : view.participants;
|
||||
const defaultPerson = view.default.person ? { id: view.default.person.entityId, source: view.default.source } : { id: null, reason: view.default.source === "none" ? view.default.reason : null };
|
||||
if (opts.json) {
|
||||
const side = registry.readParticipantsFile();
|
||||
console.log(JSON.stringify({ ok: true, participants: shown, default: defaultPerson,
|
||||
organizations: side.ok ? side.file.organizations : null, groups: side.ok ? side.file.groups : null,
|
||||
warnings: view.warnings, participantFile: view.sideFile }));
|
||||
return;
|
||||
}
|
||||
if (!shown.length) console.log(opts.organization ? `no participants in organization "${opts.organization}"` : "no participants yet: rig gateway human add … registers a person, rig gateway channel add … a shared channel");
|
||||
for (const p of shown) {
|
||||
console.log(`${p.id} ${p.kind} "${p.displayName}" ${p.address}${p.channel ? ` channel ${p.channel.channelId}` : ""}`);
|
||||
for (const line of describePlacement(p)) console.log(line);
|
||||
}
|
||||
console.log(defaultPerson.id ? `default: ${defaultPerson.id} (${defaultPerson.source === "chosen" ? "chosen" : "the only person"})` : `default: none — ${defaultPerson.reason}`);
|
||||
for (const warning of view.warnings) console.log(`warning: ${warning}`);
|
||||
if (view.sideFile.error) console.log(`participant file: ${view.sideFile.error}`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("default [entityId]")
|
||||
.description("Show or choose the default person: the one escalations, the notice digest, Mission Control's my-queue and pairing go to when no person is named")
|
||||
.option("--clear", "Clear the chosen default (with one person, they stay the default)")
|
||||
.action(async (entityId: string | undefined, opts: { clear?: boolean }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
if (entityId || opts.clear) {
|
||||
if (entityId && opts.clear) { console.error("refused: pass an id or --clear, not both"); process.exitCode = 1; return; }
|
||||
const res = registry.setDefaultPerson(opts.clear ? null : entityId!);
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
}
|
||||
const people = registry.projectHumans();
|
||||
if (!people.ok) { console.error(`refused: ${people.error}`); process.exitCode = 1; return; }
|
||||
const chosen = registry.resolveDefaultPerson(people.entities);
|
||||
console.log(JSON.stringify(chosen.person
|
||||
? { ok: true, default: chosen.person.entityId, address: chosen.person.address, source: chosen.source }
|
||||
: { ok: true, default: null, reason: chosen.source === "none" ? chosen.reason : null }));
|
||||
console.log(JSON.stringify({ ok: true, removed: res.removed, archivedPath: res.archivedPath, ...(res.orphanRecordPath ? { orphanRecordPath: res.orphanRecordPath } : {}) }));
|
||||
});
|
||||
|
||||
return cmd;
|
||||
@@ -791,26 +623,3 @@ function describeApplication(a: Record<string, any>): string {
|
||||
const effects = (a.effects as Array<Record<string, any>>).map((e) => ` ${e.consumer}: ${e.state}${e.detail ? ` (${e.detail})` : ""}`);
|
||||
return [head, ...(effects.length ? effects : [" no effects"])].join("\n");
|
||||
}
|
||||
|
||||
/** --policy or --policy-file (never both): the readable policy text agents read. Absent is undefined. */
|
||||
function readPolicyOption(opts: { policy?: string; policyFile?: string }): { ok: true; value: string | undefined } | { ok: false; error: string } {
|
||||
if (opts.policy !== undefined && opts.policyFile !== undefined) return { ok: false, error: "pass --policy or --policy-file, not both" };
|
||||
if (opts.policyFile === undefined) return { ok: true, value: opts.policy };
|
||||
try {
|
||||
return { ok: true, value: readFileSync(opts.policyFile, "utf8") };
|
||||
} catch (error) {
|
||||
return { ok: false, error: `--policy-file: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/** One participant's organization, groups, default and policy, in plain lines. */
|
||||
function describePlacement(p: { organization: { id: string; name: string } | null; groups: Array<{ id: string; name: string }>; default: boolean; policy: Array<{ source: string; text: string }> }): string[] {
|
||||
const lines = [
|
||||
` organization: ${p.organization ? `${p.organization.id} (${p.organization.name})` : "none"}`,
|
||||
` groups: ${p.groups.length ? p.groups.map((g) => `${g.id} (${g.name})`).join(", ") : "none"}`,
|
||||
];
|
||||
if (p.default) lines.push(" default: yes (escalations, the notice digest and other unaddressed needs go to this person)");
|
||||
if (!p.policy.length) lines.push(" policy: none");
|
||||
for (const part of p.policy) lines.push(` policy, from ${part.source}:`, ...part.text.trimEnd().split("\n").map((line) => ` ${line}`));
|
||||
return lines;
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executo
|
||||
import { recordHostObservation, loadHostBindings, describeBindingConflict, defaultHostBindingsPath, type HostObservationOutcome } from "../host-bindings.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { readOwnHostName, readSelectedHost } from "../host-selection.js";
|
||||
import { parseFeedHostSubscriptionKey } from "../config-store.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rig host pair — the approval wait is BOUNDED.
|
||||
@@ -72,46 +71,6 @@ export interface DoctorDeps {
|
||||
* sidecar (TOFU + loud-conflict). Optional so existing fixtures keep compiling AND unit runs never
|
||||
* write the real sidecar — absent means learning is skipped, which is the fail-open contract. */
|
||||
learnHostBinding?: (alias: string, observedHostId: string, observedUrl: string) => HostObservationOutcome;
|
||||
/** The registry entry a reply addressed `@<hostId>` resolves to, by the lookup `rig send` makes.
|
||||
* Optional like the learner: absent means doctor makes no claim about where replies go. */
|
||||
replyRoute?: (hostId: string) => HostEntry | null;
|
||||
/** The self-id learned for a registry alias, if any. */
|
||||
learnedHostId?: (alias: string) => string | null;
|
||||
}
|
||||
|
||||
/** Where `rig send` takes a target ending `@<hostId>`: registry id, then a declared hostId, then the
|
||||
* first hosts.yaml entry with that learned binding (cross-host-target.ts). Null when none matches. */
|
||||
export function replyRouteFromDisk(hostId: string): HostEntry | null {
|
||||
const loaded = loadHostRegistry();
|
||||
if (!loaded.ok) return null;
|
||||
const resolved = resolveHost(loaded.registry, hostId, loadHostBindings().bindings);
|
||||
return resolved.ok ? resolved.host : null;
|
||||
}
|
||||
|
||||
const REPLY_ROUTE_ORDER = "rig send resolves it by registry id, then a declared hostId, then the first hosts.yaml entry that learned it";
|
||||
|
||||
/** A reachable entry whose self-id another entry answers for. Unknown, not fail: doctor can't tell
|
||||
* from here whether that other entry still reaches the machine. */
|
||||
function replyRouteElsewhereRow(host: HostEntry, hostId: string, winner: HostEntry): CheckRow {
|
||||
return {
|
||||
step: "reply-route",
|
||||
status: "unknown",
|
||||
detail: `replies addressed @${hostId} go to '${winner.id}', not '${host.id}' (${REPLY_ROUTE_ORDER})`,
|
||||
fix: `run rig host doctor ${winner.id}; if it no longer reaches this machine, delete its entry from ${defaultHostRegistryPath()} (and its line in ${defaultHostBindingsPath()}, if any)`,
|
||||
};
|
||||
}
|
||||
|
||||
/** An unreachable entry that replies still resolve to: the failure a stale entry causes, named on
|
||||
* the entry itself. Null when the entry has no self-id or another entry answers for it. */
|
||||
function replyRouteThroughDeadEntryRow(host: HostEntry, deps: DoctorDeps): CheckRow | null {
|
||||
const hostId = host.hostId ?? deps.learnedHostId?.(host.id) ?? null;
|
||||
if (!hostId || deps.replyRoute?.(hostId)?.id !== host.id) return null;
|
||||
return {
|
||||
step: "reply-route",
|
||||
status: "fail",
|
||||
detail: `replies addressed @${hostId} resolve to '${host.id}', so they fail while it is unreachable`,
|
||||
fix: `if another entry reaches this machine, delete '${host.id}' from ${defaultHostRegistryPath()} (and its line in ${defaultHostBindingsPath()}, if any), then run rig host doctor on that entry; otherwise restore this entry's connectivity`,
|
||||
};
|
||||
}
|
||||
|
||||
function nestedBoolean(obj: unknown, path: readonly string[]): boolean | undefined {
|
||||
@@ -155,8 +114,6 @@ function defaultDoctorDeps(): DoctorDeps {
|
||||
sock.on("error", () => { sock.destroy(); resolve("closed"); });
|
||||
}),
|
||||
learnHostBinding: (alias, observedHostId, observedUrl) => recordHostObservation({ alias, observedHostId, observedUrl }),
|
||||
replyRoute: replyRouteFromDisk,
|
||||
learnedHostId: (alias) => loadHostBindings().bindings[alias]?.hostId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -206,8 +163,6 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
|
||||
? "SSH auth/permission gate — check the key, agent, and remote authorized_keys"
|
||||
: "verify the ssh target/alias, tailnet connectivity, and that sshd is running",
|
||||
});
|
||||
const routeRow = reach.failedStep === "ssh-unreachable" ? replyRouteThroughDeadEntryRow(host, deps) : null;
|
||||
if (routeRow) rows.push(routeRow);
|
||||
return rows; // later legs are meaningless without the shell
|
||||
}
|
||||
rows.push({ step: "transport-reachability", status: "pass", detail: `ssh to ${host.target} ok` });
|
||||
@@ -300,28 +255,20 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
|
||||
const parsed = JSON.parse(health.body) as { selfHostId?: unknown };
|
||||
if (typeof parsed.selfHostId === "string" && parsed.selfHostId.length > 0) {
|
||||
const obs = deps.learnHostBinding(host.id, parsed.selfHostId, (host as HttpHostEntry).url);
|
||||
if (obs.outcome === "conflict") {
|
||||
rows.push({
|
||||
step: "host-identity-binding",
|
||||
status: "fail",
|
||||
detail: describeBindingConflict(host.id, obs.binding),
|
||||
fix: `if the re-key is legitimate, delete the '${host.id}' entry in ${defaultHostBindingsPath()} and re-run doctor to re-learn`,
|
||||
});
|
||||
} else {
|
||||
// Ask the route rig send takes rather than assume it: an entry listed earlier in
|
||||
// hosts.yaml with the same identity keeps the replies, even when it no longer answers.
|
||||
const id = obs.binding.hostId;
|
||||
const winner = deps.replyRoute?.(id) ?? null;
|
||||
const routedHere = winner?.id === host.id;
|
||||
rows.push({
|
||||
step: "host-identity-binding",
|
||||
status: "pass",
|
||||
detail: obs.outcome === "bound"
|
||||
? `learned host identity '${id}' (first contact)${routedHere ? ` — reply hints ending @${id} now resolve to '${host.id}'` : ""}`
|
||||
: `host identity '${id}' confirmed`,
|
||||
});
|
||||
if (winner && !routedHere) rows.push(replyRouteElsewhereRow(host, id, winner));
|
||||
}
|
||||
rows.push(obs.outcome === "conflict"
|
||||
? {
|
||||
step: "host-identity-binding",
|
||||
status: "fail",
|
||||
detail: describeBindingConflict(host.id, obs.binding),
|
||||
fix: `if the re-key is legitimate, delete the '${host.id}' entry in ${defaultHostBindingsPath()} and re-run doctor to re-learn`,
|
||||
}
|
||||
: {
|
||||
step: "host-identity-binding",
|
||||
status: "pass",
|
||||
detail: obs.outcome === "bound"
|
||||
? `learned host identity '${obs.binding.hostId}' (first contact) — reply hints ending @${obs.binding.hostId} now resolve to '${host.id}'`
|
||||
: `host identity '${obs.binding.hostId}' confirmed`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// body not JSON — an older or non-daemon endpoint; learning is skipped, nothing breaks.
|
||||
@@ -329,8 +276,6 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
|
||||
}
|
||||
} catch (err) {
|
||||
rows.push({ step: "transport-reachability", status: "fail", detail: `daemon unreachable at ${(host as HttpHostEntry).url}: ${(err as Error).message}`, fix: "check tailnet connectivity and that the remote daemon is running (rig daemon start on the host)" });
|
||||
const routeRow = replyRouteThroughDeadEntryRow(host, deps);
|
||||
if (routeRow) rows.push(routeRow);
|
||||
return rows;
|
||||
}
|
||||
rows.push({
|
||||
@@ -536,21 +481,6 @@ function authPointer(h: HostEntry): string {
|
||||
return h.bearer_env ? `env:${h.bearer_env}` : h.bearer_file ? `file:${h.bearer_file}` : "—";
|
||||
}
|
||||
|
||||
function printHostNextSteps(host: HostEntry): void {
|
||||
console.log(`Verify it: rig host doctor ${host.id}`);
|
||||
if (host.transport !== "http") {
|
||||
console.log("Remote FEED requires an HTTP host entry. Pair its daemon URL with: rig host pair <url>");
|
||||
return;
|
||||
}
|
||||
const key = `feed.subscriptions.${host.id}.enabled`;
|
||||
if (!parseFeedHostSubscriptionKey(key)) {
|
||||
console.log(`For remote FEED, register this host with a different id: '${host.id}' is reserved by feed settings.`);
|
||||
return;
|
||||
}
|
||||
console.log("Remote FEED is off by default. To include this host's attention items, run locally:");
|
||||
console.log(` rig config set ${key} true`);
|
||||
}
|
||||
|
||||
export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
const cmd = new Command("host").description("Manage the multi-host registry (~/.openrig/hosts.yaml)");
|
||||
|
||||
@@ -586,7 +516,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
return;
|
||||
}
|
||||
console.log(`Added host '${res.entry.id}' (${res.entry.transport}) to ${res.path}`);
|
||||
printHostNextSteps(res.entry);
|
||||
console.log(`Verify it: rig host doctor ${res.entry.id}`);
|
||||
});
|
||||
|
||||
cmd
|
||||
@@ -856,7 +786,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
return;
|
||||
}
|
||||
console.log(`Paired. Host '${id}' registered (${targetBase}, bearer_file ${tokenPath}).`);
|
||||
printHostNextSteps(added.entry);
|
||||
console.log(`Verify it: rig host doctor ${id}`);
|
||||
});
|
||||
|
||||
cmd
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Command } from "commander";
|
||||
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps , daemonStatusGuard} from "../daemon-lifecycle.js";
|
||||
import { createMcpServer } from "../mcp-server.js";
|
||||
import { addGrant, McpHttpConfigError, mcpTokenFilePath, readGrantServiceHeader, readGrants, revokeGrant, startMcpHttpServer } from "../mcp-http.js";
|
||||
import { addGrant, McpHttpConfigError, mcpTokenFilePath, readGrants, revokeGrant, startMcpHttpServer } from "../mcp-http.js";
|
||||
import { readOpenRigEnv } from "../openrig-compat.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
@@ -23,46 +23,24 @@ export function mcpCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--port <port>", "Daemon port override")
|
||||
.option("--http", "Serve over Streamable HTTP at /mcp instead of stdio")
|
||||
.option("--bind <host:port>", "With --http: where to listen (default 127.0.0.1:7437). Beyond loopback needs a grant (rig mcp token add)")
|
||||
.option("--grant-service <url>", "With --http: check each request's key with the grant service at this URL instead of the token file")
|
||||
.option("--grant-service-header-file <path>", "With --grant-service: a file holding one 'Name: value' header to send with each grant request")
|
||||
.option("--oauth-resource <url>", "With --grant-service and --oauth-issuer: the public https URL clients reach this server as (its path ends in /mcp), for OAuth discovery")
|
||||
.option("--oauth-issuer <url>", "With --oauth-resource: the OAuth authorization server that issues the keys --grant-service checks")
|
||||
.addHelpText("after", `
|
||||
Both MCP protocol revisions are served, over stdio and --http alike: clients on 2026-07-28 (which may start with
|
||||
server/discover) and clients that open with 2025's initialize handshake.
|
||||
|
||||
With --http and no token file, the server stays on loopback and acts as OPENRIG_SESSION_NAME, declared and not
|
||||
authenticated, like stdio. Once a grant exists (rig mcp token add), every request needs its bearer credential, loopback
|
||||
included, and each caller acts as its grant's seat within its rigs and roots. Add a grant before reaching the port
|
||||
through a tunnel or proxy: they arrive on loopback too. If the token file then goes missing or can't be read, every
|
||||
request is refused; serving unauthenticated again takes a restart with no token file. TLS comes from the tunnel or
|
||||
proxy, not from this server.
|
||||
|
||||
With --grant-service, every request needs a key the grant service issued, checked with that service on each request
|
||||
for this daemon's instance (its self id); the token file isn't read. A caller acts as the seat its grant names, which
|
||||
must already exist here (created is enough; it needn't have been launched), and reaches only what the grant names. If
|
||||
the grant service can't answer, the request is refused.
|
||||
|
||||
With --grant-service, --oauth-resource and --oauth-issuer (both or neither) add OAuth discovery (RFC 9728) for clients
|
||||
that sign in with OAuth. The resource is the public URL this server is reached as, and the issuer is the authorization
|
||||
server that issues the keys --grant-service checks. GET /.well-known/oauth-protected-resource<resource path> then answers
|
||||
with both, exactly as given, without a credential or a call to the grant service, and every 401 points there. Both must
|
||||
be https with no userinfo, query or fragment, written as a URL writes them (a lowercase host, no default port). /mcp
|
||||
stays the endpoint; a proxy in front forwards the metadata path unchanged.`)
|
||||
.action(async (opts: { port?: string; http?: boolean; bind?: string; grantService?: string; grantServiceHeaderFile?: string; oauthResource?: string; oauthIssuer?: string }) => {
|
||||
proxy, not from this server.`)
|
||||
.action(async (opts: { port?: string; http?: boolean; bind?: string }) => {
|
||||
const deps = getDepsF();
|
||||
|
||||
let daemonUrl: string;
|
||||
if (opts.port) {
|
||||
const daemonPort = parseInt(opts.port, 10);
|
||||
if (Number.isNaN(daemonPort)) {
|
||||
if (isNaN(daemonPort)) {
|
||||
console.error("Invalid port number");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (daemonPort < 1 || daemonPort > 65535) {
|
||||
console.error(`Warning: daemon port ${daemonPort} is outside [1, 65535]; MCP will start, but daemon tool calls may fail. Check --port.`);
|
||||
}
|
||||
daemonUrl = `http://127.0.0.1:${daemonPort}`;
|
||||
} else {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
@@ -70,48 +48,29 @@ stays the endpoint; a proxy in front forwards the metadata path unchanged.`)
|
||||
daemonUrl = getDaemonUrl(status);
|
||||
}
|
||||
|
||||
if (!opts.http && (opts.grantService || opts.grantServiceHeaderFile)) {
|
||||
console.error("--grant-service and --grant-service-header-file need --http");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.grantServiceHeaderFile && !opts.grantService) {
|
||||
console.error("--grant-service-header-file needs --grant-service");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if ((opts.oauthResource === undefined) !== (opts.oauthIssuer === undefined)) {
|
||||
console.error("--oauth-resource and --oauth-issuer go together: give both, or neither");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const oauth = opts.oauthResource !== undefined && opts.oauthIssuer !== undefined ? { resource: opts.oauthResource, issuer: opts.oauthIssuer } : undefined;
|
||||
if (oauth && !(opts.http && opts.grantService)) {
|
||||
console.error("--oauth-resource and --oauth-issuer need --http --grant-service");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.http) {
|
||||
await serveHttp(daemonUrl, opts.bind ?? "127.0.0.1:7437", opts.grantService, opts.grantServiceHeaderFile, oauth);
|
||||
await serveHttp(daemonUrl, opts.bind ?? "127.0.0.1:7437");
|
||||
return;
|
||||
}
|
||||
const client = deps.clientFactory(daemonUrl);
|
||||
const server = createMcpServer(client);
|
||||
const transport = new StdioServerTransport();
|
||||
let finish!: () => void;
|
||||
const closed = new Promise<void>((resolve) => { finish = resolve; });
|
||||
server.server.onclose = finish;
|
||||
process.stdin.once("end", finish);
|
||||
process.once("SIGINT", finish);
|
||||
process.once("SIGTERM", finish);
|
||||
// Serves the protocol revision the client opens with: 2026-07-28 per request, or 2025's initialize handshake.
|
||||
const served = serveStdio(() => createMcpServer(client), { onerror: (err) => console.error(`rig mcp serve: ${err.message}`) });
|
||||
try {
|
||||
// EOF is a normal stdio disconnect, not an unresolved top-level await.
|
||||
await server.connect(transport);
|
||||
if (process.stdin.readableEnded) finish();
|
||||
await closed;
|
||||
} finally {
|
||||
process.stdin.removeListener("end", finish);
|
||||
process.removeListener("SIGINT", finish);
|
||||
process.removeListener("SIGTERM", finish);
|
||||
await served.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -168,8 +127,7 @@ stays the endpoint; a proxy in front forwards the metadata path unchanged.`)
|
||||
return cmd;
|
||||
}
|
||||
|
||||
async function serveHttp(daemonUrl: string, bind: string, grantService?: string, grantServiceHeaderFile?: string,
|
||||
oauth?: { resource: string; issuer: string }): Promise<void> {
|
||||
async function serveHttp(daemonUrl: string, bind: string): Promise<void> {
|
||||
const match = /^(\[[^\]]+\]|[^:]+):(\d+)$/.exec(bind);
|
||||
if (!match) {
|
||||
console.error(`--bind takes host:port (for example 127.0.0.1:7437 or [::1]:7437), not ${bind}`);
|
||||
@@ -180,21 +138,16 @@ async function serveHttp(daemonUrl: string, bind: string, grantService?: string,
|
||||
const tokenFile = mcpTokenFilePath();
|
||||
let served;
|
||||
try {
|
||||
const header = grantServiceHeaderFile ? readGrantServiceHeader(grantServiceHeaderFile) : undefined;
|
||||
served = await startMcpHttpServer({ daemonUrl, host, port: Number(match[2]), tokenFile,
|
||||
...(grantService ? { grantService: { url: grantService, ...(header ? { header } : {}) } } : {}), ...(oauth ? { oauth } : {}) });
|
||||
served = await startMcpHttpServer({ daemonUrl, host, port: Number(match[2]), tokenFile });
|
||||
} catch (err) {
|
||||
console.error(err instanceof McpHttpConfigError ? err.message : `Can't serve on ${bind}: ${(err as Error).message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const seat = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")?.trim();
|
||||
console.error(grantService
|
||||
? `OpenRig MCP over HTTP at ${served.url}: each request needs a key from the grant service, checked with ${grantService}.`
|
||||
: served.authenticated
|
||||
console.error(served.authenticated
|
||||
? `OpenRig MCP over HTTP at ${served.url}: each request needs a bearer credential from ${tokenFile}.`
|
||||
: `OpenRig MCP over HTTP at ${served.url}: no grants, so loopback only, acting as ${seat || "no seat"}, declared and not authenticated. Add a grant (rig mcp token add) before reaching it through a tunnel or proxy.`);
|
||||
if (oauth) console.error(`OAuth discovery names ${oauth.resource}, issued by ${oauth.issuer}.`);
|
||||
await new Promise<void>((resolve) => {
|
||||
process.once("SIGINT", resolve);
|
||||
process.once("SIGTERM", resolve);
|
||||
|
||||
@@ -144,7 +144,7 @@ function terminalWakeOutcome(wakeResult: string | null): TerminalDeliveryResult[
|
||||
/** Only these known states mean "not delivered yet, nothing went wrong". An unknown value never gets that label. */
|
||||
function isKnownUnconfirmed(wakeResult: string | null, outcome: TerminalDeliveryResult["outcome"]): boolean {
|
||||
if (outcome === "not-requested" || wakeResult === null || wakeResult === "delivered-ack-pending") return true;
|
||||
return wakeResult.startsWith("indeterminate:") || wakeResult.startsWith("retained:") || wakeResult.startsWith("wake-accepted:");
|
||||
return wakeResult.startsWith("indeterminate:") || wakeResult.startsWith("retained:");
|
||||
}
|
||||
|
||||
function terminalDeliveryResult(
|
||||
@@ -165,9 +165,7 @@ function terminalDeliveryResult(
|
||||
"claimed": "Terminal receipt unconfirmed, but the seat has claimed the item (claimed).",
|
||||
"retained": `The wake is held (${wakeResult}): nothing was typed into the pane. A held create wake is not retried automatically; inspect it with rig seat held-messages ${seat}.`,
|
||||
"failed": `The wake failed (${wakeResult}).`,
|
||||
"indeterminate": wakeResult?.startsWith("wake-accepted:")
|
||||
? `The member's trigger accepted the ring (${wakeResult}). That isn't pickup: its claim is.`
|
||||
: `Terminal receipt unconfirmed (${wakeResult}).`,
|
||||
"indeterminate": `Terminal receipt unconfirmed (${wakeResult}).`,
|
||||
"still-pending": waitedMs === null ? "No terminal receipt was recorded before the claim." : `No terminal receipt was recorded within ${waitedMs}ms.`,
|
||||
"not-requested": "No terminal wake was requested (--no-nudge).",
|
||||
};
|
||||
@@ -515,10 +513,13 @@ function notThisCreate(row: Record<string, unknown>, sent: SentCreate, storedSou
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Both reconciliation and --verify read the daemon that holds the row, with its own bearer. */
|
||||
async function queueReadTarget(deps: QueueDeps, client: DaemonClient, hostId: string | undefined) {
|
||||
let remote = remoteHostOf(hostId);
|
||||
if (remote && await daemonSelfHostId(client) === remote) remote = undefined;
|
||||
/** A create whose answer didn't arrive, or arrived unreadable, may still have committed. Read the row back once by the ID it
|
||||
* was sent with, from the daemon that holds it: this one for a local row, the destination's registered daemon for a
|
||||
* cross-host one. Only a row that is this create counts as committed; anything else leaves the outcome unknown. It never
|
||||
* sends the create again. */
|
||||
async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Promise<{ row: Record<string, unknown> | null; note: string }> {
|
||||
const remote = remoteHostOf(hostId);
|
||||
const where = remote ? ` on host '${remote}'` : "";
|
||||
let reader = client;
|
||||
let options: { timeoutMs: number; headers?: Record<string, string> } = { timeoutMs: CREATE_READ_BACK_TIMEOUT_MS };
|
||||
if (remote) {
|
||||
@@ -526,45 +527,15 @@ async function queueReadTarget(deps: QueueDeps, client: DaemonClient, hostId: st
|
||||
const match = registry.ok ? resolveHost(registry.registry, remote, loadHostBindings().bindings) : undefined;
|
||||
if (!match?.ok || match.host.transport !== "http") {
|
||||
const why = match?.ok ? "its entry in this CLI's host registry is SSH, not a daemon URL" : "it isn't in this CLI's host registry";
|
||||
return { error: `from host '${remote}' (${why})` };
|
||||
return { row: null, note: `This CLI couldn't read the row back from host '${remote}' (${why}).` };
|
||||
}
|
||||
const bearer = resolveRemoteBearer(match.host);
|
||||
if (!bearer.ok) return { error: `from host '${remote}' (${bearer.error})` };
|
||||
if (!bearer.ok) return { row: null, note: `This CLI couldn't read the row back from host '${remote}' (${bearer.error}).` };
|
||||
reader = deps.clientFactory(match.host.url);
|
||||
if (bearer.token) options = { ...options, headers: { Authorization: `Bearer ${bearer.token}` } };
|
||||
}
|
||||
return { reader, options };
|
||||
}
|
||||
|
||||
/** A successful forward isn't a delivery receipt. Never accept a different remote row as this create's receipt. */
|
||||
function createReceiptReader(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Pick<DaemonClient, "get"> {
|
||||
if (!remoteHostOf(hostId)) return client;
|
||||
let target: ReturnType<typeof queueReadTarget> | undefined;
|
||||
let source: ReturnType<typeof storedSourceOf> | undefined;
|
||||
return {
|
||||
get: async <T>(path: string) => {
|
||||
const selected = await (target ??= queueReadTarget(deps, client, hostId));
|
||||
if ("error" in selected) throw new Error(`receipt could not be read ${selected.error}`);
|
||||
const response = await selected.reader.get<T>(path, selected.options);
|
||||
const row = response.data as Record<string, unknown> | undefined;
|
||||
if (response.status === 200 && (!row || row.qitemId !== qitemId
|
||||
|| notThisCreate(row, sent, await (source ??= storedSourceOf(client, sent))) !== null)) {
|
||||
throw new Error("receipt row does not match the created qitem");
|
||||
}
|
||||
return response;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A create whose answer didn't arrive, or arrived unreadable, may still have committed. Read the row back once by the ID it
|
||||
* was sent with, from the daemon that holds it. Only this create's row counts; it never sends the create again. */
|
||||
async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Promise<{ row: Record<string, unknown> | null; note: string }> {
|
||||
const remote = remoteHostOf(hostId);
|
||||
const where = remote ? ` on host '${remote}'` : "";
|
||||
const target = await queueReadTarget(deps, client, hostId);
|
||||
if ("error" in target) return { row: null, note: `This CLI couldn't read the row back ${target.error}.` };
|
||||
try {
|
||||
const res = await target.reader.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`, target.options);
|
||||
const res = await reader.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`, options);
|
||||
if (res.status === 200 && res.data?.qitemId === qitemId) {
|
||||
const differs = notThisCreate(res.data, sent, await storedSourceOf(client, sent));
|
||||
if (differs === "unread") {
|
||||
@@ -790,19 +761,6 @@ function resolveCurrentSession(explicit: string | undefined, optionName: string)
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** For a verb whose actor is the seat's own identity (the X-OpenRig-Session header the CLI sets from OPENRIG_SESSION_NAME),
|
||||
* where the named flag is deprecated and ignored: the error names what actually sets the actor, never the flag. */
|
||||
function requireSeatSession(ignoredFlag: string): string | undefined {
|
||||
const session = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
|
||||
if (session) return session;
|
||||
|
||||
console.error(`OPENRIG_SESSION_NAME is not set, so there's no seat to act as. Run this from a managed seat, or set ` +
|
||||
`OPENRIG_SESSION_NAME to the seat's session name (for example dev-impl@my-rig). --${ignoredFlag} is ignored: the ` +
|
||||
"actor is the seat's own identity.");
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractRigName(sessionName: string): string | undefined {
|
||||
// OPR.0.4.6.MH1 FR-8: the shared parse contract (greedy first-@ rig).
|
||||
return sessionRigOf(sessionName);
|
||||
@@ -1098,15 +1056,10 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
if (status < 400 && createWarning?.code === "qitem_body_not_saved" && typeof createWarning.message === "string") {
|
||||
console.error(`Warning: ${createWarning.message}`);
|
||||
}
|
||||
// An ask to a shared channel was accepted; nothing there will answer it (0.7.3).
|
||||
const advisories = Array.isArray(created?.advisories) ? created.advisories as Array<{ code?: unknown; message?: unknown }> : [];
|
||||
for (const advisory of advisories) {
|
||||
if (status < 400 && advisory.code === "shared_channel_correspondence" && typeof advisory.message === "string") console.error(`Warning: ${advisory.message}`);
|
||||
}
|
||||
if (opts.verify && status < 400) {
|
||||
const createdId = typeof created.qitemId === "string" ? created.qitemId : null;
|
||||
const delivery = createdId
|
||||
? await waitForDeliveryOutcome(createReceiptReader(deps, client, hostResolved.hostId, qitemId, sent), createdId, deps.deliveryVerify, { destinationSession: hostResolved.destination, nudge: opts.nudge })
|
||||
? await waitForDeliveryOutcome(client, createdId, deps.deliveryVerify, { destinationSession: hostResolved.destination, nudge: opts.nudge })
|
||||
: unreadableDeliveryResult(null, hostResolved.destination, {}, "create response did not include a qitem id; delivery cannot be correlated");
|
||||
await printWriteResult(client, opts.json ?? false, { ...created, qitemId: createdId, persisted: true, delivery }, status, receipt);
|
||||
return;
|
||||
@@ -1164,7 +1117,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.action(async (qitemId: string, opts: { destination?: string; json?: boolean; full?: boolean }) => {
|
||||
// P21 I3 reconcile: the claimant is DERIVED from the seat env — --destination deprecated + ignored,
|
||||
// no body claim. Verify the env (the header source) or the daemon returns 400 actor_required (no seat identity to record; P18 retired the 401 refusal).
|
||||
if (!requireSeatSession("destination")) return;
|
||||
if (!resolveCurrentSession(undefined, "destination")) return;
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {});
|
||||
@@ -1182,7 +1135,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.action(async (qitemId: string, opts: { destination?: string; reason: string; json?: boolean; full?: boolean }) => {
|
||||
// P21 I3 reconcile: the releaser is DERIVED from the seat env — --destination deprecated + ignored,
|
||||
// no body claim. Verify the env or the daemon returns 400 actor_required (no seat identity to record; P18 retired the 401 refusal).
|
||||
if (!requireSeatSession("destination")) return;
|
||||
if (!resolveCurrentSession(undefined, "destination")) return;
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/unclaim`, {
|
||||
@@ -1243,7 +1196,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
// P21 I3 reconcile: the actor is DERIVED from the seat env (X-OpenRig-Session, stamped by
|
||||
// DaemonClient) — --actor is deprecated + ignored, no body actorSession. Verify the env (the
|
||||
// header source) or the daemon returns 400 actor_required (no seat identity to record; P18 retired the 401 refusal). Matches `resolve`.
|
||||
if (!requireSeatSession("actor")) return;
|
||||
if (!resolveCurrentSession(undefined, "actor")) return;
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
|
||||
@@ -1307,7 +1260,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
}) => {
|
||||
// P21 I3 reconcile: actor DERIVED from the seat env (X-OpenRig-Session) — --actor deprecated +
|
||||
// ignored, no body actorSession. Verify the env, else the daemon returns 400 actor_required (no seat identity to record). Matches `resolve`.
|
||||
if (!requireSeatSession("actor")) return;
|
||||
if (!resolveCurrentSession(undefined, "actor")) return;
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
|
||||
@@ -1347,7 +1300,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
// P21: the resolver is DERIVED from the seat env (X-OpenRig-Session, stamped by DaemonClient) —
|
||||
// --actor is deprecated + ignored. The pre-check verifies the env (the header source); else the
|
||||
// daemon returns 400 actor_required (no seat identity to record; P18 retired the 401 refusal).
|
||||
if (!requireSeatSession("actor")) return;
|
||||
if (!resolveCurrentSession(undefined, "actor")) return;
|
||||
const deps = getDeps();
|
||||
const bearer = opts.bearer ?? process.env.OPENRIG_AUTH_BEARER_TOKEN;
|
||||
await withClient(deps, async (client) => {
|
||||
@@ -1407,7 +1360,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
}) => {
|
||||
// P21 I3 reconcile: the handing-off seat is DERIVED from the seat env (X-OpenRig-Session) —
|
||||
// --from deprecated + ignored, no body fromSession. Verify the env or the daemon returns 400 actor_required (no seat identity to record; P18 retired the 401 refusal).
|
||||
if (!requireSeatSession("from")) return;
|
||||
if (!resolveCurrentSession(undefined, "from")) return;
|
||||
// slice-08 OPR.0.4.7.8 — body-input parity. Resolve through the shipped
|
||||
// resolveQueueBody ONLY when a body source is supplied; neither preserves
|
||||
// today's source-body default (POST body undefined). Both/invalid reject
|
||||
@@ -1511,7 +1464,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
}) => {
|
||||
// P21 I3 reconcile: the handing-off seat is DERIVED from the seat env (X-OpenRig-Session) —
|
||||
// --from deprecated + ignored, no body fromSession. Verify the env or the daemon returns 400 actor_required (no seat identity to record; P18 retired the 401 refusal).
|
||||
if (!requireSeatSession("from")) return;
|
||||
if (!resolveCurrentSession(undefined, "from")) return;
|
||||
// slice-08 OPR.0.4.7.8 — body-input parity (same contract as handoff):
|
||||
// resolve only when a body source is supplied; neither keeps the
|
||||
// source-body default; both/invalid reject before daemon contact.
|
||||
@@ -1960,7 +1913,7 @@ Examples:
|
||||
}
|
||||
// P21 I3 reconcile: the sender is DERIVED from the seat env (X-OpenRig-Session) — --sender
|
||||
// deprecated + ignored, no body senderSession. Verify the env or the daemon returns 400 actor_required (no seat identity to record; P18 retired the 401 refusal).
|
||||
if (!requireSeatSession("sender")) return;
|
||||
if (!resolveCurrentSession(undefined, "sender")) return;
|
||||
const deps = getDeps();
|
||||
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
await withClient(deps, async (client) => {
|
||||
|
||||
@@ -180,7 +180,8 @@ function emitOperatingPosture(value: OperatingPosture | undefined): void {
|
||||
* scope rule applies at invocation, NOT only at raw HTTP URL parsing.
|
||||
*
|
||||
* Reject:
|
||||
* - explicit qualifier with global_host (a review finding): operators who type
|
||||
* - explicit qualifier with global_host (BLOCKING re-verify finding from
|
||||
* guard qitem-20260518044650): operators who type
|
||||
* `--scope global_host --qualifier <id>` get an error and the daemon
|
||||
* is never contacted. The CLI does NOT silently drop the qualifier.
|
||||
* - missing qualifier for any non-global scope.
|
||||
@@ -268,7 +269,7 @@ export function rigModeCommand(depsOverride?: RigModeDeps): Command {
|
||||
return;
|
||||
}
|
||||
|
||||
// Review finding: when --scope is
|
||||
// BLOCKING re-verify-2 (qitem-20260518045300): when --scope is
|
||||
// explicit, validate scope + qualifier LOCALLY before touching
|
||||
// the daemon. Otherwise an invalid operator invocation
|
||||
// (e.g. `--scope global_host --qualifier X`) surfaces as
|
||||
@@ -541,7 +542,7 @@ export function rigModeCommand(depsOverride?: RigModeDeps): Command {
|
||||
return;
|
||||
}
|
||||
const scope = scopeArg as Scope;
|
||||
// Review finding: never silently drop
|
||||
// BLOCKING re-verify (qitem-20260518044650): never silently drop
|
||||
// an explicit operator-supplied qualifier on global_host on unset.
|
||||
// Same hazard class as set; same shared helper.
|
||||
const normalized = normalizeScopeQualifier(scope, qualifierArg);
|
||||
|
||||
@@ -1,32 +1,81 @@
|
||||
import { modelObservation, unknownModelObservation, formatModelObservation } from "../lib/model-observation.js";
|
||||
import { Command } from "commander";
|
||||
import { closeSync, constants, fstatSync, openSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { execFile } from "node:child_process";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { mcpPresenceFilePath, readPresence } from "../mcp-presence.js";
|
||||
import { parseRoster, readRosters, type Roster, type RosterMember, type RosterWake } from "@openrig/daemon/rosters";
|
||||
|
||||
// One roster parser, shared with the daemon (a hosted member's wake route reads the same files).
|
||||
export { parseRoster, readRosters, type Roster, type RosterMember, type RosterWake };
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
type Reference = { seat: string; host: string };
|
||||
export interface RosterMember extends Reference {
|
||||
capabilities: string[];
|
||||
engagement: string[];
|
||||
use_when: string;
|
||||
why: string;
|
||||
caveat?: string;
|
||||
}
|
||||
export interface Roster {
|
||||
version: 1;
|
||||
id: string;
|
||||
name: string;
|
||||
purpose: string;
|
||||
curator: Reference;
|
||||
updated_at: string;
|
||||
members: RosterMember[];
|
||||
}
|
||||
export interface RosterInventory {
|
||||
readAt: string;
|
||||
nodes: Row[];
|
||||
sources: Row[];
|
||||
/** This host's id, from the local daemon's own nodes; null when it has none to read it from. */
|
||||
localHostId?: string | null;
|
||||
}
|
||||
const object = (v: unknown): v is Row => !!v && typeof v === "object" && !Array.isArray(v);
|
||||
const text = (v: unknown): string | null => typeof v === "string" && v.trim() ? v : null;
|
||||
const strings = (v: unknown): v is string[] => Array.isArray(v) && v.every(x => text(x) !== null);
|
||||
const reference = (v: unknown): v is Row & Reference => object(v) && text(v.seat) !== null && text(v.host) !== null;
|
||||
|
||||
/** Select authored fields only: a file cannot supply runtime or observed-model facts. */
|
||||
export function parseRoster(value: unknown): Roster {
|
||||
if (!object(value) || value.version !== 1 || !reference(value.curator) ||
|
||||
![value.id, value.name, value.purpose, value.updated_at].every(v => text(v) !== null) || !Array.isArray(value.members)) {
|
||||
throw new Error("Expected a version 1 roster with id, name, purpose, curator, updated_at and members");
|
||||
}
|
||||
const members = value.members.map((m): RosterMember => {
|
||||
if (!reference(m) || !object(m) || !strings(m.capabilities) || !strings(m.engagement) ||
|
||||
text(m.use_when) === null || text(m.why) === null || (m.caveat !== undefined && typeof m.caveat !== "string")) {
|
||||
throw new Error("Each member needs seat, host, capabilities, engagement, use_when and why (optional caveat)");
|
||||
}
|
||||
return { seat: m.seat, host: m.host, capabilities: m.capabilities, engagement: m.engagement,
|
||||
use_when: m.use_when as string, why: m.why as string, ...(m.caveat === undefined ? {} : { caveat: m.caveat as string }) };
|
||||
});
|
||||
return { version: 1, id: value.id as string, name: value.name as string, purpose: value.purpose as string,
|
||||
curator: { seat: value.curator.seat, host: value.curator.host }, updated_at: value.updated_at as string, members };
|
||||
}
|
||||
|
||||
export function readRosters(folder: string): { rosters: Roster[]; warnings: string[] } {
|
||||
const rosters: Roster[] = [], warnings: string[] = [];
|
||||
let names: string[];
|
||||
try { names = readdirSync(folder).filter(n => n.endsWith(".json")).sort(); }
|
||||
catch (error) { return { rosters, warnings: [`Cannot read roster folder ${folder}: ${(error as Error).message}`] }; }
|
||||
for (const name of names) {
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
// Inspect the opened target before reading; NONBLOCK also handles a FIFO
|
||||
// without waiting for a writer. Regular-file symlinks remain readable.
|
||||
fd = openSync(join(folder, name), constants.O_RDONLY | constants.O_NONBLOCK);
|
||||
if (!fstatSync(fd).isFile()) throw new Error("Not a regular file; skipped");
|
||||
rosters.push(parseRoster(JSON.parse(readFileSync(fd, "utf8"))));
|
||||
} catch (error) { warnings.push(`${name}: ${(error as Error).message}`); }
|
||||
finally { if (fd !== undefined) closeSync(fd); }
|
||||
}
|
||||
return { rosters, warnings };
|
||||
}
|
||||
|
||||
// Reuse ps's local routing and observation-only HTTP fan-out, including its partial failures.
|
||||
// Explicit fields keep native transcripts, resume tokens and fresh pane captures out of this read.
|
||||
const fields = "rigId,logicalId,canonicalSessionName,hostSelfId,runtime,model,modelObservation,sessionStatus,agentActivity";
|
||||
export async function readRosterInventory(): Promise<RosterInventory> {
|
||||
const nodes: Row[] = [], sources: Row[] = [];
|
||||
let localHostId: string | null = null;
|
||||
for (const remote of [false, true]) {
|
||||
const argv = [fileURLToPath(new URL("../index.js", import.meta.url)), "ps", "--no-cleanup", "--nodes", "-A", "--json", "--fields", fields,
|
||||
...(remote ? ["--all-hosts"] : [])];
|
||||
@@ -40,7 +89,6 @@ export async function readRosterInventory(): Promise<RosterInventory> {
|
||||
const entries = Array.isArray(data) ? data : object(data) ? data.entries ?? data.items : null;
|
||||
if (!Array.isArray(entries)) throw new Error("ps returned no node array");
|
||||
nodes.push(...entries.filter(object));
|
||||
if (!remote) localHostId = entries.filter(object).map((n) => text(n.hostSelfId)).find((id) => id !== null) ?? null;
|
||||
sources.push({ scope, status: result.error || result.stderr.trim() ? "partial" : "ok",
|
||||
...(object(data) && Array.isArray(data.hosts) ? { hosts: data.hosts } : {}),
|
||||
...(result.stderr.trim() ? { warning: result.stderr.trim() } : {}),
|
||||
@@ -49,7 +97,7 @@ export async function readRosterInventory(): Promise<RosterInventory> {
|
||||
sources.push({ scope, status: "unavailable", error: result.stderr.trim() || (error as Error).message });
|
||||
}
|
||||
}
|
||||
return { readAt: new Date().toISOString(), nodes, sources, localHostId };
|
||||
return { readAt: new Date().toISOString(), nodes, sources };
|
||||
}
|
||||
|
||||
export function enrichRosterMember(member: RosterMember, inventory: RosterInventory) {
|
||||
@@ -66,9 +114,7 @@ export function enrichRosterMember(member: RosterMember, inventory: RosterInvent
|
||||
: unknownModelObservation(matches.length ? "conflicting-observations" : "host-or-seat-unavailable");
|
||||
return { ...member, live: {
|
||||
status: !node ? "unknown" : current ? "reported" : "stale",
|
||||
reason: !node ? (matches.length ? "Conflicting node observations"
|
||||
: member.hosted ? "A hosted member: it has no pane, and OpenRig doesn't observe it between calls"
|
||||
: "No exact hostSelfId and seat match; check observation gaps")
|
||||
reason: !node ? (matches.length ? "Conflicting node observations" : "No exact hostSelfId and seat match; check observation gaps")
|
||||
: current ? "Registry, activity and labelled last model observation; not a promise about the next turn" : "Seat is not reported running; current runtime and model are unknown",
|
||||
runtime: current ? text(node?.runtime) : null,
|
||||
configuredModel: current ? text(node?.model) : null,
|
||||
@@ -76,11 +122,6 @@ export function enrichRosterMember(member: RosterMember, inventory: RosterInvent
|
||||
modelObservation: observation,
|
||||
sessionStatus,
|
||||
agentActivity: { state: text(activity?.state) ?? "unknown", reason: text(activity?.reason), sampledAt: text(activity?.sampledAt) },
|
||||
// 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.
|
||||
...(member.hosted ? { lastCallAt: inventory.localHostId && member.host === inventory.localHostId
|
||||
? readPresence(mcpPresenceFilePath())[member.seat]?.lastCallAt ?? null : null } : {}),
|
||||
} };
|
||||
}
|
||||
|
||||
@@ -122,7 +163,7 @@ export function rosterCommand(deps: Deps = {}): Command {
|
||||
else {
|
||||
const members = (verb === "show" ? (result.roster as Row).members : result.matches) as Array<ReturnType<typeof enrichRosterMember> & { roster?: { id: string } }>;
|
||||
if (verb === "show") { const r = result.roster as Roster; console.log(`${r.name} — ${r.purpose}\nCurator: ${r.curator.seat} (${r.curator.host})`); }
|
||||
for (const m of members) console.log(`${m.seat} (${m.host})${m.roster ? ` [${m.roster.id}]` : ""}\n ${m.engagement.join(", ")}: ${m.capabilities.join(", ")}\n When: ${m.use_when}\n Why: ${m.why}${m.caveat ? `\n Caveat: ${m.caveat}` : ""}\n ${m.live.status}: runtime ${m.live.runtime ?? "unknown"}; configured model ${m.live.configuredModel ?? "unknown"}; last observed model ${formatModelObservation(m.live.modelObservation)}\n Activity: ${m.live.agentActivity.state}; sampled ${m.live.agentActivity.sampledAt ?? "unknown"}${m.hosted ? `\n Last call: ${m.live.lastCallAt ?? "unknown"}` : ""}\n ${m.live.reason}`);
|
||||
for (const m of members) console.log(`${m.seat} (${m.host})${m.roster ? ` [${m.roster.id}]` : ""}\n ${m.engagement.join(", ")}: ${m.capabilities.join(", ")}\n When: ${m.use_when}\n Why: ${m.why}${m.caveat ? `\n Caveat: ${m.caveat}` : ""}\n ${m.live.status}: runtime ${m.live.runtime ?? "unknown"}; configured model ${m.live.configuredModel ?? "unknown"}; last observed model ${formatModelObservation(m.live.modelObservation)}\n Activity: ${m.live.agentActivity.state}; sampled ${m.live.agentActivity.sampledAt ?? "unknown"}\n ${m.live.reason}`);
|
||||
if (!members.length) console.log("No matching members.");
|
||||
const obs = result.observations as { sources: Row[] };
|
||||
for (const source of obs.sources) if (source.status !== "ok") console.error(`Observation gap (${source.scope}): ${source.warning ?? source.error ?? source.status}`);
|
||||
|
||||
@@ -347,7 +347,7 @@ function buildSliceCreateCommand(): Command {
|
||||
action: "Pick a slug containing letters or digits.",
|
||||
});
|
||||
}
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace, strictOverride: true });
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const mission = findMission(missionsRoot, missionName);
|
||||
let nn = nextSliceNN(mission.absPath);
|
||||
const title = opts.title ?? titleFromSlug(slug);
|
||||
@@ -802,16 +802,8 @@ function resolveMissionCreateRoot(override: string | null | undefined, advisorie
|
||||
fs.existsSync(childMissionsRoot) && fs.statSync(childMissionsRoot).isDirectory();
|
||||
const directMissionsRoot = path.basename(candidate) === "missions" && !hasChildMissionsRoot;
|
||||
const workspaceRoot = directMissionsRoot ? path.dirname(candidate) : candidate;
|
||||
// A named workspace that doesn't exist is refused, not created: a mistyped path would otherwise start a new tree
|
||||
// there. The configured root is never used in its place (#70).
|
||||
if (!fs.existsSync(workspaceRoot) || !fs.statSync(workspaceRoot).isDirectory()) {
|
||||
const source = override ? "--workspace" : "OPENRIG_WORK_ROOT";
|
||||
throw new ScopeCliError({
|
||||
fact: `The workspace named by ${source} is not an existing directory: ${workspaceRoot}.`,
|
||||
consequence: "Mission not created; the configured workspace was NOT used as a fallback.",
|
||||
action: `Point ${source} at an existing workspace, or create ${workspaceRoot} first.`,
|
||||
});
|
||||
}
|
||||
// A missing workspace is created at the named path by the mission's own mkdir, after rendering; it never falls
|
||||
// back to the configured root. A path that can't be created reports the filesystem's own error there.
|
||||
|
||||
// A named workspace uses its project.yaml missions.root, as work positions do; a named missions folder is used as is.
|
||||
const missionsRoot = directMissionsRoot ? candidate : readProjectMissionsRoot(workspaceRoot, advisories);
|
||||
@@ -1068,7 +1060,7 @@ function buildResolveNotesCommand(): Command {
|
||||
|
||||
function buildAuditCommand(): Command {
|
||||
return new Command("audit")
|
||||
.description("Read-only scope audit: flag scope structure findings and show the advisory dependency graph. It doesn't read proof readiness: use rig proof show")
|
||||
.description("Read-only scope audit: flag scope findings and show the advisory dependency graph")
|
||||
.requiredOption("--mission <name>", "Mission to audit")
|
||||
.option("--json", "Machine-readable JSON output")
|
||||
.action(async (opts, command) => {
|
||||
@@ -1076,7 +1068,7 @@ function buildAuditCommand(): Command {
|
||||
const json = Boolean(opts.json);
|
||||
try {
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const { classifyScopeItem, SLICE_DIRECTORY_NAME } = await import("../lib/scope/scope-audit.js");
|
||||
const { classifyScopeItem } = await import("../lib/scope/scope-audit.js");
|
||||
const missionName = opts.mission as string;
|
||||
|
||||
const missionDir = path.join(missionsRoot, missionName);
|
||||
@@ -1215,7 +1207,7 @@ function buildAuditCommand(): Command {
|
||||
: null,
|
||||
});
|
||||
|
||||
if (!SLICE_DIRECTORY_NAME.test(entry)) {
|
||||
if (!/^\d{2,}-/.test(entry)) {
|
||||
sliceResult.findings.push({
|
||||
kind: "id_convention_violation",
|
||||
severity: "high",
|
||||
@@ -2155,7 +2147,7 @@ function shadowedNodeFileFinding(dir: string, level: "mission" | "slice"): {
|
||||
export function scopeCommand(): Command {
|
||||
const cmd = new Command("scope")
|
||||
.description("Scope tree primitive: missions, slices, sub-slices (per conventions/scope-and-versioning)")
|
||||
.option("--workspace <path>", "Workspace root; use its project.yaml missions.root, else missions/. A directory named missions without its own missions/ child is used directly. If the tree is missing, fall back to configured workspace.slices_root, except slice create and approve, which refuse, and mission create, which refuses a path that isn't an existing directory and otherwise creates the missions tree inside it. Without this flag, OPENRIG_WORK_ROOT is tried first with the same exceptions.");
|
||||
.option("--workspace <path>", "Workspace root; use its project.yaml missions.root, else missions/. A directory named missions without its own missions/ child is used directly. If the tree is missing, fall back to configured workspace.slices_root, except mission create, which creates the named tree, and approve, which refuses. Without this flag, OPENRIG_WORK_ROOT is tried first with the same exceptions.");
|
||||
|
||||
const slice = new Command("slice").description("Slice-tier commands");
|
||||
slice.addCommand(buildSliceLsCommand());
|
||||
|
||||
@@ -160,8 +160,6 @@ interface SeatHandoverMutationResult {
|
||||
sourceOutcome?:
|
||||
| { mode: "fork"; forkedFrom: string; forkId?: "captured" | "pending"; limit?: string }
|
||||
| { mode: "rebuild"; primedArtifacts: Array<{ address: string; label: string }>; gaps: string[]; emptyChainReason?: string; permissions?: { state: string; detail: string } };
|
||||
/** The restore packet was pasted and Enter pressed, but its submission couldn't be confirmed. */
|
||||
restorePacket?: { status: "unconfirmed"; reason: string };
|
||||
sideEffects: {
|
||||
departingSessionKilled: false;
|
||||
startupContextDelivered: boolean;
|
||||
@@ -341,11 +339,6 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
|
||||
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.");
|
||||
} 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.");
|
||||
} else if (result.source.mode === "fork") {
|
||||
console.log("No packet delivery: fork context rides the native conversation itself.");
|
||||
} else {
|
||||
@@ -395,35 +388,6 @@ export function seatCommand(depsOverride?: SeatDeps & { readStdin?: () => Promis
|
||||
console.log(JSON.stringify(result.data, null, json ? undefined : 2));
|
||||
if (result.status >= 400) process.exitCode = result.status >= 500 ? 2 : 1;
|
||||
};
|
||||
cmd.command("history")
|
||||
.requiredOption("--rig <id>", "Exact rig ID")
|
||||
.requiredOption("--node-id <id>", "Exact durable node ID")
|
||||
.option("--snapshot-id <id>", "Validate a reference returned by this list")
|
||||
.option("--history-session-id <id>", "Validate a reference returned by this list")
|
||||
.option("--json", "Print the daemon response as JSON")
|
||||
.description("List or validate captured histories eligible for a resume attempt (read-only)")
|
||||
.action(async (opts: { rig: string; nodeId: string; snapshotId?: string; historySessionId?: string; json?: boolean }) => {
|
||||
if (!!opts.snapshotId !== !!opts.historySessionId) {
|
||||
console.error("Supply both --snapshot-id and --history-session-id to validate a history.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const deps = getDeps(); const daemon = await getDaemonStatus(deps.lifecycleDeps, { cleanupStaleState: false });
|
||||
if (!daemonStatusGuard(daemon)) return;
|
||||
const client = deps.clientFactory(getDaemonUrl(daemon));
|
||||
const path = `/api/startup/${encodeURIComponent(opts.rig)}/nodes/${encodeURIComponent(opts.nodeId)}/history`;
|
||||
const response = opts.snapshotId && opts.historySessionId
|
||||
? await client.post<Record<string, unknown>>(`${path}/validate`, { snapshotId: opts.snapshotId, historySessionId: opts.historySessionId }, { headers: terminalAuthHeaders() })
|
||||
: await client.get<Record<string, unknown>>(path, { headers: terminalAuthHeaders() });
|
||||
if (opts.json || response.status >= 400) console.log(JSON.stringify(response.data, null, 2));
|
||||
else {
|
||||
const body = response.data as { snapshotId?: string; history?: { historySessionId: string; resumeType: string; tokenState: string }; histories?: Array<{ historySessionId: string; resumeType: string; tokenState: string }> };
|
||||
console.log(`Current snapshot: ${body.snapshotId ?? "none"}`);
|
||||
for (const item of body.history ? [body.history] : body.histories ?? []) console.log(`${item.historySessionId} ${item.resumeType} token: ${item.tokenState}`);
|
||||
if (!body.history && !body.histories?.length) console.log("No eligible captured histories.");
|
||||
}
|
||||
if (response.status >= 400) process.exitCode = response.status >= 500 ? 2 : 1;
|
||||
});
|
||||
cmd.command("set-typing-guard").argument("<seat>")
|
||||
.option("--enabled <boolean>", "Compatibility: true selects hold; false selects off")
|
||||
.option("--mode <mode>", "off, draft-aware, openrig-urgent, urgent-only, sends-only or hold; choose this or --enabled")
|
||||
@@ -686,9 +650,8 @@ identity remains unverified; a version number alone cannot clear it.
|
||||
? await client.post<Record<string, unknown>>(route, body, { timeoutMs: 120_000 })
|
||||
// #260: a dynamic Claude mode waits up to 5 s for the capability query before the
|
||||
// daemon answers, so the 5 s default deadline would abort before its refusal arrives.
|
||||
// A Codex profile change runs the native profile check (up to 10 s) before answering.
|
||||
: path === "set-permissions"
|
||||
? await client.post<Record<string, unknown>>(route, body, { timeoutMs: (body as Record<string, unknown>)["codexProfile"] !== undefined ? 30_000 : 10_000 })
|
||||
? await client.post<Record<string, unknown>>(route, body, { timeoutMs: 10_000 })
|
||||
: await client.post<Record<string, unknown>>(route, body);
|
||||
} catch (err) {
|
||||
if ((path !== "launch" && path !== "launch-plan" && path !== "continue" && path !== "restart" && path !== "set-cwd") || !(err instanceof DaemonTimeoutError)) throw err;
|
||||
@@ -768,45 +731,25 @@ identity remains unverified; a version number alone cannot clear it.
|
||||
.argument("<seat>", "Canonical session name or logical seat ref")
|
||||
.option("--mode <mode>", "floor, full_bypass, inherit, or a Claude mode supported by the bound managed launch context")
|
||||
.option("--rules <file>", "JSON allow/deny lists for this Claude seat; replaces its recorded rules without changing its mode")
|
||||
.option("--codex-permissions <file>", "JSON sandbox/approval envelope for this Codex seat; replaces its recorded mode")
|
||||
.option("--codex-profile <name>", "Codex config profile for this seat, or inherit to clear it; approval stays with the envelope or session")
|
||||
.requiredOption("--reason <text>", "Reason for the audited future-launch selection")
|
||||
.option("--operator <address>", "Operator recorded on the audit event when no session identity is present")
|
||||
.option("--json", "JSON output for agents")
|
||||
.description("Select native permissions for future managed launches; no relaunch or work-posture change")
|
||||
.addHelpText("after", "\nUse inherit to clear this seat's explicit selection. Current native processes, history, rules and hooks remain unchanged. A later lifecycle action needs its own authorization.\n--rules takes a JSON object with allow and deny lists only. Use empty lists to clear the seat rules. It does not remove inherited grants or change the running process.\n--codex-permissions takes a JSON object: sandbox (workspace-write or read-only), approval (never, on-request or untrusted), and for workspace-write network_access, writable_roots (absolute paths), exclude_tmpdir_env_var and exclude_slash_tmp. It replaces a recorded mode; --mode replaces the envelope. Full access stays --mode full_bypass.\n--codex-profile reports an absent, changed or unchecked profile file without refusing; a profile that fails to load is refused.")
|
||||
.action(async (seat: string, opts: { mode?: string; rules?: string; codexPermissions?: string; codexProfile?: string; reason: string; operator?: string; json?: boolean }) => {
|
||||
if ([opts.mode, opts.rules, opts.codexPermissions, opts.codexProfile].filter(value => value !== undefined).length !== 1) {
|
||||
console.error("Choose exactly one of --mode, --rules <file>, --codex-permissions <file> or --codex-profile <name>."); process.exitCode = 1; return;
|
||||
.addHelpText("after", "\nUse inherit to clear this seat's explicit selection. Current native processes, history, rules and hooks remain unchanged. A later lifecycle action needs its own authorization.\n--rules takes a JSON object with allow and deny lists only. Use empty lists to clear the seat rules. It does not remove inherited grants or change the running process.")
|
||||
.action(async (seat: string, opts: { mode?: string; rules?: string; reason: string; operator?: string; json?: boolean }) => {
|
||||
if ((opts.mode === undefined) === (opts.rules === undefined)) {
|
||||
console.error("Choose exactly one of --mode or --rules <file>."); process.exitCode = 1; return;
|
||||
}
|
||||
let rules: unknown;
|
||||
if (opts.rules !== undefined) {
|
||||
try { rules = JSON.parse(readFileSync(opts.rules, "utf8")); }
|
||||
catch { console.error(`Cannot read JSON rules from ${opts.rules}; provide an allow/deny mapping.`); process.exitCode = 1; return; }
|
||||
}
|
||||
let codexPermissions: unknown;
|
||||
if (opts.codexPermissions !== undefined) {
|
||||
try { codexPermissions = JSON.parse(readFileSync(opts.codexPermissions, "utf8")); }
|
||||
catch { console.error(`Cannot read JSON from ${opts.codexPermissions}; provide a sandbox/approval mapping.`); process.exitCode = 1; return; }
|
||||
}
|
||||
const choice = opts.rules !== undefined ? { rules } : codexPermissions !== undefined ? { codexPermissions }
|
||||
: opts.codexProfile !== undefined ? { codexProfile: opts.codexProfile } : { mode: opts.mode };
|
||||
await runLifecycleVerb("set-permissions", seat, { ...choice, reason: opts.reason, operator: opts.operator }, opts, data => {
|
||||
await runLifecycleVerb("set-permissions", seat, { ...(opts.rules === undefined ? { mode: opts.mode } : { rules }), reason: opts.reason, operator: opts.operator }, opts, data => {
|
||||
if (opts.rules !== undefined) {
|
||||
console.log(`Claude tool rules ${data["changed"] === false ? "unchanged" : "recorded and audited"}.`);
|
||||
console.log(String(data["effect"])); return;
|
||||
}
|
||||
if (codexPermissions !== undefined) {
|
||||
const replaced = data["replaced"] as { mode?: { mode?: string } } | undefined;
|
||||
console.log(`Codex permission envelope ${data["changed"] === false ? "unchanged" : "recorded and audited"}${replaced?.mode?.mode ? `; it replaces the recorded ${replaced.mode.mode} mode` : ""}.`);
|
||||
console.log(String(data["effect"])); return;
|
||||
}
|
||||
if (opts.codexProfile !== undefined) {
|
||||
const observed = data["observation"] as { file?: string; detail?: string; changedSinceRecorded?: boolean } | undefined;
|
||||
console.log(`Codex profile: ${(data["to"] as string | null) ?? "inherit"}${data["changed"] === false ? " (unchanged)" : " (audited)"}`);
|
||||
if (observed?.detail) console.log(`Profile file: ${observed.file ?? "unverified"}${observed.changedSinceRecorded ? ", changed since last recorded" : ""}. ${observed.detail}`);
|
||||
console.log(String(data["effect"])); return;
|
||||
}
|
||||
const selection = data["to"] as { mode: string } | null;
|
||||
console.log(`Permission mode: ${selection?.mode ?? "inherit"}${data["changed"] === false ? " (unchanged)" : " (audited)"}`);
|
||||
console.log(String(data["effect"]));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command } from "commander";
|
||||
import { resolveEffectiveHost } from "../host-selection.js";
|
||||
import { DaemonClient, DaemonConnectionError, terminalAuthHeaders } from "../client.js";
|
||||
import { DaemonClient, DaemonConnectionError, DaemonTimeoutError, terminalAuthHeaders } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, fetchSelfHostId, resolveOriginSelfHostId } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
@@ -8,18 +8,18 @@ import { loadHostRegistry, resolveHost, hostDisplayTarget, type HttpHostEntry }
|
||||
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
|
||||
import { emitCrossHostError, emitCrossHostFailure, emitRemoteHttpFailure } from "../cross-host-cli-helpers.js";
|
||||
import { resolveCrossHostTarget } from "../cross-host-target.js";
|
||||
import { requestMayHaveArrived, runRemoteHttpOp } from "../remote-host-ops.js";
|
||||
import { readOpenRigUrl } from "../openrig-compat.js";
|
||||
import { runRemoteHttpOp } from "../remote-host-ops.js";
|
||||
import { readOpenRigEnv } from "../openrig-compat.js";
|
||||
import { resolveSenderSession, SENDER_FALLBACK } from "../sender-identity.js";
|
||||
import { COMPOSER_PROMPT_CLASS } from "@openrig/daemon/composer-prompts";
|
||||
import { resolveContextRef, walkSizedWarning } from "../context-resolve.js";
|
||||
|
||||
// The reply follows the guarded paste/Enter (and optional verification), not acceptance of the request.
|
||||
// Successful sends can exceed the ordinary client's 5 s read budget. Keep a finite response wait on both
|
||||
// local and remote paths; a longer send or lost response remains unconfirmed and is never retried here.
|
||||
const SEND_RESPONSE_TIMEOUT_MS = 60_000;
|
||||
const UNCONFIRMED_SEND_GUIDANCE = "A busy agent may hold delivered input off-screen, so absence from a pane capture does not prove non-delivery. " +
|
||||
"Wait for recipient acknowledgment or reconcile with the recipient before resending; another send or queue item risks duplicate work.";
|
||||
const WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS = 5_000;
|
||||
// A cross-host send waits for the remote to deliver, and the remote has no fixed deadline of its own (its tmux
|
||||
// calls run without a timeout), so no client budget is exact. This one sits well above a normal send, and above
|
||||
// fetch's 10 s connect timeout, so a host that never accepts the connection still reads as unreachable. A send
|
||||
// that outlasts it reads as unconfirmed, never as failed.
|
||||
const CROSS_HOST_SEND_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** The shared composer glyph set (Claude `❯`, Codex `›`, Codex 0.153 `»`) from the daemon's
|
||||
* composer-prompts surface, so the CLI staged check can never disagree with the daemon guard. */
|
||||
@@ -57,9 +57,6 @@ export interface EnvelopeMeta {
|
||||
/** GHOST-STAGE (g): the SENDER's atom-B occupant generation-uuid, stamped ONCE at transport.
|
||||
* ABSENT ⇒ UNKNOWN → the render OMITS the suffix (never forges). Twin of pane-envelope's field. */
|
||||
genUuid?: string;
|
||||
/** Twin of pane-envelope's field: the row a person's (@external) message delivers, named in the
|
||||
* reply hint with --reply-to so the answer joins its Slack thread. ABSENT ⇒ the plain hint. */
|
||||
replyToQitem?: string;
|
||||
}
|
||||
|
||||
/** The To-line projection + anti-storm scale (header-alone distinguishability). */
|
||||
@@ -101,7 +98,7 @@ export function wrapSendBody(
|
||||
header.push(`Sent: ${renderShortStamp(meta.stampISO)}${genSuffix}`);
|
||||
}
|
||||
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 if needed: rig queue create --destination ${senderLabel} --summary "<one line>" --body "..." --verify`
|
||||
: `↩ Reply: rig send ${senderLabel} "..."`;
|
||||
return [...header, "---", body, "---", reply].join("\n");
|
||||
}
|
||||
@@ -111,12 +108,13 @@ export function wrapSendBody(
|
||||
* fact / consequence / action shape (`daemon-lifecycle.ts:61-73`).
|
||||
*
|
||||
* Deliberately NOT `daemonNotRunningError()`: that helper's text ("Daemon not
|
||||
* running." + restart advice) is the probe-derived claim that was
|
||||
* running." + restart advice) is the probe-derived claim qitem-c113bd41
|
||||
* removed. The SHAPE is reused; the TEXT is not.
|
||||
*
|
||||
* A connection error can follow delivery; only positive no-connect evidence
|
||||
* earns a non-delivery claim. Share that distinction with remote sends. The
|
||||
* diagnostic action asserts no daemon state: with an env URL set,
|
||||
* A `DaemonConnectionError` proves the resolved target was UNREACHABLE — not
|
||||
* why. Daemon down, wrong port, wrong host, firewall, and a wedged event loop
|
||||
* are all live explanations, so the action stays diagnostic and asserts no
|
||||
* daemon state. It also refuses to oversell `rig status`: with an env URL set,
|
||||
* that command's own probe can report `stopped` for a mere timeout
|
||||
* (`daemon-lifecycle.ts:575-585`) — the same false-stopped class this slice
|
||||
* exists to remove — so the copy names that limitation instead of hiding it,
|
||||
@@ -133,14 +131,16 @@ function printTransportFailure(err: DaemonConnectionError, opts?: { json?: boole
|
||||
// (daemon-lifecycle.ts) so an agent on the --json path gets a parseable record
|
||||
// instead of empty stdout plus human prose on stderr.
|
||||
const fact = err.message;
|
||||
// A timeout or lost connection can hide a completed send. Use the existing
|
||||
// remote-request classification rather than treating every non-timeout as unsent.
|
||||
const outcomeUnknown = requestMayHaveArrived(err);
|
||||
const consequence = outcomeUnknown
|
||||
// B8-2 (shape 73ee4b25): a TIMEOUT is delivery-UNCONFIRMED — the daemon may have
|
||||
// received AND delivered (two seats proved delivery-after-"not sent" live). Only a
|
||||
// hard connection failure earns the "was not sent" consequence.
|
||||
const timedOut = err instanceof DaemonTimeoutError;
|
||||
const consequence = timedOut
|
||||
? "Delivery UNCONFIRMED — the daemon may have received and delivered the message."
|
||||
: "The message was not sent.";
|
||||
const action = outcomeUnknown
|
||||
? "Reconcile by EFFECT before any resend: check the target (rig capture <session>). " + UNCONFIRMED_SEND_GUIDANCE
|
||||
const action = timedOut
|
||||
? "Reconcile by EFFECT before any resend: check the pane/target for the message (rig capture <session>). " +
|
||||
"A resend without checking risks a duplicate. Then 'rig daemon status' for the probe picture."
|
||||
: "Inspect the configured target with 'rig status'; a failed health probe does not prove the daemon is stopped. " +
|
||||
"If the target is wrong, check OPENRIG_URL / RIGGED_URL or daemon.host + daemon.port. " +
|
||||
"If the daemon is confirmed stopped, run 'rig daemon start'.";
|
||||
@@ -168,7 +168,7 @@ export function refuseEmptyMessage(body: string, verb: "send" | "broadcast", jso
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The LOCAL send target. The status probe is advisory,
|
||||
/** qitem-c113bd41 — the LOCAL send target. The status probe is advisory,
|
||||
* never authoritative: a busy/wedged daemon fails the probe while the
|
||||
* transport would succeed (the false-daemon-down incident). Resolution:
|
||||
* the configured env alias FIRST (exact string, custom port preserved;
|
||||
@@ -186,7 +186,7 @@ export function refuseEmptyMessage(body: string, verb: "send" | "broadcast", jso
|
||||
* keeps the single-seat and fan-out callers from having to sequence it
|
||||
* identically in two places. */
|
||||
async function resolveLocalDaemonUrl(deps: SendDeps): Promise<string> {
|
||||
const envUrl = readOpenRigUrl();
|
||||
const envUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
|
||||
if (envUrl) return envUrl;
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state === "running" && status.port !== undefined) return getDaemonUrl(status);
|
||||
@@ -238,7 +238,7 @@ export function sendCommand(depsOverride?: SendDeps): Command {
|
||||
.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("--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("--wait-for-idle <seconds>", "Wait until the target is explicitly idle before sending")
|
||||
.option("--raw", "Send exact text/keystrokes without the From/To messaging envelope (still guarded against interactive prompts)")
|
||||
.option("--dangerously-interact", "DANGEROUS: type an answer as keystrokes into an interactive prompt (implies --raw; requires --reason). Letters can select menu shortcuts; CR/ESC act as keys. Enter is added only for the complete visibly staged answer.")
|
||||
.option("--type-without-checking", "With --dangerously-interact: type without reading or clearing existing input; may join or submit that text")
|
||||
@@ -282,11 +282,7 @@ determined (unknown / missing / stale telemetry) the send PROCEEDS with an
|
||||
advisory note — telemetry is advisory, not authority over whether agents can
|
||||
communicate. Use --wait-for-idle to send only after explicit idle evidence. Use
|
||||
--verify to confirm the message appeared in the pane only; it is not agent
|
||||
acknowledgement. Local and HTTP sends wait up to 60 s for the response, plus any
|
||||
--wait-for-idle duration. A timeout or missing pane text does not prove the
|
||||
message was not delivered: a busy agent can hold input off-screen. Reconcile
|
||||
with the recipient before resending or filing the same work as a queue item.
|
||||
With transport.verify_input_box on, a Claude Code or Codex send
|
||||
acknowledgement. With transport.verify_input_box on, a Claude Code or Codex send
|
||||
reads verified only when the seat was proven idle before the paste (its screen
|
||||
matched an idle layout recorded from Claude Code 2.1.296 or Codex 0.160.0; a
|
||||
screen that names another version reads unconfirmed, and a matching layout that
|
||||
@@ -485,7 +481,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
|
||||
return;
|
||||
}
|
||||
|
||||
// The status probe is ADVISORY (target discovery
|
||||
// qitem-c113bd41 — the status probe is ADVISORY (target discovery
|
||||
// only); the actual transport is authoritative. A probe-timeout or
|
||||
// running/unhealthy verdict no longer refuses the send. ff13bcdf —
|
||||
// the resolver takes that probe lazily, and skips it entirely when an
|
||||
@@ -883,7 +879,7 @@ async function runCrossHostSend(
|
||||
* sender verbatim — honest provenance; unknown on the remote it degrades to
|
||||
* the shipped non-blocking advisory, never a refusal. `--verify` prints the
|
||||
* REMOTE route's verified/outcome verbatim (remote-authoritative, never
|
||||
* locally synthesized). Deadline: SEND_RESPONSE_TIMEOUT_MS, plus waitForIdleMs
|
||||
* locally synthesized). Deadline: CROSS_HOST_SEND_TIMEOUT_MS, plus waitForIdleMs
|
||||
* when --wait-for-idle. A send that may have arrived reads as unconfirmed.
|
||||
*
|
||||
* Auth posture (named, v0): runRemoteHttpOp presents the REGISTRY bearer
|
||||
@@ -921,7 +917,7 @@ async function runHttpHostSend(
|
||||
session, text: outboundText, exactText: raw ? undefined : text, verify: opts.verify, force: opts.force, waitForIdleMs,
|
||||
dangerouslyInteract: opts.dangerouslyInteract, typeWithoutChecking: opts.typeWithoutChecking, reason: opts.reason, actorSession: senderSession ?? null,
|
||||
...(opts.urgent ? { urgent: true } : {}),
|
||||
}, deps, { timeoutMs: (waitForIdleMs ?? 0) + SEND_RESPONSE_TIMEOUT_MS });
|
||||
}, deps, { timeoutMs: (waitForIdleMs ?? 0) + CROSS_HOST_SEND_TIMEOUT_MS });
|
||||
|
||||
// A send that may have reached the remote is unconfirmed, not unreachable: it may have been delivered, and a
|
||||
// resend without checking risks a duplicate.
|
||||
@@ -929,7 +925,7 @@ async function runHttpHostSend(
|
||||
const shown = unconfirmed ? { ...result, failedStep: "remote-outcome-unknown" as const } : result;
|
||||
const unconfirmedGuidance = {
|
||||
consequence: "Delivery UNCONFIRMED — the remote daemon may have received and delivered the message.",
|
||||
action: `Check the target before any resend: rig capture ${session} --host ${host.id}. ${UNCONFIRMED_SEND_GUIDANCE}`,
|
||||
action: `Check the target before any resend: rig capture ${session} --host ${host.id}. A resend without checking risks a duplicate.`,
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
@@ -1022,7 +1018,7 @@ async function runFanOutSend(params: {
|
||||
}): Promise<void> {
|
||||
const { toList, pod, rig, message, opts, deps, seatSender } = params;
|
||||
|
||||
// The same advisory-probe/transport-authoritative contract
|
||||
// qitem-c113bd41 — same advisory-probe/transport-authoritative contract
|
||||
// as the single-seat path, including ff13bcdf's lazy probe (see
|
||||
// resolveLocalDaemonUrl).
|
||||
const client = deps.clientFactory(await resolveLocalDaemonUrl(deps));
|
||||
@@ -1165,11 +1161,18 @@ function parseWaitForIdleMs(value: string | undefined): number | undefined | nul
|
||||
return Math.ceil(seconds * 1000);
|
||||
}
|
||||
|
||||
function transportRequestOptions(waitForIdleMs?: number): { timeoutMs: number; headers?: Record<string, string> } {
|
||||
function waitForIdleRequestOptions(waitForIdleMs: number | undefined): { timeoutMs: number } | undefined {
|
||||
if (waitForIdleMs === undefined) return undefined;
|
||||
return { timeoutMs: waitForIdleMs + WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS };
|
||||
}
|
||||
|
||||
function transportRequestOptions(waitForIdleMs?: number): { timeoutMs?: number; headers?: Record<string, string> } | undefined {
|
||||
const waitOptions = waitForIdleRequestOptions(waitForIdleMs);
|
||||
const headers = terminalAuthHeaders();
|
||||
const hasHeaders = Object.keys(headers).length > 0;
|
||||
if (!waitOptions && !hasHeaders) return undefined;
|
||||
return {
|
||||
timeoutMs: (waitForIdleMs ?? 0) + SEND_RESPONSE_TIMEOUT_MS,
|
||||
...(waitOptions ?? {}),
|
||||
...(hasHeaders ? { headers } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { parseDocument } from "yaml";
|
||||
import { runDoctorChecks, type DoctorDeps } from "./doctor.js";
|
||||
import { resolveDaemonPath } from "../daemon-lifecycle.js";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { buildTmuxCaptureFailure, buildTmuxControlFailure, probeTmuxCapture, probeTmuxControl } from "../tmux-health.js";
|
||||
import { buildTmuxControlFailure, probeTmuxControl } from "../tmux-health.js";
|
||||
import { checkClaudeAuth, checkCodexAuth } from "../provider-auth.js";
|
||||
import { describeSkillRouterFootprint } from "@openrig/daemon/router-line";
|
||||
|
||||
@@ -537,14 +537,7 @@ export async function runSetup(deps: SetupDeps, opts: { dryRun?: boolean; full?:
|
||||
fixHint: failure.fix,
|
||||
});
|
||||
} else {
|
||||
// #980: a tmux whose server exits on `capture-pane -p` fails every seat at launch.
|
||||
const capture = probeTmuxCapture((cmd, opts) => deps.exec(cmd, opts));
|
||||
if (capture.status === "fail") {
|
||||
const failure = buildTmuxCaptureFailure(tmuxProbe.version, capture.detail);
|
||||
steps.push({ id: "tmux_install", status: "fail", message: failure.message, reason: failure.reason, fixHint: failure.fix });
|
||||
} else {
|
||||
steps.push({ id: "tmux_install", status: "pass", message: "tmux available." });
|
||||
}
|
||||
steps.push({ id: "tmux_install", status: "pass", message: "tmux available." });
|
||||
}
|
||||
|
||||
// 3. Herdr is the default view provider. Leave optional cmux installations untouched.
|
||||
|
||||
@@ -175,7 +175,7 @@ export function skillCommand(depsOverride?: StatusDeps): Command {
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const wait = opts.waitForIdle !== undefined ? Number(opts.waitForIdle) : undefined;
|
||||
const res = opts.apply
|
||||
? await client.post<SeatApplyResponse>("/api/skills/seat/apply", { session, ...(wait && wait > 0 ? { waitForIdleMs: wait } : {}), ...(opts.resend ? { resend: true } : {}) }, wait && wait > 0 ? { timeoutMs: wait + 5_000 } : undefined)
|
||||
? await client.post<SeatApplyResponse>("/api/skills/seat/apply", { session, ...(wait && wait > 0 ? { waitForIdleMs: wait } : {}), ...(opts.resend ? { resend: true } : {}) })
|
||||
: await client.get<SeatInspection>(`/api/skills/seat?session=${encodeURIComponent(session)}`);
|
||||
const data = res.data as SeatApplyResponse & SeatInspection;
|
||||
const failure = (res.data as { error?: string }).error;
|
||||
|
||||
@@ -112,53 +112,10 @@ const RETIRED_TEACHING =
|
||||
const MANIFEST_FIRST_STEP =
|
||||
"`rig slack manifest --url` prints a link that creates your own Slack app from OpenRig's manifest (see `rig slack manifest --help`)";
|
||||
|
||||
// #192: a saved map change reaches the running connector at a daemon restart. Disable then enable rewires too, but
|
||||
// enabling marks alerts still waiting to be posted as already seen, so it isn't the advice.
|
||||
// #192: a saved config change reaches the running connector when it next rewires.
|
||||
const CHANNEL_MAP_APPLY =
|
||||
"Invite the app to every mapped channel, then `rig slack verify`. A running connector picks up the change " +
|
||||
"at a daemon restart (`rig daemon stop`, then `rig daemon start`). Don't use `rig slack disable` then " +
|
||||
"`rig slack enable` for it: enabling marks alerts still waiting to be posted as already seen.";
|
||||
|
||||
const POST_OPEN_ASKS = "`rig slack post-open-asks`";
|
||||
|
||||
type OpenAskOutcome = "posted" | "already-posted" | "queued" | "held" | "below-floor" | "no-longer-active" | "superseded"
|
||||
| "recipient-changed" | "state-unavailable" | "transport-failed" | "uncertain";
|
||||
interface OpenAsk { qitemId: string; recipient: string; summary: string | null; outcome: OpenAskOutcome; detail?: string }
|
||||
interface PostOpenAsksResponse { ok: boolean; asks: OpenAsk[] }
|
||||
|
||||
const OUTCOME_LABEL: Record<OpenAskOutcome, string> = {
|
||||
posted: "posted",
|
||||
"already-posted": "already posted",
|
||||
queued: "queued, awaiting receipt",
|
||||
held: "held",
|
||||
"below-floor": "below the posting floor",
|
||||
"no-longer-active": "no longer active",
|
||||
superseded: "superseded",
|
||||
"recipient-changed": "recipient changed",
|
||||
"state-unavailable": "state unavailable",
|
||||
"transport-failed": "transport failed",
|
||||
uncertain: "uncertain",
|
||||
};
|
||||
|
||||
/** #1679: one count line, one line per ask, then what to do about the outcomes that need it. */
|
||||
export function describeOpenAsks(asks: OpenAsk[]): string[] {
|
||||
if (asks.length === 0) return ["No open asks to show."];
|
||||
const counts = new Map<OpenAskOutcome, number>();
|
||||
for (const ask of asks) counts.set(ask.outcome, (counts.get(ask.outcome) ?? 0) + 1);
|
||||
const lines = [`${asks.length} open ask(s): ${[...counts].map(([outcome, n]) => `${n} ${OUTCOME_LABEL[outcome]}`).join(", ")}`];
|
||||
for (const ask of asks) {
|
||||
const detail = ask.detail ? ` (${ask.detail})` : "";
|
||||
lines.push(` ${OUTCOME_LABEL[ask.outcome]}: ${ask.qitemId} for ${ask.recipient}: ${ask.summary ?? "(no summary)"}${detail}`);
|
||||
}
|
||||
if (counts.has("queued") || counts.has("uncertain")) {
|
||||
lines.push(`Queued or uncertain asks have no Slack receipt yet. Run ${POST_OPEN_ASKS} again later to see where they landed; it reuses each ask's decision rather than offering it again.`);
|
||||
}
|
||||
if (counts.has("state-unavailable")) {
|
||||
lines.push(`No new offer was made for an ask whose state couldn't be read; its detail says what couldn't be read and what is known. Run ${POST_OPEN_ASKS} again once that reads, and the same ask continues.`);
|
||||
}
|
||||
if (counts.has("transport-failed")) lines.push("A transport failure is on the ask's row, and `rig queue undelivered` lists it for the seat that asked.");
|
||||
return lines;
|
||||
}
|
||||
"when it next rewires (`rig slack disable` then `rig slack enable`, or a daemon restart).";
|
||||
|
||||
function resolveSecrets(surface: SlackSurface, cfg: SlackConnectorConfig): { bot: string | null; app: string | null } {
|
||||
const envFile = cfg.secretsEnvFile ?? undefined;
|
||||
@@ -514,38 +471,12 @@ export function slackCommand(deps: SlackDeps = {}): Command {
|
||||
const res = await clientFactory().post<{ ok: boolean; seeded: number; onlineStatus: string }>("/api/gateway/slack/enable", { reason: opts.reason, actor: resolveSenderSession() ?? opts.actor ?? SENDER_FALLBACK });
|
||||
if (res.status !== 200 || res.data.ok !== true) throw new Error(`daemon refused enable (HTTP ${res.status}): ${JSON.stringify(res.data)}`);
|
||||
log(res.data.onlineStatus);
|
||||
if (res.data.seeded > 0) log(`To show the asks still open in Slack, run ${POST_OPEN_ASKS}.`);
|
||||
} catch (e) {
|
||||
log(`✗ enable failed: ${(e as Error).message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("post-open-asks")
|
||||
.description("Show in Slack the asks still open that enable marked as seen, through ordinary delivery, and say what became of each")
|
||||
.option("--reason <reason>", "Reason recorded with the action")
|
||||
.option("--wait <seconds>", "How long to wait for Slack receipts before reporting the rest as queued (0-60)", "10")
|
||||
.option("--actor <actor>", "Named operator when outside a managed seat")
|
||||
.option("--json", "Print the daemon's result as JSON")
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const waitSeconds = Number(opts.wait);
|
||||
if (!Number.isFinite(waitSeconds) || waitSeconds < 0 || waitSeconds > 60) throw new Error(`--wait takes 0 to 60 seconds, not ${opts.wait}`);
|
||||
const res = await clientFactory().post<PostOpenAsksResponse>("/api/gateway/slack/post-open-asks",
|
||||
{ reason: opts.reason, waitSeconds, actor: resolveSenderSession() ?? opts.actor ?? SENDER_FALLBACK },
|
||||
{ timeoutMs: (waitSeconds + 30) * 1000 });
|
||||
if (res.status !== 200 || res.data.ok !== true) {
|
||||
throw new Error((res.data as { message?: string }).message ?? `daemon refused (HTTP ${res.status}): ${JSON.stringify(res.data)}`);
|
||||
}
|
||||
if (opts.json) { log(JSON.stringify(res.data, null, 2)); return; }
|
||||
for (const line of describeOpenAsks(res.data.asks)) log(line);
|
||||
} catch (e) {
|
||||
log(`✗ post-open-asks failed: ${(e as Error).message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("disable")
|
||||
.description("Disable the connector (the daemon rewires to an inert delivery path)")
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
getDaemonUrl,
|
||||
startDaemon,
|
||||
waitForKernelReady,
|
||||
skippedKernelMessage,
|
||||
type LifecycleDeps, daemonStatusGuard } from "../daemon-lifecycle.js";
|
||||
import { shouldAutoStartDaemon } from "../daemon-reachability.js";
|
||||
import { freshSeatCommand, launchFreshSeat } from "../fresh-seat-command.js";
|
||||
@@ -191,13 +190,6 @@ Examples:
|
||||
When the daemon is restoring a kernel a reboot left down, rig start goes on to
|
||||
your other rigs. Exit 0 then does not mean the kernel is ready (check rig
|
||||
status); a restore that returned without a ready kernel exits 1.
|
||||
|
||||
--last restores a rig unless every seat it launched was stopped on purpose. A
|
||||
rig taken down with rig down before OpenRig 0.6.12, or whose seats exited or
|
||||
were lost without rig down, counts as last running: run rig down <rig> once
|
||||
(its id when names are shared) and --last keeps it down. --last never selects
|
||||
the kernel; under --no-kernel its automatic restore is skipped too, so bring it
|
||||
back with rig up kernel --existing.
|
||||
`);
|
||||
const getDepsF = (): StartDeps =>
|
||||
depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
@@ -294,8 +286,11 @@ back with rig up kernel --existing.
|
||||
if (!opts.json) console.log("Continuing with your other rigs.");
|
||||
} else if (!kernelResult.ok) {
|
||||
if (kernelResult.kernelState === "skipped") {
|
||||
// This command does not restore a kernel the daemon skipped; say why, and the way back.
|
||||
if (!opts.json) console.log(skippedKernelMessage(kernelResult.detail));
|
||||
// The daemon also skips its boot when a kernel rig already exists (kernel-boot.ts).
|
||||
// That rig need not be running, after a reboot it is not, and this command does not restore it.
|
||||
if (!opts.json) console.log(kernelResult.detail === "kernel rig already managed"
|
||||
? "Kernel auto-boot skipped (kernel rig already managed). If its seats are not running, for example after a reboot, bring it back with: rig up kernel --existing"
|
||||
: "Kernel auto-boot skipped (--no-kernel or test mode), including the operator that helps you start a team. To start it later, run rig, set up or select kernel, then start its operator seat.");
|
||||
} else {
|
||||
const terminalFailure = ["auth_blocked", "spec_missing", "bootstrap_failed", "degraded"].includes(kernelResult.kernelState ?? "");
|
||||
console.error(`${terminalFailure ? "Kernel failed to start" : "Kernel did not report ready before the deadline"}: state=${kernelResult.kernelState ?? "unknown"}, detail=${kernelResult.detail ?? "none"}`);
|
||||
|
||||
@@ -3,7 +3,6 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { formatDaemonHostForUrl } from "../client.js";
|
||||
import { readOpenRigUrl } from "../openrig-compat.js";
|
||||
|
||||
export interface StartupProofDeps {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
@@ -34,9 +33,7 @@ export function resolveStartupProofEndpoint(deps: StartupProofDeps = {}): Endpoi
|
||||
const env = deps.env ?? process.env;
|
||||
const readFile = deps.readFile ?? ((p: string) => fs.readFileSync(p, "utf8"));
|
||||
const home = first(env["OPENRIG_HOME"], env["RIGGED_HOME"]) ?? path.join(os.homedir(), ".openrig");
|
||||
// Startup proof has always trimmed these and skipped blank ones; keep that, then pick and
|
||||
// normalize through the shared reader like every other OPENRIG_URL reader.
|
||||
let baseUrl = readOpenRigUrl({ OPENRIG_URL: env["OPENRIG_URL"]?.trim(), RIGGED_URL: env["RIGGED_URL"]?.trim() }) ?? null;
|
||||
let baseUrl = first(env["OPENRIG_URL"], env["RIGGED_URL"]);
|
||||
let token = first(env["OPENRIG_ACTIVITY_HOOK_TOKEN"], env["RIGGED_ACTIVITY_HOOK_TOKEN"]);
|
||||
|
||||
if (!baseUrl || !token) {
|
||||
|
||||
@@ -17,30 +17,22 @@
|
||||
// exit 0 unless the daemon is unreachable.
|
||||
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient, terminalAuthHeaders } from "../client.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { openTerminalWindow, type WindowDeps } from "../terminal-window.js";
|
||||
|
||||
export interface TerminalDeps extends StatusDeps { windowDeps?: WindowDeps }
|
||||
export interface FollowControls {
|
||||
enabled: boolean; maxTiles?: number; graceMs?: number; focusRetentionMs?: number;
|
||||
pin?: string[]; unpin?: string[]; suppress?: string[]; unsuppress?: string[]; suppressionMs?: number;
|
||||
}
|
||||
|
||||
/** The one shared open-result shape (mirrors the daemon `OpenViewResult`). */
|
||||
export interface OpenViewResult {
|
||||
workspaceId?: string;
|
||||
following?: { id: string; enabled: boolean; state: "following" | "empty" | "retained" | "disabled";
|
||||
maxTiles: number; tiles: Array<{ key: string; seat: string; departedAt?: number; reason?: string }>;
|
||||
events: string[]; overflow: Array<{ key: string; seat: string; reason: string }>; nextAt?: number };
|
||||
provider: string;
|
||||
session?: string;
|
||||
ok: boolean;
|
||||
opened: string[];
|
||||
absent: { seat: string; host: string | null; reason: string }[];
|
||||
degraded: { seat: string; host: string; reason: string; code?: "pane_unconfirmed" }[];
|
||||
degraded: { seat: string; host: string; reason: string }[];
|
||||
pages: number;
|
||||
error?: string;
|
||||
code?: string;
|
||||
@@ -84,19 +76,14 @@ function humanOpen(r: OpenViewResult): string {
|
||||
? `Terminal window requested. Reused ${target} workspace ${r.reusedWorkspace.id} for view ${JSON.stringify(r.reusedWorkspace.view)}.`
|
||||
: tiled > 0
|
||||
? `${r.window ? "Terminal window requested. " : ""}Prepared ${tiled} tile(s) in ${target}${r.pages > 1 ? ` across ${r.pages} page(s)` : ""}.`
|
||||
: `No tiles ${r.degraded.some(d => d.code === "pane_unconfirmed") ? "confirmed" : "opened"} in ${target}.`,
|
||||
: `No tiles opened in ${target}.`,
|
||||
);
|
||||
if (r.window) lines.push(` Terminal: ${r.window.app} (${r.window.surface}).`);
|
||||
if (r.space) lines.push(` ${r.provider} session: ${JSON.stringify(r.space.label)} (${r.space.id}).`);
|
||||
if (r.error && r.code !== "terminal_window_failed") lines.push(` provider: ${r.error}${r.code ? ` (${r.code})` : ""}`);
|
||||
if (r.following) {
|
||||
lines.push(` Following: ${r.following.state}; tile limit ${r.following.maxTiles}.`);
|
||||
for (const tile of r.following.tiles) if (tile.reason) lines.push(` ${tile.seat}: ${tile.reason}`);
|
||||
for (const hidden of r.following.overflow) lines.push(` ${hidden.seat}: ${hidden.reason}`);
|
||||
}
|
||||
for (const seat of r.opened) lines.push(` ● ${seat}`);
|
||||
for (const a of r.absent) lines.push(` ○ ${a.seat} — absent: ${a.reason}`);
|
||||
for (const d of r.degraded) lines.push(` ▲ ${d.seat} — ${d.code === "pane_unconfirmed" ? "unconfirmed" : "skipped"} (${d.host}): ${d.reason}`);
|
||||
for (const d of r.degraded) lines.push(` ▲ ${d.seat} — skipped (${d.host}): ${d.reason}`);
|
||||
for (const n of r.notes ?? []) lines.push(` note: ${n}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -108,7 +95,7 @@ function printOpen(json: boolean, r: OpenViewResult, status: number): void {
|
||||
} else {
|
||||
console.log(humanOpen(r));
|
||||
}
|
||||
if (status >= 400 || (r.following ? !r.ok : r.opened.length === 0 && !(r.ok && r.reusedWorkspace))) {
|
||||
if (status >= 400 || (r.opened.length === 0 && !(r.ok && r.reusedWorkspace))) {
|
||||
process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
}
|
||||
@@ -126,43 +113,27 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
|
||||
|
||||
cmd
|
||||
.command("open")
|
||||
.argument("<view>", "a rig name, pod:<rig>/<pod>, seat:<rig>/<logical-id>, mission:<id>, slice:<id>, qitem:<id> (current local obligation owners), or a saved-view id")
|
||||
.argument("<view>", "a rig name, mission:<id>, slice:<id>, or a saved-view id")
|
||||
.description("Open a desktop terminal showing the view's live agents as interactive tiles")
|
||||
.option("--provider <name>", "herdr, cmux or rex: open in the running provider without a desktop window; each open adds a new workspace (inside Herdr, --window reuses a matching one); tmux requires --window. Inside Rex, the default opens in Rex")
|
||||
.option("--session <name>", "Herdr session on the daemon's host; --session default selects Herdr's default session")
|
||||
.option("--window", "Open a new desktop terminal tab/window (the default when --provider is omitted)")
|
||||
.option("--expected-plan <id>", "Open only if the view still matches this preview")
|
||||
.option("--follow", "Keep one qitem:<id> workspace following current obligation owners (Linux Herdr 0.9.5/protocol 22)")
|
||||
.option("--no-follow", "Stop following and keep the existing workspace and tiles as a fixed layout")
|
||||
.option("--max-tiles <count>", "Following tile limit, 1–256; explicitly raise it to show more members", Number)
|
||||
.option("--pin <seats...>", "Retain these following tiles after they leave the task")
|
||||
.option("--unpin <seats...>", "Remove pins from these following tiles")
|
||||
.option("--suppress <seats...>", "Temporarily hide these following tiles; agents keep running")
|
||||
.option("--unsuppress <seats...>", "Show these following members again")
|
||||
.option("--suppression-ms <ms>", "Suppression duration, default 60000, at most 86400000", Number)
|
||||
.option("--grace-ms <ms>", "Departure grace, default 4000, at most 60000", Number)
|
||||
.option("--focus-retention-ms <ms>", "Extra focused-reading retention, default 12000, at most 300000", Number)
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (view: string, opts: { provider?: string; session?: string; json?: boolean; window?: boolean; expectedPlan?: string;
|
||||
follow?: boolean; maxTiles?: number; pin?: string[]; unpin?: string[]; suppress?: string[]; unsuppress?: string[];
|
||||
suppressionMs?: number; graceMs?: number; focusRetentionMs?: number }) => {
|
||||
.action(async (view: string, opts: { provider?: string; session?: string; json?: boolean; window?: boolean; expectedPlan?: string }) => {
|
||||
const deps = getDeps();
|
||||
const session = opts.session?.trim();
|
||||
const { maxTiles, pin, unpin, suppress, unsuppress, suppressionMs, graceMs, focusRetentionMs } = opts;
|
||||
const hasControls = [maxTiles, pin, unpin, suppress, unsuppress, suppressionMs, graceMs, focusRetentionMs].some(v => v !== undefined);
|
||||
const following: FollowControls | undefined = opts.follow !== undefined || hasControls
|
||||
? { enabled: opts.follow !== false, maxTiles, pin, unpin, suppress, unsuppress, suppressionMs, graceMs, focusRetentionMs } : undefined;
|
||||
await withClient(deps, async (client) => {
|
||||
// Herdr's control socket can answer with no desktop client attached.
|
||||
// The default requests a desktop; an explicit provider reuses its workspace.
|
||||
if (following?.enabled !== false && (opts.window || !opts.provider)) {
|
||||
const result = await openTerminalWindow(client, view, opts.provider, deps.windowDeps, opts.expectedPlan, session, following);
|
||||
if (opts.window || !opts.provider) {
|
||||
const result = await openTerminalWindow(client, view, opts.provider, deps.windowDeps, opts.expectedPlan, session);
|
||||
printOpen(opts.json ?? false, result, 200);
|
||||
return;
|
||||
}
|
||||
let expectedPlan = opts.expectedPlan;
|
||||
if (session !== undefined) {
|
||||
const preview = await client.get<{ session?: string; planId?: string; error?: string }>(`/api/terminal/preview?view=${encodeURIComponent(view)}&provider=${encodeURIComponent(opts.provider ?? "herdr")}&session=${encodeURIComponent(session)}`);
|
||||
const preview = await client.get<{ session?: string; planId?: string; error?: string }>(`/api/terminal/preview?view=${encodeURIComponent(view)}&provider=${encodeURIComponent(opts.provider)}&session=${encodeURIComponent(session)}`);
|
||||
if (preview.status >= 400 || preview.data.session !== session || typeof preview.data.planId !== "string") {
|
||||
printResult(opts.json ?? false, { error: preview.data.error ?? "The daemon did not confirm the requested Herdr session. Update the daemon before opening a named session.", code: "terminal_session_unconfirmed" }, preview.status >= 400 ? preview.status : 409); return;
|
||||
}
|
||||
@@ -172,8 +143,7 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
|
||||
expectedPlan = preview.data.planId;
|
||||
}
|
||||
const body = { view, ...(opts.provider ? { provider: opts.provider } : {}), ...(session !== undefined ? { session } : {}), ...(expectedPlan !== undefined ? { expectedPlan } : {}) };
|
||||
const res = await client.post<OpenViewResult>(following ? "/api/terminal/follow" : "/api/terminal/open",
|
||||
{ ...body, ...following }, { timeoutMs: TERMINAL_OPEN_TIMEOUT_MS, ...(following ? { headers: terminalAuthHeaders() } : {}) });
|
||||
const res = await client.post<OpenViewResult>("/api/terminal/open", body, { timeoutMs: TERMINAL_OPEN_TIMEOUT_MS });
|
||||
if (session !== undefined && res.status < 400 && res.data?.session !== session) {
|
||||
printResult(opts.json ?? false, { ...res.data, ok: false, code: "terminal_session_unconfirmed",
|
||||
error: "The open response did not confirm the requested Herdr session. Its outcome is unknown; inspect that session before retrying." }, 409); return;
|
||||
|
||||
@@ -76,7 +76,7 @@ OPENRIG_CLAUDE_DISABLE_ALTERNATE_SCREEN=0 — relaunch it to restore scrollback.
|
||||
const requestedLines = Number.isNaN(tailLines) || tailLines < 1 ? 50 : tailLines;
|
||||
const apiPath = useGrep
|
||||
? `/api/transcripts/${encodeURIComponent(session)}/grep?pattern=${encodeURIComponent(opts.grep!)}`
|
||||
: `/api/transcripts/${encodeURIComponent(session)}/tail?lines=${requestedLines}`;
|
||||
: `/api/transcripts/${encodeURIComponent(session)}/tail?lines=${isNaN(tailLines) ? 50 : tailLines}`;
|
||||
|
||||
// --- Cross-host path (CLI-direct GET to the remote daemon's shipped
|
||||
// transcript routes — the SAME paths the local path builds, origin
|
||||
|
||||
@@ -161,8 +161,7 @@ function whoamiPeersBound(): number {
|
||||
|
||||
type TmuxExecFn = (cmd: string) => string;
|
||||
|
||||
// tmux's stderr (an unset @rigged_* option, no server) rides the thrown error; callers fall through.
|
||||
const defaultTmuxExec: TmuxExecFn = (cmd: string) => execSync(cmd, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
||||
const defaultTmuxExec: TmuxExecFn = (cmd: string) => execSync(cmd, { encoding: "utf-8" }).trim();
|
||||
|
||||
function buildPartialWhoamiResult(source: { nodeId?: string; sessionName?: string }): Record<string, unknown> {
|
||||
return {
|
||||
|
||||
@@ -788,9 +788,8 @@ function assertNoRemovedContextSetting(fileConfig: Record<string, unknown>): voi
|
||||
// settings-store.ts — the parity test pins both twins (host-registry twin
|
||||
// discipline). Reserved segments cover key-level snake_case AND FILE-level
|
||||
// camelCase toggle names so no host id shadows a flat toggle at either
|
||||
// layer. The fixed prefix/suffix delimit a literal host id, including dots.
|
||||
// Every other unknown key keeps the reject-loud behavior.
|
||||
const FEED_HOST_KEY_RE = /^feed\.subscriptions\.([A-Za-z0-9_.-]+)\.enabled$/;
|
||||
// layer. Every other unknown key keeps the reject-loud behavior.
|
||||
const FEED_HOST_KEY_RE = /^feed\.subscriptions\.([A-Za-z0-9_-]+)\.enabled$/;
|
||||
export const FEED_HOST_RESERVED_SEGMENTS = new Set([
|
||||
"action_required",
|
||||
"actionRequired",
|
||||
|
||||
@@ -7,7 +7,7 @@ export { daemonMayAnswer } from "./daemon-reachability.js";
|
||||
import type { DaemonStartLock } from "./daemon-start-lock.js";
|
||||
import { DAEMON_STOP_WAIT_MS, DAEMON_SHUTDOWN_RECEIPT, type DaemonShutdownReceipt } from "@openrig/daemon/daemon-shutdown";
|
||||
import { ConfigStore } from "./config-store.js";
|
||||
import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv, readOpenRigUrl } from "./openrig-compat.js";
|
||||
import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv } from "./openrig-compat.js";
|
||||
// Types only: the module itself (with the daemon's yaml-reading system world) is imported where an
|
||||
// instance is initialized, so a command that only talks to the daemon never loads it.
|
||||
import type {
|
||||
@@ -72,18 +72,6 @@ export interface GetDaemonStatusOptions {
|
||||
cleanupStaleState?: boolean;
|
||||
}
|
||||
|
||||
/** True for `http://host:port` with an explicit, non-default port and no path, query or
|
||||
* fragment: the only OPENRIG_URL shape getDaemonUrl's host-and-port rebuild reaches unchanged
|
||||
* (without a port, or with :80, which URL reports as no port, status falls back to 7433). */
|
||||
function isPlainHttpOrigin(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.protocol === "http:" && u.port !== "" && u.pathname === "/" && !u.search && !u.hash && !url.endsWith("/");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the daemon HTTP URL from status. Uses persisted host or defaults to 127.0.0.1. */
|
||||
export function getDaemonUrl(status: DaemonStatus): string {
|
||||
return `http://${formatDaemonHostForUrl(status.host ?? DEFAULT_HOST)}:${status.port}`;
|
||||
@@ -368,7 +356,7 @@ export function resolveCliBaseDir(baseDir: string): string {
|
||||
* stale daemon through `rig daemon start` whenever they've rebuilt
|
||||
* `@openrig/daemon` but haven't rerun `build-package.sh`. That hid the
|
||||
* fact that QA on local main 55e01f2e was getting a daemon WITHOUT the
|
||||
* shipped slice-09 `/api/rig-mode/*` routes.
|
||||
* shipped slice-09 `/api/rig-mode/*` routes (qitem-20260518054224).
|
||||
*
|
||||
* In an npm-install layout `packages/daemon/dist` does not exist
|
||||
* (only the bundled `packages/cli/daemon` lives next to the cli);
|
||||
@@ -462,7 +450,7 @@ const ENV_SCRUB_PREFIXES = ["CODEX_", "GHOSTTY_", "XPC_", "__CF"];
|
||||
// OPENRIG_BIND_HOST (exported below when opts.host declares it). THE RULE FOR FUTURE
|
||||
// PASSTHROUGH EDITS: any env var that doubles as client routing state must join this
|
||||
// set — routing state silently becoming bind policy is the incident class
|
||||
// (an incident where a host lost its Tailscale listener).
|
||||
// (operator baton qitem-20260827070400: parent lost its Tailscale listener).
|
||||
const ROUTING_ENV_SCRUB = new Set(["OPENRIG_HOST", "RIGGED_HOST"]);
|
||||
|
||||
const ENV_SCRUB_EXACT = new Set([
|
||||
@@ -917,7 +905,7 @@ function removeMatchingState(deps: LifecycleDeps, stateFile: string, state: Daem
|
||||
export async function stopDaemon(deps: LifecycleDeps): Promise<"stopped" | "no-target"> {
|
||||
const state = readState(deps);
|
||||
const configured = resolveConfiguredDaemonTarget();
|
||||
const explicitUrl = readOpenRigUrl();
|
||||
const explicitUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
|
||||
const stateUrl = state ? `http://${formatDaemonHostForUrl(state.host ?? DEFAULT_HOST)}:${state.port}` : undefined;
|
||||
const target = stateUrl ?? explicitUrl?.replace(/\/+$/, "") ?? `http://${formatDaemonHostForUrl(configured.host)}:${configured.port}`;
|
||||
if (stateUrl && explicitUrl && new URL(explicitUrl).origin !== new URL(stateUrl).origin) {
|
||||
@@ -1040,14 +1028,8 @@ export async function getDaemonStatus(
|
||||
deps: LifecycleDeps,
|
||||
options: GetDaemonStatusOptions = {},
|
||||
): Promise<DaemonStatus> {
|
||||
// If OPENRIG_URL is set, bypass daemon.json and probe that URL directly. Commands reach
|
||||
// it through getDaemonUrl's http://host:port rebuild, so only a plain http origin uses
|
||||
// the normalized form; any other URL is probed as written, exactly as before.
|
||||
const rawOpenrigUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
|
||||
const normalizedOpenrigUrl = readOpenRigUrl();
|
||||
const openrigUrl = normalizedOpenrigUrl !== undefined && isPlainHttpOrigin(normalizedOpenrigUrl)
|
||||
? normalizedOpenrigUrl
|
||||
: rawOpenrigUrl;
|
||||
// If OPENRIG_URL is set, bypass daemon.json and probe that URL directly
|
||||
const openrigUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
|
||||
if (openrigUrl) {
|
||||
// An unverified daemon keeps the endpoint it was probed at, so a command can still make its request.
|
||||
const endpoint = (() => {
|
||||
@@ -1336,18 +1318,6 @@ export interface KernelReadyResult {
|
||||
existingRestore?: "in_progress" | "finished";
|
||||
}
|
||||
|
||||
/** What to tell a person when daemon start skipped the kernel on purpose (kernel_state `skipped`).
|
||||
* The daemon also skips when a kernel rig already exists (kernel-boot.ts); that rig need not be running.
|
||||
* A longer detail with the same start is the daemon's own reason, such as a kernel already down before it started. */
|
||||
export function skippedKernelMessage(detail: string | null): string {
|
||||
const d = detail ?? "";
|
||||
return d === "kernel rig already managed"
|
||||
? "Kernel auto-boot skipped (kernel rig already managed). If its seats are not running, for example after a reboot, bring it back with: rig up kernel --existing"
|
||||
: d.startsWith("kernel rig already managed")
|
||||
? `Kernel auto-boot skipped: ${d}`
|
||||
: "Kernel auto-boot skipped (--no-kernel or test mode), including the operator that helps you start a team. To start it later, run rig, set up or select kernel, then start its operator seat.";
|
||||
}
|
||||
|
||||
export async function waitForKernelReady(
|
||||
baseUrl: string,
|
||||
timeoutMs: number,
|
||||
|
||||
@@ -40,9 +40,7 @@ export async function fetchWithTimeout(
|
||||
return response;
|
||||
} catch (err) {
|
||||
if (err instanceof FetchTimeoutError) throw err;
|
||||
// Once the request is aborted, its reason is why it failed, whatever error the abort surfaced as: Node's response
|
||||
// stream, cut by the abort while a body is being read, ends with a plain "aborted" error.
|
||||
if ((err instanceof Error && err.name === "AbortError") || requestSignal.aborted) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
if (requestSignal.reason instanceof Error) throw requestSignal.reason;
|
||||
if (externalSignal?.aborted) throw externalAbortError(requestSignal.reason);
|
||||
throw new FetchTimeoutError(options.timeoutMessage);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// release-0.3.2 slice 12 — dot-ID grammar per the scope-and-versioning
|
||||
// convention, §1.
|
||||
// release-0.3.2 slice 12 — dot-ID grammar per
|
||||
// `openrig-work/conventions/scope-and-versioning/README.md` §1.
|
||||
//
|
||||
// Grammar (v0 single-project):
|
||||
// project = <PFX> (2-3 letter; v0 hardcodes "OPR")
|
||||
|
||||
@@ -62,7 +62,7 @@ export function isPlaceholderOnlyBlock(block: string | null): boolean {
|
||||
return lines.every((l) => isScaffoldPlaceholderText(l));
|
||||
}
|
||||
|
||||
/** PM dogfood #1 — true when `body` is a
|
||||
/** PM dogfood #1 (qitem-20260720015700-630eef64) — true when `body` is a
|
||||
* PRESENT section whose EVERY non-blank line is scaffold-placeholder
|
||||
* content: either a bare placeholder line, or a structural list row
|
||||
* (numbered `1.`/`1)`, checkbox `- [ ]`, or bullet `-`/`*`) whose text is a
|
||||
|
||||
@@ -166,11 +166,6 @@ export function deriveMissionDependencyGraph(input: MissionDependencyGraphInput)
|
||||
// without this file changing too).
|
||||
export const MISSION_BRIEF_HEADERS = ["What & why", "Building", "Progress", "Proven", "Needs you", "Pointers"];
|
||||
|
||||
// A slice directory's name: two or more digits, a dash, then the slug. `rig scope slice create` numbers past 99
|
||||
// (100-…), so a two-digit-only check flags names OpenRig made. The CLI audit and the daemon's audit route both read
|
||||
// this one pattern.
|
||||
export const SLICE_DIRECTORY_NAME = /^\d{2,}-/;
|
||||
|
||||
function childPath(parent: string, child: string): string {
|
||||
return parent.endsWith("/") ? `${parent}${child}` : `${parent}/${child}`;
|
||||
}
|
||||
@@ -427,7 +422,7 @@ export function classifyScopeItem(input: ScopeAuditInput): ScopeAuditResult {
|
||||
});
|
||||
}
|
||||
|
||||
// PM dogfood #1 — per-SECTION source
|
||||
// PM dogfood #1 (qitem-20260720015700-630eef64) — per-SECTION source
|
||||
// selection, decided independently for `## Mini-requirements` and
|
||||
// `## Proof contract`: an authored PRD section is canonical, but a
|
||||
// PRESENT-and-PRISTINE scaffold-only PRD section yields to an authored
|
||||
|
||||
@@ -554,9 +554,8 @@ export function findSlice(
|
||||
continue;
|
||||
}
|
||||
const mission = buildMissionInfo(missionsRoot, owningMissionPath);
|
||||
// The directory and the name both come from the resolved path, so `<slice>/.` and `<slice>/sub/..` select the slice.
|
||||
const resolved = path.resolve(candidate);
|
||||
return buildSliceInfo(mission, path.dirname(resolved), path.basename(resolved));
|
||||
const sliceRoot = path.dirname(candidate);
|
||||
return buildSliceInfo(mission, sliceRoot, path.basename(candidate));
|
||||
}
|
||||
}
|
||||
if (firstContextError) throw firstContextError;
|
||||
@@ -568,18 +567,9 @@ export function findSlice(
|
||||
}
|
||||
|
||||
function findOwningMission(missionsRoot: string, slicePath: string): string | null {
|
||||
// path.dirname never ends in a separator, so compare against the normalized root
|
||||
// (a configured or --workspace root may be written as `/w/missions/`); resolve the
|
||||
// slice the same way so a relative root and a relative candidate stay comparable.
|
||||
// A path that leaves the root, such as `<root>/../other/m1/slices/x`, then falls back
|
||||
// to the walk as written, where it reads as before.
|
||||
return walkToMission(path.resolve(missionsRoot), path.resolve(slicePath)) ?? walkToMission(missionsRoot, slicePath);
|
||||
}
|
||||
|
||||
function walkToMission(root: string, slicePath: string): string | null {
|
||||
let dir = path.dirname(slicePath);
|
||||
while (dir.startsWith(root) && dir !== root) {
|
||||
if (path.dirname(dir) === root) return dir;
|
||||
while (dir.startsWith(missionsRoot) && dir !== missionsRoot) {
|
||||
if (path.dirname(dir) === missionsRoot) return dir;
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// release-0.3.2 slice 12 — scope CLI primitive types.
|
||||
//
|
||||
// Aligned with the scope-and-versioning convention (stage: provisional,
|
||||
// authored 2026-05-15). The CLI mints stable dot-IDs
|
||||
// Aligned with the substrate convention at
|
||||
// `openrig-work/conventions/scope-and-versioning/README.md` (stage:
|
||||
// provisional, authored 2026-05-15). The CLI mints stable dot-IDs
|
||||
// into created mission/slice frontmatter per §1 of that convention.
|
||||
|
||||
/** Scope tiers. v0 ships `mission` + `slice`; `project` and `sub-slice`
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
/**
|
||||
* Node's fetch (undici) gives up on a response whose headers haven't arrived within 300 s, and a waiting send's response
|
||||
* starts only when its wait ends. A request allowed that long goes over node:http instead, which has no headers timeout,
|
||||
* so a long `--wait-for-idle` stays one request: one deadline, and the daemon's one binding to the seat's occupant.
|
||||
*/
|
||||
export const FETCH_HEADERS_TIMEOUT_MS = 300_000;
|
||||
|
||||
/** The subset of fetch the daemon client uses (method, headers, a string body, a signal), over node:http or https. */
|
||||
export const nodeHttpFetch = ((input: string | URL, init?: RequestInit): Promise<Response> => new Promise((resolve, reject) => {
|
||||
// Only a string body is sent; any other is refused rather than sent empty.
|
||||
const body = init?.body;
|
||||
if (body != null && typeof body !== "string") {
|
||||
reject(new TypeError("This request path sends only a string body."));
|
||||
return;
|
||||
}
|
||||
const url = new URL(String(input));
|
||||
// Through the module objects, not named imports, so a test's request guard sees this call as it sees fetch.
|
||||
const transport = url.protocol === "https:" ? https : http;
|
||||
const request = transport.request(url, {
|
||||
method: init?.method ?? "GET",
|
||||
headers: init?.headers as Record<string, string> | undefined,
|
||||
signal: init?.signal ?? undefined,
|
||||
agent: false,
|
||||
}, (res) => {
|
||||
// Returned when the headers arrive, as fetch does, with the body still to read: a body lost after that is a failed
|
||||
// read of an answer the daemon sent, never a request that didn't arrive.
|
||||
try {
|
||||
const headers = new Headers();
|
||||
for (const [name, value] of Object.entries(res.headers)) for (const one of [value ?? []].flat()) headers.append(name, one);
|
||||
const status = res.statusCode ?? 0;
|
||||
const noBody = [204, 205, 304].includes(status);
|
||||
if (noBody) res.on("error", () => {}).resume();
|
||||
resolve(new Response(noBody ? null : Readable.toWeb(res) as unknown as ConstructorParameters<typeof Response>[0], { status, statusText: res.statusMessage, headers }));
|
||||
} catch (err) {
|
||||
res.destroy();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
request.on("error", reject);
|
||||
request.end(body ?? undefined);
|
||||
})) as typeof fetch;
|
||||
+37
-294
@@ -6,23 +6,14 @@
|
||||
// proves nothing about the caller. A missing, invalid or revoked credential is refused and never falls back to the env
|
||||
// seat, and so is every request once the file is gone or malformed, until a restart. Each request gets its own
|
||||
// DaemonClient, carrying its grant's seat, so concurrent callers never share an identity.
|
||||
//
|
||||
// With --grant-service, grants come from an external grant service instead and the token file is never read: each
|
||||
// request's key is checked with that service (openrig.grant/v1) for this daemon's instance, every answer but active is
|
||||
// refused, and an external caller reaches only what its grant names (externalScope in mcp-server.ts).
|
||||
//
|
||||
// The endpoint serves both protocol revisions: 2026-07-28 per request, and 2025's initialize and exchanges statelessly.
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { createMcpHandler, type AuthInfo } from "@modelcontextprotocol/server";
|
||||
import { toNodeHandler } from "@modelcontextprotocol/node";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { DaemonClient } from "./client.js";
|
||||
import { createMcpServer, type McpAccess } from "./mcp-server.js";
|
||||
import { getOpenRigHome } from "./openrig-compat.js";
|
||||
import { MCP_PRESENCE_FILE, presenceRecorder } from "./mcp-presence.js";
|
||||
import { resolveRigHandle } from "./rig-handle.js";
|
||||
|
||||
/** One caller's grant. Only the token's sha256 is stored. */
|
||||
export type McpGrant = {
|
||||
@@ -147,174 +138,13 @@ export function isLoopbackHost(host: string): boolean {
|
||||
|
||||
export class McpHttpConfigError extends Error {}
|
||||
|
||||
/** The caller whose grant this server checked, as the daemon records it beside the rows it writes (principal:v1). */
|
||||
export type McpPrincipal = { source: "file" | "grant-service"; id: string; grant?: string; version?: number | string };
|
||||
/** One request's caller as this server decided it: no seat, access or principal for the loopback local owner. */
|
||||
type RequestCaller = { seat?: string; access?: McpAccess; principal?: McpPrincipal };
|
||||
|
||||
/** An external grant service's contract. Core asks on every request with the caller's raw key and its own instance id;
|
||||
* nothing is cached, and every answer but `active` is refused. */
|
||||
export const GRANT_SCHEMA = "openrig.grant/v1";
|
||||
/** A grant key as the grant service issues it, `<connection id>.cus_<principal id>.<secret>`. Anything else is refused
|
||||
* before any call. */
|
||||
const GRANT_KEY = /^[a-f0-9]{24}\.cus_[A-Za-z0-9]{8,64}\.[a-f0-9]{48}$/;
|
||||
/** An instance id as the grant service takes it. */
|
||||
const INSTANCE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
|
||||
export type GrantServiceOptions = {
|
||||
/** The grant endpoint. It must be https, except on loopback. */
|
||||
url: string;
|
||||
/** One header sent with every grant request and nowhere else, such as a protected deployment's bypass. */
|
||||
header?: { name: string; value: string };
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
/** Reads a `Name: value` header line from a secret file. */
|
||||
export function readGrantServiceHeader(file: string): { name: string; value: string } {
|
||||
const line = fs.readFileSync(file, "utf8").trim();
|
||||
const match = /^([A-Za-z0-9-]+):[ \t]*(\S(?:.*\S)?)$/.exec(line);
|
||||
if (!match || line.includes("\n")) throw new McpHttpConfigError(`${file} must hold one line, Name: value`);
|
||||
// The request's own headers carry the key and the body; this one can't replace them.
|
||||
if (["authorization", "content-type", "content-length", "host"].includes(match[1]!.toLowerCase())) {
|
||||
throw new McpHttpConfigError(`${file} can't set ${match[1]}: the grant request sets it`);
|
||||
}
|
||||
return { name: match[1]!, value: match[2]! };
|
||||
}
|
||||
|
||||
function grantServiceUrl(url: string): URL {
|
||||
let parsed: URL;
|
||||
try { parsed = new URL(url); } catch { throw new McpHttpConfigError(`--grant-service takes the grant endpoint's URL, not ${url}`); }
|
||||
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopbackHost(parsed.hostname))) {
|
||||
throw new McpHttpConfigError(`The grant endpoint must be https: ${url}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** OAuth discovery (RFC 9728) for a server whose keys a grant service checks: the one metadata document, where it's
|
||||
* served, and the 401 challenge that points at it. */
|
||||
type OAuthDiscovery = { path: string; document: Record<string, unknown>; challenge: string };
|
||||
|
||||
/** The resource clients reach this server as and the authorization server that issues its keys, served exactly as
|
||||
* configured. Each must be https with no userinfo, query or fragment, written as a URL writes it (a lowercase host, no
|
||||
* default port), so nothing is normalized on the way out; the resource's path ends in /mcp. Its metadata is at the
|
||||
* path-inserted address, and nowhere else. */
|
||||
function oauthDiscovery(oauth: { resource: string; issuer: string }): OAuthDiscovery {
|
||||
const exact = (flag: string, value: string): URL => {
|
||||
let url: URL | undefined;
|
||||
try { url = new URL(value); } catch { /* refused below */ }
|
||||
// A bare origin is written without the slash a URL adds. An empty query or fragment shows only in the text.
|
||||
const written = url !== undefined && (url.href === value || (url.pathname === "/" && url.href === `${value}/`));
|
||||
if (!url || !written || url.protocol !== "https:" || url.username || url.password || /[?#]/.test(value)) {
|
||||
throw new McpHttpConfigError(`${flag} takes an https URL with no userinfo, query or fragment, written as a URL writes it (a lowercase host, no default port), not ${value}`);
|
||||
}
|
||||
return url;
|
||||
};
|
||||
const resource = exact("--oauth-resource", oauth.resource);
|
||||
exact("--oauth-issuer", oauth.issuer);
|
||||
if (!resource.pathname.endsWith("/mcp")) throw new McpHttpConfigError(`--oauth-resource's path must end in /mcp, not ${oauth.resource}`);
|
||||
const path = `/.well-known/oauth-protected-resource${resource.pathname}`;
|
||||
return {
|
||||
path,
|
||||
document: { resource: oauth.resource, authorization_servers: [oauth.issuer], scopes_supported: ["openrig.work"], bearer_methods_supported: ["header"] },
|
||||
challenge: `Bearer realm="openrig", resource_metadata="${resource.origin}${path}"`,
|
||||
};
|
||||
}
|
||||
|
||||
type Refusal = { status: number; error: string; message: string; log?: string };
|
||||
const unauthenticated = (message: string): Refusal => ({ status: 401, error: "unauthenticated", message });
|
||||
const NOT_VALID_HERE = "This credential isn't valid here: it's unknown or was revoked.";
|
||||
const unavailable = (log: string): Refusal => ({
|
||||
status: 503, error: "grant_authority_unavailable", message: "The grant authority can't answer now, so this request isn't served. Try again later.", log,
|
||||
});
|
||||
|
||||
const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every((item) => typeof item === "string" && item !== "");
|
||||
|
||||
/** One request's caller from the grant service's answer for its key. A refusal never falls back to the token file or
|
||||
* the env's seat. */
|
||||
async function grantServiceCaller(service: GrantServiceOptions, url: URL, key: string, instance: string): Promise<{ seat: string; access: McpAccess; principal: McpPrincipal } | Refusal> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: "POST",
|
||||
// A redirect would carry the key and the header to another origin.
|
||||
redirect: "error",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}`, ...(service.header ? { [service.header.name]: service.header.value } : {}) },
|
||||
body: JSON.stringify({ instance }),
|
||||
signal: AbortSignal.timeout(service.timeoutMs ?? 5_000),
|
||||
});
|
||||
} catch (err) {
|
||||
return unavailable(`the grant request failed: ${(err as Error).message}`);
|
||||
}
|
||||
let body: Record<string, any>;
|
||||
try { body = await res.json() as Record<string, any>; } catch { return unavailable(`HTTP ${res.status} answered with a body that isn't JSON`); }
|
||||
if (body?.schema !== GRANT_SCHEMA) return unavailable(`HTTP ${res.status} answered schema ${JSON.stringify(body?.schema)}`);
|
||||
if (body.status === "unknown" || body.status === "wrong_instance") return unauthenticated(NOT_VALID_HERE);
|
||||
if (body.status === "revoked") return unauthenticated("This connection was disconnected. Reconnect it where it was made.");
|
||||
if (body.status === "expired") return unauthenticated("This credential has expired.");
|
||||
// malformed (this server's request), unavailable, or anything else.
|
||||
if (body.status !== "active" || res.status !== 200) return unavailable(`HTTP ${res.status} answered status ${JSON.stringify(body.status)}`);
|
||||
const { principal, grant, environment, seat, rigs, artifact_roots: roots, access_class: accessClass, objects } = body;
|
||||
const seatRig = typeof seat === "string" ? seat.split("@") : [];
|
||||
const problem = typeof principal?.id !== "string" || principal.id === "" ? "principal.id"
|
||||
: typeof grant?.id !== "string" || grant.id === "" ? "grant.id"
|
||||
: environment?.core_instance !== instance ? "environment.core_instance"
|
||||
: seatRig.length !== 2 || seatRig[0] === "" ? "seat"
|
||||
: !strings(rigs) || !rigs.includes(seatRig[1]!) ? "rigs"
|
||||
: !strings(roots) ? "artifact_roots"
|
||||
: accessClass !== "work" ? "access_class"
|
||||
: !objects || typeof objects !== "object" || Array.isArray(objects) || !Object.values(objects).every(strings) ? "objects"
|
||||
: grant.expires_at !== null && grant.expires_at !== undefined && !(Date.parse(grant.expires_at) > Date.now()) ? "expired" : null;
|
||||
if (problem === "expired") return unauthenticated("This credential has expired.");
|
||||
if (problem) return unavailable(`an active answer with an unusable ${problem}`);
|
||||
// The principal the daemon records beside this caller's rows. A version that isn't a finite number is left out (a v1
|
||||
// answer sends a number).
|
||||
return { seat, access: { principal: principal.id, owner: false, rigs, roots, workspace: false, external: { objects } },
|
||||
principal: { source: "grant-service", id: principal.id, grant: grant.id, ...(Number.isFinite(grant.version) ? { version: grant.version } : {}) } };
|
||||
}
|
||||
|
||||
/** A node's seat name as the daemon derives it (deriveCanonicalFromEntry): {pod}-{member}@{rig}, the logical id split at
|
||||
* its first dot. */
|
||||
function canonicalSeat(logicalId: string, rigName: string): string | null {
|
||||
const dot = logicalId.indexOf(".");
|
||||
return dot > 0 && dot < logicalId.length - 1 ? `${logicalId.slice(0, dot)}-${logicalId.slice(dot + 1)}@${rigName}` : null;
|
||||
}
|
||||
|
||||
/** Whether a seat whoami doesn't know is one of this daemon's nodes: a seat created without a launch (rig import
|
||||
* --materialize-only) has no session yet. The rig resolves by name as rig commands resolve one (resolveRigHandle: only
|
||||
* active rigs, and two active rigs of that name are ambiguous), and the seat must be exactly one of its nodes' names.
|
||||
* Read only: nothing is created, launched or bound. A rig list or node list that can't be read, or has a malformed
|
||||
* entry, and a name two nodes share, never count as the seat. */
|
||||
async function nodeSeat(client: DaemonClient, seat: string): Promise<"found" | "absent" | Refusal> {
|
||||
const rig = await resolveRigHandle(client, seat.slice(seat.indexOf("@") + 1), { strict: true });
|
||||
if (rig.kind === "not_found") return "absent";
|
||||
if (rig.kind === "passthrough") return unavailable(`the rig list couldn't be read, or has a malformed entry, to look for seat ${seat}`);
|
||||
if (rig.kind === "ambiguous") return unavailable(`seat ${seat}'s rig name matches ${rig.ids.length} rigs (${rig.ids.join(", ")})`);
|
||||
const nodes = await client.get(`/api/rigs/${encodeURIComponent(rig.id)}/nodes`);
|
||||
if (nodes.status !== 200 || !Array.isArray(nodes.data)) return unavailable(`the node list for seat ${seat} answered HTTP ${nodes.status}`);
|
||||
let matches = 0;
|
||||
for (const node of nodes.data as unknown[]) {
|
||||
const { logicalId, rigName } = (node ?? {}) as Record<string, unknown>;
|
||||
if (typeof logicalId !== "string" || typeof rigName !== "string") return unavailable(`the node list for seat ${seat} has a malformed entry`);
|
||||
if (canonicalSeat(logicalId, rigName) === seat) matches += 1;
|
||||
}
|
||||
return matches === 1 ? "found" : matches === 0 ? "absent" : unavailable(`${matches} nodes are named ${seat}`);
|
||||
}
|
||||
|
||||
export type McpHttpOptions = {
|
||||
daemonUrl: string;
|
||||
host: string;
|
||||
port: number;
|
||||
tokenFile: string;
|
||||
/** Builds the daemon client for one request; sessionName is the grant's seat (absent: the env's), and principal the
|
||||
* caller whose grant was checked. */
|
||||
clientFactory?: (daemonUrl: string, sessionName?: string, principal?: McpPrincipal) => DaemonClient;
|
||||
/** Grants come from an external grant service instead of the token file, which is then never read. */
|
||||
grantService?: GrantServiceOptions;
|
||||
/** With grantService: OAuth discovery for clients that sign in with OAuth. The public resource this server is reached
|
||||
* as and the authorization server that issues its keys, both served exactly as given. */
|
||||
oauth?: { resource: string; issuer: string };
|
||||
/** How often one seat's call is recorded (default 60 s). */
|
||||
presenceIntervalMs?: number;
|
||||
/** Builds the daemon client for one request; sessionName is the grant's seat (absent: the env's). */
|
||||
clientFactory?: (daemonUrl: string, sessionName?: string) => DaemonClient;
|
||||
};
|
||||
|
||||
function reply(res: http.ServerResponse, status: number, body: Record<string, unknown>, headers: Record<string, string> = {}): void {
|
||||
@@ -337,130 +167,46 @@ function readBody(req: http.IncomingMessage): Promise<string> {
|
||||
|
||||
export async function startMcpHttpServer(opts: McpHttpOptions): Promise<{ url: string; authenticated: boolean; close(): Promise<void> }> {
|
||||
const loopback = isLoopbackHost(opts.host);
|
||||
const service = opts.grantService;
|
||||
const serviceUrl = service ? grantServiceUrl(service.url) : undefined;
|
||||
// The metadata describes the keys a grant service checks, so it's served only with one.
|
||||
if (opts.oauth && !service) throw new McpHttpConfigError("--oauth-resource and --oauth-issuer need --grant-service");
|
||||
const discovery = opts.oauth ? oauthDiscovery(opts.oauth) : undefined;
|
||||
const configured = service !== undefined || readGrants(opts.tokenFile) !== null;
|
||||
const configured = readGrants(opts.tokenFile) !== null;
|
||||
if (!loopback && !configured) {
|
||||
throw new McpHttpConfigError(`Serving on ${opts.host} needs authentication: add a grant with rig mcp token add, then start again. With no grants, the server stays on loopback.`);
|
||||
}
|
||||
const makeClient = opts.clientFactory ?? ((url: string, sessionName?: string, principal?: McpPrincipal) =>
|
||||
new DaemonClient(url, sessionName === undefined ? undefined : { sessionName, ...(principal ? { principal } : {}) }));
|
||||
const makeClient = opts.clientFactory ?? ((url: string, sessionName?: string) => new DaemonClient(url, sessionName === undefined ? undefined : { sessionName }));
|
||||
let boundPort = opts.port;
|
||||
// A token-file grant's last call, for rig_team's presence. An external caller isn't a team member, so none is kept.
|
||||
const presenceFile = nodePath.join(nodePath.dirname(opts.tokenFile), MCP_PRESENCE_FILE);
|
||||
const recordCall = presenceRecorder(presenceFile, opts.presenceIntervalMs);
|
||||
// Once auth has been configured here, a missing token file is a broken configuration, not a return to local-owner
|
||||
// mode: that takes a restart with no file. A beyond-loopback listener always starts with auth.
|
||||
let authSeen = configured;
|
||||
// The instance a grant key must be bound to: the self id of the daemon whose seats this server acts on, read from that
|
||||
// daemon, never from a caller. Kept once known.
|
||||
let instance: string | undefined;
|
||||
const instanceId = async (): Promise<string | undefined> => {
|
||||
if (instance) return instance;
|
||||
try {
|
||||
const res = await fetch(`${opts.daemonUrl.replace(/\/+$/, "")}/healthz`, { signal: AbortSignal.timeout(2_000) });
|
||||
const self = res.ok ? ((await res.json()) as { selfHostId?: unknown }).selfHostId : undefined;
|
||||
if (typeof self === "string" && INSTANCE_ID.test(self)) instance = self;
|
||||
} catch { /* Unknown: refused below, and asked again on the next request. */ }
|
||||
return instance;
|
||||
};
|
||||
// A 401 names the realm. With OAuth discovery it also points at the metadata, and says invalid_token when a bearer
|
||||
// credential was presented and refused (RFC 6750 §3); a request with none gets no error.
|
||||
const refuse = (res: http.ServerResponse, refusal: Refusal, presented = false): void => {
|
||||
// The detail stays in this server's log: the reply goes to a caller not yet authenticated.
|
||||
if (refusal.log) console.error(`rig mcp serve --http: ${refusal.log}`);
|
||||
const challenge = !discovery ? 'Bearer realm="openrig"' : `${discovery.challenge}${presented ? ', error="invalid_token"' : ""}`;
|
||||
reply(res, refusal.status, { error: refusal.error, message: refusal.message }, refusal.status === 401 ? { "WWW-Authenticate": challenge } : {});
|
||||
};
|
||||
// One handler for both protocol revisions: a request carrying 2026-07-28's per-request envelope is served as that
|
||||
// revision, anything else (initialize and the rest of 2025) statelessly as before. Each request gets a fresh server for
|
||||
// the caller decided below, handed over as the request's authInfo.
|
||||
const mcpHandler = createMcpHandler(({ authInfo }) => {
|
||||
const caller = (authInfo?.extra as { caller?: RequestCaller } | undefined)?.caller;
|
||||
// Never a default seat: a request reaches the handler only with its caller decided.
|
||||
if (!caller) throw new Error("a request reached the MCP handler without its caller");
|
||||
return createMcpServer(makeClient(opts.daemonUrl, caller.seat, caller.principal), { seat: caller.seat, access: caller.access, presenceFile: () => presenceFile });
|
||||
});
|
||||
const serveMcp = toNodeHandler(mcpHandler);
|
||||
const bearer = (req: http.IncomingMessage): string | undefined => /^Bearer\s+(\S+)\s*$/i.exec(req.headers.authorization ?? "")?.[1];
|
||||
const NO_BEARER = "This server needs a bearer credential: Authorization: Bearer <token>.";
|
||||
|
||||
/** The caller of one request on a server with a grant service: its key's grant, checked there, for a seat on this
|
||||
* instance. */
|
||||
const serviceRequestCaller = async (req: http.IncomingMessage): Promise<{ seat: string; access: McpAccess; principal: McpPrincipal } | Refusal> => {
|
||||
const key = bearer(req);
|
||||
if (!key) return unauthenticated(NO_BEARER);
|
||||
if (!GRANT_KEY.test(key)) return unauthenticated(NOT_VALID_HERE);
|
||||
const self = await instanceId();
|
||||
if (!self) return unavailable("this daemon reports no self id, so a grant key can't be checked against this instance");
|
||||
const caller = await grantServiceCaller(service!, serviceUrl!, key, self);
|
||||
if (!("seat" in caller)) return caller;
|
||||
// The grant service names the seat; it must already exist here. It's checked, never created.
|
||||
try {
|
||||
const res = await fetch(`${opts.daemonUrl.replace(/\/+$/, "")}/api/whoami?${new URLSearchParams({ sessionName: caller.seat, compact: "1" }).toString()}`,
|
||||
{ signal: AbortSignal.timeout(5_000) });
|
||||
if (res.status === 404) {
|
||||
// whoami knows a seat by its sessions, and a seat created without a launch has none yet: it's then this daemon's
|
||||
// node of exactly that name, or it isn't here.
|
||||
const node = await nodeSeat(makeClient(opts.daemonUrl), caller.seat);
|
||||
if (node === "absent") return { status: 503, error: "seat_unavailable", message: "This connection's seat isn't on this instance yet.", log: `The grant service named seat ${caller.seat}, which this daemon doesn't have` };
|
||||
if (node !== "found") return node;
|
||||
} else if (!res.ok) return unavailable(`the seat check for ${caller.seat} answered HTTP ${res.status}`);
|
||||
} catch (err) {
|
||||
return unavailable(`the seat check for ${caller.seat} failed: ${(err as Error).message}`);
|
||||
}
|
||||
return caller;
|
||||
};
|
||||
|
||||
const handle = async (req: http.IncomingMessage, res: http.ServerResponse): Promise<void> => {
|
||||
const { pathname } = new URL(req.url ?? "/", "http://localhost");
|
||||
// The OAuth metadata is static and public: served before any credential or grant-service call, whatever the Host.
|
||||
if (discovery && req.method === "GET" && pathname === discovery.path) {
|
||||
reply(res, 200, discovery.document);
|
||||
return;
|
||||
}
|
||||
if (pathname !== "/mcp") {
|
||||
if (new URL(req.url ?? "/", "http://localhost").pathname !== "/mcp") {
|
||||
reply(res, 404, { error: "not_found", message: "The MCP endpoint is /mcp." });
|
||||
return;
|
||||
}
|
||||
let grants: McpGrant[] | null;
|
||||
try {
|
||||
grants = readGrants(opts.tokenFile);
|
||||
if (grants === null && authSeen) throw new Error(`${opts.tokenFile} is gone after auth was configured; restore it, or restart with no token file to serve loopback unauthenticated`);
|
||||
} catch (err) {
|
||||
// The detail stays in this server's log: the reply goes to a caller not yet authenticated.
|
||||
console.error(`rig mcp serve --http: the token file can't be read: ${(err as Error).message}`);
|
||||
reply(res, 500, { error: "auth_config_unreadable", message: "The token file can't be read." });
|
||||
return;
|
||||
}
|
||||
if (grants !== null) authSeen = true;
|
||||
let access: McpAccess | undefined;
|
||||
let seat: string | undefined;
|
||||
let principal: McpPrincipal | undefined;
|
||||
let authenticated = false;
|
||||
if (service) {
|
||||
const caller = await serviceRequestCaller(req);
|
||||
if (!("seat" in caller)) { refuse(res, caller, bearer(req) !== undefined); return; }
|
||||
({ access, seat, principal } = caller);
|
||||
authenticated = true;
|
||||
} else {
|
||||
let grants: McpGrant[] | null;
|
||||
try {
|
||||
grants = readGrants(opts.tokenFile);
|
||||
if (grants === null && authSeen) throw new Error(`${opts.tokenFile} is gone after auth was configured; restore it, or restart with no token file to serve loopback unauthenticated`);
|
||||
} catch (err) {
|
||||
// The detail stays in this server's log: the reply goes to a caller not yet authenticated.
|
||||
console.error(`rig mcp serve --http: the token file can't be read: ${(err as Error).message}`);
|
||||
reply(res, 500, { error: "auth_config_unreadable", message: "The token file can't be read." });
|
||||
if (grants !== null) {
|
||||
const token = /^Bearer\s+(\S+)\s*$/i.exec(req.headers.authorization ?? "")?.[1];
|
||||
const grant = token ? grantFor(grants, token) : null;
|
||||
if (!grant) {
|
||||
reply(res, 401, {
|
||||
error: "unauthenticated",
|
||||
message: token ? "This credential isn't valid here: it's unknown or was revoked." : "This server needs a bearer credential: Authorization: Bearer <token>.",
|
||||
}, { "WWW-Authenticate": 'Bearer realm="openrig"' });
|
||||
return;
|
||||
}
|
||||
if (grants !== null) authSeen = true;
|
||||
if (grants !== null) {
|
||||
const token = bearer(req);
|
||||
const grant = token ? grantFor(grants, token) : null;
|
||||
if (!grant) {
|
||||
refuse(res, unauthenticated(token ? NOT_VALID_HERE : NO_BEARER));
|
||||
return;
|
||||
}
|
||||
access = { principal: grant.name, owner: grant.owner === true, rigs: grant.rigs ?? [], roots: grant.roots ?? [], workspace: grant.workspace === true };
|
||||
seat = grant.seat;
|
||||
// A token-file grant has a name and nothing more: no grant id or version is invented for it.
|
||||
principal = { source: "file", id: grant.name };
|
||||
authenticated = true;
|
||||
recordCall(grant.seat, grant.name);
|
||||
}
|
||||
access = { principal: grant.name, owner: grant.owner === true, rigs: grant.rigs ?? [], roots: grant.roots ?? [], workspace: grant.workspace === true };
|
||||
seat = grant.seat;
|
||||
}
|
||||
let body: unknown;
|
||||
if (req.method === "POST") {
|
||||
@@ -472,16 +218,16 @@ export async function startMcpHttpServer(opts: McpHttpOptions): Promise<{ url: s
|
||||
return;
|
||||
}
|
||||
}
|
||||
const mcp = createMcpServer(makeClient(opts.daemonUrl, seat), { seat, access });
|
||||
// With no auth (loopback only), refuse a Host that isn't loopback, as a page reaching localhost through DNS rebinding
|
||||
// or a forward from elsewhere would send. With auth, the credential is the check, so a tunnel's Host is fine. The SDK's
|
||||
// handler checks no Host, so this is the refusal its 1.x transport gave.
|
||||
if (!authenticated && ![`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`].includes(req.headers.host ?? "")) {
|
||||
reply(res, 403, { jsonrpc: "2.0", error: { code: -32000, message: `Invalid Host header: ${req.headers.host}` }, id: null });
|
||||
return;
|
||||
}
|
||||
const caller: RequestCaller = { seat, access, principal };
|
||||
(req as http.IncomingMessage & { auth?: AuthInfo }).auth = { token: "", clientId: principal?.id ?? "local-owner", scopes: [], extra: { caller } };
|
||||
await serveMcp(req, res, body);
|
||||
// or a forward from elsewhere would send. With auth, the credential is the check, so a tunnel's Host is fine.
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
...(grants === null ? { enableDnsRebindingProtection: true, allowedHosts: [`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`] } : {}),
|
||||
});
|
||||
res.on("close", () => { void transport.close(); void mcp.close(); });
|
||||
await mcp.connect(transport);
|
||||
await transport.handleRequest(req, res, body);
|
||||
};
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
@@ -498,9 +244,6 @@ export async function startMcpHttpServer(opts: McpHttpOptions): Promise<{ url: s
|
||||
return {
|
||||
url: `http://${shownHost}:${boundPort}/mcp`,
|
||||
authenticated: configured,
|
||||
close: async () => {
|
||||
await new Promise<void>((resolve) => { server.closeAllConnections?.(); server.close(() => resolve()); });
|
||||
await mcpHandler.close();
|
||||
},
|
||||
close: () => new Promise<void>((resolve) => { server.closeAllConnections?.(); server.close(() => resolve()); }),
|
||||
};
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// A hosted member's last call: `rig mcp serve --http` records when each token-file grant's seat last called, and rig_team
|
||||
// reads it. It's presence, a fact about the member, never pickup (a claim on a row) and never "idle".
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import { getOpenRigHome } from "./openrig-compat.js";
|
||||
|
||||
/** The file, beside the token file and never in it (that stays auth config, as rig mcp token writes it). */
|
||||
export const MCP_PRESENCE_FILE = "mcp-http-presence.json";
|
||||
|
||||
export function mcpPresenceFilePath(home: string = getOpenRigHome()): string {
|
||||
return nodePath.join(home, MCP_PRESENCE_FILE);
|
||||
}
|
||||
|
||||
/** A seat's last authenticated call to this host's MCP server, and the grant that made it. */
|
||||
export type McpCallPresence = { grant: string; lastCallAt: string };
|
||||
|
||||
/** Each seat's last recorded call. None recorded, or a file that can't be read, is an empty map: unknown, never idle. */
|
||||
export function readPresence(file: string): Record<string, McpCallPresence> {
|
||||
try {
|
||||
const seats = (JSON.parse(fs.readFileSync(file, "utf8")) as { seats?: unknown } | null)?.seats;
|
||||
if (!seats || typeof seats !== "object" || Array.isArray(seats)) return {};
|
||||
const out: Record<string, McpCallPresence> = {};
|
||||
for (const [seat, value] of Object.entries(seats as Record<string, unknown>)) {
|
||||
const entry = (value ?? {}) as Record<string, unknown>;
|
||||
if (typeof entry.grant === "string" && typeof entry.lastCallAt === "string") out[seat] = { grant: entry.grant, lastCallAt: entry.lastCallAt };
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Records a seat's call at most once per interval for this server, by atomic rename (several servers: the last writer
|
||||
* wins). Presence is a convenience: a write that fails is logged here and never refuses the caller. */
|
||||
export function presenceRecorder(file: string, intervalMs = 60_000, now: () => number = Date.now): (seat: string, grant: string) => void {
|
||||
const lastWrite = new Map<string, number>();
|
||||
return (seat, grant) => {
|
||||
const at = now();
|
||||
if (at - (lastWrite.get(seat) ?? Number.NEGATIVE_INFINITY) < intervalMs) return;
|
||||
lastWrite.set(seat, at);
|
||||
try {
|
||||
const seats = readPresence(file);
|
||||
seats[seat] = { grant, lastCallAt: new Date(at).toISOString() };
|
||||
const temp = `${file}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temp, `${JSON.stringify({ version: 1, seats }, null, 2)}\n`, { mode: 0o600 });
|
||||
fs.renameSync(temp, file);
|
||||
} catch (err) {
|
||||
console.error(`rig mcp serve --http: couldn't record ${seat}'s call: ${(err as Error).message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
+258
-632
File diff suppressed because it is too large.
Load diff
@@ -55,12 +55,12 @@ function warnOnce(key: string, message: string): void {
|
||||
console.warn(message);
|
||||
}
|
||||
|
||||
export function readOpenRigEnv(primary: string, legacy?: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
||||
const primaryValue = env[primary];
|
||||
export function readOpenRigEnv(primary: string, legacy?: string): string | undefined {
|
||||
const primaryValue = process.env[primary];
|
||||
if (primaryValue !== undefined && primaryValue !== "") return primaryValue;
|
||||
|
||||
if (legacy) {
|
||||
const legacyValue = env[legacy];
|
||||
const legacyValue = process.env[legacy];
|
||||
if (legacyValue !== undefined && legacyValue !== "") {
|
||||
warnOnce(`env:${legacy}`, `Warning: ${legacy} is deprecated; use ${primary} instead.`);
|
||||
return legacyValue;
|
||||
@@ -70,23 +70,6 @@ export function readOpenRigEnv(primary: string, legacy?: string, env: NodeJS.Pro
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** An explicit daemon URL with trailing slashes removed, so `http://h:7433/` addresses the
|
||||
* same daemon as `http://h:7433`. A URL with a query or fragment is returned exactly as
|
||||
* written, and so is a value that would strip down to no host (such as `/`), which stays
|
||||
* an explicit (if invalid) target. */
|
||||
export function normalizeOpenRigUrl(raw: string): string {
|
||||
if (/[?#]/.test(raw)) return raw;
|
||||
const stripped = raw.replace(/\/+$/, "");
|
||||
return /:\/\/[^/]/.test(stripped) ? stripped : raw;
|
||||
}
|
||||
|
||||
/** OPENRIG_URL (legacy RIGGED_URL), normalized by normalizeOpenRigUrl. `env` defaults to
|
||||
* process.env; callers with an injected environment pass theirs. */
|
||||
export function readOpenRigUrl(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
||||
const raw = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL", env);
|
||||
return raw === undefined ? undefined : normalizeOpenRigUrl(raw);
|
||||
}
|
||||
|
||||
export function getPreferredOpenRigHome(): string {
|
||||
const openrigHome = getOpenRigHome();
|
||||
const legacyRiggedHome = getLegacyRiggedHome();
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
// with 13.1's deterministic half. 13.3 (the dispatcher that WRITES skill
|
||||
// updates) decides invocation; this stays invocation-agnostic.
|
||||
//
|
||||
// Join grammar (reviewed):
|
||||
// Join grammar - ADVISOR-RATIFIED (qitem-20260608235706-64ba9ef2; see the slice
|
||||
// governance-watch.md SHARP WATCH #1):
|
||||
// - Skill tokens are stored WITHOUT the `rig ` prefix, in 13.1's command-path
|
||||
// grammar (`scope slice create`, `queue create`). We strip a leading `rig `
|
||||
// defensively so a skill written either way still matches.
|
||||
|
||||
@@ -26,14 +26,14 @@ export interface RemoteOpResult {
|
||||
outcomeUnknown?: true;
|
||||
}
|
||||
|
||||
// Connection failures that prove the request never reached the target daemon. Codes the kernel can also report on a
|
||||
// Connection failures that prove the request never reached the remote daemon. Codes the kernel can also report on a
|
||||
// socket that already sent the request (route loss, an interface going down, a firewall change mid-flow: EHOSTUNREACH,
|
||||
// ENETUNREACH, EHOSTDOWN, ENETDOWN, EPERM, EACCES) are left out, so they read as an unknown outcome.
|
||||
const NOT_CONNECTED_CODES = new Set([
|
||||
"ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "EADDRNOTAVAIL", "UND_ERR_CONNECT_TIMEOUT", "ERR_INVALID_URL",
|
||||
]);
|
||||
|
||||
export function requestMayHaveArrived(err: unknown): boolean {
|
||||
function requestMayHaveArrived(err: unknown): boolean {
|
||||
if (err instanceof DaemonTimeoutError) return true;
|
||||
if (err instanceof DaemonConnectionError) return err.causeCode === undefined || !NOT_CONNECTED_CODES.has(err.causeCode);
|
||||
return true;
|
||||
|
||||
@@ -64,7 +64,7 @@ function firstLine(text: string): string {
|
||||
* message text but its file references stay in the touched-files
|
||||
* inventory).
|
||||
*
|
||||
* R2 fix: inspect
|
||||
* R2 fix (per M2b R2 dispatch qitem-20260502011841-fb53d7fd): inspect
|
||||
* nested content parts BEFORE discarding non-text. Previously dropped
|
||||
* tool_use / tool_result silently, leading to misleading all-zero
|
||||
* omittedCounts on real Claude transcripts where nearly every assistant
|
||||
|
||||
@@ -24,12 +24,6 @@ export type HandleResolution =
|
||||
// name posted this way cannot reach the wrong rig.
|
||||
| { kind: "passthrough"; handle: string };
|
||||
|
||||
/** A rig list entry as the daemon writes it: a string id and name, and archivedAt a string or null. */
|
||||
function isSummaryEntry(entry: unknown): boolean {
|
||||
const rig = (entry ?? {}) as Record<string, unknown>;
|
||||
return typeof rig.id === "string" && typeof rig.name === "string" && (rig.archivedAt === null || typeof rig.archivedAt === "string");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a rig handle (rig name OR id) to a concrete rig id, mirroring the
|
||||
* `/api/rigs/summary` path `rig up` uses. Resolution is a PRE-STEP: each
|
||||
@@ -55,9 +49,7 @@ export async function resolveRigHandle(
|
||||
client: DaemonClient,
|
||||
handle: string,
|
||||
// `archived: true` matches names among ARCHIVED rigs instead (for `rig unarchive`, whose target is archived).
|
||||
// `strict: true` reads a rig list with any malformed entry as unavailable (passthrough), for a caller that must not
|
||||
// count an incomplete list as an answer.
|
||||
opts: { archived?: boolean; strict?: boolean } = {},
|
||||
opts: { archived?: boolean } = {},
|
||||
): Promise<HandleResolution> {
|
||||
let summaries: RigSummaryEntry[];
|
||||
try {
|
||||
@@ -67,7 +59,6 @@ export async function resolveRigHandle(
|
||||
if (res.status !== 200 || !Array.isArray(res.data)) {
|
||||
return { kind: "passthrough", handle };
|
||||
}
|
||||
if (opts.strict && !res.data.every(isSummaryEntry)) return { kind: "passthrough", handle };
|
||||
summaries = res.data;
|
||||
} catch {
|
||||
return { kind: "passthrough", handle };
|
||||
|
||||
@@ -4,11 +4,11 @@ import { homedir, hostname, networkInterfaces } from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promisify } from "node:util";
|
||||
import { terminalAuthHeaders, type DaemonClient } from "./client.js";
|
||||
import type { DaemonClient } from "./client.js";
|
||||
import { shellQuote } from "./cross-host-executor.js";
|
||||
import { prepareHerdrLaunchConfig } from "./herdr-launch-config.js";
|
||||
import { openRexView } from "./terminal-window-rex.js";
|
||||
import type { FollowControls, OpenViewResult } from "./commands/terminal.js";
|
||||
import type { OpenViewResult } from "./commands/terminal.js";
|
||||
|
||||
interface Pane { seat: string; label: string; paneCommand: string; recoveryCommand?: string }
|
||||
interface HerdrTab { workspace_id: string; tab_id: string; label: string }
|
||||
@@ -226,8 +226,7 @@ async function hasLiveAttachments(existing: HerdrTab, tabs: HerdrTab[], pages: P
|
||||
if (!pid || !/^\d+$/.test(pid) || !session || !["0", "1"].includes(readOnly ?? "")) continue;
|
||||
// Compare the composer's exact command, including session aliases and read-only mode.
|
||||
// SSH/custom commands cannot be confirmed from this local client inventory.
|
||||
const flags = readOnly === "1" ? ["-r "] : ["", "-f ignore-size "];
|
||||
clients.set(pid, ["tmux", quote(tmux)].flatMap(binary => flags.map(flag => `${binary} attach ${flag}-t ${quote(session)}`)));
|
||||
clients.set(pid, ["tmux", quote(tmux)].map(binary => `${binary} attach ${readOnly === "1" ? "-r " : ""}-t ${quote(session)}`));
|
||||
}
|
||||
const used = new Set<string>();
|
||||
for (const [index, page] of pages.entries()) {
|
||||
@@ -260,12 +259,12 @@ async function hasLiveAttachments(existing: HerdrTab, tabs: HerdrTab[], pages: P
|
||||
}
|
||||
|
||||
/** Render the daemon's existing composition; never rediscover/relaunch kernel seats here. */
|
||||
export async function openTerminalWindow(client: DaemonClient, view: string, requestedProvider?: string, deps = defaultWindowDeps(), expectedPlan?: string, requestedSession?: string, following?: FollowControls): Promise<OpenViewResult> {
|
||||
export async function openTerminalWindow(client: DaemonClient, view: string, requestedProvider?: string, deps = defaultWindowDeps(), expectedPlan?: string, requestedSession?: string): Promise<OpenViewResult> {
|
||||
const session = requestedSession?.trim();
|
||||
if (!following && session === undefined && (requestedProvider === "rex" || (requestedProvider === undefined && inRex(deps.env)))) {
|
||||
if (session === undefined && (requestedProvider === "rex" || (requestedProvider === undefined && inRex(deps.env)))) {
|
||||
return openRexView(client, view, deps, expectedPlan);
|
||||
}
|
||||
const selectedProvider = requestedProvider ?? (session !== undefined || following ? "herdr" : undefined);
|
||||
const selectedProvider = requestedProvider ?? (session !== undefined ? "herdr" : undefined);
|
||||
const sessionFlag = session === undefined ? "" : ` --session ${shellQuote(session)}`;
|
||||
const sessionQuery = session === undefined ? "" : `&session=${encodeURIComponent(session)}`;
|
||||
let provider = selectedProvider ?? "herdr";
|
||||
@@ -283,7 +282,6 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
|
||||
const windowNotes: string[] = [];
|
||||
try {
|
||||
if (!localDaemon(client.baseUrl)) throw new Error("The window launcher must run on the daemon's own desktop; the configured daemon is remote.");
|
||||
if (following && selectedProvider !== "herdr") throw new Error("Following requires the qualified Herdr provider.");
|
||||
if (session !== undefined && selectedProvider !== "herdr") throw new Error("--session selects a Herdr session; use --provider herdr.");
|
||||
if (selectedProvider && !["herdr", "tmux"].includes(selectedProvider)) throw new Error("--window supports herdr, rex or tmux. Use cmux without --window.");
|
||||
const launchWindow = await windowLauncher(deps, windowNotes);
|
||||
@@ -308,7 +306,7 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
|
||||
if (expectedPlan !== undefined && expectedPlan !== planId) {
|
||||
return failure(provider, "The terminal view changed since preview. Refresh the preview before opening.");
|
||||
}
|
||||
if (!composed.opened.length && !following) return { ...failed("No conversations are attachable."), absent: composed.absent, degraded: composed.degraded };
|
||||
if (!composed.opened.length) return { ...failed("No conversations are attachable."), absent: composed.absent, degraded: composed.degraded };
|
||||
const measureWindow = async () => {
|
||||
if (window && "columns" in window && window.columns !== viewportColumns && composed.kernelLayout) {
|
||||
viewportColumns = window.columns;
|
||||
@@ -394,26 +392,6 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
|
||||
// Inventory is read-only: a failed read must not prevent the first open.
|
||||
// Scope each tab list to its workspace, on the daemon's exact endpoint.
|
||||
const herdrArgs = ["-u", "TMUX", "-u", "HERDR_SESSION", "-u", "HERDR_SOCKET_PATH", `HERDR_SOCKET_PATH=${endpoint.socketPath}`, herdr];
|
||||
if (following) {
|
||||
// Follow has a stable journaled workspace. A changed membership plan
|
||||
// must never enter the one-shot plan-label/new-workspace path below.
|
||||
const answer = await client.post<OpenViewResult>("/api/terminal/follow", { view, provider: "herdr", ...following,
|
||||
...(session !== undefined ? { session } : {}), ...(expectedPlan !== undefined ? { expectedPlan } : {}) },
|
||||
{ timeoutMs: 45_000, headers: terminalAuthHeaders() });
|
||||
if (!answer.data.ok || !answer.data.workspaceId) return { ...answer.data, window, notes: [...(answer.data.notes ?? []), ...windowNotes] };
|
||||
const selectedArgs = herdrArgs;
|
||||
const selectedStatus = JSON.parse(await deps.exec("/usr/bin/env", [...selectedArgs, "status", "server", "--json"]));
|
||||
const selectedBody = selectedStatus?.result ?? selectedStatus;
|
||||
if (selectedStatus.error || selectedBody?.running !== true || selectedBody.socket !== endpoint.socketPath) {
|
||||
throw new Error("The Herdr CLI did not confirm the daemon's exact following endpoint; no tab lookup or focus was requested.");
|
||||
}
|
||||
const listed = JSON.parse(await deps.exec("/usr/bin/env", [...selectedArgs, "tab", "list", "--workspace", answer.data.workspaceId]));
|
||||
const tab = (listed?.result?.tabs as HerdrTab[] | undefined)?.find(t => t.workspace_id === answer.data.workspaceId);
|
||||
if (!tab) throw new Error("The stable following workspace has no confirmed tab. Inspect it before retrying.");
|
||||
const focused = JSON.parse(await deps.exec("/usr/bin/env", [...selectedArgs, "tab", "focus", tab.tab_id]));
|
||||
if (focused?.result?.tab?.tab_id !== tab.tab_id || focused.result.tab.workspace_id !== tab.workspace_id) throw new Error("Following tab selection was not confirmed.");
|
||||
return { ...answer.data, window, notes: [...(answer.data.notes ?? []), ...windowNotes] };
|
||||
}
|
||||
const closeCommand = (id: string) => `${session === undefined ? "herdr" : `env -u HERDR_SESSION HERDR_SOCKET_PATH=${shellQuote(endpoint.socketPath)} ${shellQuote(herdr)}`} workspace close ${shellQuote(id)}`;
|
||||
let tabs: HerdrTab[] = [];
|
||||
const spaceLabels = new Map<string, string>();
|
||||
|
||||
@@ -25,45 +25,6 @@ export function buildTmuxControlFailure(message: string): { message: string; rea
|
||||
};
|
||||
}
|
||||
|
||||
export type TmuxCaptureResult = { status: "pass" } | { status: "fail"; detail: string } | { status: "skipped"; detail: string };
|
||||
|
||||
/** Each probe command's bound: a tmux that hangs instead of crashing must not hang the check that diagnoses it. */
|
||||
export const TMUX_CAPTURE_PROBE_TIMEOUT_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Whether this tmux can serve `capture-pane -p`, which OpenRig uses to read every agent's screen. A tmux whose server exits
|
||||
* on it fails every seat at launch (#980: Oracle Linux 10's packaged `next-3.4`, also outside OpenRig). It runs the
|
||||
* getting-started guide's check on a throwaway socket with no config, so the person's own sessions are never touched.
|
||||
* Every command is bounded, and the throwaway server always gets a cleanup attempt, even after a create that failed or
|
||||
* timed out (it may have started anyway). A throwaway server that won't start is reported as not checked, not a failure.
|
||||
*/
|
||||
export function probeTmuxCapture(exec: (cmd: string, opts: { timeoutMs: number }) => string,
|
||||
socket = `openrig-check-${process.pid}`): TmuxCaptureResult {
|
||||
const run = (args: string) => exec(`tmux -L ${socket} -f /dev/null ${args}`, { timeoutMs: TMUX_CAPTURE_PROBE_TIMEOUT_MS });
|
||||
try {
|
||||
try { run("new-session -d -s check"); }
|
||||
catch (err) { return { status: "skipped", detail: errorMessage(err).trim() }; }
|
||||
try {
|
||||
run("capture-pane -p -t check");
|
||||
return { status: "pass" };
|
||||
} catch (err) {
|
||||
return { status: "fail", detail: errorMessage(err).trim() };
|
||||
}
|
||||
} finally {
|
||||
try { run("kill-server"); } catch { /* no throwaway server is left, or it already exited */ }
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTmuxCaptureFailure(version: string | null, detail: string): { message: string; reason: string; fix: string } {
|
||||
return {
|
||||
message: `This tmux (${version ?? "unknown version"}) can't capture a pane: \`capture-pane -p\` failed on a throwaway server (${detail}).`,
|
||||
reason: "OpenRig reads each agent's screen with `capture-pane -p`. A tmux whose server exits on it makes every seat fail at launch. " +
|
||||
"Oracle Linux 10's packaged tmux (`tmux -V` shows `next-3.4`) does this, also outside OpenRig (issue #980).",
|
||||
fix: "Put a tmux that passes this check first on your PATH (one report built tmux 3.5a into /usr/local/bin), then restart the daemon " +
|
||||
"from a shell where `tmux -V` shows it. The getting-started guide's \"When the kernel's seats all fail at the first start\" has the steps.",
|
||||
};
|
||||
}
|
||||
|
||||
export function probeTmuxControl(exec: (cmd: string) => string): TmuxProbeResult {
|
||||
let version: string;
|
||||
try {
|
||||
|
||||
@@ -31,10 +31,7 @@ import type { TmuxAdapter } from "../../daemon/src/adapters/tmux.js";
|
||||
|
||||
vi.mock("node:child_process", async (actual) => ({ ...await actual<typeof import("node:child_process")>(), execSync: (cmd: string) => {
|
||||
if (!cmd.startsWith("tmux display-message ")) throw new Error(`Unexpected shell command in inert fixture: ${cmd}`);
|
||||
// Render the requested format as real tmux does, which writes a control character as "_".
|
||||
const format = /'([^']*)'\s*$/.exec(cmd)?.[1] ?? /"([^"]*)"\s*$/.exec(cmd)?.[1] ?? "";
|
||||
return `${format.replace(/#\{session_name\}/g, process.env.TEST_PHYSICAL_SESSION ?? "").replace(/#\{window_id\}/g, "@12")
|
||||
.replace(/#\{pane_id\}/g, "%42").replace(/[\u0000-\u001f]/g, "_")}\n`;
|
||||
return `${process.env.TEST_PHYSICAL_SESSION}\n@12\n%42\n`;
|
||||
} }));
|
||||
|
||||
it.each(["0", "person-terminal"])("real attach -> returned env -> actual From -> reply to original %s pane", async (physical) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import http from "node:http";
|
||||
import { execFileSync, execSync, spawnSync } from "node:child_process";
|
||||
import { Command } from "commander";
|
||||
import { attachCommand, resolveAttachContext } from "../src/commands/attach.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
@@ -171,8 +170,15 @@ describe("Attach CLI", () => {
|
||||
});
|
||||
|
||||
it("resolveAttachContext detects tmux session metadata when TMUX_PANE is set", () => {
|
||||
const result = withTmuxPane("%42", () =>
|
||||
resolveAttachContext(fakeTmux({ session: "dev1-impl2@rigged-buildout", window: "@12", pane: "%42" })));
|
||||
const savedPane = process.env["TMUX_PANE"];
|
||||
process.env["TMUX_PANE"] = "%42";
|
||||
const mockTmuxExec = vi.fn(() => "dev1-impl2@rigged-buildout\n@12\n%42");
|
||||
|
||||
const result = resolveAttachContext(mockTmuxExec);
|
||||
|
||||
if (savedPane === undefined) delete process.env["TMUX_PANE"];
|
||||
else process.env["TMUX_PANE"] = savedPane;
|
||||
|
||||
expect(result).toEqual({
|
||||
attachmentType: "tmux",
|
||||
tmuxSession: "dev1-impl2@rigged-buildout",
|
||||
@@ -180,65 +186,4 @@ describe("Attach CLI", () => {
|
||||
tmuxPane: "%42",
|
||||
});
|
||||
});
|
||||
|
||||
// Real tmux rewrote the newlines of a combined format as "_" ("unrelated-shell_@2_%2"), and the session fell back to
|
||||
// external_cli, so replies to the self-attached member had no transport.
|
||||
it.each([
|
||||
["the shape real tmux returned", "unrelated-shell"],
|
||||
["a name holding underscores, spaces and id-like words", "team_1 rig_b @7 %9"],
|
||||
])("resolveAttachContext resolves the pane from %s", (_label, session) => {
|
||||
const result = withTmuxPane("%2", () => resolveAttachContext(fakeTmux({ session, window: "@2", pane: "%2" })));
|
||||
expect(result).toEqual({ attachmentType: "tmux", tmuxSession: session, tmuxWindow: "@2", tmuxPane: "%2" });
|
||||
});
|
||||
|
||||
it("resolveAttachContext keeps external_cli outside tmux, and when tmux can't answer", () => {
|
||||
const notCalled = vi.fn(() => "");
|
||||
expect(withTmuxPane(undefined, () => resolveAttachContext(notCalled))).toEqual({ attachmentType: "external_cli" });
|
||||
expect(notCalled).not.toHaveBeenCalled();
|
||||
for (const tmux of [vi.fn(() => { throw new Error("no server running"); }), vi.fn(() => ""), vi.fn(() => "unexpected")]) {
|
||||
expect(withTmuxPane("%2", () => resolveAttachContext(tmux))).toEqual({ attachmentType: "external_cli" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const TMUX_SOCKET = `openrig-attach-test-${process.pid}`;
|
||||
const tmuxAvailable = spawnSync("tmux", ["-V"]).status === 0;
|
||||
|
||||
describe.runIf(tmuxAvailable)("resolveAttachContext against a real tmux server (private socket)", () => {
|
||||
afterAll(() => { spawnSync("tmux", ["-L", TMUX_SOCKET, "kill-server"]); });
|
||||
|
||||
it("resolves the session, window and pane of a session whose name holds spaces and underscores", () => {
|
||||
const session = "unrelated_shell b";
|
||||
execFileSync("tmux", ["-L", TMUX_SOCKET, "-f", "/dev/null", "new-session", "-d", "-s", session]);
|
||||
const read = (format: string) => execFileSync("tmux", ["-L", TMUX_SOCKET, "list-panes", "-t", `=${session}`, "-F", format],
|
||||
{ encoding: "utf8" }).trim();
|
||||
const pane = read("#{pane_id}");
|
||||
const window = read("#{window_id}");
|
||||
const onSocket = (cmd: string) => execSync(cmd.replace(/^tmux /, `tmux -L ${TMUX_SOCKET} `), { encoding: "utf8" }).trim();
|
||||
expect(withTmuxPane(pane, () => resolveAttachContext(onSocket)))
|
||||
.toEqual({ attachmentType: "tmux", tmuxSession: session, tmuxWindow: window, tmuxPane: pane });
|
||||
});
|
||||
});
|
||||
|
||||
/** A `tmux display-message -p` double that renders the requested format as real tmux does, control characters
|
||||
* included: tmux writes each one as "_". */
|
||||
function fakeTmux(values: { session: string; window: string; pane: string }) {
|
||||
return vi.fn((cmd: string) => {
|
||||
const format = /'([^']*)'\s*$/.exec(cmd)?.[1] ?? /"([^"]*)"\s*$/.exec(cmd)?.[1];
|
||||
if (format === undefined) throw new Error(`unexpected tmux command: ${cmd}`);
|
||||
return format.replace(/#\{session_name\}/g, values.session).replace(/#\{window_id\}/g, values.window)
|
||||
.replace(/#\{pane_id\}/g, values.pane).replace(/[\u0000-\u001f]/g, "_");
|
||||
});
|
||||
}
|
||||
|
||||
function withTmuxPane<T>(pane: string | undefined, run: () => T): T {
|
||||
const saved = process.env["TMUX_PANE"];
|
||||
if (pane === undefined) delete process.env["TMUX_PANE"];
|
||||
else process.env["TMUX_PANE"] = pane;
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env["TMUX_PANE"];
|
||||
else process.env["TMUX_PANE"] = saved;
|
||||
}
|
||||
}
|
||||
@@ -85,8 +85,8 @@ describe("B8-2 — send transport honesty (indeterminate ≠ failed)", () => {
|
||||
expect(out).not.toMatch(/was not sent/i);
|
||||
});
|
||||
|
||||
it.each(["ECONNREFUSED", "ENOTFOUND"])("a coded %s keeps the hard 'not sent' truth", (code) => {
|
||||
printTransportFailureForTest(new DaemonConnectionError(`fetch failed: ${code}`, code));
|
||||
it("a connection REFUSAL keeps the hard 'not sent' truth", () => {
|
||||
printTransportFailureForTest(new DaemonConnectionError("fetch failed: ECONNREFUSED"));
|
||||
const out = errSpy2.mock.calls.map((c) => c.join(" ")).join("\n");
|
||||
expect(out).toMatch(/was not sent/i);
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ const SHORT = ["$ for i in 1 2 3 4 5", "line1", "line2", "line3", "line4", "line
|
||||
const BUSY_CLAUDE = ["● Reading the queue row", "", "✻ Waddling… (12s · ↓ 1.2k tokens · esc to interrupt)", "", "╭──────────────────╮", "│ > │", "╰──────────────────╯", " ? for shortcuts"].join("\n") + "\n";
|
||||
const PANES: Record<string, string> = { "tall@my-rig": TALL, "short@my-rig": SHORT, "busy@my-rig": BUSY_CLAUDE, "ghost@my-rig": "pane text\n", "empty@my-rig": "pane text\n", "small@my-rig": "Do you want to\n" };
|
||||
// What the daemon adds when the captured screen is at a prompt in a pane smaller than 80x24.
|
||||
const SMALL_PANE_HINT = "pane 18x12 is smaller than 80x24, so the prompt may be cut off; read it at a readable size with rig capture small@my-rig --readable";
|
||||
const SMALL_PANE_HINT = "pane 18x12 is smaller than 80x24, so the prompt may be cut off; resize it (tmux resize-window -t =small@my-rig: -x 120 -y 40) and capture again";
|
||||
const PROMPT_PANES: Record<string, unknown> = { "small@my-rig": { width: 18, height: 12, hint: SMALL_PANE_HINT } };
|
||||
// What the daemon adds for a Claude Code or Codex seat: the input box as the cursor and styling read it.
|
||||
const GHOST_HINT = "The input box shows \"/compact\", likely a Claude Code suggestion (cursor at the input's start; text drawn faint), not something the person typed: it isn't their intent, a draft or a staged command.";
|
||||
@@ -56,12 +56,7 @@ describe("Capture CLI", () => {
|
||||
res.end(JSON.stringify({ ok: true, sessionName: parsed.session, content: PANES[parsed.session], lines: parsed.lines, ...(INPUT_BOXES[parsed.session] ? { inputBox: INPUT_BOXES[parsed.session] } : {}), ...(PROMPT_PANES[parsed.session] ? { promptPane: PROMPT_PANES[parsed.session] } : {}) }));
|
||||
} else if (parsed.session) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
// --readable: the daemon says the size it read at, or why it left the window as it was.
|
||||
const readable = parsed.readable ? (parsed.session === "pinned@my-rig"
|
||||
? { resized: false, reason: "the window's size is pinned (its window-size is manual)" }
|
||||
: { resized: true, ...parsed.readable, release: parsed.session === "kept@my-rig" ? "kept-by-another-holder" : "given-back",
|
||||
...(parsed.session === "sizeback@my-rig" ? { sizeBack: "putting its size back failed: busy" } : {}) }) : undefined;
|
||||
res.end(JSON.stringify({ ok: true, sessionName: parsed.session, content: "line1\nline2\n", lines: 20, ...(readable ? { readable } : {}) }));
|
||||
res.end(JSON.stringify({ ok: true, sessionName: parsed.session, content: "line1\nline2\n", lines: 20 }));
|
||||
} else if (parsed.rig) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ results: [
|
||||
@@ -151,93 +146,11 @@ describe("Capture CLI", () => {
|
||||
expect(err).toEqual([]);
|
||||
});
|
||||
|
||||
it("a prompt in a pane too small to show it is named on stderr, with the daemon's hint", async () => {
|
||||
it("a prompt in a pane too small to show it is named on stderr, with the daemon's resize command", async () => {
|
||||
const { out, err } = await run(["small@my-rig"]);
|
||||
expect(out).toEqual(["Do you want to\n"]);
|
||||
expect(err).toEqual([`[rig capture: small@my-rig: ${SMALL_PANE_HINT}]`]);
|
||||
});
|
||||
|
||||
it("sends 20 lines for non-positive --lines values", async () => {
|
||||
for (const lines of ["0", "-1"]) {
|
||||
const before = requests.length;
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "capture", "tall@my-rig", `--lines=${lines}`]);
|
||||
});
|
||||
expect(requests.length).toBeGreaterThan(before);
|
||||
expect(requests[requests.length - 1]!.lines).toBe(20);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("--readable reads a small seat at a readable size", () => {
|
||||
async function run(argv: string[]): Promise<{ out: string[]; err: string[]; exitCode: number | undefined }> {
|
||||
const out: string[] = []; const err: string[] = [];
|
||||
const origLog = console.log; const origErr = console.error;
|
||||
console.log = (...args: unknown[]) => out.push(args.join(" ")); console.error = (...args: unknown[]) => err.push(args.join(" "));
|
||||
process.exitCode = undefined;
|
||||
try { await makeCmd().parseAsync(["node", "rig", "capture", ...argv]); } finally { console.log = origLog; console.error = origErr; }
|
||||
const exitCode = process.exitCode as number | undefined;
|
||||
process.exitCode = undefined;
|
||||
return { out, err, exitCode };
|
||||
}
|
||||
|
||||
it("asks for 120x40 and notes the size it was read at on stderr", async () => {
|
||||
const { out, err } = await run(["read@my-rig", "--readable"]);
|
||||
expect(requests.at(-1)!.readable).toEqual({ width: 120, height: 40 });
|
||||
expect(out).toEqual(["line1\nline2\n"]);
|
||||
expect(err).toEqual(["[rig capture: read@my-rig: read at 120x40; its window was then given back its size and its own window-size]"]);
|
||||
});
|
||||
|
||||
it("says so when a web terminal took the window over meanwhile, instead of claiming a give-back", async () => {
|
||||
const { err } = await run(["kept@my-rig", "--readable"]);
|
||||
expect(err).toEqual(["[rig capture: kept@my-rig: read at 120x40; a web terminal opened on it meanwhile and holds its window now; it gives the window back when it closes]"]);
|
||||
});
|
||||
|
||||
it("says when the size couldn't be put back, without claiming it was", async () => {
|
||||
const { err } = await run(["sizeback@my-rig", "--readable"]);
|
||||
expect(err).toEqual(["[rig capture: sizeback@my-rig: read at 120x40; its own window-size was given back, but putting its size back failed: busy]"]);
|
||||
});
|
||||
|
||||
it("a readable request waits out the daemon's readable budget; other captures keep the usual wait", async () => {
|
||||
const seen: Array<{ timeoutMs?: number } | undefined> = [];
|
||||
const deps = runningDeps(port);
|
||||
const spying = { ...deps, clientFactory: (url: string) => {
|
||||
const client = deps.clientFactory(url);
|
||||
const post = client.post.bind(client);
|
||||
client.post = ((path: string, body: unknown, options?: { timeoutMs?: number }) => { seen.push(options); return post(path, body, options as never); }) as typeof client.post;
|
||||
return client;
|
||||
} };
|
||||
const prog = new Command(); prog.exitOverride(); prog.addCommand(captureCommand(spying));
|
||||
await captureLogs(async () => { await prog.parseAsync(["node", "rig", "capture", "--rig", "my-rig", "--readable"]); });
|
||||
const plain = new Command(); plain.exitOverride(); plain.addCommand(captureCommand(spying));
|
||||
await captureLogs(async () => { await plain.parseAsync(["node", "rig", "capture", "--rig", "my-rig"]); });
|
||||
expect(seen.map((options) => options?.timeoutMs)).toEqual([15_000, undefined]);
|
||||
});
|
||||
|
||||
it("--size WxH asks for that size", async () => {
|
||||
await run(["read@my-rig", "--size", "160x50"]);
|
||||
expect(requests.at(-1)!.readable).toEqual({ width: 160, height: 50 });
|
||||
});
|
||||
|
||||
it("says why a window was left as it was", async () => {
|
||||
const { err } = await run(["pinned@my-rig", "--readable"]);
|
||||
expect(err).toEqual(["[rig capture: pinned@my-rig: not resized for reading: the window's size is pinned (its window-size is manual)]"]);
|
||||
});
|
||||
|
||||
it("a bad --size is refused before any request", async () => {
|
||||
const before = requests.length;
|
||||
for (const size of ["120", "0x40", "120x", "abcxdef", "2000x40"]) {
|
||||
const { err, exitCode } = await run(["read@my-rig", "--size", size]);
|
||||
expect(err[0]).toContain("--size wants WIDTHxHEIGHT");
|
||||
expect(exitCode).toBe(1);
|
||||
}
|
||||
expect(requests.length).toBe(before);
|
||||
});
|
||||
|
||||
it("a capture without it asks for nothing", async () => {
|
||||
await run(["read@my-rig"]);
|
||||
expect(requests.at(-1)).not.toHaveProperty("readable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("--lines N shows the last N lines (not N plus the visible pane)", () => {
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { chatroomWatchTmuxArgs } from "../src/commands/chatroom.js";
|
||||
|
||||
const sessionArg = (rig: string) => { const args = chatroomWatchTmuxArgs(rig); return args[args.indexOf("-s") + 1]; };
|
||||
|
||||
describe("chatroom watch --tmux arguments", () => {
|
||||
it("hands tmux the watcher as separate arguments, with the rig name after --, and asks for the name it used", () => {
|
||||
expect(chatroomWatchTmuxArgs("my rig")).toEqual(["new-session", "-d", "-P", "-F", "#{session_name}", "-s", "chatroom@my rig",
|
||||
"rig", "chatroom", "watch", "--", "my rig"]);
|
||||
});
|
||||
|
||||
// tmux reads -s as a format: `##` is one `#`, and a run of `#` before `[` is a style it keeps as written.
|
||||
it.each([
|
||||
["my-rig", "chatroom@my-rig"],
|
||||
["a#b", "chatroom@a##b"],
|
||||
["a##b", "chatroom@a####b"],
|
||||
["a#", "chatroom@a##"],
|
||||
["a#{b}", "chatroom@a##{b}"],
|
||||
["a#[fg=red]b", "chatroom@a#[fg=red]b"],
|
||||
["a##[b", "chatroom@a##[b"],
|
||||
])("encodes %j for tmux's session-name format as %j", (rig, session) => {
|
||||
expect(sessionArg(rig)).toBe(session);
|
||||
});
|
||||
|
||||
// tmux ends a command at an argument ending in `;` unless a `\` comes before it.
|
||||
it.each([
|
||||
["a;", "chatroom@a\\;", "a\\;"],
|
||||
[";", "chatroom@\\;", "\\;"],
|
||||
["a\\;", "chatroom@a\\\\;", "a\\\\;"],
|
||||
["a;b", "chatroom@a;b", "a;b"],
|
||||
])("puts a \\ before a final ; in %j", (rig, session, watcherArg) => {
|
||||
const args = chatroomWatchTmuxArgs(rig);
|
||||
expect(sessionArg(rig)).toBe(session);
|
||||
expect(args[args.length - 1]).toBe(watcherArg);
|
||||
});
|
||||
});
|
||||
|
||||
const TMUX_SOCKET = `openrig-chatroom-test-${process.pid}`;
|
||||
const tmuxAvailable = spawnSync("tmux", ["-V"]).status === 0;
|
||||
const names = ["my-rig", "my rig", "it's", 'say "hi"', "$(echo pwned)", "`id`", "a;b", "a$HOME", "-rig",
|
||||
"a#b", "a##b", "a#{b}", "a#[fg=red]b", "a##[b", "a;", ";"];
|
||||
|
||||
describe.runIf(tmuxAvailable)("chatroom watch --tmux against a real tmux server (private socket)", () => {
|
||||
let dir = "";
|
||||
let recorder = "";
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), "openrig-chatroom-watch-"));
|
||||
// Stands in for `rig`: records the arguments the watcher gets, then stays up so the session lives.
|
||||
recorder = path.join(dir, "record.cjs");
|
||||
writeFileSync(recorder, "require('node:fs').writeFileSync(process.argv[2], JSON.stringify(process.argv.slice(3)));\nsetTimeout(() => {}, 30000);\n");
|
||||
});
|
||||
afterAll(() => {
|
||||
spawnSync("tmux", ["-L", TMUX_SOCKET, "kill-server"]);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// The exact arguments, with only the `rig` program swapped for the recorder; tmux prints the session it made.
|
||||
function start(rig: string, out: string): string {
|
||||
const args = chatroomWatchTmuxArgs(rig);
|
||||
const program = args.indexOf("-s") + 2;
|
||||
return execFileSync("tmux", ["-L", TMUX_SOCKET, "-f", "/dev/null", ...args.slice(0, program), process.execPath, recorder, out,
|
||||
...args.slice(program + 1)], { encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
it.each(names.map((rig, i) => [rig, i] as const))("keeps the session chatroom@%s and hands the watcher the name as one argument", async (rig, i) => {
|
||||
const out = path.join(dir, `argv-${i}.json`);
|
||||
expect(start(rig, out)).toBe(`chatroom@${rig}`);
|
||||
await vi.waitFor(() => expect(JSON.parse(readFileSync(out, "utf8"))).toEqual(["chatroom", "watch", "--", rig]), { timeout: 5000 });
|
||||
});
|
||||
|
||||
// Whether tmux changes a `.` depends on its version (3.5a writes `_`; the tmux CI runs keeps the `.`), so the CLI
|
||||
// prints the name tmux reports, which is always the session tmux has.
|
||||
it("reports the name tmux used for a rig name with a ., whichever form its version keeps", async () => {
|
||||
const out = path.join(dir, "argv-dot.json");
|
||||
const reported = start("release-0.7", out);
|
||||
expect(["[email protected]", "chatroom@release-0_7"]).toContain(reported);
|
||||
expect(execFileSync("tmux", ["-L", TMUX_SOCKET, "list-sessions", "-F", "#{session_name}"], { encoding: "utf8" }).split("\n"))
|
||||
.toContain(reported);
|
||||
await vi.waitFor(() => expect(JSON.parse(readFileSync(out, "utf8"))).toEqual(["chatroom", "watch", "--", "release-0.7"]), { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
// The MCP adapter's per-request client tells the daemon whose grant it checked (X-OpenRig-Principal), and only this host's
|
||||
// own daemon: an endpoint that's forwarded (a loopback port reaching another instance) or remote never receives it.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
|
||||
const PRINCIPAL = { source: "file" as const, id: "alice" };
|
||||
const BASE = "http://127.0.0.1:7999";
|
||||
|
||||
describe("DaemonClient's principal header goes only to this host's own daemon", () => {
|
||||
let home: string;
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), "client-principal-"));
|
||||
vi.stubEnv("OPENRIG_HOME", home);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** The headers of the first daemon request, with the endpoint answering /healthz as `endpointId`. */
|
||||
async function headersSent(opts: { direct?: boolean; endpointId?: string; remote?: boolean; principal?: { source: "file" | "grant-service"; id: string } }): Promise<Record<string, string>> {
|
||||
vi.stubEnv("OPENRIG_URL", opts.direct ? BASE : "");
|
||||
const sent: Array<Record<string, string>> = [];
|
||||
const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
|
||||
if (String(url).endsWith("/healthz")) return Response.json({ ok: true, ...(opts.endpointId ? { selfHostId: opts.endpointId } : {}) });
|
||||
sent.push({ ...(init?.headers as Record<string, string>) });
|
||||
return Response.json([]);
|
||||
}) as typeof fetch;
|
||||
const client = new DaemonClient(BASE, { sessionName: "alice@team", principal: opts.principal ?? PRINCIPAL, fetchImpl });
|
||||
client.originSelfHostId = "this-host";
|
||||
if (opts.remote) client.remoteTarget = true;
|
||||
await client.get("/api/queue/list");
|
||||
return sent[0]!;
|
||||
}
|
||||
|
||||
it("sends it to an endpoint that answers with this host's own id, configured or not", async () => {
|
||||
for (const headers of [await headersSent({ endpointId: "this-host" }), await headersSent({ direct: true, endpointId: "this-host" })]) {
|
||||
expect(headers["X-OpenRig-Principal"]).toBe(JSON.stringify(PRINCIPAL));
|
||||
expect(headers["X-OpenRig-Session"]).toBe("alice@team");
|
||||
}
|
||||
});
|
||||
|
||||
it("carries a name beyond Latin-1 as JSON escapes, which a real request's headers accept and the daemon parses back", async () => {
|
||||
const principal = { source: "file" as const, id: "测试-café" };
|
||||
const headers = await headersSent({ endpointId: "this-host", principal });
|
||||
const value = headers["X-OpenRig-Principal"]!;
|
||||
expect(value).toMatch(/^[\x20-\x7e]*$/);
|
||||
expect(() => new Headers(headers)).not.toThrow();
|
||||
expect(JSON.parse(value)).toEqual(principal);
|
||||
});
|
||||
|
||||
it("never sends it to a forwarded endpoint (another instance's id, or none) or a remote target", async () => {
|
||||
for (const headers of [
|
||||
await headersSent({ direct: true, endpointId: "other-host" }),
|
||||
await headersSent({ direct: true }),
|
||||
// rig mcp serve --port <forwarded port>: no OPENRIG_URL, an explicit loopback URL that reaches another instance.
|
||||
await headersSent({ endpointId: "other-host" }),
|
||||
await headersSent({}),
|
||||
await headersSent({ remote: true }),
|
||||
]) {
|
||||
expect(headers["X-OpenRig-Principal"]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -285,32 +285,6 @@ describe("capture --host (cross-host short-circuit)", () => {
|
||||
expect(captureCalls.argv).toEqual(["rig", "capture", "dev-impl@my-rig", "--lines", "5x"]);
|
||||
});
|
||||
|
||||
it("forwards a fractional --lines as given: a positive parseInt means the remote decides", async () => {
|
||||
const captureCalls: { argv?: readonly string[] } = {};
|
||||
const cmd = captureCommand(deps({
|
||||
run: async (_h, argv) => {
|
||||
captureCalls.argv = argv;
|
||||
return { ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 };
|
||||
},
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "dev-impl@my-rig", "--lines=3.5"], { from: "user" });
|
||||
expect(captureCalls.argv).toEqual(["rig", "capture", "dev-impl@my-rig", "--lines", "3.5"]);
|
||||
});
|
||||
|
||||
it("forwards a non-positive or non-numeric --lines as the documented default of 20", async () => {
|
||||
for (const value of ["0", "-1", "abc"]) {
|
||||
const captureCalls: { argv?: readonly string[] } = {};
|
||||
const cmd = captureCommand(deps({
|
||||
run: async (_h, argv) => {
|
||||
captureCalls.argv = argv;
|
||||
return { ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 };
|
||||
},
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "dev-impl@my-rig", `--lines=${value}`], { from: "user" });
|
||||
expect(captureCalls.argv).toEqual(["rig", "capture", "dev-impl@my-rig", "--lines", "20"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards --history-plus-pane with the --lines it applies to", async () => {
|
||||
const captureCalls: { argv?: readonly string[] } = {};
|
||||
const cmd = captureCommand(deps({
|
||||
@@ -323,18 +297,6 @@ describe("capture --host (cross-host short-circuit)", () => {
|
||||
expect(captureCalls.argv).toEqual(["rig", "capture", "dev-impl@my-rig", "--lines", "5", "--history-plus-pane"]);
|
||||
});
|
||||
|
||||
it("forwards --readable and --size", async () => {
|
||||
const captureCalls: { argv?: readonly string[] } = {};
|
||||
const cmd = captureCommand(deps({
|
||||
run: async (_h, argv) => {
|
||||
captureCalls.argv = argv;
|
||||
return { ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 };
|
||||
},
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "dev-impl@my-rig", "--readable", "--size", "160x50"], { from: "user" });
|
||||
expect(captureCalls.argv).toEqual(["rig", "capture", "dev-impl@my-rig", "--readable", "--size", "160x50"]);
|
||||
});
|
||||
|
||||
it("unknown host id surfaces a friendly error", async () => {
|
||||
const cmd = captureCommand(deps());
|
||||
await cmd.parseAsync(["--host", "vm-bogus", "s@r"], { from: "user" });
|
||||
|
||||
@@ -351,22 +351,15 @@ describe("send --host (http branch)", () => {
|
||||
const h = mockClient(() => ({ status: 200, data: {} }));
|
||||
const cmd = sendCommand(httpDeps(h));
|
||||
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "hello", "--wait-for-idle", "30"], { from: "user" });
|
||||
expect(h.calls[0]!.options?.timeoutMs).toBe(30_000 + 60_000);
|
||||
expect(h.calls[0]!.options?.timeoutMs).toBe(30_000 + 30_000);
|
||||
expect((h.calls[0]!.body as Record<string, unknown>).waitForIdleMs).toBe(30_000);
|
||||
});
|
||||
|
||||
it("--wait-for-idle longer than 300 s is one request to the remote carrying the whole wait", async () => {
|
||||
const h = mockClient(() => ({ status: 200, data: { ok: true } }));
|
||||
const cmd = sendCommand(httpDeps(h));
|
||||
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "hello", "--wait-for-idle", "400"], { from: "user" });
|
||||
expect(h.calls.map((c) => [(c.body as Record<string, unknown>).waitForIdleMs, c.options?.timeoutMs])).toEqual([[400_000, 400_000 + 60_000]]);
|
||||
});
|
||||
|
||||
it("a cross-host send waits 60 s for the remote to deliver, not the 5 s read default", async () => {
|
||||
it("a cross-host send waits 30 s for the remote to deliver, not the 5 s read default", async () => {
|
||||
const h = mockClient(() => ({ status: 200, data: {} }));
|
||||
const cmd = sendCommand(httpDeps(h));
|
||||
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "hello"], { from: "user" });
|
||||
expect(h.calls[0]!.options?.timeoutMs).toBe(60_000);
|
||||
expect(h.calls[0]!.options?.timeoutMs).toBe(30_000);
|
||||
});
|
||||
|
||||
it("a remote that delivers after more than 5 s reports the send, not a failure", { timeout: 20_000 }, async () => {
|
||||
@@ -414,10 +407,7 @@ describe("send --host (http branch)", () => {
|
||||
const err = captured.stderrLines.join("\n");
|
||||
expect(err).toContain("cross-host (host=vps-b, http://vps-b:7433): http remote-outcome-unknown:");
|
||||
expect(err).toContain("Delivery UNCONFIRMED — the remote daemon may have received and delivered the message.");
|
||||
expect(err).toContain("rig capture dev-impl@my-rig --host vps-b");
|
||||
expect(err).toContain("off-screen");
|
||||
expect(err).toContain("does not prove non-delivery");
|
||||
expect(err).toContain("duplicate");
|
||||
expect(err).toContain("Check the target before any resend: rig capture dev-impl@my-rig --host vps-b. A resend without checking risks a duplicate.");
|
||||
expect(err).not.toContain("remote-daemon-unreachable");
|
||||
expect(captured.stdoutLines).not.toContain("Sent to dev-impl@my-rig");
|
||||
expect(process.exitCode).toBe(1);
|
||||
@@ -441,7 +431,7 @@ describe("send --host (http branch)", () => {
|
||||
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "hello", "--json"], { from: "user" });
|
||||
const parsed = JSON.parse(captured.stdoutLines[0]!) as Record<string, any>;
|
||||
expect(parsed.result).toMatchObject({ ok: false, failedStep: "remote-outcome-unknown", outcomeUnknown: true });
|
||||
expect(parsed.error.action).toContain("does not prove non-delivery");
|
||||
expect(parsed.error.action).toContain("Check the target before any resend");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
@@ -504,13 +494,6 @@ describe("capture --host (http branch)", () => {
|
||||
expect(captured.stdoutLines).toContain("pane content here");
|
||||
});
|
||||
|
||||
it("http host: a non-positive --lines posts the documented default of 20, not 0", async () => {
|
||||
const h = mockClient(() => ({ status: 200, data: { content: "pane content here" } }));
|
||||
const cmd = captureCommand(httpDeps(h));
|
||||
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "--lines=0"], { from: "user" });
|
||||
expect(h.calls[0]!.body).toEqual({ lines: 20, session: "dev-impl@my-rig", inputBox: true });
|
||||
});
|
||||
|
||||
it("anonymous (URL-only) http host: capture POSTs with NO Authorization header; still succeeds", async () => {
|
||||
const h = mockClient(() => ({ status: 200, data: { content: "pane content here" } }));
|
||||
const cmd = captureCommand(httpDeps(h));
|
||||
|
||||
@@ -21,18 +21,18 @@ describe("S20 — buildDaemonEnv: routing env never crosses into the daemon", ()
|
||||
it("declared intent exports the DEDICATED bind env plus a COHERENT routing env (seats route where the daemon binds)", () => {
|
||||
const env = buildDaemonEnv(
|
||||
{ HOME: "/Users/op", OPENRIG_HOST: "10.0.0.9" }, // stale inherited routing is still ignored
|
||||
{ port: 7433, host: "100.88.0.20", db: "/tmp/t.db" },
|
||||
{ port: 7433, host: "100.95.124.51", db: "/tmp/t.db" },
|
||||
);
|
||||
expect(env["OPENRIG_BIND_HOST"]).toBe("100.88.0.20");
|
||||
expect(env["OPENRIG_HOST"]).toBe("100.88.0.20"); // the routing half follows the DECLARED intent
|
||||
expect(env["OPENRIG_BIND_HOST"]).toBe("100.95.124.51");
|
||||
expect(env["OPENRIG_HOST"]).toBe("100.95.124.51"); // the routing half follows the DECLARED intent
|
||||
});
|
||||
|
||||
it("an inherited OPENRIG_BIND_HOST passes through — the dedicated surface IS unambiguous operator opt-in", () => {
|
||||
const env = buildDaemonEnv(
|
||||
{ HOME: "/Users/op", OPENRIG_BIND_HOST: "100.88.0.20" },
|
||||
{ HOME: "/Users/op", OPENRIG_BIND_HOST: "100.95.124.51" },
|
||||
{ port: 7433, db: "/tmp/t.db" },
|
||||
);
|
||||
expect(env["OPENRIG_BIND_HOST"]).toBe("100.88.0.20");
|
||||
expect(env["OPENRIG_BIND_HOST"]).toBe("100.95.124.51");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -133,13 +133,13 @@ describe("S20 — the restored listener gate: adoption fails loudly on a dropped
|
||||
|
||||
it("explicit mode requires exactly the declared host", async () => {
|
||||
const ok = await verifyRequiredListeners({
|
||||
bind: { mode: "explicit", hosts: ["100.88.0.20"], tailscaleDetected: true },
|
||||
bind: { mode: "explicit", hosts: ["100.95.124.51"], tailscaleDetected: true },
|
||||
port: 7433,
|
||||
probe: probeUp(new Set(["100.88.0.20"])),
|
||||
probe: probeUp(new Set(["100.95.124.51"])),
|
||||
});
|
||||
expect(ok.ok).toBe(true);
|
||||
const bad = await verifyRequiredListeners({
|
||||
bind: { mode: "explicit", hosts: ["100.88.0.20"], tailscaleDetected: false },
|
||||
bind: { mode: "explicit", hosts: ["100.95.124.51"], tailscaleDetected: false },
|
||||
port: 7433,
|
||||
probe: probeUp(new Set()),
|
||||
});
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { waitForKernelReady, type KernelReadyResult } from "../src/daemon-lifecycle.js";
|
||||
import { daemonCommand } from "../src/commands/daemon.js";
|
||||
|
||||
vi.mock("../src/daemon-lifecycle.js", async (original) => {
|
||||
const actual = await original<typeof import("../src/daemon-lifecycle.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getDaemonStatus: vi.fn(async () => ({ state: "stopped" })),
|
||||
startDaemon: vi.fn(async () => ({ pid: 4242, port: 7433, host: "127.0.0.1" })),
|
||||
waitForKernelReady: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("../src/system-preflight.js", () => ({ SystemPreflight: class { run = async () => ({ ready: true, checks: [] }); }, quietPreflightExec: async () => "" }));
|
||||
let home: string;
|
||||
let errors: string[];
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "daemon-start-wait-for-kernel-"));
|
||||
vi.stubEnv("OPENRIG_HOME", home);
|
||||
errors = [];
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { errors.push(args.join(" ")); });
|
||||
});
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); rmSync(home, { recursive: true, force: true }); process.exitCode = undefined; });
|
||||
|
||||
async function startWaiting(result: Partial<KernelReadyResult>) {
|
||||
vi.mocked(waitForKernelReady).mockResolvedValue({ ok: false, kernelState: null, variant: null, detail: null, ...result });
|
||||
await daemonCommand({} as never).parseAsync(["start", "--wait-for-kernel"], { from: "user" });
|
||||
return errors.join("\n");
|
||||
}
|
||||
|
||||
it("a kernel already down before this start: says so and how to bring it back, not a timeout or an auth fix", async () => {
|
||||
const out = await startWaiting({ kernelState: "skipped",
|
||||
detail: "kernel rig already managed; it was already down before this daemon start, so it was not restored. Bring it back with: rig up kernel --existing" });
|
||||
expect(out).toContain("Kernel auto-boot skipped: kernel rig already managed; it was already down before this daemon start");
|
||||
expect(out).toContain("rig up kernel --existing");
|
||||
expect(out).not.toContain("did not reach ready");
|
||||
expect(out).not.toContain("auth status");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("a kernel skipped on purpose: names the opt-out, not a timeout or an auth fix", async () => {
|
||||
const out = await startWaiting({ kernelState: "skipped", detail: "OPENRIG_NO_KERNEL=1" });
|
||||
expect(out).toContain("Kernel auto-boot skipped (--no-kernel or test mode)");
|
||||
expect(out).not.toContain("did not reach ready");
|
||||
expect(out).not.toContain("auth status");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("a kernel still booting at the deadline keeps the timeout and its fix", async () => {
|
||||
const out = await startWaiting({ kernelState: "booting" });
|
||||
expect(out).toContain("Error: kernel did not reach ready / partial_ready within 60000ms.");
|
||||
expect(out).toContain("Reason: kernel_state=booting");
|
||||
expect(out).toContain("Fix: inspect `rig ps --rig kernel` for stalled agents");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { doctorCommand } from "../src/commands/doctor.js";
|
||||
import { TMUX_CAPTURE_PROBE_TIMEOUT_MS } from "../src/tmux-health.js";
|
||||
|
||||
const fixture = vi.hoisted(() => ({ home: "", exec: vi.fn() }));
|
||||
vi.mock("node:child_process", async () => ({ ...await vi.importActual("node:child_process"), execSync: fixture.exec }));
|
||||
@@ -46,17 +45,12 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("doctor default executor", () => {
|
||||
it("keeps existing probes unbounded while bounding only the new provider calls and the tmux capture probe", async () => {
|
||||
it("keeps existing probes unbounded while bounding only the new provider calls", async () => {
|
||||
await new Command().addCommand(doctorCommand()).parseAsync(["node", "rig", "doctor", "--json"]);
|
||||
const body = JSON.parse(vi.mocked(console.log).mock.calls[0]![0] as string);
|
||||
expect(body.checks.find((check: { name: string }) => check.name === "tmux").status).toBe("pass");
|
||||
const calls = fixture.exec.mock.calls as Array<[string, { timeout?: number }]>;
|
||||
// #980's capture probe runs on its own throwaway socket, and every one of its commands is bounded.
|
||||
const capture = calls.filter(([command]) => command.includes(" -L openrig-check-"));
|
||||
expect(capture.map(([command]) => command.replace(/openrig-check-\d+/, "openrig-check-N"))).toEqual([
|
||||
"tmux -L openrig-check-N -f /dev/null new-session -d -s check", "tmux -L openrig-check-N -f /dev/null kill-server"]);
|
||||
expect(capture.every(([,options]) => options.timeout === TMUX_CAPTURE_PROBE_TIMEOUT_MS)).toBe(true);
|
||||
const prior = calls.filter(([command]) => !/^(claude|codex) /.test(command) && !command.includes(" -L openrig-check-"));
|
||||
const prior = calls.filter(([command]) => !/^(claude|codex) /.test(command));
|
||||
expect(prior.some(([command]) => command === "tmux list-sessions")).toBe(true);
|
||||
expect(prior.every(([,options]) => !Object.hasOwn(options, "timeout"))).toBe(true);
|
||||
const auth = calls.filter(([command]) => /^(claude|codex) /.test(command));
|
||||
|
||||
@@ -53,26 +53,6 @@ describe("runDoctorChecks", () => {
|
||||
expect(daemonCheck?.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("#980: a tmux whose server exits on capture-pane -p fails tmux_capture with the fix; a working one passes", () => {
|
||||
const base = makeDeps();
|
||||
const bounds: Array<number | undefined> = [];
|
||||
const crashing = makeDeps({ exec: (cmd: string, opts?: { timeoutMs?: number }) => {
|
||||
if (cmd.includes("openrig-check-")) bounds.push(opts?.timeoutMs);
|
||||
if (cmd.includes("capture-pane -p")) throw new Error("server exited unexpectedly");
|
||||
if (cmd === "tmux -V") return "tmux next-3.4\n";
|
||||
return base.exec(cmd);
|
||||
} });
|
||||
const failed = runDoctorChecks(crashing).checks.find((c) => c.name === "tmux_capture");
|
||||
// Every probe command reaches doctor's runner with its bound: create, capture and cleanup.
|
||||
expect(bounds).toHaveLength(3);
|
||||
expect(bounds.every((b) => typeof b === "number" && b > 0)).toBe(true);
|
||||
expect(failed).toMatchObject({ status: "fail", message: expect.stringContaining("tmux next-3.4") });
|
||||
expect(failed?.message).toContain("server exited unexpectedly");
|
||||
expect(failed?.reason).toContain("#980");
|
||||
expect(failed?.fix).toMatch(/first on your PATH/);
|
||||
expect(runDoctorChecks(base).checks.find((c) => c.name === "tmux_capture")).toMatchObject({ status: "pass" });
|
||||
});
|
||||
|
||||
it("daemon dist missing -> fail with guidance", () => {
|
||||
const deps = makeDeps({ exists: () => false });
|
||||
const { checks } = runDoctorChecks(deps);
|
||||
|
||||
Loaded 100 of 600 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user