Compare commits

..
Author SHA1 Message Date
dev60-review aaa683a8e1 test(codex): a fresh Codex seat's managed guidance arrives as launch developer instructions
On a fresh launch of Codex 0.164.0 or later with no developer_instructions
configured, OpenRig's managed guidance should go in through
-c developer_instructions, leaving the tracked repository AGENTS.md and the
Codex home alone. A configured or unreadable layer, an older or unknown
version, and a resume keep the repository AGENTS.md. Roles and the
seat-skills line stay where they are today.
2026-11-03 06:13:26 +00:00
dev60-review 8f30607a64 fix(pi): a restore records the guidance file it resumes on, and writes nothing through a later link
An exact resume keeps a seat's saved startup selection, so the recorded
guidance file stayed at a newer launch's agent file while an older snapshot's
seat resumed on its repository file: inventory named the wrong file, and
another rig's down in the same cwd could take this live seat's guidance. The
restore now records the snapshot's choice, and nothing else in the selection
changes.

The link check that keeps a fresh launch off a linked agent file now also runs
where a recorded file is written: the exact-resume put-back and the Pi
adapter's guidance writes. A file that became a link after its launch gets
nothing written through it, nothing falls back to the repository file, and the
missing guidance is reported as a gap. The conversation still resumes.
2026-11-03 05:21:11 +00:00
dev60-review a5ff0fcda0 test(pi): a restore records the guidance file it resumes on, and writes nothing through a later link
An older snapshot restored exactly after a newer launch recorded the agent file
resumes on the repository file, so the record must name that file for
inventory and for another rig's down in the same cwd. A recorded agent file
that became a link after its launch gets nothing put back through it, and the
missing guidance is reported.
2026-11-03 05:18:58 +00:00
dev60-review bcdfd7a39c fix(pi): a Pi seat's managed guidance goes to its agent folder, never the tracked AGENTS.md
Pi loads its agent folder's AGENTS.md (PI_CODING_AGENT_DIR) before the
repository's, so a Pi seat's managed guidance now goes there and the person's
tracked repository AGENTS.md is no longer written at launch or restore.

A fresh launch asks the adapter once where its guidance goes and records the
answer with the startup context (node_startup_context.guidance_file, new and
nullable). Restore, rig down and node inventory read the recorded file, so one
target is used everywhere. A row or snapshot from before this records nothing
and keeps the repository file, so an existing seat isn't repointed by a restore.

The Pi adapter keeps the repository file when the seat's agent folder or its
AGENTS.md is a symlink or a hard link, since other sessions could read it there,
and says so in the launch warnings. OMP, Codex and Claude are unchanged.

rig down removes OpenRig's unedited blocks from the recorded file and from the
repository file, where an older launch of the same seat wrote them.
2026-11-03 05:02:06 +00:00
dev60-review e91e308b32 test(pi): a Pi seat's managed guidance goes to its agent folder and the tracked AGENTS.md is never written
A Pi seat loads its agent folder's AGENTS.md before the repository's, so its
managed guidance can live there instead of in the person's tracked file. These
cells pin that: the launch chooses and records the file, every reader uses the
recorded one, rig down and an exact resume leave the tracked AGENTS.md
byte-identical, a linked agent file or an older snapshot keeps the repository
file, and OMP is unchanged.
2026-11-03 04:54:27 +00:00
270 changed files with 685 additions and 11411 deletions

No files matched your search

+23 -19
View File
@@ -10,14 +10,12 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
## [0.7.8]
Published 2026-11-05 as `@openrig/[email protected]`, built from the commit tagged
`v0.7.8` (`12df562c`).
**Not yet published.**
OpenRig 0.7.8 lets a person answer a runner seat's permission requests, with a
conformance check and an example runner to build against. It includes 0.7.7's
fixes plus repairs across seats, tmux, Slack and the TUI. It also includes a
reference for running a Claude worker in an NVIDIA OpenShell sandbox, but that
worker isn't usable in this release: see [the OpenShell reference](#the-openshell-reference-isnt-usable-in-078).
conformance check and an example runner to build against. It adds a maintained
way to run a Claude worker in an NVIDIA OpenShell sandbox, and includes 0.7.7's
fixes plus repairs across seats, tmux, Slack and the TUI.
### Before you upgrade
@@ -63,15 +61,20 @@ worker isn't usable in this release: see [the OpenShell reference](#the-openshel
- **A seat activity grant** lets a runner outside the host's hook setup report
activity for one seat and launch, without the install-wide hook token. ([#2295](https://github.com/mvschwarz/openrig/pull/2295))
### The OpenShell reference isn't usable in 0.7.8
### Claude workers in OpenShell sandboxes
0.7.8 includes `rig environment` (`setup`, `create`, `attach`) and a reference
for binding a seat to an NVIDIA OpenShell sandbox (`daemon/assets/openshell/README.md`).
**In our release testing the worker never became usable:** after the sandbox was
created and the native process started, `rig environment connect` failed when its
check timed out after 5 seconds, so no team work ran. Don't rely on this path in
0.7.8.
([#2296](https://github.com/mvschwarz/openrig/pull/2296), [#2300](https://github.com/mvschwarz/openrig/pull/2300), [#2378](https://github.com/mvschwarz/openrig/pull/2378))
- **`rig environment setup` installs a maintained reference** that binds a seat
to one NVIDIA OpenShell sandbox (`rig environment create` or `attach`, then
`rig up`).
- **Context and results:** the seat's guidance, skills, plugins and startup
files go into the sandbox, and verified JSON results come back through
queue work.
- **Stop and delete:** stopping the agent, stopping compute and deleting the
sandbox are separate actions.
- **Restore:** a recreated sandbox resumes the same conversation.
The steps are in `daemon/assets/openshell/README.md`.
([#2296](https://github.com/mvschwarz/openrig/pull/2296), [#2300](https://github.com/mvschwarz/openrig/pull/2300))
### Seats, delivery and the TUI
@@ -147,13 +150,14 @@ check timed out after 5 seconds, so no team work ran. Don't rely on this path in
- **The dsh example keeps one hold per seat:** "if dsh asks twice in one turn,
settling one ask releases the hold while the other may still wait on a
person."
- **The worker isn't usable** (above). The reference also requires NVIDIA
OpenShell 0.1.6, a Linux image, and an API-backed provider using
`ANTHROPIC_API_KEY`; subscription or OAuth credentials aren't supported.
- **The OpenShell reference requires:**
- NVIDIA OpenShell 0.1.6;
- a Linux image;
- an API-backed provider using `ANTHROPIC_API_KEY`. Subscription or OAuth
credentials aren't supported.
"Provider registration and image construction can incur costs." The worker's
activity reads unknown, from pane sampling, never idle.
- **The OpenShell README example uses `operator@kernel`;** the actual kernel
operator is `operator-agent@kernel`.
- **"Never grant the guest access to the daemon's main API port (normally
7433)."** The guest-only listener is what confines a guest's reach. Its grant
only authorizes requests for its own seat and launch.
@@ -461,13 +461,6 @@ in the current update window. Current-update coverage is independent of the
receipt window, so an unavailable current reader cannot retain closed delivery
history beyond a successful history read. Both reads leave queue state intact.
Clients that need only current FYIs can use `GET /api/queue/open-human-updates`.
It returns `{items, limit, truncated}` with open human-addressed updates, ordered
critical then urgent then routine, without requiring a delivery receipt. `limit`
defaults to 20 and accepts integers from 1 to 100. Optional `view` follows the
same person perspective as `/api/attention`; a perspective read scans at most
1000 rows and signals `truncated` when the window may omit matching items.
- `/api/stream` (`server.ts:783`) — `POST /emit` (`routes/stream.ts:56`),
`GET /list` (`:91`, including `sourceSession`, `hintDestination`, `hintTag`,
`since`, `until` and `direction` filters), `GET /watch` + `/sse` SSE
+3 -10
View File
@@ -356,13 +356,6 @@ the sections that were not read are `null`.
| `rig host list` | `ls` | `--json` |
| `rig host doctor <id>` | — | `--posture <profile>`<br>`--public-addr <ip>`<br>`--json` |
`rig host pair` needs the target daemon to run with `OPENRIG_AUTH_BEARER_TOKEN` set,
even when it is bound to a tailnet address and so starts without one: pairing hands
over that token. Without it, the target's `POST /api/hosts/pair-request` answers 409
`pair_target_no_bearer`; it answers 409 `pair_human_required` when no single
registered person can approve (none, or several with no default; name one with
`--human`) (`packages/daemon/src/routes/hosts.ts:167-186`).
Registration makes a host visible in topology; remote FEED attention remains opt-in.
The human-readable success output of `host add` and `host pair` now includes the FEED
next step alongside the existing `host doctor` suggestion. For an HTTP entry named
@@ -386,7 +379,7 @@ do not establish connectivity to a live peer.
|---|---|---|
| `rig ps` | — | `--json`<br>`--no-cleanup`<br>`--resources`<br>`--nodes`<br>`--full`<br>`--verbose`<br>`--limit <n>`<br>`--fields <list>`<br>`--summary`<br>`--filter <key=value>`<br>`--active`<br>`--running`<br>`-A, --all-rigs`<br>`--rig <name>`<br>`--session <name>`<br>`--include-archived`<br>`--host <id>`<br>`--all-hosts`<br>`--hosts <ids>` |
- `--nodes --json` — each seat carries the daemon's served `needsAttention` flag; the CLI doesn't re-derive it, and it is absent when an older daemon doesn't serve it. `arbitratedNeedsInput` and `needsInputEvidence` (`{ rung, observedAt }` or null) come with `--full`, or by name through `--fields`. `startedWithoutRequired` lists the required startup files a handover's successor started without, and the compact read keeps it.
- `--nodes --json` — each seat carries the daemon's served `needsAttention` flag; the CLI doesn't re-derive it, and it is absent when an older daemon doesn't serve it. `arbitratedNeedsInput` and `needsInputEvidence` (`{ rung, observedAt }` or null) come with `--full`, or by name through `--fields`.
### roster
@@ -470,7 +463,7 @@ do not establish connectivity to a live peer.
| `rig queue create` | — | `--source <session>`<br>`--destination <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--body-context <ref>`<br>`--mission <id>`<br>`--slice <id>`<br>`--gate <role>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--expires-at <iso>`<br>`--id <qitemId>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--human-intent <intent>`<br>`--alert`<br>`--human-detail-file <path>`<br>`--reply-to <qitemId>`<br>`--human-questions-file <path>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--verify`<br>`--full`<br>`--json` |
| `rig queue claim <qitemId>` | — | `--destination <session>`<br>`--full`<br>`--json` |
| `rig queue unclaim <qitemId>` | — | `--destination <session>`<br>`--reason <text>`<br>`--full`<br>`--json` |
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--closure-required-at <iso>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--full`<br>`--json` |
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--full`<br>`--json` |
| `rig queue block <qitemId>` | — | `--on <blocker>` **required**<br>`--actor <session>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--continuation <text>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--full`<br>`--json` |
| `rig queue resolve <qitemId>` | — | `--decision <text>` **required**<br>`--actor <session>`<br>`--bearer <token>`<br>`--no-notify`<br>`--json` |
| `rig queue handoff <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--alert`<br>`--host <id>`<br>`--no-nudge`<br>`--full`<br>`--json` |
@@ -638,7 +631,7 @@ Only Claude and Codex tenures can be woken.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig specs` | — | — |
| `rig specs ls` | — | `--kind <kind>`<br>`--include-builtins`<br>`--json` |
| `rig specs ls` | — | `--kind <kind>`<br>`--json` |
| `rig specs show <name-or-id>` | — | `--kind <kind>`<br>`--json` |
| `rig specs preview <name-or-id>` | — | `--kind <kind>`<br>`--json` |
| `rig specs add <path>` | — | `--json` |
+1 -1
View File
@@ -540,7 +540,7 @@ Before you start, two honest constraints:
| registry | `host` | add/list/select/rename isolated entries that resolve to fixture endpoints. Pairing needs two container daemons. |
| coordination | `parked` | An owed item with a stopped worker is reported. A held worker with a live wake is distinguished. |
| coordination | `stream` | emit/list/show/archive an exact item. A restart and a bounded watch keep the order with no duplicates. |
| coordination | `queue` | (partial) handoff → successor owned by `--to` and source asserted closed (`handed-off`), block → parked blocked, resolve → unpark to in-progress (owner nudge suppressed), unclaim → pending with destination kept — each with exact items and destinations asserted via `expect queue` (`rig queue list --json --full --all`) in `queue-transitions-reach-exact-states.yaml`; baton restart coverage stays in `queue-baton-survives-restart.yaml`. |
| coordination | `queue` | (partial) Next: handoff, block, resolve and unclaim, each with exact items and destinations. |
| coordination | `project` | Acquire a lease, classify one item twice, read one projection. The wrong holder cannot act. |
| coordination | `view` | Register a view over known items. list/show return the exact filtered result after a mutation. |
| coordination | `watchdog` | Register one short wake, see one response, stop it, prove no further deliveries. |
+3 -89
View File
@@ -214,7 +214,7 @@ After the files, OpenRig sends a session-identity message (rig, pod, member, log
| Delivery Hint | When | How | Use For |
|---------------|------|-----|---------|
| `auto` | Depends on the file | Resolved per file: a `SKILL.md` (or content starting `# SKILL`) is `skill_install`, any other `.md` is `guidance_merge`, and any other file is `send_text` | Default — let OpenRig decide |
| `guidance_merge` | Before harness boot | Merged as a managed block into the repository `AGENTS.md` (Codex, OMP), a fresh Pi seat's own agent-directory `AGENTS.md` (see [Runtime Differences](#runtime-differences)), or `CLAUDE.md` (Claude; `CLAUDE.local.md` with the rig's `managed_blocks: { claude-code: CLAUDE.local.md }`). A fresh launch of Codex 0.164.0 or later can carry it as launch developer instructions instead (see [Codex guidance at launch](#codex-guidance-at-launch)) | Role guidance, culture, project context |
| `guidance_merge` | Before harness boot | Merged as a managed block into `AGENTS.md` (Codex, Pi) or `CLAUDE.md` (Claude; `CLAUDE.local.md` with the rig's `managed_blocks: { claude-code: CLAUDE.local.md }`) | Role guidance, culture, project context |
| `skill_install` | Before harness boot | Copied to runtime's skill directory | Skills |
| `send_text` | After harness is ready | Sent as text to agent's terminal via tmux | Boot-time grounding, identity hints, instructions to read skills |
@@ -367,15 +367,7 @@ Each startup file and action specifies when it applies:
- `restore` — delivered when a seat is restored without its earlier conversation: a restore whose resume falls back to a fresh conversation, or a retried start
- Default: `[fresh_start, restore]` (both)
**An exact resume delivers neither.** When a restore reopens the seat's saved conversation (`rig up <rig> --existing` with a resume token), OpenRig types no startup actions and delivers no startup files, `restore` ones included: the conversation already has them, and typing them again would add turns to it. The one exception: the guidance file's managed blocks are brought up to date before the harness starts. A missing block is put back, and a stale one is refreshed (see below).
**A seat handover does the same put-back.** Before a fresh, fork or rebuild successor starts, the handover puts the seat's missing managed guidance blocks back in its guidance file from the saved startup context. That's the rig's Claude file (`CLAUDE.md`, or `CLAUDE.local.md` when the rig uses it) for Claude, and `AGENTS.md` for Codex, Pi and omp. So a seat rebuilt into a new working directory keeps its guidance.
**A block you edited by hand is never overwritten.** OpenRig records what it last wrote in each managed block, so a launch, an exact resume, a handover or a runtime switch can refresh a block whose source file changed since. The record follows the file itself, so two links to one file share it. A block you've edited since OpenRig wrote it is left as you wrote it. The start's result names it (`guidance_block_edited`), and the seat is told in one line once it's ready, or in a handover's packet. A block OpenRig has no record of writing (any block written before these records were kept) that differs from its source works like this:
- a launch replaces it, as launches always did, and names it (`guidance_block_refreshed_unrecorded`);
- an exact resume or a handover leaves it, and names it as possibly stale (`guidance_block_may_be_stale`).
An exact resume doesn't type anything into the resumed conversation. So there, the restore result names the block (`guidanceNotes`), but the seat isn't told.
**An exact resume delivers neither.** When a restore reopens the seat's saved conversation (`rig up <rig> --existing` with a resume token), OpenRig types no startup actions and delivers no startup files, `restore` ones included: the conversation already has them, and typing them again would add turns to it. The one exception: a managed guidance block missing from the guidance file is put back before the harness starts.
Use this to avoid re-sending context that a fresh conversation doesn't need twice. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both, so a seat that comes back fresh gets it again.
@@ -536,58 +528,6 @@ This separates provider files, not OS users or accounts. Project-local resources
still use the selected project directory; shared project and system settings
remain shared.
### Codex guidance at launch
On a fresh launch of Codex 0.164.0 or later, OpenRig passes a seat's managed guidance (OpenRig's start file and the
rig's and project's context, culture and SOP files) with the launch, as `-c developer_instructions=…`, instead of
merging it into the repository `AGENTS.md`. Neither the tracked `AGENTS.md` nor the Codex home is written for it, and
both still load as usual. The text opens: "OpenRig seat guidance for this session follows. Where it conflicts with
AGENTS.md guidance in the repository or the Codex home, follow AGENTS.md."
A launch value replaces a configured one, so this happens only when no Codex config layer OpenRig reads sets
`developer_instructions`. OpenRig reads:
- `/etc/codex/config.toml` and `/etc/codex/managed_config.toml`;
- the Codex home's `config.toml`, profiles included, and the selected profile's `<profile>.config.toml`;
- each `.codex/config.toml` from the seat's working directory up.
The Codex home it reads is the seat's own (a `CODEX_HOME` in its `env:`, or a recorded account switch), else the
daemon's `CODEX_HOME`, else `~/.codex`.
The guidance still goes to the repository `AGENTS.md`, as before, when:
- a layer OpenRig reads sets `developer_instructions`, or can't be read. A launch warning names the file;
- the launch resumes or forks a conversation. Developer instructions on a resume haven't been tested, and an exact
resume puts the guidance back in the repository `AGENTS.md`;
- the seat is restored, even when the restore starts a new conversation. A restore keeps the guidance file its launch
recorded, which for Codex is the repository `AGENTS.md`;
- a handover successor starts (fresh, fork or rebuild). Its launch carries no guidance of its own, so the handover
first puts the seat's missing blocks back in the repository `AGENTS.md` from its saved startup context;
- Codex is older than 0.164.0, or its version is unknown.
A seat's role still arrives as startup text, and the seat-skills line, after the person's yes, still goes to
`AGENTS.md`.
A launch that carries developer instructions writes no new blocks to the repository `AGENTS.md`. But blocks an earlier
handover or resume left there are refreshed from their sources, by the same rule as every other managed block. That
matters because the launch text asks Codex to follow `AGENTS.md` where the two differ.
**If your shell sets `CODEX_HOME`.** When neither the seat nor the daemon declares a `CODEX_HOME`, a seat's pane keeps
its own, and OpenRig reads `~/.codex`. A `CODEX_HOME` that only your shell startup sets (in `~/.zshrc`, for example),
with the daemon started without it, isn't seen. A `developer_instructions` set in that home is then replaced by the
launch value for the session. To have it checked, declare that same absolute path as `CODEX_HOME` in the member's or
rig's `env:` (see [Launch environment](rig-spec.md#launch-environment), "A seat's own account"). OpenRig then reads
that home at the seat's next fresh launch, and a `developer_instructions` set there keeps the repository `AGENTS.md`.
The seat keeps the same home and login.
**What it doesn't cover.** OpenRig doesn't read Codex's packaged defaults, or MDM- or enterprise-managed settings. A
`developer_instructions` set there may be replaced for the session. Legacy MDM-managed config ranks above launch flags,
so it can override OpenRig's guidance, and the seat then gets none of it.
**Not established.** The order Codex assembles developer instructions and `AGENTS.md` in, and how it settles every
conflict, aren't established; the opening sentence asks it to follow `AGENTS.md`. The native behaviour was observed on
Codex 0.164.0 alone, on fresh launches without tools.
### Runtime Differences
**Claude Code:**
@@ -618,10 +558,7 @@ Codex 0.164.0 alone, on fresh launches without tools.
- Can self-install dependencies from instructions but timer/recurring behavior is not reliably available
**Pi and OMP:**
- Read `AGENTS.md` in the working directory, added to Pi's own system prompt. Pi also reads the one in its agent
directory: on a fresh launch, a Pi seat's guidance is merged there, under OpenRig's state, not into the
repository's. When that file or its directory is a link, the repository `AGENTS.md` is used instead. A restore keeps
the file the seat launched with. OMP keeps its guidance in the repository `AGENTS.md`
- Read `AGENTS.md` in the working directory; guidance is merged there, added to Pi's own system prompt
- Selected skills are projected into the seat's own agent directory under OpenRig's state, not into a shared
workspace folder
@@ -735,29 +672,6 @@ When authoring startup content, note which instructions are runtime-specific. Fo
---
## Files OpenRig writes into a project
These are the files OpenRig writes into a seat's working directory, and what `rig down` does with each. Paths are
relative to that directory.
| Path | When OpenRig writes it | What `rig down` does |
| --- | --- | --- |
| Managed blocks in `CLAUDE.md` (or the rig's `managed_blocks` file, such as `CLAUDE.local.md`) or `AGENTS.md` | Each launch merges the seat's guidance blocks. A fresh Codex launch that takes developer instructions puts them in its launch text instead: in `AGENTS.md` it adds only the seat-skills line, after your yes, and refreshes blocks already there. A fresh Pi seat writes its own agent-directory `AGENTS.md` outside the project, except when that folder or file is a link: then its guidance stays in the repository `AGENTS.md`. | Removes OpenRig's blocks, current or legacy, and keeps everything else. When nothing else is left, it deletes the file if the file began with an OpenRig block, or held only blocks in an older format. A block-only file isn't proof that OpenRig created it. The seat-skills line is kept if you edited it. |
| `.openrig/context-collector.cjs` | Claude seats, at launch. | Removes it while its bytes are still the shipped collector's; otherwise keeps it and says so. |
| `.claude/settings.local.json` | Claude seats, at launch: OpenRig's status line and its activity hooks are merged in, separately. Your own status line is never replaced, but the activity hooks are still merged beside it. A file that doesn't parse gets neither. | Removes OpenRig's status line and hooks and keeps the file, even if `{}` is all that's left. |
| `.claude/settings.local.json` fragments and `.mcp.json` | When a profile or spec declares `claude_settings_fragment` or `claude_mcp_fragment`. | Leaves the merged content in place. |
| `.claude/agents/`, `.claude/plugins/<id>/`, `.claude/extensions/<id>/`; `.agents/<id>.yaml`, `.codex/plugins/<id>/`, `.agents/extensions/<id>/` | When the seat's projection includes subagents, plugins or runtime resources (Claude, then Codex). | Leaves them in place. |
| `.claude/skills/<id>/`, `.agents/skills/<id>/` | Only for a seat without seat storage. New Claude and Codex seats keep their skills under `$OPENRIG_HOME/state/seats/`, outside the project. | Leaves them in place. |
| The managed compaction preparation folder described in [Claude compaction preparation directory](#claude-compaction-preparation-directory), with its `.gitignore` of `*` and each attempt's `RESTORE-MAP.md` | Managed Claude compaction, unless a preparation root is configured elsewhere. | Leaves it in place. |
| `.rigged-checkpoint.md` | A restore that didn't resume a seat's native conversation (by choice or not) and had a checkpoint to hand it. | Leaves it in place. |
`rig down` leaves a file alone while another seat still uses it: a running seat of the same rig, or any seat of
another rig that isn't archived. That is decided file by file. A guidance file is kept while another seat's
guidance targets the same file. The Claude settings and collector are kept while another Claude seat works in that
directory. So another rig's Codex seat keeps `AGENTS.md` without keeping `CLAUDE.md` or the Claude settings. It
removes `.openrig/` and `.claude/` only when they are empty.
`rig down --delete` does nothing more to project files. OpenRig doesn't edit your project's own `.gitignore`.
## Patterns and Anti-Patterns
### Good Patterns
+2 -23
View File
@@ -23,10 +23,7 @@ Only the activity axis is served today. The session and resumability values are
`activity-taxonomy.ts` but no surface reads them: `rig ps --nodes --full` shows the raw session
STATUS, both node views show a LIFECYCLE of `running`, `detached`, `recoverable` or `attention_required` (`run`, `det`, `rec`,
`att`), and node inventory serves `occupantLifecycle` (`active`, `retiring`, `retired`,
`context_walled`, `compacted`, `crashed`, `unknown`). Node inventory's `sessionStatus` is the
recorded session status, with two observed corrections: a running session whose pane is verified
to have exited (identity reason `pane_exited`) reads `exited`, with `startupStatus:
"attention_required"`, and a running session confirmed missing reads `detached`.
`context_walled`, `compacted`, `crashed`, `unknown`).
The axes never blend. Reachability is strict; revival optimism is a different field
(omnigent's strict-liveness split). `unknown` is a first-class honest value — the oracle
@@ -62,21 +59,6 @@ the existing live-hook fallback in effect; lifecycle, startup, held and error si
can independently raise attention. A false attention flag means no known trigger, not
proof that an unobserved input box is clear.
A handover whose successor started without a required file also raises attention. That's
a projected guidance file or a guidance startup file marked `required` still missing from
the guidance file, or, for a fresh or rebuild successor, a required `send_text` startup
file that's missing. The seat serves `startedWithoutRequired` with each file's path, the
table's reason column names each file, and the handover's own output says it's degraded.
The mark belongs to that successor's session, so the seat's next launch or handover
clears it. Time alone never does. Repairing the file in place doesn't clear it either,
because no route delivers guidance into a running conversation.
A fresh or rebuild successor's first turn opens with the seat's `send_text` startup files,
the ones a fresh start delivers, as absolute paths with the role first. A pointer asks the
successor to read the file; it isn't the pasted text a start sends. A fork successor
carries its parent's conversation and gets no list. `send_text` startup actions, such as
an `after_ready` preload, aren't run for a successor.
The compact human table's ATTN column renders `yes`, `no`, or `?` when the flag is
unavailable. Compact JSON retains `needsAttention`; use `--full` or
`--fields needsAttention,arbitratedNeedsInput,needsInputEvidence` with `--json` for the
@@ -134,10 +116,7 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
PER-HARNESS when that harness's hook rung earns trust through a measured agreement
window in production — never by fixture pass alone (symmetric admission: a fixture
pass admits a rung to TRIAL, where it is measured but not consulted; production agreement
promotes it). For Codex, this fallback also accepts a fresh working status adjacent to
the composer, or an advancing counter across two fresh captures when output separates
them. This positive evidence expires after five seconds; unrecognized, frozen, old or
pre-occupant frames add nothing. It uses the existing pane capture. Undeclared and partial-coverage rungs are identity-only, which is also where
promotes it). Undeclared and partial-coverage rungs are identity-only, which is also where
a degraded rung goes, with one declared exception: for a pi or omp seat or a community
harness, sampling is positive-only: a changing pane reads working, and a quiet one never reads idle on its
own, since the pane can be quiet mid-turn.
+1 -12
View File
@@ -8,15 +8,4 @@ Read `GET /api/seat/stop-precondition/:seatRef` immediately before an operator-c
Pass `expected` unchanged in the body of the existing `POST /api/seat/stop/:seatRef`, alongside `reason` and optional `operator`. The contract marker lets clients detect support. `expected` is optional; a request without it retains the existing stop behavior. A malformed explicit value returns HTTP 400. A null session ID can describe an empty seat, but stopping it returns `no_session`.
Checked stop requires the delivery guard. It refuses a changed node, latest session, binding or occupant before a terminal effect with HTTP 409 `selection_changed`. A kill failure or a failure to save stop state after a kill reports `stop_uncertain`; inspect the seat before retrying. Success retains the usual `seat`, `sessionName` and `sessionId` fields.
A `selection_changed` or `stop_uncertain` response from a checked stop says how far the stop got in `stopPhase`. A refusal before the stop starts carries neither `stopPhase` nor `partialEffect`, and nothing was sent. The stop detaches the seat's clients, checks the selection again, then kills the session:
| `stopPhase` | What was sent to the seat |
| --- | --- |
| `none` | Nothing. |
| `detach_attempted` | The client detach, which may not have finished. No kill. |
| `detached` | The client detach. No kill. |
| `kill_attempted` | The kill. |
`partialEffect` is `true` whenever `stopPhase` isn't `none`, so on its own it doesn't say whether the kill went out. `selection_changed` with `stopPhase: "kill_attempted"` is a change seen after the kill, when the stop state is saved: the session was killed, so re-read the seat rather than offering the stop again.
Checked stop requires the delivery guard. It refuses a changed node, latest session, binding or occupant before a terminal effect with HTTP 409 `selection_changed`. If the selection changes after client detach, the response reports `partialEffect: true` and no kill is sent. A kill failure or a failure to save stop state after a kill reports `stop_uncertain`; inspect the seat before retrying. Success retains the usual `seat`, `sessionName` and `sessionId` fields.
-203
View File
@@ -1,203 +0,0 @@
# Writing a daemon client
What a program that talks to the OpenRig daemon over HTTP needs and can't read from `rig --help`: how to find the
daemon, its health route, which bearer token each route family checks, the event streams, and some route
details. It describes the daemon as it is. It adds no promise beyond what each section says, and route shapes not
listed here can change.
## Finding the daemon
`rig status --json` is the discovery surface. Its `daemon` object gives `state` and `reachable`, and `pid` and
`port` when they are known: a stopped daemon's response has no `port`, and a running daemon found through
`OPENRIG_URL` has no `pid`. `reachable` is `true` when the daemon answered its health probe, `false` when it is
positively down, and `null` when that isn't confirmed (see `rig status` in
[the command reference](../as-built/cli-reference.md#status)). It reads the recorded state and probes; it never
starts a daemon.
The CLI itself finds the daemon in these orders:
- **Data commands** (everything that sends a request): `OPENRIG_URL` (or the legacy `RIGGED_URL`); else
`$OPENRIG_HOME/daemon.json` when it holds a valid `pid` and `port`; else the config keys `daemon.host` and
`daemon.port` (environment `OPENRIG_HOST` and `OPENRIG_PORT`), which default to `127.0.0.1:7433`.
- **Status** (`rig status`, `rig daemon status`): `OPENRIG_URL` (or `RIGGED_URL`) first, probed directly; else
the state file below.
- **Start and stop** (`rig daemon start`, `stop`): the state file, `$OPENRIG_HOME/daemon.json`, then the legacy
`~/.rigged/daemon.json`.
`daemon.json` holds `pid`, `port`, `host`, `db` and `startedAt`. `rig daemon start` writes a new daemon's record
only after its health check passes, so while a start is pending, the file holds no record of the new daemon: it
may be missing, or hold an earlier daemon's stale record. It is the CLI's own state file, not a published
contract: prefer `rig status --json`.
## `GET /healthz`
The liveness and identity read. It always returns `status: "ok"` and `pid`. Every other field is present only
in the case named:
| Field | When it is present |
| --- | --- |
| `euid` | On platforms with effective user ids. A self-report for the CLI's owner check, not authentication. |
| `databaseWarnings` | When the daemon opened its database with warnings. |
| `semver`, `commit`, `dirty`, `builtAt` | On a stamped (released) build; a development run has none. |
| `selfHostId`, `selfHostIdSource` | After the boot reconcile has set this host's id. |
| `eventLoop` | When the event-loop monitor runs: `lagMeanMs`, `lagP99Ms`, `utilization` (0 to 1), `lastTickAgeMs` and `healthy`. |
| `routeTimings` | With `eventLoop`. |
| `slowOperations` | When the slow-operation recorder runs. |
| `bind` | The bind plan: `mode` (`explicit` or `default`), `hosts` (the addresses bound), `tailscaleDetected`, and `ignoredRoutingHost` when a routing value was ignored. |
| `stuckSweep`, `wakeLadder` | The heartbeats of the stuck sweep and the wake ladder, when they run. |
## Bearer tokens
The daemon reads two tokens from its environment at start:
- **`OPENRIG_AUTH_BEARER_TOKEN`** checks Mission Control's write routes (`POST /api/mission-control/action` and
`/notifications/test`), the `/api/hosts` writes (`add`, `update`, `remove`, `pair`), and the `/api/rig-mode`
binding writes. `rig host pair` hands this token to the paired machine, so a daemon that will be paired with
needs it set (see `rig host pair` in [the command reference](../as-built/cli-reference.md)).
- **`OPENRIG_TERMINAL_BEARER_TOKEN`** checks every `/api/transport` and `/api/compaction` route, `POST
/api/terminal/follow` and `/viewer/ensure`, and every `GET /api/terminal/<name>` route, the terminal websocket
included. When the daemon is bound to an explicit address (`rig daemon start --host`) that isn't loopback or a
tailnet address, and this token isn't set, these routes check `OPENRIG_AUTH_BEARER_TOKEN` instead.
A route whose token isn't set lets every request through. Send a token as `Authorization: Bearer <token>`; the
`GET /api/terminal/<name>` routes, the websocket included, also take `?token=<token>`. The CLI sends the terminal token from `OPENRIG_TERMINAL_BEARER_TOKEN`,
or else from the file `$OPENRIG_HOME/terminal-token`, which the person writes; the daemon doesn't create it.
A wrong or missing token gets HTTP 401. The routes that check `OPENRIG_AUTH_BEARER_TOKEN`, and `/api/transport`
and `/api/compaction`, answer `{ "error": "unauthorized", "message", "what_failed", "why_it_matters", "what_to_do" }`.
The `GET /api/terminal/<name>` routes, the websocket included, answer `{ "error": "unauthorized", "hint" }`. A token
doesn't get past the browser boundary's 403 refusals, which [Browser access](browser-access.md) describes.
## Event streams
All three are server-sent events (`text/event-stream`):
- **`GET /api/events`**: the daemon's event log. `?rigId=` limits it to one rig. Each event carries `id: <seq>`.
Without `Last-Event-ID`, `?replay=<n>` first sends the newest `n` stored events (`0`: none, live events only);
`?replay=all`, no `replay`, or any other value sends the whole log first. A `Last-Event-ID` header (what
`EventSource` sends when it reconnects) sends the events after that id instead, whatever `replay` says.
Live events that arrive during the replay follow it, without duplicates.
- **`GET /api/activity/events`**: live only, no replay or ids. Each message is named after its event type:
`seat.activity_changed`, `seat.rung_health`, `proof.judged` or `proof.sources_changed`. It tells a view to read
again; it carries no derived state.
- **`GET /api/queue/sse`** (alias `/api/queue/watch`): live only, with `id: <seq>`, for `queue.created`,
`queue.handed_off`, `queue.claimed`, `queue.unclaimed`, `qitem.fallback_routed`, `qitem.closure_overdue`,
`inbox.absorbed` and `inbox.denied`.
## People, asks and updates without Slack
`GET /api/gateway/participants` returns the same JSON projection as `rig gateway participants --json`:
`{ ok, participants, default, organizations, groups, warnings, participantFile }`. Pick a person by `kind: "person"`
and use their `id` for a Feed `view`, or their `address` for the queue's `destinationSession`. The effective
`default` is `{ id, source }`, or `{ id: null, reason }`; it does not establish who is using the client.
Participant records omit connector credential pointers. Check `warnings` and `participantFile.error` for a partial
registry read; an unreadable human registry returns HTTP 503. See [Gateway participants](gateway-participants.md).
| Read | Meaning |
| --- | --- |
| `GET /api/queue/list?destinationSession=<address>&state=pending,in-progress,blocked&limit=100` | Open rows for exactly that address. `humanIntent: "update"` identifies an FYI; other values are asks. Each row has `qitemId`, `state`, `summary` and `body`. |
| `GET /api/queue/open-human-updates?view=<person-id>&limit=20` | Open human FYIs, even when no Slack delivery receipt exists. Returns `{ items, limit, truncated, perspective? }`. |
| `GET /api/queue/<qitemId>` and `/<qitemId>/transitions` | The current row and its history, including closed state and recorded outcome. Closing or acknowledging a row is different from delivering it. |
| `GET /api/queue/human-updates?view=<person-id>&limit=20` | Existing delivered-update history: explicit FYIs with a recorded Slack posting receipt, including closed rows. Items add `deliveredAt` and `deliveryReceipt`. It is not the open-update inbox. |
Both update lists accept integer `limit` values from 1 to 100 (default 20); invalid values return HTTP 400.
Open FYIs are ordered by priority, then newest first. `view=all` (also the default) shows all perspectives;
`view=default` selects the effective default person, or all when no default is available. A named view that cannot
resolve a person has `perspective.basis: "unknown"` and no items when a registry is available. With no registered
people there is no perspective filter: inspect `perspective` rather than assuming the requested view was applied.
A person's view also includes instance or unassigned items when that person is the default. These are views on a
trusted daemon, not per-person authentication or access control; existing daemon and browser access rules still apply.
Always display `truncated: true` as incomplete, even if fewer than `limit` items arrive. A person-view scan examines
at most 1,001 open updates (101 for delivered history) before filtering; older matching items may be outside that
window. These endpoints have no pagination cursor. Reading an open update never posts it to Slack, acknowledges it,
or manufactures a delivery receipt.
For example, this Node client takes a daemon URL, a registered person id and a rig name. It uses no Slack API and
makes only reads; use the daemon discovery and access rules above. In an ES module, `process.argv.slice(1)` is the
argument list for `node --input-type=module -e '<code>' <url> <person-id> <rig>`:
```js
// client-people-example
const [base, personId, rig] = process.argv.slice(1);
async function get(path) {
const response = await fetch(new URL(path, base));
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
return response.json();
}
const registry = await get("/api/gateway/participants");
const person = registry.participants.find(p => p.kind === "person" && p.id === personId);
if (!person) throw new Error("Choose a registered person id");
const rows = await get(`/api/queue/list?destinationSession=${encodeURIComponent(person.address)}&state=pending,in-progress,blocked&limit=100`);
const updates = await get(`/api/queue/open-human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
const receipts = await get(`/api/queue/human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
const rigHistory = await get(`/api/queue/list?rig=${encodeURIComponent(rig)}&limit=100`);
const messages = await get(`/api/queue/outbox/list?rig=${encodeURIComponent(rig)}&limit=100`);
console.log(JSON.stringify({ participants: registry.participants, default: registry.default,
warnings: registry.warnings, participantFile: registry.participantFile,
asks: rows.filter(row => row.humanIntent !== "update"), updates, receipts, rigHistory, messages }, null, 2));
```
The queue and outbox lists are bounded snapshots, not subscriptions. A full queue list may need a larger `limit`.
An outbox entry's `deliveryState` reports the send attempt; only `delivered` confirms delivery. A queue transition,
an open FYI, or a closed ask is not that receipt. The rig-wide reads and their limits are described below; the existing
`outbox/list?senderSession=<session>` read remains available.
## Route details
- **A rig's queue history:** `GET /api/queue/list?rig=<rig>&updatedSince=<ISO time>&sort=updated` returns the
rig's queue items (any whose source or destination seat is in the rig) updated at or after that time: open items
(`pending`, `in-progress`, `blocked`) first, then the rest by latest update. `limit` defaults to 100 and has no
upper cap. The list is cut after `limit` rows in that order, so when it comes back full, raise `limit` to read
them all. Each item's own history is `GET /api/queue/<qitemId>/transitions`. This read scans the queue table, so
read it occasionally rather than polling it. For live changes, treat `GET /api/queue/sse` events as hints to read
`queue/list?rig=` again. Not every event names both seats (`queue.claimed` and `queue.unclaimed` carry only the
destination), and not every state change is on that stream, so the events alone don't track a rig's items.
- **A rig's messages:** `GET /api/queue/outbox/list?rig=<rig>` returns the messages sent from or to the rig's
seats, newest first; `?senderSession=<session>` still returns one sender's. One of the two is required; with
both, `senderSession` is used and `rig` is ignored. `limit` defaults to 100. No event is sent when a message is delivered, so read this list to see delivery
states.
- **`GET /api/queue/recent-transitions`** returns at most 20 rows. A positive `limit` above 20 is capped at 20;
a missing, zero, negative or unparseable `limit` means 20.
- **`POST /api/transport/send`** takes `urgent: true`, the same as `rig send --urgent`. The daemon records who
marked it from the sender identity, not from the body.
- **`POST /api/scope/approve`** is what `rig scope slice approve` and `rig scope mission approve` call. The body
takes `scopeTier` (`slice` or `mission`), `scopePath`, and optionally `approvalScope` (`spec` or `delivery`, the
default), `missionsRoot`, `onBehalfOf`, `reApprove`, `reason` and `lockedArtifacts`. The approver is the
request's sender identity (`actorSession` in the body is recorded beside it, and a different value is
superseded). Success is 201 with `scopeTier`, `scopeId`, `scopePath`, `approvalScope`, `approvedBy`,
`approvedAt`, `onBehalfOf`, `actionId`, `freezeFired` and `reApproved`. Refusals include 400 for an invalid
`scopeTier`, `scopePath` or `approvalScope`, or a missing actor (`actor_required`); 503 `action_log_unavailable`;
and, from the approval itself, 404 `scope_not_found`, 409 `already_approved` or `nothing_to_reapprove`, 503
`workspace_not_configured`, 500 `audit_write_failed`, and 400 for its other refusals. An unexpected error is 500
`internal`.
- **`POST /api/proof/judge`** is what `rig proof judge` calls. The body takes `scope`, `item`, `verdict`
(`accept`, `reject` or `withdraw`), `reason`, `expectedRevision` and `expectedPrevious` (a judgment id or
`null`), and optionally `evidence`, `expectedEvidence`, `subject`, `operationId`, `replace`, `project` and
`projectRoot`. The judge is the request's sender identity. The response is `judgment`, the slice's `readiness`,
`replayed` and `notification`: 201 for a new judgment, 200 when an `operationId` replays an earlier one.
- **`POST /api/workflow/instantiate`** requires `specPath`, `rootObjective` and `createdBySession`, and takes
`entryOwnerSession`, `targetRig` and `scope` (see [Project workspace](project-workspace.md) for `scope`).
Success is 201 with `instance`, `spec`, `entryQitemId` and `entryOwnerSession`, any `advisories`, and
`scopeIgnored` when a scope wasn't applied.
- **`GET /api/provider/usage`** returns `{ hostUsage }`, this host's usage rows (`host: "local"`, a `provider`
and a `state`). A Claude row's `windows` are the seats' status-line readings, one entry per reading, each with
its `window`, `usedPercent`, `resetsAt`, `asOf` and `seatSession`. A Codex row has `windows: []`. Treating the
readings as one account rests on the deployment assumption the row's `provenance` names (one account per
host), not on a measured identity. Readings whose reset times disagree are reported as `anomalies`, and the
row's `state` can then be `explicit_unknown`. Show the row's `state`, `anomalies` and each reading's seat and
`asOf` as reported, rather than merging readings that happen to match.
- **Slack channels and people:** `GET /api/gateway/slack/channels` and `GET /api/gateway/slack/users` each return
one page from Slack, read with the daemon's configured bot token, which never leaves the daemon. Channels are
public and unarchived, as `{ id, name, isMember }`; people are `{ id, name, realName, displayName, isBot,
deleted }`, with no email. Pass `cursor` (the previous page's `nextCursor`, `null` on the last page) and
`limit` (default 100, at most 200). Inbound delivery needn't be enabled. Failures: 503 `slack_not_configured`
when no bot token resolves, which is different from an empty list; 403 `slack_missing_scope` with the scope
Slack names in `needed` (an older app may lack `users:read`); 429 `slack_rate_limited` with Slack's
`Retry-After`; otherwise 502. Each request is a live Slack call, so page through a list when someone opens a
chooser rather than polling it. Private channels and direct messages aren't listed.
- **Slices are found by folder name alone.** `GET /api/slices/:name`, `/:name/doc/*`, `/:name/proof-asset/*` and
`GET /api/review/slice/:name` take only the name; the detail route reads `?mission` only together with
`?project`. When two missions have a slice folder of the same name, the daemon keeps the first one it finds:
that one is listed and served, and the other doesn't appear in `GET /api/slices` at all. Keep slice names unique
across missions, as [Project workspace](project-workspace.md) says.
+17 -29
View File
@@ -5,31 +5,15 @@ OpenRig's gateway can register **several people** and **shared channels**, group
OpenRig doesn't enforce it. This page covers the commands, what the default is for, and what upgrading and rolling back
do.
For a remote client, `GET /api/gateway/participants` returns the same participant, organization, group, default and
warning fields as `rig gateway participants --json`. Add `?organization=<id>` to filter the participant list; the default
and organization and group maps still describe the whole registry. The result names the participant file and reports
an unreadable side file without treating it as an empty one. If the human registry itself cannot be read, the route
returns `503` with `error: "human_registry_unavailable"`. The result does not include connector secret pointers.
## Participants
A participant is either:
- **a person**, registered with `rig gateway human add`, with or without a connector binding; or
- **a person**, registered with `rig gateway human add` as before, with their own Slack binding; or
- **a shared channel**: one vetted Slack channel registered as one participant, `<id>@external`, not a person per
sender.
People and channels share one address space: a channel can't take a person's id.
A registered person is addressed as `<id>@external`, including without Slack: for example,
`rig gateway human add alex --display-name "Alex" --delivery-class B` creates `alex@external`, reached in the Feed and Steering.
`human@host` is the unnamed person at the terminal; `human@kernel` is also accepted as a human/gate address.
These aliases (including `human-<name>@host` and `human-<name>@kernel`) are not agent sessions: reply with
`rig queue create --destination human@host --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`,
substituting the sender's address and your summary, evidence path and reply. A reply destination doesn't identify
who acted or determine who may answer an ask. Clients acting as a registered person name that person's
`actorSession`; a seat identity header takes precedence. The terminal's primary-user authority records the unnamed
`human@host` actor, not the person whose queue is being viewed.
```bash
# An organization and a group inside it (one command each, create or update)
rig gateway org set acme --name "Acme" --policy "Answer Acme's customers within a working day. Never promise a date."
@@ -75,26 +59,30 @@ never changes `workspace.operator_seat_name`, which keeps its own meaning.
## In the TUI: whose queue, and who acts
The person at the terminal is the **primary user**. The TUI records their acts as unnamed (`human@host`),
whatever person's queue it shows or who is configured as the default.
The person at the terminal is the **primary user**: the default person, or, with several people and none chosen, the
person at the terminal unnamed. Whatever the TUI shows, its acts are theirs.
- **Opening:** with a chosen default, the TUI opens on that person's Feed. With none chosen, including a one-person
install that never chose one, it opens on the topology, as before.
- **Whose queue:** `whose <person>` shows one person's asks, `whose all` everyone's, and `whose default` the default
person's. The Feed's first line says whose queue it shows and who acts.
- **Acting on someone else's ask** (answer, approve or resolve from the TUI) records both: the unnamed terminal
actor `human@host` and the person asked in the audit notes and transition. The asking seat reads, for example,
"Unattributed (human@host) answered sarah@external's ask".
- **Viewing Sarah isn't acting as Sarah.** A registered person may answer, approve, deny or resolve another person's
ask as themselves; both the actor and person asked are recorded. No connector binding is required, and viewing
someone else's queue doesn't make its viewer that person.
- **Acting on someone else's ask** (answer, approve or resolve from the TUI) records both: the primary user as the actor,
and the person asked in the audit notes and the transition. The asking seat reads, for example, "Answered by
mike@external, the primary user at the terminal, on sarah@external's ask". With several people and none chosen, the
actor is `human@host`.
- **Viewing Sarah isn't acting as Sarah.** Only Sarah, from her own channel, answers as Sarah. Without the primary
user's authority, one registered person can't answer, approve, deny or resolve what was asked of another; that's
refused (`answer_not_recorded`) and nothing changes.
- With no one registered, the TUI and its acts are as before, except on a shared channel's ask or a row parked on a
channel: the TUI shows the act as `human@host`, the person at the terminal, and it's recorded that way, never as the
channel.
- **A shared channel never acts.** Its messages are correspondence, so nothing it says answers, approves, denies or
resolves an ask, and a customer's earlier message in an ask's thread is never taken as its answer. A request without
the primary user's authority that acts as a channel, with any Mission Control verb (including drop, route, hold, hand
off and annotate), is refused (`answer_not_recorded`) and nothing changes.
- **Where the authority applies:** primary-user authority is accepted only on a request that carries no seat identity
header, which is how the TUI calls the daemon. A headerless client can instead name a person's `actorSession` without
that authority. Seats and the CLI send the identity header, so their acts stay theirs. This is the existing
trusted-network distinction, not an authentication check.
- **Where the authority applies:** only on a request that carries no seat identity header, which is how the TUI calls
the daemon. Seats and the CLI send that header, so their acts stay theirs. This is the existing trusted-network
distinction, not an authentication check: a request that leaves the header out is taken as the terminal's, as any
such request already is.
## Shared-channel conversations
-11
View File
@@ -291,17 +291,6 @@ fine; if both accounts are authenticated, the kernel uses both even when your
later project uses just one. A request to use only one provider for everything
is a separate choice.
`/api/kernel/status` retains the chosen template (`rig.yaml`,
`rig-claude-only.yaml` or `rig-codex-only.yaml`) across daemon restarts and
recovery, including starts with `--no-kernel`. It reads the rig's latest rig-spec
bootstrap record, including a partial launch, so an older kernel's recorded
selection is also available. This is the original selection; the rig's live spec
describes its current configuration. The saved source must match a template under
the current installation’s built-in specs directory. A custom project path with
the same directory suffix, an old source path that can no longer be verified
(for example, after a Node version change moves the global install), or a kernel
without bootstrap history reports an unknown variant.
**Started is not ready.** Read the `Kernel:` boot state in `rig status` and the
individual seat state in `rig ps --nodes --rig kernel`. The view may open while
agents finish starting; describe that state, without claiming they are ready.
-11
View File
@@ -110,17 +110,6 @@ limitation: use the live spec-library commands to determine where a spec is
served from, and do not infer convergence merely because the canonical
directory exists.
The spec library scans the built-in specs, `$OPENRIG_HOME/specs` and, when
present, that legacy library. At any depth below them it skips directories
named `.git`, `.worktrees`, `node_modules`, `dist`, `build`, `.turbo` and
`.next`, which usually hold build output or dependencies (`SKIP_DIRS` in
`packages/daemon/src/domain/spec-library-service.ts`). A spec inside one of
them, such as `specs/build/rig.yaml`, isn't listed, and nothing reports that it
was skipped. To list it, rename its directory and run `rig specs sync`.
`rig specs add <directory>` copies a directory under its own name, so adding a
directory named `build` doesn't help. `rig up <path-to-rig.yaml>` still launches
a spec by its path, without the library.
Config updates publish a complete replacement file atomically where directory
permissions and the filesystem allow it. On POSIX systems, replacement preserves
the existing owner, group, and read/write/execute permission bits. If creating or
-22
View File
@@ -107,28 +107,6 @@ rig queue create --destination <entityId>@external --human-intent update \
- **`posted` means the connector posted it, not that they read it.** Don't say they saw it.
- **If delivery fails or is indeterminate,** inspect that task before anything else. Never resend blindly.
## If the timer cannot reach its recipient
A `periodic-reminder` occurrence that definitely fails before input remains owed. The watchdog
retries through its existing loop, using `queue.wake_retry_interval_seconds` and
`queue.wake_retry_cap` (by default, three retries five minutes apart). A failed attempt does not
advance the reminder to its next regular cycle. A confirmed send advances the normal interval;
recovery does not replay every elapsed cycle.
`rig watchdog show <jobId> --json` includes the failed attempt, its retained outbox IDs, retry
time and next action. If input may have reached the recipient, that occurrence ends unconfirmed
and is not automatically sent again. Inspect the recipient and exact outbox record before any
manual replay. A recurring reminder keeps its next normal cycle under a new occurrence identity. A confirmed receipt survives a daemon restart, so
finishing its accounting does not type the same occurrence twice.
Exhausted retries end only the failed occurrence, with its cause and next action; a recurring
reminder keeps its next normal cycle. A permanent transport failure leaves a terminal job:
correct the destination and register a replacement reminder. A one-shot park timer records its
failed or unconfirmed wake on the parked row before it ends. A retryable failure is recorded
there while the timer stays armed. Deliberately held
input keeps its existing retained-message custody. Condition watches and parked-owner policy
behavior are unchanged. This does not replay failed occurrences recorded by older versions.
## "What are you watching for me?"
List **every active state**, not just set-aside ones. A watch being checked can be in progress, and a new one can be
+1 -2
View File
@@ -25,8 +25,7 @@ that restores nothing is not itself evidence of accidental loss.
## Choose the interface the seat actually has
Pi 1.4.0 includes an opt-in selector at
`packages/coding-agent/examples/extensions/tools.ts` in the Pi repository. Its
Pi 1.4.0 includes an opt-in selector at `examples/extensions/tools.ts`. Its
`/tools` command remembers selection per branch and needs Pi's own TUI. Use it
where that extension and native TUI are available. **Do not send `/tools` to an
agent OpenRig drives over RPC**: that interface cannot operate the selector.
+2 -2
View File
@@ -91,10 +91,10 @@ my-rig.rigbundle.sha256 — "a1b2c3d4..." (64-char hex SHA-256)
The packer produces deterministic output:
- Files are sorted alphabetically
- A fixed mtime is used (`2026-01-01T00:00:00Z`)
- Portable mode omits owner metadata (uid, gid, user and group names) and only partly normalizes file modes: owner read and write are always set and group and other write are cleared, while execute and read bits are kept
- Portable mode normalizes uid/gid/mode
- Maximum gzip compression (level 9)
Identical staged bytes and file modes (including manifest timestamps and provenance) produce the same archive hash, so staged trees with the same contents whose files differ in execute or read bits hash differently. Use the package digest to compare packaged file content independently of file modes and manifest metadata.
Identical staged bytes (including manifest timestamps and provenance) produce the same archive hash. Use the package digest to compare packaged file content independently of manifest metadata.
---
-59
View File
@@ -755,7 +755,6 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
| `agent_file` | string | no | — | Claude members only. A Claude agent definition (a `.md` file with `name` in its frontmatter) for this seat. It's copied into the seat's own storage and launched as `--agent openrig:<name>`. A path beside the spec: no `..`, not absolute. |
| `append_system_prompt_file` | string | no | — | Claude members only. A prompt file appended to Claude's system prompt for this seat. It's copied into the seat's own storage and passed as `--append-system-prompt-file`. A path beside the spec. |
| `plugin_dirs` | list | no | — | Claude members only. Claude plugin folders for this seat, each copied into the seat's own storage and loaded through `CLAUDE_CODE_PLUGIN_DIRS`. Paths beside the spec. Each is copied under its folder name, so two folders with the same name are refused; an empty list is none. A missing file or folder fails that seat's launch by name. `rig bundle create` carries all three; `rig export` names a seat whose additions it doesn't carry. |
| `plugin_channels` | list | no | — | Claude members only. Plugin channels this agent registers for, as `plugin:<name>@<marketplace>` entries, passed as one `--channels` on every launch; see [Claude plugin channels](#claude-plugin-channels-runtime-claude-code). |
| `role` | string | no | — | The seat's role, letters, digits, `_`, `.` and `-`. Not valid on terminal members. |
| `restore_policy` | string | no | the AgentSpec's | Restore behavior. One of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`. The default comes from the AgentSpec's `defaults.lifecycle` (else `resume_if_possible`) as narrowed by its profile; a member value may only narrow it further, in that order. |
| `compaction_strategy` | string | no | — | `default-compaction`, `managed-compaction`, `handover` or `apprentice-handover`; `harness_native` and `pod_continuity` are accepted as deprecated aliases, and `custom_prompt` is refused. `managed-compaction` and `apprentice-handover` create their jobs only for `claude-code` seats; see [Codex compaction and sizing](agent-startup-guide.md#codex-compaction-and-sizing). |
@@ -811,52 +810,6 @@ OpenRig keeps the requested opt-out flags, so startup and resume remain subject
to that native refusal; it does not fall back to loading servers. Use a host
without that managed configuration, or omit the opt-out to use the managed set.
### Claude plugin channels (`runtime: claude-code`)
Set `plugin_channels` on a Claude member to register that agent for channel
notifications (messages a plugin's MCP server pushes into the session):
```yaml
members:
- id: support
agent_ref: local:agents/support
profile: default
runtime: claude-code
cwd: .
plugin_channels:
- plugin:example-channel@example-marketplace
```
OpenRig passes `--channels plugin:example-channel@example-marketplace` to this
agent's Claude on every launch, resume, fork, restore and handover. Several
entries follow one `--channels`, space-separated, and OpenRig's own session
options come after them. Claude Code 2.1.316 hides `--channels` from
`claude --help`, so it isn't listed there. An agent without the field gets no
`--channels`, and nothing is added for other agents or other runtimes; there is
no rig-wide or environment default.
Each entry is `plugin:<name>@<marketplace>`, both names made of letters, digits,
`.`, `_` and `-`, starting with a letter or digit. `server:` entries and Claude's
`--dangerously-load-development-channels` aren't supported. Combining a non-empty
channel list with `claude_mcp: none` produces a validation warning: that setting
starts Claude without MCP servers (plugin servers included), so no channel could
register. OpenRig keeps both settings and launches the member normally. Remove
`claude_mcp: none` to use the channels, or remove `plugin_channels` to keep the MCP
opt-out. OpenRig doesn't install plugins or marketplaces, or change the settings
that admit a channel: whether each channel registers is Claude's decision (the plugin installed, and
admitted by your organisation's channel settings or Claude's approved list).
Selecting a channel for one agent doesn't isolate what the plugin shares with
other agents. A token or other credential the plugin keeps in its own
configuration is the same for every agent that loads that plugin.
The selection is saved with the seat's other launch additions when the seat
starts from the spec, and starting it from a spec without the field removes it.
`rig seat launch --plan` and `rig skill loadout --seat` list it, a fork keeps it,
and `rig bundle create` and `rig export` carry it in the member; a bundle's
before-install view names each channel as a need. A running Claude process is
unaffected.
### Claude Advisor (`runtime: claude-code`)
Set `advisor` independently of the primary `model`:
@@ -1321,15 +1274,3 @@ These are the built-in specs shipped with OpenRig. Read them as worked examples.
Also shipped, in the same tree: `launch/kernel`, OpenRig's own team, which starts automatically. `first-project` is
starter's old name and still resolves to it. `workshop`, the four-agent middle team, is a rig bundle rather than a
built-in. List the built-ins with `rig specs ls --kind rig`.
To omit built-in rigs, agents and workflows from the shared spec inventory, run
`rig config set specs.builtins_visible false`. This changes the CLI, TUI and other
consumers of the library listing; user specs remain visible. Use
`rig specs ls --include-builtins` to list them deliberately, and set the value
back to `true` to restore the default. Explicit `show`, `preview`, and launch
lookups can still find hidden built-ins. When built-ins are hidden and a user
spec has the same name, the user spec wins the name lookup; use the built-in's
ID from `rig specs ls --include-builtins --json` to select it. With built-ins visible,
same-name matches remain ambiguous. This is an inventory preference, not an
access-control boundary; other endpoints and on-disk files can still expose the
built-ins.
+3 -22
View File
@@ -144,23 +144,6 @@ changes either. `rig scope` reads an `established` or `canonical` slice whose
`verified:` is missing, has no source, names a tool-scaffolded placeholder, or is
older than 90 days as `provisional`.
A slice's frontmatter may also carry `status:`. `GET /api/slices` reports the
bucket each word falls in as the slice's `status`, and its
`?filter=all|active|done|blocked` filters on that bucket:
| `status:` word | Bucket |
| --- | --- |
| `active`, `in-flight`, `ratified` | `active` |
| `draft`, `draft-pending-orch-ratification`, `placeholder` | `draft` |
| `done`, `shipped`, `promoted`, `closed` | `done` |
| `blocked`, `parked-with-evidence` | `blocked` |
Words are matched ignoring case. Any other word is matched by what it contains:
`done`, `ship` or `close` makes it `done`; `block` or `park`, `blocked`; `draft`
or `pending`, `draft`. A word that matches none of these counts as `active`, so a
slice marked `complete` counts as `active`. A slice with no `status:` counts as
`draft`.
When the owning scope selects `proofPolicy.judges`, use `rig proof judge` to
record an attributed accept, reject or withdraw decision on a contract item,
and `rig proof show` to read derived readiness up through slice, mission and
@@ -195,11 +178,9 @@ missions whose `mission.yaml` carries `metadata.status: active` or
a mission does not change the project's readiness. A `planned` mission stays
outside the rollup, even after its slices are accepted, until someone changes
its status to `active` (or `release-candidate` for a release that is ready to
judge). Nothing changes the status automatically. A mission can also carry
`metadata.status: complete`, which only `POST /api/missions/:missionId/complete`
writes (no `rig` verb does); a `complete` mission is outside the rollup too.
Missions created before this default carry no status and are not counted either;
add the line by hand to include them. It also writes `metadata.name`, the mission's folder name, which
judge). Nothing changes the status automatically. Missions created before this
default carry no status and are not counted either; add the line by hand to
include them. It also writes `metadata.name`, the mission's folder name, which
the project lifecycle compiler requires; a mission created without it refuses to
compile with `lifecycle_field_missing` until you add that line.
+6 -31
View File
@@ -10,7 +10,7 @@ type: `openrig-urgent`, `urgent-only` and `sends-only`. Sibling seats keep their
| `off` (default) | Existing delivery and prompt checks | Existing send result |
| `draft-aware` | Paste into a recognized empty composer; check ownership again before Enter | Hold input and retain this attempt for inspection when bookkeeping is available |
| `openrig-urgent` | OpenRig's own messages (watchdogs, scheduled wakes, notices) and anything marked urgent type, never over a draft | Other agents' messages are held, the sender is told, and held messages stay readable |
| `urgent-only` | Only messages marked urgent type (`rig send --urgent`, `urgent: true` on `POST /api/transport/send`, or a queue item at priority urgent or critical), never over a draft | Everything else is held, the sender is told, and held messages stay readable |
| `urgent-only` | Only messages marked urgent type (`rig send --urgent`, or a queue item at priority urgent or critical), never over a draft | Everything else is held, the sender is told, and held messages stay readable |
| `sends-only` | Deliberate sends from people and agents type, as with `off`; a terminal member's typing isn't protected, because its shell input can't be read | OpenRig's own notices are held. A terminal member defaults to this level |
| `hold` | No automatic terminal input, even at an empty prompt | Retain for inspection outside the pane |
@@ -111,18 +111,8 @@ their existing behaviour.
### Terminal delivery
`rig seat set-typing-guard` works from an ordinary terminal as well as from a
seat's shell. The daemon takes the recorded actor from transport identity only:
- From a managed seat's shell (`OPENRIG_SESSION_NAME` set), the preference and
its audit entry name that seat.
- From a terminal with no seat identity, the change applies the same way, and
the preference and its audit entry record the actor as
`unattributed (no seat identity)`.
Neither case takes an actor from the request body or from the target seat's name.
`rig seat retire-held-message` still records the seat that retired a message, so
run it from a seat's shell.
Run from a seat shell with `OPENRIG_SESSION_NAME` set; the daemon derives the
audit actor from transport identity.
~~~sh
rig seat set-typing-guard dev-impl@my-rig --mode draft-aware \
@@ -141,21 +131,8 @@ to finish. The API may return HTTP 202 with `pending: true`; wait for
and effective modes and retains the text `Typing guard: on (hold)` for hold.
A held send immediately returns `outcome: "retained"`, `sent: false`, its exact
reason and the saved attempt's `outboxIds`. No automatic
retry is scheduled. The reasons are:
- `typing_guard_enabled`: the seat's mode holds this send (`hold`, or a level that
holds this kind of message).
- `runtime_switch_in_progress`: the seat is switching runtime.
- In `draft-aware` mode, a refusal before any input was written:
`draft_input_busy` (the input holds unsubmitted text), `draft_input_unknown` (the
input can't be read), `draft_input_changed` (the input or its target changed during
the delivery), `draft_wake_superseded`, `target_needs_input`,
`wait_for_idle_timeout`, `target_activity_unknown`, `target_runtime_unverified`,
`target_runtime_not_running`, `target_runtime_conflict`, `invalid_wait_for_idle`,
`invalid_dangerously_interact`, `dangerously_interact_requires_reason`,
`prompt_override_audit_unavailable`, `transport_unavailable`, `session_missing` or
`tmux_unavailable`. Each explicit draft-aware request is a new attempt, including
reason (such as `draft_input_busy`) and the saved attempt's `outboxIds`. No automatic
retry is scheduled. Each explicit draft-aware request is a new attempt, including
one with a reused `deliveryId`; the result warns older callers that this field no
longer deduplicates input. The CLI does not generate a deduplication key.
@@ -253,9 +230,7 @@ refuses Enter and is not replayed. Unsupported provider layouts remain held.
Use off when that tradeoff is unsuitable.
HTTP configuration uses `POST /api/seat/set-typing-guard/:seatRef` with
`{mode, reason}` or `{enabled, reason}`. The `X-OpenRig-Session` header, when
present, is the recorded actor; without it the change is recorded as
`unattributed (no seat identity)`. Status and held-message routes are
`{mode, reason}` or `{enabled, reason}`. Status and held-message routes are
unchanged; no RigSpec field is required.
## Verification scope
+4 -54
View File
@@ -99,24 +99,8 @@ So after installing, compare the granted scopes Slack shows for the app with all
`rig slack manifest --json` lists.
The app subscribes to messages in public channels it is a member of (`message.channels`), to
mentions of the app (`app_mention`), and to emoji reactions (`reaction_added`). The manifest does
not request direct-message or private-channel access.
### Using a private channel
The connector doesn't check whether a channel is public or private, so a private channel can be
the default channel or a mapped one once the app can read it. OpenRig doesn't test this setup. In
the app's settings:
1. Under **OAuth & Permissions**, add the bot scopes `groups:history` (read messages in private
channels the app is in) and `groups:read` (read a private channel's details; `rig slack verify`
needs it to confirm the app is a member).
2. Under **Event Subscriptions**, subscribe to the bot event `message.groups`.
3. Reinstall the app to the workspace, then invite it to the private channel.
4. To have `rig slack verify` require the new scopes too, run
`rig slack setup --required-scopes chat:write,channels:history,channels:read,groups:history,groups:read`.
Direct messages are not supported.
mentions of the app (`app_mention`), and to emoji reactions (`reaction_added`). It does not request
direct-message or private-channel access.
A reaction added to an ask reaches the agent that asked, as a note naming who reacted and with
which emoji; the agent decides what it means. Every message OpenRig posted for the ask counts:
@@ -182,9 +166,6 @@ rig slack channel-map list
rig slack channel-map remove pr@my-rig
```
A client can offer the channels to choose from with `GET /api/gateway/slack/channels`, which lists the public
channels the bot token can see, with their ids ([Writing a daemon client](daemon-http-clients.md#route-details)).
- **Which channel an item posts to.** The item's seat is matched exactly first (`pr@my-rig`),
then its rig (`my-rig`). Anything unmapped posts to the default channel, so a config
without a map behaves exactly as before. Aggregate delivery digests have no single seat and
@@ -240,9 +221,8 @@ configuration matches the file.
The map is stored as `channelMap` in `slack-connector.json`, as a list of `{ "match", "channel" }`
entries, each with an optional `"inbound"` seat. Saving refuses any other field. A field written by a newer OpenRig is ignored when the
connector reads the file, and `rig slack status` names it. A private channel can be a target once
the app has the private-channel scopes ([Using a private channel](#using-a-private-channel)).
Direct messages are not supported targets.
connector reads the file, and `rig slack status` names it. Private channels and direct messages
are not supported targets.
## Named bots (optional)
@@ -386,36 +366,6 @@ Quiet hours hold a person's routine asks while they're on, and post them once th
## Reconnect recovery and status
When a posted ask is answered, closed, canceled or replaced elsewhere, the connector edits its
original primary message with the status, time, actor and closure note. Buttons become option
text and the answer hint disappears; the remaining content and reconciliation marker stay as
posted. Supplemental messages are unchanged. This adds no post or reaction and needs no agent
turn. Separate resolution notices retain their existing behavior.
A deliberately reopened direct ask says **Open again**; its options remain as text. If it has
no committed answer, it tells the person to reply in its thread. Otherwise it says **Earlier
answer retained**: reopening does not accept a second answer. Earlier replaced parks remain
historical; **Asked again** does not claim that the later ask has been posted. Edits reconcile
the current state again after awaited Slack calls, so an obsolete close is not recorded as the
current result after a reopen.
Edits follow committed queue changes, with a startup catch-up for posted asks on retained queue
rows. Each primary costs one exact-message read and one `chat.update`. The serial edit worker
uses the recovery budget helpers (four calls, 100 entries, 15 seconds) and waits for the next
budget window when exhausted. Slack's per-method `Retry-After` is retained across restarts.
Only an acknowledged edit gets a success receipt. `rig slack status` shows queued reconciliation
and the last edit failure. Transient failures retry with increasing waits, capped at an hour,
behind other queued work. Permanent Slack failures (such as missing scope, a deleted original,
an archived channel or an uneditable message) remain unfinished and are retried only on the next
connector start. They do not keep consuming edit capacity. These edit calls have their own
budget, separate from missed-message scanning below.
The baseline already includes public-channel history access and `chat:write`. Private-channel
reads may require `groups:history`, which the baseline does not grant; a refused read never
counts as an edit. An unavailable/deleted original cannot be replaced by a new post. Startup
catch-up cannot edit rows already removed by queue retention or posts without their original
notification receipt and channel stamp.
The daemon scans available top-level messages and replies broadcast with **Also send to
channel** in the channels this machine owns: its default channel and its channel map's, except a
channel it relays to another machine. It also scans replies in the threads it tracks in those
+5 -15
View File
@@ -300,23 +300,13 @@ small presentation provenance, separate from queue membership. Herdr display
labels are not that provenance. Other providers/platforms remain unsupported
for ensure-viewer until qualified; ordinary open retains its existing behavior.
A writable tile attaches with `tmux attach -f ignore-size` when its session
already has a client that sizes it, so the tile doesn't fight that client over
the agent window's dimensions. When the session has no sizing client, as with a
freshly launched seat, the tile attaches without the flag and sizes the window
itself: tmux applies ignore-size across the whole server, so a session viewed
only by ignore-size clients would otherwise keep its 80x24 creation size while
any other session has a sizing client. Read-only clients keep `-r` (which also
implies ignore-size), and the printed manual attach commands keep
`-f ignore-size`. This is **not resize isolation**: opening a tile can change
the agent's terminal size. Observers of a Herdr terminal do not themselves resize it;
Writable tile clients use `tmux attach -f ignore-size`; read-only clients keep
`-r` (which also implies ignore-size). This can avoid contributing to sizing
when other clients establish the agent window's dimensions. It is **not resize
isolation**: opening a tile can change the agent's terminal size when it is the
only sizing client. Observers of a Herdr terminal do not themselves resize it;
a controlling browser can. No cross-version resize guarantee is implied.
To type into a seat without changing its size, send the text through OpenRig
(`rig send`, or `POST /api/transport/send`): it is written to the pane with tmux
buffers and keys, and no client attaches. There is no supported way to attach a
writable client that is sure never to resize the agent's window.
## Following a slice or mission's current work
-1
View File
@@ -69,7 +69,6 @@ Keys:
topology.root the topology tree root (instance altitude at its top; default $OPENRIG_HOME/topology)
context.root addressable context library for 'rig context add' (default $OPENRIG_HOME/context)
context.system_world default System World, replacement manifest path, or disabled
specs.builtins_visible show built-ins in spec inventory (default true)
skills.root versioned skill catalog (default $OPENRIG_HOME/skills)
skills.router_line.enabled the person's yes to the seat-skills line (one line plus OpenRig's markers) in AGENTS.md / CLAUDE.md (default false)
onboarding.default_pack.enabled deliver the two-part fresh-seat mental-model pack (default on)
+7 -6
View File
@@ -732,16 +732,17 @@ Examples:
.option("--json", "Complete records as JSON, including organizations and groups")
.action(async (opts: { organization?: string; json?: boolean }) => {
const registry = await import("@openrig/daemon/gateway-human-registry");
if (opts.json) {
const result = registry.participantsJsonView(undefined, opts.organization);
if (!result.ok) { console.error(`refused: ${result.error}`); process.exitCode = 1; return; }
console.log(JSON.stringify(result));
return;
}
const view = registry.participantsView();
if (!view.ok) { console.error(`refused: ${view.error}`); process.exitCode = 1; return; }
const shown = opts.organization ? view.participants.filter((p) => p.organization?.id === opts.organization) : view.participants;
const defaultPerson = view.default.person ? { id: view.default.person.entityId, source: view.default.source } : { id: null, reason: view.default.source === "none" ? view.default.reason : null };
if (opts.json) {
const side = registry.readParticipantsFile();
console.log(JSON.stringify({ ok: true, participants: shown, default: defaultPerson,
organizations: side.ok ? side.file.organizations : null, groups: side.ok ? side.file.groups : null,
warnings: view.warnings, participantFile: view.sideFile }));
return;
}
if (!shown.length) console.log(opts.organization ? `no participants in organization "${opts.organization}"` : "no participants yet: rig gateway human add … registers a person, rig gateway channel add … a shared channel");
for (const p of shown) {
console.log(`${p.id} ${p.kind} "${p.displayName}" ${p.address}${p.channel ? ` channel ${p.channel.channelId}` : ""}`);
+9 -31
View File
@@ -24,9 +24,8 @@ import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { hostname as osHostname, userInfo } from "node:os";
import { connect } from "node:net";
import { execFile } from "node:child_process";
import { getOpenRigHome } from "../openrig-compat.js";
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry, type SshHostEntry } from "../host-registry.js";
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry } from "../host-registry.js";
import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executor.js";
import { recordHostObservation, loadHostBindings, describeBindingConflict, defaultHostBindingsPath, type HostObservationOutcome } from "../host-bindings.js";
import { DaemonClient } from "../client.js";
@@ -168,35 +167,15 @@ function defaultDoctorDeps(): DoctorDeps {
};
}
/** Where ssh would connect for this host, from `ssh -G`, which reads ssh config without connecting.
* Null when ssh can't resolve the target or a ProxyJump/ProxyCommand sits in between, since no
* direct dial stands for that transport. */
export function resolveSshEndpoint(host: SshHostEntry, timeoutMs = 1500): Promise<{ hostname: string; port: number } | null> {
const args = [...(host.user ? ["-l", host.user] : []), "-G", host.target];
return new Promise((resolve) => {
execFile("ssh", args, { timeout: timeoutMs }, (error, stdout) => {
if (error) return resolve(null);
const conf = new Map(stdout.split("\n").map((line) => {
const i = line.indexOf(" ");
return [line.slice(0, i).toLowerCase(), line.slice(i + 1).trim()] as const;
}));
const proxied = ["proxyjump", "proxycommand"].some((k) => (conf.get(k) ?? "none") !== "none");
const hostname = conf.get("hostname");
const port = Number(conf.get("port"));
resolve(hostname && Number.isInteger(port) && !proxied ? { hostname, port } : null);
});
});
}
/** FR-1 doctor legs — each failing step maps to a DISTINCT actionable error:
* "SSH works but daemon is down" ≠ "daemon works but registry is wrong" ≠
* "remote rig binary missing/old". */
/** OPR.0.4.6.MH1 FR-3 — the coarse ls status word: a BOUNDED tcp dial
* against the host's transport endpoint (ssh: the host and port ssh itself
* resolves, see resolveSshEndpoint; http: the URL's host:port). Coarse by
* design (the kubectl STATUS / docker context ls precedent): reachable |
* unreachable | unknown — never a hang (hard timeout), never a guess (dial
* failure = unreachable, probe error/unparseable endpoint = unknown). */
* against the host's transport endpoint (ssh: target:22; http: the
* URL's host:port). Coarse by design (the kubectl STATUS / docker
* context ls precedent): reachable | unreachable | unknown — never a
* hang (hard timeout), never a guess (dial failure = unreachable,
* probe error/unparseable endpoint = unknown). */
export async function probeHostStatus(
host: HostEntry,
tcpProbe: DoctorDeps["tcpProbe"],
@@ -206,9 +185,8 @@ export async function probeHostStatus(
let target: string;
let port: number;
if (host.transport === "ssh") {
const endpoint = await resolveSshEndpoint(host, timeoutMs);
if (!endpoint) return "unknown";
({ hostname: target, port } = endpoint);
target = host.target;
port = 22;
} else {
const u = new URL(host.url);
target = u.hostname.replace(/^\[|\]$/g, "");
@@ -783,7 +761,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
cmd
.command("pair")
.description("Pair with a remote host from one pasted address — one approval on the target, done (FR-6)")
.argument("<url>", "The target daemon's address (http[s]://host:port, or host:port); that daemon must run with OPENRIG_AUTH_BEARER_TOKEN set")
.argument("<url>", "The target daemon's address (http[s]://host:port, or host:port)")
.option("--id <id>", "Registry id for the new host (default: derived from the hostname)")
.option("--timeout <seconds>", "How long to wait for the target-side approval", "600")
.option("--human <address>", "Registered @external approval recipient on the target (required when several exist)")
+1 -1
View File
@@ -42,7 +42,7 @@ export function setExperiment(file: string, enabled: boolean, options: Partial<E
const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW, 0o600);
try {
const s = fs.fstatSync(fd);
if (!s.isFile() || s.nlink !== 1 || (process.getuid !== undefined && s.uid !== process.getuid())) throw Error("experiment config must be an owned regular file");
if (!s.isFile() || s.nlink !== 1 || s.uid !== process.getuid?.()) throw Error("experiment config must be an owned regular file");
fs.ftruncateSync(fd, 0); fs.writeFileSync(fd, JSON.stringify(config, null, 2) + "\n");
} finally { fs.closeSync(fd); }
return config;
+2 -13
View File
@@ -99,8 +99,6 @@ interface NodeEntry {
blockedWorkCount?: number;
/** OPR.0.3.4.11 — held reason from node.held event. */
heldReason?: string | null;
/** #2456: the required startup files this occupant's handover started it without. */
startedWithoutRequired?: string[];
agentActivity?: {
state: "running" | "needs_input" | "idle" | "unknown";
reason: string;
@@ -240,7 +238,6 @@ const ALLOWED_NODE_FIELDS = new Set([
"needsInputEvidence",
"contextUsage",
"heldReason",
"startedWithoutRequired",
"storedSessionStatus",
"identityVerdict",
]);
@@ -708,11 +705,6 @@ export function formatDeclaredModel(model: string | null | undefined): string {
return m.length > 0 ? m : "not-declared";
}
/** #2456: the attention reason for an occupant started without a required startup file. */
function startedWithoutLine(files: string[]): string {
return `missing required ${files.map((file) => file.split("/").pop()).join(", ")}`;
}
export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string, unknown>> {
return nodes.map((n) => {
const keepDiagnostics = n.needsAttention !== false;
@@ -776,7 +768,6 @@ export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string,
}
// held/attention reason (short) when present.
if (n.heldReason) compact.heldReason = n.heldReason;
if (n.startedWithoutRequired?.length) compact.startedWithoutRequired = n.startedWithoutRequired;
if (keepDiagnostics && n.latestError) {
compact.latestError = n.latestError;
}
@@ -935,7 +926,7 @@ intentionallyStopped, startupStatus, restoreOutcome, lifecycleState, tmuxAttachC
resumeCommand, latestError, terminalActive, hasAssignedWork,
assignedWorkCount, pendingWorkCount, inProgressWorkCount, blockedWorkCount,
agentActivity, needsAttention, arbitratedNeedsInput, needsInputEvidence,
contextUsage, heldReason, startedWithoutRequired, storedSessionStatus, identityVerdict.
contextUsage, heldReason, storedSessionStatus, identityVerdict.
--host runs on a remote host declared in ~/.openrig/hosts.yaml (no current-rig
default applied; the remote host's rigs are shown).
@@ -1367,8 +1358,7 @@ async function handleNodes(
console.log(padCompactNodeRow("RIG", "SESSION", "LIFECYCLE", "ACTIVITY", "WORK", "REASON", "ATTN"));
for (const n of humanList as NodeEntry[]) {
const reason = missingSessionGuidance(n) ?? (n.needsAttention !== false || hasCurrentUsageLimit(n)
? (n.latestError ? truncate(n.latestError, 40) : n.startedWithoutRequired?.length ? truncate(startedWithoutLine(n.startedWithoutRequired), 40)
: n.agentActivity?.reason ?? "—")
? (n.latestError ? truncate(n.latestError, 40) : n.agentActivity?.reason ?? "—")
: livenessUnconfirmed(n) ? "liveness unconfirmed" : "—");
console.log(padCompactNodeRow(
n.rigName,
@@ -1405,7 +1395,6 @@ async function handleNodes(
formatContextUsage(n.contextUsage),
n.restoreOutcome,
missingSessionGuidance(n) ?? (n.latestError ? truncate(n.latestError, 30) : n.heldReason ? `held: ${truncate(n.heldReason, 25)}`
: n.startedWithoutRequired?.length ? truncate(startedWithoutLine(n.startedWithoutRequired), 30)
: livenessUnconfirmed(n) ? "liveness unconfirmed" : "—"),
));
}
+1 -13
View File
@@ -703,12 +703,6 @@ export function previewBody(
};
}
// Mark the cut inside the body so readers that discard metadata or stderr can request the complete brief.
// Counts use code points, like the preview.
export function previewCutMarker(qitemId: string, shownCodePoints: number, totalCodePoints: number): string {
return `…[cut at ${shownCodePoints} of ${totalCodePoints} chars; rig queue show ${shellQuote(qitemId)} --full]`;
}
function isRecordWithStringBody(v: unknown): v is Record<string, unknown> & { body: string } {
return (
typeof v === "object" &&
@@ -1240,7 +1234,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--reopen", "Explicitly acknowledge a deliberate terminal-to-active repair; requires --state and --note")
.option("--closure-reason <reason>", "Required for state=done. --state canceled takes no reason, except 'superseded' (with --closure-target = the successor), which records a supersession, distinct from an abandoned cancel")
.option("--closure-target <target>", "Required for handed_off_to, escalation, and superseded; blocked_on park records use state=blocked with --blocked-on")
.option("--closure-required-at <iso>", "Accept a revised closure deadline on an in-progress item; keeps the claim and records the old/new time and accepting actor")
.option("--resolution <text>", "The outcome, stored on the row and shown by rig queue show. Needs --state done, canceled, denied, failed or handed-off, and non-blank text. Stored when the update closes an open row, after anything the row already has (a fallback line, an earlier outcome); a row that is already closed keeps what it has")
.option("--blocked-on <blocker>", "For state=blocked: the blocker — a qitem id (must exist and be live), a human seat (FR-6 park; requires summary + evidence_ref), or a typed non-qitem gate 'fold:<what>' / 'auth:<what>' / 'external:<what>'")
.option("--wake-watchdog <jobId>", "For state=blocked: attach an existing live watchdog id targeting the row owner")
@@ -1256,7 +1249,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
reopen?: boolean;
closureReason?: string;
closureTarget?: string;
closureRequiredAt?: string;
resolution?: string;
blockedOn?: string;
wakeWatchdog?: string;
@@ -1293,7 +1285,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
reopen: opts.reopen,
closureReason: opts.closureReason,
closureTarget: opts.closureTarget,
...(opts.closureRequiredAt !== undefined ? { closureRequiredAt: opts.closureRequiredAt } : {}),
resolution: opts.resolution,
blockedOn: opts.blockedOn,
wakeWatchdogId: opts.wakeWatchdog,
@@ -1684,10 +1675,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
...(closingTransition === undefined ? [] : [omittedReadField("closingTransition", closingTransition)]),
];
const view = readView(item, fullCommand, omitted);
const shownBody = bodyTruncated
? `${preview}${previewCutMarker(qitemId, Array.from(preview).length, Array.from(item.body).length)}`
: preview;
const transformed = { ...record, body: shownBody, bodyBytes, bodyTruncated, readView: view };
const transformed = { ...record, body: preview, bodyBytes, bodyTruncated, readView: view };
// Slice 15 P2: with output.compact on, the empty fields are left out and counted.
const compact = compactOutputEnabled() ? compactShow(transformed) : null;
printResult(json, compact ? compact.item : transformed, res.status);
+2 -36
View File
@@ -213,7 +213,7 @@ function buildSliceLsCommand(): Command {
const cmd = new Command("ls")
.description("List slices in a mission (or across all missions)")
.option("--mission <name>", "Restrict to a single mission")
.option("--state <state>", "Filter: active (in flight, blocked included) | shipped (delivered in place: shipped or done) | closed (in closed/, or superseded, retired or closed in place) | all", "active")
.option("--state <state>", "Filter: active | closed | shipped | all", "active")
.option("--json", "Machine-readable output")
.action(async (opts, command) => {
const out = makeStdout();
@@ -727,13 +727,12 @@ function buildMissionLsCommand(): Command {
id: m.id,
path: m.absPath,
activeSliceCount: m.activeSliceCount,
shippedSliceCount: m.shippedSliceCount,
closedSliceCount: m.closedSliceCount,
}));
emit(out, { ok: true, count: rows.length, missions: rows }, json,
rows.length === 0
? ["(no missions)"]
: rows.map((r) => `${r.name} ${r.id ?? "—"} active=${r.activeSliceCount} shipped=${r.shippedSliceCount} closed=${r.closedSliceCount}`),
: rows.map((r) => `${r.name} ${r.id ?? "—"} active=${r.activeSliceCount} closed=${r.closedSliceCount}`),
);
} catch (err) {
fail(err, json, out);
@@ -765,7 +764,6 @@ function buildMissionShowCommand(): Command {
id: mission.id,
path: mission.absPath,
activeSliceCount: mission.activeSliceCount,
shippedSliceCount: mission.shippedSliceCount,
closedSliceCount: mission.closedSliceCount,
frontmatter: mission.frontmatter,
trust,
@@ -780,7 +778,6 @@ function buildMissionShowCommand(): Command {
out.write(` id: ${mission.id ?? "—"}\n`);
out.write(formatTrustLine(trust));
out.write(` active slices: ${mission.activeSliceCount}\n`);
out.write(` shipped slices: ${mission.shippedSliceCount}\n`);
out.write(` closed slices: ${mission.closedSliceCount}\n`);
out.write(` path: ${mission.absPath}\n`);
if (readme) {
@@ -1103,7 +1100,6 @@ function buildAuditCommand(): Command {
frontmatter: {},
id: null,
activeSliceCount: 0,
shippedSliceCount: 0,
closedSliceCount: 0,
};
const graph = buildMissionDependencyGraph(auditMission);
@@ -1236,8 +1232,6 @@ function buildAuditCommand(): Command {
for (const sr of sliceResults) {
const shadow = shadowedNodeFileFinding(path.join(slicesDir, sr.name), "slice");
if (shadow) sr.result.findings.push(shadow);
const shared = sharedSliceNameFinding(missionsRoot, missionName, sr.name);
if (shared) sr.result.findings.push(shared);
}
const allFindings = [
@@ -2158,34 +2152,6 @@ function shadowedNodeFileFinding(dir: string, level: "mission" | "slice"): {
};
}
/**
* Advisory-only: another mission has a slice folder of the same name. The slice list and the routes that
* find a slice by name keep one slice per name, so the others are hidden there with no sign they exist.
* Twin of the daemon's finding of the same name.
*/
function sharedSliceNameFinding(missionsRoot: string, missionName: string, sliceName: string): {
kind: "slice_name_shared"; severity: "low"; path: string; message: string; remediation: string;
} | null {
let others: string[];
try {
others = fs.readdirSync(missionsRoot, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== missionName
&& fs.statSync(path.join(missionsRoot, e.name, "slices", sliceName), { throwIfNoEntry: false })?.isDirectory() === true)
.map((e) => e.name)
.sort();
} catch {
return null;
}
if (others.length === 0) return null;
return {
kind: "slice_name_shared",
severity: "low",
path: path.join(missionsRoot, missionName, "slices", sliceName),
message: `Mission ${others.join(", ")} also ${others.length === 1 ? "has" : "have"} a slice named "${sliceName}". The slice list (GET /api/slices) and the routes that find a slice by name keep one slice per name, so all but one of these slices are hidden there.`,
remediation: "Rename one of the slice folders so each slice name is unique across missions. Advisory only — nothing is blocked.",
};
}
export function scopeCommand(): Command {
const cmd = new Command("scope")
.description("Scope tree primitive: missions, slices, sub-slices (per conventions/scope-and-versioning)")
+1 -15
View File
@@ -159,16 +159,12 @@ interface SeatHandoverMutationResult {
currentStatus: SeatHandoverPlan["currentStatus"];
handoverAt: string;
eventSeq: number;
/** What the successor was told on taking the seat, such as its held messages. */
warnings?: string[];
/** OPR.0.5.5.5 — per-source execution record (wire mirror of the daemon shape). */
sourceOutcome?:
| { mode: "fork"; forkedFrom: string; forkId?: "captured" | "pending"; limit?: string }
| { mode: "rebuild"; primedArtifacts: Array<{ address: string; label: string }>; gaps: string[]; emptyChainReason?: string; permissions?: { state: string; detail: string } };
/** The restore packet was pasted and Enter pressed, but its submission couldn't be confirmed. */
restorePacket?: { status: "unconfirmed"; reason: string };
/** #2456: the required startup files the successor started without, where a start would have failed. */
startedWithoutRequired?: string[];
sideEffects: {
departingSessionKilled: false;
/** Absent from an older daemon, whose receipt reported both side effects as a fixed false. */
@@ -327,11 +323,7 @@ function printHumanHandoverPlan(plan: SeatHandoverPlan): void {
}
function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
// #2456: a successor that started without a required startup file isn't a plain success; a start would have failed.
const withoutRequired = result.startedWithoutRequired ?? [];
console.log(`Seat handover complete${withoutRequired.length > 0 ? ", degraded" : ""}: ${result.seat.ref}`);
for (const file of withoutRequired) console.log(`The successor started without required file ${file}.`);
if (withoutRequired.length > 0) console.log("rig ps --nodes shows the seat as needing attention until it's next launched.");
console.log(`Seat handover complete: ${result.seat.ref}`);
console.log(`Rig: ${result.seat.rigName}`);
console.log(`Logical ID: ${result.seat.logicalId}`);
console.log(`Source: ${result.source.mode}${result.source.ref ? `:${result.source.ref}` : ""}`);
@@ -341,7 +333,6 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
console.log(`Current occupant: ${result.currentOccupant}`);
console.log(`Handover result: ${display(result.currentStatus.handoverResult)}`);
console.log("Seat binding and inventory provenance were updated.");
for (const warning of result.warnings ?? []) console.warn(`Warning: ${warning}`);
// An older daemon reports both side effects as a fixed false: say nothing rather than misreport them.
if (result.sideEffects.departingProcess) {
console.log(result.sideEffects.provenanceRecordWritten
@@ -469,11 +460,6 @@ when the current input contains a draft or cannot be read. It returns the exact
reason and held message ID immediately, without automatic retry.
Off (--enabled false) permits new automatic sends.
This works from an ordinary terminal as well as from a seat's shell. From a
managed seat's shell (OPENRIG_SESSION_NAME set), the change is recorded under
that seat. From a terminal with no seat identity, it applies the same way and is
recorded as "unattributed (no seat identity)".
Between off (always interrupt) and hold (full quiet):
openrig-urgent OpenRig's own messages (watchdogs, scheduled wakes, notices) and
anything marked urgent type; other agents' messages are held.
+15 -42
View File
@@ -1,4 +1,3 @@
import { isHumanSeatSessionRef } from "../session-name.js";
import { Command } from "commander";
import { resolveEffectiveHost } from "../host-selection.js";
import { DaemonClient, DaemonConnectionError, terminalAuthHeaders } from "../client.js";
@@ -91,9 +90,6 @@ export function wrapSendBody(
// renders EXACTLY AS RECEIVED — bare member@rig locally (copy-paste reply hint), and a
// cross-host caller constructs the origin triple at the forwarding boundary before wrapping.
const senderLabel = sender && sender.trim().length > 0 ? sender : SENDER_FALLBACK;
// Only reply classification uses the bare address; From and the destination retain the origin host.
const senderParts = senderLabel.split("@");
const personAddress = senderParts.length === 3 && senderParts[2] ? senderParts.slice(0, 2).join("@") : senderLabel;
const header = [`From: ${senderLabel}`, renderToLine(recipient, meta?.scope)];
if (meta?.stampISO) {
// GHOST-STAGE (g): the sender's occupant generation rides the Sent: line as a short suffix
@@ -106,9 +102,7 @@ export function wrapSendBody(
}
const reply = senderLabel.endsWith("@external")
? `↩ Reply if needed: rig queue create --destination ${senderLabel}${meta?.replyToQitem ? ` --human-intent update --reply-to ${meta.replyToQitem}` : ""} --summary "<one line>" --body "..." --verify`
: isHumanSeatSessionRef(personAddress)
? `↩ Reply if needed: rig queue create --destination ${senderLabel} --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`
: `↩ Reply: rig send ${senderLabel} "..."`;
: `↩ Reply: rig send ${senderLabel} "..."`;
return [...header, "---", body, "---", reply].join("\n");
}
@@ -571,7 +565,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
const envelope = !effect
? res.data
: effectUnresolved(effect)
? { ...res.data, verified: false, outcome: effectSubmissionUnconfirmed(effect) ? "submission-unconfirmed" : "staged-not-consumed", effectCheck: effect }
? { ...res.data, verified: false, outcome: "staged-not-consumed", effectCheck: effect }
: { ...res.data, effectCheck: effect };
console.log(JSON.stringify(envelope));
if (res.status >= 400) process.exitCode = res.status >= 500 ? 2 : 1;
@@ -638,7 +632,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
* recheck, then stops. Pure classification: no printing here.
*/
type EffectCheck =
| { checked: true; state: "staged"; remedy: "submitted-cleared" | "submitted-still-staged" | "submit-refused" | "submit-unconfirmed"; detail?: string }
| { checked: true; state: "staged"; remedy: "submitted-cleared" | "submitted-still-staged" | "submit-refused"; detail?: string }
| { checked: true; state: "no-staged-residual" }
| { checked: false; why: string };
@@ -689,26 +683,16 @@ async function classifyDeliveryEffect(
const probe = await detectStagedAtPrompt(client, session, identity);
if (probe.state === "unchecked") return { checked: false, why: probe.why };
if (probe.state === "not-staged") return { checked: true, state: "no-staged-residual" };
let enter: { status: number; data: Record<string, unknown> };
try {
enter = await client.post<Record<string, unknown>>("/api/transport/send", {
session,
submitOnly: true,
expectedStagedText: identity.expectedStagedText,
expectedStagedLineCount: identity.expectedLines,
}, transportRequestOptions(waitForIdleMs));
} catch (err) {
return { checked: true, state: "staged", remedy: "submit-unconfirmed", detail: (err as Error).message };
}
const enter = await client.post<Record<string, unknown>>("/api/transport/send", {
session,
submitOnly: true,
expectedStagedText: identity.expectedStagedText,
expectedStagedLineCount: identity.expectedLines,
}, transportRequestOptions(waitForIdleMs));
if (enter.status >= 400) {
const phase = enter.data?.["inputPhase"];
const reason = enter.data?.["reason"];
const unconfirmed = phase === "attempted" || phase === "written"
|| (phase !== "not_reached" && enter.data?.["sent"] !== false && (reason === "submit_failed" || enter.status >= 500));
return { checked: true, state: "staged", remedy: unconfirmed ? "submit-unconfirmed" : "submit-refused", detail: (enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}` };
return { checked: true, state: "staged", remedy: "submit-refused", detail: (enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}` };
}
const recheck = await detectStagedAtPrompt(client, session, identity);
if (recheck.state === "unchecked") return { checked: true, state: "staged", remedy: "submit-unconfirmed", detail: recheck.why };
return recheck.state === "staged"
? { checked: true, state: "staged", remedy: "submitted-still-staged" }
: { checked: true, state: "staged", remedy: "submitted-cleared" };
@@ -721,17 +705,13 @@ function effectHumanLines(effect: EffectCheck, session: string): string[] {
return [`Note: the pane-effect check could not run (${effect.why}); the verdict above is transport-level only.`];
}
if (effect.state === "no-staged-residual") return [];
if (effect.remedy === "submit-unconfirmed") return [
`Delivery: submission unconfirmed — the text was staged before one guarded Enter attempt, but the result could not be confirmed (${effect.detail}). Inspect with: rig capture ${session}`,
];
if (effect.remedy === "submit-refused") return [
`Delivery: unconfirmed — the guarded Enter was refused (${effect.detail}); the earlier staged observation may have changed. Inspect with: rig capture ${session}`,
];
const out = ["Delivery: staged, not consumed (checked: post-send pane capture; observed: the sent text is still at the prompt — typed, never submitted)"];
if (effect.remedy === "submitted-cleared") {
out.push("Remedy: one guarded Enter submitted — the staged text left the prompt.");
} else if (effect.remedy === "submitted-still-staged") {
out.push(`Remedy: one guarded Enter submitted, but the text is STILL at the prompt — not consumed; stopping here (one submit is the contract). Inspect with: rig capture ${session}`);
} else {
out.push(`Remedy: the single guarded Enter (submit path) was refused (${effect.detail}); stopping here — one submit is the contract. Inspect with: rig capture ${session}`);
}
return out;
}
@@ -741,10 +721,6 @@ function effectUnresolved(effect: EffectCheck | undefined): boolean {
return !!effect && effect.checked && effect.state === "staged" && effect.remedy !== "submitted-cleared";
}
function effectSubmissionUnconfirmed(effect: EffectCheck | undefined): boolean {
return !!effect && effect.checked && effect.state === "staged" && (effect.remedy === "submit-unconfirmed" || effect.remedy === "submit-refused");
}
/**
* S3 (OPR.0.5.4.6) — the staged-at-prompt detector, walk's staged-evidence
* primitive generalized to plain sends. POSITIVE evidence only: text rendered
@@ -778,7 +754,7 @@ async function detectStagedAtPrompt(
for (let i = lines.length - 1; i >= 0; i--) {
if (PROMPT_LINE_PATTERN.test(lines[i]!)) { lastPrompt = i; break; }
}
if (lastPrompt === -1) return { state: "unchecked", why: "no input prompt was visible in the capture" };
if (lastPrompt === -1) return { state: "not-staged" };
const inputRegionRaw = lines.slice(lastPrompt).join("\n").replace(PROMPT_LINE_PATTERN, "");
// IDENTITY FIRST (round-2 F2, desk-binding): the literal-residual match uses
// the daemon submit-precheck's own normalization (strip ALL whitespace,
@@ -1107,7 +1083,6 @@ async function runFanOutSend(params: {
}
const effectBySeat = new Map((effects ?? []).map((e) => [e.sessionName, e.effect]));
const unresolvedCount = (effects ?? []).filter((e) => effectUnresolved(e.effect)).length;
const unconfirmedCount = (effects ?? []).filter((e) => effectSubmissionUnconfirmed(e.effect)).length;
if (opts.json) {
// Round-2 F1: ONE verdict per recipient — a staged-unresolved recipient's
@@ -1115,7 +1090,7 @@ async function runFanOutSend(params: {
const rawResults = (res.data["results"] as Array<{ sessionName: string; ok: boolean; outcome?: string }> | undefined) ?? [];
const encodedResults = rawResults.map((r) =>
effectUnresolved(effectBySeat.get(r.sessionName))
? { ...r, ok: false, verified: false, outcome: effectSubmissionUnconfirmed(effectBySeat.get(r.sessionName)) ? "submission-unconfirmed" : "staged-not-consumed", error: effectSubmissionUnconfirmed(effectBySeat.get(r.sessionName)) ? "delivery unconfirmed; inspect the recipient after the guarded submit attempt" : "staged, not consumed (pane effect); the one guarded submit did not clear it" }
? { ...r, ok: false, verified: false, outcome: "staged-not-consumed", error: "staged, not consumed (pane effect); the one guarded submit did not clear it" }
: r
);
// Round-3 (r2 row 00fb3a68): the machine-readable AGGREGATE derives from
@@ -1158,8 +1133,6 @@ async function runFanOutSend(params: {
if (ec && ec.checked && ec.state === "staged") {
if (ec.remedy === "submitted-cleared") {
console.log(`${r.sessionName}: sent — staged at the prompt, cleared by one guarded Enter (consumed)`);
} else if (effectSubmissionUnconfirmed(ec)) {
console.log(`${r.sessionName}: ${effectHumanLines(ec, r.sessionName)[0]}`);
} else {
console.log(`${r.sessionName}: staged, not consumed — ${effectHumanLines(ec, r.sessionName).slice(-1)[0]}`);
}
@@ -1172,7 +1145,7 @@ async function runFanOutSend(params: {
}
// The delivered count never includes a staged-unresolved recipient.
const deliveredCount = Math.max(0, ((data["sent"] as number) ?? 0) - unresolvedCount);
console.log(`${deliveredCount}/${data["total"]} delivered${unresolvedCount - unconfirmedCount > 0 ? `, ${unresolvedCount - unconfirmedCount} staged-not-consumed` : ""}${unconfirmedCount > 0 ? `, ${unconfirmedCount} submission-unconfirmed` : ""}`);
console.log(`${deliveredCount}/${data["total"]} delivered${unresolvedCount > 0 ? `, ${unresolvedCount} staged-not-consumed` : ""}`);
// S2 (OPR.0.5.4.3): surface additive advisories (e.g. the unknown-sender
// sign-it notice) — an env-less operator must see it, not "sent" lines alone.
const fanoutAdvisory = data["warning"] as string | undefined;
-5
View File
@@ -251,7 +251,6 @@ export function slackCommand(deps: SlackDeps = {}): Command {
log(`Daemon: ${observation.state}${observation.reason ? ` (${observation.reason})` : ""}`);
const connector = observation.connector as { configurationDigest?: string; inboundReady?: boolean; outboundReady?: boolean;
inboundRouting?: { fallback: string; destination: string };
settlement?: { pending: number; lastFailure: string | null };
deadLetterBacklog?: number | null; deadLetterBacklogState?: string; deadLetterBacklogReason?: string;
inbound?: InboundDeliveryStatus & { state?: string; generation?: number; lastEventAt?: string };
recovery?: { state?: string; reason?: string; lastScanAt?: string; acceptedThisProcess?: number; deadLetteredThisProcess?: number;
@@ -262,10 +261,6 @@ export function slackCommand(deps: SlackDeps = {}): Command {
backoff?: Record<string, number>; incomplete?: Record<string, number> };
limits?: string[] } } | undefined;
if (connector) {
if (connector.settlement) {
log(` Ask edits: ${connector.settlement.pending} queued for reconciliation`);
if (connector.settlement.lastFailure) log(` Last ask-edit failure: ${connector.settlement.lastFailure}`);
}
if (connector.inboundRouting?.fallback === "local") log(` Configured routing fallback: local to ${connector.inboundRouting.destination} (after forwarding, thread and channel routes)`);
log(` Socket: ${connector.inbound?.state ?? "unknown"}; generation ${connector.inbound?.generation ?? "unknown"}; last event ${connector.inbound?.lastEventAt ?? "unknown"}`);
for (const line of socketDeliveryLines(connector.inbound)) log(` ${line}`);
+13 -21
View File
@@ -157,25 +157,21 @@ export async function resolveLibrarySpec(
nameOrId: string,
opts?: { kind?: string },
): Promise<LibraryEntry> {
const url = opts?.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library";
const res = await client.get<LibraryEntry[]>(url);
const filterKind = (entries: LibraryEntry[]) =>
const res = await client.get<LibraryEntry[]>(
opts?.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library",
);
const rawEntries = res.data ?? [];
const entries =
opts?.kind !== undefined && !KNOWN_SPEC_KINDS.includes(opts.kind as (typeof KNOWN_SPEC_KINDS)[number])
? entries.filter((entry) => entry.kind === opts.kind)
: entries;
const visibleEntries = filterKind(res.data ?? []);
// An exact ID always wins, including when a visible name matches a hidden ID.
const visibleId = visibleEntries.find((entry) => entry.id === nameOrId);
if (visibleId) return visibleId;
const separator = opts?.kind !== undefined ? "&" : "?";
const all = await client.get<LibraryEntry[]>(`${url}${separator}include_builtins=true`);
const entries = filterKind(all.data ?? []);
? rawEntries.filter((e) => e.kind === opts.kind)
: rawEntries;
// Try exact ID match first
const byId = entries.find((e) => e.id === nameOrId);
if (byId) return byId;
// Visible names take precedence over hidden built-ins with the same name.
const visibleNames = visibleEntries.filter((e) => e.name === nameOrId);
const byName = visibleNames.length ? visibleNames : entries.filter((e) => e.name === nameOrId);
// Try name match
const byName = entries.filter((e) => e.name === nameOrId);
if (byName.length === 1) return byName[0]!;
if (byName.length > 1) {
@@ -234,16 +230,12 @@ Examples:
cmd.command("ls")
.description("List library rigs, agents, and managed apps")
.option("--kind <kind>", "Filter by kind (rig, agent, or workflow)")
.option("--include-builtins", "Include built-ins hidden by the visibility setting")
.option("--json", "JSON output")
.action(async (opts: { kind?: string; json?: boolean; includeBuiltins?: boolean }) => {
.action(async (opts: { kind?: string; json?: boolean }) => {
try {
warnIfUnknownKind(opts.kind);
const client = await getClient();
const params = new URLSearchParams();
if (opts.kind !== undefined) params.set("kind", opts.kind);
if (opts.includeBuiltins) params.set("include_builtins", "true");
const url = `/api/specs/library${params.size ? `?${params}` : ""}`;
const url = opts.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library";
const res = await client.get<LibraryEntry[]>(url);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
const rawEntries = res.data ?? [];
+1 -1
View File
@@ -104,7 +104,7 @@ export function streamCommand(depsOverride?: StreamDeps): Command {
.description("Append a stream item")
.requiredOption("--source <session>", "Source session (e.g. dev-build@starter)")
.requiredOption("--body <text>", "Stream item body")
.option("--hint-destination <session>", "Hint at intended destination seat (the queue worker routes by this hint)")
.option("--hint-destination <session>", "Hint at intended destination seat")
.option("--hint-type <type>", "Hint type (e.g. review, handoff, idea)")
.option("--hint-urgency <urgency>", "Hint urgency (routine, urgent, critical)")
.option("--hint-tags <tags>", "Comma-separated hint tags")
@@ -11,7 +11,7 @@ interface AgentLibraryEntry {
/** Resolve the runtime's terminal seat or the shipped general-purpose agent. */
export async function resolveDefaultAgentRef(client: DaemonClient, runtime: string): Promise<string> {
if (runtime === "terminal") return "builtin:terminal";
const res = await client.get<AgentLibraryEntry[]>("/api/specs/library?kind=agent&include_builtins=true");
const res = await client.get<AgentLibraryEntry[]>("/api/specs/library?kind=agent");
const entry = res.data?.find(
(candidate) =>
candidate.kind === "agent"
+10 -26
View File
@@ -273,7 +273,7 @@ initial record is explicitly reported as unverified delivery.`)
let verdict = await pollConsumed();
if (verdict === "generation-rolled") {
failPiece(i, piece.label, "the seat's generation rolled mid-walk (a re-prime); delivery is unconfirmed. Inspect the recipient before any resend; the walk cannot continue into a different generation.");
failPiece(i, piece.label, "the seat's generation rolled mid-walk (a re-prime); the walk cannot continue into a different generation.");
return;
}
if (verdict === "timeout") {
@@ -289,39 +289,23 @@ initial record is explicitly reported as unverified delivery.`)
/\[Pasted text #\d+ \+\d+ lines\]/.test(pane)
);
if (stagedEvidence) {
let enter: { status: number; data: Record<string, unknown> };
try {
enter = await client.post<Record<string, unknown>>("/api/transport/send", {
session: seat,
submitOnly: true,
expectedStagedText: piece.content, // FULL bytes — the transport checks the rendered literal residual for contiguous containment
expectedStagedLineCount: piece.content.split("\n").length,
}, { headers: terminalAuthHeaders() });
} catch (error) {
failPiece(i, piece.label, `the guarded submit result is unconfirmed (${(error as Error).message}); inspect the recipient before any resend.`);
return;
}
const enter = await client.post<Record<string, unknown>>("/api/transport/send", {
session: seat,
submitOnly: true,
expectedStagedText: piece.content, // FULL bytes — the transport checks the rendered literal residual for contiguous containment
expectedStagedLineCount: piece.content.split("\n").length,
}, { headers: terminalAuthHeaders() });
if (enter.status >= 400) {
const phase = enter.data?.["inputPhase"], reason = enter.data?.["reason"];
const unconfirmed = phase === "attempted" || phase === "written"
|| (phase !== "not_reached" && enter.data?.["sent"] !== false && (reason === "submit_failed" || enter.status >= 500));
const detail = (enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}`;
failPiece(i, piece.label, unconfirmed
? `the guarded submit result is unconfirmed (${detail}); inspect the recipient before any resend.`
: `the single guarded submit was refused (${detail}); the earlier staged observation may have changed. Inspect the recipient before any resend.`);
failPiece(i, piece.label, `typed but not consumed; the single submit retry was refused (${(enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}`}).`);
return;
}
verdict = await pollConsumed();
if (verdict === "generation-rolled") {
failPiece(i, piece.label, "the seat's generation rolled after the guarded submit; delivery is unconfirmed. Inspect the recipient before any resend.");
return;
}
if (verdict !== "consumed") {
failPiece(i, piece.label, "no matching conversation record was observed within the verification window after the single submit retry; delivery is unconfirmed. Inspect the recipient before any resend.");
failPiece(i, piece.label, `typed and staged, but not consumed even after the single submit retry — the piece never entered the seat's conversation record. Not re-sending (one retry is the contract).`);
return;
}
} else {
failPiece(i, piece.label, `send reported ${sendOutcome === "ok" ? "success" : "a transport error"}, but no matching record or staged input was observed within the verification window. Delivery and consumption are unconfirmed; inspect the recipient before any resend.`);
failPiece(i, piece.label, `send reported ${sendOutcome === "ok" ? "success" : "a transport error"} but the piece is neither consumed in the generation record nor staged in the pane — delivery lost; consumption verification fails this walk closed.`);
return;
}
}
-26
View File
@@ -13,7 +13,6 @@ import {
// truth at ~/.openrig/config.json. Resolution stays env > file > default.
export interface RiggedConfig {
specs: { builtinsVisible: boolean };
launch: { nonInterruptive: boolean };
daemon: { port: number; host: string; dbConflict?: "warn" | "stop" };
// OPR.0.4.6.MH1 FR-1 — the persisted host-selection pointer.
@@ -230,10 +229,6 @@ export interface RiggedConfig {
wakeSwapGraceSeconds: number;
wakeHumanRung: string;
};
// Issue #1856 — stream intake setting
stream: {
intake: string;
};
}
const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
@@ -288,7 +283,6 @@ const DEFAULTS = {
topology: { root: getDefaultOpenRigPath("topology") },
// OPR.0.5.9.5 Wave B — canonical addressable context library.
context: { root: getDefaultOpenRigPath("context"), systemWorld: "default" },
specs: { builtinsVisible: true },
skills: { root: getDefaultOpenRigPath("skills"), routerLine: { enabled: false } },
onboarding: { defaultPack: { enabled: true } },
health: { contextPressure: { warningPercent: 95, criticalPercent: 99 } },
@@ -394,10 +388,6 @@ const DEFAULTS = {
consumers: "",
templateDir: "",
},
// Issue #1856 — stream intake default queue.worker; read live by the stream intake coordinator.
stream: {
intake: "queue.worker",
},
// OPR.0.4.6.FS-1 W2 — retention defaults (twin of daemon getDefaultValue).
retention: {
enabled: true,
@@ -464,7 +454,6 @@ export const VALID_KEYS = [
// OPR.0.5.9.5 Wave B — canonical context library; old key is refused.
"context.root",
"context.system_world",
"specs.builtins_visible",
"skills.root",
// OPR.0.7.0.13 (router): the person's recorded yes to OpenRig's one-line seat-skills bootstrap in AGENTS.md / CLAUDE.md.
"skills.router_line.enabled",
@@ -548,7 +537,6 @@ export const VALID_KEYS = [
// Slice 22 outcome 9 — stream retention: unreferenced items older than N days or beyond the newest N.
"retention.stream_days",
"retention.stream_max_items",
"stream.intake",
"retention.batch_size",
// S04 — pickup-receipt stall threshold; lockstep with the daemon settings-store twin.
"queue.pickup_stall_threshold_minutes",
@@ -593,7 +581,6 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"topology.root": { primary: "OPENRIG_TOPOLOGY_ROOT" },
"context.root": { primary: "OPENRIG_CONTEXT_ROOT" },
"context.system_world": { primary: "OPENRIG_CONTEXT_SYSTEM_WORLD" },
"specs.builtins_visible": { primary: "OPENRIG_SPECS_BUILTINS_VISIBLE" },
"skills.root": { primary: "OPENRIG_SKILLS_ROOT" },
"skills.router_line.enabled": { primary: "OPENRIG_SKILLS_ROUTER_LINE_ENABLED" },
"onboarding.default_pack.enabled": { primary: "OPENRIG_ONBOARDING_DEFAULT_PACK_ENABLED" },
@@ -665,7 +652,6 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"retention.watchdog_keep_per_job": { primary: "OPENRIG_RETENTION_WATCHDOG_KEEP_PER_JOB" },
"retention.stream_days": { primary: "OPENRIG_RETENTION_STREAM_DAYS" },
"retention.stream_max_items": { primary: "OPENRIG_RETENTION_STREAM_MAX_ITEMS" },
"stream.intake": { primary: "OPENRIG_STREAM_INTAKE" },
"retention.batch_size": { primary: "OPENRIG_RETENTION_BATCH_SIZE" },
"queue.pickup_stall_threshold_minutes": { primary: "OPENRIG_QUEUE_PICKUP_STALL_THRESHOLD_MINUTES" },
"queue.stuck_sweep_interval_seconds": { primary: "OPENRIG_QUEUE_STUCK_SWEEP_INTERVAL_SECONDS" },
@@ -702,7 +688,6 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"topology.root": ["topology", "root"],
"context.root": ["context", "root"],
"context.system_world": ["context", "systemWorld"],
"specs.builtins_visible": ["specs", "builtinsVisible"],
"skills.root": ["skills", "root"],
"skills.router_line.enabled": ["skills", "routerLine", "enabled"],
"onboarding.default_pack.enabled": ["onboarding", "defaultPack", "enabled"],
@@ -765,7 +750,6 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"retention.watchdog_keep_per_job": ["retention", "watchdogKeepPerJob"],
"retention.stream_days": ["retention", "streamDays"],
"retention.stream_max_items": ["retention", "streamMaxItems"],
"stream.intake": ["stream", "intake"],
"retention.batch_size": ["retention", "batchSize"],
"queue.pickup_stall_threshold_minutes": ["queue", "pickupStallThresholdMinutes"],
"queue.stuck_sweep_interval_seconds": ["queue", "stuckSweepIntervalSeconds"],
@@ -1129,12 +1113,6 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
throw new Error(`Invalid value for retention.stream_max_items: must be an integer >= 1, got "${raw}"`);
}
},
"stream.intake": (raw) => {
const val = (raw ?? "").trim();
if (val !== "off" && val !== "queue.worker" && !/^[a-zA-Z0-9\-_.]+@[a-zA-Z0-9\-_.]+$/.test(val)) {
throw new Error(`Invalid value for stream.intake: must be "queue.worker", a canonical session (<seat>@<rig>), or "off", got "${raw}"`);
}
},
"retention.batch_size": (raw, coerced) => {
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
throw new Error(`Invalid value for retention.batch_size: must be an integer >= 1, got "${raw}"`);
@@ -1281,7 +1259,6 @@ export class ConfigStore {
root: v("context.root") as string,
systemWorld: v("context.system_world") as string,
},
specs: { builtinsVisible: v("specs.builtins_visible") as boolean },
skills: {
root: v("skills.root") as string,
routerLine: { enabled: v("skills.router_line.enabled") as boolean },
@@ -1408,9 +1385,6 @@ export class ConfigStore {
wakeSwapGraceSeconds: v("queue.wake_swap_grace_seconds") as number,
wakeHumanRung: v("queue.wake_human_rung") as string,
},
stream: {
intake: v("stream.intake") as string,
},
};
}
+1 -4
View File
@@ -31,10 +31,7 @@ export type FindingKind =
// SPEC.md compatibility — a node carrying BOTH authored files. Advisory by
// construction (low severity never flips the exit code): SPEC.md wins, and the
// shadowed README.md is a state to notice, never a failure to gate on.
| "shadowed_node_file"
// Another mission has a slice folder of the same name, which the slice list and the name-only routes
// hide. Advisory (low severity): renaming is the person's call.
| "slice_name_shared";
| "shadowed_node_file";
export interface AuditFinding {
kind: FindingKind;
+52 -36
View File
@@ -362,23 +362,31 @@ export function listMissions(missionsRoot: string): MissionInfo[] {
const hasReadme = readmePath !== null;
if (!hasReadme) continue;
const frontmatter = readFrontmatter(readmePath);
const slicesDir = path.join(absPath, "slices");
const closedDir = path.join(absPath, "closed");
const activeSliceCount = countSliceDirs(slicesDir);
const closedSliceCount = countSliceDirs(closedDir);
const id = pickIdFromFrontmatter(frontmatter);
out.push(withSliceCounts({ name: entry.name, absPath, readmePath, frontmatter, id }));
out.push({
name: entry.name,
absPath,
readmePath: hasReadme ? readmePath : null,
frontmatter,
id,
activeSliceCount,
closedSliceCount,
});
}
// Stable order: by mission name.
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
/** The mission with each of its slices counted once, by its standing (the same one `slice ls` filters on). */
function withSliceCounts(base: Omit<MissionInfo, "activeSliceCount" | "shippedSliceCount" | "closedSliceCount">): MissionInfo {
const mission: MissionInfo = { ...base, activeSliceCount: 0, shippedSliceCount: 0, closedSliceCount: 0 };
for (const { standing } of slicesWithStanding(mission)) {
if (standing === "active") mission.activeSliceCount++;
else if (standing === "shipped") mission.shippedSliceCount++;
else mission.closedSliceCount++;
}
return mission;
function countSliceDirs(dir: string): number {
if (!fs.existsSync(dir)) return 0;
return fs.readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory() && SLICE_DIRNAME_RE.test(e.name))
.length;
}
function pickIdFromFrontmatter(fm: Record<string, unknown>): string | null {
@@ -417,7 +425,15 @@ export function findMission(missionsRoot: string, identifier: string): MissionIn
function buildMissionInfo(missionsRoot: string, absPath: string): MissionInfo {
const readmePath = resolveNodeFile(absPath);
const frontmatter = readmePath ? readFrontmatter(readmePath) : {};
return withSliceCounts({ name: path.basename(absPath), absPath, readmePath, frontmatter, id: pickIdFromFrontmatter(frontmatter) });
return {
name: path.basename(absPath),
absPath,
readmePath,
frontmatter,
id: pickIdFromFrontmatter(frontmatter),
activeSliceCount: countSliceDirs(path.join(absPath, "slices")),
closedSliceCount: countSliceDirs(path.join(absPath, "closed")),
};
}
// ---------------------------------------------------------------------
@@ -428,35 +444,35 @@ export function listSlices(
mission: MissionInfo,
state: SliceState,
): SliceInfo[] {
return slicesWithStanding(mission)
.filter(({ standing }) => state === "all" || standing === state)
.map(({ slice }) => slice);
}
/** Where a slice stands. In closed/, it's closed. In place: shipped (or done) is delivered; a closed or superseded
* status, or a superseded or retired stage, is an exit, so closed; anything else, blocked work included, is active. */
export function sliceStanding(slice: SliceInfo, inClosedFolder: boolean): Exclude<SliceState, "all"> {
if (inClosedFolder) return "closed";
const status = (slice.status ?? "").toLowerCase();
const stage = typeof slice.frontmatter.stage === "string" ? slice.frontmatter.stage.toLowerCase() : "";
if (status === "done" || status.startsWith("shipped")) return "shipped";
if (status.startsWith("closed") || status === "superseded" || stage === "superseded" || stage === "retired") return "closed";
return "active";
}
/** Every slice in the mission (slices/ and closed/), in order, each with its standing. */
function slicesWithStanding(mission: MissionInfo): Array<{ slice: SliceInfo; standing: Exclude<SliceState, "all"> }> {
const out: Array<{ slice: SliceInfo; standing: Exclude<SliceState, "all"> }> = [];
for (const [folder, inClosedFolder] of [["slices", false], ["closed", true]] as const) {
const root = path.join(mission.absPath, folder);
const dirs: Array<{ root: string; bucket: "active" | "closed" }> = [];
if (state === "active" || state === "shipped" || state === "all") {
dirs.push({ root: path.join(mission.absPath, "slices"), bucket: "active" });
}
if (state === "closed" || state === "all") {
dirs.push({ root: path.join(mission.absPath, "closed"), bucket: "closed" });
}
const out: SliceInfo[] = [];
for (const { root } of dirs) {
if (!fs.existsSync(root)) continue;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory() || !SLICE_DIRNAME_RE.test(entry.name)) continue;
const slice = buildSliceInfo(mission, root, entry.name);
out.push({ slice, standing: sliceStanding(slice, inClosedFolder) });
if (!entry.isDirectory()) continue;
const m = SLICE_DIRNAME_RE.exec(entry.name);
if (!m) continue;
const sliceInfo = buildSliceInfo(mission, root, entry.name);
out.push(sliceInfo);
}
}
out.sort((a, b) => (a.slice.nn ?? Number.MAX_SAFE_INTEGER) - (b.slice.nn ?? Number.MAX_SAFE_INTEGER) || a.slice.name.localeCompare(b.slice.name));
out.sort((a, b) => (a.nn ?? Number.MAX_SAFE_INTEGER) - (b.nn ?? Number.MAX_SAFE_INTEGER) || a.name.localeCompare(b.name));
if (state === "active") {
// active = not in closed/, and not already delivered in-place.
return out.filter((s) => {
const st = (s.status ?? "").toLowerCase();
return st !== "done" && !st.startsWith("closed") && !st.startsWith("shipped");
});
}
if (state === "shipped") {
return out.filter((s) => (s.status ?? "").toLowerCase().startsWith("shipped"));
}
return out;
}
-3
View File
@@ -30,10 +30,7 @@ export interface MissionInfo {
/** Minted dot-ID for this mission (read from frontmatter if present,
* otherwise inferred from the folder name pattern). */
id: string | null;
/** Slices in flight (blocked included); delivered in place; and closed (closed/, or an exit in place). Each slice is
* counted once, by the standing `slice ls --state` filters on. */
activeSliceCount: number;
shippedSliceCount: number;
closedSliceCount: number;
}
@@ -7,8 +7,6 @@ export interface SeatLaunchAdditionsView {
agentPromptReplacesDefault?: boolean;
appendSystemPromptFile?: string;
pluginDirs: string[];
/** #2044: absent from a daemon before it, and when the seat has none. */
pluginChannels?: string[];
missing: string[];
refused: string[];
}
@@ -19,7 +17,6 @@ export function seatLaunchAdditionLines(additions: SeatLaunchAdditionsView | nul
...(additions.agent ? [`agent ${additions.agent} (--agent${additions.agentPromptReplacesDefault ? "; its prompt replaces Claude's default system prompt" : ""})`] : []),
...(additions.appendSystemPromptFile ? [`prompt ${additions.appendSystemPromptFile} (--append-system-prompt-file)`] : []),
...additions.pluginDirs.map((dir) => `plugin dir ${dir} (CLAUDE_CODE_PLUGIN_DIRS)`),
...(additions.pluginChannels ?? []).map((entry) => `channel ${entry} (--channels)`),
...additions.missing.map((file) => `missing ${file}: not passed to the launch`),
...additions.refused.map((reason) => `refused ${reason}: not passed to the launch`),
];
@@ -46,7 +46,7 @@ describe("bootstrap source paths respect the daemon target", () => {
// The responder reads relative paths from a different root, like an already-running daemon.
server = http.createServer(async (req, res) => {
res.setHeader("Content-Type", "application/json");
if (req.url === "/api/specs/library" || req.url === "/api/specs/library?include_builtins=true") {
if (req.url === "/api/specs/library") {
catalogReads++;
res.end(JSON.stringify(catalog));
return;
@@ -184,7 +184,7 @@ describe("bootstrap source paths respect the daemon target", () => {
("preserves catalog source paths (explicit URL=%s, plan=%s)", async (explicitUrl, plan) => {
if (explicitUrl) vi.stubEnv("OPENRIG_URL", daemonUrl);
await run("bootstrap", "catalog-rig", [plan ? "--plan" : "--yes"]);
expect(catalogReads).toBe(2);
expect(catalogReads).toBe(1);
expect(requests[0]?.sourceRef).toBe(catalog[0]!.sourcePath);
expect(requests[0]?.content).toBe("name: daemon\n");
});
@@ -192,7 +192,7 @@ describe("bootstrap source paths respect the daemon target", () => {
it.each([false, true])("preserves unresolved bare-name fallback (explicit URL=%s)", async (explicitUrl) => {
if (explicitUrl) vi.stubEnv("OPENRIG_URL", daemonUrl);
await run("bootstrap", "unlisted", ["--plan"]);
expect(catalogReads).toBe(2);
expect(catalogReads).toBe(1);
expect(requests[0]?.sourceRef).toBe("unlisted");
});
@@ -208,7 +208,7 @@ describe("bootstrap source paths respect the daemon target", () => {
if (explicitUrl) vi.stubEnv("OPENRIG_URL", daemonUrl);
catalog.push({ ...catalog[0]!, id: "other-id", sourcePath: path.join(callerDir, "rig.yaml") });
await run("bootstrap", "catalog-rig", ["--plan"]);
expect(catalogReads).toBe(2);
expect(catalogReads).toBe(1);
expect(requests).toEqual([]);
expect(process.exitCode).toBe(1);
});
+1 -1
View File
@@ -400,7 +400,7 @@ describe("Bootstrap CLI", () => {
let body = "";
req.on("data", (chunk: Buffer) => { body += chunk; });
req.on("end", () => {
if (url === "/api/specs/library" || url === "/api/specs/library?include_builtins=true") {
if (url === "/api/specs/library") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "lib1", name: "my-lib-rig", sourcePath: "/specs/my-lib-rig.yaml", kind: "rig", version: "0.2", sourceType: "user_file" }]));
} else if (url.includes("/api/bootstrap")) {
@@ -138,7 +138,6 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"topology.root",
"context.root",
"context.system_world",
"specs.builtins_visible",
"skills.root",
"skills.router_line.enabled",
"onboarding.default_pack.enabled",
@@ -212,7 +211,6 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"retention.watchdog_keep_per_job",
"retention.stream_days", // slice 22 outcome 9: stream retention
"retention.stream_max_items",
"stream.intake",
"retention.batch_size",
"queue.pickup_stall_threshold_minutes",
// S02 — standing-stuck-sweep cadence + unclaimed-obligation age.
@@ -236,16 +234,6 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
expect(() => store.set("daemon.db_conflict", "always")).toThrow(/warn or stop/);
});
it("defaults stream.intake to queue.worker and validates destinations", () => {
const store = new ConfigStore(configPath);
expect(store.get("stream.intake")).toBe("queue.worker");
store.set("stream.intake", "off");
expect(store.get("stream.intake")).toBe("off");
store.set("stream.intake", "lead@kernel");
expect(store.get("stream.intake")).toBe("lead@kernel");
expect(() => store.set("stream.intake", "invalid-bare-seat")).toThrow(/Invalid value for stream.intake/);
});
it("resolves context.root from the canonical default and OPENRIG_CONTEXT_ROOT", () => {
const store = new ConfigStore(configPath);
expect(store.resolveWithSource("context.root")).toMatchObject({
-8
View File
@@ -63,14 +63,6 @@ describe("ConfigStore", () => {
});
// Test 1
it("persists the built-in inventory preference as a validated boolean", () => {
const store = new ConfigStore(join(tmpDir, "config.json"));
expect(store.resolveOne("specs.builtins_visible").value).toBe(true);
store.set("specs.builtins_visible", "false");
expect(store.resolve().specs.builtinsVisible).toBe(false);
expect(() => store.set("specs.builtins_visible", "no")).toThrow();
});
it("resolve() returns defaults when no config file and no env", () => {
const store = new ConfigStore(join(tmpDir, "config.json"));
const config = store.resolve();
+1 -6
View File
@@ -484,17 +484,12 @@ describe("PUBLIC bin ownership (guard finding 1 — the wrapper is the real fron
// "PRODUCTION PATH … REAL probeFrontDoor" test earlier, with no real TUI spawned.)
it.skipIf(!existsSync(binWrapper))("piped PUBLIC bin keeps the commander usage baseline (no TUI, fast exit)", () => {
const started = Date.now();
const result = spawnSync(process.execPath, [binWrapper], {
input: "x\n",
timeout: 5000,
encoding: "utf-8",
});
expect(result.status, JSON.stringify({
elapsedMs: Date.now() - started, status: result.status, signal: result.signal,
error: result.error && { name: result.error.name, message: result.error.message, code: (result.error as NodeJS.ErrnoException).code },
stdout: result.stdout?.slice(-4096), stderr: result.stderr?.slice(-4096),
})).toBe(0);
expect(result.status).toBe(0);
expect(`${result.stderr}${result.stdout}`).toMatch(/Usage: rig/);
});
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -6,7 +6,6 @@ import { createProgram } from "../src/index.js";
// The registry module is home-state owned by the daemon; the verb (and this test) reach
// it via the narrow @openrig/daemon/gateway-human-registry subpath (the C3/crash-cart rail).
import { humansDir, loadHumanRegistry } from "@openrig/daemon/gateway-human-registry";
import { gatewayRoutes } from "../../daemon/src/routes/gateway.js";
// M1 A3 pt2 / A4b relocate — `rig gateway human add` verb integration. The verb lazy-imports
// the relocated daemon surface; these tests drive the real command end-to-end.
@@ -20,24 +19,10 @@ describe("rig gateway human add verb (post-relocate)", () => {
process.exitCode = undefined;
});
afterEach(() => {
vi.restoreAllMocks();
if (prevHome === undefined) delete process.env.OPENRIG_HOME; else process.env.OPENRIG_HOME = prevHome;
rmSync(home, { recursive: true, force: true });
});
it("participants --json and the daemon route expose the same filtered view", async () => {
const add = createProgram(); add.exitOverride();
await add.parseAsync(["node", "rig", "gateway", "human", "add", "mike", "--display-name", "Mike", "--delivery-class", "B"]);
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const command = createProgram(); command.exitOverride();
await command.parseAsync(["node", "rig", "gateway", "participants", "--organization", "missing", "--json"]);
const cli = JSON.parse(String(log.mock.calls.at(-1)?.[0]));
const response = await gatewayRoutes({ home }).request("/participants?organization=missing");
expect(response.status).toBe(200);
expect(await response.json()).toEqual(cli);
expect(cli).toMatchObject({ ok: true, participants: [], default: { id: "mike", source: "only-person" } });
});
it("is wired via createProgram (gateway human add)", () => {
const gw = createProgram().commands.find((c) => c.name() === "gateway");
expect(gw).toBeDefined();
@@ -1,51 +0,0 @@
// `rig host ls` status for an ssh host dials where ssh itself would connect (`ssh -G`), so an
// ssh-config alias or a non-22 port isn't reported unreachable. A stand-in `ssh` on PATH plays
// `ssh -G`; no real ssh runs.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { probeHostStatus } from "../src/commands/host.js";
let dir: string;
let savedPath: string | undefined;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "host-status-ssh-"));
writeFileSync(join(dir, "ssh"), `#!/bin/sh
case "$*" in
*-G*devbox*) printf 'user ops\\nhostname 10.1.2.3\\nport 2222\\n' ;;
*-G*jumpy*) printf 'hostname jumpy\\nport 22\\nproxyjump bastion\\n' ;;
*) exit 255 ;;
esac
`);
chmodSync(join(dir, "ssh"), 0o755);
savedPath = process.env["PATH"];
process.env["PATH"] = `${dir}:${savedPath}`;
});
afterEach(() => {
process.env["PATH"] = savedPath;
rmSync(dir, { recursive: true, force: true });
});
describe("rig host ls status for an ssh host", () => {
it("dials the host and port ssh resolves for an ssh-config alias", async () => {
const dials: string[] = [];
const status = await probeHostStatus({ id: "devbox", transport: "ssh", target: "devbox" }, async (target, port) => {
dials.push(`${target}:${port}`);
return "open";
});
expect(status).toBe("reachable");
expect(dials).toEqual(["10.1.2.3:2222"]);
});
it.each([["a ProxyJump host", "jumpy"], ["a target ssh can't resolve", "broken"]])(
"reads unknown for %s, since a direct dial can't stand for the transport", async (_label, target) => {
const dials: string[] = [];
const status = await probeHostStatus({ id: target, transport: "ssh", target }, async (t, port) => {
dials.push(`${t}:${port}`);
return "closed";
});
expect(status).toBe("unknown");
expect(dials).toEqual([]);
});
});
+3 -32
View File
@@ -4,7 +4,7 @@ import path from "node:path";
import os from "node:os";
import { PassThrough } from "node:stream";
import type { QueueDeps } from "../src/commands/queue.js";
import { resolveQueueBody, previewBody, previewCutMarker, waitForDeliveryOutcome } from "../src/commands/queue.js";
import { resolveQueueBody, previewBody, waitForDeliveryOutcome } from "../src/commands/queue.js";
import { createProgram } from "../src/index.js";
/**
@@ -1204,17 +1204,6 @@ describe("rig queue CLI", () => {
});
});
it("update forwards an explicitly accepted closure deadline without a state change", async () => {
const { deps, calls } = makeDeps();
const program = createProgram({ queueDeps: deps });
program.exitOverride();
await program.parseAsync(["node", "rig", "queue", "update", "qitem-estimate", "--closure-required-at", "2026-11-04T10:00:00.000Z", "--note", "Coordinator accepted the revised estimate", "--json"]);
expect(calls.find(c => c.path === "/api/queue/qitem-estimate/update")?.body).toMatchObject({
closureRequiredAt: "2026-11-04T10:00:00.000Z", transitionNote: "Coordinator accepted the revised estimate",
});
expect(calls.find(c => c.path === "/api/queue/qitem-estimate/update")?.body).toHaveProperty("state", undefined);
});
it("update forwards explicit reopen acknowledgment", async () => {
const { deps, calls } = makeDeps();
const program = createProgram({ queueDeps: deps });
@@ -1860,8 +1849,7 @@ describe("rig queue CLI", () => {
const printed = JSON.parse(logs[0]);
expect(printed.bodyTruncated).toBe(true);
expect(printed.bodyBytes).toBe(1000);
// Keep the first 512 code points, then mark the cut inside the body.
expect(printed.body).toBe(`${"A".repeat(512)}…[cut at 512 of 1000 chars; rig queue show 'qitem-1' --full]`);
expect(Array.from(printed.body).length).toBe(512);
// #1806: stdout is the record alone and parses as JSON; the marker line, with the honest total byte size, is on stderr
expect(JSON.parse(logs.join("\n"))).toMatchObject({ qitemId: "qitem-1", bodyTruncated: true });
expect(errors.join("\n")).toContain("bounded preview — complete body is 1000 bytes; full record");
@@ -1904,24 +1892,7 @@ describe("rig queue CLI", () => {
const printed = JSON.parse(logs[0]);
expect(printed.bodyTruncated).toBe(true);
expect(printed.bodyBytes).toBe(1000);
const marker = "…[cut at 512 of 1000 chars; rig queue show 'qitem-1' --full]";
expect(printed.body.endsWith(marker)).toBe(true);
expect(Array.from(printed.body.slice(0, -marker.length)).length).toBe(PREVIEW_MAX_CODEPOINTS);
});
it("show default: a multibyte body's marker counts code points, not bytes", async () => {
const full = "😀".repeat(600); // 600 code points, 2400 bytes
const { deps } = showRoute({ qitemId: "qitem-1", body: full });
const program = createProgram({ queueDeps: deps });
program.exitOverride();
await program.parseAsync(["node", "rig", "queue", "show", "qitem-1", "--json"]);
const printed = JSON.parse(logs[0]);
expect(printed.bodyBytes).toBe(2400);
expect(printed.body).toBe(`${"😀".repeat(512)}…[cut at 512 of 600 chars; rig queue show 'qitem-1' --full]`);
});
it("previewCutMarker quotes the row id for the shell", () => {
expect(previewCutMarker("qitem-it's", 512, 3983)).toBe(`…[cut at 512 of 3983 chars; rig queue show 'qitem-it'\\''s' --full]`);
expect(Array.from(printed.body).length).toBe(PREVIEW_MAX_CODEPOINTS);
});
it("show --full --json: byte-identical COMPLETE item body (compatibility contract — no preview fields)", async () => {
@@ -1,43 +0,0 @@
// The CLI twin of the daemon's slice_name_shared advisory: two missions with a slice folder of the same
// name leave one of them out of the slice list and the routes that find a slice by name. Advisory only.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { scopeCommand } from "../src/commands/scope.js";
let workspace: string;
let missionsRoot: string;
beforeEach(() => {
workspace = fs.mkdtempSync(path.join(os.tmpdir(), "cli-slice-name-audit-"));
missionsRoot = path.join(workspace, "missions");
for (const mission of ["alpha", "beta"]) {
fs.mkdirSync(path.join(missionsRoot, mission, "slices", "01-login"), { recursive: true });
fs.writeFileSync(path.join(missionsRoot, mission, "SPEC.md"), "# mission\n");
fs.writeFileSync(path.join(missionsRoot, mission, "slices", "01-login", "SPEC.md"), "# login\n");
}
});
afterEach(() => fs.rmSync(workspace, { recursive: true, force: true }));
async function audit(mission: string): Promise<{ slices: Array<{ name: string; findings: Array<{ kind: string; severity: string; message: string }> }> }> {
const buf: string[] = [];
const origWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = ((c: unknown) => { buf.push(String(c)); return true; }) as typeof process.stdout.write;
const program = new Command();
program.addCommand(scopeCommand());
program.exitOverride();
try {
await program.parseAsync(["node", "rig", "scope", "audit", "--mission", mission, "--json", "--workspace", workspace]);
} finally {
process.stdout.write = origWrite;
}
return JSON.parse(buf.join(""));
}
describe("rig scope audit — a slice name another mission also uses", () => {
it("names the other mission, as advice that blocks nothing", async () => {
const shared = (await audit("alpha")).slices.find((s) => s.name === "01-login")!.findings.find((f) => f.kind === "slice_name_shared");
expect(shared).toMatchObject({ severity: "low", message: expect.stringContaining("beta") });
});
});
-11
View File
@@ -678,17 +678,6 @@ describe("rig scope mission ls (HG-8)", () => {
const names = parsed.missions.map((m: { name: string }) => m.name).sort();
expect(names).toEqual(["backlog", "release-0.3.2"]);
});
it("counts a mission's shipped slices apart from its active ones (#2427)", async () => {
for (const nn of ["02-shipped-a", "03-shipped-b"]) {
writeFile(path.join(env.missionsRoot, "release-0.3.2", "slices", nn, "README.md"), `---\nid: OPR.0.3.2.${nn[1]}\nstatus: shipped\n---\n# ${nn}\n`);
}
const text = await run(["mission", "ls"], env.missionsRoot);
expect(text.stdout).toContain("release-0.3.2 OPR.0.3.2 active=1 shipped=2 closed=0");
const json = JSON.parse((await run(["mission", "ls", "--json"], env.missionsRoot)).stdout);
expect(json.missions.find((m: { name: string }) => m.name === "release-0.3.2"))
.toMatchObject({ activeSliceCount: 1, shippedSliceCount: 2, closedSliceCount: 0 });
});
});
describe("rig scope mission show", () => {
-38
View File
@@ -326,44 +326,6 @@ describe("listMissions + listSlices + nextSliceNN", () => {
});
});
describe("a slice's standing: in flight, delivered in place, or closed (#2427)", () => {
let root: string;
let missionsRoot: string;
beforeEach(() => {
root = mktemp();
missionsRoot = path.join(root, "missions");
writeFile(path.join(missionsRoot, "m1", "README.md"), "---\nid: OPR.9.1\n---\n# m1\n");
const slice = (dir: string, frontmatter: string) => writeFile(path.join(missionsRoot, "m1", dir, "README.md"), `---\n${frontmatter}\n---\nbody\n`);
slice("slices/01-open", "id: OPR.9.1.1\nstatus: active");
slice("slices/02-blocked", "id: OPR.9.1.2\nstatus: blocked");
slice("slices/03-shipped", "id: OPR.9.1.3\nstatus: shipped");
slice("slices/04-done", "id: OPR.9.1.4\nstatus: done");
slice("slices/05-replaced", "id: OPR.9.1.5\nstatus: active\nstage: superseded\nsuperseded-by: OPR.9.1.9");
slice("slices/06-superseded", "id: OPR.9.1.6\nstatus: superseded");
slice("closed/07-stale", "id: OPR.9.1.7\nstatus: closed-stale");
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});
it("slice ls: active is work in flight (blocked included); shipped is delivered in place; closed includes exits in place", () => {
const mission = findMission(missionsRoot, "m1");
const names = (state: "active" | "shipped" | "closed" | "all") => listSlices(mission, state).map((s) => s.name);
expect(names("active")).toEqual(["01-open", "02-blocked"]);
expect(names("shipped")).toEqual(["03-shipped", "04-done"]);
expect(names("closed")).toEqual(["05-replaced", "06-superseded", "07-stale"]);
expect(names("all")).toHaveLength(7);
});
it("mission counts give each slice one standing, the same as slice ls", () => {
const counts = { activeSliceCount: 2, shippedSliceCount: 2, closedSliceCount: 3 };
expect(listMissions(missionsRoot)[0]).toMatchObject(counts);
expect(findMission(missionsRoot, "m1")).toMatchObject(counts);
});
});
describe("findSlice + resolution variants", () => {
let root: string;
let missionsRoot: string;
@@ -481,7 +481,7 @@ describe("fresh launch preview", () => {
it("OPR.0.7.0.9: the printed plan shows the seat's launch additions, as the seat view does", async () => {
const launchAdditions = { agent: "openrig:reviewer", agentPromptReplacesDefault: true, appendSystemPromptFile: "/s/additions/system-prompt.md",
pluginDirs: ["/s/additions/plugins/review"], pluginChannels: ["plugin:example-channel@example"], missing: ["/s/gone"], refused: ["/s/linked is not inside this seat's storage"] };
pluginDirs: ["/s/additions/plugins/review"], missing: ["/s/gone"], refused: ["/s/linked is not inside this seat's storage"] };
const data = { ok: true, planOnly: true, complete: true, launchable: true, sessionName: "dev@fixture", currentOccupant: "absent",
startup: { projectionCount: 0, fileCount: 0, actionCount: 0 }, stopRequired: false, wouldStop: false, blockers: [], warnings: [], siblings: [],
launchAdditions };
@@ -491,7 +491,6 @@ describe("fresh launch preview", () => {
expect(text).toContain("agent openrig:reviewer (--agent; its prompt replaces Claude's default system prompt)");
expect(text).toContain("prompt /s/additions/system-prompt.md (--append-system-prompt-file)");
expect(text).toContain("plugin dir /s/additions/plugins/review (CLAUDE_CODE_PLUGIN_DIRS)");
expect(text).toContain("channel plugin:example-channel@example (--channels)");
expect(text).toContain("missing /s/gone: not passed to the launch");
expect(text).toContain("refused /s/linked is not inside this seat's storage: not passed to the launch");
});
-13
View File
@@ -793,19 +793,6 @@ describe("rig seat status", () => {
expect(output).not.toContain("restore packet) was delivered");
});
it("handover human output says the successor started without a required file, not a plain complete (#2456)", async () => {
const missing = "/project/spec/culture.md";
const deps = makeDeps({ status: 200, data: { ...HANDOVER_RESULT_FRESH, startedWithoutRequired: [missing] } }, []);
const { logs, exitCode } = await captureLogs(async () => {
await makeCommand(deps).parseAsync(["node", "rig", "seat", "handover", "dev-impl@seat-rig", "--source", "fresh", "--reason", "context-wall"]);
});
const output = logs.join("\n");
expect(exitCode).toBeUndefined(); // the successor is running, so the handover itself completed
expect(logs[0]).toBe("Seat handover complete, degraded: dev-impl@seat-rig");
expect(output).toContain(`The successor started without required file ${missing}.`);
expect(output).not.toContain("Seat handover complete: dev-impl@seat-rig");
});
it("handover surfaces a loud step-named failure (successor_create_failed)", async () => {
const deps = makeDeps({
status: 500,
-41
View File
@@ -1,6 +1,5 @@
import { describe, it, expect } from "vitest";
import { wrapSendBody } from "../src/commands/send.js";
import { resolveQueueHostDestination } from "../src/commands/queue.js";
describe("wrapSendBody — pre-release CLI/daemon Item 2 (email-style envelope)", () => {
it("renders From / To / body / reply hint with both session names", () => {
@@ -11,46 +10,6 @@ describe("wrapSendBody — pre-release CLI/daemon Item 2 (email-style envelope)"
expect(out).toContain('↩ Reply: rig send driver-3@my-rig "..."');
});
it.each(["human@kernel", "human@host", "human-alice@kernel", "human-alice@host"])(
"gives %s a queue update reply with the existing human-route fields",
(sender) => {
const body = "Please check this.\nKeep the actor unchanged.";
const out = wrapSendBody(sender, "driver@rig", body, { replyToQitem: "qitem-original" });
expect(out).toContain(`From: ${sender}\nTo: driver@rig\n`);
expect(out.split("\n---\n")[1]).toBe(body);
expect(out.split("\n").at(-1)).toBe(`↩ Reply if needed: rig queue create --destination ${sender} --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`);
expect(out).not.toContain(`rig send ${sender}`);
expect(out).not.toContain("--reply-to"); // Slack-thread guidance belongs to registered external addresses.
},
);
it.each(["human@kernel@host-origin", "human-reviewer@host@host-origin", "alex@external@host-origin"])(
"a forwarded person %s gets a queue hint retaining the origin host",
(sender) => {
const body = "Original body\nkept verbatim.";
expect(resolveQueueHostDestination(sender)).toEqual({ ok: true, destination: sender.slice(0, sender.lastIndexOf("@")), hostId: "host-origin" });
const out = wrapSendBody(sender, "driver@receiver", body);
expect(out).toContain("From: " + sender + "\nTo: driver@receiver\n");
expect(out.split("\n---\n")[1]).toBe(body);
expect(out.split("\n").at(-1)).toBe("↩ Reply if needed: rig queue create --destination " + sender + ' --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify');
},
);
it("keeps host-qualified look-alike seats and malformed human triples on the ordinary hint", () => {
for (const sender of ["humanity@kernel@host-origin", "human@real-rig@host-origin", "dev-human@host@host-origin",
"human@kernel@", "human@kernel@host-origin@extra"]) {
expect(wrapSendBody(sender, "driver@receiver", "hi").split("\n").at(-1))
.toBe("↩ Reply: rig send " + sender + ' "..."');
}
});
it("keeps similarly named real seats on the ordinary reply route", () => {
for (const sender of ["human@my-rig", "humanity@kernel", "dev-human@host", "dev-human@rig@origin-host"]) {
expect(wrapSendBody(sender, "driver@rig", "hi").split("\n").at(-1))
.toBe(`↩ Reply: rig send ${sender} "..."`);
}
});
it("routes an external sender's reply through the durable human queue", () => {
const out = wrapSendBody("decision-maker@external", "driver@rig", "Decision received.");
expect(out).toContain("From: decision-maker@external");
-179
View File
@@ -285,185 +285,6 @@ describe("Send CLI", () => {
expect(output).toContain("Note: sent as exact text, without the From/To envelope, because infra-ui@term-rig is a terminal seat.");
});
it.each([
{ json: false, missingPrompt: false }, { json: true, missingPrompt: false },
{ json: false, missingPrompt: true }, { json: true, missingPrompt: true },
])("keeps the send unresolved when the post-submit capture is unusable ($json, $missingPrompt)", async ({ json, missingPrompt }) => {
let captures = 0, submits = 0;
const client = {
post: async (path: string, body: Record<string, unknown>) => {
if (path === "/api/transport/capture") {
captures++;
return captures === 1 ? { status: 200, data: { content: "❯ make test" } }
: missingPrompt ? { status: 200, data: { content: "redrawing" } }
: { status: 503, data: { error: "capture unavailable" } };
}
if (body["submitOnly"]) { submits++; return { status: 200, data: { ok: true } }; }
return { status: 200, data: { ok: true, sessionName: "infra-ui@term-rig", verified: true, outcome: "delivered", envelopeOmitted: true } };
},
} as unknown as DaemonClient;
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "infra-ui@term-rig", "make test", "--verify", ...(json ? ["--json"] : []),
]);
});
expect(submits).toBe(1);
if (json) {
const result = JSON.parse(logs.find(line => line.trim().startsWith("{")) ?? "{}");
expect(result).toMatchObject({ verified: false, outcome: "submission-unconfirmed", effectCheck: { state: "staged", remedy: "submit-unconfirmed" } });
} else {
expect(logs.join("\n")).toContain("submission unconfirmed");
expect(logs.join("\n")).not.toContain("staged text left the prompt");
}
expect(exitCode).toBe(1);
});
it.each([false, true])("reports a lost guarded Enter response as unconfirmed (json: %s)", async json => {
let captures = 0, submits = 0;
const client = {
post: async (path: string, body: Record<string, unknown>) => {
if (path === "/api/transport/capture") { captures++; return { status: 200, data: { content: "❯ make test" } }; }
if (body["submitOnly"]) { submits++; throw new Error("Enter response lost"); }
return { status: 200, data: { ok: true, sessionName: "infra-ui@term-rig", verified: true, outcome: "delivered", envelopeOmitted: true } };
},
} as unknown as DaemonClient;
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "infra-ui@term-rig", "make test", "--verify", ...(json ? ["--json"] : []),
]);
});
expect(submits).toBe(1);
expect(captures).toBe(1);
expect(exitCode).toBe(1);
if (json) {
const result = JSON.parse(logs.find(line => line.trim().startsWith("{")) ?? "{}");
expect(result).toMatchObject({ verified: false, outcome: "submission-unconfirmed", effectCheck: { remedy: "submit-unconfirmed" } });
} else expect(logs.join("\n")).toContain("submission unconfirmed");
});
it.each([false, true])("reports a failed guarded Enter after attempted input as unconfirmed (json: %s)", async json => {
let submits = 0;
const client = {
post: async (path: string, body: Record<string, unknown>) => {
if (path === "/api/transport/capture") return { status: 200, data: { content: "❯ make test" } };
if (body["submitOnly"]) { submits++; return { status: 500, data: { ok: false, reason: "submit_failed", inputPhase: "attempted", error: "submission unconfirmed" } }; }
return { status: 200, data: { ok: true, sessionName: "infra-ui@term-rig", verified: true, outcome: "delivered", envelopeOmitted: true } };
},
} as unknown as DaemonClient;
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "infra-ui@term-rig", "make test", "--verify", ...(json ? ["--json"] : []),
]);
});
expect(submits).toBe(1);
expect(exitCode).toBe(1);
if (json) {
const result = JSON.parse(logs.find(line => line.trim().startsWith("{")) ?? "{}");
expect(result).toMatchObject({ verified: false, outcome: "submission-unconfirmed", effectCheck: { remedy: "submit-unconfirmed" } });
} else expect(logs.join("\n")).toContain("submission unconfirmed");
});
it.each([
[{ reason: "submit_failed", error: "older daemon" }, "submit-unconfirmed"],
[{ reason: "staged_mismatch", sent: false, inputPhase: "not_reached", error: "changed" }, "submit-refused"],
] as const)("classifies guarded Enter failure %j as %s", async (failure, remedy) => {
const client = {
post: async (path: string, body: Record<string, unknown>) => {
if (path === "/api/transport/capture") return { status: 200, data: { content: "❯ make test" } };
if (body["submitOnly"]) return { status: 500, data: { ok: false, ...failure } };
return { status: 200, data: { ok: true, sessionName: "infra-ui@term-rig", verified: true, outcome: "delivered", envelopeOmitted: true } };
},
} as unknown as DaemonClient;
const { logs } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "infra-ui@term-rig", "make test", "--verify", "--json",
]);
});
const result = JSON.parse(logs.find(line => line.trim().startsWith("{")) ?? "{}");
expect(result.effectCheck.remedy).toBe(remedy);
expect(result.outcome).toBe("submission-unconfirmed");
});
it("does not claim the earlier staged text is still unconsumed after a refused Enter", async () => {
const client = {
post: async (path: string, body: Record<string, unknown>) => {
if (path === "/api/transport/capture") return { status: 200, data: { content: "❯ make test" } };
if (body["submitOnly"]) return { status: 409, data: { ok: false, reason: "staged_mismatch", inputPhase: "not_reached", sent: false, error: "changed" } };
return { status: 200, data: { ok: true, sessionName: "infra-ui@term-rig", verified: true, outcome: "delivered", envelopeOmitted: true } };
},
} as unknown as DaemonClient;
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "infra-ui@term-rig", "make test", "--verify",
]);
});
expect(exitCode).toBe(1);
expect(logs.join("\n")).toContain("guarded Enter was refused");
expect(logs.join("\n")).not.toContain("typed, never submitted");
});
it("reports a fan-out refusal as unconfirmed alongside a successful recipient", async () => {
let submits = 0;
const client = {
post: async (path: string, body: Record<string, unknown>) => {
if (path === "/api/transport/broadcast") return { status: 200, data: {
total: 2, sent: 2, failed: 0, results: [
{ sessionName: "seat-a@rig", ok: true, outcome: "delivered" },
{ sessionName: "seat-b@rig", ok: true, outcome: "delivered" },
],
} };
if (path === "/api/transport/capture") return { status: 200, data: {
content: body["session"] === "seat-a@rig" ? "❯ make test" : "❯ ",
} };
if (body["submitOnly"]) {
submits++;
return { status: 409, data: { ok: false, reason: "staged_mismatch", inputPhase: "not_reached", sent: false, error: "pane changed" } };
}
throw new Error("unexpected request");
},
} as unknown as DaemonClient;
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "--to", "seat-a@rig,seat-b@rig", "make test", "--verify",
]);
});
const output = logs.join("\n");
expect(output).toContain("seat-a@rig: Delivery: unconfirmed");
expect(output).toContain("guarded Enter was refused (pane changed)");
expect(output).not.toContain("staged, not consumed");
expect(output).toContain("seat-b@rig: sent");
expect(output).toContain("1/2 delivered, 1 submission-unconfirmed");
expect(submits).toBe(1);
expect(exitCode).toBe(1);
});
it("reports an unconfirmed post-submit recheck per recipient in fan-out JSON", async () => {
let captures = 0, submits = 0;
const client = {
post: async (path: string, body: Record<string, unknown>) => {
if (path === "/api/transport/broadcast") return { status: 200, data: {
total: 1, sent: 1, failed: 0, results: [{ sessionName: "infra-ui@term-rig", ok: true, verified: true, outcome: "delivered" }],
} };
if (path === "/api/transport/capture") {
captures++;
return captures === 1 ? { status: 200, data: { content: "❯ make test" } }
: { status: 503, data: { error: "capture unavailable" } };
}
if (body["submitOnly"]) { submits++; return { status: 200, data: { ok: true } }; }
throw new Error("unexpected request");
},
} as unknown as DaemonClient;
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "--to", "infra-ui@term-rig", "make test", "--verify", "--json",
]);
});
const result = JSON.parse(logs.find(line => line.trim().startsWith("{")) ?? "{}");
expect(submits).toBe(1);
expect(result).toMatchObject({ sent: 0, failed: 1, results: [{ ok: false, verified: false, outcome: "submission-unconfirmed" }] });
expect(exitCode).toBe(1);
});
it("--raw sends no exactText", async () => {
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "send", "dev-impl@my-rig", "/help", "--raw"]);
-17
View File
@@ -7,23 +7,6 @@ const clientFor = (entries: object[]) => ({ get: vi.fn(async () => ({ status: 20
afterEach(() => vi.restoreAllMocks());
describe("first-project compatibility alias", () => {
it("keeps same-name user and built-in specs ambiguous when both are visible", async () => {
const user = { ...starter, id: "user-id", sourceType: "user_file" };
await expect(resolveLibrarySpec(clientFor([starter, user]), "starter", { kind: "rig" })).rejects.toThrow("ambiguous");
});
it("finds an explicitly requested hidden built-in without shadowing a visible user spec", async () => {
const user = { ...starter, id: "user-id", sourceType: "user_file" };
const get = vi.fn(async (url: string) => ({ status: 200, data: url.includes("include_builtins=true") ? [starter, user] : [user] }));
const client = { get } as unknown as DaemonClient;
expect(await resolveLibrarySpec(client, "starter", { kind: "rig" })).toEqual(user);
expect(await resolveLibrarySpec(client, "starter-id", { kind: "rig" })).toEqual(starter);
expect(get).toHaveBeenCalledWith("/api/specs/library?kind=rig&include_builtins=true");
});
it("prefers a hidden exact ID over a visible spec with that name", async () => {
const user = { ...starter, id: "user-id", name: "starter-id", sourceType: "user_file" };
const get = vi.fn(async (url: string) => ({ status: 200, data: url.includes("include_builtins=true") ? [starter, user] : [user] }));
expect(await resolveLibrarySpec({ get } as unknown as DaemonClient, "starter-id")).toEqual(starter);
});
it("resolves the built-in starter without creating a second library entry, with a stderr notice", async () => {
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
+1 -20
View File
@@ -60,7 +60,6 @@ const RIG_REVIEW = {
describe("Specs CLI", () => {
let server: http.Server;
let port: number;
let lastLibraryUrl = "";
const libraryRequests: string[] = [];
beforeAll(async () => {
@@ -69,7 +68,6 @@ describe("Specs CLI", () => {
libraryRequests.push(rawUrl);
const url = decodeURIComponent(rawUrl);
if (url === "/api/specs/library" || url.startsWith("/api/specs/library?")) {
lastLibraryUrl = url;
res.writeHead(200, { "Content-Type": "application/json" });
const kind = new URL(`http://localhost${url}`).searchParams.get("kind");
const filtered = kind ? LIBRARY_ENTRIES.filter((e) => e.kind === kind) : LIBRARY_ENTRIES;
@@ -139,13 +137,6 @@ describe("Specs CLI", () => {
expect(parsed.length).toBe(2);
});
it("requests hidden built-ins only when explicitly asked", async () => {
await captureLogs(async () => { await makeCmd().parseAsync(["node", "rig", "specs", "ls", "--include-builtins", "--kind", "rig"]); });
const params = new URL(`http://localhost${lastLibraryUrl}`).searchParams;
expect(params.get("kind")).toBe("rig");
expect(params.get("include_builtins")).toBe("true");
});
it("specs ls --kind warns on unrecognized kind and still queries daemon", async () => {
const requestStart = libraryRequests.length;
const { stdout, stderr, exitCode } = await captureOutputs(async () => {
@@ -174,16 +165,6 @@ describe("Specs CLI", () => {
expect(libraryRequests.slice(requestStart)).toContain("/api/specs/library?kind=rig%23draft");
});
it("filters an unknown kind even when built-ins are requested", async () => {
const { stdout, stderr } = await captureOutputs(async () => {
await makeCmd().parseAsync(["node", "rig", "specs", "ls", "--kind", "rig&kind=agent", "--include-builtins", "--json"]);
});
expect(stderr.join("\n")).toContain("Warning: unrecognized --kind");
expect(JSON.parse(stdout.join("\n"))).toEqual([]);
expect(new URL(`http://localhost${libraryRequests.at(-1)}`).searchParams.get("kind")).toBe("rig&kind=agent");
expect(new URL(`http://localhost${libraryRequests.at(-1)}`).searchParams.get("include_builtins")).toBe("true");
});
it.each(["rig", "agent", "workflow"])("specs ls --kind %s runs without warning", async (kind) => {
const requestStart = libraryRequests.length;
const { stderr, exitCode } = await captureOutputs(async () => {
@@ -250,7 +231,7 @@ describe("Specs CLI", () => {
it("specs show --kind resolves same-name rig/workflow entries", async () => {
const dupServer = http.createServer((req, res) => {
const url = decodeURIComponent(req.url ?? "");
if (url.startsWith("/api/specs/library?kind=rig")) {
if (url === "/api/specs/library?kind=rig") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ ...LIBRARY_ENTRIES[0], id: "rig-same", kind: "rig", name: "conveyor" }]));
} else {
@@ -1,16 +0,0 @@
import { describe, expect, it } from "vitest";
import type { DaemonClient } from "../src/client.js";
import { resolveDefaultAgentRef } from "../src/commands/topology-default-agent.js";
describe("default agent lookup", () => {
it("finds the built-in orchestrator when inventory hides built-ins", async () => {
const client = {
get: async (url: string) => ({
data: url.includes("include_builtins=true")
? [{ kind: "agent", name: "orchestrator", sourceType: "builtin", sourcePath: "/builtin/orchestrator/agent.yaml" }]
: [],
}),
} as unknown as DaemonClient;
expect(await resolveDefaultAgentRef(client, "codex")).toBe("path:/builtin/orchestrator");
});
});
+1 -1
View File
@@ -1408,7 +1408,7 @@ describe("Up CLI", () => {
let body = "";
for await (const chunk of req) body += chunk;
const url = decodeURIComponent(req.url ?? "");
if (url.startsWith("/api/specs/library?kind=rig")) {
if (url === "/api/specs/library?kind=rig") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "rig1", kind: "rig", name: "conveyor", sourceType: "builtin", sourcePath: "/specs/rigs/conveyor/rig.yaml" }]));
} else if (url === "/api/rigs/summary") {
-64
View File
@@ -320,22 +320,6 @@ describe("rig walk — per-piece consumption verification (RED-first, mechanics-
expect(result.logs.some(l => l.includes('"consumptionVerified":true'))).toBe(succeeds);
});
it("reports a lost guarded submit response as unconfirmed without retrying", async () => {
const w: ScriptedWorld = { record: { generationId: "g1", content: "" }, pane: "", sends: [], gets: [] };
w.sendBehavior = (body) => {
if (body["text"] !== undefined) {
w.pane = `❯ ${String(body["text"])} `;
return { status: 200, data: { ok: true } };
}
return "throw";
};
const { errLogs, exitCode } = await captureLogs(() => makeCmd(consumptionDeps(w)).parseAsync(walkArgs));
expect(exitCode).toBe(1);
expect(w.sends.filter(s => s.path === "/api/transport/send" && s.body["submitOnly"] === true)).toHaveLength(1);
expect(w.sends.filter(s => s.path === "/api/transport/send" && s.body["text"] !== undefined)).toHaveLength(1);
expect(errLogs.join("\n")).toContain("unconfirmed");
});
it.each(["during-send", "between-pieces"])("generation changes %s cannot produce a verified walk", async when => {
const w: ScriptedWorld = { record: { generationId: "g1", content: "" }, pane: "", sends: [], gets: [] };
w.sendBehavior = b => {
@@ -349,7 +333,6 @@ describe("rig walk — per-piece consumption verification (RED-first, mechanics-
expect(result.exitCode).toBe(1);
expect(w.sends.filter(s => s.path === "/api/transport/send")).toHaveLength(1);
expect(result.logs.some(l => l.includes('"consumptionVerified":true'))).toBe(false);
if (when === "during-send") expect(result.errLogs.join("\n")).toMatch(/delivery is unconfirmed/i);
});
it("PIN W1 — a typed-but-never-consumed piece FAILS LOUD naming the piece; the next piece is never sent [GREEN — consumption verification]", async () => {
@@ -365,31 +348,6 @@ describe("rig walk — per-piece consumption verification (RED-first, mechanics-
expect(errLogs.join("\n")).toMatch(/consum/i); // names the failure class
});
it("keeps delivery unknown when the send times out and neither record nor pane proves its effect", async () => {
const w: ScriptedWorld = { record: { generationId: "g1", content: "" }, pane: "", sends: [], gets: [], sendBehavior: () => "throw" };
const { errLogs, exitCode } = await captureLogs(() => makeCmd(consumptionDeps(w)).parseAsync(walkArgs));
expect(exitCode).toBe(1);
expect(w.sends.filter(s => s.path === "/api/transport/send")).toHaveLength(1);
expect(errLogs.join("\n")).toMatch(/unconfirmed/i);
expect(errLogs.join("\n")).not.toMatch(/delivery lost/i);
expect(errLogs.join("\n")).toMatch(/before.*resend/i);
});
it("reports a generation change after the guarded Enter without claiming the piece was lost", async () => {
const w: ScriptedWorld = { record: { generationId: "g1", content: "" }, pane: "", sends: [], gets: [] };
w.sendBehavior = body => {
if (body["text"] !== undefined) w.pane = `❯ ${String(body["text"]).slice(0, 40)}`;
else w.record.generationId = "g2";
return { status: 200, data: { ok: true } };
};
const { errLogs, exitCode } = await captureLogs(() => makeCmd(consumptionDeps(w)).parseAsync(walkArgs));
expect(exitCode).toBe(1);
expect(w.sends.filter(s => s.path === "/api/transport/send" && s.body["submitOnly"] === true)).toHaveLength(1);
expect(w.sends.filter(s => s.path === "/api/transport/send" && s.body["text"] !== undefined)).toHaveLength(1);
expect(errLogs.join("\n")).toMatch(/generation rolled.*unconfirmed/i);
expect(errLogs.join("\n")).not.toMatch(/never entered|delivery lost/i);
});
it("PIN W2 — staged text detected -> exactly ONE submit retry (bare Enter, never a piece re-send), then consumed -> walk proceeds [GREEN — the retry path]", async () => {
const w: ScriptedWorld = { record: { generationId: "g1", content: "" }, pane: "", sends: [], gets: [] };
w.sendBehavior = (b) => {
@@ -414,28 +372,6 @@ describe("rig walk — per-piece consumption verification (RED-first, mechanics-
expect(enterRetries).toHaveLength(2); // exactly one Enter per staged piece
});
it.each([
[{ reason: "submit_failed", inputPhase: "attempted", error: "reply lost" }, "unconfirmed"],
[{ reason: "submit_failed", error: "old daemon" }, "unconfirmed"],
[{ reason: "staged_mismatch", inputPhase: "not_reached", sent: false, error: "changed" }, "refused"],
] as const)("classifies a guarded submit error %j as %s", async (failure, wording) => {
const w: ScriptedWorld = { record: { generationId: "g1", content: "" }, pane: "", sends: [], gets: [] };
w.sendBehavior = (body) => {
if (body["text"] !== undefined) {
w.pane = `❯ ${String(body["text"])} `;
return { status: 200, data: { ok: true } };
}
return { status: 500, data: { ok: false, ...failure } };
};
const { errLogs, exitCode } = await captureLogs(() => makeCmd(consumptionDeps(w)).parseAsync(walkArgs));
expect(exitCode).toBe(1);
expect(w.sends.filter(s => s.path === "/api/transport/send" && s.body["submitOnly"] === true)).toHaveLength(1);
expect(w.sends.filter(s => s.path === "/api/transport/send" && s.body["text"] !== undefined)).toHaveLength(1);
expect(errLogs.join("\n")).toContain(wording);
expect(errLogs.join("\n")).not.toMatch(/typed but not consumed/i);
if (wording === "unconfirmed") expect(errLogs.join("\n")).not.toMatch(/was refused/i);
});
it("PIN W3 — a client timeout with server-side completion reconciles BY EFFECT: no re-send, walk proceeds [GREEN — reconcile-by-effect]", async () => {
const w: ScriptedWorld = { record: { generationId: "g1", content: "" }, pane: "", sends: [], gets: [] };
w.sendBehavior = (b) => {
@@ -537,9 +537,7 @@ not have to tear one down to change it, and you rarely have to start from nothin
- **A Claude member can bring its own setup** in the rig spec: `agent_file`,
`append_system_prompt_file`, `plugin_dirs` and `advisor` (an Advisor model). They're copied into
that seat's own storage, never the project, so one seat in a shared repository can have its own
persona, prompt or plugins. A bundle's before-install view lists them. `plugin_channels`
(`plugin:<name>@<marketplace>` entries) registers that seat alone for those plugins' channel
notifications, through Claude's `--channels`.
persona, prompt or plugins. A bundle's before-install view lists them.
- **A new Claude member can name a prepared subscription token** with
`claude_token: {label: name, file: /abs/token}` beside its `env.CLAUDE_CONFIG_DIR`; the seat stores
the file reference and reads it at launch. A missing/unreadable token uses the existing fallback
@@ -98,10 +98,9 @@ node "$SKILL_DIR/scripts/backup-sqlite.mjs" \
--destination "/safe/path/openrig-before-upgrade.sqlite"
```
The helper refuses to overwrite a destination, uses the `sqlite3` CLI `.backup`
command when available (falling back to an in-process backup via `node:sqlite`
when the CLI is absent), and verifies the backup with `PRAGMA integrity_check`.
It does not stop the daemon or restore a database.
The helper refuses to overwrite a destination, uses SQLite's backup operation,
and verifies the backup with `PRAGMA integrity_check`. It does not stop the
daemon or restore a database.
### Plan or apply safe managed-plugin refreshes
@@ -4,28 +4,6 @@ import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
// Suppress experimental warning for node:sqlite to keep stderr clean for JSON errors
const originalEmitWarning = process.emitWarning;
process.emitWarning = (warning, ...args) => {
if (typeof warning === "string" && warning.includes("SQLite is an experimental feature")) {
return;
}
if (warning && typeof warning === "object" && warning.name === "ExperimentalWarning" && warning.message?.includes("SQLite")) {
return;
}
return originalEmitWarning.call(process, warning, ...args);
};
let sqliteModule;
let DatabaseSync;
try {
sqliteModule = await import("node:sqlite");
DatabaseSync = sqliteModule.DatabaseSync;
} catch {
sqliteModule = undefined;
DatabaseSync = undefined;
}
function argument(name) {
const index = process.argv.indexOf(name);
return index === -1 ? undefined : process.argv[index + 1];
@@ -55,76 +33,22 @@ if (destinationPath.includes("'") || destinationPath.includes("\n")) {
}
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
const sqliteBin = process.env.OPENRIG_SQLITE_BIN || "sqlite3";
const probe = spawnSync(sqliteBin, ["-version"], { encoding: "utf8" });
const hasCli = probe.status === 0;
let integrity;
if (hasCli) {
const backup = spawnSync(sqliteBin, [sourcePath, `.backup '${destinationPath}'`], { encoding: "utf8" });
if (backup.status !== 0) {
fs.rmSync(destinationPath, { force: true });
fail(
backup.stderr?.trim() || backup.stdout?.trim() || backup.error?.message || "sqlite3 backup failed",
`run ${sqliteBin} against the source directly, resolve locking/path/tooling errors, then choose a fresh destination`,
);
}
const check = spawnSync(sqliteBin, [destinationPath, "PRAGMA integrity_check;"], { encoding: "utf8" });
integrity = check.stdout?.trim();
if (check.status !== 0 || integrity !== "ok") {
fail(
check.stderr?.trim() || `backup integrity result was ${JSON.stringify(integrity)}`,
"preserve the failed backup for diagnosis only; do not use it for rollback",
);
}
} else if (DatabaseSync) {
try {
const srcDb = new DatabaseSync(sourcePath, { readOnly: true });
try {
if (typeof sqliteModule?.backup === "function") {
await sqliteModule.backup(srcDb, destinationPath);
} else {
srcDb.exec(`VACUUM INTO '${destinationPath}';`);
}
} finally {
srcDb.close();
}
} catch (err) {
fs.rmSync(destinationPath, { force: true });
fail(
err?.message || "sqlite backup failed via node:sqlite",
"resolve locking/path/tooling errors, then choose a fresh destination",
);
}
try {
const backupDb = new DatabaseSync(destinationPath, { readOnly: true });
try {
const rows = backupDb.prepare("PRAGMA integrity_check;").all();
integrity = rows[0]?.integrity_check;
} finally {
backupDb.close();
}
} catch (err) {
fail(
err?.message || `backup integrity result was ${JSON.stringify(integrity)}`,
"preserve the failed backup for diagnosis only; do not use it for rollback",
);
}
if (integrity !== "ok") {
fail(
`backup integrity result was ${JSON.stringify(integrity)}`,
"preserve the failed backup for diagnosis only; do not use it for rollback",
);
}
} else {
const sqlite = process.env.OPENRIG_SQLITE_BIN || "sqlite3";
const backup = spawnSync(sqlite, [sourcePath, `.backup '${destinationPath}'`], { encoding: "utf8" });
if (backup.status !== 0) {
fs.rmSync(destinationPath, { force: true });
fail(
"neither sqlite3 CLI nor node:sqlite is available to create a backup",
"install sqlite3 CLI or run on Node with node:sqlite support",
backup.stderr?.trim() || backup.stdout?.trim() || backup.error?.message || "sqlite3 backup failed",
`run ${sqlite} against the source directly, resolve locking/path/tooling errors, then choose a fresh destination`,
);
}
const check = spawnSync(sqlite, [destinationPath, "PRAGMA integrity_check;"], { encoding: "utf8" });
const integrity = check.stdout?.trim();
if (check.status !== 0 || integrity !== "ok") {
fail(
check.stderr?.trim() || `backup integrity result was ${JSON.stringify(integrity)}`,
"preserve the failed backup for diagnosis only; do not use it for rollback",
);
}
@@ -7,7 +7,7 @@ fleet can pick them up.
## What you do
- Inspect new stream items with `rig stream list --json`. When stream items carry `--hint-destination`, route by that hinted destination seat. Hint-destination
- Inspect new stream items with `rig stream list --json`. Hint-destination
filtering is exact, so `?` does not mean unassigned. Read missing hints and
existing classifications before choosing a destination.
- For each: decide the destination seat (which rig + which member),
@@ -19,8 +19,7 @@ import { assessNativeResumeProbe, hasClaudeDrawnInput } from "../domain/native-r
import { resumeProbeEvidence } from "../domain/native-resume-probe-evidence.js";
import { resolveReadinessTimeoutMs } from "../domain/readiness-timeout.js";
import { subtreeNamesClaudeExecutable, claudeCommandSessionToken, listNativeProcesses, observeClaudeDelivery, observeClaudePaneProcess, observeClaudePaneRuntime, observeCodexPaneProcess, verifyClaudePaneProcess, verifyCodexPaneProcess, verifyPaneIdleShell, type ClaudeLaunchedProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
import { isPerSeatRoleGuidance, perSeatRoleSkipWarning, DEFAULT_CLAUDE_MANAGED_BLOCK_FILE, type ClaudeManagedBlockFile } from "../domain/managed-blocks.js";
import { writeManagedGuidanceBlock, type ManagedBlockRecords } from "../domain/managed-block-records.js";
import { isPerSeatRoleGuidance, mergeManagedBlock, perSeatRoleSkipWarning, DEFAULT_CLAUDE_MANAGED_BLOCK_FILE, type ClaudeManagedBlockFile } from "../domain/managed-blocks.js";
import { claudeResumeLaunchEnv, claudeResumeLaunchPrefix } from "./claude-resume-launch.js";
import { shellQuote } from "./shell-quote.js";
import { validateClaudeActivityHookDelivery, claudeActivityRelayPath, parseManagedClaudeNodeCommand } from "../domain/claude-activity-hooks.js";
@@ -30,7 +29,7 @@ import { observeClaudePermission } from "../domain/permission-drift.js";
import { unresolvedClaudePermissionModes } from "../domain/native-permission-selection.js";
import type { ClaudeManagedLaunch } from "../domain/claude-managed-launch.js";
import { contextUsageDirectory, providerUsageDirectory } from "../domain/telemetry-state-paths.js";
import { seatLaunchAdditionArgs, seatLaunchAdditionShellArgs } from "../domain/seat-launch-additions.js";
import { seatLaunchAdditionArgs } from "../domain/seat-launch-additions.js";
import { recordSeatSkillWrite, seatPluginSkillsLossNote, seatProjectionComplete, seatSkillWriteRefusal } from "../domain/seat-skill-storage.js";
export interface ClaudeAdapterFsOps {
@@ -100,8 +99,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
/** P20 — called after a projected file is written to a target, so the manifest
* records what we last wrote (→ operator-vs-stale discrimination). No-op by default. */
private recordProjection: (targetPath: string, content: string) => void;
/** #2456: OpenRig's record of what it last wrote in each managed guidance block (startup wires the daemon's). */
private managedBlockRecords: ManagedBlockRecords | undefined;
constructor(deps: {
tmux: TmuxAdapter;
@@ -128,9 +125,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
/** P20 — record-at-apply hook (startup wires it to the projection manifest store).
* Absent → no-op (the manifest stays empty → discrimination safe-degrades to P17). */
recordProjection?: (targetPath: string, content: string) => void;
/** #2456: with it, every guidance block this adapter writes is checked against the record and recorded; absent, the
* plain merge, as before. */
managedBlockRecords?: ManagedBlockRecords;
}) {
this.tmux = deps.tmux;
this.seatLaunchEnvironment = deps.seatLaunchEnvironment;
@@ -148,7 +142,6 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
this.activityRelayPath = deps.activityRelayPath ?? null;
this.claudeHooksManifestPath = deps.claudeHooksManifestPath ?? null;
this.recordProjection = deps.recordProjection ?? (() => {});
this.managedBlockRecords = deps.managedBlockRecords;
}
async listInstalled(binding: NodeBinding): Promise<InstalledResource[]> {
@@ -385,7 +378,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
const mcpArg = mcpArgs.map(arg => ` ${shellQuote(arg)}`).join("");
// OPR.0.7.0.9: the seat's own additions, after the model, effort and Advisor and before the identity options.
const additionArgs = seatLaunchAdditionArgs(binding.launchAdditions);
const additionArg = seatLaunchAdditionShellArgs(binding.launchAdditions, shellQuote); // each flag bare, each value quoted
const additionArg = additionArgs.map((arg, index) => ` ${index % 2 === 0 ? arg : shellQuote(arg)}`).join(""); // flag, value pairs
// Fork branch: `claude --resume <parent> --fork-session --session-id <fork> --name <seat>`
// (Claude Code 2.0.73 or later). The fork's own id is chosen here, like a fresh launch's,
@@ -1025,11 +1018,11 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
if (warning) warnings.push(warning);
return false;
}
// #2456: a block a person edited since OpenRig wrote it is kept and named, not counted as merged.
return writeManagedGuidanceBlock(this.fs, targetPath, blockId, content, {
records: this.managedBlockRecords, sourcePath, warnings,
mergeManagedBlock(this.fs, targetPath, blockId, content, {
warnings,
replaceBlockIds: blockId === "openrig-start.md" ? ["using-openrig.md"] : [],
});
return true;
}
private detectDeliveryHint(path: string, content: string, orientation?: string): "guidance_merge" | "skill_install" | "send_text" {
+4 -13
View File
@@ -14,7 +14,7 @@ import { verifyClaudePaneProcess, verifyPaneIdleShell, type ClaudeLaunchedProces
import { observeClaudePermission, type AppliedLaunchObservation } from "../domain/permission-drift.js";
import { unresolvedClaudePermissionModes } from "../domain/native-permission-selection.js";
import type { ClaudeManagedLaunch } from "../domain/claude-managed-launch.js";
import { seatLaunchAdditionArgs, seatLaunchAdditionShellArgs, type SeatLaunchAdditions } from "../domain/seat-launch-additions.js";
import { seatLaunchAdditionArgs, type SeatLaunchAdditions } from "../domain/seat-launch-additions.js";
export type ResumeResult =
// `launchedProcess` (Claude): the process this resume's own identity check observed (#1077).
@@ -97,7 +97,7 @@ export class ClaudeResumeAdapter {
const mcpArgs = claudeMcpArgs(claudeMcp);
const mcpArg = mcpArgs.map(arg => ` ${shellQuote(arg)}`).join("");
const additionArgs = seatLaunchAdditionArgs(launchAdditions);
const additionArg = seatLaunchAdditionShellArgs(launchAdditions, shellQuote); // each flag bare, each value quoted
const additionArg = additionArgs.map((arg, index) => ` ${index % 2 === 0 ? arg : shellQuote(arg)}`).join(""); // flag, value pairs
let managed: Awaited<ReturnType<ClaudeManagedLaunch["prepare"]>> | undefined;
if (selectedPermissionMode !== undefined) {
try {
@@ -235,8 +235,8 @@ export class ClaudeResumeAdapter {
}
// The exact --resume identity is stronger evidence than a pane command or
// a version/footer heuristic. Use it with Claude's interactive prompt visible
// (or behind a shell label, below), after the untrusted screen classifiers have had their say.
// a version/footer heuristic. Use it only with Claude's interactive prompt
// visible, and after the untrusted screen classifiers have had their say.
const mayBeWrappedComposer = finalProbe.status === "inconclusive"
|| (finalProbe.status === "failed" && finalProbe.code === "returned_to_shell");
if (/(^|\n)\s*❯/.test(finalContent) && mayBeWrappedComposer) {
@@ -273,15 +273,6 @@ export class ClaudeResumeAdapter {
message: "Claude resume failed: pane returned to shell instead of entering Claude",
};
}
// #2508: a launch subshell that didn't exec Claude (an rc alias or function, or a fallen-back environment
// correction) keeps the shell as the label. A proven Claude naming this resume beneath it is the foreground
// runtime, as a `claude` label is, before its prompt shows.
const identity = await verifyClaudePaneProcess({ target: tmuxSessionName, tmux: this.tmux,
...(this.options.listProcesses ? { listProcesses: this.options.listProcesses } : {}), expectedToken: resumeToken });
if (identity) {
return { ok: true, ...(identity.process.startedAt
? { launchedProcess: { pid: identity.process.pid, startedAt: identity.process.startedAt } } : {}) };
}
return {
ok: false,
code: "attention_required",
@@ -19,7 +19,6 @@ import type {
InstalledResource, ProjectionResult, StartupDeliveryResult, ReadinessResult,
HarnessLaunchResult,
} from "../domain/runtime-adapter.js";
import type { GuidanceChoice } from "../domain/runtime-adapter.js";
// Type-only — keeps the profile-preflight module's dynamic import lazy for
// production (no runtime import cost from this line).
import type { CodexProfileProbeResult } from "../domain/codex-profile-preflight.js";
@@ -37,8 +36,7 @@ import { assessNativeResumeProbe, buildCodexResumeCore, hasCodexUpdateHeader, ty
import { unknownDaemonSupportMessage, type CodexDaemonSupportDetector } from "../domain/codex-daemon-support.js";
import { CODEX_NETWORK_DEFAULT_OVERRIDE, codexNetworkDefaultArg, type CodexNetworkDefaultReader } from "../domain/codex-network-default.js";
import { resolveCodexGitAddDirs, type CodexGitAddDirResolver } from "../domain/codex-git-add-dirs.js";
import { isPerSeatRoleGuidance, managedBlockText, perSeatRoleSkipWarning } from "../domain/managed-blocks.js";
import { writeManagedGuidanceBlock, type ManagedBlockRecords } from "../domain/managed-block-records.js";
import { isPerSeatRoleGuidance, mergeManagedBlock, perSeatRoleSkipWarning } from "../domain/managed-blocks.js";
import { excludeNewGeneratedFiles } from "../domain/generated-file-hygiene.js";
import { codexQueueStateRoot, codexTeamWorkspaceArg, type PrepareCodexSeatFolder, type PrepareCodexTeamWorkspace } from "../domain/codex-team-workspace.js";
import { shellQuote } from "./shell-quote.js";
@@ -70,7 +68,7 @@ const codexHookTrust = createRequire(import.meta.url)("../../assets/plugins/open
};
import { listNativeProcesses, observeCodexPaneProcess, observeCodexForkProcess, verifyCodexPaneProcess, verifyPaneIdleShell, type NativeProcessRow } from "../domain/native-process-lineage.js";
import { recordSeatSkillWrite, seatPluginSkillsLossNote, seatProjectionComplete, seatSkillWriteRefusal, SEAT_SKILLS_BOOTSTRAP_BLOCK_ID } from "../domain/seat-skill-storage.js";
import { recordSeatSkillWrite, seatPluginSkillsLossNote, seatProjectionComplete, seatSkillWriteRefusal } from "../domain/seat-skill-storage.js";
import { codexPluginContentHash, planOwnedCodexContentApproval, recordOwnedCodexApproval } from "../domain/codex-owned-plugin-trust.js";
import { findTomlTable, opensTomlTable, parseTomlRoot, tomlArrayTableHeaderPath, tomlDefines, tomlTableHeaderPath } from "../domain/toml-tables.js";
@@ -198,10 +196,6 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
// so each of those runs its whole setup under this one lock, one after another.
private configLock: Promise<unknown> = Promise.resolve();
private codexSystemConfigDir: string;
/** #875: the managed guidance blocks a fresh launch's startup queued for its developer instructions, per tmux session. */
private launchInstructions = new Map<string, Map<string, string>>();
/** #2456: OpenRig's record of what it last wrote in each managed guidance block (startup wires the daemon's). */
private managedBlockRecords: ManagedBlockRecords | undefined;
/** Build the Codex launch adapter with injectable filesystem, process, and CLI seams for isolated checks. */
constructor(deps: {
@@ -245,11 +239,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
runCodexCli?: CodexCliRunner;
/** Where Codex's system and legacy managed config files live (default /etc/codex). */
codexSystemConfigDir?: string;
/** #2456: with it, every guidance block this adapter writes is checked against the record and recorded; absent, the
* plain merge, as before. */
managedBlockRecords?: ManagedBlockRecords;
}) {
this.managedBlockRecords = deps.managedBlockRecords;
this.resolveCorePluginDir = deps.resolveCorePluginDir;
this.resolveCorePluginSelected = deps.resolveCorePluginSelected;
this.runCodexCli = deps.runCodexCli ?? defaultCodexCli;
@@ -801,7 +791,6 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
}
async project(plan: ProjectionPlan, binding: NodeBinding): Promise<ProjectionResult> {
if (binding.guidanceLaunchInstructions && binding.tmuxSession) this.launchInstructions.set(binding.tmuxSession, new Map());
const projected: string[] = [];
const warnings: string[] = [];
const skipped: string[] = [];
@@ -814,7 +803,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
}
try {
const didProject = this.projectEntry(entry, binding.cwd, warnings, binding.seatSkillsRoot, binding.seatSkillsRefused, this.homeFor(binding), binding);
const didProject = this.projectEntry(entry, binding.cwd, warnings, binding.seatSkillsRoot, binding.seatSkillsRefused, this.homeFor(binding));
if (didProject) {
projected.push(entry.effectiveId);
} else {
@@ -844,7 +833,8 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
switch (hint) {
case "guidance_merge": {
const merged = this.placeGuidance(binding.cwd, binding, file.path, content, warnings);
const targetPath = nodePath.join(binding.cwd, "AGENTS.md");
const merged = this.mergeGuidance(targetPath, file.path, content, warnings);
if (!merged) continue; // rig-role skip: do not count as delivered
break;
}
@@ -959,7 +949,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
// 0.5.2-07 A2-3: the pod-aware RESUME path threads the SPEC model too (reverted before — the
// grounding map assumed codex parity with the claude adapter, but only fresh emitted -m).
? this.resumeCore(prepared, opts.resumeToken, binding)
: `codex${daemonArg}${postureArg}${networkArg} -C ${shellQuote(binding.cwd)}${gitDirArg}${seatFolderArg}${queueStateDirArg}${modelArg}${effortArg}${binding.guidanceLaunchInstructions ? this.developerInstructionsArg(binding.tmuxSession) : ""}`;
: `codex${daemonArg}${postureArg}${networkArg} -C ${shellQuote(binding.cwd)}${gitDirArg}${seatFolderArg}${queueStateDirArg}${modelArg}${effortArg}`;
const staged = await this.seatCommand(binding, cmd);
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) return { ok: false, error: "Selected history changed during Codex launch preparation; no native command was sent.", recovery: "attention_required" };
@@ -1380,14 +1370,15 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
}
private projectEntry(entry: ProjectionEntry, cwd: string, warnings: string[], seatSkillsRoot?: string, seatSkillsRefused?: string,
home?: string, binding?: NodeBinding): boolean {
home?: string): boolean {
if (entry.category === "runtime_resource" && this.applyRuntimeResource(entry, home)) {
return true;
}
if (entry.category === "guidance" && entry.mergeStrategy === "managed_block") {
const targetPath = nodePath.join(cwd, "AGENTS.md");
const content = this.fs.readFile(entry.absolutePath);
return this.placeGuidance(cwd, binding, entry.effectiveId, content, warnings, entry.absolutePath);
return this.mergeGuidance(targetPath, entry.effectiveId, content, warnings, entry.absolutePath);
}
// HG-1.3 plugin runtime applicability filter (per DESIGN.md §5.1):
@@ -1538,78 +1529,6 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
* propagate the skip signal so ProjectionResult and StartupDeliveryResult
* report honest counts.
*/
/** #875: a block goes in with the launch's developer instructions when this fresh launch chose them (guidanceFileFor),
* else into the repository AGENTS.md as before. A seat's role is skipped either way, and the seat-skills line keeps its
* AGENTS.md merge: the person's yes was for that file. */
private placeGuidance(cwd: string, binding: NodeBinding | undefined, blockId: string, content: string, warnings: string[], sourcePath?: string): boolean {
const targetPath = nodePath.join(cwd, "AGENTS.md");
const queued = binding?.guidanceLaunchInstructions && binding.tmuxSession ? this.launchInstructions.get(binding.tmuxSession) : undefined;
if (!queued || blockId === SEAT_SKILLS_BOOTSTRAP_BLOCK_ID || isPerSeatRoleGuidance(blockId, sourcePath)) {
return this.mergeGuidance(targetPath, blockId, content, warnings, sourcePath);
}
if (blockId === "openrig-start.md") queued.delete("using-openrig.md");
queued.set(blockId, content);
// #2456: a block an earlier handover or resume left in AGENTS.md is refreshed by the same rule (the launch text asks
// Codex to follow AGENTS.md where the two differ); nothing new is written there.
writeManagedGuidanceBlock(this.fs, targetPath, blockId, content, { records: this.managedBlockRecords, onlyIfPresent: true, sourcePath, warnings });
return true;
}
/** The `-c developer_instructions` argument for a fresh launch, from the blocks its startup queued (#875). The value is a
* TOML basic string (JSON's escapes, with DEL escaped too). Codex can give developer instructions more weight than AGENTS.md,
* so the text asks it to keep the person's AGENTS.md guidance first. */
private developerInstructionsArg(tmuxSession: string): string {
const blocks = this.launchInstructions.get(tmuxSession);
this.launchInstructions.delete(tmuxSession);
if (!blocks || blocks.size === 0) return "";
const text = [LAUNCH_INSTRUCTIONS_PREAMBLE, ...[...blocks].map(([id, content]) => managedBlockText(id, content))].join("\n\n");
return ` -c ${shellQuote(`developer_instructions=${JSON.stringify(text).replace(/\u007f/g, "\\u007F")}`)}`;
}
/** #875: whether a fresh launch can carry this seat's managed guidance as developer instructions instead of writing the
* repository AGENTS.md: Codex 0.164.0 or later (where `developer_instructions` is a declared key), and no visible config
* layer sets one, since the launch value would replace it. Anything else, or anything unreadable, keeps AGENTS.md. */
guidanceFileFor(binding: NodeBinding, opts?: { resuming?: boolean }): GuidanceChoice | Promise<GuidanceChoice> {
// A resume or fork (untested with developer instructions) and a startup continuation (it launches nothing) keep the
// repository path, answered at once.
if (opts?.resuming || !binding.tmuxSession) return { file: null };
return this.freshGuidanceChoice(binding);
}
private async freshGuidanceChoice(binding: NodeBinding): Promise<GuidanceChoice> {
let version: string | undefined;
try { version = (await this.resolveManagedConfigSettings?.())?.codexVersion; } catch { version = undefined; }
if (!version || !codexAcceptsDeveloperInstructions(version)) return { file: null };
const configured = this.configuredDeveloperInstructions(binding);
if (configured) return { file: null, note: `codex: ${configured}, so this seat's managed guidance stays in its repository AGENTS.md` };
return { file: null, launchInstructions: true };
}
/** The first visible Codex config layer that sets `developer_instructions` (at any depth, profiles included) or can't be
* read, as `profileRemainsReadOnly` reads the same layers. Null when none does. */
private configuredDeveloperInstructions(binding: NodeBinding): string | null {
const home = this.homeFor(binding) ?? nodePath.join(this.fs.homedir ?? os.homedir(), ".codex");
const layers = [
nodePath.join(this.codexSystemConfigDir, "config.toml"),
nodePath.join(home, "config.toml"),
...(binding.codexConfigProfile ? [nodePath.join(home, `${binding.codexConfigProfile}.config.toml`)] : []),
nodePath.join(this.codexSystemConfigDir, "managed_config.toml"),
];
for (let dir = nodePath.normalize(binding.cwd);;) {
layers.push(nodePath.join(dir, ".codex", "config.toml"));
const parent = nodePath.dirname(dir);
if (parent === dir) break;
dir = parent;
}
for (const path of new Set(layers)) {
if (!this.fs.exists(path)) continue;
let value: unknown;
try { value = parseToml(this.fs.readFile(path)); } catch { return `${path} can't be read as TOML`; }
if (holdsKey(value, "developer_instructions")) return `${path} sets developer_instructions`;
}
return null;
}
private mergeGuidance(targetPath: string, blockId: string, content: string, warnings: string[], sourcePath?: string): boolean {
// Mirrors Claude Code adapter: the `rig-role` managed block collides across
// pod-mates because the regenerator pairs (target-file × spec) without
@@ -1624,11 +1543,11 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
if (warning) warnings.push(warning);
return false;
}
// #2456: a block a person edited since OpenRig wrote it is kept and named, not counted as merged.
return writeManagedGuidanceBlock(this.fs, targetPath, blockId, content, {
records: this.managedBlockRecords, sourcePath, warnings,
mergeManagedBlock(this.fs, targetPath, blockId, content, {
warnings,
replaceBlockIds: blockId === "openrig-start.md" ? ["using-openrig.md"] : [],
});
return true;
}
private detectDeliveryHint(path: string, content: string, orientation?: string): "guidance_merge" | "skill_install" | "send_text" {
@@ -2204,21 +2123,6 @@ export function upsertCodexHookTrust(content: string, key: string, hash: string,
return field === "trusted_content_hash" ? lines.join("\n") : `${lines.join("\n").replace(/\n*$/, "\n")}`;
}
const LAUNCH_INSTRUCTIONS_PREAMBLE = "OpenRig seat guidance for this session follows. Where it conflicts with AGENTS.md guidance in the repository or the Codex home, follow AGENTS.md.";
/** Codex 0.164.0 or later, where `developer_instructions` is a declared config key (#875). */
export function codexAcceptsDeveloperInstructions(version: string): boolean {
const match = /^(\d+)\.(\d+)/.exec(version);
if (!match) return false;
return parseInt(match[1]!, 10) > 0 || parseInt(match[2]!, 10) >= 164;
}
function holdsKey(value: unknown, key: string): boolean {
if (!value || typeof value !== "object") return false;
if (Array.isArray(value)) return value.some((item) => holdsKey(item, key));
return Object.entries(value).some(([name, item]) => name === key || holdsKey(item, key));
}
export function isCodex013xOrLater(version: string): boolean {
const match = /^(\d+)\.(\d+)/.exec(version);
if (!match) return false;
@@ -22,8 +22,7 @@ import type {
import { resolveConcreteHint } from "../domain/runtime-adapter.js";
import type { ProjectionPlan, ProjectionEntry } from "../domain/projection-planner.js";
import { validateResumeToken } from "../domain/resume-token-validation.js";
import { isPerSeatRoleGuidance, perSeatRoleSkipWarning } from "../domain/managed-blocks.js";
import { writeManagedGuidanceBlock, type ManagedBlockRecords } from "../domain/managed-block-records.js";
import { isPerSeatRoleGuidance, mergeManagedBlock, perSeatRoleSkipWarning } from "../domain/managed-blocks.js";
import { guidanceFileSharedByLink } from "../domain/restore-guidance.js";
import { observePiResourceTrust, observeOmpApprovalMode } from "../domain/permission-drift.js";
import {
@@ -67,9 +66,6 @@ export interface PiRuntimeAdapterDeps {
usageFactsEnabled?: () => boolean;
/** OPR.0.7.0.2 K4: whether usage.pi_openrouter_charge is on, read at each launch (absent = off). */
openRouterChargeEnabled?: () => boolean;
/** #2456: with it, every guidance block this adapter writes is checked against the record and recorded; absent, the
* plain merge, as before. */
managedBlockRecords?: ManagedBlockRecords;
}
export class PiRuntimeAdapter implements RuntimeAdapter {
@@ -84,10 +80,8 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
private newLaunchId: () => string;
private usageFactsEnabled: () => boolean;
private openRouterChargeEnabled: () => boolean;
private managedBlockRecords: ManagedBlockRecords | undefined;
constructor(deps: PiRuntimeAdapterDeps) {
this.managedBlockRecords = deps.managedBlockRecords;
this.usageFactsEnabled = deps.usageFactsEnabled ?? (() => false);
this.openRouterChargeEnabled = deps.openRouterChargeEnabled ?? (() => false);
this.tmux = deps.tmux;
@@ -533,10 +527,10 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
if (warning) warnings.push(warning);
return false;
}
// #2456: a block a person edited since OpenRig wrote it is kept and named, not counted as merged.
return writeManagedGuidanceBlock(this.fs, targetPath, blockId, content, {
records: this.managedBlockRecords, sourcePath, warnings,
mergeManagedBlock(this.fs, targetPath, blockId, content, {
warnings,
replaceBlockIds: blockId === "openrig-start.md" ? ["using-openrig.md"] : [],
});
return true;
}
}
@@ -24,8 +24,7 @@ import type {
} from "../domain/runtime-adapter.js";
import { resolveConcreteHint } from "../domain/runtime-adapter.js";
import type { ProjectionPlan, ProjectionEntry } from "../domain/projection-planner.js";
import { isPerSeatRoleGuidance, perSeatRoleSkipWarning } from "../domain/managed-blocks.js";
import { writeManagedGuidanceBlock, type ManagedBlockRecords } from "../domain/managed-block-records.js";
import { isPerSeatRoleGuidance, mergeManagedBlock, perSeatRoleSkipWarning } from "../domain/managed-blocks.js";
import {
stubSeatSidecarPath, buildStubRunnerCommand, parseStubRunnerState,
type StubRunnerState,
@@ -57,9 +56,6 @@ export interface StubRuntimeAdapterDeps {
sleep?: (ms: number) => Promise<void>;
/** Launch-attempt id minting (tests inject; defaults to randomUUID). */
newLaunchId?: () => string;
/** #2456: with it, every guidance block this adapter writes is checked against the record and recorded; absent, the
* plain merge, as before. */
managedBlockRecords?: ManagedBlockRecords;
}
/** In-memory launch record: the readiness fallback for the hermetic path (no
@@ -78,10 +74,8 @@ export class StubRuntimeAdapter implements RuntimeAdapter {
private sleep: (ms: number) => Promise<void>;
private newLaunchId: () => string;
private readonly launchRecords = new Map<string, StubLaunchRecord>();
private managedBlockRecords: ManagedBlockRecords | undefined;
constructor(deps: StubRuntimeAdapterDeps) {
this.managedBlockRecords = deps.managedBlockRecords;
this.tmux = deps.tmux;
this.fsOps = deps.fsOps;
this.runtime = deps.runtime ?? "stub";
@@ -335,10 +329,10 @@ export class StubRuntimeAdapter implements RuntimeAdapter {
if (warning) warnings.push(warning);
return false;
}
// #2456: a block a person edited since OpenRig wrote it is kept and named, not counted as merged.
return writeManagedGuidanceBlock(this.fsOps, targetPath, blockId, content, {
records: this.managedBlockRecords, sourcePath, warnings,
mergeManagedBlock(this.fsOps, targetPath, blockId, content, {
warnings,
replaceBlockIds: blockId === "openrig-start.md" ? ["using-openrig.md"] : [],
});
return true;
}
}
+1 -34
View File
@@ -8,24 +8,6 @@ const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
// exec's 1 MiB default holds about a dozen 1000-line panes; a batched capture chains 24 (#161). A bound, not an allocation.
export const TMUX_MAX_BUFFER = 64 * 1024 * 1024;
export const TMUX_EXEC_TIMEOUT_MS = 30_000;
const tmuxExecOptions = { windowsHide: true, maxBuffer: TMUX_MAX_BUFFER, timeout: TMUX_EXEC_TIMEOUT_MS, killSignal: "SIGKILL" as const };
function warnTmuxDeadline(err: unknown, started: number, argv: string[]): void {
const failure = err as { killed?: boolean; signal?: string } | null;
if (!failure?.killed || failure.signal !== "SIGKILL" || performance.now() - started < TMUX_EXEC_TIMEOUT_MS) return;
let command = 1;
while (argv[command]?.startsWith("-")) command += ["-L", "-S", "-f", "-c", "-T"].includes(argv[command]!) ? 2 : 1;
const paneTarget = argv.indexOf("-t", command + 1);
const sessionTarget = ["new-session", "detach-client"].includes(argv[command] ?? "") ? argv.indexOf("-s", command + 1) : -1;
const target = paneTarget >= 0 ? paneTarget : sessionTarget;
// Name the operation and target, never payloads, environment values or whole command lines.
console.warn("[tmux] execution deadline exceeded; client killed", {
command: argv[command] ?? argv[0], target: target < 0 ? "(server)" : argv[target + 1] ?? "(unknown)", timeoutMs: TMUX_EXEC_TIMEOUT_MS,
});
if (err instanceof Error) err.message = "tmux did not answer within 30 s and its client was ended; the write may still have happened. Check with rig capture before resending.\n" + err.message;
}
/**
* The first regular executable `name` on `pathEnv`, in execvp order. Null when there is none, or when an
@@ -91,19 +73,6 @@ export const execCommand: ExecFn = async (cmd: string): Promise<string> => {
}
};
/** Replace the shell only for tmux clients; other adapter commands keep shell builtins such as kill. */
export const execTmuxCommand: ExecFn = async (cmd: string): Promise<string> => {
const started = performance.now();
try {
return (await execFileAsync("/bin/sh", ["-c", /^tmux\s/.test(cmd) ? `exec ${cmd}` : cmd], tmuxExecOptions)).stdout;
} catch (err) {
// Tokens are diagnostic labels only; the command is executed by the shell without rewriting its arguments.
const labels = (cmd.match(/'[^']*'|"[^"]*"|\S+/g) ?? []).map(word => word.replace(/^(['"])(.*)\1$/, "$2"));
warnTmuxDeadline(err, started, labels);
throw withExecOutput(err);
}
};
/**
* Production ArgvExecFn for TmuxAdapter.
* Runs argv[0] directly via execFile: no shell, no quoting layer. On POSIX
@@ -114,10 +83,9 @@ export const execTmuxCommand: ExecFn = async (cmd: string): Promise<string> => {
export const execArgvCommand: ArgvExecFn = async (argv: string[]): Promise<string> => {
const [bin, ...args] = argv;
if (!bin) throw new Error("execArgvCommand: empty argv");
const started = performance.now();
// Nothing ran when no file was found: a shell also finds tmux through an entry the lookup skips, such as a literal
// `~/bin`, and reports a missing one as the shell transport does.
const opts = tmuxExecOptions;
const opts = { windowsHide: true, maxBuffer: TMUX_MAX_BUFFER };
const run = (file: string | null) => file ? execFileAsync(file, args, opts) : execFileAsync("/bin/sh", ["-c", 'exec "$0" "$@"', bin, ...args], opts);
try {
if (process.platform === "win32") return (await execFileAsync(bin, args, opts)).stdout;
@@ -132,7 +100,6 @@ export const execArgvCommand: ArgvExecFn = async (argv: string[]): Promise<strin
return (await run(null)).stdout;
}
} catch (err) {
warnTmuxDeadline(err, started, argv);
throw withExecOutput(err);
}
};
+1 -10
View File
@@ -919,16 +919,7 @@ export class TmuxAdapter {
const identity = this.freshManaged.get(target)?.nodeId ?? target;
const checkDraft = guard?.needsInputCheck(identity);
const policy = checkDraft || options?.beforePaste ? {
// A paste the guard proves it left unsubmitted (#2429) is submitted first, under this lease, once this send's own
// checks pass; they then aren't run a second time.
beforePaste: async () => {
let checked = false;
const checks = async () => { await options?.beforePaste?.(); beforeWrite(); beforeInput?.(); checked = true; };
// The synchronous checks run again just before the key: the guard's re-read awaits after `checks`.
const enter = async () => { beforeWrite(); beforeInput?.(); return (await this.sendKeysUnchecked(pane, ["Enter"])).ok; };
if (checkDraft) await guard!.beforeInput(identity, false, text, { checks, enter });
if (!checked) await options?.beforePaste?.();
},
beforePaste: async () => { if (checkDraft) await guard!.beforeInput(identity, false, text); await options?.beforePaste?.(); },
afterPaste: async () => { if (checkDraft) await guard!.notePasted(identity, text); },
} : undefined;
return this.sendTextUnchecked(pane, text, () => { beforeWrite(); beforeInput?.(); }, options?.bracketed, policy);
-6
View File
@@ -2,9 +2,6 @@ import { activityGrantsSchema } from "./migrations/143_activity_grants.js";
import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js";
import { nodePermissionsToSchema } from "./migrations/145_node_permissions_to.js";
import { nodeStartupGuidanceFileSchema } from "./migrations/146_node_startup_guidance_file.js";
import { managedBlockWritesSchema } from "./migrations/147_managed_block_writes.js";
import { outboxDispatchedIndexSchema } from "./migrations/148_outbox_dispatched_index.js";
import { streamIntakeCursorSchema } from "./migrations/149_stream_intake_cursor.js";
import { nodeAccountHomeSchema } from "./migrations/141_node_account_home.js";
import { escalationTargetRefsSchema } from "./migrations/126_escalation_target_refs.js";
import { claudeTokenReadingsSchema } from "./migrations/134_claude_token_readings.js";
@@ -298,7 +295,4 @@ export const ALL_MIGRATIONS: Migration[] = [
environmentBindingsSchema,
nodePermissionsToSchema,
nodeStartupGuidanceFileSchema,
managedBlockWritesSchema,
outboxDispatchedIndexSchema,
streamIntakeCursorSchema,
];
@@ -1,20 +0,0 @@
import type { Migration } from "../migrate.js";
/**
* #2456: what OpenRig last wrote in each managed guidance block, by the guidance file's real path and the block id: the
* sha256 of the block's body without the writer's framing. A block whose body still has that hash is OpenRig's own and is
* refreshed from its source; any other body is a person's edit and is kept. No row (every block written before this) means
* OpenRig can't tell which it is.
*/
export const managedBlockWritesSchema: Migration = {
name: "147_managed_block_writes.sql",
sql: `
CREATE TABLE managed_block_writes (
path_key TEXT NOT NULL,
block_id TEXT NOT NULL,
body_sha256 TEXT NOT NULL,
written_at TEXT NOT NULL,
PRIMARY KEY (path_key, block_id)
);
`,
};
@@ -1,13 +0,0 @@
import type { Migration } from "../migrate.js";
/**
* GET /api/queue/outbox/list?rig= matches session names by suffix, which no session index can serve. This index lets
* that read walk the outbox newest-first and stop at its limit, instead of scanning and sorting the whole table. The
* session columns ride along so the match is tested in the index, and only matching rows are read.
*/
export const outboxDispatchedIndexSchema: Migration = {
name: "148_outbox_dispatched_index.sql",
sql: `
CREATE INDEX IF NOT EXISTS idx_outbox_entries_ts_dispatched ON outbox_entries(ts_dispatched, sender_session, destination_session);
`,
};
@@ -1,15 +0,0 @@
import type { Migration } from "../migrate.js";
/** Stream intake cursor table tracking the last processed stream sort key. */
export const streamIntakeCursorSchema: Migration = {
name: "149_stream_intake_cursor.sql",
sql: `
CREATE TABLE IF NOT EXISTS stream_intake_cursor (
id INTEGER PRIMARY KEY CHECK (id = 1),
last_sort_key TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_stream_items_archived_sort_key ON stream_items(archived_at, stream_sort_key);
CREATE INDEX IF NOT EXISTS idx_stream_items_sort_key ON stream_items(stream_sort_key);
`,
};
@@ -186,7 +186,6 @@ export class BootstrapOrchestrator {
try {
const podSource = await this.deps.podBundleSourceResolver.resolve(sourceRef);
podBundleTempDir = podSource.tempDir;
warnings.push(...(podSource.warnings ?? []).filter(w => !warnings.includes(w)));
try {
const sourceYaml = this.deps.fsOps.readFile(podSource.specPath);
const sharedProject = rigNameComposeProjectConflict(sourceYaml, opts.rigName);
@@ -310,7 +309,6 @@ export class BootstrapOrchestrator {
try {
bundleSource = await this.deps.bundleSourceResolver.resolve(sourceRef);
bundleTempDir = bundleSource.tempDir;
warnings.push(...(bundleSource.warnings ?? []).filter(w => !warnings.includes(w)));
specDir = nodePath.dirname(bundleSource.specPath);
const rawYaml = withRigName(this.deps.fsOps.readFile(bundleSource.specPath), opts.rigName);
const raw = RigSpecCodec.parse(rawYaml);
@@ -782,7 +780,7 @@ export class BootstrapOrchestrator {
}
const failureWarnings = routingFailureWarnings(bundleRouting);
stages.push({ stage: "route_bundle_contents", status: failureWarnings.length > 0 ? "failed" : "ok", detail: bundleRouting });
warnings.push(...(bundleRouting.warnings ?? []).filter(w => !warnings.includes(w)), ...failureWarnings);
warnings.push(...failureWarnings);
return { ok: true };
}
: undefined;
+3 -35
View File
@@ -11,7 +11,7 @@ import { createHash } from "node:crypto";
import fs from "node:fs";
import nodePath from "node:path";
import * as tar from "tar";
import { verifyIntegrity, isHarnessLoginPath, type IntegrityFsOps } from "./bundle-integrity.js";
import { verifyIntegrity, type IntegrityFsOps } from "./bundle-integrity.js";
// TODO: AS-T12 — migrate to pod-aware bundle types
import { parseLegacyBundleManifest as parseBundleManifest, normalizeLegacyBundleManifest as normalizeBundleManifest, normalizeIncludedLoginFiles } from "./bundle-types.js";
@@ -124,7 +124,6 @@ export async function unpack(archivePath: string, outputDir: string): Promise<{
}
// Step 3: Extract (safe — pre-scanned and defensively filtered)
const fileModes: Array<{ file: string; mode: number }> = [];
fs.mkdirSync(outputDir, { recursive: true });
await tar.extract({
file: archivePath,
@@ -132,11 +131,7 @@ export async function unpack(archivePath: string, outputDir: string): Promise<{
filter: (p, entry) => {
if ("isSymbolicLink" in entry && typeof entry.isSymbolicLink === "function" && entry.isSymbolicLink()) return false;
const type = "type" in entry ? (entry as { type?: string }).type : undefined;
if (unsafeArchiveEntryReason(p, type) !== null) return false;
if ((type === "File" || type === "OldFile" || type === "ContiguousFile") && typeof entry.mode === "number") {
fileModes.push({ file: nodePath.resolve(outputDir, p), mode: entry.mode & 0o777 });
}
return true;
return unsafeArchiveEntryReason(p, type) === null;
},
});
@@ -189,34 +184,7 @@ export async function unpack(archivePath: string, outputDir: string): Promise<{
];
throw new Error(`Content integrity verification failed: ${details.join("; ")}`);
}
const warnings = [...(result.warnings ?? [])];
// Keep every header in archive order: aliases and duplicates can interleave.
// POSIX device/inode identity groups aliases without folding distinct names
// on case-sensitive volumes. Read bigint IDs to avoid number precision loss.
if (process.platform !== "win32") {
const files = new Map<string, { file: string; mode: number; extracted: number; login: boolean }>();
for (const { file, mode } of fileModes) {
const stat = fs.lstatSync(file, { throwIfNoEntry: false, bigint: true });
// A later entry may have replaced this file with a directory.
if (!stat?.isFile()) continue;
const identity = `${stat.dev}:${stat.ino}`;
const login = isHarnessLoginPath(nodePath.relative(outputDir, file)) || !!files.get(identity)?.login;
files.set(identity, { file, mode, extracted: Number(stat.mode) & 0o7777, login });
}
for (const { file, mode, extracted, login } of files.values()) {
// Restore owner bits only. Keep extracted group/other read/execute bits,
// excluding group/other write and all special bits even on external tars.
const target = login ? mode & 0o600 : (mode & 0o700) | (extracted & 0o055);
if (extracted === target) continue;
try {
fs.chmodSync(file, target);
} catch {
const relative = nodePath.relative(outputDir, file);
warnings.push(`Bundle mode repair warning: chmod failed for '${relative}' (observed mode ${extracted.toString(8).padStart(4, "0")}). ${login ? "The owner-only login cap could not be applied. " : ""}Extract to a private folder on a filesystem that supports permissions.`);
}
}
}
if (warnings.length > 0) return { warnings };
if (result.warnings) return { warnings: result.warnings };
}
}
@@ -262,12 +262,6 @@ export function describeBundleBehaviour(input: DescribeBundleInput): BundleBehav
if (kind === "launch_plugin_dir") { view.alsoRuns.push({ ...fact, trigger: "if selected runtime supports and loads this resource" }); hooks(fact); }
write(seat, kind, "openrig_seat_storage", dest(String(ref)), fact.sourceRefs, "copy");
}
// #2044: a member's plugin channels are names, not files: what its launches need from the installing host.
for (const [index, entry] of list(member.plugin_channels).entries()) {
if (!text(entry)) continue;
view.needs.push({ seat, kind: "claude_plugin_channel", name: `Claude plugin channel ${entry} (--channels): its plugin installed, and the channel admitted by Claude's channel policy`,
status: "not_checked", sourceRefs: [{ path: rigPath, field: `pods.${pod.id}.members.${member.id}.plugin_channels[${index}]` }] });
}
}
const startup = (block: StartupBlock | undefined, base: string, refs: SourceRef[]) => {
for (const f of block?.files ?? []) {
@@ -26,7 +26,6 @@ import { registerBundleProject, type ProjectRegistrationResult } from "./workspa
export type BundleContentKind = "bundle" | "skills" | "plugins" | "workflowSpecs" | "contextPacks" | "agentImages" | "project";
export interface BundleContentRouting {
warnings?: string[];
skillsRouting?: RouteSkillsResult;
pluginsRouting?: RoutePluginsResult;
workflowSpecsRouting?: RouteWorkflowSpecsResult;
@@ -99,8 +98,7 @@ export async function routeBundleContents(
tmpDir = bundleRoot;
let manifest: Record<string, unknown> | null = null;
try {
const unpacked = await unpack(bundlePath, bundleRoot);
if (unpacked?.warnings?.length) routing.warnings = unpacked.warnings;
await unpack(bundlePath, bundleRoot);
const manifestPath = nodePath.join(bundleRoot, "bundle.yaml");
if (fs.existsSync(manifestPath)) {
manifest = parsePodBundleManifest(fs.readFileSync(manifestPath, "utf-8")) as Record<string, unknown>;
@@ -17,7 +17,6 @@ export interface BundleResolvedSource {
packageRefMap: Record<string, ResolvedPackage>;
manifest: BundleManifest;
tempDir: string;
warnings?: string[];
}
/**
@@ -41,7 +40,7 @@ export class LegacyBundleSourceResolver {
try {
// Extract + verify (archive digest + content integrity)
const unpacked = await unpack(bundlePath, tempDir);
await unpack(bundlePath, tempDir);
// Parse bundle manifest
const manifestPath = nodePath.join(tempDir, "bundle.yaml");
@@ -99,7 +98,7 @@ export class LegacyBundleSourceResolver {
packageRefMap[`./${entry.path}`] = result.resolved;
}
return { specPath, resolvedPackages, packageRefMap, manifest, tempDir, warnings: unpacked?.warnings };
return { specPath, resolvedPackages, packageRefMap, manifest, tempDir };
} catch (err) {
// Clean up on failure
this.cleanup(tempDir);
@@ -126,7 +125,6 @@ export interface PodBundleResolvedSource {
specPath: string;
manifest: PodBundleManifest;
tempDir: string;
warnings?: string[];
}
/**
@@ -139,7 +137,7 @@ export class PodBundleSourceResolver {
try {
// Safe unpack with digest/symlink/integrity verification
const unpacked = await unpack(bundlePath, tempDir);
await unpack(bundlePath, tempDir);
const manifestPath = nodePath.join(tempDir, "bundle.yaml");
if (!fs.existsSync(manifestPath)) {
@@ -184,7 +182,7 @@ export class PodBundleSourceResolver {
throw new Error(`Bundle missing rig spec at ${manifest.rigSpec}`);
}
return { specPath, manifest, tempDir, warnings: unpacked?.warnings };
return { specPath, manifest, tempDir };
} catch (err) {
this.cleanup(tempDir);
throw err;
@@ -1,7 +1,7 @@
import type { TerminalActor, TerminalPolicyStore } from "./terminal-policy.js";
import { TerminalPolicyRefusal } from "./terminal-policy.js";
import { DeliveryGuardError } from "./seat-delivery-guard.js";
import { CAPTURE_READABLE_BUDGET_MS, type SessionTransport, type SendResult } from "./session-transport.js";
import type { SessionTransport, SendResult } from "./session-transport.js";
import type { ClaudeCompactionPolicy, SettingsStore } from "./user-settings/settings-store.js";
import { postCompactExtraSetting } from "./managed-restore-read-access.js";
import * as fs from "node:fs";
@@ -503,8 +503,6 @@ interface CompactionRecord {
* `compact_uncertain`: the attempt stopped after a /compact whose outcome is unknown, so its restore is owed if it ran.
* `done`: the post-restore audit was sent. `ended`: the attempt stopped or was cancelled. */
phase: "preparing" | PendingPostCompactStage | "compact_uncertain" | "done" | "ended";
/** Diagnostic only; older records may omit the reason an attempt stopped. */
reason?: string;
delivery: PreparationState["delivery"];
deadlineAt: number | null;
sends: number;
@@ -1370,11 +1368,11 @@ export class ClaudeCompactionEnforcer {
};
const send = async () => {
check();
// Both paths send only to an idle seat. Include the readable observation's budget in the wait,
// still bounded by the attempt's existing deadline.
// Both paths send only to an idle seat, with the same idle wait, bounded by the attempt's existing
// deadline. (The automatic path used to send into a seat still finishing its preparation turn.)
// Idle comes from a live pane read: the newest hook can predate the rest of the preparation turn.
return this.sessionTransport.send(input.sessionName, composed.command,
{ openrigNotice: true, preserveInput: true, beforeWrite, ...(attempt.deadlineAt !== null ? { waitForIdleMs: Math.max(1, Math.min(this.compactSendIdleWaitMs + CAPTURE_READABLE_BUDGET_MS, this.compactDeadline(attempt)! - this.now())), readinessFromPaneOnly: true } : {}) });
{ openrigNotice: true, preserveInput: true, beforeWrite, ...(attempt.deadlineAt !== null ? { waitForIdleMs: Math.max(1, Math.min(this.compactSendIdleWaitMs, this.compactDeadline(attempt)! - this.now())), readinessFromPaneOnly: true } : {}) });
};
let result: Pick<SendResult, "ok" | "reason" | "outcome" | "sent" | "inputPhase">;
let guardRefused = false;
@@ -1430,7 +1428,7 @@ export class ClaudeCompactionEnforcer {
* why it couldn't be read. Never throws. */
private async inputBoxStatus(sessionName: string): Promise<string> {
try {
const capture = await this.sessionTransport.capture(sessionName, { inputBox: true, readable: { width: 120, height: 40 } });
const capture = await this.sessionTransport.capture(sessionName, { inputBox: true });
return capture.inputBox?.status ?? capture.reason ?? "unknown";
} catch { return "unknown"; }
}
@@ -1507,9 +1505,9 @@ export class ClaudeCompactionEnforcer {
} catch { try { fs.rmSync(temporary, { force: true }); } catch { /* nothing to clean */ } }
}
private persistStage(sessionName: string, phase: CompactionRecord["phase"], reason?: string): void {
private persistStage(sessionName: string, phase: CompactionRecord["phase"]): void {
const current = this.records.get(sessionName);
if (current) this.persist(sessionName, current.record.attemptId, { phase, ...(reason !== undefined ? { reason } : {}), lastDelivery: undefined });
if (current) this.persist(sessionName, current.record.attemptId, { phase, lastDelivery: undefined });
}
/**
@@ -1595,7 +1593,7 @@ export class ClaudeCompactionEnforcer {
const attempt = this.pendingPreCompactPrep.get(session);
if ((expected && expected !== attempt) || !attempt || attempt.status === "stopped" || attempt.status === "compact-sent") return;
attempt.status = "stopped"; attempt.reason = reason; attempt.stoppedAt = this.now(); attempt.controller.abort();
this.persist(session, attempt.attemptId, { phase: attempt.compact === "uncertain" ? "compact_uncertain" : "ended", reason });
this.persist(session, attempt.attemptId, { phase: attempt.compact === "uncertain" ? "compact_uncertain" : "ended" });
if (attempt.mode === "manual") this.setManualStage(session, "skipped-or-failed", reason);
}
@@ -1611,7 +1609,7 @@ export class ClaudeCompactionEnforcer {
attempt.reason = "compaction_cancelled";
attempt.controller.abort();
}
this.persistStage(session, "ended", "compaction_cancelled");
this.persistStage(session, "ended");
this.pendingPostCompactRestore.delete(session);
this.pendingStageGeneration.delete(session);
this.restoreCarry.delete(session);
@@ -1,13 +0,0 @@
// #2044 — a Claude member's own plugin channels (spec `plugin_channels`). Claude Code's `--channels` registers the
// session for channel notifications (inbound push) from the MCP servers it names; Claude 2.1.316 hides the option from
// `claude --help`. OpenRig passes only plugin entries, `plugin:<name>@<marketplace>`, as one `--channels` followed by the
// entries. The option takes every following value up to the next option, so each launch puts OpenRig's identity options
// after it. One rule for the spec, the seat's additions record and identity proof.
/** A plugin channel entry as OpenRig passes it: a plain plugin name and marketplace name. Claude refuses to start on an
* entry that isn't `plugin:<name>@<marketplace>` or `server:<name>`, and `server:` entries aren't supported here. */
const CLAUDE_PLUGIN_CHANNEL = /^plugin:[A-Za-z0-9][A-Za-z0-9._-]*@[A-Za-z0-9][A-Za-z0-9._-]*$/;
export function isClaudePluginChannel(value: unknown): value is string {
return typeof value === "string" && CLAUDE_PLUGIN_CHANNEL.test(value);
}
@@ -1,29 +0,0 @@
/** Current interactive Claude 2.1.312 theme picker, including its tall preview.
* This establishes a dialog for the native send guard, never permission to cancel
* it. A later composer, quoted frame, partial menu or editor is not this surface. */
export function currentClaudeThemeMenu(pane: string): string | null {
const lines = pane.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
// OpenRig-launched Claude may draw its named seat rule below the menu footer.
if (/^─{3,} [\w.-]+@[\w.-]+ ─+$/.test(lines.at(-1) ?? "")) lines.pop();
if (lines.at(-1) !== "Enter to select · Esc to cancel") return null;
const header = lines.lastIndexOf("Theme");
if (header < 1 || !/^─{3,}$/.test(lines[header - 1]!)
|| lines[header + 1] !== "Choose the text style that looks best with your terminal") return null;
const body = lines.slice(header + 2, -1);
const labels = ["Auto (match terminal)", "Dark mode", "Light mode", "Dark mode (colorblind-friendly)",
"Light mode (colorblind-friendly)", "Dark mode (ANSI colors only)", "Light mode (ANSI colors only)", "New custom theme…"];
if (body.length !== 15) return null;
let pointers = 0, checks = 0;
const choices = body.slice(0, labels.length).map(line => {
if (/^❯\s+/.test(line)) { pointers++; line = line.replace(/^❯\s+/, ""); }
if (/^✔\s+/.test(line)) { checks++; line = line.replace(/^✔\s+/, ""); }
return line;
});
if (pointers !== 1 || checks !== 1 || !choices.every((line, i) => line === labels[i])) return null;
if (!/^╌{3,}$/.test(body[8]!) || !/^╌{3,}$/.test(body[13]!)) return null;
const preview = [/^1\s+function greet\(\) \{$/, /^2\s*-\s+console\.log\("Hello, World!"\);$/,
/^2\s*\+\s+console\.log\("Hello, Claude!"\);$/, /^3\s+\}$/];
if (!preview.every((pattern, i) => pattern.test(body[9 + i]!))
|| !/^Syntax theme: .+ \(ctrl\+t to disable\)$/.test(body[14]!)) return null;
return lines[header]!;
}
@@ -2,5 +2,3 @@
// then the elapsed time and "esc to interrupt" in parentheses, e.g. "• Working (1h 09m 39s • esc to interrupt)".
// Completed output that merely says "Working directory: …" or "Working tree is clean." never matches.
export const CODEX_TURN_STATUS_PATTERN = /^[•◦]\s+\S.*\((?:\d+[hms]\s*)+•\s*esc to interrupt\)/;
// Some Codex frames omit the closing parenthesis before the status separator. This is positive activity evidence only.
export const CODEX_LIVE_WORKING_FRAME_PATTERN = /^[•◦]\s+\S.*\((?:\d+[hms]\s*)+•\s*esc to interrupt(?:\)|\s+·(?:\s|$))/;
@@ -137,8 +137,6 @@ export interface ComposerInput {
state: "empty" | "text" | "unknown";
rows: string[];
columns: number;
/** How many columns short of `columns` the harness may wrap its text: a row boundary at a width in that range is a wrap. */
wrapSlack?: number;
/** Conservative visible capacity of the recognized Codex fullscreen composer. */
pasteViewport?: { columns: number; rows: number };
cursor?: ComposerSnapshot["cursor"];
@@ -160,7 +158,6 @@ export const COMPOSER_COLLAPSED_PASTE_PATTERN = /^\[Pasted text #\d+ \+(\d+) lin
export function composerContainsOwnedText(input: ComposerInput, expected: string): boolean {
if (input.state !== "text" || !input.cursorAtEnd || input.collapsedPaste || !input.rows.length) return false;
const text = expected.replace(/\r\n/g, "\n");
const narrowest = input.columns - (input.wrapSlack ?? 0);
let offsets = new Set([0]);
for (let index = 0; index < input.rows.length; index++) {
const row = input.rows[index]!;
@@ -170,10 +167,10 @@ export function composerContainsOwnedText(input: ComposerInput, expected: string
const end = offset + row.length;
if (index === input.rows.length - 1) { if (end === text.length) return true; continue; }
if (text[end] === "\n") next.add(end + 1);
if (stringWidth(row) >= narrowest) next.add(end);
if (stringWidth(row) >= input.columns) next.add(end);
if (text[end] === " ") {
const word = /^\S+/.exec(text.slice(end + 1))?.[0] ?? "";
if (word && stringWidth(row + " " + word) > narrowest) next.add(end + 1);
if (word && stringWidth(row + " " + word) > input.columns) next.add(end + 1);
}
}
offsets = next;
@@ -1,8 +1,7 @@
import nodePath, { dirname, isAbsolute } from "node:path";
import { dirname, isAbsolute } from "node:path";
import type Database from "better-sqlite3";
import { resolveAgentRef, type AgentResolverFsOps } from "./agent-resolver.js";
import { resolveProfileRuntimeResources } from "./profile-resolver.js";
import { reanchorBuiltinStartupFile } from "./builtin-startup-files.js";
/** What the seat's CURRENT authored specification selects for Claude activity
* hooks: `known` when the agent file and profile resolve, `unknown` when they
@@ -12,67 +11,19 @@ export type DesiredClaudeActivityHooks =
| { status: "known"; selected: boolean }
| { status: "unknown"; evidence: string };
type ResolvedAgent = Extract<ReturnType<typeof resolveAgentRef>, { ok: true }>;
/** #1530: a `local:` agent ref resolved as at launch. Launch resolves it against the rig root, which isn't kept on its own,
* but every rig, pod and member startup file records that root as its `ownerRoot`. So the ref is resolved against each
* saved startup file's owner root (re-anchored to the running install for shipped specs, as the runtime switch reads them,
* #261). Launch also records the folder it read the agent from, as the owner root of the agent's own startup files, so a
* folder counts only when the ref lands on a recorded root, holds an `agent.yaml` with the seat's resolved spec name, and
* is the only one that does. Where the records can't prove which folder is the seat's spec (an import or author folder can
* share its name), the answer is an error, never a guess. */
function agentResolvedAgainstRecordedRoots(
agentRef: string,
resolvedSpecName: string | null,
resolvedStartupFiles: readonly unknown[],
fsOps: AgentResolverFsOps,
): { ok: true; agent: ResolvedAgent } | { ok: false; error: string } {
const rel = agentRef.startsWith("local:") ? agentRef.slice("local:".length) : "";
if (!rel) return { ok: false, error: "Current agent reference/profile has no established absolute source location" };
const roots = new Set<string>();
for (const raw of resolvedStartupFiles) {
if (!raw || typeof raw !== "object") continue;
const { path, absolutePath, ownerRoot } = raw as Record<string, unknown>;
if (typeof path !== "string" || typeof absolutePath !== "string" || typeof ownerRoot !== "string") continue;
const root = reanchorBuiltinStartupFile({ path, absolutePath, ownerRoot }).ownerRoot;
if (isAbsolute(root)) roots.add(nodePath.resolve(root));
}
const folders = new Set([...roots].map((root) => nodePath.resolve(root, rel)).filter((folder) => roots.has(folder)));
const agents = [...folders]
.filter((folder) => fsOps.exists(nodePath.join(folder, "agent.yaml")))
.map((folder) => resolveAgentRef(`path:${folder}`, folder, fsOps))
.flatMap((result) => result.ok && result.resolved.spec.name === resolvedSpecName ? [result] : []);
if (agents.length !== 1) {
return { ok: false, error: agents.length === 0
? `The seat's agent spec (${resolvedSpecName ?? agentRef}) wasn't found by resolving ${agentRef} against a rig root its last launch recorded`
: `The seat's agent spec (${resolvedSpecName}) resolved to more than one folder: ${agents.map((agent) => agent.resolved.sourcePath).join(", ")}` };
}
return { ok: true, agent: agents[0]! };
}
/** Resolve the current selection from an agent reference and profile. An absolute `path:` ref is read where it points; a
* `local:` ref is resolved against the rig roots its saved launch recorded (#1530). Anything else, or a `local:` ref that
* doesn't resolve to exactly one folder holding the seat's spec, resolves to `unknown` rather than guessing. */
/** Resolve the current selection from an agent reference and profile. A
* relative agent reference has no retained authoritative launch root here, so
* it resolves to `unknown` rather than guessing from cwd or rig name. */
export function resolveDesiredClaudeActivityHooks(
agentRef: string | null,
profile: string | null,
fsOps: AgentResolverFsOps,
saved?: { resolvedSpecName: string | null; resolvedStartupFiles: readonly unknown[] },
): DesiredClaudeActivityHooks {
if (!agentRef || !profile) {
if (!agentRef?.startsWith("path:") || !isAbsolute(agentRef.slice(5)) || !profile) {
return { status: "unknown", evidence: "Current agent reference/profile has no established absolute source location" };
}
let resolved: ResolvedAgent;
if (agentRef.startsWith("path:") && isAbsolute(agentRef.slice(5))) {
const result = resolveAgentRef(agentRef, dirname(agentRef.slice(5)), fsOps);
if (!result.ok) return { status: "unknown", evidence: "Current agent specification could not be resolved" };
resolved = result;
} else {
if (!saved) return { status: "unknown", evidence: "Current agent reference/profile has no established absolute source location" };
const found = agentResolvedAgainstRecordedRoots(agentRef, saved.resolvedSpecName, saved.resolvedStartupFiles, fsOps);
if (!found.ok) return { status: "unknown", evidence: found.error };
resolved = found.agent;
}
const resolved = resolveAgentRef(agentRef, dirname(agentRef.slice(5)), fsOps);
if (!resolved.ok) return { status: "unknown", evidence: "Current agent specification could not be resolved" };
const selected = resolveProfileRuntimeResources(resolved.resolved, resolved.imports, profile);
if (!selected.ok) return { status: "unknown", evidence: selected.errors.join("; ") };
return {
@@ -82,24 +33,15 @@ export function resolveDesiredClaudeActivityHooks(
};
}
/** Read the live node record and its saved startup files, and resolve its current selection. A missing row resolves to
* `unknown`: the caller reports the gap instead of projecting. Saved startup files that can't be read are left out, so a
* `local:` ref that needs them stays `unknown`. */
/** Read the live node record and resolve its current selection. A missing row
* resolves to `unknown`: the caller reports the gap instead of projecting. */
export function createDesiredClaudeActivityHooksReader(
db: Database.Database,
fsOps: AgentResolverFsOps,
): (nodeId: string) => DesiredClaudeActivityHooks {
return (nodeId) => {
const node = db.prepare("SELECT agent_ref, profile, resolved_spec_name FROM nodes WHERE id = ?").get(nodeId) as
{ agent_ref: string | null; profile: string | null; resolved_spec_name: string | null } | undefined;
let resolvedStartupFiles: unknown[] | undefined;
try {
const row = db.prepare("SELECT resolved_files_json FROM node_startup_context WHERE node_id = ?").get(nodeId) as
{ resolved_files_json: string } | undefined;
const parsed: unknown = row ? JSON.parse(row.resolved_files_json) : undefined;
if (Array.isArray(parsed)) resolvedStartupFiles = parsed;
} catch { /* unreadable: left out */ }
return resolveDesiredClaudeActivityHooks(node?.agent_ref ?? null, node?.profile ?? null, fsOps,
resolvedStartupFiles ? { resolvedSpecName: node?.resolved_spec_name ?? null, resolvedStartupFiles } : undefined);
const node = db.prepare("SELECT agent_ref, profile FROM nodes WHERE id = ?").get(nodeId) as
{ agent_ref: string | null; profile: string | null } | undefined;
return resolveDesiredClaudeActivityHooks(node?.agent_ref ?? null, node?.profile ?? null, fsOps);
};
}
@@ -10,8 +10,7 @@ export interface GuardedDeliverySummary {
}
/** Historical attempts contain outcomes, never executable options or callbacks. */
interface Receipt { ids: string[]; result?: SendResult; expectedIdentity?: import("./expected-conversation.js").ExpectedConversation;
watchdog?: { nextAction?: string } }
interface Receipt { ids: string[]; result?: SendResult; expectedIdentity?: import("./expected-conversation.js").ExpectedConversation }
type Deliver = (session: string, text: string, opts: SendOpts) => Promise<SendResult>;
/** Applicability needs the intended queue row and recipient, not a saved delivery receipt. */
export type QueueWakeFacts = Pick<OutboxEntry, "outboxId" | "auditPointer" | "destinationSession">;
@@ -35,7 +34,7 @@ export class DraftAwareDelivery {
const entry = this.outbox.getById(id)!;
const state = entry.deliveryState === "retained" || entry.deliveryState === "retired" ? "held"
: entry.deliveryState === "delivered" ? "complete" : entry.deliveryState === "sending" ? "sending" : entry.deliveryState === "failed" ? "failed" : "indeterminate";
const reason = entry.deliveryState === "retired" ? "message_retired" : receipt.result?.reason ?? receipt.watchdog?.nextAction
const reason = entry.deliveryState === "retired" ? "message_retired" : receipt.result?.reason
?? (state === "sending" ? "delivery_in_progress" : state === "complete" ? "delivered" : "interrupted_write");
return { id: receipt.ids[0]!, state, reason };
}
@@ -75,9 +74,7 @@ export class DraftAwareDelivery {
/* Legacy bookkeeping cannot veto input; report its absence with the actual result. */
}
try {
// A paste the guard left unsubmitted (#2429) is recognized here, to be submitted ahead of this delivery, unless the
// delivery needs an idle seat: submitting it starts a turn, so that delivery is held as before.
await this.guard.beforeInput(session, false, undefined, opts.waitForIdleMs === undefined && !opts.preserveInput ? "recognize" : undefined);
await this.guard.beforeInput(session);
result = await this.deliver(session, text, { ...opts,
onInputEffect: phase => { inputWritten = true; opts.onInputEffect?.(phase); },
beforeWrite: () => {
@@ -109,9 +106,6 @@ export class DraftAwareDelivery {
// A wait-path failure can follow a dispatched native write. Only a positive pre-input result proves no write.
const { sent: _sent, ...uncertain } = result; result = uncertain;
}
// Whatever became of this delivery's own text, its record says it submitted OpenRig's earlier paste.
if (this.guard.submittedOwnLeftover(session)) result = { ...result, ownPasteSubmitted: true,
warning: [result.warning, "OpenRig first submitted its own earlier paste, left unsubmitted in this seat's input."].filter(Boolean).join(" ") };
if (opts.deliveryId && !opts.committedOutboxIds) result = { ...result,
warning: [result.warning, "Delivery IDs do not deduplicate requests; this is a new input attempt."].filter(Boolean).join(" ") };
const state = noWrite ? "retained" : result.sent === false ? "failed" : result.ok && (result.verified || !opts.verify) ? "delivered" : "indeterminate";
@@ -328,21 +328,6 @@ export function participantsView(home: string = getOpenRigHome()): ParticipantsV
};
}
/** The participant JSON shared by the CLI and daemon read route. Never return the raw default-person fragment,
* which includes connector credential pointers; the participant records carry the public address and policy. */
export function participantsJsonView(home: string = getOpenRigHome(), organization?: string) {
const view = participantsView(home);
if (!view.ok) return view;
const side = readParticipantsFile(home);
const defaultPerson = view.default.person
? { id: view.default.person.entityId, source: view.default.source }
: { id: null, reason: view.default.source === "none" ? view.default.reason : null };
return { ok: true as const,
participants: organization ? view.participants.filter((participant) => participant.organization?.id === organization) : view.participants,
default: defaultPerson, organizations: side.ok ? side.file.organizations : null, groups: side.ok ? side.file.groups : null,
warnings: view.warnings, participantFile: view.sideFile };
}
// ── The writes behind the `rig gateway` verbs. Each goes through updateParticipantsFile, so each is validated the same
// way a read is and written atomically.
@@ -134,9 +134,8 @@ export function buildImageBlocks(mediaRefs: readonly SlackMediaRef[] | undefined
* exactly these (one producer, one parser: see parseQuestionAction). */
export const QUESTION_BLOCK_PREFIX = "or-q:";
export const OPTION_ACTION_PREFIX = "or-opt:";
export const TYPED_REPLY_HINT = "Or reply in this thread with your own answer. A typed reply submits your answer without waiting for every question to be answered.";
export const CHANNEL_REPLY_HINT = "Reply in this thread with your answer.";
export const HUMAN_REQUIRED_REPLY_HINT = "Reply in this thread after this question to answer it. Earlier and extra messages still reach the agent as conversation.";
const TYPED_REPLY_HINT = "Or reply in this thread with your own answer. A typed reply submits your answer without waiting for every question to be answered.";
const CHANNEL_REPLY_HINT = "Reply in this thread with your answer.";
/** Parse a clicked button back into its question and option ids; null if it is not ours. */
export function parseQuestionAction(blockId: unknown, actionId: unknown): { questionId: string; optionId: string } | null {
@@ -598,7 +597,7 @@ export function buildOutboundMessage(q: QitemLike, opts: OutboundMessageOpts): S
throw new HumanMessageShapeError("Extra blocks have no complete accessible fallback. Use mediaRefs for images or author supplemental human detail.");
}
const replyHint = q.ownerNotificationKind === "human-required"
? HUMAN_REQUIRED_REPLY_HINT : null;
? "Reply in this thread after this question to answer it. Earlier and extra messages still reach the agent as conversation." : null;
const fallback = (context: string | null, repeat: string | null = null) => [attr, headline, repeat, context, body, questionParts?.text, replyHint, evidence ? evidence.text : null, attachmentText, opts.reconcileMarker].filter(Boolean).join("\n");
// The gate context line is supplementary: when it alone would push the complete fallback over
// budget, the instance part alone is tried, and the line is left out only when even that
@@ -19,7 +19,6 @@ export function budgetLeft(budget: RecoveryBudget, now: number): boolean {
export class MethodBackoff {
private readonly until = new Map<string, number>();
blocked(method: string, now: number): boolean { return (this.until.get(method) ?? 0) > now; }
waitMs(method: string, now: number): number { return Math.max(0, (this.until.get(method) ?? 0) - now); }
hold(method: string, at: number): void { this.until.set(method, Math.max(at, this.until.get(method) ?? 0)); }
}
@@ -1,243 +0,0 @@
// Edit the posted primary, never re-render an old ask from its mutable queue body.
// Queue receipts are the durable backlog; successful edits alone get an edited receipt.
import type { QueueRepository } from "../../queue-repository.js";
import type { QueueTransition } from "../../queue-transition-log.js";
import { createHash } from "node:crypto";
import { resolveRegisteredHumanAddress } from "../human-registry.js";
import { callWebApi, type FetchImpl, type WebApiResult } from "./slack-api.js";
import { budgetLeft, HISTORY, REPLIES, MethodBackoff, newRecoveryBudget } from "./recovery-budget.js";
import { parsePostedStamp } from "./thread-seat-map.js";
import { TYPED_REPLY_HINT, CHANNEL_REPLY_HINT, HUMAN_REQUIRED_REPLY_HINT, QUESTION_BLOCK_PREFIX, escapeSlackText, redactSecrets } from "./message.js";
const UPDATE = "chat.update";
const EDITED = "slack-ask-settled";
const RATE_LIMIT = "slack-ask-edit-rate-limit";
const STATUS_BLOCK = "or-settled";
const permanentErrors = new Set(["missing_scope", "not_in_channel", "channel_not_found", "is_archived",
"cant_update_message", "invalid_blocks", "message_not_found", "thread_not_found", "original message unavailable"]);
const hints = new Set([TYPED_REPLY_HINT, CHANNEL_REPLY_HINT, HUMAN_REQUIRED_REPLY_HINT]);
const terminal = new Set(["done", "canceled", "denied", "failed", "handed-off"]);
const field = (note: string, key: string) => note.split(/\s+/).find((part) => part.startsWith(`${key}=`))?.slice(key.length + 1);
type Block = { type?: string; block_id?: string; elements?: Array<{ text?: string }>; [key: string]: unknown };
/** Remove only our answer controls, retaining authored content and reconciliation identity. */
function handledMessage(message: { text: string; blocks: Block[] }, status: string) {
let text = message.text;
const previousStatus = message.blocks.find((b) => b.block_id === STATUS_BLOCK)?.elements?.[0]?.text;
if (previousStatus && text.startsWith(previousStatus + "\n")) text = text.slice(previousStatus.length + 1);
const blocks = message.blocks.flatMap((block): Block[] => {
if (block.block_id === STATUS_BLOCK) return [];
if (block.type === "actions" && block.block_id?.startsWith(QUESTION_BLOCK_PREFIX)) {
// The labels remain history after their controls disappear, as in the resolved-notice render.
const buttons = block.elements as unknown as Array<{ text?: { text?: string }; style?: string }>;
return [{ type: "section", text: { type: "mrkdwn", text: buttons.map((b) => `• ${b.text?.text ?? ""}${b.style === "primary" ? " (recommended)" : ""}`).join("\n") } }];
}
const hint = block.type === "context" && block.elements?.length === 1 ? block.elements[0]?.text : undefined;
if (!hint || !hints.has(hint)) return [block];
// This generated hint's last occurrence, never a matching sentence earlier in the brief.
const at = text.lastIndexOf("\n" + hint);
if (at >= 0) text = text.slice(0, at) + text.slice(at + hint.length + 1);
return [];
});
return { text: status + "\n" + text,
blocks: [{ type: "context", block_id: STATUS_BLOCK, elements: [{ type: "mrkdwn", text: status }] }, ...blocks] };
}
function statusLine(t: QueueTransition, label?: string): string {
const what = label ?? (t.ownerNotificationKind === "human-decision-resolved" ? "Answered" : t.state === "done" ? "Done"
: t.state === "canceled" ? "Canceled" : terminal.has(t.state) ? t.state : "Handled");
const note = redactSecrets((t.transitionNote ?? "").replace(/\s+/g, " ")).slice(0, 1000);
return escapeSlackText([`${what}${t.closureReason ? ` · ${t.closureReason}` : ""}`, t.ts, `by ${t.actorSession}`, note].filter(Boolean).join(" · "));
}
export class SlackAskSettlement {
private readonly pending = new Map<string, number>();
private readonly failures = new Map<string, number>();
private readonly unavailable = new Set<string>();
private readonly backoff = new MethodBackoff();
private budget = newRecoveryBudget(Date.now());
private readonly controller = new AbortController();
private unsubscribe?: () => void;
private timer?: ReturnType<typeof setTimeout>;
private running = false;
private stopped = true;
private lastFailure: string | null = null;
constructor(private readonly opts: { queue: QueueRepository; token: string; fetchImpl?: FetchImpl; log: (message: string) => void }) {}
start(): void {
this.stopped = false;
this.unsubscribe = this.opts.queue.subscribeChanges((id) => this.enqueue(id));
for (const id of this.opts.queue.transitionLog.postedHumanAskIds()) {
for (const transition of this.opts.queue.transitionLog.listForQitem(id)) {
const note = transition.transitionNote ?? "";
if (!note.startsWith(RATE_LIMIT + " ")) continue;
const method = field(note, "method");
const until = Number(field(note, "retry_at"));
if (method && Number.isFinite(until)) this.backoff.hold(method, until);
}
this.enqueue(id);
}
}
stop(): void {
this.stopped = true;
this.unsubscribe?.();
if (this.timer) clearTimeout(this.timer);
this.controller.abort();
}
status() { return { pending: this.pending.size, lastFailure: this.lastFailure }; }
private enqueue(id: string): void {
if (this.stopped) return;
if (!this.pending.has(id)) this.pending.set(id, 0);
if (!this.running && !this.timer) this.schedule(0);
}
private schedule(ms: number): void {
if (this.stopped) return;
this.timer = setTimeout(() => { this.timer = undefined; void this.pump(); }, Math.max(0, ms));
this.timer.unref?.();
}
private async pump(): Promise<void> {
this.running = true;
try {
while (!this.stopped && this.pending.size) {
const now = Date.now();
if (now >= this.budget.deadline) this.budget = newRecoveryBudget(now);
if (!budgetLeft(this.budget, now)) { this.schedule(this.budget.deadline - now); return; }
const next = [...this.pending].find(([, at]) => at <= now);
if (!next) { this.schedule(Math.min(...this.pending.values()) - now); return; }
const id = next[0];
const wait = await this.settleOne(id);
if (wait === null) this.pending.delete(id);
else { this.pending.delete(id); this.pending.set(id, Date.now() + wait); }
}
} catch (error) {
this.lastFailure = `settlement retained: ${(error as Error).message}`;
this.opts.log(this.lastFailure);
this.schedule(30_000);
} finally { this.running = false; }
}
private async api(method: string, body: Record<string, unknown>): Promise<WebApiResult> {
this.budget.calls++;
const fetchImpl: FetchImpl = (url, init) => (this.opts.fetchImpl ?? fetch)(url, { ...init,
signal: init?.signal ? AbortSignal.any([init.signal, this.controller.signal]) : this.controller.signal });
return callWebApi(method, this.opts.token, body, fetchImpl, Math.max(1, this.budget.deadline - Date.now()),
method === HISTORY || method === REPLIES ? "get-query" : "json-post");
}
private failed(id: string, key: string, method: string, result: WebApiResult): number {
this.lastFailure = `${id}: ${method} not done (${result.error ?? result.status})`;
this.opts.log(`slack ask settlement retained: ${this.lastFailure}`);
if (permanentErrors.has(result.error ?? "")) { this.unavailable.add(key); return 0; }
const count = (this.failures.get(key) ?? 0) + 1;
this.failures.set(key, count);
const wait = Math.max(Math.min(30_000 * 2 ** Math.min(count - 1, 7), 3_600_000), (result.retryAfterSeconds ?? 0) * 1000);
if (result.status === 429) {
const until = Date.now() + wait;
this.backoff.hold(method, until);
this.opts.queue.update({ qitemId: id, actorSession: "daemon@kernel",
transitionNote: `${RATE_LIMIT} method=${method} retry_at=${until}` });
}
return wait;
}
private desired(id: string, episode: QueueTransition, recipient: string, transitions: QueueTransition[]) {
const item = this.opts.queue.getById(id);
if (!item) return undefined;
const after = transitions.filter((t) => t.transitionId > episode.transitionId && ![
"slack-posted ", "slack-owner-notification-posted ", EDITED + " ", RATE_LIMIT + " ",
].some((prefix) => (t.transitionNote ?? "").startsWith(prefix)));
const make = (t: QueueTransition, label?: string) => {
const status = statusLine(t, label);
return { status, revision: createHash("sha256").update(status).digest("hex").slice(0, 24) };
};
const exit = after.find((t) => terminal.has(t.state) || t.ownerNotificationKind === "human-decision-resolved"
|| (episode.state === "blocked" && t.state !== "blocked"));
const replacement = after.find((t) => t.ownerNotificationKind === "human-required");
if (replacement) return exit && exit.transitionId < replacement.transitionId
? make(exit) : make(replacement, "Asked again");
const rerouted = after.find((t) => (t.transitionNote ?? "").startsWith("fallback-routed:"));
if (rerouted) return make(rerouted, "Handled");
if (terminal.has(item.state)) {
const latest = after.filter((t) => t.state === item.state).at(-1);
return latest ? make(latest) : undefined;
}
const registry = this.opts.queue.humanRegistry();
const samePerson = (address: string | null) => address === recipient || (registry.ok &&
resolveRegisteredHumanAddress(recipient, registry.entities) !== null &&
resolveRegisteredHumanAddress(address, registry.entities) === resolveRegisteredHumanAddress(recipient, registry.entities));
if (episode.state === "pending" && samePerson(item.destinationSession)) {
const reopened = after.filter((t) => (t.transitionNote ?? "").startsWith("reopen acknowledged:")).at(-1);
if (reopened) {
const answered = this.opts.queue.getHumanAnswer(id, recipient) ?? this.opts.queue.committedAnswerFromAnother(id, recipient);
return make(reopened, answered ? "Open again · Earlier answer retained" : "Open again · Reply in this thread to answer");
}
}
// A reopened work row does not reopen the older park it already left. A new human park has its own episode above.
if (exit) return make(exit);
if (episode.state === "blocked" && item.state === "blocked" && registry.ok && !samePerson(item.blockedOn)) {
const moved = after.at(-1);
if (moved) return make(moved, "Handled");
}
return undefined;
}
/** null = no remaining edits; 0 = next primary; positive = retained retry. */
private async settleOne(id: string): Promise<number | null> {
if (!this.opts.queue.db.prepare("SELECT 1 FROM queue_items WHERE qitem_id = ?").get(id)) return null;
const transitions = this.opts.queue.transitionLog.listForQitem(id);
for (const receipt of transitions) {
const note = receipt.transitionNote ?? "";
if (!note.startsWith("slack-owner-notification-posted ") || field(note, "kind") !== "human-required" || field(note, "digest")) continue;
const key = field(note, "notification_key");
const episode = transitions.find((t) => `${id}:${t.transitionId}` === key);
if (!episode) continue;
const messageTs = field(note, "message_ts");
const stamp = transitions.filter((t) => t.transitionId < receipt.transitionId)
.map((t) => parsePostedStamp(t.transitionNote ?? ""))
.filter((s) => s !== null && s.messageTs === messageTs && s.origin === "posted").at(-1);
if (!stamp) continue; // No authoritative channel/timestamp: never guess a replacement post.
const edited = `${EDITED} notification_key=${key} channel=${stamp.channel} message_ts=${messageTs}`;
if (this.unavailable.has(edited)) continue;
const desired = this.desired(id, episode, stamp.human, transitions);
if (!desired) continue;
const lastAck = transitions.filter((t) => t.transitionNote === edited || t.transitionNote?.startsWith(edited + " ")).at(-1);
if (lastAck && field(lastAck.transitionNote!, "revision") === desired.revision) continue;
if (this.budget.calls + 2 > 4) return Math.max(1, this.budget.deadline - Date.now());
const method = stamp.messageTs === stamp.threadTs ? HISTORY : REPLIES;
const held = Math.max(this.backoff.waitMs(method, Date.now()), this.backoff.waitMs(UPDATE, Date.now()));
if (held) return held;
// Exact original message, including on an upgrade with only the old posted stamps.
const read = await this.api(method, { channel: stamp.channel, limit: 1, inclusive: true,
...(method === HISTORY ? { latest: messageTs } : { ts: stamp.threadTs, oldest: messageTs }) });
if (this.stopped) return null;
if (!read.ok) return this.failed(id, edited, method, read);
this.budget.entries++;
const message = (Array.isArray(read.json.messages) ? read.json.messages : []).find((m) => m?.ts === messageTs);
if (!message || typeof message.text !== "string" || !Array.isArray(message.blocks)) {
return this.failed(id, edited, method, { ...read, error: "original message unavailable" });
}
if (!budgetLeft(this.budget, Date.now())) return 0;
// A queue change during the read may add information; derive again before writing.
const current = this.desired(id, episode, stamp.human, this.opts.queue.transitionLog.listForQitem(id));
if (!current || this.stopped) return null;
const update = await this.api(UPDATE, { channel: stamp.channel, ts: messageTs, ...handledMessage(message, current.status) });
if (this.stopped) return null;
if (!update.ok) return this.failed(id, edited, UPDATE, update);
if (update.json.ts !== messageTs || update.json.channel !== stamp.channel) {
return this.failed(id, edited, UPDATE, { ...update, error: "update acknowledgement did not name the requested message" });
}
const afterUpdate = this.desired(id, episode, stamp.human, this.opts.queue.transitionLog.listForQitem(id));
if (afterUpdate?.revision !== current.revision) return 0; // The write landed for an obsolete state; reconcile it again.
this.failures.delete(edited);
this.opts.queue.update({ qitemId: id, actorSession: "daemon@kernel", transitionNote: `${edited} revision=${current.revision}` });
return 0;
}
return null;
}
}
@@ -147,23 +147,10 @@ export async function callWebApi(
...(Number.isFinite(retryAfter) && retryAfter > 0 ? { retryAfterSeconds: retryAfter } : {}), error: ok ? undefined : String(json.error ?? `http ${res.status}`) };
});
} catch (e) {
return { ok: false, status: 0, grantedScopes: [], json: {}, error: `web-api transport: ${withoutToken((e as Error).message, token)}` };
return { ok: false, status: 0, grantedScopes: [], json: {}, error: `web-api transport: ${(e as Error).message}` };
}
}
/** A transport error's message can quote the request's Authorization header (Node's fetch does for an invalid value),
* so the token, and each line of it, is removed before the message goes anywhere, as sent and as trimmed (Node trims
* the value before quoting it). A line shorter than 6 characters is left: removing it everywhere would garble the rest
* of the message. One pass, so a marker is never rescanned. */
function withoutToken(message: string, token: string): string {
const lines = token.split(/[\r\n]+/).flatMap((line) => [line, line.trim()]).filter((part) => part.length >= 6);
const parts = [...new Set([token, token.trim(), ...lines])].filter((part) => part.length > 0)
.sort((a, b) => b.length - a.length);
if (parts.length === 0) return message;
const pattern = new RegExp(parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), "g");
return message.replace(pattern, "[redacted]");
}
/** Item 5: read the ACTUAL granted scopes from the response header (configured != granted). */
export async function getGrantedScopes(
token: string,
@@ -264,7 +251,7 @@ export async function downloadPrivateFile(
return { ok: true as const, bytes };
});
} catch (e) {
return { ok: false, error: withoutToken((e as Error).message, token) || "download failed" };
return { ok: false, error: (e as Error).message || "download failed" };
}
}
@@ -302,49 +289,6 @@ export async function verifyChannelMembership(
return { ok: true, isMember: ch.is_member === true, name: ch.name };
}
/** One page of a directory read: only the projected fields, never Slack's whole object; on failure, Slack's own result. */
export type DirectoryPage<T> = { ok: true; items: T[]; nextCursor: string | null } | { ok: false; result: WebApiResult };
export interface DirectoryChannel { id: string; name: string; isMember: boolean }
export interface DirectoryUser { id: string; name: string; realName: string | null; displayName: string | null; isBot: boolean; deleted: boolean }
function directoryPage<T>(r: WebApiResult, key: string, project: (raw: Record<string, unknown>) => T): DirectoryPage<T> {
if (!r.ok) return { ok: false, result: r };
const raw = Array.isArray(r.json[key]) ? (r.json[key] as Record<string, unknown>[]) : [];
const next = (r.json.response_metadata as { next_cursor?: unknown } | undefined)?.next_cursor;
return { ok: true, items: raw.map(project), nextCursor: typeof next === "string" && next ? next : null };
}
/** One page of the workspace's public, unarchived channels (conversations.list; channels:read). */
export async function listPublicChannels(
token: string,
page: { cursor?: string; limit: number },
fetchImpl: FetchImpl = defaultFetch,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<DirectoryPage<DirectoryChannel>> {
const r = await callWebApi("conversations.list", token,
{ types: "public_channel", exclude_archived: true, limit: page.limit, cursor: page.cursor }, fetchImpl, timeoutMs, "get-query");
return directoryPage(r, "channels", (c) => ({ id: String(c.id), name: String(c.name ?? ""), isMember: c.is_member === true }));
}
/** One page of the workspace's people (users.list; users:read), without email or other profile fields. */
export async function listUsers(
token: string,
page: { cursor?: string; limit: number },
fetchImpl: FetchImpl = defaultFetch,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<DirectoryPage<DirectoryUser>> {
const r = await callWebApi("users.list", token, { limit: page.limit, cursor: page.cursor }, fetchImpl, timeoutMs, "get-query");
return directoryPage(r, "members", (u) => {
const profile = (u.profile ?? {}) as { display_name?: unknown };
return {
id: String(u.id), name: String(u.name ?? ""),
realName: typeof u.real_name === "string" ? u.real_name : null,
displayName: typeof profile.display_name === "string" && profile.display_name ? profile.display_name : null,
isBot: u.is_bot === true, deleted: u.deleted === true,
};
});
}
/** Inbound Socket Mode: open a WebSocket URL via apps.connections.open (app-level xapp token). */
export async function openSocketConnection(
appToken: string,
@@ -26,7 +26,6 @@ import { resolveSecret } from "./secrets.js";
import { SeenStore, DeadLetterStore, InboundReceiptStore, slackMicros } from "./state-store.js";
import { makeQueuePorts } from "./queue-access.js";
import { SlackOutboundDriver, OUTBOUND_OP, type OutboundPostPayload } from "./outbound-driver.js";
import { SlackAskSettlement } from "./settled-asks.js";
import { abortableSleep, subsystemSlackDeliver } from "./slack-delivery.js";
import { InboundRouter, SLACK_THREAD_ROOT_TAG, handleEnvelope, type SocketEnvelope, type SlackEvent, type SlackBlockActions, type InboundFilePort, type InboundFileResult, type StoredInboundFile, type FailedInboundFile } from "./inbound.js";
import { makeInboundSenderResolver, type RegistrySurface } from "./inbound-admission.js";
@@ -1007,11 +1006,6 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
// hermeticity rule, same as every monitor in the supervision tree.
const stops: Array<() => void> = [];
const starts: Array<() => void> = [];
const settlement = outboundReady ? new SlackAskSettlement({ queue: opts.queueRepo, token: bot!, fetchImpl: opts.fetchImpl, log }) : null;
if (settlement) {
starts.push(() => settlement.start());
stops.push(() => settlement.stop());
}
let inboundHandle: SocketInboundHandle | undefined;
let recovery: SlackRecovery | undefined;
// Recovery covers the channels this machine owns and the threads tracked in them. The configured
@@ -1206,7 +1200,6 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
platform: "slack", configurationDigest: channelStateDigest(cfg),
outboundReady,
inboundReady,
...(settlement ? { settlement: settlement.status() } : {}),
inboundRouting,
recovery: recovery?.status() ?? { state: "unavailable", reason: "inbound-not-configured" },
inbound: inboundHandle?.status() ?? { state: inboundReady ? "not-started" : "not-configured", generation: 0, reconnects: 0 },
@@ -59,8 +59,7 @@ export interface KernelBootStatus {
* terminal/skipped states. */
firstUnreadySince: string | null;
/** Filename of the picked variant (rig.yaml / rig-claude-only.yaml /
* rig-codex-only.yaml), retained from automatic boot across daemon restarts.
* null when no selection was recorded. This is origin metadata, not the current runtime mix. */
* rig-codex-only.yaml). null when no variant was selected. */
variant: string | null;
/** Human-readable detail for auth_blocked / spec_missing /
* bootstrap_failed / degraded states. null otherwise. */
@@ -106,11 +105,10 @@ export class KernelBootTracker {
/** Mark the tracker as having intentionally not booted (--no-kernel,
* already-managed short-circuit, VITEST auto-skip). Terminal state;
* no degraded timer. */
setSkipped(detail: string, variant: string | null = null): void {
setSkipped(detail: string): void {
this.cancelTimer();
this.state = "skipped";
this.detail = detail;
this.variant = variant;
this.firstUnreadySince = null;
}
+3 -7
View File
@@ -85,10 +85,6 @@ export async function bootKernelIfNeeded(deps: KernelBootDeps): Promise<KernelBo
degradedTimeoutMs: deps.degradedTimeoutMs,
});
const kernels = deps.rigRepo.findUnarchivedRigsByName("kernel");
const recordedVariant = kernels.length === 1
? deps.rigRepo.getKernelVariantFromBootstrap(kernels[0]!.id, deps.specsDir) : null;
// 1. --no-kernel opt-out (CLI flag projected via OPENRIG_NO_KERNEL).
// VITEST env additionally short-circuits the live runtime probe
// path so daemon-composition tests don't block 5s+ on shelled-out
@@ -102,7 +98,7 @@ export async function bootKernelIfNeeded(deps: KernelBootDeps): Promise<KernelBo
if (process.env["OPENRIG_NO_KERNEL"] === "1" || (process.env["VITEST"] === "true" && !probeInjected)) {
const reason = process.env["OPENRIG_NO_KERNEL"] === "1" ? "OPENRIG_NO_KERNEL=1" : "VITEST=true";
log("info", `kernel-boot: ${reason} — skipping kernel auto-boot`);
tracker.setSkipped(reason, recordedVariant);
tracker.setSkipped(reason);
return tracker;
}
@@ -115,14 +111,14 @@ export async function bootKernelIfNeeded(deps: KernelBootDeps): Promise<KernelBo
// Startup reconcile found none of its seats running and marked them detached: a reboot or
// crash took it down, not `rig down` (which marks them exited). Bring it back as it was.
log("info", "kernel-boot: kernel rig was lost (all seats detached); restoring it");
tracker.startBooting(recordedVariant, deps.restoreLostKernel(managed.rigId), managed.seats, { existingRestore: true });
tracker.startBooting(null, deps.restoreLostKernel(managed.rigId), managed.seats, { existingRestore: true });
return tracker;
}
log("info", `kernel-boot: kernel rig already managed (${managed.kind}); skipping builtin boot`);
// A stale kernel stays down on purpose; say why and how to bring it back (`rig start` prints it).
tracker.setSkipped(managed.kind === "stale"
? "kernel rig already managed; it was already down before this daemon start, so it was not restored. Bring it back with: rig up kernel --existing"
: "kernel rig already managed", recordedVariant);
: "kernel rig already managed");
if (managed.kind === "live") tracker.observeManagedKernel();
return tracker;
}
@@ -1,129 +0,0 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import nodePath from "node:path";
import type Database from "better-sqlite3";
import { frameManagedBlockBody, managedBlockStanding, mergeManagedBlock, type ManagedBlockMergeFsOps, type ManagedBlockStanding } from "./managed-blocks.js";
/** #2456: OpenRig's record of what it last wrote in each managed guidance block, so a later start, exact resume, handover
* or runtime switch can tell a block it wrote (refreshed when its source changes) from a block a person edited (kept). */
export interface ManagedBlockRecords {
/** The hash of what OpenRig last wrote in this block of this file, or undefined when there's no record. Throws when the
* record can't be read, which is unknown, never "no record". */
get(file: string, blockId: string): string | undefined;
/** Record that OpenRig's block in this file now holds `body`. Throws when the record can't be saved. */
set(file: string, blockId: string, body: string): void;
}
/** The hash a record keeps: sha256 of the body without the writer's framing, so framing never reads as an edit. */
export function managedBlockBodyHash(body: string): string {
return createHash("sha256").update(frameManagedBlockBody(body)).digest("hex");
}
/** A guidance file's key: the file's own real path, so every link to one file names the same file; a file that doesn't
* exist yet is keyed by its folder's real path and its name. */
export function guidanceFileKey(file: string): string {
const resolved = nodePath.resolve(file);
try { return fs.realpathSync.native(resolved); } catch { /* not there yet */ }
try { return nodePath.join(fs.realpathSync.native(nodePath.dirname(resolved)), nodePath.basename(resolved)); }
catch { return resolved; }
}
/** Whether a database error is a schema without the records table (an older test or tool schema), which has no records. */
function noRecordsTable(err: unknown): boolean {
return err instanceof Error && err.message.includes("no such table: managed_block_writes");
}
/** The records kept in the daemon database (`managed_block_writes`). A database without that table has no records: reads
* find none and writes record nothing, so its blocks read as unrecorded. Any other failure is thrown, so a caller can
* name it instead of reading it as no record. */
export function managedBlockRecords(db: Pick<Database.Database, "prepare">, now: () => Date = () => new Date()): ManagedBlockRecords {
return {
get(file, blockId) {
try {
const row = db.prepare("SELECT body_sha256 FROM managed_block_writes WHERE path_key = ? AND block_id = ?")
.get(guidanceFileKey(file), blockId) as { body_sha256: string } | undefined;
return row?.body_sha256;
} catch (err) {
if (noRecordsTable(err)) return undefined;
throw err;
}
},
set(file, blockId, body) {
try {
db.prepare(`INSERT INTO managed_block_writes (path_key, block_id, body_sha256, written_at) VALUES (?, ?, ?, ?)
ON CONFLICT(path_key, block_id) DO UPDATE SET body_sha256 = excluded.body_sha256, written_at = excluded.written_at`)
.run(guidanceFileKey(file), blockId, managedBlockBodyHash(body), now().toISOString());
} catch (err) {
if (!noRecordsTable(err)) throw err;
}
},
};
}
/** Record a block just written. A record that can't be saved is named in `notes`; the write itself still counts. */
export function recordManagedBlockWrite(records: ManagedBlockRecords, targetPath: string, blockId: string, body: string, notes?: string[]): void {
try { records.set(targetPath, blockId, body); }
catch { notes?.push(`guidance_block_record_not_saved: ${targetPath}: ${blockId} was written, but its write record wasn't saved, so a later edit may read as unrecorded`); }
}
/** The note for a block left as it is, named for the seat and the person. */
export function keptGuidanceNote(standing: "edited" | "unrecorded", targetPath: string, blockId: string, sourcePath: string): string {
return standing === "edited"
? `guidance_block_edited: ${targetPath}: ${blockId} was edited by hand since OpenRig wrote it, so it wasn't refreshed from ${sourcePath}`
: `guidance_block_may_be_stale: ${targetPath}: ${blockId} differs from ${sourcePath} and OpenRig has no record of writing it, so it was left as it is`;
}
/** The note for a block a start replaced although OpenRig had no record of writing it. */
export function refreshedUnrecordedGuidanceNote(targetPath: string, blockId: string, sourcePath: string): string {
return `guidance_block_refreshed_unrecorded: ${targetPath}: ${blockId} was replaced from ${sourcePath}; OpenRig had no record of writing it, as for every block written before records were kept`;
}
/** #2456: what to do with one block, from the file's text, the source's text and the record:
* - write it when it's absent (unless `onlyIfPresent`), current, OpenRig's own last write, or (`unrecorded: "refresh"`,
* a start, as starts always replaced) different with no record, which is named;
* - keep it, named, when a person edited it since OpenRig wrote it, or (`"keep"`, exact resume and handover) when it
* differs with no record;
* - keep it, named, when it differs and its record can't be read (`unknown`): that's never read as no record. */
export function decideManagedBlock(content: string, blockId: string, source: string, records: ManagedBlockRecords, targetPath: string,
opts: { unrecorded: "keep" | "refresh"; onlyIfPresent?: boolean; sourcePath?: string }): { standing: ManagedBlockStanding | "unknown"; write: boolean; note?: string } {
let recorded: string | undefined;
let unreadable = false;
try { recorded = records.get(targetPath, blockId); } catch { unreadable = true; }
const standing = managedBlockStanding(content, blockId, source, recorded, managedBlockBodyHash);
const named = opts.sourcePath ?? "its source";
if (standing === "absent") return { standing, write: !opts.onlyIfPresent };
if (unreadable && standing === "unrecorded") {
return { standing: "unknown", write: false,
note: `guidance_block_record_unreadable: ${targetPath}: ${blockId} wasn't refreshed from ${named} because OpenRig couldn't read its write record` };
}
if (standing === "edited" || (standing === "unrecorded" && opts.unrecorded === "keep")) {
return { standing, write: false, note: keptGuidanceNote(standing, targetPath, blockId, named) };
}
if (standing === "unrecorded") return { standing, write: true, note: refreshedUnrecordedGuidanceNote(targetPath, blockId, named) };
return { standing, write: true };
}
/** #2456: the one write of a managed guidance block that every adapter's guidance merge goes through. Without `records` it's
* the plain merge, as before. With them, `decideManagedBlock` decides; a kept block is named in `warnings` and nothing is
* written. A written block is recorded at once, so a later failure in the same start can't lose its record. Returns
* whether the block was written. */
export function writeManagedGuidanceBlock(files: ManagedBlockMergeFsOps, targetPath: string, blockId: string, content: string, opts: {
records?: ManagedBlockRecords; unrecorded?: "keep" | "refresh"; onlyIfPresent?: boolean; sourcePath?: string;
warnings?: string[]; replaceBlockIds?: string[];
}): boolean {
const merge = () => mergeManagedBlock(files, targetPath, blockId, content, { warnings: opts.warnings, replaceBlockIds: opts.replaceBlockIds ?? [] });
// Without records, the plain merge as before; a refresh-only call (`onlyIfPresent`) then writes nothing.
if (!opts.records) {
if (opts.onlyIfPresent) return false;
merge();
return true;
}
const existing = files.exists(targetPath) ? files.readFile(targetPath) : "";
const decision = decideManagedBlock(existing, blockId, content, opts.records, targetPath,
{ unrecorded: opts.unrecorded ?? "refresh", onlyIfPresent: opts.onlyIfPresent, sourcePath: opts.sourcePath });
if (decision.note) opts.warnings?.push(decision.note);
if (!decision.write) return false;
merge();
recordManagedBlockWrite(opts.records, targetPath, blockId, content, opts.warnings);
return true;
}
+2 -31
View File
@@ -107,40 +107,11 @@ function managedBlockPattern(blockId: string): RegExp {
return new RegExp(`${escapeRegex(MANAGED_BLOCK_START(blockId))}([\\s\\S]*?)${escapeRegex(MANAGED_BLOCK_END(blockId))}`, "g");
}
/** A block body without the writer's own framing: CRLF line endings become LF, and newlines at either end go. */
export function frameManagedBlockBody(text: string): string {
return text.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, "");
}
/** Whether a block's body still holds `written`: the same bytes, ignoring only the writer's own framing (newlines at either
* end, and CRLF line endings). A person's spaces count, so an indented line is an edit. */
export function managedBlockBodyMatches(body: string, written: string): boolean {
return frameManagedBlockBody(body) === frameManagedBlockBody(written);
}
/** The bodies of every `blockId` block in `content`, in the current or the legacy marker form. */
export function managedBlockBodies(content: string, blockId: string): string[] {
const legacy = new RegExp(`${escapeRegex(LEGACY_BLOCK_START(blockId))}([\\s\\S]*?)${escapeRegex(LEGACY_BLOCK_END(blockId))}`, "g");
return [...content.matchAll(managedBlockPattern(blockId)), ...content.matchAll(legacy)].map((m) => m[1]!);
}
/** #2456: how a block already in a guidance file stands against its current source and OpenRig's record of what it last
* wrote there (a hash of the framed body, `managedBlockBodyHash`):
* - `absent`: the file holds no such block;
* - `current`: it holds the source's text;
* - `stale`: it holds what OpenRig last wrote, and the source has changed since, so it's OpenRig's to refresh;
* - `edited`: it holds something else, which someone wrote by hand;
* - `unrecorded`: it differs from the source and OpenRig has no record of writing it, so which of the two is unknown.
* A block written more than once counts as edited when any copy is. */
export type ManagedBlockStanding = "absent" | "current" | "stale" | "edited" | "unrecorded";
export function managedBlockStanding(content: string, blockId: string, source: string, recordedHash: string | undefined,
hash: (body: string) => string): ManagedBlockStanding {
const bodies = managedBlockBodies(content, blockId);
if (bodies.length === 0) return "absent";
if (bodies.every((body) => managedBlockBodyMatches(body, source))) return "current";
if (recordedHash === undefined) return "unrecorded";
return bodies.every((body) => hash(body) === recordedHash) ? "stale" : "edited";
const frame = (text: string) => text.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, "");
return frame(body) === frame(written);
}
/** Whether `content` holds a `blockId` block whose body is anything but `written`: someone edited it. */
@@ -39,7 +39,6 @@
// closure_target+handed_off_to=<destination>;
// creates new qitem at destination (4-step canonical)
import { createHash } from "node:crypto";
import type Database from "better-sqlite3";
import type { EventBus } from "../event-bus.js";
import { isTypedGateBlocker, UNATTRIBUTED_ACTOR, type QueueRepository, type QueueItem } from "../queue-repository.js";
@@ -101,9 +100,6 @@ export interface MissionControlActionInput {
* the default person or the claim; the audit notes say whose ask it was. Only a request with no transport identity
* carries it. A surface that knows its person sends them without it. */
authority?: "primary-user";
/** #2446, opt-in: the `decisionRevision` the caller read. A settling act then runs only on that decision; omitted, the
* act and its result are exactly as before. An `operationId` in `auditNotes` lets a retry be answered from the record. */
expectedRevision?: string;
/**
* For handoff: opt-in best-effort wake. Default true (PL-004 Phase A R1
* pattern: durable + waking by default; operators opt out for cold queues).
@@ -123,12 +119,6 @@ export interface MissionControlActionResult {
auditedAt: string;
/** For `answer`: recorded with questions still open, or answered (returned to the asker, and the ask closed). */
answer?: { outcome: "recorded" | "answered"; unanswered: string[]; message?: string };
/** With expectedRevision only: the decision's revision as saved. */
decisionRevision?: string;
/** With expectedRevision only: this retry is the caller's own committed operation, answered from its record. */
reconciled?: true;
/** With expectedRevision on a verb that doesn't settle a decision: it wasn't checked, and the act ran as without it. */
revisionNote?: string;
}
interface WriteContractDeps {
@@ -160,41 +150,6 @@ export class MissionControlWriteContract {
* notify subscribers + opt-in transport wake (handoff only).
*/
async act(input: MissionControlActionInput): Promise<MissionControlActionResult> {
if (input.expectedRevision === undefined) return this.actOn(input);
// Only the settling verbs compare it. Another verb acts as today, and says the revision wasn't checked.
if (!SETTLING_VERBS.has(input.verb)) {
return { ...await this.actOn(input), revisionNote: `expectedRevision isn't checked for ${input.verb}; only answer, resolve, approve and deny compare it.` };
}
// #2446: the act runs only on the decision its caller read. No await separates this check from the act's first
// write, so no other request in this daemon changes the decision in between. The one path whose first write follows
// an await, a typed answer to a runner's permission ask, compares again before it (answerAsk).
const operation = typeof input.auditNotes?.operationId === "string" ? operationFingerprint(input) : null;
const item = this.queueRepo.getById(input.qitemId);
const current = item ? this.queueRepo.decisionRevision(item) : null;
if (item && current !== input.expectedRevision) {
const last = this.actionLog.listForQitem(input.qitemId, 1)[0];
// The caller's own committed operation retried after an uncertain result: answered from its record, nothing written.
if (operation && last?.auditNotes?.operation === operation) {
return { actionId: last.actionId, verb: input.verb, qitemId: input.qitemId, closedQitem: item, createdQitemId: null,
notifyAttempted: false, notifyResult: null, auditedAt: last.actedAt, reconciled: true, decisionRevision: current! };
}
throw this.revisionChanged(item, current!);
}
const result = await this.actOn(operation ? { ...input, auditNotes: { ...input.auditNotes, operation } } : input);
const saved = this.queueRepo.getById(input.qitemId);
return saved ? { ...result, decisionRevision: this.queueRepo.decisionRevision(saved) } : result;
}
/** A guarded act's decision changed since its revision was read: nothing was written, and the caller gets what's saved. */
private revisionChanged(item: QueueItem, current: string): MissionControlWriteContractError {
const last = this.actionLog.listForQitem(item.qitemId, 1)[0];
return new MissionControlWriteContractError("decision_revision_changed",
`qitem ${item.qitemId} changed since this revision was read, so nothing was written. Read it again for the current decision.`,
{ qitemId: item.qitemId, currentRevision: current, state: item.state, humanAnswers: item.humanAnswers ?? null,
lastAction: last ? { actionId: last.actionId, verb: last.actionVerb, actorSession: last.actorSession, actedAt: last.actedAt, auditNotes: last.auditNotes } : null });
}
private async actOn(input: MissionControlActionInput): Promise<MissionControlActionResult> {
if (input.verb === "annotate") {
return this.annotateOnly(input);
}
@@ -273,7 +228,7 @@ export class MissionControlWriteContract {
// P34: stage the successor's WAKE INTENT inside this transaction, so the
// close and the durable wake commit as ONE act or NONE. The pane write
// itself stays post-commit (reversed-never).
this.queueRepo.stageHandoffWake(
this.queueRepo.stageWakeIntent(
created.qitemId,
input.actorSession,
created.destinationSession,
@@ -618,7 +573,6 @@ export class MissionControlWriteContract {
const resume = !chose && !words && questions.length > 0 && unansweredQuestions(questions, ask.humanAnswers ?? {}).length === 0;
const answersReply = (answers: HumanAnswers) => `${formatHumanAnswers(questions, answers).join("\n")}\n\n---\nAnswers (question id → option id): ${JSON.stringify(answers)}\nIn reply to: ${input.qitemId} (its humanAnswers field holds the same)`;
let reply: string;
let wrote = false; // whether this call changed the ask before the return below awaits
if (chose) {
const recorded = this.queueRepo.recordHumanAnswer({ qitemId: input.qitemId, actorSession: input.actorSession,
questionId: input.questionId ?? "", optionId: input.optionId ?? "", primaryUser });
@@ -626,8 +580,6 @@ export class MissionControlWriteContract {
throw new MissionControlWriteContractError("answer_not_recorded", `The answer wasn't recorded: ${recorded.reason}.`,
{ qitemId: input.qitemId, reason: recorded.reason });
}
// An already-complete set (an interrupted return) comes back without a write.
wrote = JSON.stringify(recorded.answers) !== JSON.stringify(ask.humanAnswers ?? {});
const [just] = formatHumanAnswers(recorded.questions.filter((q) => q.id === input.questionId), recorded.answers);
if (!recorded.complete) {
return this.recordAnswer(input, before, just ?? `answered ${input.questionId}`, {
@@ -672,15 +624,6 @@ export class MissionControlWriteContract {
const decision = chose || resume
? formatHumanAnswers(questions, this.queueRepo.getById(input.qitemId)?.humanAnswers ?? {}).join("; ")
: returned.qitemId === input.qitemId ? words : humanAnswerText(returned.body);
// #2446: on a runner's permission ask (no reply row), a call that changed nothing before the await above (typed words,
// or a choice whose set was already complete) still has its first write ahead, and the await let other requests in, so
// a guarded answer compares its revision again here, with no await before the close. A call that wrote was compared
// before that write.
if (input.expectedRevision !== undefined && replyId === input.qitemId && !wrote) {
const now = this.queueRepo.getById(input.qitemId);
const revision = now ? this.queueRepo.decisionRevision(now) : null;
if (now && revision !== input.expectedRevision) throw this.revisionChanged(now, revision!);
}
if (!this.queueRepo.resolveDirectHumanReply({ qitemId: input.qitemId, actorSession: input.actorSession, decision, primaryUser })
&& this.queueRepo.getById(input.qitemId)?.state !== "done") {
throw new MissionControlWriteContractError("answer_not_closed",
@@ -777,14 +720,6 @@ interface ClosureMapping {
blockedOn?: string;
}
/** One logical operation (#2446): the caller's operationId with the verb, who sent it and what it carries, so a retry
* matches only itself, never another verb or payload sent under the same ID. */
function operationFingerprint(input: MissionControlActionInput): string {
const sent = [input.auditNotes?.operationId, input.verb, input.actorSession, input.authority ?? null, input.questionId ?? null,
input.optionId ?? null, input.decision ?? null, input.reason ?? null, input.destinationSession ?? null, input.body ?? null, input.annotation ?? null];
return createHash("sha256").update(JSON.stringify(sent)).digest("hex").slice(0, 24);
}
function verbToClosure(input: MissionControlActionInput): ClosureMapping {
switch (input.verb) {
case "approve":
@@ -1,6 +1,5 @@
import { isShellForeground } from "./shell-classifier.js";
import { EMPTY_CLAUDE_MCP_CONFIG } from "./claude-mcp.js";
import { isClaudePluginChannel } from "./claude-plugin-channels.js";
import { runAsyncSite } from "./sync-site-wrap.js";
import { readNativeExecutablePaths } from "./native-process-executable.js";
import { readNativeProcessArguments } from "./native-process-arguments.js";
@@ -285,23 +284,6 @@ export function claudePathBounds(path: string): boolean {
return parts.length > 0 && parts.join(" ") === path.trim() && !parts[0]!.startsWith("-") && !parts.some(opensSpan);
}
// #2044: a seat's plugin channels, read the same way by both selectors. Claude's `--channels` is variadic: it takes
// every following value up to the next option, and OpenRig passes one or more `plugin:<name>@<marketplace>` entries
// there. Every value must be such an entry, so a bare word or a `server:` entry after the option is no proof;
// `--channels=<entry>` carries one entry. Returns the index of the last value; null when there is none or one isn't an
// entry; undefined when the argument isn't the option.
function claudeChannelsValueEnd(args: string[], index: number): number | null | undefined {
const arg = args[index]!;
if (arg.startsWith("--channels=")) return isClaudePluginChannel(arg.slice("--channels=".length)) ? index : null;
if (arg !== "--channels") return undefined;
let end = index;
while (end + 1 < args.length && !args[end + 1]!.startsWith("-")) {
if (!isClaudePluginChannel(args[end + 1])) return null;
end += 1;
}
return end > index ? end : null;
}
/** The conversation a parsed Claude argv names. A fork's --resume names its parent, so with
* --fork-session only the fork's own --session-id names it, and both must be present. Without
* --fork-session one identity names it and two prove nothing. undefined: no positive proof. */
@@ -341,9 +323,6 @@ function claudeSessionToken(args: string[]): string | null {
const additionEnd = claudeAdditionValueEnd(args, index);
if (additionEnd === null) return null;
if (additionEnd !== undefined) { index = additionEnd; continue; }
const channelsEnd = claudeChannelsValueEnd(args, index);
if (channelsEnd === null) return null;
if (channelsEnd !== undefined) { index = channelsEnd; continue; }
if (arg === "--fork-session") { fork = true; continue; }
const identity = arg.match(/^--(session-id|resume)(?:=(.*))?$/);
if (!identity) return null; // Unknown argv is not positive identity proof.
@@ -387,9 +366,6 @@ function claudeSessionIdentity(args: string[]): string | null | { unparsed: true
const additionEnd = claudeAdditionValueEnd(args, index);
if (additionEnd === null) return unparsed;
if (additionEnd !== undefined) { index = additionEnd; continue; }
const channelsEnd = claudeChannelsValueEnd(args, index);
if (channelsEnd === null) return unparsed;
if (channelsEnd !== undefined) { index = channelsEnd; continue; }
if (arg === "--fork-session") { fork = true; continue; }
const identity = arg.match(/^--(session-id|resume)(?:=(.*))?$/);
if (!identity) return unparsed; // Unknown argv is not positive identity proof.
+1 -22
View File
@@ -848,28 +848,9 @@ function buildInventoryEntry(
// never observed; carries evidence on mismatch/missing.
identityVerdict,
heldReason: deriveHeldReason(db, row.rig_id, row.node_id, sessionStatus),
...(row.handover_at ? startedWithoutRequired(db, row.node_id, row.session_id) : {}),
};
}
/** #2456: the required startup files the seat's current occupant started without, as its handover recorded them. Only
* the handover whose successor session is still the current one counts, so a later launch or handover clears it. Read
* only for a seat that has had a handover. */
function startedWithoutRequired(db: Database.Database, nodeId: string, sessionId: string | null): { startedWithoutRequired?: string[] } {
if (!sessionId) return {};
const row = db.prepare(
"SELECT payload FROM events WHERE node_id = ? AND type = 'seat.handover_completed' ORDER BY seq DESC LIMIT 1"
).get(nodeId) as { payload: string } | undefined;
if (!row) return {};
try {
const event = JSON.parse(row.payload) as { startedWithoutRequired?: unknown; successorSessionId?: unknown };
const files = Array.isArray(event.startedWithoutRequired) ? event.startedWithoutRequired.filter((f): f is string => typeof f === "string") : [];
return event.successorSessionId === sessionId && files.length > 0 ? { startedWithoutRequired: files } : {};
} catch {
return {};
}
}
/** FS-1 W1.2 — all-rigs batched form of `readRigWorkspaceJson`: one query,
* `rigId → WorkspaceSpec`. Rigs with no/malformed workspace are absent (callers
* default to null — byte-identical to `readRigWorkspaceJson`'s null return). */
@@ -1236,7 +1217,6 @@ const NEEDS_INPUT_RUNGS: readonly EvidenceRungId[] = ["needs-input-chrome", "lif
* and the seat row must not disagree about the same seat.
* - a held seat (`heldReason` present)
* - a recorded startup error (`latestError` present)
* - an occupant a handover started without a required startup file (`startedWithoutRequired`, #2456)
*/
export function seatNeedsAttention(
entry: NodeInventoryEntry,
@@ -1248,8 +1228,7 @@ export function seatNeedsAttention(
|| entry.startupStatus === "failed"
|| (arbitratedNeedsInput ?? activity?.state === "needs_input")
|| entry.heldReason != null
|| entry.latestError != null
|| (entry.startedWithoutRequired?.length ?? 0) > 0;
|| entry.latestError != null;
}
/**
@@ -14,8 +14,6 @@ import type Database from "better-sqlite3";
* select it. Single source of truth for the drain query only.
*/
export const WAKE_INTENT_PREFIX = "wake-intent-";
/** The sender a watchdog's reminders carry; their audit pointer is the job. */
const WATCHDOG_SENDER = "watchdog@system";
// Outcomes retain unresolved uncertainty. Wake recovery can claim failed/indeterminate
// work again; it never steals a live sending claim or interprets unknown as unsent.
@@ -219,14 +217,6 @@ export class OutboxHandler {
} else if (precommitted) {
throw new OutboxHandlerError("outbox_not_found", "Precommitted delivery is missing; no replacement custody is created.");
}
// One held reminder per watchdog job: a newer reminder retires the one held before it, so a held seat's quota isn't
// filled by repeats of one job (#2430).
if (!existing && !precommitted && input.senderSession === WATCHDOG_SENDER && input.auditPointer) {
this.db.prepare(`UPDATE outbox_entries SET delivery_state='retired', retired_at=?, retired_by=?, retirement_reason=?
WHERE delivery_state='retained' AND sender_session=? AND audit_pointer=? AND json_extract(guard_binding,'$.nodeId')=?`)
.run(new Date().toISOString(), WATCHDOG_SENDER, "superseded by a newer reminder from the same watchdog job",
WATCHDOG_SENDER, input.auditPointer, binding.nodeId);
}
if (!precommitted) this.assertRetentionCapacity(binding.nodeId, input.body);
if (!existing) this.record(input);
this.db.prepare(`UPDATE outbox_entries SET delivery_state='retained', guard_binding=?, delivered_at=NULL
@@ -382,18 +372,6 @@ export class OutboxHandler {
return rows.map((r) => this.rowToEntry(r));
}
/** Messages a rig's seats sent or were sent, newest first: sender or destination `<member>@<rig>`. */
listForRig(rig: string, limit = 100): OutboxEntry[] {
const pattern = `%@${rig.replace(/[\\%_]/g, (ch) => `\\${ch}`)}`;
const rows = this.db
.prepare(
`SELECT * FROM outbox_entries WHERE sender_session LIKE ? ESCAPE '\\' OR destination_session LIKE ? ESCAPE '\\'
ORDER BY ts_dispatched DESC, rowid DESC LIMIT ?`
)
.all(pattern, pattern, limit) as OutboxEntryRow[];
return rows.map((r) => this.rowToEntry(r));
}
/**
* W1 (transactional closure): list still-`pending` rows whose outbox_id begins
* with `idPrefix`, oldest first. The drain uses this to recover intents a crash
@@ -470,11 +448,3 @@ export class OutboxHandler {
};
}
}
/** The line a seat's new occupant is told when its seat holds messages (#2431): a started seat, a handover successor and a
* runtime-switch successor alike. Held messages are never replayed; null when there are none or no held-message store. */
export function heldMessagesNotice(db: Database.Database, nodeId: string, seat: string): string | null {
let count = 0;
try { count = new OutboxHandler(db).retentionUsage(nodeId).count; } catch { /* no held-message store: nothing to tell */ }
return count ? `${count} held message${count === 1 ? " was" : "s were"} kept for this seat and not delivered: read ${count === 1 ? "it" : "them"} with rig seat held-messages ${seat}.` : null;
}
@@ -13,7 +13,6 @@ import { resolveStartup } from "./startup-resolver.js";
import { discoverSkillsForRuntime, findNamedSkillsForRuntime, parseSkillFrontmatter, scanCacheFor, type SkillRuntime } from "./skill-discovery.js";
import { inspectSkillDirectory, resolvePluginSkills, resolveSkillLoadout, type SkillLoadout } from "./skill-catalog.js";
import { resolvePluginPath } from "./projection-planner.js";
import { selectedCorePluginDir, vendoredCorePluginDir } from "./claude-core-plugin-env.js";
// -- Types --
@@ -353,13 +352,9 @@ export function resolveNodeConfig(ctx: ResolutionContext): ResolutionResult {
const selectedIds = new Set(selectedResult!.skills.map((skill) => skill.effectiveId));
for (const plugin of selectedResult!.plugins) {
const resource = plugin.resource as PluginResource;
const pluginRoot = resolvePluginPath(resource.source.path, plugin.sourcePath);
// Except the vendored openrig-core on a Claude seat: the seat loads that plugin itself, skills included
// (CLAUDE_CODE_PLUGIN_DIRS, by seatCorePluginDir's rule), so a loadout copy would load each skill twice.
if (runtime === "claude-code" && vendoredCorePluginDir() && selectedCorePluginDir([pluginRoot])) continue;
const pluginSkills = resolvePluginSkills({
pluginId: resource.id,
pluginRoot,
pluginRoot: resolvePluginPath(resource.source.path, plugin.sourcePath),
runtime,
pluginType: resource.pluginType,
});
Loaded 100 of 270 files, more files were not shown because too many files have changed in this diff. Show more