Compare commits

..
Author SHA1 Message Date
dev60-review 42564fb1bf docs(getting-started): say which snapshot rig launch restores a kernel seat from
The line said `rig launch kernel <logical id>` restores from the
kernel's latest restore-usable snapshot. Restore ranks automatic
snapshots (taken before `rig down`, on the periodic schedule, or at a
runtime switch) first, then the newest, skipping unusable ones, so an
older automatic snapshot wins over a newer manual one. It now says so,
names what `--plan` shows (the snapshot picked and any newer usable
one) and `--snapshot-id`, and says the resume holds under the default
restore policy, which the kernel's seats keep.
2026-11-12 06:05:21 +00:00
37 changed files with 273 additions and 1146 deletions

No files matched your search

+22 -58
View File
@@ -13,13 +13,12 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
OpenRig 0.7.12 is a patch for people running their own agent teams, cut from
main `16e8b7fb`. When you answer your team, by typing, clicking or qualifying
an answer, the answer now goes to an agent that reads it with the question and
decides. When no receiving agent is available, your answer waits. Setup's
guidance asks your agent to offer to register you as the team's person, and
registering works when you ask for it. `rig down` no longer stops a terminal it
decides. Answers go to the configured receiving agent or kernel operator when
available; otherwise they wait. Setup offers to register you as the team's
person. `rig down` no longer stops a terminal it
can't show belongs to the seat. There are fixes across delivery, Codex,
restore, the queue, Slack and speed, many contributed by people using
OpenRig. Two repairs aren't in this release, and some checks weren't run: see
Known limits.
OpenRig. Two repairs aren't in this release: see Known limits.
### Before you upgrade
@@ -27,8 +26,7 @@ Known limits.
seat's own `OPENRIG_NODE_ID`, on the server and session it read. A terminal
OpenRig didn't create, a mismarked one or a replaced one is left running, and
`rig down` exits 2 naming it and how to stop it yourself. A seat joined with
`rig attach` as an external CLI is listed as not stopped, since OpenRig never
stops it; that alone doesn't make `rig down` exit 2. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
`rig attach --self` is in that group. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
- **openrig-core is 0.1.28.** A Codex session kept running across the upgrade
can ask for hook approval until it's relaunched.
@@ -36,29 +34,15 @@ Known limits.
- Answers nothing else reads, such as a host pairing request or a mode's
scheduled end, use the configured receiving agent or the kernel operator.
Answers wait when no receiver is available, and nothing decides them
meanwhile. To decide one yourself, use its own command: `rig host approve` or
`rig host deny` for a pairing request, `rig seat decide-permission` for a
runner's request. Closing an ask isn't permission, and words that were kept
aren't a default permit. ([#2923](https://github.com/mvschwarz/openrig/pull/2923))
Answers wait when no receiver is available. ([#2923](https://github.com/mvschwarz/openrig/pull/2923))
- Pairing answers go to the receiving agent, and you can decide directly in the
TUI's pairing settings. A pairing request says who it went to and who reads
the answer. ([#2931](https://github.com/mvschwarz/openrig/pull/2931), [#2935](https://github.com/mvschwarz/openrig/pull/2935))
- A runner's permission answer goes to the receiving agent, which decides while
the runner waits, so a qualified answer such as "only if it's the test file"
is read before anything acts. Your answer is kept and an explicit decision
works, but the runner's confirmation updates to you aren't written in this
release. Don't expect a confirmation in the Feed, a notice of the receiver's
state or an immediate wake: the decision reaches the runner at its next read
(up to about 21 seconds in testing). To decide one yourself:
`rig seat decide-permission <askId> --option <id> --reason "…"`. This is the
runner's path only: a pairing request says who reads the answer, as above. ([#2934](https://github.com/mvschwarz/openrig/pull/2934), [#2938](https://github.com/mvschwarz/openrig/pull/2938), [#2950](https://github.com/mvschwarz/openrig/pull/2950), [#2966](https://github.com/mvschwarz/openrig/pull/2966))
- Setup's guidance asks your agent to offer to register you as the team's
person. In testing the agent didn't reliably make that offer (0 of 5 runs).
Registering works when you ask for it: `rig gateway human add`. Nothing is
registered without your yes, an existing default person is kept, and no person
is chosen automatically. The empty-registry hint is right. A team you start
from your own assistant fits your own logins,
is read before anything acts. ([#2934](https://github.com/mvschwarz/openrig/pull/2934), [#2938](https://github.com/mvschwarz/openrig/pull/2938), [#2950](https://github.com/mvschwarz/openrig/pull/2950), [#2966](https://github.com/mvschwarz/openrig/pull/2966))
- Setup offers to register you as the team's person, and the empty-registry
hint is right. A team you start from your own assistant fits your own logins,
with a review before anything is committed. ([#2924](https://github.com/mvschwarz/openrig/pull/2924), [#2905](https://github.com/mvschwarz/openrig/pull/2905))
### Harnesses and connections
@@ -96,9 +80,8 @@ Known limits.
- **Delivery and drafts:** Claude seats with a custom status line read idle and
verify sends; a fresh idle hook doesn't type into a current Claude dialog;
Claude's context hint and drawn cursor are read correctly; when the input
can't be read, a deliberate override (`--dangerously-interact`) pastes with a
warning and doesn't press Enter, so it isn't submitted; update notices beside the
Claude's context hint and drawn cursor are read correctly; a deliberate
override is delivered when the input can't be read; update notices beside the
paste hint are recognized. ([#2807](https://github.com/mvschwarz/openrig/pull/2807), [#2813](https://github.com/mvschwarz/openrig/pull/2813), [#2947](https://github.com/mvschwarz/openrig/pull/2947), [#2942](https://github.com/mvschwarz/openrig/pull/2942), [#2933](https://github.com/mvschwarz/openrig/pull/2933), [#2800](https://github.com/mvschwarz/openrig/pull/2800), [#2786](https://github.com/mvschwarz/openrig/pull/2786))
- **Codex:** when you answer a Codex choice prompt, a message waiting on it is
delivered instead of escalated; a working counter is compared only within one pane process; hook
@@ -131,9 +114,8 @@ Known limits.
config accepts two keys the daemon already resolves; `rig whoami` says the
team is still being built during `rig up`; rigs with `@` in the name find
attention; a seat waiting on a person names its question; the installer
reports PATH gaps with shell hints; daemon credentials stay out of a tmux
server's environment from its next start (a server already running keeps its
old environment until it restarts). ([#2914](https://github.com/mvschwarz/openrig/pull/2914), [#2969](https://github.com/mvschwarz/openrig/pull/2969), [#2955](https://github.com/mvschwarz/openrig/pull/2955), [#2949](https://github.com/mvschwarz/openrig/pull/2949), [#2891](https://github.com/mvschwarz/openrig/pull/2891), [#2888](https://github.com/mvschwarz/openrig/pull/2888), [#2883](https://github.com/mvschwarz/openrig/pull/2883), [#2850](https://github.com/mvschwarz/openrig/pull/2850))
reports PATH gaps with shell hints; daemon credentials stay out of the tmux
server's environment. ([#2914](https://github.com/mvschwarz/openrig/pull/2914), [#2969](https://github.com/mvschwarz/openrig/pull/2969), [#2955](https://github.com/mvschwarz/openrig/pull/2955), [#2949](https://github.com/mvschwarz/openrig/pull/2949), [#2891](https://github.com/mvschwarz/openrig/pull/2891), [#2888](https://github.com/mvschwarz/openrig/pull/2888), [#2883](https://github.com/mvschwarz/openrig/pull/2883), [#2850](https://github.com/mvschwarz/openrig/pull/2850))
- **Pi and OMP:** typed edits submit in order on the affected Node releases
(22.21.x to 22.22.0, 24.9.0 to 24.11.1, 25.0.0 to 25.1.0) instead of appending an
insertion at the end. ([#2847](https://github.com/mvschwarz/openrig/pull/2847))
@@ -179,35 +161,17 @@ Known limits.
- **Custom blocker IDs** explain the wait, but their progress gives no event
wake; the timer remains. ([#2983](https://github.com/mvschwarz/openrig/pull/2983))
- **Delivery evidence** records what was known, not that the agent read the
message; where the input ended up can read unknown. Events are best effort, so
a missing event isn't proof nothing was sent. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
- **Runner permission answers** were run end to end with a real receiving
agent and the example runner, whose requesting agent was a stand-in; a
model-driven runner waiting on a person's answer hasn't been run. ([#2934](https://github.com/mvschwarz/openrig/pull/2934))
message; where the input ended up can read unknown. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
- **Runner permission answers:** when a person answers a runner's permission
request, the answer is kept, and an explicit decision works. In 0.7.12 the
runner's confirmation updates fail to record, so no receipt or Feed
confirmation appears, and the decision can reach the runner on its next read
rather than at once (up to about 21 seconds in our test). To settle a request
directly, run `rig seat decide-permission <request> --option <id>`. Pairing
answers are separate, and their receipts are kept. Tested on one native daemon
with the TUI and a runner driven by a stand-in agent. ([#2934](https://github.com/mvschwarz/openrig/pull/2934))
- **herdr-app views** are one-shot; following isn't claimed. **Pull relay**
needs a connector that implements pull. ([#2744](https://github.com/mvschwarz/openrig/pull/2744), [#2844](https://github.com/mvschwarz/openrig/pull/2844))
- **A receiving agent you choose** (`agents.operator_session`) that runs in an
explicit or auto permission mode gets no OpenRig allowance for
`rig host approve` or `rig host deny`, so Claude's review can refuse them. A
kernel receiver in auto mode is included. The default kernel with the default
policy passed at its tested scope.
- **A receiver that was stopped and then started** may not act on an answer that
waited for it. In the tested case where it needed to ask you something back,
the question appeared in its own conversation, not in the Feed or Steering.
- **Delivery isn't fixed in general:** a footer row OpenRig doesn't recognize can
still hold a delivery ([#2925](https://github.com/mvschwarz/openrig/issues/2925)), and a held message's full envelope can stay held;
clearing a draft doesn't deliver it ([#2515](https://github.com/mvschwarz/openrig/pull/2515)).
- **Not run for this release** (not run, not passed): the two-seat Codex journey
with `AGENTS.md` restore; Codex account-home, upgrade and continuity checks;
the OpenShell worker's second report; Pi model switches on real providers; ACP
in a desktop app; Slack directory paging; some Slack setup cases (channel and
thread precedence, a stopped receiver's delayed pickup, an inherited inbound
default, a plain yes with no receiver); native checks of [#2786](https://github.com/mvschwarz/openrig/pull/2786), [#2799](https://github.com/mvschwarz/openrig/pull/2799), [#2807](https://github.com/mvschwarz/openrig/pull/2807)
and [#2813](https://github.com/mvschwarz/openrig/pull/2813), with [#2947](https://github.com/mvschwarz/openrig/pull/2947) partly; a button-and-words answer, a non-pairing ask
with an explicit owner, Steering's Approve page, and these answer and setup
checks on a default model (the tested runs used smaller models);
`--wait-for-kernel` ([#2888](https://github.com/mvschwarz/openrig/pull/2888)), the installed effect of [#2800](https://github.com/mvschwarz/openrig/pull/2800), and [#2720](https://github.com/mvschwarz/openrig/issues/2720) beyond one
platform.
- **From 0.7.11:** its known limits below still apply, except Codex hook setup
with smol-toml 1.9.x ([#2720](https://github.com/mvschwarz/openrig/issues/2720)), fixed by [#2804](https://github.com/mvschwarz/openrig/pull/2804). 0.7.12 changes openrig-core
again, so the Codex hook note there applies to this upgrade too. They weren't
+5 -5
View File
@@ -26,9 +26,9 @@ use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.11/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.11/skills/rigs --skill rigs --agent codex
```
Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what you want built. The skill has your agent tell
@@ -68,11 +68,11 @@ Requires Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude Co
### Install it yourself
This runs OpenRig's install script from the `v0.7.12` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
This runs OpenRig's install script from the `v0.7.11` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
```bash
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh
```
If the last step isn't ready, the installer reports `INSTALLED, SETUP NOT COMPLETE` and points to the details from `rig setup` above. Follow that output and any reported Fix, then rerun `rig setup`; the CLI does not need reinstalling. If the only remaining failures are provider sign-ins, sign in to each selected provider as below and continue.
@@ -220,26 +220,6 @@ providers. Cached plugin versions are excluded from that duplicate-provider chec
constructs the service while building the daemon's dependencies and doesn't catch that
rejection, so a duplicate vendored hook stops daemon creation.
Vendoring at daemon start (`ensureVendored` in `plugin-vendor-service.ts`) seeds a bundled
plugin that isn't installed, and refreshes an installed one only when the bundled manifest
version is strictly newer, backing up what it replaces. At an equal version it leaves the
installed content unchanged, though it may reconcile file modes on byte-identical files; a newer
installed version is left as it is. That describes this start-time path only, not every way a
plugin can be installed or updated. The `openrig` plugin ships inside the CLI, so its four
manifests carry the CLI's release version, set in release preparation; `openrig-core` keeps its
own `0.1.x` line. So each release refreshes an older installed `openrig` plugin, while a daemon
run from development source, whose `openrig` manifests stay at `0.1.0`, leaves a newer released
install as it is rather than downgrading it. `npm run pack:release` refuses a missing `openrig`
manifest, or one that doesn't carry the CLI's version, in source or in the assembled CLI. It also
refuses a baseline (`scripts/fixtures/packaged-plugins-last-release.json`) whose release isn't
earlier than the CLI's, or that has no archive hash or plugins, and changed plugin bytes without a
greater version than that release's. Those baseline checks read the file's own fields: they don't
prove the baseline is the published, latest or complete release archive, so a successful pack
prints the baseline it used, to confirm against the registry. Refresh the baseline from the
published archive with the snapshot, validate and replace steps at the end of
[`scripts/check-packaged-plugin-versions.mjs`](../../../scripts/check-packaged-plugin-versions.mjs);
`--snapshot` on its own only prints the JSON.
`packages/daemon/src/routes/plugins.ts` exposes GET-only inspection at `/api/plugins`:
list, detail, used-by and file list/read. The file routes use the discovered plugin root as
their path allowlist. `?cwd=` supplies rig-cwd discovery roots.
+16 -13
View File
@@ -180,22 +180,20 @@ user.
**Agents:** if you're setting OpenRig up for someone, load the `rigs` skill and follow it. It carries the install, the
OpenRig view and the handoff to the operator as one procedure. From 0.6.7, OpenRig installs it as `rigs` in
`~/.claude/skills` and `~/.agents/skills` when the daemon starts. For a new install, add it with the command for the
agent doing the setup. Both use this release's tag:
`~/.claude/skills` and `~/.agents/skills` when the daemon starts. To add it manually, use the release tag matching
your installed `rig --version`: replace `<release-tag>` below with that tag, including its `v` prefix.
`rig --version` prints the version and a commit, for example `0.6.11 (379f0a0d)`; the tag is `v0.6.11`.
The skill is in the repository from v0.6.6; on an older install, use the v0.6.6 tag or upgrade first.
Choose the command for the agent doing the setup:
```sh
# Claude Code
npx skills add "https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs" --skill rigs --agent claude-code
npx skills add "https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs" --skill rigs --agent claude-code
# Codex
npx skills add "https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs" --skill rigs --agent codex
npx skills add "https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs" --skill rigs --agent codex
```
If OpenRig is already installed, use the release tag matching your installed `rig --version` instead: replace the tag
after `tree/` with it, including its `v` prefix. `rig --version` prints the version and a commit, for example
`0.6.11 (379f0a0d)`; the tag is `v0.6.11`. The skill is in the repository from v0.6.6; on an older install, use the
v0.6.6 tag or upgrade first.
These are the pinned, per-agent forms used on the [rigs pages](https://openrig.dev/rigs).
You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
@@ -203,11 +201,11 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
script will do and changes nothing:
```sh
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh -s -- --dry-run
```
The same command without `--dry-run`,
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.12/scripts/install.sh | sh`, installs the
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.11/scripts/install.sh | sh`, installs the
latest published `@openrig/cli` with `npm install -g`,
runs the Node.js and SQLite check, then `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
Codex and may install a missing one, as described below. A failed step prints
@@ -349,8 +347,13 @@ with `rig up kernel --existing`. A seat you stopped or unclaimed also stays down
While the kernel's other seats are running, `rig up kernel --existing` refuses
(it restores only a kernel with no live session), so start one stopped seat with
`rig launch kernel <logical id>` (`--plan` first). It restores that seat from the
kernel's latest restore-usable snapshot and resumes its saved conversation; a seat
it can't resume is reported as waiting for a decision, not as started.
kernel snapshot that restore ranks first: the newest usable automatic one (taken
before `rig down`, on the periodic schedule, or at a runtime switch), even when a
manual one is newer, and otherwise the newest usable one. `--plan` names the
snapshot it picked and any newer usable one, and `--snapshot-id <id>` picks one
yourself. Under the default restore policy, which the kernel's seats keep, it
resumes the seat's saved conversation; a seat it can't resume is reported as
waiting for a decision, not as started.
`rig seat launch <seat> --fresh --reason "…"` instead starts a new conversation
with no continuity source: use it only when you want a fresh occupant, since the
seat's earlier conversation isn't carried.
-27
View File
@@ -112,33 +112,6 @@ OpenRig doesn't observe a hosted member between its calls, so it never reads idl
- `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`.
### Check-in status
A connected assistant (a roster member marked `hosted: true`) checks in each time it reads its own pending inbox (through the queue list, as the MCP list tool does
when it lists the member's own work). Other calls it makes don't count yet. Its status compares its last check-in with
what its roster entry states: a cadence (`check_in_minutes`), or that it checks in when a person asks it
(`check_in_on_request: true`):
| Status | Meaning |
|---|---|
| `checking-in` | its last check-in is no older than twice its stated cadence |
| `check-in-overdue` | its last check-in is older than that: its schedule may be off or waiting for an approval |
| `no-check-in-yet` | no check-in recorded for it on this daemon, whatever its entry states |
| `on-request` | its entry says it checks in when a person asks it |
| `disconnected` | reserved for a recorded revocation or removal of its connection. Nothing records one yet, so a revoked assistant reads overdue (or unknown, with no cadence) |
| `unknown` | its entry states neither, its statement can't be used, or its check-ins couldn't be read; the reason says which, and its last check-in is shown. OpenRig assumes no cadence, so this never reads idle or overdue |
Each place it's shown gives the evidence with it, for example `overdue: last check-in 2026-11-12T13:05:00Z, expected
every 60 min; its schedule may be off or waiting for an approval`.
A stated cadence is its person's statement: neither reading proves the assistant's own schedule ran, or that it picked
anything up. It's a status only. Work addressed to a member that isn't checking in still queues and is rung as before, a check-in
isn't pickup (pickup is its claim), and no check-in doesn't mean it's idle, failed or safe. `GET /api/queue/hosted-members`
gives this daemon's host id and each member on this host with its status, last check-in and stated cadence (with the
reason when it's unknown); `rig roster show` and `find` show it as `Check-in:`, for a team with or without terminal seats.
When the member has unclaimed work, the standing notice described above states the same status in its `check-ins:`
line; when that notice is filed doesn't change.
## Revoke
`rig mcp token revoke grok` refuses its next request. The rows it worked, and their answers, stay.
+4 -4
View File
@@ -12,13 +12,13 @@ In your project folder, add OpenRig's `rigs` skill to the agent you use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs --skill rigs --agent claude-code
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.12/skills/rigs --skill rigs --agent codex
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
```
These use this release's tag, for a new install. If OpenRig is already installed, replace the tag after `tree/` with
the one that matches `rig --version` (`v` and its version number), or upgrade first. If the command fails with `npx: command not found`, Node.js isn't
For a new install, use the latest release's tag, for example `v0.7.11`. If OpenRig is already installed, use the tag
that matches `rig --version`, or upgrade first. If the command fails with `npx: command not found`, Node.js isn't
installed or isn't on your PATH yet (see [What you need](#what-you-need)). Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what
you want built.
-5
View File
@@ -92,8 +92,3 @@ A member marked `"hosted": true` is a hosted member: a tool outside the terminal
`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)).
It can also declare `check_in_minutes`, how often it's expected to check its queue (a positive whole number of minutes;
an hourly schedule is `60`), or `check_in_on_request: true` when it checks in only when a person asks it. That's what
its person set up, not something OpenRig observes. With neither, its check-in status reads unknown: OpenRig doesn't
assume a cadence. A value that can't be used doesn't stop the roster loading: that member's check-in
status reads unknown, with the reason. See [Check-in status](hosted-members.md#check-in-status).
+2 -49
View File
@@ -5,10 +5,6 @@ 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 { CHECK_IN_DISPLAY, describeCheckIn, type CheckInStatus } from "@openrig/daemon/connection-contract";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { DaemonClient } from "../client.js";
import { realDeps } from "./daemon.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).
@@ -21,23 +17,6 @@ export interface RosterInventory {
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;
/** The local daemon's hosted members with their check-in status, and that daemon's own host id (the host whose
* check-ins these are); null when it couldn't be read. */
checkIns?: { hostId: string | null; members: Row[] } | null;
}
/** Each hosted member's check-in status, from the local daemon. Null when the daemon isn't running or doesn't answer
* (an older daemon has no such read): every hosted member's status then reads unknown. */
export async function readHostedCheckIns(): Promise<RosterInventory["checkIns"]> {
try {
const status = await getDaemonStatus(realDeps());
if (status.state !== "running") return null;
const res = await new DaemonClient(getDaemonUrl(status)).get<unknown>("/api/queue/hosted-members");
return res.status < 400 && object(res.data) && Array.isArray(res.data.members)
? { hostId: text(res.data.hostId), members: res.data.members.filter(object) } : null;
} catch {
return 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;
@@ -70,30 +49,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, checkIns: await readHostedCheckIns() };
}
function hostedCheckIn(member: RosterMember, inventory: RosterInventory): Row {
const unknown = (reason: string) => ({ status: "unknown", lastCheckInAt: null, expectedMinutes: member.check_in_minutes ?? null,
onRequest: member.check_in_on_request === true, reason });
// The daemon names its own host with its check-ins, so a team with no terminal seat still matches its members.
if (!inventory.checkIns) return unknown("this host's daemon gave no check-in status");
if (!inventory.checkIns.hostId || member.host !== inventory.checkIns.hostId) return unknown("it's listed on another host, whose check-ins this host doesn't hold");
const row = inventory.checkIns.members.find((m) => m.seat === member.seat);
return row && typeof row.status === "string" && row.status in CHECK_IN_DISPLAY ? row
: unknown("this host's daemon gave no status for it");
}
/** A connected assistant's check-in in one human line with its evidence, in the daemon's own words
* (`describeCheckIn`): "overdue: last check-in 13:05, expected every 60 min; its schedule may be off or waiting for an
* approval". A status this CLI doesn't know reads unknown. */
export function formatCheckIn(checkIn: Row): string {
const known = typeof checkIn.status === "string" && checkIn.status in CHECK_IN_DISPLAY;
return describeCheckIn({ status: known ? checkIn.status as CheckInStatus : "unknown",
lastCheckInAt: typeof checkIn.lastCheckInAt === "string" ? checkIn.lastCheckInAt : null,
expectedMinutes: typeof checkIn.expectedMinutes === "number" ? checkIn.expectedMinutes : null,
onRequest: checkIn.onRequest === true,
...(known && typeof checkIn.reason === "string" ? { reason: checkIn.reason } : {}) });
return { readAt: new Date().toISOString(), nodes, sources, localHostId };
}
export function enrichRosterMember(member: RosterMember, inventory: RosterInventory) {
@@ -137,9 +93,6 @@ export function enrichRosterMember(member: RosterMember, inventory: RosterInvent
// is in rig_team.
...(member.hosted ? { lastCallAt: inventory.localHostId && member.host === inventory.localHostId
? readPresence(mcpPresenceFilePath())[member.seat]?.lastCallAt ?? null : null } : {}),
// Whether it's checking its queue, for a member on this host; another host's member, or a status this daemon
// couldn't give, reads unknown.
...(member.hosted ? { checkIn: hostedCheckIn(member, inventory) } : {}),
} };
}
@@ -181,7 +134,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 Check-in: ${formatCheckIn(m.live.checkIn ?? {})}` : ""}\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"}${m.hosted ? `\n Last call: ${m.live.lastCallAt ?? "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}`);
+1 -53
View File
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFile, execFileSync } from "node:child_process";
import { promisify } from "node:util";
import { enrichRosterMember, formatCheckIn, parseRoster, readRosters, rosterCommand, type RosterInventory } from "../src/commands/roster.js";
import { enrichRosterMember, parseRoster, readRosters, rosterCommand, type RosterInventory } from "../src/commands/roster.js";
const member = { seat: "editor@studio", host: "host-a", capabilities: ["colour grading"], engagement: ["consult"], use_when: "Choose a look", why: "Knows the footage", caveat: "Advice only" };
const authored = { version: 1, id: "production", name: "Production", purpose: "Make a film", curator: { seat: "lead@studio", host: "host-a" }, updated_at: "2026-09-01", members: [member] };
@@ -133,55 +133,3 @@ it("does not choose the first duplicate route when activity observations conflic
expect(enrichRosterMember(member, { readAt: "2026-10-01T00:00:02Z", nodes: [idle, unclocked], sources: [] }).live.agentActivity)
.toEqual({ state: "idle", reason: null, sampledAt: null });
});
it("keeps any positive whole check-in cadence; one that can't be used still loads, with its reason and no cadence", () => {
const hourly = { ...member, seat: "bot@team", hosted: true, check_in_minutes: 60 };
expect(parseRoster({ ...authored, members: [hourly, { ...hourly, seat: "fast@team", check_in_minutes: 1 }] }).members)
.toEqual([hourly, { ...hourly, seat: "fast@team", check_in_minutes: 1 }]);
for (const bad of [{ ...hourly, check_in_minutes: 0 }, { ...hourly, check_in_minutes: 1.5 }, { ...hourly, check_in_minutes: "60" },
{ ...member, check_in_minutes: 60 }]) {
const [loaded] = parseRoster({ ...authored, members: [bad] }).members;
expect(loaded).not.toHaveProperty("check_in_minutes");
expect(loaded!.check_in_problem).toMatch(/check_in_minutes/);
}
});
it("shows a hosted member's check-in status from this host's daemon; another host's member, or no answer, reads unknown", () => {
const bot = { ...member, seat: "bot@team", host: "host-a", hosted: true, check_in_minutes: 60 };
const checkIns = [{ seat: "bot@team", rung: false, status: "check-in-overdue", lastCheckInAt: "2026-11-12T09:00:00.000Z", expectedMinutes: 60, reporter: "openrig-observed" }];
const here = { ...inventory([]), localHostId: "host-a", checkIns: { hostId: "host-a", members: checkIns } };
expect(enrichRosterMember(bot, here).live.checkIn).toMatchObject({ status: "check-in-overdue", lastCheckInAt: "2026-11-12T09:00:00.000Z" });
expect(formatCheckIn(enrichRosterMember(bot, here).live.checkIn!))
.toBe("overdue: last check-in 2026-11-12T09:00:00.000Z, expected every 60 min; its schedule may be off or waiting for an approval");
for (const [missing, reason] of [[{ ...here, checkIns: null }, "this host's daemon gave no check-in status"],
[{ ...here, checkIns: { hostId: "host-b", members: checkIns } }, "it's listed on another host, whose check-ins this host doesn't hold"],
[{ ...here, checkIns: { hostId: null, members: checkIns } }, "it's listed on another host, whose check-ins this host doesn't hold"],
[{ ...here, checkIns: { hostId: "host-a", members: [{ ...checkIns[0], status: "invented" }] } }, "this host's daemon gave no status for it"]] as const) {
expect(enrichRosterMember(bot, missing).live.checkIn).toEqual({ status: "unknown", lastCheckInAt: null, expectedMinutes: 60, onRequest: false, reason });
}
// The roster line is the daemon's own sentence for each status.
expect(formatCheckIn({ status: "unknown", lastCheckInAt: null, expectedMinutes: null, reason: "its check-ins couldn't be read" }))
.toBe("unknown (its check-ins couldn't be read)");
expect(formatCheckIn({ status: "no-check-in-yet", lastCheckInAt: null, expectedMinutes: null })).toBe("no check-in yet; no check-in cadence declared");
expect(formatCheckIn({ status: "no-check-in-yet", lastCheckInAt: null, expectedMinutes: 60 })).toBe("no check-in yet; expected every 60 min");
expect(formatCheckIn({ status: "on-request", lastCheckInAt: "2026-11-12T09:00:00.000Z", expectedMinutes: null, onRequest: true }))
.toBe("on request: it checks its queue when a person asks it; last check-in 2026-11-12T09:00:00.000Z");
expect(formatCheckIn({ status: "invented", lastCheckInAt: null, expectedMinutes: null })).toBe("unknown (its check-in status couldn't be read)");
expect(enrichRosterMember(member, here).live).not.toHaveProperty("checkIn");
});
it("a team with no terminal seat still shows its members' check-ins: the daemon names its own host with them", () => {
const bot = { ...member, seat: "bot@team", host: "host-a", hosted: true, check_in_minutes: 60 };
const fresh = { seat: "bot@team", rung: false, status: "checking-in", lastCheckInAt: "2026-11-12T09:55:00.000Z", expectedMinutes: 60, reporter: "openrig-observed" };
const noSeats: RosterInventory = { ...inventory([]), localHostId: null, checkIns: { hostId: "host-a", members: [fresh] } };
expect(enrichRosterMember(bot, noSeats).live.checkIn).toMatchObject({ status: "checking-in", lastCheckInAt: fresh.lastCheckInAt });
});
it("keeps check_in_on_request: true; any other value, both statements at once, or a member not marked hosted loads with its reason", () => {
const asked = { ...member, seat: "bot@team", hosted: true, check_in_on_request: true };
expect(parseRoster({ ...authored, members: [asked] }).members).toEqual([asked]);
for (const [bad, reason] of [[{ ...asked, check_in_on_request: false }, /isn't true/], [{ ...asked, check_in_minutes: 60 }, /both/],
[{ ...member, check_in_on_request: true }, /connected assistant/]] as const) {
const [loaded] = parseRoster({ ...authored, members: [bad] }).members;
expect(loaded).not.toHaveProperty("check_in_on_request");
expect(loaded).not.toHaveProperty("check_in_minutes");
expect(loaded!.check_in_problem).toMatch(reason);
}
});
+2 -5
View File
@@ -166,7 +166,7 @@ person needs in their project or home folder and name that path.
(never the logical ID or a guessed name) and send:
`rig send <canonicalSessionName> 'This is the agent that installed OpenRig. The person will answer in your pane. Goal: <goal>. Project folder: <absolute path>. Branch: <branch>.'`
If the person stays here, replace "The person will answer in your pane" with "The person stays with me; send me your questions and
results to relay." Carry their constraints and any permission choice they made, chosen or declined, in either handoff.
results to relay." Carry their constraints and any declined permission choices in either handoff.
Or the person types the goal and folder in the operator's pane. If they give you a goal later, forward it the same
way. The operator helps them pick a team that fits their logins, starts it (`rig up`) only on their yes to the plan it
shows, and gives the goal to the team's lead. Before that yes, it says what launching into their folder changes for their own agent there; relay
@@ -190,7 +190,7 @@ person needs in their project or home folder and name that path.
(`--dry-run` first). If it isn't, or you can't tell, say so and offer their choices: sign in to Claude Code, or give the
seat a model their account can run with `rig seat set-model`, then start it again with
`rig seat launch <seat> --fresh --stop --reason "…"` and check the model it reports. Either way the operator starts a
new conversation without your handoff, so send it again: the goal, folder, branch, constraints and permission choices.
new conversation without your handoff, so send it again: the goal, folder, branch, constraints and declined choices.
- **Installation is finished** when the operator is ready and the person is talking to it; a healthy daemon alone isn't
that. If they'd rather talk later, keep that choice, leave them the exact connection step and say the handoff to the
operator is still pending.
@@ -207,9 +207,6 @@ person needs in their project or home folder and name that path.
whole action, answer only when it's clearly inside what they said, and tell them you answered under that instruction,
not that they said yes. Bring anything unclear or outside it back to them; a command's name or a path that looks like
their project doesn't show what the action touches. Their instruction changes no permission setting.
Don't send them to a bare `tmux attach` to answer: relay their choice this way, or point them to that seat in the
team's terminals the operator opened. If they want fewer prompts, the operator offers once to allow the commands
that keep asking, for their project only (`applying-a-permission-policy`); tell it what they chose.
Another kind of menu you can answer with
the key that screen shows, or ask the person. Capture it again before sending anything more.
- **Replies:** in a plain shell, other agents can't type into this session. Read work sent to you with
-4
View File
@@ -31,10 +31,6 @@
"types": "./dist/rosters-surface.d.ts",
"import": "./dist/rosters-surface.js"
},
"./connection-contract": {
"types": "./dist/connection-contract-surface.d.ts",
"import": "./dist/connection-contract-surface.js"
},
"./gateway-protocol": {
"types": "./dist/gateway-protocol-surface.d.ts",
"import": "./dist/gateway-protocol-surface.js"
@@ -119,8 +119,7 @@ configurations, as below; do not take the pin from a cached page summary.
from the installing agent; do not ask for them again. If the goal is missing,
ask once: "What would you like to build or change?" If they already named a
team, use it and skip the questions it answers. Keep any choice the
handoff carries, such as a trust or hook-review prompt they declined or
a permission choice they made.
handoff carries, such as a trust or hook-review prompt they declined.
2. **Where it works.** If the folder is missing, ask which folder the team should
work in (usually a clone of their repository) and use its absolute path. Your
own working directory is OpenRig's: the workspace's `kernel` folder (on an older
@@ -250,30 +249,11 @@ configurations, as below; do not take the pin from a cached page summary.
separates project files, not that Codex home. `rig setup` lists these writes. If they'd rather keep
their folder as it is, offer a separate copy, such as a git worktree.
If the plan declares non-interruptive or broad access, say so plainly before asking for their yes.
Before their yes, explain the team's permission defaults. Claude's team
default adds allowances for ordinary `rig` commands, project reads and
common tests. Branches, commits, running their program, installs and other
shell commands may ask, depending on existing native rules. A Codex seat
uses its configured writable roots and may ask for broader access. When a
prompt appears, they can answer in that seat's terminal or through you.
Their own agent's mode isn't the team's; existing user and project rules
can still affect the seats. Their yes starts the team; it isn't a yes to
later prompts.
- **A permission choice already made** (in the handoff or to you: fewer
prompts for this project, a mode, a rule, or keep prompts on): use it,
and don't ask again.
- **Otherwise, one offer if they want fewer interruptions:** name the
command families this goal will repeat in their folder (for example
`git switch`, `git add`, `git commit` and the project's test command)
and offer to allow just those, for this project only. For Claude seats
that's allow rules in the project's `.claude/settings.local.json`,
normally loaded by Claude sessions in that folder, including their own,
under the default setting sources; for a Codex seat, follow the same
skill's Codex rules. On their yes, apply it with
`applying-a-permission-policy`. Say what still asks: a command joined
with `&&`, `;` or a pipe asks unless every part is allowed, and on Claude
seats OpenRig's lifecycle commands still ask. On a no, keep the team
default and don't offer again.
If they keep permission prompts on, say what that will be like before
their yes: each seat stops and asks before an action its permissions
don't already cover, the asks come often, especially early, and each one
needs their answer, in the seat's pane or through you. Their yes starts
the team; it isn't a yes to those later asks.
For a team that already exists, `rig seat status <seat>` shows each seat's
planned permissions: the native mode, where it comes from, what OpenRig
adds and what isn't known. Read it to them as planned, not observed.
@@ -359,14 +339,10 @@ configurations, as below; do not take the pin from a cached page summary.
names. Tell them it's a usage limit, which seat, and when it resets, with
the menu's choices in its own words. One of them may spend usage credits,
so the choice is theirs.
When you see one, tell them where it is and where they can answer: which
seat, what its prompt asks (`rig capture <session>` with the seat's
`canonicalSessionName` shows it), and that seat's terminal in the team's
space from step 8, or through you. Only when no space is open, give the
seat's `tmuxAttachCommand` as a command for a new terminal, with one
line on what it does (it shows that seat; closing the window leaves the
seat running), or just its name if it has none. Never send them to a
bare `tmux attach`. Don't answer another seat's prompt yourself.
When you see one, tell them where they already are: which seat, what its
prompt asks (`rig capture <session>` with the seat's
`canonicalSessionName` shows it) and the seat's `tmuxAttachCommand`, or
just its name if it has none. Don't answer another seat's prompt yourself.
With prompts on, several seats may wait at once. Gather them, then tell
the person in one message, one line per prompt: the seat, the exact
action it asks to take (the command or file), and the choices the prompt
@@ -390,14 +366,12 @@ configurations, as below; do not take the pin from a cached page summary.
and check the result before sending anything more; never resend an answer
whose effect you can't see. Otherwise they answer in the seat's pane.
Grouping can shorten the asking, not the number of prompts. If they find
the prompts too many and haven't already chosen, make step 6's one offer,
named from the commands that actually asked: allow those command families
for this project, applied on their yes with `applying-a-permission-policy`,
which says how to check it took effect in the seats. Their other choices
stay open: keep prompts on; take a prompt's own standing approval for one
command when it offers one; `rig seat set-permissions` for later launches;
or Workshop's broad-access bundle. An answer to a prompt never changes a
permission mode.
the prompts too many, lay out their choices without picking one: keep
prompts on; take a prompt's own standing approval for one command when it
offers one; or change the team's permissions deliberately, as a separate
choice (`applying-a-permission-policy`, `rig seat set-permissions` for
later launches, or Workshop's broad-access bundle). An answer to a prompt
never changes a permission mode.
Avoid these:
- launching a team without the person's yes;
@@ -1,3 +0,0 @@
// The `@openrig/daemon/connection-contract` surface: the shared outcome and status words, so the CLI and the daemon
// read them from one place.
export * from "./domain/connection-contract.js";
@@ -42,102 +42,6 @@ export interface ReportedFact<F extends string> {
reporter: Reporter;
}
/** Whether a member outside any terminal is checking its queue. It's a status, never a refusal: work addressed to the
* member queues and is rung exactly as before, a check-in isn't pickup (pickup is a claim), and no check-in isn't
* idle, failure or safe. */
export type CheckInStatus =
/** Its last check-in is no older than twice its stated cadence (`CHECK_IN_OVERDUE_CADENCES`). */
| "checking-in"
/** Its last check-in is older than twice its stated cadence: its schedule may be off or waiting for an approval. */
| "check-in-overdue"
/** No check-in recorded for it. */
| "no-check-in-yet"
/** Its setup says it checks its queue when a person asks it (`check_in_on_request`), not on a schedule. */
| "on-request"
/** Its connection was revoked or removed, as recorded. Never inferred from silence. Nothing records a revocation yet,
* so nothing emits it: a revoked member stops checking in and reads overdue. */
| "disconnected"
/** Its setup states no cadence, its stated one can't be used, or its check-ins couldn't be read (the reason says
* which). Never idle and never overdue: with no stated cadence, OpenRig assumes none. */
| "unknown";
/** How each check-in status reads to a person. */
export const CHECK_IN_DISPLAY: Readonly<Record<CheckInStatus, string>> = {
"checking-in": "checking in",
"check-in-overdue": "overdue",
"no-check-in-yet": "no check-in yet",
"on-request": "on request",
"disconnected": "disconnected",
"unknown": "unknown",
};
/** The one rule for a stated cadence: a last check-in no older than this many cadences reads checking in, and an older
* one overdue. One missed run, plus its schedule's own jitter, isn't overdue. A cadence is its person's statement, so
* neither reading proves a vendor schedule or a pickup. */
export const CHECK_IN_OVERDUE_CADENCES = 2;
export interface MemberCheckIn {
status: CheckInStatus;
/** Its own latest read of its pending inbox, as OpenRig observed it. */
lastCheckInAt: string | null;
/** The cadence its person stated at setup; null when none was. */
expectedMinutes: number | null;
/** Its setup says it checks in when a person asks it. */
onRequest: boolean;
reporter: Reporter;
/** Why the status is unknown, when it is. */
reason?: string;
}
/** A connected assistant's check-in status from what its setup states (a cadence, or on request) and its last observed
* check-in (`seen`, `never`, or `unknown` when the record can't be read). First match wins: disconnected, unknown (an
* unusable statement or an unreadable record), no check-in yet, on request, then checking in or overdue by its cadence,
* else unknown because it states none. Nothing passes `revokedAt` yet, so nothing reads disconnected. */
export function memberCheckInStatus(input: {
checkInMinutes: number | null | undefined;
/** Its setup says it checks in on request rather than on a schedule. */
onRequest?: boolean;
/** Why the stated cadence can't be used, from the roster. */
cadenceProblem?: string | null;
checkIn: { state: "seen"; at: string } | { state: "never" } | { state: "unknown" };
revokedAt?: string | null;
now: Date;
}): MemberCheckIn {
const expectedMinutes = input.checkInMinutes ?? null;
const lastCheckInAt = input.checkIn.state === "seen" ? input.checkIn.at : null;
const onRequest = input.onRequest === true;
const unknown = (reason: string): MemberCheckIn => ({ status: "unknown", lastCheckInAt, expectedMinutes, onRequest, reporter: "openrig-observed", reason });
const known = (status: CheckInStatus): MemberCheckIn => ({ status, lastCheckInAt, expectedMinutes, onRequest, reporter: "openrig-observed" });
if (input.revokedAt) return known("disconnected");
if (input.cadenceProblem) return unknown(input.cadenceProblem);
if (input.checkIn.state === "unknown") return unknown("its check-ins couldn't be read");
if (input.checkIn.state === "never") return known("no-check-in-yet");
if (onRequest) return known("on-request");
if (expectedMinutes === null) return unknown(NO_CADENCE_STATED);
const age = input.now.getTime() - Date.parse(input.checkIn.at);
if (!Number.isFinite(age)) return unknown("its last check-in's time couldn't be read");
return known(age <= expectedMinutes * CHECK_IN_OVERDUE_CADENCES * 60_000 ? "checking-in" : "check-in-overdue");
}
/** Why a connected assistant whose setup states neither a cadence nor on request reads unknown. */
export const NO_CADENCE_STATED = "no check-in cadence declared";
/** A connected assistant's check-in in one line for a person, with its evidence: "overdue: last check-in 13:05,
* expected every 60 min; its schedule may be off or waiting for an approval". */
export function describeCheckIn(c: Pick<MemberCheckIn, "status" | "lastCheckInAt" | "expectedMinutes" | "onRequest" | "reason">): string {
const last = c.lastCheckInAt ? `last check-in ${c.lastCheckInAt}` : null;
const expected = c.expectedMinutes !== null ? `expected every ${c.expectedMinutes} min`
: c.onRequest ? "checks in when a person asks it" : NO_CADENCE_STATED;
switch (c.status) {
case "checking-in": return `checking in: ${last}, ${expected}`;
case "check-in-overdue": return `overdue: ${last}, ${expected}; its schedule may be off or waiting for an approval`;
case "no-check-in-yet": return `no check-in yet; ${expected}`;
case "on-request": return `on request: it checks its queue when a person asks it${last ? `; ${last}` : ""}`;
case "disconnected": return "disconnected: its connection was revoked or removed";
case "unknown": return `unknown (${c.reason ?? "its check-in status couldn't be read"})${last ? `; ${last}` : ""}`;
}
}
/** The route a wake took. `none` means no route can carry it (a pull-only member, an address nothing holds). */
export type WakeRoute = "terminal" | "gateway" | "hosted-trigger" | "none";
+1 -33
View File
@@ -85,8 +85,7 @@ import { parseReplyToChoice, parseReplyToThreadMissing, formatReplyToChoice, des
import { classifyDestination, type DestinationClass } from "./gateway/destination-resolver.js";
import { join } from "node:path";
import { SettingsStore } from "./user-settings/settings-store.js";
import { hostedMembersAt, hostedWakeFor, isHostedMemberAt, type RosterMember, type RosterWake } from "./rosters.js";
import { memberCheckInStatus, type MemberCheckIn } from "./connection-contract.js";
import { hostedWakeFor, isHostedMemberAt, type RosterWake } from "./rosters.js";
import { hostedRingMayHaveArrived, ringHostedWake } from "./hosted-wake.js";
import { effectiveGateRoles } from "./gate-predicate.js";
import {
@@ -652,16 +651,6 @@ function defaultIsHostedMember(destination: string): boolean {
}
}
/** The hosted members the authored rosters list on this host. Empty when the rosters or this host's id can't be read. */
function defaultHostedMembers(): RosterMember[] {
try {
const root = new SettingsStore().resolveOne("workspace.root").value;
return typeof root === "string" && root ? hostedMembersAt(join(root, "rosters"), getSelfHostId()) : [];
} catch {
return [];
}
}
/** A session's latest read of its own pending inbox: when it was seen, never seen, or unknown (the record can't be read). */
export type InboxCheckIn = { state: "seen"; at: string } | { state: "never" } | { state: "unknown" };
@@ -1109,7 +1098,6 @@ export class QueueRepository {
private readonly hostedWakeFn: (destination: string) => RosterWake | null;
/** Whether the rosters list an address as a hosted member, trigger or not. */
private readonly hostedMemberFn: (destination: string) => boolean;
private readonly hostedMembersFn: () => RosterMember[];
private readonly permissionDecider?: (ask: QueueItem, requester: string) => { session: string; describe?: string } | null;
private readonly hostedWakeFetch: typeof fetch | undefined;
/** When this process last wrote each session's inbox check-in, so a seat that polls often writes at most once a minute. */
@@ -1163,7 +1151,6 @@ export class QueueRepository {
sharedChannels?: () => ReadonlyArray<{ address: string }>;
hostedWake?: (destination: string) => RosterWake | null;
isHostedMember?: (destination: string) => boolean;
hostedMembers?: () => RosterMember[];
hostedWakeFetch?: typeof fetch;
/** Who decides a runner's permission ask after its person answers, while the runner waits (startup wires the
* receiving-agent order, `permissionDeciderFor`). Absent, or null for an ask: the ask closes with the answer, as
@@ -1187,7 +1174,6 @@ export class QueueRepository {
this.sharedChannelsFn = opts?.sharedChannels ?? (() => sharedChannelAddresses());
this.hostedWakeFn = opts?.hostedWake ?? defaultHostedWake;
this.hostedMemberFn = opts?.isHostedMember ?? defaultIsHostedMember;
this.hostedMembersFn = opts?.hostedMembers ?? defaultHostedMembers;
this.permissionDecider = opts?.permissionDecider;
this.hostedWakeFetch = opts?.hostedWakeFetch;
this.hasTargetRepoColumn = detectQueueColumn(db, "target_repo");
@@ -5285,24 +5271,6 @@ export class QueueRepository {
}
}
/** Each hosted member on this host with its check-in status: its stated cadence against its own inbox reads. A status
* only; nothing here changes how work reaches it. */
hostedMemberCheckIns(now: Date = new Date()): Array<{ seat: string; rung: boolean } & MemberCheckIn> {
return this.hostedMembersFn().map((m) => ({ seat: m.seat, rung: m.wake !== undefined,
...memberCheckInStatus({ checkInMinutes: m.check_in_minutes, onRequest: m.check_in_on_request, cadenceProblem: m.check_in_problem,
checkIn: this.lastInboxCheckIn(m.seat), now }) }));
}
/** One member's check-in status, as `hostedMemberCheckIns` gives it. A seat the rosters don't list here (or can't be
* read for) states no cadence, so it reads by its check-ins alone. */
hostedMemberCheckIn(seat: string, now: Date = new Date()): MemberCheckIn {
let member: RosterMember | undefined;
try { member = this.hostedMembersFn().find((m) => this.isSameSession(m.seat, seat)); } catch { member = undefined; }
// The roster's spelling of the seat, as the list read uses, so both read the same check-ins.
return memberCheckInStatus({ checkInMinutes: member?.check_in_minutes, onRequest: member?.check_in_on_request,
cadenceProblem: member?.check_in_problem, checkIn: this.lastInboxCheckIn(member?.seat ?? seat), now });
}
/** This session's latest recorded check-in under any of its names. */
lastInboxCheckIn(session: string): InboxCheckIn {
try {
+14 -30
View File
@@ -30,11 +30,11 @@ import { resolveOperatorSeat, resolveRecoveryAgentSeat, resolveSessionNodeId } f
import { nodeExecutorStoppedReason } from "./node-inventory.js";
import { isBoundTerminalSeat } from "./terminal-seat.js";
import type Database from "better-sqlite3";
import { createLinkedSuccessorLookup, deriveCrossHostSuccessorId, hasLinkedSuccessor, matchesRecordedHandoffTarget, QueueRepositoryError, type QueueItem, type QueueRepository } from "./queue-repository.js";
import { createLinkedSuccessorLookup, deriveCrossHostSuccessorId, hasLinkedSuccessor, matchesRecordedHandoffTarget, QueueRepositoryError, type InboxCheckIn, type QueueItem, type QueueRepository } from "./queue-repository.js";
import { stalledPickupFinding } from "./queue-pickup.js";
import { SettingsStore } from "./user-settings/settings-store.js";
import { loadHostRegistry } from "./hosts/hosts-registry-reader.js";
import { describeCheckIn, readWakeOutcome, type MemberCheckIn } from "./connection-contract.js";
import { readWakeOutcome } from "./connection-contract.js";
export const STUCK_SWEEP_INTERVAL_KEY = "queue.stuck_sweep_interval_seconds";
export const DEFAULT_STUCK_SWEEP_INTERVAL_SECONDS = 300;
@@ -621,36 +621,20 @@ function unclaimedBacklogText(seat: string, rows: BacklogRow[], thresholdMinutes
* and what to do. Its work's age runs from each row's own time, which a check-in never resets. Built from the rows' fixed
* facts and the member's check-in state, never a current check-in's exact time, so a pass rewrites it only when the rows
* or that state change. */
function polledBacklogText(member: string, rows: BacklogRow[], thresholdMinutes: number, checkIn: MemberCheckIn,
function polledBacklogText(member: string, rows: BacklogRow[], thresholdMinutes: number, checkIn: InboxCheckIn,
now: Date): { summary: string; body: string } {
const { sorted, listed, count } = backlogListing(rows);
// The member's shared check-in status, in the same words `rig roster` uses (`describeCheckIn`). Only the words follow
// it; when this notice is filed doesn't. Where that line would carry the time of a check-in that keeps moving (checking
// in, on request, unknown), the notice says instead whether the member read its inbox within the threshold, so a
// check-in rewrites the notice only when it changes that, never its time alone. An overdue or never-seen member's line
// is fixed until it checks in, so it keeps its evidence.
const at = checkIn.lastCheckInAt;
const lately = at !== null && now.getTime() - Date.parse(at) <= thresholdMinutes * 60_000;
const recency = at === null ? "" : lately ? `; it read its own inbox within the last ${thresholdMinutes} min` : `; no check-in since ${at}`;
const recent = checkIn.status === "checking-in" || ((checkIn.status === "on-request" || checkIn.status === "unknown") && lately);
const checkIns = ((): string => {
switch (checkIn.status) {
case "checking-in": return `checking in: its last check-in is within twice its expected ${checkIn.expectedMinutes} min`;
case "on-request": return `on request: it checks its queue when a person asks it${recency}`;
case "unknown": return `unknown (${checkIn.reason ?? "its check-in status couldn't be read"})${recency}`;
case "check-in-overdue":
case "no-check-in-yet":
case "disconnected":
return describeCheckIn(checkIn);
}
})();
const recent = checkIn.state === "seen" && now.getTime() - Date.parse(checkIn.at) <= thresholdMinutes * 60_000;
const checkIns = recent
? `checking in: it read its own inbox within the last ${thresholdMinutes} min`
: checkIn.state === "seen"
? `no check-in observed since ${checkIn.at}`
: checkIn.state === "never" ? "no check-in observed yet" : "unknown: its check-ins couldn't be read";
const why = recent
? "it's reading its own inbox, but this work is still unclaimed. Check whether it can do this work, or route it to another seat"
: checkIn.status === "check-in-overdue"
? "nobody has claimed these rows, and it hasn't checked in as often as its setup expects. Ask its owner to check its schedule or a pending approval, or route the work to another seat"
: checkIn.status === "unknown" && at === null
? "nobody has claimed these rows, and whether it's reading its queue isn't known. Ask its owner, or route the work to another seat"
: "nobody has claimed these rows, and no read of its own inbox has been observed in that time. Ask its owner to check it's running, or route the work to another seat";
? "it's checking in, but this work is still unclaimed. Check whether it can do this work, or route it to another seat"
: checkIn.state === "unknown"
? "nobody has claimed these rows, and whether it's reading its queue isn't known. Ask its owner, or route the work to another seat"
: "nobody has claimed these rows, and no read of its own inbox has been observed in that time. Ask its owner to check it's running, or route the work to another seat";
return {
summary: `Stuck sweep: unclaimed-backlog on ${member} (${count})`,
body:
@@ -1511,7 +1495,7 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
// A polling member's notice says whether it's checking in; an agent seat's names the route to add instead.
const polled = rows.length > 0 ? rows.some(polledUnclaimed) : pollsForWork(seat);
const backlogText = (standsFor: BacklogRow[]) => polled
? polledBacklogText(seat, standsFor, ageMinutes, deps.queueRepo.hostedMemberCheckIn(seat, now), now)
? polledBacklogText(seat, standsFor, ageMinutes, deps.queueRepo.lastInboxCheckIn(seat), now)
: unclaimedBacklogText(seat, standsFor, ageMinutes, node);
// A polling member's notice goes where its rows' findings went: the person, else their creator when they share one,
// else the operator agent. With none, it stays with the member, which reads it when it next checks.
@@ -256,13 +256,6 @@ export class QueueWakeRepository {
row.state === "blocked" && this.getStatus(row.qitemId)?.ref === jobId);
}
/** An ordinary one-shot park has no later cycle to replace a failed wake.
* Repeating waits and provider-limit expiry keep their own existing lifecycle. */
hasOwedOneShotParkWake(jobId: string): boolean {
return this.findCurrentQitemsByGeneratedTimer(jobId).some(row => !this.isUsageLimitBlocker(row.qitemId))
&& !this.isRepeatingTimer(jobId);
}
/** Active park-generated timers that are still their blocked row's current wake and still target the
* row's owner. These wake the seat that owns the row, not a particular occupant, so a seat swap keeps
* them; any other job the retiring occupant registered still stops. */
+1 -30
View File
@@ -26,14 +26,6 @@ export interface RosterMember extends Reference {
hosted?: boolean;
/** A hosted member's trigger, rung when a row lands for it. */
wake?: RosterWake;
/** How often a hosted member is expected to check its queue, in minutes, as its person set it up (an hourly schedule
* is 60). Stated, not observed: absent means it checks only when a person prompts it. */
check_in_minutes?: number;
/** Its person set it to check in when asked, not on a schedule. */
check_in_on_request?: true;
/** Why an authored `check_in_minutes` can't be used. The roster still loads; the member's check-in status reads
* unknown with this reason. */
check_in_problem?: string;
}
export interface Roster {
version: 1;
@@ -73,19 +65,9 @@ export function parseRoster(value: unknown): Roster {
|| (m.hosted !== undefined && typeof m.hosted !== "boolean")) {
throw new Error("Each member needs seat, host, capabilities, engagement, use_when and why (optional caveat, and hosted: true or false)");
}
// A cadence that can't be used never stops the roster loading: the member's check-in status reads unknown instead.
const cadence = m.check_in_minutes, onRequest = m.check_in_on_request;
const cadenceProblem = cadence === undefined && onRequest === undefined ? null
: m.hosted !== true ? "check_in_minutes and check_in_on_request are for a connected assistant (a member marked hosted: true)"
: onRequest !== undefined && onRequest !== true ? `check_in_on_request ${JSON.stringify(onRequest)} isn't true`
: onRequest === true && cadence !== undefined ? "it states both check_in_minutes and check_in_on_request"
: cadence !== undefined && (!Number.isInteger(cadence) || (cadence as number) <= 0) ? `check_in_minutes ${JSON.stringify(cadence)} isn't a positive whole number of minutes`
: null;
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 }),
...(m.hosted === true ? { hosted: true } : {}), ...(m.wake === undefined ? {} : { wake: parseWake(m.wake) }),
...(cadenceProblem ? { check_in_problem: cadenceProblem }
: { ...(cadence === undefined ? {} : { check_in_minutes: cadence as number }), ...(onRequest === true ? { check_in_on_request: true as const } : {}) }) };
...(m.hosted === true ? { hosted: true } : {}), ...(m.wake === undefined ? {} : { wake: parseWake(m.wake) }) };
});
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 };
@@ -121,17 +103,6 @@ export function hostedWakeFor(destination: string, folder: string, selfHostId: s
return null;
}
/** The hosted members the rosters list on this host, one per seat: where two rosters list the same seat, the first by
* file name wins. Empty when this host's id is unknown. */
export function hostedMembersAt(folder: string, selfHostId: string | null): RosterMember[] {
if (!selfHostId) return [];
const bySeat = new Map<string, RosterMember>();
for (const roster of readRosters(folder).rosters) {
for (const m of roster.members) if (m.hosted === true && m.host === selfHostId && !bySeat.has(m.seat)) bySeat.set(m.seat, m);
}
return [...bySeat.values()];
}
/** Whether a roster on this host lists this address as a hosted member, with or without a trigger. */
export function isHostedMemberAt(destination: string, folder: string, selfHostId: string | null): boolean {
if (!selfHostId) return false;
@@ -1335,11 +1335,10 @@ export class SeatHandoverService {
});
// (e/Class-B): capture the RETIRING occupant's generation BEFORE registerClaimedSession mints the
// successor's tenure below — after that mint the node's "current" generation IS the successor's
// (the name is reused), so this is the only point the retiree's generation is resolvable. It is read by
// this node, not by its session name: an earlier generation of the seat (an archived rig of the same
// name) can hold a newer session row under that name.
// (the name is reused), so this is the only point the retiree's generation is resolvable.
const retiringGeneration =
this.sessionRegistry.currentOccupantGenerationForSession(input.latestSession.session_name) ?? undefined;
const tenureBefore = this.readOccupantTenure(input.node.id);
const retiringGeneration = tenureBefore?.generationUuid ?? undefined;
// atom-B: a seat handover mints a HANDOVER-kind occupant generation (not the default 'adopt').
const newSession = this.sessionRegistry.registerClaimedSession(
input.node.id,
+32 -5
View File
@@ -3,6 +3,7 @@ import { performance } from "node:perf_hooks";
import { Worker } from "node:worker_threads";
import type { MiddlewareHandler } from "hono";
export const SLOW_OPERATION_BARRIER_TIMEOUT_MS = 250;
export const SLOW_OPERATION_THRESHOLD_MS = 250;
export const SLOW_OPERATION_ROTATION_BYTES = 1024 * 1024;
export const SLOW_OPERATION_ROTATION_COUNT = 3;
@@ -88,6 +89,7 @@ interface SlowOpRecorderOptions {
maxBytes?: number;
rotationCount?: number;
slowThresholdMs?: number;
barrierTimeoutMs?: number;
}
interface Span {
@@ -139,7 +141,13 @@ const WORKER_SOURCE = String.raw`
ok = false;
error = caught instanceof Error ? caught.message : String(caught);
}
if (message.id) parentPort.postMessage({ id: message.id, ok, error });
if (message.signal) {
const state = new Int32Array(message.signal);
Atomics.store(state, 0, ok ? 1 : 2);
Atomics.notify(state, 0);
} else if (message.id) {
parentPort.postMessage({ id: message.id, ok, error });
}
});
`;
@@ -149,6 +157,7 @@ export class SlowOpRecorder implements SlowOperationInstrumentation {
private readonly maxBytes: number;
private readonly rotationCount: number;
private readonly slowThresholdMs: number;
private readonly barrierTimeoutMs: number;
private readonly pending = new Map<string, { resolve: () => void; reject: (error: unknown) => void }>();
private degraded: SlowOperationSnapshot = { healthy: true };
private degradedHandler?: (snapshot: Required<Pick<SlowOperationSnapshot, "reason" | "site">>) => void;
@@ -172,6 +181,7 @@ export class SlowOpRecorder implements SlowOperationInstrumentation {
this.maxBytes = options.maxBytes ?? SLOW_OPERATION_ROTATION_BYTES;
this.rotationCount = options.rotationCount ?? SLOW_OPERATION_ROTATION_COUNT;
this.slowThresholdMs = options.slowThresholdMs ?? SLOW_OPERATION_THRESHOLD_MS;
this.barrierTimeoutMs = options.barrierTimeoutMs ?? SLOW_OPERATION_BARRIER_TIMEOUT_MS;
this.worker = new Worker(WORKER_SOURCE, { eval: true, execArgv: [] });
this.worker.unref();
this.worker.on("message", (message: { id?: string; ok?: boolean }) => {
@@ -210,10 +220,27 @@ export class SlowOpRecorder implements SlowOperationInstrumentation {
beginSyncSpan(site: string): Span {
const span: Span = { spanId: randomUUID(), site, startedAt: performance.now() };
// Queue before the operation without waiting on worker startup or fsync. The
// worker can write even while the main thread is wedged; only flush/close
// acknowledge durability. A crash before the worker appends can lose begin.
this.appendAsync({ v: 1, ts: new Date().toISOString(), spanId: span.spanId, phase: "begin", site });
const signal = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
const state = new Int32Array(signal);
try {
this.worker.postMessage({
type: "append",
signal,
logPath: this.logPath,
maxBytes: this.maxBytes,
rotationCount: this.rotationCount,
record: { v: 1, ts: new Date().toISOString(), spanId: span.spanId, phase: "begin", site },
});
} catch {
// Synchronous postMessage failure means the Worker is gone: degrade
// through the terminal transition but still return the span so the
// wrapped operation runs to completion (its value/error stays exact).
this.handleTerminalFailure("recorder_post_failed", site);
return span;
}
const wait = Atomics.wait(state, 0, 0, this.barrierTimeoutMs);
if (wait === "timed-out") this.markDegraded("begin_barrier_timeout", site);
else if (Atomics.load(state, 0) !== 1) this.markDegraded("begin_barrier_failed", site);
return span;
}
@@ -1,6 +1,6 @@
import { execFile } from "node:child_process";
import { spawnSync } from "node:child_process";
import type { TmuxAdapter } from "../adapters/tmux.js";
import { runAsyncSite } from "./sync-site-wrap.js";
import { runSyncSite } from "./sync-site-wrap.js";
export interface TmuxOptionDefaultsDeps {
tmuxAdapter: TmuxAdapter;
@@ -17,11 +17,11 @@ export interface TmuxOptionDefaultsDeps {
*/
platform?: NodeJS.Platform;
/**
* OPR.0.4.6.02 S1 — asynchronous `command -v <bin>` probe for the linux
* copy-command fallback chain. Defaults to a bounded asynchronous shell probe; injectable
* OPR.0.4.6.02 S1 — cheap `command -v <bin>` probe for the linux
* copy-command fallback chain. Defaults to a real shell probe; injectable
* for deterministic tests.
*/
hasCommand?: (bin: string) => boolean | Promise<boolean>;
hasCommand?: (bin: string) => boolean;
}
/**
@@ -44,7 +44,7 @@ export class TmuxOptionDefaultsApplier {
private tmuxAdapter: TmuxAdapter;
private readTmuxOptionDefaults: () => { statusBar: boolean };
private platform: NodeJS.Platform;
private hasCommand: (bin: string) => boolean | Promise<boolean>;
private hasCommand: (bin: string) => boolean;
private serverDefaultsAsserted = false;
constructor(deps: TmuxOptionDefaultsDeps) {
@@ -120,14 +120,7 @@ export class TmuxOptionDefaultsApplier {
warnings.push(`tmux "set-clipboard" server option not set: ${clip.message}`);
}
let copyCommand: string | null;
try {
copyCommand = await resolveCopyCommand(this.platform, this.hasCommand);
} catch {
this.serverDefaultsAsserted = false;
warnings.push("Clipboard lookup interrupted (250 ms limit); copy-command left unchanged (OSC 52 if unset). Will retry on the next fresh session.");
return;
}
const copyCommand = resolveCopyCommand(this.platform, this.hasCommand);
if (copyCommand !== null) {
const cc = await this.tmuxAdapter.setServerOption("copy-command", copyCommand);
if (!cc.ok) {
@@ -148,14 +141,14 @@ export class TmuxOptionDefaultsApplier {
* Pure: platform + a `command -v` probe in, the command string (or null)
* out. One table, no side effects — the applier decides whether to write.
*/
export async function resolveCopyCommand(
export function resolveCopyCommand(
platform: NodeJS.Platform,
hasCommand: (bin: string) => boolean | Promise<boolean>,
): Promise<string | null> {
hasCommand: (bin: string) => boolean,
): string | null {
if (platform === "darwin") return "pbcopy";
if (platform === "linux") {
if (await hasCommand("wl-copy")) return "wl-copy";
if (await hasCommand("xclip")) return "xclip -selection clipboard -i";
if (hasCommand("wl-copy")) return "wl-copy";
if (hasCommand("xclip")) return "xclip -selection clipboard -i";
return null;
}
return null;
@@ -163,18 +156,16 @@ export async function resolveCopyCommand(
/**
* OPR.0.4.6.02 S1 — default `command -v <bin>` probe (POSIX shell builtin).
* Returns true when the binary resolves on PATH. A killed probe rejects so
* the applier can warn and retry; other failures read as absent.
* Returns true when the binary resolves on PATH. Cheap + best-effort; any
* failure (spawn error, non-shell env) reads as absent.
*/
async function defaultHasCommand(bin: string): Promise<boolean> {
function defaultHasCommand(bin: string): boolean {
try {
return await runAsyncSite("tmux_options.command_v", () => new Promise<boolean>((resolve, reject) => {
execFile("/bin/sh", ["-c", 'command -v "$1"', "--", bin], {
timeout: 250, killSignal: "SIGKILL", maxBuffer: 1024,
}, error => error?.killed ? reject(error) : resolve(!error));
}));
} catch (error) {
if ((error as { killed?: boolean } | null)?.killed) throw error;
const r = runSyncSite("tmux_options.command_v", () =>
spawnSync(`command -v ${bin}`, { shell: true, stdio: "ignore" })
);
return r.status === 0;
} catch {
return false;
}
}
@@ -1,5 +1,4 @@
import { OutboxHandler } from "./outbox-handler.js";
import { QueueWakeRepository } from "./queue-wake-repository.js";
import { resolveWakeRetryCap, resolveWakeRetryIntervalSeconds } from "./queue-wake-ladder.js";
import type { DeliveryFn, DeliveryOutcome, WatchdogDeliverySource } from "./watchdog-policy-engine.js";
@@ -12,7 +11,6 @@ export function withWatchdogReceipts(send: DeliveryFn, deps: {
outbox: OutboxHandler; now?: () => Date; retryIntervalSeconds?: number; retryCap?: number;
}): DeliveryFn {
const { outbox } = deps;
const wakes = new QueueWakeRepository(outbox.db);
const now = deps.now ?? (() => new Date());
const interval = deps.retryIntervalSeconds ?? resolveWakeRetryIntervalSeconds();
const cap = deps.retryCap ?? resolveWakeRetryCap();
@@ -94,17 +92,14 @@ export function withWatchdogReceipts(send: DeliveryFn, deps: {
for (const member of selected) {
const receipt = read(member.id)!;
let retryAt: string | undefined, nextAction: string | undefined, terminalReason: string | undefined;
// No input reached the seat: keep this park's same occurrence owed until delivery or the park ends.
const owedParkWake = noInput && wakes.hasOwedOneShotParkWake(member.jobId);
const exhausted = receipt.attempts > cap && !owedParkWake;
const unavailable = permanent && !owedParkWake;
const exhausted = receipt.attempts > cap;
const state = result.status === "ok" ? "delivered" : result.status === "retained" ? "retained"
: noInput ? "failed" : "indeterminate";
if (result.status === "retained") {
nextAction = `Retained by existing input protection; inspect held messages ${(result.outboxIds ?? []).join(", ")}. Nothing was delivered.`;
}
if (result.status === "failed") {
if (noInput && unavailable) {
if (noInput && permanent) {
terminalReason = "watchdog_delivery_unavailable";
nextAction = `Inspect outbox ${member.id}: ${result.error ?? result.transportReason ?? "delivery failed"}. Fix the destination and register a replacement reminder.`;
} else if (noInput && exhausted) {
@@ -116,7 +111,7 @@ export function withWatchdogReceipts(send: DeliveryFn, deps: {
nextAction = `Inspect outbox ${member.id} and the recipient before any manual replay; input is unconfirmed. This occurrence ends; the next regular cycle is a new occurrence.`;
}
}
const ended = result.status === "failed" && (!noInput || unavailable || exhausted);
const ended = result.status === "failed" && (!noInput || permanent || exhausted);
outbox.finalizeDelivery(member.id, state);
save(member.id, { ...receipt, ...(ended ? { ended: true, error: result.error } : {}), ...(result.status === "retained" ? { heldIds: result.outboxIds ?? [] } : {}),
...(retryAt ? { retryAt } : {}), ...(nextAction ? { nextAction } : {}),
-5
View File
@@ -1441,11 +1441,6 @@ export function queueRoutes(): Hono {
return c.json(items);
});
// Each connected assistant on this host and whether it's checking its queue, with this daemon's own host id: the check-ins
// are this host's, so a reader matches members to it without needing a terminal seat to learn it. A status only:
// work addressed to an assistant that isn't checking in still queues and is rung as before. Precedes /:qitemId.
app.get("/hosted-members", (c) => c.json({ hostId: getSelfHostId(), members: getRepo(c).hostedMemberCheckIns() }));
// GET /undelivered — surfaces pending create-path nudge failures plus active
// human-notification episodes whose gateway delivery ledger failed or never posted.
// Rig-scoped + bounded + compact-by-default, mirroring /overdue.
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { describeCheckIn, memberCheckInStatus, readWakeOutcome } from "../src/domain/connection-contract.js";
import { readWakeOutcome } from "../src/domain/connection-contract.js";
import { NUDGE_FAIL_PREFIX } from "../src/domain/policies/parked-owner-consumer.js";
describe("readWakeOutcome: every recorded nudge result reads as a delivery fact", () => {
@@ -51,73 +51,3 @@ describe("readWakeOutcome: every recorded nudge result reads as a delivery fact"
expect(readWakeOutcome(undefined)).toBeNull();
});
});
describe("memberCheckInStatus: a hosted member's check-in status", () => {
const now = new Date("2026-11-12T12:00:00.000Z");
const seenAgo = (minutes: number) => ({ state: "seen" as const, at: new Date(now.getTime() - minutes * 60_000).toISOString() });
const status = (checkInMinutes: number | null, checkIn: Parameters<typeof memberCheckInStatus>[0]["checkIn"], revokedAt?: string) =>
memberCheckInStatus({ checkInMinutes, checkIn, now, ...(revokedAt ? { revokedAt } : {}) }).status;
it("with a stated cadence, one rule: checking in up to exactly twice it, overdue a moment after", () => {
const at = (ms: number) => ({ state: "seen" as const, at: new Date(now.getTime() - ms).toISOString() });
expect(status(60, seenAgo(5))).toBe("checking-in");
expect(status(60, seenAgo(90))).toBe("checking-in"); // past one cadence is not yet overdue
expect(status(60, at(2 * 60 * 60_000))).toBe("checking-in");
expect(status(60, at(2 * 60 * 60_000 + 1))).toBe("check-in-overdue");
expect(status(1, at(2 * 60_000 + 1))).toBe("check-in-overdue"); // any positive cadence works the same way
});
it("a stated cadence that can't be used reads unknown with its reason, ahead of every other reading", () => {
expect(memberCheckInStatus({ checkInMinutes: null, cadenceProblem: "check_in_minutes \"60\" isn't a positive whole number of minutes",
checkIn: seenAgo(5), now })).toMatchObject({ status: "unknown", reason: expect.stringContaining("check_in_minutes") });
expect(memberCheckInStatus({ checkInMinutes: 60, checkIn: { state: "unknown" }, now })).toMatchObject({ status: "unknown", reason: "its check-ins couldn't be read" });
});
it("with no stated cadence it reads unknown with its last check-in, never on request, idle or overdue", () => {
expect(memberCheckInStatus({ checkInMinutes: null, checkIn: seenAgo(5), now }))
.toMatchObject({ status: "unknown", reason: "no check-in cadence declared", lastCheckInAt: seenAgo(5).at });
expect(status(null, seenAgo(10_000))).toBe("unknown");
});
it("on request only when its setup says so, whatever its last check-in's age", () => {
const onRequest = (checkIn: Parameters<typeof memberCheckInStatus>[0]["checkIn"]) =>
memberCheckInStatus({ checkInMinutes: null, onRequest: true, checkIn, now });
expect(onRequest(seenAgo(5)).status).toBe("on-request");
expect(onRequest(seenAgo(10_000))).toMatchObject({ status: "on-request", onRequest: true });
expect(onRequest({ state: "never" }).status).toBe("no-check-in-yet");
expect(memberCheckInStatus({ checkInMinutes: undefined, checkIn: seenAgo(5), now })).toMatchObject({ lastCheckInAt: seenAgo(5).at, expectedMinutes: null });
});
it("no recorded check-in reads never, with or without a cadence; an unreadable record reads unknown, never idle or overdue", () => {
expect(status(60, { state: "never" })).toBe("no-check-in-yet");
expect(memberCheckInStatus({ checkInMinutes: null, checkIn: { state: "never" }, now })).toMatchObject({ status: "no-check-in-yet", expectedMinutes: null });
expect(status(60, { state: "unknown" })).toBe("unknown");
expect(status(60, { state: "seen", at: "not a time" })).toBe("unknown");
});
it("disconnected comes only from a recorded revocation, ahead of everything else; silence never produces it", () => {
expect(status(60, seenAgo(5), "2026-11-12T11:00:00.000Z")).toBe("disconnected");
expect(status(60, seenAgo(100_000))).toBe("check-in-overdue");
expect(status(null, { state: "never" })).not.toBe("disconnected");
});
});
describe("describeCheckIn: one line, with its evidence", () => {
const base = { lastCheckInAt: "2026-11-12T13:05:00.000Z", expectedMinutes: 60, onRequest: false };
it("overdue carries its last check-in, its expected cadence and the suspected reason", () => {
expect(describeCheckIn({ ...base, status: "check-in-overdue" }))
.toBe("overdue: last check-in 2026-11-12T13:05:00.000Z, expected every 60 min; its schedule may be off or waiting for an approval");
expect(describeCheckIn({ ...base, status: "checking-in" })).toBe("checking in: last check-in 2026-11-12T13:05:00.000Z, expected every 60 min");
});
it("names what setup says when there's no check-in, and gives an unknown its reason and last check-in", () => {
expect(describeCheckIn({ ...base, status: "no-check-in-yet", lastCheckInAt: null })).toBe("no check-in yet; expected every 60 min");
expect(describeCheckIn({ ...base, status: "no-check-in-yet", lastCheckInAt: null, expectedMinutes: null, onRequest: true }))
.toBe("no check-in yet; checks in when a person asks it");
expect(describeCheckIn({ ...base, status: "no-check-in-yet", lastCheckInAt: null, expectedMinutes: null }))
.toBe("no check-in yet; no check-in cadence declared");
expect(describeCheckIn({ ...base, status: "on-request", expectedMinutes: null, onRequest: true }))
.toBe("on request: it checks its queue when a person asks it; last check-in 2026-11-12T13:05:00.000Z");
expect(describeCheckIn({ ...base, status: "unknown", expectedMinutes: null, reason: "no check-in cadence declared" }))
.toBe("unknown (no check-in cadence declared); last check-in 2026-11-12T13:05:00.000Z");
});
});
@@ -187,44 +187,6 @@ describe("PluginVendorService — vendoring (HG-2.3)", () => {
expect(fs._store["/home/test/.openrig/plugins/openrig-core/.claude-plugin/plugin.json"]).toContain('"0.2.0"');
});
// The openrig plugin carries the CLI's release version from 0.7.13, so an install still at its earlier 0.1.0 refreshes;
// a daemon run from development source (still 0.1.0) leaves a newer released install as it is.
it("refreshes an openrig plugin installed at 0.1.0 to a release-stamped 0.7.13, backing up what it replaces, and never downgrades", async () => {
const manifest = (version: string) => `{"name":"openrig","version":"${version}"}`;
const bundled = (version: string, skill: string) => ({
"/asset-root/openrig/.claude-plugin/plugin.json": manifest(version),
"/asset-root/openrig/.codex-plugin/plugin.json": manifest(version),
"/asset-root/openrig/skills/working-with-an-openrig-team/SKILL.md": skill,
});
const installedSkill = "/fixture/home/.openrig/plugins/openrig/skills/working-with-an-openrig-team/SKILL.md";
const installedManifest = "/fixture/home/.openrig/plugins/openrig/.claude-plugin/plugin.json";
const service = (fs: ReturnType<typeof mockFs>) => new PluginVendorService({ vendoredAssetsDir: "/asset-root",
userPluginsDir: "/fixture/home/.openrig/plugins", fs, httpClient: vi.fn(), logger: vi.fn() });
const older = mockFs({ ...bundled("0.7.13", "# released 0.7.13"), [installedManifest]: manifest("0.1.0"), [installedSkill]: "# installed 0.1.0" });
await service(older).ensureVendored("openrig");
expect(older._store[installedSkill]).toBe("# released 0.7.13");
expect(older._store[installedManifest]).toBe(manifest("0.7.13"));
const backups = Object.entries(older._store).filter(([path]) => path.startsWith("/fixture/home/.openrig/backups/managed-refresh/"));
expect(backups.map(([path, text]) => [path.split("/").slice(-1)[0], text])).toEqual(expect.arrayContaining([["SKILL.md", "# installed 0.1.0"]]));
const devSource = mockFs({ ...bundled("0.1.0", "# development source"), [installedManifest]: manifest("0.7.13"), [installedSkill]: "# released 0.7.13" });
await service(devSource).ensureVendored("openrig");
expect(devSource._store[installedSkill]).toBe("# released 0.7.13");
expect(devSource._store[installedManifest]).toBe(manifest("0.7.13"));
// An openrig install with no version manifest was put there another way: left as it is, with no version invented.
const unversioned = mockFs({ ...bundled("0.7.13", "# released 0.7.13"), [installedSkill]: "# a person's own openrig copy" });
const before = { ...unversioned._store };
const logger = vi.fn();
await new PluginVendorService({ vendoredAssetsDir: "/asset-root", userPluginsDir: "/fixture/home/.openrig/plugins", fs: unversioned,
httpClient: vi.fn(), logger }).ensureVendored("openrig");
expect(unversioned._store).toEqual(before);
expect(unversioned.exists(installedManifest)).toBe(false);
expect(unversioned.exists("/fixture/home/.openrig/plugins/openrig/.codex-plugin/plugin.json")).toBe(false);
expect(logger).toHaveBeenCalledWith(expect.stringMatching(/existing plugin 'openrig' has no version authority .*leaving it unchanged/));
});
it("the shipped core upgrades 0.1.5 installs to daemon-publication instructions", async () => {
const source = "/asset-root/openrig-core";
const target = "/home/test/.openrig/plugins/openrig-core";
@@ -11,7 +11,7 @@ import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { QueueRepository, type QueueItem } from "../src/domain/queue-repository.js";
import { EventBus } from "../src/domain/event-bus.js";
import { queueRoutes } from "../src/routes/queue.js";
import type { RosterMember, RosterWake } from "../src/domain/rosters.js";
import type { RosterWake } from "../src/domain/rosters.js";
import { createStuckSweepStatus, runStuckSweep, STUCK_SWEEP_FINDING_TAG, unclaimedBacklogTag } from "../src/domain/queue-stuck-sweep.js";
const MEMBER = "helper-a@hub";
@@ -23,13 +23,12 @@ describe("stuck sweep: a hosted member that polls for its work", () => {
let repo: QueueRepository;
function makeRepo(opts: { hostedWake?: (d: string) => RosterWake | null; resolveSessionAddresses?: (n: string) => string[];
validateRig?: (d: string) => boolean; hostedMembers?: () => RosterMember[] } = {}): QueueRepository {
validateRig?: (d: string) => boolean } = {}): QueueRepository {
return new QueueRepository(db, bus, {
validateRig: opts.validateRig ?? (() => true),
loadHumanRegistry: () => ({ ok: true, entities: [] }),
isHostedMember: (d) => d === MEMBER,
hostedWake: opts.hostedWake ?? (() => null),
hostedMembers: opts.hostedMembers ?? (() => []),
...(opts.resolveSessionAddresses ? { resolveSessionAddresses: opts.resolveSessionAddresses } : {}),
});
}
@@ -115,88 +114,16 @@ describe("stuck sweep: a hosted member that polls for its work", () => {
expect(more).toEqual([]);
expect(notice!.destinationSession).toBe(PERSON);
expect(notice!.sourceSession).toBe(MEMBER);
expect(notice!.body).toContain(`check-ins: unknown (no check-in cadence declared); no check-in since ${at.toISOString()}`);
expect(notice!.body).toContain(`check-ins: no check-in observed since ${at.toISOString()}`);
expect(notice!.body).toContain("no read of its own inbox has been observed in that time. Ask its owner to check it's running, or route the work to another seat");
for (const row of rows) expect(notice!.body).toContain(`- ${row.qitemId}, unclaimed since ${row.tsCreated}, from lead@hub: work for ${MEMBER}`);
expect(notice!.body).not.toMatch(/dead/i);
});
/** The member as its roster lists it, with the cadence its person stated at setup (or none). */
const asListed = (checkInMinutes?: number): RosterMember[] => [{ seat: MEMBER, host: "hub", capabilities: ["research"],
engagement: ["delegate"], use_when: "research", why: "it researches", hosted: true,
...(checkInMinutes === undefined ? {} : { check_in_minutes: checkInMinutes }) }];
it("a stated cadence decides only the notice's check-in line, never when the notice is filed: overdue past twice it, checking in within it", async () => {
const at = new Date(Date.now() - 3 * 3_600_000);
repo = makeRepo({ hostedMembers: () => asListed(60) });
repo.recordInboxCheckIn(MEMBER, at);
await work(MEMBER, { ageMinutes: 61 });
await sweep();
expect(notices()[0]!.body).toContain(`check-ins: overdue: last check-in ${at.toISOString()}, expected every 60 min; its schedule may be off or waiting for an approval`);
expect(notices()[0]!.body).toContain("why: nobody has claimed these rows, and it hasn't checked in as often as its setup expects.");
// The same three-hour-old check-in is on time for a member expected every four hours, though older than the
// unclaimed age that judges a member with no stated cadence.
db.close();
db = new Database(":memory:");
migrate(db, ALL_MIGRATIONS);
bus = new EventBus(db);
repo = makeRepo({ hostedMembers: () => asListed(240) });
seedSeat("lead@hub");
repo.recordInboxCheckIn(MEMBER, at);
await work(MEMBER, { ageMinutes: 61 });
await sweep();
expect(notices()[0]!.body).toContain("check-ins: checking in: its last check-in is within twice its expected 240 min");
});
it("the notice says what the hosted-members read says: an unusable cadence reads unknown with its reason, even with a fresh check-in", async () => {
const member = { ...asListed()[0]!, check_in_problem: "check_in_minutes \"60\" isn't a positive whole number of minutes" };
repo = makeRepo({ hostedMembers: () => [member] });
repo.recordInboxCheckIn(MEMBER, new Date(Date.now() - 5 * 60_000));
await work(MEMBER, { ageMinutes: 61 });
await sweep();
expect(repo.hostedMemberCheckIns()[0]).toMatchObject({ status: "unknown", reason: member.check_in_problem });
expect(notices()[0]!.body).toContain(`check-ins: unknown (${member.check_in_problem}); it read its own inbox within the last 60 min`);
expect(notices()[0]!.body).not.toContain("checking in");
});
it("check-in status is a status, never a refusal: work for an overdue or never-seen member queues and is recorded as before", async () => {
repo = makeRepo({ hostedMembers: () => asListed(60) });
repo.recordInboxCheckIn(MEMBER, new Date(Date.now() - 5 * 3_600_000));
const overdue = await repo.create({ sourceSession: "lead@hub", destinationSession: MEMBER, body: "work", summary: "work" });
const unlisted = makeRepo();
const baseline = await unlisted.create({ sourceSession: "lead@hub", destinationSession: MEMBER, body: "work", summary: "work" });
expect(repo.getById(overdue.qitemId)).toMatchObject({ state: "pending", destinationSession: MEMBER });
expect(repo.getById(overdue.qitemId)!.lastNudgeResult).toBe(unlisted.getById(baseline.qitemId)!.lastNudgeResult);
expect(repo.hostedMemberCheckIns().map((m) => m.status)).toEqual(["check-in-overdue"]);
});
it("the hosted-members read gives each member's status, last check-in and stated cadence", async () => {
const members = [...asListed(60), { ...asListed()[0]!, seat: "helper-b@hub", check_in_on_request: true as const },
{ ...asListed(30)[0]!, seat: "helper-c@hub" }, { ...asListed()[0]!, seat: "helper-d@hub" }];
repo = makeRepo({ hostedMembers: () => members });
const at = new Date(Date.now() - 10 * 60_000);
repo.recordInboxCheckIn(MEMBER, at);
repo.recordInboxCheckIn("helper-b@hub", at);
repo.recordInboxCheckIn("helper-d@hub", at);
const res = await appFor(repo).request("/api/queue/hosted-members");
expect(res.status).toBe(200);
const body = await res.json() as { hostId: unknown; members: unknown };
expect(body).toHaveProperty("hostId");
expect(body.members).toEqual([
{ seat: MEMBER, rung: false, status: "checking-in", lastCheckInAt: at.toISOString(), expectedMinutes: 60, onRequest: false, reporter: "openrig-observed" },
{ seat: "helper-b@hub", rung: false, status: "on-request", lastCheckInAt: at.toISOString(), expectedMinutes: null, onRequest: true, reporter: "openrig-observed" },
{ seat: "helper-c@hub", rung: false, status: "no-check-in-yet", lastCheckInAt: null, expectedMinutes: 30, onRequest: false, reporter: "openrig-observed" },
// States neither a cadence nor on request: unknown, with its last check-in, never on request, idle or overdue.
{ seat: "helper-d@hub", rung: false, status: "unknown", lastCheckInAt: at.toISOString(), expectedMinutes: null, onRequest: false,
reporter: "openrig-observed", reason: "no check-in cadence declared" },
]);
});
it("a member that never checked in says so", async () => {
await work(MEMBER, { ageMinutes: 61 });
await sweep();
expect(notices()[0]!.body).toContain("check-ins: no check-in yet; no check-in cadence declared");
expect(notices()[0]!.body).toContain("check-ins: no check-in observed yet");
});
it("polling without claiming: old work still surfaces, and the member's check-ins neither reset its age nor rewrite the notice", async () => {
@@ -205,8 +132,8 @@ describe("stuck sweep: a hosted member that polls for its work", () => {
await poll();
await sweep();
const [notice] = notices();
expect(notice!.body).toContain("check-ins: unknown (no check-in cadence declared); it read its own inbox within the last 60 min");
expect(notice!.body).toContain("it's reading its own inbox, but this work is still unclaimed");
expect(notice!.body).toContain("check-ins: checking in: it read its own inbox within the last 60 min");
expect(notice!.body).toContain("it's checking in, but this work is still unclaimed");
expect(notice!.body).toContain(`- ${old.qitemId}, unclaimed since ${old.tsCreated}`);
expect(notice!.body).not.toContain(young.qitemId);
expect(perRow()).toEqual([]);
@@ -259,7 +186,7 @@ describe("stuck sweep: a hosted member that polls for its work", () => {
expect(errors.mock.calls.filter((call) => String(call[0]).includes("inbox check-in not recorded"))).toHaveLength(1);
expect(repo.lastInboxCheckIn(MEMBER)).toEqual({ state: "unknown" });
await sweep();
expect(notices()[0]!.body).toContain("check-ins: unknown (its check-ins couldn't be read)");
expect(notices()[0]!.body).toContain("check-ins: unknown: its check-ins couldn't be read");
});
it("after a restart the stale check-in is still known, and the first new read records at once", async () => {
@@ -102,9 +102,8 @@ describe("SeatHandoverService source execution (OPR.0.5.5.5)", () => {
return { runtime: "codex", launchHarness, checkReady } as unknown as RuntimeAdapter;
}
function newService(extra: Partial<ConstructorParameters<typeof SeatHandoverService>[0]> = {}): SeatHandoverService {
function newService(): SeatHandoverService {
return new SeatHandoverService({
...extra,
db,
rigRepo,
sessionRegistry,
@@ -655,32 +654,6 @@ describe("SeatHandoverService source execution (OPR.0.5.5.5)", () => {
expect(discoveryRepo.getDiscoveredSession(previous.discovered.id)?.claimedNodeId).toBe(other.id);
});
it.each(["fresh", "rebuild"])("%s hands the invalidator the handed-over seat's own generation, not a newer same-name row's", async (source) => {
// Re-running a spec archives a stopped rig and makes a new one with its name; the old one, unarchived and restored
// while the new one is stopped, records a newer session under the seat's name, and is then archived again.
const old = rigRepo.createRig("seat-rig");
const oldNode = rigRepo.addNode(old.id, "dev.impl", { runtime: "codex", cwd: "/project" });
sessionRegistry.updateStatus(sessionRegistry.registerSession(oldNode.id, "dev-impl@seat-rig").id, "exited");
rigRepo.archiveRig(old.id);
const { node, sessionId } = seedSeat();
sessionRegistry.updateStatus(sessionId, "exited");
const retiring = sessionRegistry.currentOccupantTenure(node.id)!.generationUuid;
rigRepo.unarchiveRig(old.id);
await new Promise((resolve) => setTimeout(resolve, 2)); // a later millisecond, so its row sorts newer
sessionRegistry.updateStatus(sessionRegistry.registerSession(oldNode.id, "dev-impl@seat-rig").id, "exited");
rigRepo.archiveRig(old.id);
const newer = sessionRegistry.currentOccupantTenure(oldNode.id)!.generationUuid;
expect(newer).not.toBe(retiring);
expect(sessionRegistry.currentOccupantGenerationForSession("dev-impl@seat-rig")).toBe(newer);
const invalidations: Array<{ retiringGeneration?: string }> = [];
service = newService({ occupantInvalidator: { invalidateRetiringOccupant: (input) => { invalidations.push(input); } } });
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source });
expect(result).toMatchObject({ ok: true, result: { mutated: true } });
expect(invalidations).toEqual([expect.objectContaining({ retiringSessionName: "dev-impl@seat-rig", retiringGeneration: retiring })]);
});
it.each([false, true])("a foreign pane claim refuses with a running session present=%s (#237 holds)", async (runningSession) => {
const { rig, node, sessionId } = seedSeat();
const other = rigRepo.addNode(rig.id, "dev.other", { runtime: "codex", cwd: "/project" });
+83 -49
View File
@@ -5,7 +5,7 @@ import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import type { Worker } from "node:worker_threads";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { Hono } from "hono";
import { createDaemon } from "../src/startup.js";
import { createSlowOpRequestMiddleware } from "../src/domain/slow-op-recorder.js";
@@ -67,37 +67,12 @@ afterEach(() => {
});
describe("SlowOpRecorder locked instrumentation contract", () => {
it("returns promptly while begin acknowledgements are delayed", async () => {
const mod = await loadRecorderModule();
if (!mod) throw new Error("recorder module missing");
const logPath = path.join(tempDir(), "pending.jsonl");
const recorder = new mod.SlowOpRecorder({ logPath });
const worker: Worker = (recorder as any).worker;
const post = worker.postMessage;
const held: unknown[] = [];
worker.postMessage = message => {
if (message?.record?.phase === "begin") held.push(message);
else post.call(worker, message);
};
try {
const start = performance.now();
const values = [1, 2, 3].map(value => recorder.runSync("test.delayed.begin", () => value));
const elapsedMs = performance.now() - start;
expect(values).toEqual([1, 2, 3]);
expect(held).toHaveLength(3);
expect(elapsedMs).toBeLessThan(100);
} finally {
worker.postMessage = post;
for (const message of held) post.call(worker, message);
await recorder.close();
}
});
it("pins the absolute boundary, rotation, filename, and secret-safe record contract", async () => {
const mod = await loadRecorderModule();
expect(mod, "slow-op-recorder production module is missing").not.toBeNull();
if (!mod) return;
expect(mod.SLOW_OPERATION_BARRIER_TIMEOUT_MS).toBe(250);
expect(mod.SLOW_OPERATION_THRESHOLD_MS).toBe(250);
expect(mod.SLOW_OPERATION_ROTATION_BYTES).toBe(1024 * 1024);
expect(mod.SLOW_OPERATION_ROTATION_COUNT).toBe(3);
@@ -110,6 +85,9 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
process.env.OPENRIG_TEST_SECRET = secret;
try {
const value = recorder.runSync("test.sync.boundary", () => {
const records = fs.readFileSync(logPath, "utf8");
expect(records).toContain('"phase":"begin"');
expect(records).toContain('"site":"test.sync.boundary"');
return 41;
});
expect(value).toBe(41);
@@ -129,34 +107,88 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
}
});
it("flushes a queued open span and exposes acknowledged write failure in health", async () => {
it("leaves a durable open span after the begin barrier and exposes fail-visible degradation", async () => {
const mod = await loadRecorderModule();
if (!mod) throw new Error("recorder module missing");
expect(mod, "slow-op-recorder production module is missing").not.toBeNull();
if (!mod) return;
const dir = tempDir();
const logPath = path.join(dir, "open.jsonl");
const logPath = path.join(dir, "slow-operations.jsonl");
const recorder = new mod.SlowOpRecorder({ logPath });
recorder.beginSyncSpan("test.sync.wedge");
await recorder.flush();
expect(await readRecords(logPath)).toMatchObject([{ phase: "begin", site: "test.sync.wedge" }]);
const span = recorder.beginSyncSpan("test.sync.wedge");
expect(span).toBeTruthy();
const records = await readRecords(logPath);
expect(records).toHaveLength(1);
expect(records[0]).toMatchObject({ phase: "begin", site: "test.sync.wedge" });
expect(recorder.snapshot()).toMatchObject({ healthy: true });
await recorder.close();
const degraded = new mod.SlowOpRecorder({ logPath: dir });
const degraded = new mod.SlowOpRecorder({
logPath: path.join(dir, "degraded.jsonl"),
barrierTimeoutMs: 0,
});
const oldNoKernel = process.env.OPENRIG_NO_KERNEL;
process.env.OPENRIG_NO_KERNEL = "1";
const daemon = await createTestDaemon({ dbPath: ":memory:", tmuxExec: async () => "", cmuxExec: async () => "", slowOpRecorder: degraded } as never);
const daemon = await createTestDaemon({
dbPath: ":memory:",
tmuxExec: async () => "",
cmuxExec: async () => "",
slowOpRecorder: degraded,
} as never);
try {
expect(degraded.runSync("test.sync.degraded", () => "continued")).toBe("continued");
await expect(degraded.flush()).rejects.toBeInstanceOf(mod.SlowOpRecorderWriteError);
expect(degraded.snapshot()).toMatchObject({ healthy: false, reason: "recorder_write_failed", site: "recorder.worker" });
const response = await daemon.app.request("/healthz");
expect(await response.json()).toMatchObject({ status: "ok", slowOperations: { healthy: false, reason: "recorder_write_failed" } });
const worker: Worker = (degraded as any).worker;
const postMessage = worker.postMessage;
let intercepted = 0;
let withheldSignal: SharedArrayBuffer | undefined;
try {
worker.postMessage = (message, transferList) => {
if (message?.type === "append"
&& message.record?.phase === "begin"
&& message.record?.site === "test.sync.degraded"
&& message.signal instanceof SharedArrayBuffer) {
intercepted++;
const { signal, ...withoutSignal } = message;
withheldSignal = signal;
// Append through the real worker, but withhold this acknowledgement.
return postMessage.call(worker, withoutSignal, transferList);
}
return postMessage.call(worker, message, transferList);
};
expect(degraded.runSync("test.sync.degraded", () => "continued")).toBe("continued");
} finally {
worker.postMessage = postMessage;
}
expect(intercepted).toBe(1);
expect(worker.postMessage).toBe(postMessage);
expect(withheldSignal).toBeInstanceOf(SharedArrayBuffer);
expect(new Int32Array(withheldSignal!)[0]).toBe(0);
expect(degraded.snapshot()).toMatchObject({
healthy: false,
reason: "begin_barrier_timeout",
site: "test.sync.degraded",
});
const healthResponse = await daemon.app.request("/healthz");
expect(await healthResponse.json()).toMatchObject({
status: "ok",
slowOperations: {
healthy: false,
reason: "begin_barrier_timeout",
site: "test.sync.degraded",
},
});
const observations = daemon.deps.streamStore!.list();
expect(observations).toHaveLength(1);
expect(observations[0]!.body).toContain("recorder_write_failed");
expect(observations[0]!.body).toContain("begin_barrier_timeout");
expect(observations[0]!.body).toContain("test.sync.degraded");
await degraded.flush();
const degradedRecords = await readRecords(path.join(dir, "degraded.jsonl"));
expect(degradedRecords.filter((record) => record.phase === "begin"
&& record.site === "test.sync.degraded")).toHaveLength(1);
} finally {
daemon.eventLoopMonitor.stop();
daemon.db.close();
await degraded.close().catch(() => {});
await degraded.close();
if (oldNoKernel === undefined) delete process.env.OPENRIG_NO_KERNEL;
else process.env.OPENRIG_NO_KERNEL = oldNoKernel;
}
@@ -179,17 +211,20 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
});
expect(value).toBe("continued");
expect(ran).toBe(true);
await expect(recorder.flush()).rejects.toBeInstanceOf(mod.SlowOpRecorderWriteError);
expect(recorder.snapshot()).toEqual({ healthy: false, reason: "recorder_write_failed", site: "recorder.worker" });
expect(recorder.snapshot()).toEqual({
healthy: false,
reason: "begin_barrier_failed",
site: "test.sync.observer_failure",
});
} finally {
// This recorder's log path is intentionally unwritable (to force the
// asynchronously acknowledged write failure above), so close() now correctly rejects on the
// begin-barrier failure above), so close() now correctly rejects on the
// latched lost-write durability — tolerate it in teardown.
await recorder.close().catch(() => {});
}
});
it("writes a queued begin while the main thread is wedged, before a later kill", async () => {
it("durably records an open span before a main-thread sync wedge is killed", async () => {
const dir = tempDir();
const logPath = path.join(dir, "crash.jsonl");
fs.writeFileSync(logPath, "", { mode: 0o600 });
@@ -243,7 +278,6 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
});
});
expect(callbackEvidence).toEqual({ isMainThread: true, threadId: 0 });
await vi.waitFor(async () => expect(await readRecords(logPath)).toMatchObject([{ phase: "begin", site: "test.sync.crash" }]));
child.kill("SIGKILL");
await once(child, "exit");
@@ -317,7 +351,7 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
expect(JSON.stringify(requests)).not.toContain("must-not-appear");
});
it("keeps the accepted slow-operation site labels", () => {
it("wraps all nine accepted sync calls without moving them off the main thread", () => {
const srcRoot = path.resolve(import.meta.dirname, "../src");
for (const [relativePath, sites] of expectedSites) {
const source = fs.readFileSync(path.join(srcRoot, relativePath), "utf8");
@@ -462,7 +496,7 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
expect((await app.request("/definitely-missing")).status).toBe(404);
});
it("drives the asynchronous tmux command-v site through a hermetic daemon", async () => {
it("drives the real tmux command-v spawnSync site through a hermetic daemon", async () => {
const mod = await loadRecorderModule();
expect(mod, "slow-op-recorder production module is missing").not.toBeNull();
if (!mod) return;
@@ -1,45 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
import { TmuxOptionDefaultsApplier } from "../src/domain/tmux-option-defaults.js";
const execute = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", () => ({ execFile: execute }));
beforeEach(() => { execute.mockReset(); });
describe("clipboard discovery deadline", () => {
it.each([null, "custom-copy"])("warns and retries a killed default probe, preserving %j", async initial => {
let copyCommand = initial;
const setServerOption = vi.fn(async (name: string, value: string) => {
if (name === "copy-command") copyCommand = value;
return { ok: true };
});
const adapter = { setSessionOption: async () => ({ ok: true }), setServerOption } as unknown as TmuxAdapter;
execute.mockImplementationOnce((_file, _args, options, callback) => {
expect(options).toMatchObject({ timeout: 250, killSignal: "SIGKILL" });
callback(Object.assign(new Error("probe deadline"), { killed: true }));
}).mockImplementationOnce((_file, _args, _options, callback) => callback(null));
const applier = new TmuxOptionDefaultsApplier({ tmuxAdapter: adapter, platform: "linux" });
const warnings = await applier.applyToFreshSession("first@test");
expect(warnings).toHaveLength(1);
expect(warnings[0]).toMatch(/clipboard.*250 ms/i);
expect(warnings[0]).toMatch(/retry/i);
expect(copyCommand).toBe(initial);
expect(execute).toHaveBeenCalledTimes(1);
expect(await applier.applyToFreshSession("next@test")).toEqual([]);
expect(copyCommand).toBe("wl-copy");
expect(execute).toHaveBeenCalledTimes(2);
expect(await applier.applyToFreshSession("later@test")).toEqual([]);
expect(execute).toHaveBeenCalledTimes(2);
});
it("memoizes confirmed absence without a deadline warning", async () => {
execute.mockImplementation((_file, _args, _options, callback) => callback(Object.assign(new Error("absent"), { code: 1 })));
const setServerOption = vi.fn(async () => ({ ok: true }));
const adapter = { setSessionOption: async () => ({ ok: true }), setServerOption } as unknown as TmuxAdapter;
const applier = new TmuxOptionDefaultsApplier({ tmuxAdapter: adapter, platform: "linux" });
expect(await applier.applyToFreshSession("first@test")).toEqual([]);
expect(await applier.applyToFreshSession("next@test")).toEqual([]);
expect(execute).toHaveBeenCalledTimes(2);
expect(setServerOption).not.toHaveBeenCalledWith("copy-command", expect.anything());
});
});
@@ -23,24 +23,6 @@ function mockAdapter(overrides?: {
}
describe("TmuxOptionDefaultsApplier", () => {
it("awaits an asynchronous clipboard lookup without holding unrelated work", async () => {
const { adapter, setServerOption } = mockAdapter();
let release!: (present: boolean) => void;
const pending = new Promise<boolean>(resolve => { release = resolve; });
const probe = vi.fn((bin: string) => bin === "wl-copy" ? pending : Promise.resolve(true));
let finished = false;
const applier = new TmuxOptionDefaultsApplier({ tmuxAdapter: adapter, platform: "linux", hasCommand: probe });
const applied = applier.applyToFreshSession("async-clipboard@test").then(value => { finished = true; return value; });
try {
await new Promise(resolve => setImmediate(resolve));
expect(finished).toBe(false);
expect(setServerOption).not.toHaveBeenCalledWith("copy-command", expect.anything());
} finally { release(false); }
expect(await applied).toEqual([]);
expect(probe.mock.calls.map(([bin]) => bin)).toEqual(["wl-copy", "xclip"]);
expect(setServerOption).toHaveBeenCalledWith("copy-command", "xclip -selection clipboard -i");
});
it("sets mouse on + status off (default) on the given session via SESSION scope only", async () => {
const { adapter, setSessionOption, setServerOption } = mockAdapter();
// default reader (omitted) → statusBar false.
@@ -127,20 +109,20 @@ describe("TmuxOptionDefaultsApplier", () => {
});
});
describe("resolveCopyCommand (pure per-platform table)", async () => {
it("darwin → pbcopy", async () => {
expect(await resolveCopyCommand("darwin", () => false)).toBe("pbcopy");
describe("resolveCopyCommand (pure per-platform table)", () => {
it("darwin → pbcopy", () => {
expect(resolveCopyCommand("darwin", () => false)).toBe("pbcopy");
});
it("linux → wl-copy when present", async () => {
expect(await resolveCopyCommand("linux", (b) => b === "wl-copy")).toBe("wl-copy");
it("linux → wl-copy when present", () => {
expect(resolveCopyCommand("linux", (b) => b === "wl-copy")).toBe("wl-copy");
});
it("linux → xclip when wl-copy absent but xclip present", async () => {
expect(await resolveCopyCommand("linux", (b) => b === "xclip")).toBe("xclip -selection clipboard -i");
it("linux → xclip when wl-copy absent but xclip present", () => {
expect(resolveCopyCommand("linux", (b) => b === "xclip")).toBe("xclip -selection clipboard -i");
});
it("linux → null when neither present (OSC 52 fallback)", async () => {
expect(await resolveCopyCommand("linux", () => false)).toBeNull();
it("linux → null when neither present (OSC 52 fallback)", () => {
expect(resolveCopyCommand("linux", () => false)).toBeNull();
});
it("other platforms → null", async () => {
expect(await resolveCopyCommand("win32", () => true)).toBeNull();
it("other platforms → null", () => {
expect(resolveCopyCommand("win32", () => true)).toBeNull();
});
});
@@ -94,42 +94,16 @@ it("records a retryable refusal on the parked row without consuming its one-shot
expect(f.repo.getParkWakeStatus(row.qitemId)).toMatchObject({ phase: "fired", deliveryStatus: "ok", live: false });
});
it.each([false, true])("keeps a refused one-shot owed beyond the retry cap and across restart (digest=%s)", async digest => {
let result = absent;
const send = vi.fn(async () => result), f = fixture(send, undefined, digest), rows = [await f.park()];
if (digest) rows.push(await f.park());
it("records exhausted one-shot retries as a failed wake before ending the timer", async () => {
const send = vi.fn(async () => absent), f = fixture(send), row = await f.park();
later(3600); await f.scheduler.runTickNow();
for (let i = 0; i < 5; i++) { later(300); await f.scheduler.runTickNow(); }
expect(f.errors).toHaveLength(0); expect(send).toHaveBeenCalledTimes(6);
const ids = f.outbox.listForSender("watchdog@system").map(e => e.outboxId).sort();
for (const row of rows) {
expect(f.repo.getParkWakeStatus(row.qitemId)).toMatchObject({ phase: "armed", live: true });
expect(f.jobs.getByIdOrThrow(row.jobId)).toMatchObject({ state: "active", lastFireAt: null });
expect(f.history.listForJob(row.jobId)[0]?.evaluationNotes?.nextAction).toContain("remains owed");
}
const saved = f.db.serialize(); f.db.close(); const resumed = fixture(send, saved, digest);
result = delivered; later(299); await resumed.scheduler.runTickNow(); expect(send).toHaveBeenCalledTimes(6);
later(1); await resumed.scheduler.runTickNow(); expect(send).toHaveBeenCalledTimes(7);
for (const row of rows) {
expect(resumed.repo.getParkWakeStatus(row.qitemId)).toMatchObject({ phase: "fired", deliveryStatus: "ok", live: false });
expect(resumed.jobs.getByIdOrThrow(row.jobId).terminalReason).toBe("park_timer_fired_once");
expect(resumed.repo.getById(row.qitemId)).toMatchObject({ state: "blocked", blockedOn: "external:wait", body: "Continue work" });
expect(resumed.repo.currentParkContinuation(row.qitemId)).toBe("work");
}
expect(resumed.outbox.listForSender("watchdog@system").map(e => e.outboxId).sort()).toEqual(ids);
expect(resumed.outbox.listForSender("watchdog@system").every(e => e.deliveryState === "delivered")).toBe(true);
later(7200); await resumed.scheduler.runTickNow(); expect(send).toHaveBeenCalledTimes(7);
expect(resumed.errors).toHaveLength(0);
});
it("keeps a one-shot owed when its transport is unavailable, then delivers when repaired", async () => {
let result: SendResult = { ...absent, reason: "transport_unavailable" };
const send = vi.fn(async () => result), f = fixture(send), row = await f.park();
later(3600); await f.scheduler.runTickNow();
expect(f.repo.getParkWakeStatus(row.qitemId)).toMatchObject({ phase: "armed", live: true });
result = delivered; later(300); await f.scheduler.runTickNow();
expect(send).toHaveBeenCalledTimes(2);
expect(f.repo.getParkWakeStatus(row.qitemId)).toMatchObject({ phase: "fired", deliveryStatus: "ok", live: false });
for (let i = 0; i < 3; i++) { later(300); await f.scheduler.runTickNow(); }
expect(f.errors).toHaveLength(0); expect(send).toHaveBeenCalledTimes(4);
expect(f.repo.getById(row.qitemId)?.state).toBe("blocked");
expect(f.repo.getParkWakeStatus(row.qitemId)).toMatchObject({ phase: "fired", deliveryStatus: "failed", live: false, unconsumed: true });
expect(f.jobs.getByIdOrThrow(row.jobId).terminalReason).toBe("park_timer_fired_once");
expect(f.history.listForJob(row.jobId)[0]?.evaluationNotes?.nextAction).toContain("exhausted");
later(7200); await f.scheduler.runTickNow(); expect(send).toHaveBeenCalledTimes(4);
});
it.each([false, true])("repairs a post-fire accounting crash without duplicate input (digest=%s)", async digest => {
+5 -11
View File
@@ -1,7 +1,6 @@
import type { AttentionRead, AttentionItem } from "@openrig/daemon/attention";
import type { Action, FleetSnapshot, ViewState } from "../types.js";
import { listItem, wrapDetailLines, type ContentLine } from "../detail.js";
import { displayTime } from "../time.js";
/** The bounded, confirmed-delivery or bridge-acceptance projection served by S02; no client classification. */
export interface DeliveredHumanUpdates {
@@ -12,8 +11,6 @@ export interface DeliveredHumanUpdates {
truncated: boolean;
}
const BRIDGE_ACCEPTED_SCOPE = " · accepted by bridge";
function isBridgeAcceptedUpdate(q: DeliveredHumanUpdates["items"][number]): boolean {
return q.deliveryOutcome === "accepted" || q.deliveryReceipt.startsWith("relay-owner-notification-accepted");
}
@@ -41,13 +38,13 @@ export function composeHumanUpdates(attention: AttentionRead | null, updates: De
const baseScope = projects.length === 1 ? `project ${projects[0]} (queue tag)` : "instance · project unknown";
const item: AttentionItem = { id: `human-update:${q.qitemId}`, kind: "update", summary: q.summary || q.body.trim().split(/\r?\n/).find(Boolean) || (accepted ? "Accepted by bridge" : "Delivered update"),
recipient: q.destinationSession, urgency: "update", unblocks: null, at: q.deliveredAt,
scope: accepted ? `${baseScope}${BRIDGE_ACCEPTED_SCOPE}` : baseScope, project: null,
scope: accepted ? `${baseScope} · accepted by bridge` : baseScope, project: null,
source: `/api/queue/${encodeURIComponent(q.qitemId)}` };
if (!read.items.some(current => current.id === `queue-update:${q.qitemId}`)) read.items.push(item);
if (wanted === item.id && read.detail?.item.id !== item.id) {
read.detailError = null;
read.detail = { item, lines: [q.body, ...(q.humanDetail ? ["Supplemental detail:", q.humanDetail] : []),
`From: ${q.sourceSession}`, `Delivery receipt: ${q.deliveryReceipt}`, `Queue: ${q.qitemId}`, `Evidence: ${q.evidenceRef ?? "none recorded"}`],
`From: ${q.sourceSession}`, accepted ? `Accepted by bridge: ${q.deliveredAt}` : `Delivered: ${q.deliveredAt}`, `Delivery receipt: ${q.deliveryReceipt}`, `Queue: ${q.qitemId}`, `Evidence: ${q.evidenceRef ?? "none recorded"}`],
files: q.evidenceRef?.startsWith("/") ? [{ label: "Update evidence", path: q.evidenceRef }] : [] };
}
}
@@ -74,10 +71,7 @@ export function attentionLines(state: ViewState, snap: FleetSnapshot, width: num
if (d.item.recipient) lines.push({ text: `To: ${d.item.recipient}` });
if (d.item.id.startsWith("human-update:") || d.item.id.startsWith("queue-update:")) lines.push({ text: "No action needed" });
if (d.item.unblocks) lines.push({ text: `Unblocks: ${d.item.unblocks}` });
// Times are drawn in the person's zone (ui.timezone), as the header's are; a delivered update's time is when it was
// delivered or accepted by the bridge.
const when = !d.item.id.startsWith("human-update:") ? "Observed" : d.item.scope.endsWith(BRIDGE_ACCEPTED_SCOPE) ? "Accepted by bridge" : "Delivered";
lines.push({ text: `Scope: ${d.item.scope}` }, { text: `${when}: ${displayTime(d.item.at, state.timeZone)}` });
lines.push({ text: `Scope: ${d.item.scope}` }, { text: `Observed: ${d.item.at ?? "unknown"}` });
lines.push(...questionLines(d.item).map(text => ({ text })));
lines.push(...d.lines.map(text => ({ text })));
for (const f of d.files) lines.push(listItem(f.label, { type: "attention-source", path: f.path }));
@@ -130,10 +124,10 @@ export function attentionLines(state: ViewState, snap: FleetSnapshot, width: num
if (i.humanQuestions?.length) lines.push({ text: ` Questions: ${i.humanQuestions.length}, ${unansweredCount(i)} unanswered` });
if (i.id.startsWith("human-update:") || i.id.startsWith("queue-update:")) lines.push({ text: " No action needed" });
if (i.unblocks) lines.push({ text: ` Unblocks: ${i.unblocks}` });
lines.push({ text: ` ${i.scope} · ${displayTime(i.at, state.timeZone)}` });
lines.push({ text: ` ${i.scope} · ${i.at ?? "time unknown"}` });
}
}
lines.push({ text: "" }, { text: `Read at ${displayTime(read.readAt, state.timeZone)}` }, ...read.sources.map(s => ({ text: `${s.source}: ${s.state} · ${s.detail}` })));
lines.push({ text: "" }, { text: `Read at ${read.readAt}` }, ...read.sources.map(s => ({ text: `${s.source}: ${s.state} · ${s.detail}` })));
}
lines.push({ text: "" }, { text: "Viewing is not approval. Each request's detail says whether it went to Slack and where to answer it." });
return wrapDetailLines(lines, width);
@@ -50,24 +50,6 @@ it("renders an unsent local FYI without inventing an approval or a delivery rece
expect(text).not.toContain("Delivery receipt:");
});
it("draws Feed times in the person's zone, as the header does, and redraws them when the zone changes", () => {
const item = current(), attention = read([item]);
attention.detail = { item, lines: ["Current queue body"], files: [] };
const merged = composeHumanUpdates(attention, delivered("other"), item.id)!;
const draw = (timeZone: string, attentionOpen: string | null) => attentionLines(
{ ...createViewState({ instanceId: "owned-fixture", timeZone }).get(), section: "needs" as const, attentionOpen },
{ ...emptySnapshot(), attentionRead: merged }, 140).map(l => l.text).join("\n");
const london = draw("Europe/London", null);
expect(london).toContain("instance · 2026-10-01 13:00:00 GMT+1");
expect(london).toContain("instance · project unknown · 2026-10-08 13:00:00 GMT+1");
expect(london).toContain("Read at 2026-10-09 13:00:00 GMT+1");
expect(london).not.toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:/);
// The same read, drawn after the zone setting arrives: nothing is re-read, and nothing keeps the earlier zone.
const angeles = draw("America/Los_Angeles", item.id);
expect(angeles).toContain("Observed: 2026-10-01 05:00:00 PDT");
expect(angeles).not.toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:|GMT\+1/);
});
it("does not retain delivered history beyond a successful empty receipt window when only current updates fail", () => {
const prior = composeHumanUpdates(read([]), delivered())!;
const failed = read([]);
@@ -173,9 +155,8 @@ it("labels relay bridge acceptance as accepted by the bridge rather than Deliver
],
};
const openedBridge = composeHumanUpdates(read([]), bridgeUpdates, "human-update:bridge-fyi")!;
const detailState = (id: string) => ({ ...createViewState({ instanceId: "owned-fixture", timeZone: "UTC" }).get(), section: "needs" as const, attentionOpen: id });
const bridgeDetailLines = attentionLines(detailState("human-update:bridge-fyi"), { ...emptySnapshot(), attentionRead: openedBridge }, 140).map(l => l.text).join("\n");
expect(bridgeDetailLines).toContain("Accepted by bridge: 2026-10-08 14:00:00 UTC");
const bridgeDetailLines = openedBridge.detail?.lines.join("\n") ?? "";
expect(bridgeDetailLines).toContain("Accepted by bridge: 2026-10-08T14:00:00Z");
expect(bridgeDetailLines).not.toContain("Delivered:");
expect(openedBridge.sources.find(s => s.source === "delivered updates")?.detail).toContain("accepted by the bridge");
expect(openedBridge.sources.find(s => s.source === "delivered updates")?.detail).not.toContain("confirmed delivered updates");
@@ -187,8 +168,8 @@ it("labels relay bridge acceptance as accepted by the bridge rather than Deliver
// Slack delivered wording remains unchanged
const openedSlack = composeHumanUpdates(read([]), delivered("slack-fyi"), "human-update:slack-fyi")!;
const slackDetailLines = attentionLines(detailState("human-update:slack-fyi"), { ...emptySnapshot(), attentionRead: openedSlack }, 140).map(l => l.text).join("\n");
expect(slackDetailLines).toContain("Delivered: 2026-10-08 12:00:00 UTC");
const slackDetailLines = openedSlack.detail?.lines.join("\n") ?? "";
expect(slackDetailLines).toContain("Delivered: 2026-10-08T12:00:00Z");
expect(slackDetailLines).not.toContain("Accepted by bridge:");
expect(openedSlack.sources.find(s => s.source === "delivered updates")?.detail).toBe(
"Latest 20 confirmed delivered updates; retained receipt window.",
@@ -1,14 +1,10 @@
{
"release": "0.7.11",
"archiveSha256": "71e3b727db0d8ab68ff0da99f5d46eebe123b9474446a7ff3e6e5497b2506419",
"release": "0.7.10",
"archiveSha256": "c55aff7e7c7d1429e6d12ee89540961005bcb1efd6e7017753071155b1952732",
"plugins": {
"openrig": {
"version": "0.1.0",
"sha256": "ee8a1300119576f36889cc6494988c69faa3dfb2c3172cfd856a7ecc3edc0506"
},
"openrig-core": {
"version": "0.1.27",
"sha256": "006f018d2a80bd22949a651461269010627e2ed68c7be91a51fc57c4ed723f43"
"version": "0.1.26",
"sha256": "23ed778d65d7feeff7e22b29538627973678241d1676a8d41253371d8ff4ec65"
}
}
}
+1 -74
View File
@@ -9,7 +9,6 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { packEntry } from './npm-pack-json.mjs';
import { checkPackagedPluginVersions } from './check-packaged-plugin-versions.mjs';
const LISTING = ['packages/acp/registry/agent.json', 'packages/acp/registry/icon.svg'];
const readJson = (root, rel) => JSON.parse(readFileSync(join(root, rel), 'utf8'));
@@ -28,81 +27,12 @@ export function launcherMismatches(root) {
].filter(([, actual, wanted]) => actual !== wanted).map(([where, actual, wanted]) => `${where} is ${JSON.stringify(actual)}, not ${JSON.stringify(wanted)}`);
}
// The pages a newcomer copies install commands from. Each links the rigs skill, and the README and getting started
// also link the install script, at a release tag, so a copied command fetches that release's skill or install script
// (the script itself installs the latest published CLI): never a placeholder, a stale tag or main's unreleased skill.
// build:package copies docs/reference into the assembled CLI, and npm packs that copy, so both copies are checked.
const INSTALL_PAGES = ['README.md', 'docs/reference/getting-started.md', 'docs/reference/quickstart.md',
'packages/cli/daemon/docs/reference/getting-started.md', 'packages/cli/daemon/docs/reference/quickstart.md'];
const INSTALL_LINK = /github(?:usercontent)?\.com\/mvschwarz\/openrig\/(?:tree\/)?([^/\s"'`]+)\/(skills\/rigs|scripts\/install\.sh)/g;
/** Where an install page links the rigs skill or the install script at anything but this release's tag, each named by
* file and line. A page with no rigs-skill link is named too, so a rewritten page can't pass by linking nothing. */
export function installTagMismatches(root, version) {
const tag = `v${version}`;
return INSTALL_PAGES.flatMap(rel => {
if (!existsSync(join(root, rel))) return [`${rel} is missing`];
const links = readFileSync(join(root, rel), 'utf8').split('\n')
.flatMap((line, i) => [...line.matchAll(INSTALL_LINK)].map(([, ref, target]) => ({ line: i + 1, ref, target })));
return [
...(links.some(link => link.target === 'skills/rigs') ? [] : [`${rel} has no rigs-skill install link`]),
...links.filter(link => link.ref !== tag).map(link => `${rel}:${link.line} links ${link.target} at ${JSON.stringify(link.ref)}, not ${JSON.stringify(tag)}`),
];
});
}
const BASELINE = 'scripts/fixtures/packaged-plugins-last-release.json';
// The openrig plugin in source, and as the assembled CLI packs it.
const OPENRIG_PLUGIN_DIRS = ['packages/daemon/assets/plugins/openrig', 'packages/cli/daemon/assets/plugins/openrig'];
const OPENRIG_MANIFESTS = ['plugin.json', '.claude-plugin/plugin.json', '.codex-plugin/plugin.json', '.cursor-plugin/plugin.json'];
const parts = version => version.split('.').map(Number);
const earlier = (a, b) => { const [x, y] = [parts(a), parts(b)]; const i = x.findIndex((n, k) => n !== y[k]); return i >= 0 && x[i] < y[i]; };
/** The packaged plugins this release is checked against: the last published release's, refreshed from that published
* archive with `node scripts/check-packaged-plugin-versions.mjs <published.tgz> --snapshot`. An earlier release with
* its archive hash and plugins, never this candidate's own. */
export function releaseBaseline(root, version) {
const baseline = readJson(root, BASELINE);
const problems = [
...(typeof baseline.release === 'string' && /^\d+\.\d+\.\d+$/.test(baseline.release) && earlier(baseline.release, version) ? []
: [`its release is ${JSON.stringify(baseline.release)}, not a release published before ${version}`]),
...(/^[0-9a-f]{64}$/.test(baseline.archiveSha256 ?? '') ? [] : ['it has no archive sha256']),
...(baseline.plugins && Object.keys(baseline.plugins).length > 0 ? [] : ['it lists no plugins']),
];
if (problems.length > 0) throw new Error(`${BASELINE} must be the last published release's packaged plugins: ${problems.join('; ')}. Refresh it from that published archive (node scripts/check-packaged-plugin-versions.mjs <published.tgz> --snapshot).`);
return baseline;
}
/** The openrig plugin ships inside the CLI, so all four of its manifests, in source and in the assembled CLI, carry the
* CLI's release version; openrig-core keeps its own, and other plugins' optional manifests stay optional. */
export function openrigPluginMismatches(root, version) {
return OPENRIG_PLUGIN_DIRS.flatMap(dir => OPENRIG_MANIFESTS.map(rel => `${dir}/${rel}`)).flatMap(path => {
if (!existsSync(join(root, path))) return [`${path} is missing`];
const actual = readJson(root, path).version;
return actual === version ? [] : [`${path} "version" is ${JSON.stringify(actual)}, not ${JSON.stringify(version)}`];
});
}
/** What a successful pack says about its baseline: the checks read the baseline's own fields, so whether it's the
* registry's latest @openrig/cli is for the person releasing to confirm. */
export function baselineNotice(manifest) {
const { release, archiveSha256 } = manifest.plugins.baseline;
return `Plugins checked against ${release} (archive sha256 ${archiveSha256}); confirm that's the registry's latest @openrig/cli before publishing.`;
}
export function packRelease(root, dest) {
const version = readJson(root, 'packages/cli/package.json').version;
const mismatches = launcherMismatches(root);
if (mismatches.length > 0) throw new Error(`The launcher must match @openrig/cli ${version}:\n- ${mismatches.join('\n- ')}`);
// This catches a build that never ran, not a partial one: a successful build:package is the precondition.
if (!existsSync(join(root, 'packages/cli/daemon/dist'))) throw new Error('packages/cli has no daemon/dist, so build:package hasn\'t run: run npm run build:package first');
const links = installTagMismatches(root, version);
if (links.length > 0) throw new Error(`The install pages must link this release's tag, v${version}, in source and in the assembled CLI; set them in the release preparation, then run npm run build:package again:\n- ${links.join('\n- ')}`);
const stamp = openrigPluginMismatches(root, version);
if (stamp.length > 0) throw new Error(`The openrig plugin's four manifests must carry @openrig/cli ${version}, in source and in the assembled CLI; set them in the release preparation, then run npm run build:package again:\n- ${stamp.join('\n- ')}`);
// The assembled CLI's plugins are what's packed: changed plugin bytes need a greater version than the last release's.
const baseline = releaseBaseline(root, version);
const plugins = checkPackagedPluginVersions(join(root, 'packages/cli'), baseline);
mkdirSync(dest, { recursive: true });
// The CLI is packed as the release producer has packed it; the launcher isn't a workspace, so it's packed by path.
// --ignore-scripts skips pack's prepack and postpack, but npm 10 still runs a `prepare`; neither package has one.
@@ -113,9 +43,7 @@ export function packRelease(root, dest) {
return { name: packed.name, version: packed.version, path, size: packed.size, sha256: sha256(path), integrity: packed.integrity };
});
const listing = LISTING.map(rel => ({ path: join(root, rel), sha256: sha256(join(root, rel)) }));
const manifest = { version, artifacts, listing,
plugins: { baseline: { release: baseline.release, archiveSha256: baseline.archiveSha256 }, packed: Object.fromEntries(Object.entries(plugins).map(([name, p]) => [name, p.version])) },
publishOrder: ['@openrig/cli', '@openrig/acp', 'ACP registry listing'] };
const manifest = { version, artifacts, listing, publishOrder: ['@openrig/cli', '@openrig/acp', 'ACP registry listing'] };
writeFileSync(join(dest, 'RELEASE-ARTIFACTS.json'), `${JSON.stringify(manifest, null, 2)}\n`);
return manifest;
}
@@ -130,7 +58,6 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
const manifest = packRelease(resolve(fileURLToPath(import.meta.url), '../..'), resolve(dest));
for (const a of manifest.artifacts) console.log(`${a.name}@${a.version} ${a.path} sha256 ${a.sha256} ${a.size} B`);
for (const l of manifest.listing) console.log(`listing ${l.path} sha256 ${l.sha256}`);
console.log(baselineNotice(manifest));
console.log(`Wrote ${join(resolve(dest), 'RELEASE-ARTIFACTS.json')}. Publish these archive files themselves, by path, in this order: ${manifest.publishOrder.join(', then ')}.`);
console.log('Publishing from the packages/cli folder instead runs its prepublishOnly, which rebuilds, so the bytes published could differ from these.');
} catch (error) {
+3 -115
View File
@@ -4,19 +4,12 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { baselineNotice, installTagMismatches, launcherMismatches, openrigPluginMismatches, packRelease, releaseBaseline } from './pack-release.mjs';
import { snapshotPlugins } from './check-packaged-plugin-versions.mjs';
import { launcherMismatches, packRelease } from './pack-release.mjs';
const sha256 = file => createHash('sha256').update(readFileSync(file)).digest('hex');
const MANIFESTS = ['plugin.json', '.claude-plugin/plugin.json', '.codex-plugin/plugin.json', '.cursor-plugin/plugin.json'];
/** A repo with an assembled CLI at `version` and a launcher whose four pins are `pins`. Its openrig plugin carries
* `plugin.source` in source and `plugin.assembled` in the assembled CLI (both `version` by default), and its baseline is
* the published `baseline.release` (0.7.10 by default) with openrig-core's `baseline.core` bytes. Its install pages
* link the rigs skill and the install script at `pages.tag` in source and `pages.assembled` in the assembled CLI (both
* this release's tag by default). */
function fixture(t, version, pins = {}, { assembled = true, plugin = {}, baseline = {}, pages = {} } = {}) {
/** A repo with an assembled CLI at `version` and a launcher whose four pins are `pins`. */
function fixture(t, version, pins = {}, { assembled = true } = {}) {
const root = mkdtempSync(join(tmpdir(), 'pack-release-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const write = (rel, text) => { mkdirSync(join(root, rel, '..'), { recursive: true }); writeFileSync(join(root, rel), text); };
@@ -31,34 +24,6 @@ function fixture(t, version, pins = {}, { assembled = true, plugin = {}, baselin
json('packages/acp/registry/agent.json', { id: 'openrig', version: pins.listing ?? version,
distribution: { npx: { package: pins.npx ?? `@openrig/acp@${version}` } } });
write('packages/acp/registry/icon.svg', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"/>\n');
const skill = tag => `npx skills add https://github.com/mvschwarz/openrig/tree/${tag}/skills/rigs --skill rigs --agent codex\n`;
const script = tag => `curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/${tag}/scripts/install.sh | sh\n`;
const reference = (dir, tag) => {
write(`${dir}/getting-started.md`, `# Getting started\n\n${skill(tag).replace(/(https:\S+)/, '"$1"')}${script(tag)}`);
write(`${dir}/quickstart.md`, `# Quickstart\n\n${skill(tag)}`);
};
const tag = pages.tag ?? `v${version}`;
write('README.md', `# OpenRig\n\n${skill(tag)}${script(tag)}`);
reference('docs/reference', tag);
if (assembled) reference('packages/cli/daemon/docs/reference', pages.assembled ?? tag);
const plugins = (dir, openrig, core) => {
for (const rel of MANIFESTS) json(`${dir}/openrig/${rel}`, { name: 'openrig', version: openrig });
write(`${dir}/openrig/skills/team/SKILL.md`, '# team\n');
for (const rel of MANIFESTS.slice(1, 3)) json(`${dir}/openrig-core/${rel}`, { name: 'openrig-core', version: '0.1.28' });
write(`${dir}/openrig-core/skills/user/SKILL.md`, core);
};
plugins('packages/daemon/assets/plugins', plugin.source ?? version, '# user\n');
if (assembled) plugins('packages/cli/daemon/assets/plugins', plugin.assembled ?? plugin.source ?? version, plugin.core ?? '# user\n');
// The published baseline: openrig at its earlier 0.1.0, and openrig-core 0.1.28 with the bytes given.
const published = mkdtempSync(join(tmpdir(), 'pack-release-published-'));
t.after(() => rmSync(published, { recursive: true, force: true }));
const pub = (rel, text) => { mkdirSync(join(published, rel, '..'), { recursive: true }); writeFileSync(join(published, rel), text); };
for (const rel of MANIFESTS) pub(`daemon/assets/plugins/openrig/${rel}`, `${JSON.stringify({ name: 'openrig', version: '0.1.0' }, null, 2)}\n`);
pub('daemon/assets/plugins/openrig/skills/team/SKILL.md', '# team, 0.1.0\n');
for (const rel of MANIFESTS.slice(1, 3)) pub(`daemon/assets/plugins/openrig-core/${rel}`, `${JSON.stringify({ name: 'openrig-core', version: '0.1.28' }, null, 2)}\n`);
pub('daemon/assets/plugins/openrig-core/skills/user/SKILL.md', baseline.core ?? '# user\n');
json('scripts/fixtures/packaged-plugins-last-release.json', { release: baseline.release ?? '0.7.10',
archiveSha256: baseline.archiveSha256 ?? 'a'.repeat(64), plugins: snapshotPlugins(published) });
return root;
}
@@ -100,83 +65,6 @@ test('packs both packages at the one version, with exact paths and hashes, and t
assert.deepEqual(JSON.parse(readFileSync(join(dest, 'RELEASE-ARTIFACTS.json'), 'utf8')), manifest);
});
test("the openrig plugin's four manifests must carry the CLI version, in source and in the assembled CLI", t => {
const SOURCE = 'packages/daemon/assets/plugins/openrig', ASSEMBLED = 'packages/cli/daemon/assets/plugins/openrig';
const root = fixture(t, '0.7.13', {}, { plugin: { source: '0.1.0' } });
assert.deepEqual(openrigPluginMismatches(root, '0.7.13'), [SOURCE, ASSEMBLED].flatMap(dir => MANIFESTS.map(rel =>
`${dir}/${rel} "version" is "0.1.0", not "0.7.13"`)));
const dest = join(root, 'out');
assert.throws(() => packRelease(root, dest), /The openrig plugin's four manifests must carry @openrig\/cli 0\.7\.13, in source and in the assembled CLI/);
assert.equal(existsSync(dest), false);
// A stale assembly is named file by file, with the rebuild step.
const stale = fixture(t, '0.7.13', {}, { plugin: { source: '0.7.13', assembled: '0.1.0' } });
assert.deepEqual(openrigPluginMismatches(stale, '0.7.13'), MANIFESTS.map(rel => `${ASSEMBLED}/${rel} "version" is "0.1.0", not "0.7.13"`));
assert.throws(() => packRelease(stale, join(stale, 'out')), /run npm run build:package again/);
// Each of the four is required in both places, even when the others carry the version.
for (const dir of [SOURCE, ASSEMBLED]) {
const missing = fixture(t, '0.7.13');
rmSync(join(missing, dir, '.cursor-plugin/plugin.json'));
assert.deepEqual(openrigPluginMismatches(missing, '0.7.13'), [`${dir}/.cursor-plugin/plugin.json is missing`]);
assert.throws(() => packRelease(missing, join(missing, 'out')), /\.cursor-plugin\/plugin\.json is missing/);
assert.equal(existsSync(join(missing, 'out')), false);
}
assert.deepEqual(openrigPluginMismatches(fixture(t, '0.7.13'), '0.7.13'), []);
});
test("the install pages must link this release's tag, and every other link is named by file and line", t => {
const root = fixture(t, '0.7.13', {}, { pages: { tag: '<release-tag>', assembled: 'v0.7.13' } });
assert.deepEqual(installTagMismatches(root, '0.7.13'), [
'README.md:3 links skills/rigs at "<release-tag>", not "v0.7.13"',
'README.md:4 links scripts/install.sh at "<release-tag>", not "v0.7.13"',
'docs/reference/getting-started.md:3 links skills/rigs at "<release-tag>", not "v0.7.13"',
'docs/reference/getting-started.md:4 links scripts/install.sh at "<release-tag>", not "v0.7.13"',
'docs/reference/quickstart.md:3 links skills/rigs at "<release-tag>", not "v0.7.13"',
]);
const dest = join(root, 'out');
assert.throws(() => packRelease(root, dest), /The install pages must link this release's tag, v0\.7\.13, in source and in the assembled CLI; set them in the release preparation/);
assert.equal(existsSync(dest), false);
// The last release's tag and main's unreleased skill are refused the same way.
for (const tag of ['v0.7.12', 'main']) assert.equal(installTagMismatches(fixture(t, '0.7.13', {}, { pages: { tag } }), '0.7.13').length, 8);
// npm packs the assembled copies: current source over a stale assembly is refused, with the rebuild step.
const stale = fixture(t, '0.7.13', {}, { pages: { assembled: 'v0.7.12' } });
assert.deepEqual(installTagMismatches(stale, '0.7.13'), [
'packages/cli/daemon/docs/reference/getting-started.md:3 links skills/rigs at "v0.7.12", not "v0.7.13"',
'packages/cli/daemon/docs/reference/getting-started.md:4 links scripts/install.sh at "v0.7.12", not "v0.7.13"',
'packages/cli/daemon/docs/reference/quickstart.md:3 links skills/rigs at "v0.7.12", not "v0.7.13"',
]);
assert.throws(() => packRelease(stale, join(stale, 'out')), /then run npm run build:package again/);
assert.equal(existsSync(join(stale, 'out')), false);
// A page that links no rigs skill is named, rather than passing with nothing checked.
const bare = fixture(t, '0.7.13');
writeFileSync(join(bare, 'docs/reference/quickstart.md'), '# Quickstart\n');
assert.deepEqual(installTagMismatches(bare, '0.7.13'), ['docs/reference/quickstart.md has no rigs-skill install link']);
assert.deepEqual(installTagMismatches(fixture(t, '0.7.13'), '0.7.13'), []);
});
test('the baseline must be an earlier published release with its archive hash, never the candidate', t => {
for (const [release, why] of [['0.7.13', /not a release published before 0\.7\.13/], ['0.7.14', /not a release published before/], ['candidate', /not a release published before/]]) {
const root = fixture(t, '0.7.13', {}, { baseline: { release } });
assert.throws(() => releaseBaseline(root, '0.7.13'), why);
assert.throws(() => packRelease(root, join(root, 'out')), /must be the last published release's packaged plugins/);
}
assert.throws(() => releaseBaseline(fixture(t, '0.7.13', {}, { baseline: { archiveSha256: '' } }), '0.7.13'), /no archive sha256/);
assert.equal(releaseBaseline(fixture(t, '0.7.13', {}, { baseline: { release: '0.7.9' } }), '0.7.13').release, '0.7.9');
});
test('changed plugin bytes need a greater version than the published release, and nothing is packed otherwise', t => {
const root = fixture(t, '0.7.13', {}, { plugin: { core: '# user, changed\n' } });
const dest = join(root, 'out');
assert.throws(() => packRelease(root, dest), /openrig-core: packaged files changed since 0\.7\.10, but version 0\.1\.28 is not greater than 0\.1\.28/);
assert.equal(existsSync(dest), false);
});
test('the release manifest records the baseline it was checked against and each packed plugin version', t => {
const root = fixture(t, '0.7.13', {}, { baseline: { release: '0.7.12', archiveSha256: 'b'.repeat(64) } });
const manifest = packRelease(root, join(root, 'out'));
assert.deepEqual(manifest.plugins, { baseline: { release: '0.7.12', archiveSha256: 'b'.repeat(64) }, packed: { openrig: '0.7.13', 'openrig-core': '0.1.28' } });
assert.equal(baselineNotice(manifest), `Plugins checked against 0.7.12 (archive sha256 ${'b'.repeat(64)}); confirm that's the registry's latest @openrig/cli before publishing.`);
});
test('the pack step never publishes', () => {
const source = readFileSync(new URL('./pack-release.mjs', import.meta.url), 'utf8');
assert.doesNotMatch(source, /\['publish'|"publish"|npm publish/);
+2 -5
View File
@@ -166,7 +166,7 @@ person needs in their project or home folder and name that path.
(never the logical ID or a guessed name) and send:
`rig send <canonicalSessionName> 'This is the agent that installed OpenRig. The person will answer in your pane. Goal: <goal>. Project folder: <absolute path>. Branch: <branch>.'`
If the person stays here, replace "The person will answer in your pane" with "The person stays with me; send me your questions and
results to relay." Carry their constraints and any permission choice they made, chosen or declined, in either handoff.
results to relay." Carry their constraints and any declined permission choices in either handoff.
Or the person types the goal and folder in the operator's pane. If they give you a goal later, forward it the same
way. The operator helps them pick a team that fits their logins, starts it (`rig up`) only on their yes to the plan it
shows, and gives the goal to the team's lead. Before that yes, it says what launching into their folder changes for their own agent there; relay
@@ -190,7 +190,7 @@ person needs in their project or home folder and name that path.
(`--dry-run` first). If it isn't, or you can't tell, say so and offer their choices: sign in to Claude Code, or give the
seat a model their account can run with `rig seat set-model`, then start it again with
`rig seat launch <seat> --fresh --stop --reason "…"` and check the model it reports. Either way the operator starts a
new conversation without your handoff, so send it again: the goal, folder, branch, constraints and permission choices.
new conversation without your handoff, so send it again: the goal, folder, branch, constraints and declined choices.
- **Installation is finished** when the operator is ready and the person is talking to it; a healthy daemon alone isn't
that. If they'd rather talk later, keep that choice, leave them the exact connection step and say the handoff to the
operator is still pending.
@@ -207,9 +207,6 @@ person needs in their project or home folder and name that path.
whole action, answer only when it's clearly inside what they said, and tell them you answered under that instruction,
not that they said yes. Bring anything unclear or outside it back to them; a command's name or a path that looks like
their project doesn't show what the action touches. Their instruction changes no permission setting.
Don't send them to a bare `tmux attach` to answer: relay their choice this way, or point them to that seat in the
team's terminals the operator opened. If they want fewer prompts, the operator offers once to allow the commands
that keep asking, for their project only (`applying-a-permission-policy`); tell it what they chose.
Another kind of menu you can answer with
the key that screen shows, or ask the person. Capture it again before sending anything more.
- **Replies:** in a plain shell, other agents can't type into this session. Read work sent to you with