mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-10 03:31:40 -10:00
Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
030cf97210 | ||
|
|
f692215383 | ||
|
|
de3b0658a6 | ||
|
|
826bc1cc5c | ||
|
|
3f0a5cc744 | ||
|
|
7d7634947d | ||
|
|
7c5c514457 | ||
|
|
78978dcb21 | ||
|
|
2eddb4a2d7 | ||
|
|
91d21bc962 | ||
|
|
3448150528 | ||
|
|
889b804ada | ||
|
|
863c0f58f2 | ||
|
|
87297f0f8e | ||
|
|
de47425168 | ||
|
|
101e5e3ff6 | ||
|
|
4ebb06059a | ||
|
|
a7bd8fe50f | ||
|
|
d9b09ec3f2 | ||
|
|
a3e5e92112 | ||
|
|
7b02076427 | ||
|
|
e10b8014f6 | ||
|
|
23daf8513a | ||
|
|
f7206db6de | ||
|
|
74501bdf63 | ||
|
|
432a6bcc41 | ||
|
|
c4b1181dc4 | ||
|
|
72a124de82 | ||
|
|
499dfe3ece | ||
|
|
9b3f91ecae | ||
|
|
8baec13548 | ||
|
|
f8ba5c4bac | ||
|
|
3917d119de | ||
|
|
7ae36414ed | ||
|
|
a0242b206f | ||
|
|
d7e2b5ca63 | ||
|
|
bb007efabc | ||
|
|
8e85902060 | ||
|
|
c7f5fc9674 | ||
|
|
5fb816f2b9 | ||
|
|
5f31cb5760 | ||
|
|
886b9d0586 | ||
|
|
1fa46a6118 | ||
|
|
be85d82546 | ||
|
|
7cabac4432 | ||
|
|
e935df86f1 | ||
|
|
6fb1e2c1c5 | ||
|
|
91ad6e719f | ||
|
|
fe29a92e44 | ||
|
|
ab858f691b | ||
|
|
18b5d79c9d | ||
|
|
c9b1ae480f | ||
|
|
076c710d42 | ||
|
|
e7e6dc4172 | ||
|
|
2128bb85d1 | ||
|
|
3352bee0d6 | ||
|
|
94b01e5c88 | ||
|
|
79e5b7952d | ||
|
|
ea4c178126 | ||
|
|
98f0f4939c | ||
|
|
9819da9d5e | ||
|
|
3fe80b97f6 | ||
|
|
ac7a6b0713 | ||
|
|
cb3fb1890d | ||
|
|
a0622b63fd | ||
|
|
0f6e6ea8f3 | ||
|
|
1593295db6 | ||
|
|
e3c461a7e3 | ||
|
|
c9e73d363c | ||
|
|
51c2bd9d4d | ||
|
|
6a35cd39f3 | ||
|
|
85881245b4 | ||
|
|
e5cd19e25a | ||
|
|
9d2d0d47d8 | ||
|
|
7b858418f3 |
No files matched your search
+19
-23
@@ -10,12 +10,14 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
|
||||
|
||||
## [0.7.8]
|
||||
|
||||
**Not yet published.**
|
||||
Published 2026-11-05 as `@openrig/[email protected]`, built from the commit tagged
|
||||
`v0.7.8` (`12df562c`).
|
||||
|
||||
OpenRig 0.7.8 lets a person answer a runner seat's permission requests, with a
|
||||
conformance check and an example runner to build against. It adds a maintained
|
||||
way to run a Claude worker in an NVIDIA OpenShell sandbox, and includes 0.7.7's
|
||||
fixes plus repairs across seats, tmux, Slack and the TUI.
|
||||
conformance check and an example runner to build against. It includes 0.7.7's
|
||||
fixes plus repairs across seats, tmux, Slack and the TUI. It also includes a
|
||||
reference for running a Claude worker in an NVIDIA OpenShell sandbox, but that
|
||||
worker isn't usable in this release: see [the OpenShell reference](#the-openshell-reference-isnt-usable-in-078).
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
@@ -61,20 +63,15 @@ fixes plus repairs across seats, tmux, Slack and the TUI.
|
||||
- **A seat activity grant** lets a runner outside the host's hook setup report
|
||||
activity for one seat and launch, without the install-wide hook token. ([#2295](https://github.com/mvschwarz/openrig/pull/2295))
|
||||
|
||||
### Claude workers in OpenShell sandboxes
|
||||
### The OpenShell reference isn't usable in 0.7.8
|
||||
|
||||
- **`rig environment setup` installs a maintained reference** that binds a seat
|
||||
to one NVIDIA OpenShell sandbox (`rig environment create` or `attach`, then
|
||||
`rig up`).
|
||||
- **Context and results:** the seat's guidance, skills, plugins and startup
|
||||
files go into the sandbox, and verified JSON results come back through
|
||||
queue work.
|
||||
- **Stop and delete:** stopping the agent, stopping compute and deleting the
|
||||
sandbox are separate actions.
|
||||
- **Restore:** a recreated sandbox resumes the same conversation.
|
||||
|
||||
The steps are in `daemon/assets/openshell/README.md`.
|
||||
([#2296](https://github.com/mvschwarz/openrig/pull/2296), [#2300](https://github.com/mvschwarz/openrig/pull/2300))
|
||||
0.7.8 includes `rig environment` (`setup`, `create`, `attach`) and a reference
|
||||
for binding a seat to an NVIDIA OpenShell sandbox (`daemon/assets/openshell/README.md`).
|
||||
**In our release testing the worker never became usable:** after the sandbox was
|
||||
created and the native process started, `rig environment connect` failed when its
|
||||
check timed out after 5 seconds, so no team work ran. Don't rely on this path in
|
||||
0.7.8.
|
||||
([#2296](https://github.com/mvschwarz/openrig/pull/2296), [#2300](https://github.com/mvschwarz/openrig/pull/2300), [#2378](https://github.com/mvschwarz/openrig/pull/2378))
|
||||
|
||||
### Seats, delivery and the TUI
|
||||
|
||||
@@ -150,14 +147,13 @@ fixes plus repairs across seats, tmux, Slack and the TUI.
|
||||
- **The dsh example keeps one hold per seat:** "if dsh asks twice in one turn,
|
||||
settling one ask releases the hold while the other may still wait on a
|
||||
person."
|
||||
- **The OpenShell reference requires:**
|
||||
- NVIDIA OpenShell 0.1.6;
|
||||
- a Linux image;
|
||||
- an API-backed provider using `ANTHROPIC_API_KEY`. Subscription or OAuth
|
||||
credentials aren't supported.
|
||||
|
||||
- **The worker isn't usable** (above). The reference also requires NVIDIA
|
||||
OpenShell 0.1.6, a Linux image, and an API-backed provider using
|
||||
`ANTHROPIC_API_KEY`; subscription or OAuth credentials aren't supported.
|
||||
"Provider registration and image construction can incur costs." The worker's
|
||||
activity reads unknown, from pane sampling, never idle.
|
||||
- **The OpenShell README example uses `operator@kernel`;** the actual kernel
|
||||
operator is `operator-agent@kernel`.
|
||||
- **"Never grant the guest access to the daemon's main API port (normally
|
||||
7433)."** The guest-only listener is what confines a guest's reach. Its grant
|
||||
only authorizes requests for its own seat and launch.
|
||||
|
||||
@@ -461,6 +461,13 @@ in the current update window. Current-update coverage is independent of the
|
||||
receipt window, so an unavailable current reader cannot retain closed delivery
|
||||
history beyond a successful history read. Both reads leave queue state intact.
|
||||
|
||||
Clients that need only current FYIs can use `GET /api/queue/open-human-updates`.
|
||||
It returns `{items, limit, truncated}` with open human-addressed updates, ordered
|
||||
critical then urgent then routine, without requiring a delivery receipt. `limit`
|
||||
defaults to 20 and accepts integers from 1 to 100. Optional `view` follows the
|
||||
same person perspective as `/api/attention`; a perspective read scans at most
|
||||
1000 rows and signals `truncated` when the window may omit matching items.
|
||||
|
||||
- `/api/stream` (`server.ts:783`) — `POST /emit` (`routes/stream.ts:56`),
|
||||
`GET /list` (`:91`, including `sourceSession`, `hintDestination`, `hintTag`,
|
||||
`since`, `until` and `direction` filters), `GET /watch` + `/sse` SSE
|
||||
|
||||
@@ -356,6 +356,13 @@ 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
|
||||
@@ -379,7 +386,7 @@ do not establish connectivity to a live peer.
|
||||
|---|---|---|
|
||||
| `rig ps` | — | `--json`<br>`--no-cleanup`<br>`--resources`<br>`--nodes`<br>`--full`<br>`--verbose`<br>`--limit <n>`<br>`--fields <list>`<br>`--summary`<br>`--filter <key=value>`<br>`--active`<br>`--running`<br>`-A, --all-rigs`<br>`--rig <name>`<br>`--session <name>`<br>`--include-archived`<br>`--host <id>`<br>`--all-hosts`<br>`--hosts <ids>` |
|
||||
|
||||
- `--nodes --json` — each seat carries the daemon's served `needsAttention` flag; the CLI doesn't re-derive it, and it is absent when an older daemon doesn't serve it. `arbitratedNeedsInput` and `needsInputEvidence` (`{ rung, observedAt }` or null) come with `--full`, or by name through `--fields`.
|
||||
- `--nodes --json` — each seat carries the daemon's served `needsAttention` flag; the CLI doesn't re-derive it, and it is absent when an older daemon doesn't serve it. `arbitratedNeedsInput` and `needsInputEvidence` (`{ rung, observedAt }` or null) come with `--full`, or by name through `--fields`. `startedWithoutRequired` lists the required startup files a handover's successor started without, and the compact read keeps it.
|
||||
|
||||
### roster
|
||||
|
||||
@@ -463,7 +470,7 @@ do not establish connectivity to a live peer.
|
||||
| `rig queue create` | — | `--source <session>`<br>`--destination <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--body-context <ref>`<br>`--mission <id>`<br>`--slice <id>`<br>`--gate <role>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--expires-at <iso>`<br>`--id <qitemId>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--human-intent <intent>`<br>`--alert`<br>`--human-detail-file <path>`<br>`--reply-to <qitemId>`<br>`--human-questions-file <path>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--verify`<br>`--full`<br>`--json` |
|
||||
| `rig queue claim <qitemId>` | — | `--destination <session>`<br>`--full`<br>`--json` |
|
||||
| `rig queue unclaim <qitemId>` | — | `--destination <session>`<br>`--reason <text>`<br>`--full`<br>`--json` |
|
||||
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--full`<br>`--json` |
|
||||
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--closure-required-at <iso>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--full`<br>`--json` |
|
||||
| `rig queue block <qitemId>` | — | `--on <blocker>` **required**<br>`--actor <session>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--continuation <text>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--full`<br>`--json` |
|
||||
| `rig queue resolve <qitemId>` | — | `--decision <text>` **required**<br>`--actor <session>`<br>`--bearer <token>`<br>`--no-notify`<br>`--json` |
|
||||
| `rig queue handoff <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--alert`<br>`--host <id>`<br>`--no-nudge`<br>`--full`<br>`--json` |
|
||||
@@ -631,7 +638,7 @@ Only Claude and Codex tenures can be woken.
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig specs` | — | — |
|
||||
| `rig specs ls` | — | `--kind <kind>`<br>`--json` |
|
||||
| `rig specs ls` | — | `--kind <kind>`<br>`--include-builtins`<br>`--json` |
|
||||
| `rig specs show <name-or-id>` | — | `--kind <kind>`<br>`--json` |
|
||||
| `rig specs preview <name-or-id>` | — | `--kind <kind>`<br>`--json` |
|
||||
| `rig specs add <path>` | — | `--json` |
|
||||
|
||||
@@ -540,7 +540,7 @@ Before you start, two honest constraints:
|
||||
| registry | `host` | add/list/select/rename isolated entries that resolve to fixture endpoints. Pairing needs two container daemons. |
|
||||
| coordination | `parked` | An owed item with a stopped worker is reported. A held worker with a live wake is distinguished. |
|
||||
| coordination | `stream` | emit/list/show/archive an exact item. A restart and a bounded watch keep the order with no duplicates. |
|
||||
| coordination | `queue` | (partial) Next: handoff, block, resolve and unclaim, each with exact items and destinations. |
|
||||
| coordination | `queue` | (partial) handoff → successor owned by `--to` and source asserted closed (`handed-off`), block → parked blocked, resolve → unpark to in-progress (owner nudge suppressed), unclaim → pending with destination kept — each with exact items and destinations asserted via `expect queue` (`rig queue list --json --full --all`) in `queue-transitions-reach-exact-states.yaml`; baton restart coverage stays in `queue-baton-survives-restart.yaml`. |
|
||||
| coordination | `project` | Acquire a lease, classify one item twice, read one projection. The wrong holder cannot act. |
|
||||
| coordination | `view` | Register a view over known items. list/show return the exact filtered result after a mutation. |
|
||||
| coordination | `watchdog` | Register one short wake, see one response, stop it, prove no further deliveries. |
|
||||
|
||||
@@ -214,7 +214,7 @@ After the files, OpenRig sends a session-identity message (rig, pod, member, log
|
||||
| Delivery Hint | When | How | Use For |
|
||||
|---------------|------|-----|---------|
|
||||
| `auto` | Depends on the file | Resolved per file: a `SKILL.md` (or content starting `# SKILL`) is `skill_install`, any other `.md` is `guidance_merge`, and any other file is `send_text` | Default — let OpenRig decide |
|
||||
| `guidance_merge` | Before harness boot | Merged as a managed block into `AGENTS.md` (Codex, Pi) or `CLAUDE.md` (Claude; `CLAUDE.local.md` with the rig's `managed_blocks: { claude-code: CLAUDE.local.md }`) | Role guidance, culture, project context |
|
||||
| `guidance_merge` | Before harness boot | Merged as a managed block into the repository `AGENTS.md` (Codex, OMP), a fresh Pi seat's own agent-directory `AGENTS.md` (see [Runtime Differences](#runtime-differences)), or `CLAUDE.md` (Claude; `CLAUDE.local.md` with the rig's `managed_blocks: { claude-code: CLAUDE.local.md }`). A fresh launch of Codex 0.164.0 or later can carry it as launch developer instructions instead (see [Codex guidance at launch](#codex-guidance-at-launch)) | Role guidance, culture, project context |
|
||||
| `skill_install` | Before harness boot | Copied to runtime's skill directory | Skills |
|
||||
| `send_text` | After harness is ready | Sent as text to agent's terminal via tmux | Boot-time grounding, identity hints, instructions to read skills |
|
||||
|
||||
@@ -367,7 +367,15 @@ Each startup file and action specifies when it applies:
|
||||
- `restore` — delivered when a seat is restored without its earlier conversation: a restore whose resume falls back to a fresh conversation, or a retried start
|
||||
- Default: `[fresh_start, restore]` (both)
|
||||
|
||||
**An exact resume delivers neither.** When a restore reopens the seat's saved conversation (`rig up <rig> --existing` with a resume token), OpenRig types no startup actions and delivers no startup files, `restore` ones included: the conversation already has them, and typing them again would add turns to it. The one exception: a managed guidance block missing from the guidance file is put back before the harness starts.
|
||||
**An exact resume delivers neither.** When a restore reopens the seat's saved conversation (`rig up <rig> --existing` with a resume token), OpenRig types no startup actions and delivers no startup files, `restore` ones included: the conversation already has them, and typing them again would add turns to it. The one exception: the guidance file's managed blocks are brought up to date before the harness starts. A missing block is put back, and a stale one is refreshed (see below).
|
||||
|
||||
**A seat handover does the same put-back.** Before a fresh, fork or rebuild successor starts, the handover puts the seat's missing managed guidance blocks back in its guidance file from the saved startup context. That's the rig's Claude file (`CLAUDE.md`, or `CLAUDE.local.md` when the rig uses it) for Claude, and `AGENTS.md` for Codex, Pi and omp. So a seat rebuilt into a new working directory keeps its guidance.
|
||||
|
||||
**A block you edited by hand is never overwritten.** OpenRig records what it last wrote in each managed block, so a launch, an exact resume, a handover or a runtime switch can refresh a block whose source file changed since. The record follows the file itself, so two links to one file share it. A block you've edited since OpenRig wrote it is left as you wrote it. The start's result names it (`guidance_block_edited`), and the seat is told in one line once it's ready, or in a handover's packet. A block OpenRig has no record of writing (any block written before these records were kept) that differs from its source works like this:
|
||||
- a launch replaces it, as launches always did, and names it (`guidance_block_refreshed_unrecorded`);
|
||||
- an exact resume or a handover leaves it, and names it as possibly stale (`guidance_block_may_be_stale`).
|
||||
|
||||
An exact resume doesn't type anything into the resumed conversation. So there, the restore result names the block (`guidanceNotes`), but the seat isn't told.
|
||||
|
||||
Use this to avoid re-sending context that a fresh conversation doesn't need twice. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both, so a seat that comes back fresh gets it again.
|
||||
|
||||
@@ -528,6 +536,58 @@ This separates provider files, not OS users or accounts. Project-local resources
|
||||
still use the selected project directory; shared project and system settings
|
||||
remain shared.
|
||||
|
||||
### Codex guidance at launch
|
||||
|
||||
On a fresh launch of Codex 0.164.0 or later, OpenRig passes a seat's managed guidance (OpenRig's start file and the
|
||||
rig's and project's context, culture and SOP files) with the launch, as `-c developer_instructions=…`, instead of
|
||||
merging it into the repository `AGENTS.md`. Neither the tracked `AGENTS.md` nor the Codex home is written for it, and
|
||||
both still load as usual. The text opens: "OpenRig seat guidance for this session follows. Where it conflicts with
|
||||
AGENTS.md guidance in the repository or the Codex home, follow AGENTS.md."
|
||||
|
||||
A launch value replaces a configured one, so this happens only when no Codex config layer OpenRig reads sets
|
||||
`developer_instructions`. OpenRig reads:
|
||||
|
||||
- `/etc/codex/config.toml` and `/etc/codex/managed_config.toml`;
|
||||
- the Codex home's `config.toml`, profiles included, and the selected profile's `<profile>.config.toml`;
|
||||
- each `.codex/config.toml` from the seat's working directory up.
|
||||
|
||||
The Codex home it reads is the seat's own (a `CODEX_HOME` in its `env:`, or a recorded account switch), else the
|
||||
daemon's `CODEX_HOME`, else `~/.codex`.
|
||||
|
||||
The guidance still goes to the repository `AGENTS.md`, as before, when:
|
||||
|
||||
- a layer OpenRig reads sets `developer_instructions`, or can't be read. A launch warning names the file;
|
||||
- the launch resumes or forks a conversation. Developer instructions on a resume haven't been tested, and an exact
|
||||
resume puts the guidance back in the repository `AGENTS.md`;
|
||||
- the seat is restored, even when the restore starts a new conversation. A restore keeps the guidance file its launch
|
||||
recorded, which for Codex is the repository `AGENTS.md`;
|
||||
- a handover successor starts (fresh, fork or rebuild). Its launch carries no guidance of its own, so the handover
|
||||
first puts the seat's missing blocks back in the repository `AGENTS.md` from its saved startup context;
|
||||
- Codex is older than 0.164.0, or its version is unknown.
|
||||
|
||||
A seat's role still arrives as startup text, and the seat-skills line, after the person's yes, still goes to
|
||||
`AGENTS.md`.
|
||||
|
||||
A launch that carries developer instructions writes no new blocks to the repository `AGENTS.md`. But blocks an earlier
|
||||
handover or resume left there are refreshed from their sources, by the same rule as every other managed block. That
|
||||
matters because the launch text asks Codex to follow `AGENTS.md` where the two differ.
|
||||
|
||||
**If your shell sets `CODEX_HOME`.** When neither the seat nor the daemon declares a `CODEX_HOME`, a seat's pane keeps
|
||||
its own, and OpenRig reads `~/.codex`. A `CODEX_HOME` that only your shell startup sets (in `~/.zshrc`, for example),
|
||||
with the daemon started without it, isn't seen. A `developer_instructions` set in that home is then replaced by the
|
||||
launch value for the session. To have it checked, declare that same absolute path as `CODEX_HOME` in the member's or
|
||||
rig's `env:` (see [Launch environment](rig-spec.md#launch-environment), "A seat's own account"). OpenRig then reads
|
||||
that home at the seat's next fresh launch, and a `developer_instructions` set there keeps the repository `AGENTS.md`.
|
||||
The seat keeps the same home and login.
|
||||
|
||||
**What it doesn't cover.** OpenRig doesn't read Codex's packaged defaults, or MDM- or enterprise-managed settings. A
|
||||
`developer_instructions` set there may be replaced for the session. Legacy MDM-managed config ranks above launch flags,
|
||||
so it can override OpenRig's guidance, and the seat then gets none of it.
|
||||
|
||||
**Not established.** The order Codex assembles developer instructions and `AGENTS.md` in, and how it settles every
|
||||
conflict, aren't established; the opening sentence asks it to follow `AGENTS.md`. The native behaviour was observed on
|
||||
Codex 0.164.0 alone, on fresh launches without tools.
|
||||
|
||||
### Runtime Differences
|
||||
|
||||
**Claude Code:**
|
||||
@@ -558,7 +618,10 @@ remain shared.
|
||||
- Can self-install dependencies from instructions but timer/recurring behavior is not reliably available
|
||||
|
||||
**Pi and OMP:**
|
||||
- Read `AGENTS.md` in the working directory; guidance is merged there, added to Pi's own system prompt
|
||||
- Read `AGENTS.md` in the working directory, added to Pi's own system prompt. Pi also reads the one in its agent
|
||||
directory: on a fresh launch, a Pi seat's guidance is merged there, under OpenRig's state, not into the
|
||||
repository's. When that file or its directory is a link, the repository `AGENTS.md` is used instead. A restore keeps
|
||||
the file the seat launched with. OMP keeps its guidance in the repository `AGENTS.md`
|
||||
- Selected skills are projected into the seat's own agent directory under OpenRig's state, not into a shared
|
||||
workspace folder
|
||||
|
||||
@@ -672,6 +735,29 @@ When authoring startup content, note which instructions are runtime-specific. Fo
|
||||
|
||||
---
|
||||
|
||||
## Files OpenRig writes into a project
|
||||
|
||||
These are the files OpenRig writes into a seat's working directory, and what `rig down` does with each. Paths are
|
||||
relative to that directory.
|
||||
|
||||
| Path | When OpenRig writes it | What `rig down` does |
|
||||
| --- | --- | --- |
|
||||
| Managed blocks in `CLAUDE.md` (or the rig's `managed_blocks` file, such as `CLAUDE.local.md`) or `AGENTS.md` | Each launch merges the seat's guidance blocks. A fresh Codex launch that takes developer instructions puts them in its launch text instead: in `AGENTS.md` it adds only the seat-skills line, after your yes, and refreshes blocks already there. A fresh Pi seat writes its own agent-directory `AGENTS.md` outside the project, except when that folder or file is a link: then its guidance stays in the repository `AGENTS.md`. | Removes OpenRig's blocks, current or legacy, and keeps everything else. When nothing else is left, it deletes the file if the file began with an OpenRig block, or held only blocks in an older format. A block-only file isn't proof that OpenRig created it. The seat-skills line is kept if you edited it. |
|
||||
| `.openrig/context-collector.cjs` | Claude seats, at launch. | Removes it while its bytes are still the shipped collector's; otherwise keeps it and says so. |
|
||||
| `.claude/settings.local.json` | Claude seats, at launch: OpenRig's status line and its activity hooks are merged in, separately. Your own status line is never replaced, but the activity hooks are still merged beside it. A file that doesn't parse gets neither. | Removes OpenRig's status line and hooks and keeps the file, even if `{}` is all that's left. |
|
||||
| `.claude/settings.local.json` fragments and `.mcp.json` | When a profile or spec declares `claude_settings_fragment` or `claude_mcp_fragment`. | Leaves the merged content in place. |
|
||||
| `.claude/agents/`, `.claude/plugins/<id>/`, `.claude/extensions/<id>/`; `.agents/<id>.yaml`, `.codex/plugins/<id>/`, `.agents/extensions/<id>/` | When the seat's projection includes subagents, plugins or runtime resources (Claude, then Codex). | Leaves them in place. |
|
||||
| `.claude/skills/<id>/`, `.agents/skills/<id>/` | Only for a seat without seat storage. New Claude and Codex seats keep their skills under `$OPENRIG_HOME/state/seats/`, outside the project. | Leaves them in place. |
|
||||
| The managed compaction preparation folder described in [Claude compaction preparation directory](#claude-compaction-preparation-directory), with its `.gitignore` of `*` and each attempt's `RESTORE-MAP.md` | Managed Claude compaction, unless a preparation root is configured elsewhere. | Leaves it in place. |
|
||||
| `.rigged-checkpoint.md` | A restore that didn't resume a seat's native conversation (by choice or not) and had a checkpoint to hand it. | Leaves it in place. |
|
||||
|
||||
`rig down` leaves a file alone while another seat still uses it: a running seat of the same rig, or any seat of
|
||||
another rig that isn't archived. That is decided file by file. A guidance file is kept while another seat's
|
||||
guidance targets the same file. The Claude settings and collector are kept while another Claude seat works in that
|
||||
directory. So another rig's Codex seat keeps `AGENTS.md` without keeping `CLAUDE.md` or the Claude settings. It
|
||||
removes `.openrig/` and `.claude/` only when they are empty.
|
||||
`rig down --delete` does nothing more to project files. OpenRig doesn't edit your project's own `.gitignore`.
|
||||
|
||||
## Patterns and Anti-Patterns
|
||||
|
||||
### Good Patterns
|
||||
|
||||
@@ -23,7 +23,10 @@ Only the activity axis is served today. The session and resumability values are
|
||||
`activity-taxonomy.ts` but no surface reads them: `rig ps --nodes --full` shows the raw session
|
||||
STATUS, both node views show a LIFECYCLE of `running`, `detached`, `recoverable` or `attention_required` (`run`, `det`, `rec`,
|
||||
`att`), and node inventory serves `occupantLifecycle` (`active`, `retiring`, `retired`,
|
||||
`context_walled`, `compacted`, `crashed`, `unknown`).
|
||||
`context_walled`, `compacted`, `crashed`, `unknown`). Node inventory's `sessionStatus` is the
|
||||
recorded session status, with two observed corrections: a running session whose pane is verified
|
||||
to have exited (identity reason `pane_exited`) reads `exited`, with `startupStatus:
|
||||
"attention_required"`, and a running session confirmed missing reads `detached`.
|
||||
|
||||
The axes never blend. Reachability is strict; revival optimism is a different field
|
||||
(omnigent's strict-liveness split). `unknown` is a first-class honest value — the oracle
|
||||
@@ -59,6 +62,21 @@ the existing live-hook fallback in effect; lifecycle, startup, held and error si
|
||||
can independently raise attention. A false attention flag means no known trigger, not
|
||||
proof that an unobserved input box is clear.
|
||||
|
||||
A handover whose successor started without a required file also raises attention. That's
|
||||
a projected guidance file or a guidance startup file marked `required` still missing from
|
||||
the guidance file, or, for a fresh or rebuild successor, a required `send_text` startup
|
||||
file that's missing. The seat serves `startedWithoutRequired` with each file's path, the
|
||||
table's reason column names each file, and the handover's own output says it's degraded.
|
||||
The mark belongs to that successor's session, so the seat's next launch or handover
|
||||
clears it. Time alone never does. Repairing the file in place doesn't clear it either,
|
||||
because no route delivers guidance into a running conversation.
|
||||
|
||||
A fresh or rebuild successor's first turn opens with the seat's `send_text` startup files,
|
||||
the ones a fresh start delivers, as absolute paths with the role first. A pointer asks the
|
||||
successor to read the file; it isn't the pasted text a start sends. A fork successor
|
||||
carries its parent's conversation and gets no list. `send_text` startup actions, such as
|
||||
an `after_ready` preload, aren't run for a successor.
|
||||
|
||||
The compact human table's ATTN column renders `yes`, `no`, or `?` when the flag is
|
||||
unavailable. Compact JSON retains `needsAttention`; use `--full` or
|
||||
`--fields needsAttention,arbitratedNeedsInput,needsInputEvidence` with `--json` for the
|
||||
@@ -116,7 +134,10 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
|
||||
PER-HARNESS when that harness's hook rung earns trust through a measured agreement
|
||||
window in production — never by fixture pass alone (symmetric admission: a fixture
|
||||
pass admits a rung to TRIAL, where it is measured but not consulted; production agreement
|
||||
promotes it). Undeclared and partial-coverage rungs are identity-only, which is also where
|
||||
promotes it). For Codex, this fallback also accepts a fresh working status adjacent to
|
||||
the composer, or an advancing counter across two fresh captures when output separates
|
||||
them. This positive evidence expires after five seconds; unrecognized, frozen, old or
|
||||
pre-occupant frames add nothing. It uses the existing pane capture. Undeclared and partial-coverage rungs are identity-only, which is also where
|
||||
a degraded rung goes, with one declared exception: for a pi or omp seat or a community
|
||||
harness, sampling is positive-only: a changing pane reads working, and a quiet one never reads idle on its
|
||||
own, since the pane can be quiet mid-turn.
|
||||
|
||||
@@ -8,4 +8,15 @@ Read `GET /api/seat/stop-precondition/:seatRef` immediately before an operator-c
|
||||
|
||||
Pass `expected` unchanged in the body of the existing `POST /api/seat/stop/:seatRef`, alongside `reason` and optional `operator`. The contract marker lets clients detect support. `expected` is optional; a request without it retains the existing stop behavior. A malformed explicit value returns HTTP 400. A null session ID can describe an empty seat, but stopping it returns `no_session`.
|
||||
|
||||
Checked stop requires the delivery guard. It refuses a changed node, latest session, binding or occupant before a terminal effect with HTTP 409 `selection_changed`. If the selection changes after client detach, the response reports `partialEffect: true` and no kill is sent. A kill failure or a failure to save stop state after a kill reports `stop_uncertain`; inspect the seat before retrying. Success retains the usual `seat`, `sessionName` and `sessionId` fields.
|
||||
Checked stop requires the delivery guard. It refuses a changed node, latest session, binding or occupant before a terminal effect with HTTP 409 `selection_changed`. A kill failure or a failure to save stop state after a kill reports `stop_uncertain`; inspect the seat before retrying. Success retains the usual `seat`, `sessionName` and `sessionId` fields.
|
||||
|
||||
A `selection_changed` or `stop_uncertain` response from a checked stop says how far the stop got in `stopPhase`. A refusal before the stop starts carries neither `stopPhase` nor `partialEffect`, and nothing was sent. The stop detaches the seat's clients, checks the selection again, then kills the session:
|
||||
|
||||
| `stopPhase` | What was sent to the seat |
|
||||
| --- | --- |
|
||||
| `none` | Nothing. |
|
||||
| `detach_attempted` | The client detach, which may not have finished. No kill. |
|
||||
| `detached` | The client detach. No kill. |
|
||||
| `kill_attempted` | The kill. |
|
||||
|
||||
`partialEffect` is `true` whenever `stopPhase` isn't `none`, so on its own it doesn't say whether the kill went out. `selection_changed` with `stopPhase: "kill_attempted"` is a change seen after the kill, when the stop state is saved: the session was killed, so re-read the seat rather than offering the stop again.
|
||||
@@ -0,0 +1,203 @@
|
||||
# Writing a daemon client
|
||||
|
||||
What a program that talks to the OpenRig daemon over HTTP needs and can't read from `rig --help`: how to find the
|
||||
daemon, its health route, which bearer token each route family checks, the event streams, and some route
|
||||
details. It describes the daemon as it is. It adds no promise beyond what each section says, and route shapes not
|
||||
listed here can change.
|
||||
|
||||
## Finding the daemon
|
||||
|
||||
`rig status --json` is the discovery surface. Its `daemon` object gives `state` and `reachable`, and `pid` and
|
||||
`port` when they are known: a stopped daemon's response has no `port`, and a running daemon found through
|
||||
`OPENRIG_URL` has no `pid`. `reachable` is `true` when the daemon answered its health probe, `false` when it is
|
||||
positively down, and `null` when that isn't confirmed (see `rig status` in
|
||||
[the command reference](../as-built/cli-reference.md#status)). It reads the recorded state and probes; it never
|
||||
starts a daemon.
|
||||
|
||||
The CLI itself finds the daemon in these orders:
|
||||
|
||||
- **Data commands** (everything that sends a request): `OPENRIG_URL` (or the legacy `RIGGED_URL`); else
|
||||
`$OPENRIG_HOME/daemon.json` when it holds a valid `pid` and `port`; else the config keys `daemon.host` and
|
||||
`daemon.port` (environment `OPENRIG_HOST` and `OPENRIG_PORT`), which default to `127.0.0.1:7433`.
|
||||
- **Status** (`rig status`, `rig daemon status`): `OPENRIG_URL` (or `RIGGED_URL`) first, probed directly; else
|
||||
the state file below.
|
||||
- **Start and stop** (`rig daemon start`, `stop`): the state file, `$OPENRIG_HOME/daemon.json`, then the legacy
|
||||
`~/.rigged/daemon.json`.
|
||||
|
||||
`daemon.json` holds `pid`, `port`, `host`, `db` and `startedAt`. `rig daemon start` writes a new daemon's record
|
||||
only after its health check passes, so while a start is pending, the file holds no record of the new daemon: it
|
||||
may be missing, or hold an earlier daemon's stale record. It is the CLI's own state file, not a published
|
||||
contract: prefer `rig status --json`.
|
||||
|
||||
## `GET /healthz`
|
||||
|
||||
The liveness and identity read. It always returns `status: "ok"` and `pid`. Every other field is present only
|
||||
in the case named:
|
||||
|
||||
| Field | When it is present |
|
||||
| --- | --- |
|
||||
| `euid` | On platforms with effective user ids. A self-report for the CLI's owner check, not authentication. |
|
||||
| `databaseWarnings` | When the daemon opened its database with warnings. |
|
||||
| `semver`, `commit`, `dirty`, `builtAt` | On a stamped (released) build; a development run has none. |
|
||||
| `selfHostId`, `selfHostIdSource` | After the boot reconcile has set this host's id. |
|
||||
| `eventLoop` | When the event-loop monitor runs: `lagMeanMs`, `lagP99Ms`, `utilization` (0 to 1), `lastTickAgeMs` and `healthy`. |
|
||||
| `routeTimings` | With `eventLoop`. |
|
||||
| `slowOperations` | When the slow-operation recorder runs. |
|
||||
| `bind` | The bind plan: `mode` (`explicit` or `default`), `hosts` (the addresses bound), `tailscaleDetected`, and `ignoredRoutingHost` when a routing value was ignored. |
|
||||
| `stuckSweep`, `wakeLadder` | The heartbeats of the stuck sweep and the wake ladder, when they run. |
|
||||
|
||||
## Bearer tokens
|
||||
|
||||
The daemon reads two tokens from its environment at start:
|
||||
|
||||
- **`OPENRIG_AUTH_BEARER_TOKEN`** checks Mission Control's write routes (`POST /api/mission-control/action` and
|
||||
`/notifications/test`), the `/api/hosts` writes (`add`, `update`, `remove`, `pair`), and the `/api/rig-mode`
|
||||
binding writes. `rig host pair` hands this token to the paired machine, so a daemon that will be paired with
|
||||
needs it set (see `rig host pair` in [the command reference](../as-built/cli-reference.md)).
|
||||
- **`OPENRIG_TERMINAL_BEARER_TOKEN`** checks every `/api/transport` and `/api/compaction` route, `POST
|
||||
/api/terminal/follow` and `/viewer/ensure`, and every `GET /api/terminal/<name>` route, the terminal websocket
|
||||
included. When the daemon is bound to an explicit address (`rig daemon start --host`) that isn't loopback or a
|
||||
tailnet address, and this token isn't set, these routes check `OPENRIG_AUTH_BEARER_TOKEN` instead.
|
||||
|
||||
A route whose token isn't set lets every request through. Send a token as `Authorization: Bearer <token>`; the
|
||||
`GET /api/terminal/<name>` routes, the websocket included, also take `?token=<token>`. The CLI sends the terminal token from `OPENRIG_TERMINAL_BEARER_TOKEN`,
|
||||
or else from the file `$OPENRIG_HOME/terminal-token`, which the person writes; the daemon doesn't create it.
|
||||
|
||||
A wrong or missing token gets HTTP 401. The routes that check `OPENRIG_AUTH_BEARER_TOKEN`, and `/api/transport`
|
||||
and `/api/compaction`, answer `{ "error": "unauthorized", "message", "what_failed", "why_it_matters", "what_to_do" }`.
|
||||
The `GET /api/terminal/<name>` routes, the websocket included, answer `{ "error": "unauthorized", "hint" }`. A token
|
||||
doesn't get past the browser boundary's 403 refusals, which [Browser access](browser-access.md) describes.
|
||||
|
||||
## Event streams
|
||||
|
||||
All three are server-sent events (`text/event-stream`):
|
||||
|
||||
- **`GET /api/events`**: the daemon's event log. `?rigId=` limits it to one rig. Each event carries `id: <seq>`.
|
||||
Without `Last-Event-ID`, `?replay=<n>` first sends the newest `n` stored events (`0`: none, live events only);
|
||||
`?replay=all`, no `replay`, or any other value sends the whole log first. A `Last-Event-ID` header (what
|
||||
`EventSource` sends when it reconnects) sends the events after that id instead, whatever `replay` says.
|
||||
Live events that arrive during the replay follow it, without duplicates.
|
||||
- **`GET /api/activity/events`**: live only, no replay or ids. Each message is named after its event type:
|
||||
`seat.activity_changed`, `seat.rung_health`, `proof.judged` or `proof.sources_changed`. It tells a view to read
|
||||
again; it carries no derived state.
|
||||
- **`GET /api/queue/sse`** (alias `/api/queue/watch`): live only, with `id: <seq>`, for `queue.created`,
|
||||
`queue.handed_off`, `queue.claimed`, `queue.unclaimed`, `qitem.fallback_routed`, `qitem.closure_overdue`,
|
||||
`inbox.absorbed` and `inbox.denied`.
|
||||
|
||||
## People, asks and updates without Slack
|
||||
|
||||
`GET /api/gateway/participants` returns the same JSON projection as `rig gateway participants --json`:
|
||||
`{ ok, participants, default, organizations, groups, warnings, participantFile }`. Pick a person by `kind: "person"`
|
||||
and use their `id` for a Feed `view`, or their `address` for the queue's `destinationSession`. The effective
|
||||
`default` is `{ id, source }`, or `{ id: null, reason }`; it does not establish who is using the client.
|
||||
Participant records omit connector credential pointers. Check `warnings` and `participantFile.error` for a partial
|
||||
registry read; an unreadable human registry returns HTTP 503. See [Gateway participants](gateway-participants.md).
|
||||
|
||||
| Read | Meaning |
|
||||
| --- | --- |
|
||||
| `GET /api/queue/list?destinationSession=<address>&state=pending,in-progress,blocked&limit=100` | Open rows for exactly that address. `humanIntent: "update"` identifies an FYI; other values are asks. Each row has `qitemId`, `state`, `summary` and `body`. |
|
||||
| `GET /api/queue/open-human-updates?view=<person-id>&limit=20` | Open human FYIs, even when no Slack delivery receipt exists. Returns `{ items, limit, truncated, perspective? }`. |
|
||||
| `GET /api/queue/<qitemId>` and `/<qitemId>/transitions` | The current row and its history, including closed state and recorded outcome. Closing or acknowledging a row is different from delivering it. |
|
||||
| `GET /api/queue/human-updates?view=<person-id>&limit=20` | Existing delivered-update history: explicit FYIs with a recorded Slack posting receipt, including closed rows. Items add `deliveredAt` and `deliveryReceipt`. It is not the open-update inbox. |
|
||||
|
||||
Both update lists accept integer `limit` values from 1 to 100 (default 20); invalid values return HTTP 400.
|
||||
Open FYIs are ordered by priority, then newest first. `view=all` (also the default) shows all perspectives;
|
||||
`view=default` selects the effective default person, or all when no default is available. A named view that cannot
|
||||
resolve a person has `perspective.basis: "unknown"` and no items when a registry is available. With no registered
|
||||
people there is no perspective filter: inspect `perspective` rather than assuming the requested view was applied.
|
||||
A person's view also includes instance or unassigned items when that person is the default. These are views on a
|
||||
trusted daemon, not per-person authentication or access control; existing daemon and browser access rules still apply.
|
||||
|
||||
Always display `truncated: true` as incomplete, even if fewer than `limit` items arrive. A person-view scan examines
|
||||
at most 1,001 open updates (101 for delivered history) before filtering; older matching items may be outside that
|
||||
window. These endpoints have no pagination cursor. Reading an open update never posts it to Slack, acknowledges it,
|
||||
or manufactures a delivery receipt.
|
||||
|
||||
For example, this Node client takes a daemon URL, a registered person id and a rig name. It uses no Slack API and
|
||||
makes only reads; use the daemon discovery and access rules above. In an ES module, `process.argv.slice(1)` is the
|
||||
argument list for `node --input-type=module -e '<code>' <url> <person-id> <rig>`:
|
||||
|
||||
```js
|
||||
// client-people-example
|
||||
const [base, personId, rig] = process.argv.slice(1);
|
||||
async function get(path) {
|
||||
const response = await fetch(new URL(path, base));
|
||||
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
const registry = await get("/api/gateway/participants");
|
||||
const person = registry.participants.find(p => p.kind === "person" && p.id === personId);
|
||||
if (!person) throw new Error("Choose a registered person id");
|
||||
const rows = await get(`/api/queue/list?destinationSession=${encodeURIComponent(person.address)}&state=pending,in-progress,blocked&limit=100`);
|
||||
const updates = await get(`/api/queue/open-human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
|
||||
const receipts = await get(`/api/queue/human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
|
||||
const rigHistory = await get(`/api/queue/list?rig=${encodeURIComponent(rig)}&limit=100`);
|
||||
const messages = await get(`/api/queue/outbox/list?rig=${encodeURIComponent(rig)}&limit=100`);
|
||||
console.log(JSON.stringify({ participants: registry.participants, default: registry.default,
|
||||
warnings: registry.warnings, participantFile: registry.participantFile,
|
||||
asks: rows.filter(row => row.humanIntent !== "update"), updates, receipts, rigHistory, messages }, null, 2));
|
||||
```
|
||||
|
||||
The queue and outbox lists are bounded snapshots, not subscriptions. A full queue list may need a larger `limit`.
|
||||
An outbox entry's `deliveryState` reports the send attempt; only `delivered` confirms delivery. A queue transition,
|
||||
an open FYI, or a closed ask is not that receipt. The rig-wide reads and their limits are described below; the existing
|
||||
`outbox/list?senderSession=<session>` read remains available.
|
||||
|
||||
## Route details
|
||||
|
||||
- **A rig's queue history:** `GET /api/queue/list?rig=<rig>&updatedSince=<ISO time>&sort=updated` returns the
|
||||
rig's queue items (any whose source or destination seat is in the rig) updated at or after that time: open items
|
||||
(`pending`, `in-progress`, `blocked`) first, then the rest by latest update. `limit` defaults to 100 and has no
|
||||
upper cap. The list is cut after `limit` rows in that order, so when it comes back full, raise `limit` to read
|
||||
them all. Each item's own history is `GET /api/queue/<qitemId>/transitions`. This read scans the queue table, so
|
||||
read it occasionally rather than polling it. For live changes, treat `GET /api/queue/sse` events as hints to read
|
||||
`queue/list?rig=` again. Not every event names both seats (`queue.claimed` and `queue.unclaimed` carry only the
|
||||
destination), and not every state change is on that stream, so the events alone don't track a rig's items.
|
||||
- **A rig's messages:** `GET /api/queue/outbox/list?rig=<rig>` returns the messages sent from or to the rig's
|
||||
seats, newest first; `?senderSession=<session>` still returns one sender's. One of the two is required; with
|
||||
both, `senderSession` is used and `rig` is ignored. `limit` defaults to 100. No event is sent when a message is delivered, so read this list to see delivery
|
||||
states.
|
||||
- **`GET /api/queue/recent-transitions`** returns at most 20 rows. A positive `limit` above 20 is capped at 20;
|
||||
a missing, zero, negative or unparseable `limit` means 20.
|
||||
- **`POST /api/transport/send`** takes `urgent: true`, the same as `rig send --urgent`. The daemon records who
|
||||
marked it from the sender identity, not from the body.
|
||||
- **`POST /api/scope/approve`** is what `rig scope slice approve` and `rig scope mission approve` call. The body
|
||||
takes `scopeTier` (`slice` or `mission`), `scopePath`, and optionally `approvalScope` (`spec` or `delivery`, the
|
||||
default), `missionsRoot`, `onBehalfOf`, `reApprove`, `reason` and `lockedArtifacts`. The approver is the
|
||||
request's sender identity (`actorSession` in the body is recorded beside it, and a different value is
|
||||
superseded). Success is 201 with `scopeTier`, `scopeId`, `scopePath`, `approvalScope`, `approvedBy`,
|
||||
`approvedAt`, `onBehalfOf`, `actionId`, `freezeFired` and `reApproved`. Refusals include 400 for an invalid
|
||||
`scopeTier`, `scopePath` or `approvalScope`, or a missing actor (`actor_required`); 503 `action_log_unavailable`;
|
||||
and, from the approval itself, 404 `scope_not_found`, 409 `already_approved` or `nothing_to_reapprove`, 503
|
||||
`workspace_not_configured`, 500 `audit_write_failed`, and 400 for its other refusals. An unexpected error is 500
|
||||
`internal`.
|
||||
- **`POST /api/proof/judge`** is what `rig proof judge` calls. The body takes `scope`, `item`, `verdict`
|
||||
(`accept`, `reject` or `withdraw`), `reason`, `expectedRevision` and `expectedPrevious` (a judgment id or
|
||||
`null`), and optionally `evidence`, `expectedEvidence`, `subject`, `operationId`, `replace`, `project` and
|
||||
`projectRoot`. The judge is the request's sender identity. The response is `judgment`, the slice's `readiness`,
|
||||
`replayed` and `notification`: 201 for a new judgment, 200 when an `operationId` replays an earlier one.
|
||||
- **`POST /api/workflow/instantiate`** requires `specPath`, `rootObjective` and `createdBySession`, and takes
|
||||
`entryOwnerSession`, `targetRig` and `scope` (see [Project workspace](project-workspace.md) for `scope`).
|
||||
Success is 201 with `instance`, `spec`, `entryQitemId` and `entryOwnerSession`, any `advisories`, and
|
||||
`scopeIgnored` when a scope wasn't applied.
|
||||
- **`GET /api/provider/usage`** returns `{ hostUsage }`, this host's usage rows (`host: "local"`, a `provider`
|
||||
and a `state`). A Claude row's `windows` are the seats' status-line readings, one entry per reading, each with
|
||||
its `window`, `usedPercent`, `resetsAt`, `asOf` and `seatSession`. A Codex row has `windows: []`. Treating the
|
||||
readings as one account rests on the deployment assumption the row's `provenance` names (one account per
|
||||
host), not on a measured identity. Readings whose reset times disagree are reported as `anomalies`, and the
|
||||
row's `state` can then be `explicit_unknown`. Show the row's `state`, `anomalies` and each reading's seat and
|
||||
`asOf` as reported, rather than merging readings that happen to match.
|
||||
- **Slack channels and people:** `GET /api/gateway/slack/channels` and `GET /api/gateway/slack/users` each return
|
||||
one page from Slack, read with the daemon's configured bot token, which never leaves the daemon. Channels are
|
||||
public and unarchived, as `{ id, name, isMember }`; people are `{ id, name, realName, displayName, isBot,
|
||||
deleted }`, with no email. Pass `cursor` (the previous page's `nextCursor`, `null` on the last page) and
|
||||
`limit` (default 100, at most 200). Inbound delivery needn't be enabled. Failures: 503 `slack_not_configured`
|
||||
when no bot token resolves, which is different from an empty list; 403 `slack_missing_scope` with the scope
|
||||
Slack names in `needed` (an older app may lack `users:read`); 429 `slack_rate_limited` with Slack's
|
||||
`Retry-After`; otherwise 502. Each request is a live Slack call, so page through a list when someone opens a
|
||||
chooser rather than polling it. Private channels and direct messages aren't listed.
|
||||
- **Slices are found by folder name alone.** `GET /api/slices/:name`, `/:name/doc/*`, `/:name/proof-asset/*` and
|
||||
`GET /api/review/slice/:name` take only the name; the detail route reads `?mission` only together with
|
||||
`?project`. When two missions have a slice folder of the same name, the daemon keeps the first one it finds:
|
||||
that one is listed and served, and the other doesn't appear in `GET /api/slices` at all. Keep slice names unique
|
||||
across missions, as [Project workspace](project-workspace.md) says.
|
||||
@@ -5,15 +5,31 @@ OpenRig's gateway can register **several people** and **shared channels**, group
|
||||
OpenRig doesn't enforce it. This page covers the commands, what the default is for, and what upgrading and rolling back
|
||||
do.
|
||||
|
||||
For a remote client, `GET /api/gateway/participants` returns the same participant, organization, group, default and
|
||||
warning fields as `rig gateway participants --json`. Add `?organization=<id>` to filter the participant list; the default
|
||||
and organization and group maps still describe the whole registry. The result names the participant file and reports
|
||||
an unreadable side file without treating it as an empty one. If the human registry itself cannot be read, the route
|
||||
returns `503` with `error: "human_registry_unavailable"`. The result does not include connector secret pointers.
|
||||
|
||||
## Participants
|
||||
|
||||
A participant is either:
|
||||
- **a person**, registered with `rig gateway human add` as before, with their own Slack binding; or
|
||||
- **a person**, registered with `rig gateway human add`, with or without a connector binding; or
|
||||
- **a shared channel**: one vetted Slack channel registered as one participant, `<id>@external`, not a person per
|
||||
sender.
|
||||
|
||||
People and channels share one address space: a channel can't take a person's id.
|
||||
|
||||
A registered person is addressed as `<id>@external`, including without Slack: for example,
|
||||
`rig gateway human add alex --display-name "Alex" --delivery-class B` creates `alex@external`, reached in the Feed and Steering.
|
||||
`human@host` is the unnamed person at the terminal; `human@kernel` is also accepted as a human/gate address.
|
||||
These aliases (including `human-<name>@host` and `human-<name>@kernel`) are not agent sessions: reply with
|
||||
`rig queue create --destination human@host --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`,
|
||||
substituting the sender's address and your summary, evidence path and reply. A reply destination doesn't identify
|
||||
who acted or determine who may answer an ask. Clients acting as a registered person name that person's
|
||||
`actorSession`; a seat identity header takes precedence. The terminal's primary-user authority records the unnamed
|
||||
`human@host` actor, not the person whose queue is being viewed.
|
||||
|
||||
```bash
|
||||
# An organization and a group inside it (one command each, create or update)
|
||||
rig gateway org set acme --name "Acme" --policy "Answer Acme's customers within a working day. Never promise a date."
|
||||
@@ -59,30 +75,26 @@ never changes `workspace.operator_seat_name`, which keeps its own meaning.
|
||||
|
||||
## In the TUI: whose queue, and who acts
|
||||
|
||||
The person at the terminal is the **primary user**: the default person, or, with several people and none chosen, the
|
||||
person at the terminal unnamed. Whatever the TUI shows, its acts are theirs.
|
||||
The person at the terminal is the **primary user**. The TUI records their acts as unnamed (`human@host`),
|
||||
whatever person's queue it shows or who is configured as the default.
|
||||
- **Opening:** with a chosen default, the TUI opens on that person's Feed. With none chosen, including a one-person
|
||||
install that never chose one, it opens on the topology, as before.
|
||||
- **Whose queue:** `whose <person>` shows one person's asks, `whose all` everyone's, and `whose default` the default
|
||||
person's. The Feed's first line says whose queue it shows and who acts.
|
||||
- **Acting on someone else's ask** (answer, approve or resolve from the TUI) records both: the primary user as the actor,
|
||||
and the person asked in the audit notes and the transition. The asking seat reads, for example, "Answered by
|
||||
mike@external, the primary user at the terminal, on sarah@external's ask". With several people and none chosen, the
|
||||
actor is `human@host`.
|
||||
- **Viewing Sarah isn't acting as Sarah.** Only Sarah, from her own channel, answers as Sarah. Without the primary
|
||||
user's authority, one registered person can't answer, approve, deny or resolve what was asked of another; that's
|
||||
refused (`answer_not_recorded`) and nothing changes.
|
||||
- With no one registered, the TUI and its acts are as before, except on a shared channel's ask or a row parked on a
|
||||
channel: the TUI shows the act as `human@host`, the person at the terminal, and it's recorded that way, never as the
|
||||
channel.
|
||||
- **Acting on someone else's ask** (answer, approve or resolve from the TUI) records both: the unnamed terminal
|
||||
actor `human@host` and the person asked in the audit notes and transition. The asking seat reads, for example,
|
||||
"Unattributed (human@host) answered sarah@external's ask".
|
||||
- **Viewing Sarah isn't acting as Sarah.** A registered person may answer, approve, deny or resolve another person's
|
||||
ask as themselves; both the actor and person asked are recorded. No connector binding is required, and viewing
|
||||
someone else's queue doesn't make its viewer that person.
|
||||
- **A shared channel never acts.** Its messages are correspondence, so nothing it says answers, approves, denies or
|
||||
resolves an ask, and a customer's earlier message in an ask's thread is never taken as its answer. A request without
|
||||
the primary user's authority that acts as a channel, with any Mission Control verb (including drop, route, hold, hand
|
||||
off and annotate), is refused (`answer_not_recorded`) and nothing changes.
|
||||
- **Where the authority applies:** only on a request that carries no seat identity header, which is how the TUI calls
|
||||
the daemon. Seats and the CLI send that header, so their acts stay theirs. This is the existing trusted-network
|
||||
distinction, not an authentication check: a request that leaves the header out is taken as the terminal's, as any
|
||||
such request already is.
|
||||
- **Where the authority applies:** primary-user authority is accepted only on a request that carries no seat identity
|
||||
header, which is how the TUI calls the daemon. A headerless client can instead name a person's `actorSession` without
|
||||
that authority. Seats and the CLI send the identity header, so their acts stay theirs. This is the existing
|
||||
trusted-network distinction, not an authentication check.
|
||||
|
||||
## Shared-channel conversations
|
||||
|
||||
|
||||
@@ -291,6 +291,17 @@ fine; if both accounts are authenticated, the kernel uses both even when your
|
||||
later project uses just one. A request to use only one provider for everything
|
||||
is a separate choice.
|
||||
|
||||
`/api/kernel/status` retains the chosen template (`rig.yaml`,
|
||||
`rig-claude-only.yaml` or `rig-codex-only.yaml`) across daemon restarts and
|
||||
recovery, including starts with `--no-kernel`. It reads the rig's latest rig-spec
|
||||
bootstrap record, including a partial launch, so an older kernel's recorded
|
||||
selection is also available. This is the original selection; the rig's live spec
|
||||
describes its current configuration. The saved source must match a template under
|
||||
the current installation’s built-in specs directory. A custom project path with
|
||||
the same directory suffix, an old source path that can no longer be verified
|
||||
(for example, after a Node version change moves the global install), or a kernel
|
||||
without bootstrap history reports an unknown variant.
|
||||
|
||||
**Started is not ready.** Read the `Kernel:` boot state in `rig status` and the
|
||||
individual seat state in `rig ps --nodes --rig kernel`. The view may open while
|
||||
agents finish starting; describe that state, without claiming they are ready.
|
||||
|
||||
@@ -110,6 +110,17 @@ limitation: use the live spec-library commands to determine where a spec is
|
||||
served from, and do not infer convergence merely because the canonical
|
||||
directory exists.
|
||||
|
||||
The spec library scans the built-in specs, `$OPENRIG_HOME/specs` and, when
|
||||
present, that legacy library. At any depth below them it skips directories
|
||||
named `.git`, `.worktrees`, `node_modules`, `dist`, `build`, `.turbo` and
|
||||
`.next`, which usually hold build output or dependencies (`SKIP_DIRS` in
|
||||
`packages/daemon/src/domain/spec-library-service.ts`). A spec inside one of
|
||||
them, such as `specs/build/rig.yaml`, isn't listed, and nothing reports that it
|
||||
was skipped. To list it, rename its directory and run `rig specs sync`.
|
||||
`rig specs add <directory>` copies a directory under its own name, so adding a
|
||||
directory named `build` doesn't help. `rig up <path-to-rig.yaml>` still launches
|
||||
a spec by its path, without the library.
|
||||
|
||||
Config updates publish a complete replacement file atomically where directory
|
||||
permissions and the filesystem allow it. On POSIX systems, replacement preserves
|
||||
the existing owner, group, and read/write/execute permission bits. If creating or
|
||||
|
||||
@@ -107,6 +107,28 @@ rig queue create --destination <entityId>@external --human-intent update \
|
||||
- **`posted` means the connector posted it, not that they read it.** Don't say they saw it.
|
||||
- **If delivery fails or is indeterminate,** inspect that task before anything else. Never resend blindly.
|
||||
|
||||
## If the timer cannot reach its recipient
|
||||
|
||||
A `periodic-reminder` occurrence that definitely fails before input remains owed. The watchdog
|
||||
retries through its existing loop, using `queue.wake_retry_interval_seconds` and
|
||||
`queue.wake_retry_cap` (by default, three retries five minutes apart). A failed attempt does not
|
||||
advance the reminder to its next regular cycle. A confirmed send advances the normal interval;
|
||||
recovery does not replay every elapsed cycle.
|
||||
|
||||
`rig watchdog show <jobId> --json` includes the failed attempt, its retained outbox IDs, retry
|
||||
time and next action. If input may have reached the recipient, that occurrence ends unconfirmed
|
||||
and is not automatically sent again. Inspect the recipient and exact outbox record before any
|
||||
manual replay. A recurring reminder keeps its next normal cycle under a new occurrence identity. A confirmed receipt survives a daemon restart, so
|
||||
finishing its accounting does not type the same occurrence twice.
|
||||
|
||||
Exhausted retries end only the failed occurrence, with its cause and next action; a recurring
|
||||
reminder keeps its next normal cycle. A permanent transport failure leaves a terminal job:
|
||||
correct the destination and register a replacement reminder. A one-shot park timer records its
|
||||
failed or unconfirmed wake on the parked row before it ends. A retryable failure is recorded
|
||||
there while the timer stays armed. Deliberately held
|
||||
input keeps its existing retained-message custody. Condition watches and parked-owner policy
|
||||
behavior are unchanged. This does not replay failed occurrences recorded by older versions.
|
||||
|
||||
## "What are you watching for me?"
|
||||
|
||||
List **every active state**, not just set-aside ones. A watch being checked can be in progress, and a new one can be
|
||||
|
||||
@@ -25,7 +25,8 @@ that restores nothing is not itself evidence of accidental loss.
|
||||
|
||||
## Choose the interface the seat actually has
|
||||
|
||||
Pi 1.4.0 includes an opt-in selector at `examples/extensions/tools.ts`. Its
|
||||
Pi 1.4.0 includes an opt-in selector at
|
||||
`packages/coding-agent/examples/extensions/tools.ts` in the Pi repository. Its
|
||||
`/tools` command remembers selection per branch and needs Pi's own TUI. Use it
|
||||
where that extension and native TUI are available. **Do not send `/tools` to an
|
||||
agent OpenRig drives over RPC**: that interface cannot operate the selector.
|
||||
|
||||
@@ -91,10 +91,10 @@ my-rig.rigbundle.sha256 — "a1b2c3d4..." (64-char hex SHA-256)
|
||||
The packer produces deterministic output:
|
||||
- Files are sorted alphabetically
|
||||
- A fixed mtime is used (`2026-01-01T00:00:00Z`)
|
||||
- Portable mode normalizes uid/gid/mode
|
||||
- Portable mode omits 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
|
||||
- Maximum gzip compression (level 9)
|
||||
|
||||
Identical staged bytes (including manifest timestamps and provenance) produce the same archive hash. Use the package digest to compare packaged file content independently of manifest metadata.
|
||||
Identical staged bytes and file modes (including 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -755,6 +755,7 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
|
||||
| `agent_file` | string | no | — | Claude members only. A Claude agent definition (a `.md` file with `name` in its frontmatter) for this seat. It's copied into the seat's own storage and launched as `--agent openrig:<name>`. A path beside the spec: no `..`, not absolute. |
|
||||
| `append_system_prompt_file` | string | no | — | Claude members only. A prompt file appended to Claude's system prompt for this seat. It's copied into the seat's own storage and passed as `--append-system-prompt-file`. A path beside the spec. |
|
||||
| `plugin_dirs` | list | no | — | Claude members only. Claude plugin folders for this seat, each copied into the seat's own storage and loaded through `CLAUDE_CODE_PLUGIN_DIRS`. Paths beside the spec. Each is copied under its folder name, so two folders with the same name are refused; an empty list is none. A missing file or folder fails that seat's launch by name. `rig bundle create` carries all three; `rig export` names a seat whose additions it doesn't carry. |
|
||||
| `plugin_channels` | list | no | — | Claude members only. Plugin channels this agent registers for, as `plugin:<name>@<marketplace>` entries, passed as one `--channels` on every launch; see [Claude plugin channels](#claude-plugin-channels-runtime-claude-code). |
|
||||
| `role` | string | no | — | The seat's role, letters, digits, `_`, `.` and `-`. Not valid on terminal members. |
|
||||
| `restore_policy` | string | no | the AgentSpec's | Restore behavior. One of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`. The default comes from the AgentSpec's `defaults.lifecycle` (else `resume_if_possible`) as narrowed by its profile; a member value may only narrow it further, in that order. |
|
||||
| `compaction_strategy` | string | no | — | `default-compaction`, `managed-compaction`, `handover` or `apprentice-handover`; `harness_native` and `pod_continuity` are accepted as deprecated aliases, and `custom_prompt` is refused. `managed-compaction` and `apprentice-handover` create their jobs only for `claude-code` seats; see [Codex compaction and sizing](agent-startup-guide.md#codex-compaction-and-sizing). |
|
||||
@@ -810,6 +811,52 @@ OpenRig keeps the requested opt-out flags, so startup and resume remain subject
|
||||
to that native refusal; it does not fall back to loading servers. Use a host
|
||||
without that managed configuration, or omit the opt-out to use the managed set.
|
||||
|
||||
### Claude plugin channels (`runtime: claude-code`)
|
||||
|
||||
Set `plugin_channels` on a Claude member to register that agent for channel
|
||||
notifications (messages a plugin's MCP server pushes into the session):
|
||||
|
||||
```yaml
|
||||
members:
|
||||
- id: support
|
||||
agent_ref: local:agents/support
|
||||
profile: default
|
||||
runtime: claude-code
|
||||
cwd: .
|
||||
plugin_channels:
|
||||
- plugin:example-channel@example-marketplace
|
||||
```
|
||||
|
||||
OpenRig passes `--channels plugin:example-channel@example-marketplace` to this
|
||||
agent's Claude on every launch, resume, fork, restore and handover. Several
|
||||
entries follow one `--channels`, space-separated, and OpenRig's own session
|
||||
options come after them. Claude Code 2.1.316 hides `--channels` from
|
||||
`claude --help`, so it isn't listed there. An agent without the field gets no
|
||||
`--channels`, and nothing is added for other agents or other runtimes; there is
|
||||
no rig-wide or environment default.
|
||||
|
||||
Each entry is `plugin:<name>@<marketplace>`, both names made of letters, digits,
|
||||
`.`, `_` and `-`, starting with a letter or digit. `server:` entries and Claude's
|
||||
`--dangerously-load-development-channels` aren't supported. Combining a non-empty
|
||||
channel list with `claude_mcp: none` produces a validation warning: that setting
|
||||
starts Claude without MCP servers (plugin servers included), so no channel could
|
||||
register. OpenRig keeps both settings and launches the member normally. Remove
|
||||
`claude_mcp: none` to use the channels, or remove `plugin_channels` to keep the MCP
|
||||
opt-out. OpenRig doesn't install plugins or marketplaces, or change the settings
|
||||
that admit a channel: whether each channel registers is Claude's decision (the plugin installed, and
|
||||
admitted by your organisation's channel settings or Claude's approved list).
|
||||
|
||||
Selecting a channel for one agent doesn't isolate what the plugin shares with
|
||||
other agents. A token or other credential the plugin keeps in its own
|
||||
configuration is the same for every agent that loads that plugin.
|
||||
|
||||
The selection is saved with the seat's other launch additions when the seat
|
||||
starts from the spec, and starting it from a spec without the field removes it.
|
||||
`rig seat launch --plan` and `rig skill loadout --seat` list it, a fork keeps it,
|
||||
and `rig bundle create` and `rig export` carry it in the member; a bundle's
|
||||
before-install view names each channel as a need. A running Claude process is
|
||||
unaffected.
|
||||
|
||||
### Claude Advisor (`runtime: claude-code`)
|
||||
|
||||
Set `advisor` independently of the primary `model`:
|
||||
@@ -1274,3 +1321,15 @@ These are the built-in specs shipped with OpenRig. Read them as worked examples.
|
||||
Also shipped, in the same tree: `launch/kernel`, OpenRig's own team, which starts automatically. `first-project` is
|
||||
starter's old name and still resolves to it. `workshop`, the four-agent middle team, is a rig bundle rather than a
|
||||
built-in. List the built-ins with `rig specs ls --kind rig`.
|
||||
|
||||
To omit built-in rigs, agents and workflows from the shared spec inventory, run
|
||||
`rig config set specs.builtins_visible false`. This changes the CLI, TUI and other
|
||||
consumers of the library listing; user specs remain visible. Use
|
||||
`rig specs ls --include-builtins` to list them deliberately, and set the value
|
||||
back to `true` to restore the default. Explicit `show`, `preview`, and launch
|
||||
lookups can still find hidden built-ins. When built-ins are hidden and a user
|
||||
spec has the same name, the user spec wins the name lookup; use the built-in's
|
||||
ID from `rig specs ls --include-builtins --json` to select it. With built-ins visible,
|
||||
same-name matches remain ambiguous. This is an inventory preference, not an
|
||||
access-control boundary; other endpoints and on-disk files can still expose the
|
||||
built-ins.
|
||||
@@ -144,6 +144,23 @@ changes either. `rig scope` reads an `established` or `canonical` slice whose
|
||||
`verified:` is missing, has no source, names a tool-scaffolded placeholder, or is
|
||||
older than 90 days as `provisional`.
|
||||
|
||||
A slice's frontmatter may also carry `status:`. `GET /api/slices` reports the
|
||||
bucket each word falls in as the slice's `status`, and its
|
||||
`?filter=all|active|done|blocked` filters on that bucket:
|
||||
|
||||
| `status:` word | Bucket |
|
||||
| --- | --- |
|
||||
| `active`, `in-flight`, `ratified` | `active` |
|
||||
| `draft`, `draft-pending-orch-ratification`, `placeholder` | `draft` |
|
||||
| `done`, `shipped`, `promoted`, `closed` | `done` |
|
||||
| `blocked`, `parked-with-evidence` | `blocked` |
|
||||
|
||||
Words are matched ignoring case. Any other word is matched by what it contains:
|
||||
`done`, `ship` or `close` makes it `done`; `block` or `park`, `blocked`; `draft`
|
||||
or `pending`, `draft`. A word that matches none of these counts as `active`, so a
|
||||
slice marked `complete` counts as `active`. A slice with no `status:` counts as
|
||||
`draft`.
|
||||
|
||||
When the owning scope selects `proofPolicy.judges`, use `rig proof judge` to
|
||||
record an attributed accept, reject or withdraw decision on a contract item,
|
||||
and `rig proof show` to read derived readiness up through slice, mission and
|
||||
@@ -178,9 +195,11 @@ missions whose `mission.yaml` carries `metadata.status: active` or
|
||||
a mission does not change the project's readiness. A `planned` mission stays
|
||||
outside the rollup, even after its slices are accepted, until someone changes
|
||||
its status to `active` (or `release-candidate` for a release that is ready to
|
||||
judge). Nothing changes the status automatically. Missions created before this
|
||||
default carry no status and are not counted either; add the line by hand to
|
||||
include them. It also writes `metadata.name`, the mission's folder name, which
|
||||
judge). Nothing changes the status automatically. A mission can also carry
|
||||
`metadata.status: complete`, which only `POST /api/missions/:missionId/complete`
|
||||
writes (no `rig` verb does); a `complete` mission is outside the rollup too.
|
||||
Missions created before this default carry no status and are not counted either;
|
||||
add the line by hand to include them. It also writes `metadata.name`, the mission's folder name, which
|
||||
the project lifecycle compiler requires; a mission created without it refuses to
|
||||
compile with `lifecycle_field_missing` until you add that line.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ type: `openrig-urgent`, `urgent-only` and `sends-only`. Sibling seats keep their
|
||||
| `off` (default) | Existing delivery and prompt checks | Existing send result |
|
||||
| `draft-aware` | Paste into a recognized empty composer; check ownership again before Enter | Hold input and retain this attempt for inspection when bookkeeping is available |
|
||||
| `openrig-urgent` | OpenRig's own messages (watchdogs, scheduled wakes, notices) and anything marked urgent type, never over a draft | Other agents' messages are held, the sender is told, and held messages stay readable |
|
||||
| `urgent-only` | Only messages marked urgent type (`rig send --urgent`, or a queue item at priority urgent or critical), never over a draft | Everything else is held, the sender is told, and held messages stay readable |
|
||||
| `urgent-only` | Only messages marked urgent type (`rig send --urgent`, `urgent: true` on `POST /api/transport/send`, or a queue item at priority urgent or critical), never over a draft | Everything else is held, the sender is told, and held messages stay readable |
|
||||
| `sends-only` | Deliberate sends from people and agents type, as with `off`; a terminal member's typing isn't protected, because its shell input can't be read | OpenRig's own notices are held. A terminal member defaults to this level |
|
||||
| `hold` | No automatic terminal input, even at an empty prompt | Retain for inspection outside the pane |
|
||||
|
||||
@@ -111,8 +111,18 @@ their existing behaviour.
|
||||
|
||||
### Terminal delivery
|
||||
|
||||
Run from a seat shell with `OPENRIG_SESSION_NAME` set; the daemon derives the
|
||||
audit actor from transport identity.
|
||||
`rig seat set-typing-guard` works from an ordinary terminal as well as from a
|
||||
seat's shell. The daemon takes the recorded actor from transport identity only:
|
||||
|
||||
- From a managed seat's shell (`OPENRIG_SESSION_NAME` set), the preference and
|
||||
its audit entry name that seat.
|
||||
- From a terminal with no seat identity, the change applies the same way, and
|
||||
the preference and its audit entry record the actor as
|
||||
`unattributed (no seat identity)`.
|
||||
|
||||
Neither case takes an actor from the request body or from the target seat's name.
|
||||
`rig seat retire-held-message` still records the seat that retired a message, so
|
||||
run it from a seat's shell.
|
||||
|
||||
~~~sh
|
||||
rig seat set-typing-guard dev-impl@my-rig --mode draft-aware \
|
||||
@@ -131,8 +141,21 @@ to finish. The API may return HTTP 202 with `pending: true`; wait for
|
||||
and effective modes and retains the text `Typing guard: on (hold)` for hold.
|
||||
|
||||
A held send immediately returns `outcome: "retained"`, `sent: false`, its exact
|
||||
reason (such as `draft_input_busy`) and the saved attempt's `outboxIds`. No automatic
|
||||
retry is scheduled. Each explicit draft-aware request is a new attempt, including
|
||||
reason and the saved attempt's `outboxIds`. No automatic
|
||||
retry is scheduled. The reasons are:
|
||||
|
||||
- `typing_guard_enabled`: the seat's mode holds this send (`hold`, or a level that
|
||||
holds this kind of message).
|
||||
- `runtime_switch_in_progress`: the seat is switching runtime.
|
||||
- In `draft-aware` mode, a refusal before any input was written:
|
||||
`draft_input_busy` (the input holds unsubmitted text), `draft_input_unknown` (the
|
||||
input can't be read), `draft_input_changed` (the input or its target changed during
|
||||
the delivery), `draft_wake_superseded`, `target_needs_input`,
|
||||
`wait_for_idle_timeout`, `target_activity_unknown`, `target_runtime_unverified`,
|
||||
`target_runtime_not_running`, `target_runtime_conflict`, `invalid_wait_for_idle`,
|
||||
`invalid_dangerously_interact`, `dangerously_interact_requires_reason`,
|
||||
`prompt_override_audit_unavailable`, `transport_unavailable`, `session_missing` or
|
||||
`tmux_unavailable`. Each explicit draft-aware request is a new attempt, including
|
||||
one with a reused `deliveryId`; the result warns older callers that this field no
|
||||
longer deduplicates input. The CLI does not generate a deduplication key.
|
||||
|
||||
@@ -230,7 +253,9 @@ refuses Enter and is not replayed. Unsupported provider layouts remain held.
|
||||
Use off when that tradeoff is unsuitable.
|
||||
|
||||
HTTP configuration uses `POST /api/seat/set-typing-guard/:seatRef` with
|
||||
`{mode, reason}` or `{enabled, reason}`. Status and held-message routes are
|
||||
`{mode, reason}` or `{enabled, reason}`. The `X-OpenRig-Session` header, when
|
||||
present, is the recorded actor; without it the change is recorded as
|
||||
`unattributed (no seat identity)`. Status and held-message routes are
|
||||
unchanged; no RigSpec field is required.
|
||||
|
||||
## Verification scope
|
||||
|
||||
@@ -99,8 +99,24 @@ So after installing, compare the granted scopes Slack shows for the app with all
|
||||
`rig slack manifest --json` lists.
|
||||
|
||||
The app subscribes to messages in public channels it is a member of (`message.channels`), to
|
||||
mentions of the app (`app_mention`), and to emoji reactions (`reaction_added`). It does not request
|
||||
direct-message or private-channel access.
|
||||
mentions of the app (`app_mention`), and to emoji reactions (`reaction_added`). The manifest does
|
||||
not request direct-message or private-channel access.
|
||||
|
||||
### Using a private channel
|
||||
|
||||
The connector doesn't check whether a channel is public or private, so a private channel can be
|
||||
the default channel or a mapped one once the app can read it. OpenRig doesn't test this setup. In
|
||||
the app's settings:
|
||||
|
||||
1. Under **OAuth & Permissions**, add the bot scopes `groups:history` (read messages in private
|
||||
channels the app is in) and `groups:read` (read a private channel's details; `rig slack verify`
|
||||
needs it to confirm the app is a member).
|
||||
2. Under **Event Subscriptions**, subscribe to the bot event `message.groups`.
|
||||
3. Reinstall the app to the workspace, then invite it to the private channel.
|
||||
4. To have `rig slack verify` require the new scopes too, run
|
||||
`rig slack setup --required-scopes chat:write,channels:history,channels:read,groups:history,groups:read`.
|
||||
|
||||
Direct messages are not supported.
|
||||
|
||||
A reaction added to an ask reaches the agent that asked, as a note naming who reacted and with
|
||||
which emoji; the agent decides what it means. Every message OpenRig posted for the ask counts:
|
||||
@@ -166,6 +182,9 @@ rig slack channel-map list
|
||||
rig slack channel-map remove pr@my-rig
|
||||
```
|
||||
|
||||
A client can offer the channels to choose from with `GET /api/gateway/slack/channels`, which lists the public
|
||||
channels the bot token can see, with their ids ([Writing a daemon client](daemon-http-clients.md#route-details)).
|
||||
|
||||
- **Which channel an item posts to.** The item's seat is matched exactly first (`pr@my-rig`),
|
||||
then its rig (`my-rig`). Anything unmapped posts to the default channel, so a config
|
||||
without a map behaves exactly as before. Aggregate delivery digests have no single seat and
|
||||
@@ -221,8 +240,9 @@ configuration matches the file.
|
||||
|
||||
The map is stored as `channelMap` in `slack-connector.json`, as a list of `{ "match", "channel" }`
|
||||
entries, each with an optional `"inbound"` seat. Saving refuses any other field. A field written by a newer OpenRig is ignored when the
|
||||
connector reads the file, and `rig slack status` names it. Private channels and direct messages
|
||||
are not supported targets.
|
||||
connector reads the file, and `rig slack status` names it. A private channel can be a target once
|
||||
the app has the private-channel scopes ([Using a private channel](#using-a-private-channel)).
|
||||
Direct messages are not supported targets.
|
||||
|
||||
## Named bots (optional)
|
||||
|
||||
@@ -366,6 +386,36 @@ Quiet hours hold a person's routine asks while they're on, and post them once th
|
||||
|
||||
## Reconnect recovery and status
|
||||
|
||||
When a posted ask is answered, closed, canceled or replaced elsewhere, the connector edits its
|
||||
original primary message with the status, time, actor and closure note. Buttons become option
|
||||
text and the answer hint disappears; the remaining content and reconciliation marker stay as
|
||||
posted. Supplemental messages are unchanged. This adds no post or reaction and needs no agent
|
||||
turn. Separate resolution notices retain their existing behavior.
|
||||
|
||||
A deliberately reopened direct ask says **Open again**; its options remain as text. If it has
|
||||
no committed answer, it tells the person to reply in its thread. Otherwise it says **Earlier
|
||||
answer retained**: reopening does not accept a second answer. Earlier replaced parks remain
|
||||
historical; **Asked again** does not claim that the later ask has been posted. Edits reconcile
|
||||
the current state again after awaited Slack calls, so an obsolete close is not recorded as the
|
||||
current result after a reopen.
|
||||
|
||||
Edits follow committed queue changes, with a startup catch-up for posted asks on retained queue
|
||||
rows. Each primary costs one exact-message read and one `chat.update`. The serial edit worker
|
||||
uses the recovery budget helpers (four calls, 100 entries, 15 seconds) and waits for the next
|
||||
budget window when exhausted. Slack's per-method `Retry-After` is retained across restarts.
|
||||
Only an acknowledged edit gets a success receipt. `rig slack status` shows queued reconciliation
|
||||
and the last edit failure. Transient failures retry with increasing waits, capped at an hour,
|
||||
behind other queued work. Permanent Slack failures (such as missing scope, a deleted original,
|
||||
an archived channel or an uneditable message) remain unfinished and are retried only on the next
|
||||
connector start. They do not keep consuming edit capacity. These edit calls have their own
|
||||
budget, separate from missed-message scanning below.
|
||||
|
||||
The baseline already includes public-channel history access and `chat:write`. Private-channel
|
||||
reads may require `groups:history`, which the baseline does not grant; a refused read never
|
||||
counts as an edit. An unavailable/deleted original cannot be replaced by a new post. Startup
|
||||
catch-up cannot edit rows already removed by queue retention or posts without their original
|
||||
notification receipt and channel stamp.
|
||||
|
||||
The daemon scans available top-level messages and replies broadcast with **Also send to
|
||||
channel** in the channels this machine owns: its default channel and its channel map's, except a
|
||||
channel it relays to another machine. It also scans replies in the threads it tracks in those
|
||||
|
||||
@@ -300,13 +300,23 @@ small presentation provenance, separate from queue membership. Herdr display
|
||||
labels are not that provenance. Other providers/platforms remain unsupported
|
||||
for ensure-viewer until qualified; ordinary open retains its existing behavior.
|
||||
|
||||
Writable tile clients use `tmux attach -f ignore-size`; read-only clients keep
|
||||
`-r` (which also implies ignore-size). This can avoid contributing to sizing
|
||||
when other clients establish the agent window's dimensions. It is **not resize
|
||||
isolation**: opening a tile can change the agent's terminal size when it is the
|
||||
only sizing client. Observers of a Herdr terminal do not themselves resize it;
|
||||
A writable tile attaches with `tmux attach -f ignore-size` when its session
|
||||
already has a client that sizes it, so the tile doesn't fight that client over
|
||||
the agent window's dimensions. When the session has no sizing client, as with a
|
||||
freshly launched seat, the tile attaches without the flag and sizes the window
|
||||
itself: tmux applies ignore-size across the whole server, so a session viewed
|
||||
only by ignore-size clients would otherwise keep its 80x24 creation size while
|
||||
any other session has a sizing client. Read-only clients keep `-r` (which also
|
||||
implies ignore-size), and the printed manual attach commands keep
|
||||
`-f ignore-size`. This is **not resize isolation**: opening a tile can change
|
||||
the agent's terminal size. Observers of a Herdr terminal do not themselves resize it;
|
||||
a controlling browser can. No cross-version resize guarantee is implied.
|
||||
|
||||
To type into a seat without changing its size, send the text through OpenRig
|
||||
(`rig send`, or `POST /api/transport/send`): it is written to the pane with tmux
|
||||
buffers and keys, and no client attaches. There is no supported way to attach a
|
||||
writable client that is sure never to resize the agent's window.
|
||||
|
||||
|
||||
## Following a slice or mission's current work
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ Keys:
|
||||
topology.root the topology tree root (instance altitude at its top; default $OPENRIG_HOME/topology)
|
||||
context.root addressable context library for 'rig context add' (default $OPENRIG_HOME/context)
|
||||
context.system_world default System World, replacement manifest path, or disabled
|
||||
specs.builtins_visible show built-ins in spec inventory (default true)
|
||||
skills.root versioned skill catalog (default $OPENRIG_HOME/skills)
|
||||
skills.router_line.enabled the person's yes to the seat-skills line (one line plus OpenRig's markers) in AGENTS.md / CLAUDE.md (default false)
|
||||
onboarding.default_pack.enabled deliver the two-part fresh-seat mental-model pack (default on)
|
||||
|
||||
@@ -732,17 +732,16 @@ Examples:
|
||||
.option("--json", "Complete records as JSON, including organizations and groups")
|
||||
.action(async (opts: { organization?: string; json?: boolean }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
if (opts.json) {
|
||||
const result = registry.participantsJsonView(undefined, opts.organization);
|
||||
if (!result.ok) { console.error(`refused: ${result.error}`); process.exitCode = 1; return; }
|
||||
console.log(JSON.stringify(result));
|
||||
return;
|
||||
}
|
||||
const view = registry.participantsView();
|
||||
if (!view.ok) { console.error(`refused: ${view.error}`); process.exitCode = 1; return; }
|
||||
const shown = opts.organization ? view.participants.filter((p) => p.organization?.id === opts.organization) : view.participants;
|
||||
const defaultPerson = view.default.person ? { id: view.default.person.entityId, source: view.default.source } : { id: null, reason: view.default.source === "none" ? view.default.reason : null };
|
||||
if (opts.json) {
|
||||
const side = registry.readParticipantsFile();
|
||||
console.log(JSON.stringify({ ok: true, participants: shown, default: defaultPerson,
|
||||
organizations: side.ok ? side.file.organizations : null, groups: side.ok ? side.file.groups : null,
|
||||
warnings: view.warnings, participantFile: view.sideFile }));
|
||||
return;
|
||||
}
|
||||
if (!shown.length) console.log(opts.organization ? `no participants in organization "${opts.organization}"` : "no participants yet: rig gateway human add … registers a person, rig gateway channel add … a shared channel");
|
||||
for (const p of shown) {
|
||||
console.log(`${p.id} ${p.kind} "${p.displayName}" ${p.address}${p.channel ? ` channel ${p.channel.channelId}` : ""}`);
|
||||
|
||||
@@ -24,8 +24,9 @@ import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { hostname as osHostname, userInfo } from "node:os";
|
||||
import { connect } from "node:net";
|
||||
import { execFile } from "node:child_process";
|
||||
import { getOpenRigHome } from "../openrig-compat.js";
|
||||
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry } from "../host-registry.js";
|
||||
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry, type SshHostEntry } from "../host-registry.js";
|
||||
import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executor.js";
|
||||
import { recordHostObservation, loadHostBindings, describeBindingConflict, defaultHostBindingsPath, type HostObservationOutcome } from "../host-bindings.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
@@ -167,15 +168,35 @@ function defaultDoctorDeps(): DoctorDeps {
|
||||
};
|
||||
}
|
||||
|
||||
/** Where ssh would connect for this host, from `ssh -G`, which reads ssh config without connecting.
|
||||
* Null when ssh can't resolve the target or a ProxyJump/ProxyCommand sits in between, since no
|
||||
* direct dial stands for that transport. */
|
||||
export function resolveSshEndpoint(host: SshHostEntry, timeoutMs = 1500): Promise<{ hostname: string; port: number } | null> {
|
||||
const args = [...(host.user ? ["-l", host.user] : []), "-G", host.target];
|
||||
return new Promise((resolve) => {
|
||||
execFile("ssh", args, { timeout: timeoutMs }, (error, stdout) => {
|
||||
if (error) return resolve(null);
|
||||
const conf = new Map(stdout.split("\n").map((line) => {
|
||||
const i = line.indexOf(" ");
|
||||
return [line.slice(0, i).toLowerCase(), line.slice(i + 1).trim()] as const;
|
||||
}));
|
||||
const proxied = ["proxyjump", "proxycommand"].some((k) => (conf.get(k) ?? "none") !== "none");
|
||||
const hostname = conf.get("hostname");
|
||||
const port = Number(conf.get("port"));
|
||||
resolve(hostname && Number.isInteger(port) && !proxied ? { hostname, port } : null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** FR-1 doctor legs — each failing step maps to a DISTINCT actionable error:
|
||||
* "SSH works but daemon is down" ≠ "daemon works but registry is wrong" ≠
|
||||
* "remote rig binary missing/old". */
|
||||
/** OPR.0.4.6.MH1 FR-3 — the coarse ls status word: a BOUNDED tcp dial
|
||||
* against the host's transport endpoint (ssh: target:22; http: the
|
||||
* URL's host:port). Coarse by design (the kubectl STATUS / docker
|
||||
* context ls precedent): reachable | unreachable | unknown — never a
|
||||
* hang (hard timeout), never a guess (dial failure = unreachable,
|
||||
* probe error/unparseable endpoint = unknown). */
|
||||
* against the host's transport endpoint (ssh: the host and port ssh itself
|
||||
* resolves, see resolveSshEndpoint; http: the URL's host:port). Coarse by
|
||||
* design (the kubectl STATUS / docker context ls precedent): reachable |
|
||||
* unreachable | unknown — never a hang (hard timeout), never a guess (dial
|
||||
* failure = unreachable, probe error/unparseable endpoint = unknown). */
|
||||
export async function probeHostStatus(
|
||||
host: HostEntry,
|
||||
tcpProbe: DoctorDeps["tcpProbe"],
|
||||
@@ -185,8 +206,9 @@ export async function probeHostStatus(
|
||||
let target: string;
|
||||
let port: number;
|
||||
if (host.transport === "ssh") {
|
||||
target = host.target;
|
||||
port = 22;
|
||||
const endpoint = await resolveSshEndpoint(host, timeoutMs);
|
||||
if (!endpoint) return "unknown";
|
||||
({ hostname: target, port } = endpoint);
|
||||
} else {
|
||||
const u = new URL(host.url);
|
||||
target = u.hostname.replace(/^\[|\]$/g, "");
|
||||
@@ -761,7 +783,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
cmd
|
||||
.command("pair")
|
||||
.description("Pair with a remote host from one pasted address — one approval on the target, done (FR-6)")
|
||||
.argument("<url>", "The target daemon's address (http[s]://host:port, or host:port)")
|
||||
.argument("<url>", "The target daemon's address (http[s]://host:port, or host:port); that daemon must run with OPENRIG_AUTH_BEARER_TOKEN set")
|
||||
.option("--id <id>", "Registry id for the new host (default: derived from the hostname)")
|
||||
.option("--timeout <seconds>", "How long to wait for the target-side approval", "600")
|
||||
.option("--human <address>", "Registered @external approval recipient on the target (required when several exist)")
|
||||
|
||||
@@ -42,7 +42,7 @@ export function setExperiment(file: string, enabled: boolean, options: Partial<E
|
||||
const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW, 0o600);
|
||||
try {
|
||||
const s = fs.fstatSync(fd);
|
||||
if (!s.isFile() || s.nlink !== 1 || s.uid !== process.getuid?.()) throw Error("experiment config must be an owned regular file");
|
||||
if (!s.isFile() || s.nlink !== 1 || (process.getuid !== undefined && s.uid !== process.getuid())) throw Error("experiment config must be an owned regular file");
|
||||
fs.ftruncateSync(fd, 0); fs.writeFileSync(fd, JSON.stringify(config, null, 2) + "\n");
|
||||
} finally { fs.closeSync(fd); }
|
||||
return config;
|
||||
|
||||
@@ -99,6 +99,8 @@ interface NodeEntry {
|
||||
blockedWorkCount?: number;
|
||||
/** OPR.0.3.4.11 — held reason from node.held event. */
|
||||
heldReason?: string | null;
|
||||
/** #2456: the required startup files this occupant's handover started it without. */
|
||||
startedWithoutRequired?: string[];
|
||||
agentActivity?: {
|
||||
state: "running" | "needs_input" | "idle" | "unknown";
|
||||
reason: string;
|
||||
@@ -238,6 +240,7 @@ const ALLOWED_NODE_FIELDS = new Set([
|
||||
"needsInputEvidence",
|
||||
"contextUsage",
|
||||
"heldReason",
|
||||
"startedWithoutRequired",
|
||||
"storedSessionStatus",
|
||||
"identityVerdict",
|
||||
]);
|
||||
@@ -705,6 +708,11 @@ export function formatDeclaredModel(model: string | null | undefined): string {
|
||||
return m.length > 0 ? m : "not-declared";
|
||||
}
|
||||
|
||||
/** #2456: the attention reason for an occupant started without a required startup file. */
|
||||
function startedWithoutLine(files: string[]): string {
|
||||
return `missing required ${files.map((file) => file.split("/").pop()).join(", ")}`;
|
||||
}
|
||||
|
||||
export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string, unknown>> {
|
||||
return nodes.map((n) => {
|
||||
const keepDiagnostics = n.needsAttention !== false;
|
||||
@@ -768,6 +776,7 @@ export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string,
|
||||
}
|
||||
// held/attention reason (short) when present.
|
||||
if (n.heldReason) compact.heldReason = n.heldReason;
|
||||
if (n.startedWithoutRequired?.length) compact.startedWithoutRequired = n.startedWithoutRequired;
|
||||
if (keepDiagnostics && n.latestError) {
|
||||
compact.latestError = n.latestError;
|
||||
}
|
||||
@@ -926,7 +935,7 @@ intentionallyStopped, startupStatus, restoreOutcome, lifecycleState, tmuxAttachC
|
||||
resumeCommand, latestError, terminalActive, hasAssignedWork,
|
||||
assignedWorkCount, pendingWorkCount, inProgressWorkCount, blockedWorkCount,
|
||||
agentActivity, needsAttention, arbitratedNeedsInput, needsInputEvidence,
|
||||
contextUsage, heldReason, storedSessionStatus, identityVerdict.
|
||||
contextUsage, heldReason, startedWithoutRequired, storedSessionStatus, identityVerdict.
|
||||
|
||||
--host runs on a remote host declared in ~/.openrig/hosts.yaml (no current-rig
|
||||
default applied; the remote host's rigs are shown).
|
||||
@@ -1358,7 +1367,8 @@ async function handleNodes(
|
||||
console.log(padCompactNodeRow("RIG", "SESSION", "LIFECYCLE", "ACTIVITY", "WORK", "REASON", "ATTN"));
|
||||
for (const n of humanList as NodeEntry[]) {
|
||||
const reason = missingSessionGuidance(n) ?? (n.needsAttention !== false || hasCurrentUsageLimit(n)
|
||||
? (n.latestError ? truncate(n.latestError, 40) : n.agentActivity?.reason ?? "—")
|
||||
? (n.latestError ? truncate(n.latestError, 40) : n.startedWithoutRequired?.length ? truncate(startedWithoutLine(n.startedWithoutRequired), 40)
|
||||
: n.agentActivity?.reason ?? "—")
|
||||
: livenessUnconfirmed(n) ? "liveness unconfirmed" : "—");
|
||||
console.log(padCompactNodeRow(
|
||||
n.rigName,
|
||||
@@ -1395,6 +1405,7 @@ async function handleNodes(
|
||||
formatContextUsage(n.contextUsage),
|
||||
n.restoreOutcome,
|
||||
missingSessionGuidance(n) ?? (n.latestError ? truncate(n.latestError, 30) : n.heldReason ? `held: ${truncate(n.heldReason, 25)}`
|
||||
: n.startedWithoutRequired?.length ? truncate(startedWithoutLine(n.startedWithoutRequired), 30)
|
||||
: livenessUnconfirmed(n) ? "liveness unconfirmed" : "—"),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -703,6 +703,12 @@ export function previewBody(
|
||||
};
|
||||
}
|
||||
|
||||
// Mark the cut inside the body so readers that discard metadata or stderr can request the complete brief.
|
||||
// Counts use code points, like the preview.
|
||||
export function previewCutMarker(qitemId: string, shownCodePoints: number, totalCodePoints: number): string {
|
||||
return `…[cut at ${shownCodePoints} of ${totalCodePoints} chars; rig queue show ${shellQuote(qitemId)} --full]`;
|
||||
}
|
||||
|
||||
function isRecordWithStringBody(v: unknown): v is Record<string, unknown> & { body: string } {
|
||||
return (
|
||||
typeof v === "object" &&
|
||||
@@ -1234,6 +1240,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--reopen", "Explicitly acknowledge a deliberate terminal-to-active repair; requires --state and --note")
|
||||
.option("--closure-reason <reason>", "Required for state=done. --state canceled takes no reason, except 'superseded' (with --closure-target = the successor), which records a supersession, distinct from an abandoned cancel")
|
||||
.option("--closure-target <target>", "Required for handed_off_to, escalation, and superseded; blocked_on park records use state=blocked with --blocked-on")
|
||||
.option("--closure-required-at <iso>", "Accept a revised closure deadline on an in-progress item; keeps the claim and records the old/new time and accepting actor")
|
||||
.option("--resolution <text>", "The outcome, stored on the row and shown by rig queue show. Needs --state done, canceled, denied, failed or handed-off, and non-blank text. Stored when the update closes an open row, after anything the row already has (a fallback line, an earlier outcome); a row that is already closed keeps what it has")
|
||||
.option("--blocked-on <blocker>", "For state=blocked: the blocker — a qitem id (must exist and be live), a human seat (FR-6 park; requires summary + evidence_ref), or a typed non-qitem gate 'fold:<what>' / 'auth:<what>' / 'external:<what>'")
|
||||
.option("--wake-watchdog <jobId>", "For state=blocked: attach an existing live watchdog id targeting the row owner")
|
||||
@@ -1249,6 +1256,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
reopen?: boolean;
|
||||
closureReason?: string;
|
||||
closureTarget?: string;
|
||||
closureRequiredAt?: string;
|
||||
resolution?: string;
|
||||
blockedOn?: string;
|
||||
wakeWatchdog?: string;
|
||||
@@ -1285,6 +1293,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
reopen: opts.reopen,
|
||||
closureReason: opts.closureReason,
|
||||
closureTarget: opts.closureTarget,
|
||||
...(opts.closureRequiredAt !== undefined ? { closureRequiredAt: opts.closureRequiredAt } : {}),
|
||||
resolution: opts.resolution,
|
||||
blockedOn: opts.blockedOn,
|
||||
wakeWatchdogId: opts.wakeWatchdog,
|
||||
@@ -1675,7 +1684,10 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
...(closingTransition === undefined ? [] : [omittedReadField("closingTransition", closingTransition)]),
|
||||
];
|
||||
const view = readView(item, fullCommand, omitted);
|
||||
const transformed = { ...record, body: preview, bodyBytes, bodyTruncated, readView: view };
|
||||
const shownBody = bodyTruncated
|
||||
? `${preview}${previewCutMarker(qitemId, Array.from(preview).length, Array.from(item.body).length)}`
|
||||
: preview;
|
||||
const transformed = { ...record, body: shownBody, bodyBytes, bodyTruncated, readView: view };
|
||||
// Slice 15 P2: with output.compact on, the empty fields are left out and counted.
|
||||
const compact = compactOutputEnabled() ? compactShow(transformed) : null;
|
||||
printResult(json, compact ? compact.item : transformed, res.status);
|
||||
|
||||
@@ -213,7 +213,7 @@ function buildSliceLsCommand(): Command {
|
||||
const cmd = new Command("ls")
|
||||
.description("List slices in a mission (or across all missions)")
|
||||
.option("--mission <name>", "Restrict to a single mission")
|
||||
.option("--state <state>", "Filter: active | closed | shipped | all", "active")
|
||||
.option("--state <state>", "Filter: active (in flight, blocked included) | shipped (delivered in place: shipped or done) | closed (in closed/, or superseded, retired or closed in place) | all", "active")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (opts, command) => {
|
||||
const out = makeStdout();
|
||||
@@ -727,12 +727,13 @@ function buildMissionLsCommand(): Command {
|
||||
id: m.id,
|
||||
path: m.absPath,
|
||||
activeSliceCount: m.activeSliceCount,
|
||||
shippedSliceCount: m.shippedSliceCount,
|
||||
closedSliceCount: m.closedSliceCount,
|
||||
}));
|
||||
emit(out, { ok: true, count: rows.length, missions: rows }, json,
|
||||
rows.length === 0
|
||||
? ["(no missions)"]
|
||||
: rows.map((r) => `${r.name} ${r.id ?? "—"} active=${r.activeSliceCount} closed=${r.closedSliceCount}`),
|
||||
: rows.map((r) => `${r.name} ${r.id ?? "—"} active=${r.activeSliceCount} shipped=${r.shippedSliceCount} closed=${r.closedSliceCount}`),
|
||||
);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
@@ -764,6 +765,7 @@ function buildMissionShowCommand(): Command {
|
||||
id: mission.id,
|
||||
path: mission.absPath,
|
||||
activeSliceCount: mission.activeSliceCount,
|
||||
shippedSliceCount: mission.shippedSliceCount,
|
||||
closedSliceCount: mission.closedSliceCount,
|
||||
frontmatter: mission.frontmatter,
|
||||
trust,
|
||||
@@ -778,6 +780,7 @@ function buildMissionShowCommand(): Command {
|
||||
out.write(` id: ${mission.id ?? "—"}\n`);
|
||||
out.write(formatTrustLine(trust));
|
||||
out.write(` active slices: ${mission.activeSliceCount}\n`);
|
||||
out.write(` shipped slices: ${mission.shippedSliceCount}\n`);
|
||||
out.write(` closed slices: ${mission.closedSliceCount}\n`);
|
||||
out.write(` path: ${mission.absPath}\n`);
|
||||
if (readme) {
|
||||
@@ -1100,6 +1103,7 @@ function buildAuditCommand(): Command {
|
||||
frontmatter: {},
|
||||
id: null,
|
||||
activeSliceCount: 0,
|
||||
shippedSliceCount: 0,
|
||||
closedSliceCount: 0,
|
||||
};
|
||||
const graph = buildMissionDependencyGraph(auditMission);
|
||||
@@ -1232,6 +1236,8 @@ function buildAuditCommand(): Command {
|
||||
for (const sr of sliceResults) {
|
||||
const shadow = shadowedNodeFileFinding(path.join(slicesDir, sr.name), "slice");
|
||||
if (shadow) sr.result.findings.push(shadow);
|
||||
const shared = sharedSliceNameFinding(missionsRoot, missionName, sr.name);
|
||||
if (shared) sr.result.findings.push(shared);
|
||||
}
|
||||
|
||||
const allFindings = [
|
||||
@@ -2152,6 +2158,34 @@ function shadowedNodeFileFinding(dir: string, level: "mission" | "slice"): {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Advisory-only: another mission has a slice folder of the same name. The slice list and the routes that
|
||||
* find a slice by name keep one slice per name, so the others are hidden there with no sign they exist.
|
||||
* Twin of the daemon's finding of the same name.
|
||||
*/
|
||||
function sharedSliceNameFinding(missionsRoot: string, missionName: string, sliceName: string): {
|
||||
kind: "slice_name_shared"; severity: "low"; path: string; message: string; remediation: string;
|
||||
} | null {
|
||||
let others: string[];
|
||||
try {
|
||||
others = fs.readdirSync(missionsRoot, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== missionName
|
||||
&& fs.statSync(path.join(missionsRoot, e.name, "slices", sliceName), { throwIfNoEntry: false })?.isDirectory() === true)
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (others.length === 0) return null;
|
||||
return {
|
||||
kind: "slice_name_shared",
|
||||
severity: "low",
|
||||
path: path.join(missionsRoot, missionName, "slices", sliceName),
|
||||
message: `Mission ${others.join(", ")} also ${others.length === 1 ? "has" : "have"} a slice named "${sliceName}". The slice list (GET /api/slices) and the routes that find a slice by name keep one slice per name, so all but one of these slices are hidden there.`,
|
||||
remediation: "Rename one of the slice folders so each slice name is unique across missions. Advisory only — nothing is blocked.",
|
||||
};
|
||||
}
|
||||
|
||||
export function scopeCommand(): Command {
|
||||
const cmd = new Command("scope")
|
||||
.description("Scope tree primitive: missions, slices, sub-slices (per conventions/scope-and-versioning)")
|
||||
|
||||
@@ -159,12 +159,16 @@ 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. */
|
||||
@@ -323,7 +327,11 @@ function printHumanHandoverPlan(plan: SeatHandoverPlan): void {
|
||||
}
|
||||
|
||||
function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
|
||||
console.log(`Seat handover complete: ${result.seat.ref}`);
|
||||
// #2456: a successor that started without a required startup file isn't a plain success; a start would have failed.
|
||||
const withoutRequired = result.startedWithoutRequired ?? [];
|
||||
console.log(`Seat handover complete${withoutRequired.length > 0 ? ", degraded" : ""}: ${result.seat.ref}`);
|
||||
for (const file of withoutRequired) console.log(`The successor started without required file ${file}.`);
|
||||
if (withoutRequired.length > 0) console.log("rig ps --nodes shows the seat as needing attention until it's next launched.");
|
||||
console.log(`Rig: ${result.seat.rigName}`);
|
||||
console.log(`Logical ID: ${result.seat.logicalId}`);
|
||||
console.log(`Source: ${result.source.mode}${result.source.ref ? `:${result.source.ref}` : ""}`);
|
||||
@@ -333,6 +341,7 @@ 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
|
||||
@@ -460,6 +469,11 @@ when the current input contains a draft or cannot be read. It returns the exact
|
||||
reason and held message ID immediately, without automatic retry.
|
||||
Off (--enabled false) permits new automatic sends.
|
||||
|
||||
This works from an ordinary terminal as well as from a seat's shell. From a
|
||||
managed seat's shell (OPENRIG_SESSION_NAME set), the change is recorded under
|
||||
that seat. From a terminal with no seat identity, it applies the same way and is
|
||||
recorded as "unattributed (no seat identity)".
|
||||
|
||||
Between off (always interrupt) and hold (full quiet):
|
||||
openrig-urgent OpenRig's own messages (watchdogs, scheduled wakes, notices) and
|
||||
anything marked urgent type; other agents' messages are held.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isHumanSeatSessionRef } from "../session-name.js";
|
||||
import { Command } from "commander";
|
||||
import { resolveEffectiveHost } from "../host-selection.js";
|
||||
import { DaemonClient, DaemonConnectionError, terminalAuthHeaders } from "../client.js";
|
||||
@@ -90,6 +91,9 @@ export function wrapSendBody(
|
||||
// renders EXACTLY AS RECEIVED — bare member@rig locally (copy-paste reply hint), and a
|
||||
// cross-host caller constructs the origin triple at the forwarding boundary before wrapping.
|
||||
const senderLabel = sender && sender.trim().length > 0 ? sender : SENDER_FALLBACK;
|
||||
// Only reply classification uses the bare address; From and the destination retain the origin host.
|
||||
const senderParts = senderLabel.split("@");
|
||||
const personAddress = senderParts.length === 3 && senderParts[2] ? senderParts.slice(0, 2).join("@") : senderLabel;
|
||||
const header = [`From: ${senderLabel}`, renderToLine(recipient, meta?.scope)];
|
||||
if (meta?.stampISO) {
|
||||
// GHOST-STAGE (g): the sender's occupant generation rides the Sent: line as a short suffix
|
||||
@@ -102,7 +106,9 @@ export function wrapSendBody(
|
||||
}
|
||||
const reply = senderLabel.endsWith("@external")
|
||||
? `↩ Reply if needed: rig queue create --destination ${senderLabel}${meta?.replyToQitem ? ` --human-intent update --reply-to ${meta.replyToQitem}` : ""} --summary "<one line>" --body "..." --verify`
|
||||
: `↩ Reply: rig send ${senderLabel} "..."`;
|
||||
: isHumanSeatSessionRef(personAddress)
|
||||
? `↩ Reply if needed: rig queue create --destination ${senderLabel} --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`
|
||||
: `↩ Reply: rig send ${senderLabel} "..."`;
|
||||
return [...header, "---", body, "---", reply].join("\n");
|
||||
}
|
||||
|
||||
@@ -565,7 +571,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
|
||||
const envelope = !effect
|
||||
? res.data
|
||||
: effectUnresolved(effect)
|
||||
? { ...res.data, verified: false, outcome: "staged-not-consumed", effectCheck: effect }
|
||||
? { ...res.data, verified: false, outcome: effectSubmissionUnconfirmed(effect) ? "submission-unconfirmed" : "staged-not-consumed", effectCheck: effect }
|
||||
: { ...res.data, effectCheck: effect };
|
||||
console.log(JSON.stringify(envelope));
|
||||
if (res.status >= 400) process.exitCode = res.status >= 500 ? 2 : 1;
|
||||
@@ -632,7 +638,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
|
||||
* recheck, then stops. Pure classification: no printing here.
|
||||
*/
|
||||
type EffectCheck =
|
||||
| { checked: true; state: "staged"; remedy: "submitted-cleared" | "submitted-still-staged" | "submit-refused"; detail?: string }
|
||||
| { checked: true; state: "staged"; remedy: "submitted-cleared" | "submitted-still-staged" | "submit-refused" | "submit-unconfirmed"; detail?: string }
|
||||
| { checked: true; state: "no-staged-residual" }
|
||||
| { checked: false; why: string };
|
||||
|
||||
@@ -683,16 +689,26 @@ async function classifyDeliveryEffect(
|
||||
const probe = await detectStagedAtPrompt(client, session, identity);
|
||||
if (probe.state === "unchecked") return { checked: false, why: probe.why };
|
||||
if (probe.state === "not-staged") return { checked: true, state: "no-staged-residual" };
|
||||
const enter = await client.post<Record<string, unknown>>("/api/transport/send", {
|
||||
session,
|
||||
submitOnly: true,
|
||||
expectedStagedText: identity.expectedStagedText,
|
||||
expectedStagedLineCount: identity.expectedLines,
|
||||
}, transportRequestOptions(waitForIdleMs));
|
||||
let enter: { status: number; data: Record<string, unknown> };
|
||||
try {
|
||||
enter = await client.post<Record<string, unknown>>("/api/transport/send", {
|
||||
session,
|
||||
submitOnly: true,
|
||||
expectedStagedText: identity.expectedStagedText,
|
||||
expectedStagedLineCount: identity.expectedLines,
|
||||
}, transportRequestOptions(waitForIdleMs));
|
||||
} catch (err) {
|
||||
return { checked: true, state: "staged", remedy: "submit-unconfirmed", detail: (err as Error).message };
|
||||
}
|
||||
if (enter.status >= 400) {
|
||||
return { checked: true, state: "staged", remedy: "submit-refused", detail: (enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}` };
|
||||
const phase = enter.data?.["inputPhase"];
|
||||
const reason = enter.data?.["reason"];
|
||||
const unconfirmed = phase === "attempted" || phase === "written"
|
||||
|| (phase !== "not_reached" && enter.data?.["sent"] !== false && (reason === "submit_failed" || enter.status >= 500));
|
||||
return { checked: true, state: "staged", remedy: unconfirmed ? "submit-unconfirmed" : "submit-refused", detail: (enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}` };
|
||||
}
|
||||
const recheck = await detectStagedAtPrompt(client, session, identity);
|
||||
if (recheck.state === "unchecked") return { checked: true, state: "staged", remedy: "submit-unconfirmed", detail: recheck.why };
|
||||
return recheck.state === "staged"
|
||||
? { checked: true, state: "staged", remedy: "submitted-still-staged" }
|
||||
: { checked: true, state: "staged", remedy: "submitted-cleared" };
|
||||
@@ -705,13 +721,17 @@ function effectHumanLines(effect: EffectCheck, session: string): string[] {
|
||||
return [`Note: the pane-effect check could not run (${effect.why}); the verdict above is transport-level only.`];
|
||||
}
|
||||
if (effect.state === "no-staged-residual") return [];
|
||||
if (effect.remedy === "submit-unconfirmed") return [
|
||||
`Delivery: submission unconfirmed — the text was staged before one guarded Enter attempt, but the result could not be confirmed (${effect.detail}). Inspect with: rig capture ${session}`,
|
||||
];
|
||||
if (effect.remedy === "submit-refused") return [
|
||||
`Delivery: unconfirmed — the guarded Enter was refused (${effect.detail}); the earlier staged observation may have changed. Inspect with: rig capture ${session}`,
|
||||
];
|
||||
const out = ["Delivery: staged, not consumed (checked: post-send pane capture; observed: the sent text is still at the prompt — typed, never submitted)"];
|
||||
if (effect.remedy === "submitted-cleared") {
|
||||
out.push("Remedy: one guarded Enter submitted — the staged text left the prompt.");
|
||||
} else if (effect.remedy === "submitted-still-staged") {
|
||||
out.push(`Remedy: one guarded Enter submitted, but the text is STILL at the prompt — not consumed; stopping here (one submit is the contract). Inspect with: rig capture ${session}`);
|
||||
} else {
|
||||
out.push(`Remedy: the single guarded Enter (submit path) was refused (${effect.detail}); stopping here — one submit is the contract. Inspect with: rig capture ${session}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -721,6 +741,10 @@ function effectUnresolved(effect: EffectCheck | undefined): boolean {
|
||||
return !!effect && effect.checked && effect.state === "staged" && effect.remedy !== "submitted-cleared";
|
||||
}
|
||||
|
||||
function effectSubmissionUnconfirmed(effect: EffectCheck | undefined): boolean {
|
||||
return !!effect && effect.checked && effect.state === "staged" && (effect.remedy === "submit-unconfirmed" || effect.remedy === "submit-refused");
|
||||
}
|
||||
|
||||
/**
|
||||
* S3 (OPR.0.5.4.6) — the staged-at-prompt detector, walk's staged-evidence
|
||||
* primitive generalized to plain sends. POSITIVE evidence only: text rendered
|
||||
@@ -754,7 +778,7 @@ async function detectStagedAtPrompt(
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (PROMPT_LINE_PATTERN.test(lines[i]!)) { lastPrompt = i; break; }
|
||||
}
|
||||
if (lastPrompt === -1) return { state: "not-staged" };
|
||||
if (lastPrompt === -1) return { state: "unchecked", why: "no input prompt was visible in the capture" };
|
||||
const inputRegionRaw = lines.slice(lastPrompt).join("\n").replace(PROMPT_LINE_PATTERN, "");
|
||||
// IDENTITY FIRST (round-2 F2, desk-binding): the literal-residual match uses
|
||||
// the daemon submit-precheck's own normalization (strip ALL whitespace,
|
||||
@@ -1083,6 +1107,7 @@ async function runFanOutSend(params: {
|
||||
}
|
||||
const effectBySeat = new Map((effects ?? []).map((e) => [e.sessionName, e.effect]));
|
||||
const unresolvedCount = (effects ?? []).filter((e) => effectUnresolved(e.effect)).length;
|
||||
const unconfirmedCount = (effects ?? []).filter((e) => effectSubmissionUnconfirmed(e.effect)).length;
|
||||
|
||||
if (opts.json) {
|
||||
// Round-2 F1: ONE verdict per recipient — a staged-unresolved recipient's
|
||||
@@ -1090,7 +1115,7 @@ async function runFanOutSend(params: {
|
||||
const rawResults = (res.data["results"] as Array<{ sessionName: string; ok: boolean; outcome?: string }> | undefined) ?? [];
|
||||
const encodedResults = rawResults.map((r) =>
|
||||
effectUnresolved(effectBySeat.get(r.sessionName))
|
||||
? { ...r, ok: false, verified: false, outcome: "staged-not-consumed", error: "staged, not consumed (pane effect); the one guarded submit did not clear it" }
|
||||
? { ...r, ok: false, verified: false, outcome: effectSubmissionUnconfirmed(effectBySeat.get(r.sessionName)) ? "submission-unconfirmed" : "staged-not-consumed", error: effectSubmissionUnconfirmed(effectBySeat.get(r.sessionName)) ? "delivery unconfirmed; inspect the recipient after the guarded submit attempt" : "staged, not consumed (pane effect); the one guarded submit did not clear it" }
|
||||
: r
|
||||
);
|
||||
// Round-3 (r2 row 00fb3a68): the machine-readable AGGREGATE derives from
|
||||
@@ -1133,6 +1158,8 @@ async function runFanOutSend(params: {
|
||||
if (ec && ec.checked && ec.state === "staged") {
|
||||
if (ec.remedy === "submitted-cleared") {
|
||||
console.log(`${r.sessionName}: sent — staged at the prompt, cleared by one guarded Enter (consumed)`);
|
||||
} else if (effectSubmissionUnconfirmed(ec)) {
|
||||
console.log(`${r.sessionName}: ${effectHumanLines(ec, r.sessionName)[0]}`);
|
||||
} else {
|
||||
console.log(`${r.sessionName}: staged, not consumed — ${effectHumanLines(ec, r.sessionName).slice(-1)[0]}`);
|
||||
}
|
||||
@@ -1145,7 +1172,7 @@ async function runFanOutSend(params: {
|
||||
}
|
||||
// The delivered count never includes a staged-unresolved recipient.
|
||||
const deliveredCount = Math.max(0, ((data["sent"] as number) ?? 0) - unresolvedCount);
|
||||
console.log(`${deliveredCount}/${data["total"]} delivered${unresolvedCount > 0 ? `, ${unresolvedCount} staged-not-consumed` : ""}`);
|
||||
console.log(`${deliveredCount}/${data["total"]} delivered${unresolvedCount - unconfirmedCount > 0 ? `, ${unresolvedCount - unconfirmedCount} staged-not-consumed` : ""}${unconfirmedCount > 0 ? `, ${unconfirmedCount} submission-unconfirmed` : ""}`);
|
||||
// S2 (OPR.0.5.4.3): surface additive advisories (e.g. the unknown-sender
|
||||
// sign-it notice) — an env-less operator must see it, not "sent" lines alone.
|
||||
const fanoutAdvisory = data["warning"] as string | undefined;
|
||||
|
||||
@@ -251,6 +251,7 @@ 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;
|
||||
@@ -261,6 +262,10 @@ export function slackCommand(deps: SlackDeps = {}): Command {
|
||||
backoff?: Record<string, number>; incomplete?: Record<string, number> };
|
||||
limits?: string[] } } | undefined;
|
||||
if (connector) {
|
||||
if (connector.settlement) {
|
||||
log(` Ask edits: ${connector.settlement.pending} queued for reconciliation`);
|
||||
if (connector.settlement.lastFailure) log(` Last ask-edit failure: ${connector.settlement.lastFailure}`);
|
||||
}
|
||||
if (connector.inboundRouting?.fallback === "local") log(` Configured routing fallback: local to ${connector.inboundRouting.destination} (after forwarding, thread and channel routes)`);
|
||||
log(` Socket: ${connector.inbound?.state ?? "unknown"}; generation ${connector.inbound?.generation ?? "unknown"}; last event ${connector.inbound?.lastEventAt ?? "unknown"}`);
|
||||
for (const line of socketDeliveryLines(connector.inbound)) log(` ${line}`);
|
||||
|
||||
@@ -157,21 +157,25 @@ export async function resolveLibrarySpec(
|
||||
nameOrId: string,
|
||||
opts?: { kind?: string },
|
||||
): Promise<LibraryEntry> {
|
||||
const res = await client.get<LibraryEntry[]>(
|
||||
opts?.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library",
|
||||
);
|
||||
const rawEntries = res.data ?? [];
|
||||
const entries =
|
||||
const url = opts?.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library";
|
||||
const res = await client.get<LibraryEntry[]>(url);
|
||||
const filterKind = (entries: LibraryEntry[]) =>
|
||||
opts?.kind !== undefined && !KNOWN_SPEC_KINDS.includes(opts.kind as (typeof KNOWN_SPEC_KINDS)[number])
|
||||
? rawEntries.filter((e) => e.kind === opts.kind)
|
||||
: rawEntries;
|
||||
|
||||
// Try exact ID match first
|
||||
? entries.filter((entry) => entry.kind === opts.kind)
|
||||
: entries;
|
||||
const visibleEntries = filterKind(res.data ?? []);
|
||||
// An exact ID always wins, including when a visible name matches a hidden ID.
|
||||
const visibleId = visibleEntries.find((entry) => entry.id === nameOrId);
|
||||
if (visibleId) return visibleId;
|
||||
const separator = opts?.kind !== undefined ? "&" : "?";
|
||||
const all = await client.get<LibraryEntry[]>(`${url}${separator}include_builtins=true`);
|
||||
const entries = filterKind(all.data ?? []);
|
||||
const byId = entries.find((e) => e.id === nameOrId);
|
||||
if (byId) return byId;
|
||||
|
||||
// Try name match
|
||||
const byName = entries.filter((e) => e.name === nameOrId);
|
||||
// Visible names take precedence over hidden built-ins with the same name.
|
||||
const visibleNames = visibleEntries.filter((e) => e.name === nameOrId);
|
||||
const byName = visibleNames.length ? visibleNames : entries.filter((e) => e.name === nameOrId);
|
||||
if (byName.length === 1) return byName[0]!;
|
||||
|
||||
if (byName.length > 1) {
|
||||
@@ -230,12 +234,16 @@ Examples:
|
||||
cmd.command("ls")
|
||||
.description("List library rigs, agents, and managed apps")
|
||||
.option("--kind <kind>", "Filter by kind (rig, agent, or workflow)")
|
||||
.option("--include-builtins", "Include built-ins hidden by the visibility setting")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (opts: { kind?: string; json?: boolean }) => {
|
||||
.action(async (opts: { kind?: string; json?: boolean; includeBuiltins?: boolean }) => {
|
||||
try {
|
||||
warnIfUnknownKind(opts.kind);
|
||||
const client = await getClient();
|
||||
const url = opts.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library";
|
||||
const params = new URLSearchParams();
|
||||
if (opts.kind !== undefined) params.set("kind", opts.kind);
|
||||
if (opts.includeBuiltins) params.set("include_builtins", "true");
|
||||
const url = `/api/specs/library${params.size ? `?${params}` : ""}`;
|
||||
const res = await client.get<LibraryEntry[]>(url);
|
||||
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
|
||||
const rawEntries = res.data ?? [];
|
||||
|
||||
@@ -104,7 +104,7 @@ export function streamCommand(depsOverride?: StreamDeps): Command {
|
||||
.description("Append a stream item")
|
||||
.requiredOption("--source <session>", "Source session (e.g. dev-build@starter)")
|
||||
.requiredOption("--body <text>", "Stream item body")
|
||||
.option("--hint-destination <session>", "Hint at intended destination seat")
|
||||
.option("--hint-destination <session>", "Hint at intended destination seat (the queue worker routes by this hint)")
|
||||
.option("--hint-type <type>", "Hint type (e.g. review, handoff, idea)")
|
||||
.option("--hint-urgency <urgency>", "Hint urgency (routine, urgent, critical)")
|
||||
.option("--hint-tags <tags>", "Comma-separated hint tags")
|
||||
|
||||
@@ -11,7 +11,7 @@ interface AgentLibraryEntry {
|
||||
/** Resolve the runtime's terminal seat or the shipped general-purpose agent. */
|
||||
export async function resolveDefaultAgentRef(client: DaemonClient, runtime: string): Promise<string> {
|
||||
if (runtime === "terminal") return "builtin:terminal";
|
||||
const res = await client.get<AgentLibraryEntry[]>("/api/specs/library?kind=agent");
|
||||
const res = await client.get<AgentLibraryEntry[]>("/api/specs/library?kind=agent&include_builtins=true");
|
||||
const entry = res.data?.find(
|
||||
(candidate) =>
|
||||
candidate.kind === "agent"
|
||||
|
||||
@@ -273,7 +273,7 @@ initial record is explicitly reported as unverified delivery.`)
|
||||
|
||||
let verdict = await pollConsumed();
|
||||
if (verdict === "generation-rolled") {
|
||||
failPiece(i, piece.label, "the seat's generation rolled mid-walk (a re-prime); the walk cannot continue into a different generation.");
|
||||
failPiece(i, piece.label, "the seat's generation rolled mid-walk (a re-prime); delivery is unconfirmed. Inspect the recipient before any resend; the walk cannot continue into a different generation.");
|
||||
return;
|
||||
}
|
||||
if (verdict === "timeout") {
|
||||
@@ -289,23 +289,39 @@ initial record is explicitly reported as unverified delivery.`)
|
||||
/\[Pasted text #\d+ \+\d+ lines\]/.test(pane)
|
||||
);
|
||||
if (stagedEvidence) {
|
||||
const enter = await client.post<Record<string, unknown>>("/api/transport/send", {
|
||||
session: seat,
|
||||
submitOnly: true,
|
||||
expectedStagedText: piece.content, // FULL bytes — the transport checks the rendered literal residual for contiguous containment
|
||||
expectedStagedLineCount: piece.content.split("\n").length,
|
||||
}, { headers: terminalAuthHeaders() });
|
||||
let enter: { status: number; data: Record<string, unknown> };
|
||||
try {
|
||||
enter = await client.post<Record<string, unknown>>("/api/transport/send", {
|
||||
session: seat,
|
||||
submitOnly: true,
|
||||
expectedStagedText: piece.content, // FULL bytes — the transport checks the rendered literal residual for contiguous containment
|
||||
expectedStagedLineCount: piece.content.split("\n").length,
|
||||
}, { headers: terminalAuthHeaders() });
|
||||
} catch (error) {
|
||||
failPiece(i, piece.label, `the guarded submit result is unconfirmed (${(error as Error).message}); inspect the recipient before any resend.`);
|
||||
return;
|
||||
}
|
||||
if (enter.status >= 400) {
|
||||
failPiece(i, piece.label, `typed but not consumed; the single submit retry was refused (${(enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}`}).`);
|
||||
const phase = enter.data?.["inputPhase"], reason = enter.data?.["reason"];
|
||||
const unconfirmed = phase === "attempted" || phase === "written"
|
||||
|| (phase !== "not_reached" && enter.data?.["sent"] !== false && (reason === "submit_failed" || enter.status >= 500));
|
||||
const detail = (enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}`;
|
||||
failPiece(i, piece.label, unconfirmed
|
||||
? `the guarded submit result is unconfirmed (${detail}); inspect the recipient before any resend.`
|
||||
: `the single guarded submit was refused (${detail}); the earlier staged observation may have changed. Inspect the recipient before any resend.`);
|
||||
return;
|
||||
}
|
||||
verdict = await pollConsumed();
|
||||
if (verdict === "generation-rolled") {
|
||||
failPiece(i, piece.label, "the seat's generation rolled after the guarded submit; delivery is unconfirmed. Inspect the recipient before any resend.");
|
||||
return;
|
||||
}
|
||||
if (verdict !== "consumed") {
|
||||
failPiece(i, piece.label, `typed and staged, but not consumed even after the single submit retry — the piece never entered the seat's conversation record. Not re-sending (one retry is the contract).`);
|
||||
failPiece(i, piece.label, "no matching conversation record was observed within the verification window after the single submit retry; delivery is unconfirmed. Inspect the recipient before any resend.");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
failPiece(i, piece.label, `send reported ${sendOutcome === "ok" ? "success" : "a transport error"} but the piece is neither consumed in the generation record nor staged in the pane — delivery lost; consumption verification fails this walk closed.`);
|
||||
failPiece(i, piece.label, `send reported ${sendOutcome === "ok" ? "success" : "a transport error"}, but no matching record or staged input was observed within the verification window. Delivery and consumption are unconfirmed; inspect the recipient before any resend.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ 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.
|
||||
@@ -229,6 +230,10 @@ export interface RiggedConfig {
|
||||
wakeSwapGraceSeconds: number;
|
||||
wakeHumanRung: string;
|
||||
};
|
||||
// Issue #1856 — stream intake setting
|
||||
stream: {
|
||||
intake: string;
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
|
||||
@@ -283,6 +288,7 @@ const DEFAULTS = {
|
||||
topology: { root: getDefaultOpenRigPath("topology") },
|
||||
// OPR.0.5.9.5 Wave B — canonical addressable context library.
|
||||
context: { root: getDefaultOpenRigPath("context"), systemWorld: "default" },
|
||||
specs: { builtinsVisible: true },
|
||||
skills: { root: getDefaultOpenRigPath("skills"), routerLine: { enabled: false } },
|
||||
onboarding: { defaultPack: { enabled: true } },
|
||||
health: { contextPressure: { warningPercent: 95, criticalPercent: 99 } },
|
||||
@@ -388,6 +394,10 @@ const DEFAULTS = {
|
||||
consumers: "",
|
||||
templateDir: "",
|
||||
},
|
||||
// Issue #1856 — stream intake default queue.worker; read live by the stream intake coordinator.
|
||||
stream: {
|
||||
intake: "queue.worker",
|
||||
},
|
||||
// OPR.0.4.6.FS-1 W2 — retention defaults (twin of daemon getDefaultValue).
|
||||
retention: {
|
||||
enabled: true,
|
||||
@@ -454,6 +464,7 @@ 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",
|
||||
@@ -537,6 +548,7 @@ export const VALID_KEYS = [
|
||||
// Slice 22 outcome 9 — stream retention: unreferenced items older than N days or beyond the newest N.
|
||||
"retention.stream_days",
|
||||
"retention.stream_max_items",
|
||||
"stream.intake",
|
||||
"retention.batch_size",
|
||||
// S04 — pickup-receipt stall threshold; lockstep with the daemon settings-store twin.
|
||||
"queue.pickup_stall_threshold_minutes",
|
||||
@@ -581,6 +593,7 @@ 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" },
|
||||
@@ -652,6 +665,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
"retention.watchdog_keep_per_job": { primary: "OPENRIG_RETENTION_WATCHDOG_KEEP_PER_JOB" },
|
||||
"retention.stream_days": { primary: "OPENRIG_RETENTION_STREAM_DAYS" },
|
||||
"retention.stream_max_items": { primary: "OPENRIG_RETENTION_STREAM_MAX_ITEMS" },
|
||||
"stream.intake": { primary: "OPENRIG_STREAM_INTAKE" },
|
||||
"retention.batch_size": { primary: "OPENRIG_RETENTION_BATCH_SIZE" },
|
||||
"queue.pickup_stall_threshold_minutes": { primary: "OPENRIG_QUEUE_PICKUP_STALL_THRESHOLD_MINUTES" },
|
||||
"queue.stuck_sweep_interval_seconds": { primary: "OPENRIG_QUEUE_STUCK_SWEEP_INTERVAL_SECONDS" },
|
||||
@@ -688,6 +702,7 @@ 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"],
|
||||
@@ -750,6 +765,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"retention.watchdog_keep_per_job": ["retention", "watchdogKeepPerJob"],
|
||||
"retention.stream_days": ["retention", "streamDays"],
|
||||
"retention.stream_max_items": ["retention", "streamMaxItems"],
|
||||
"stream.intake": ["stream", "intake"],
|
||||
"retention.batch_size": ["retention", "batchSize"],
|
||||
"queue.pickup_stall_threshold_minutes": ["queue", "pickupStallThresholdMinutes"],
|
||||
"queue.stuck_sweep_interval_seconds": ["queue", "stuckSweepIntervalSeconds"],
|
||||
@@ -1113,6 +1129,12 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
|
||||
throw new Error(`Invalid value for retention.stream_max_items: must be an integer >= 1, got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"stream.intake": (raw) => {
|
||||
const val = (raw ?? "").trim();
|
||||
if (val !== "off" && val !== "queue.worker" && !/^[a-zA-Z0-9\-_.]+@[a-zA-Z0-9\-_.]+$/.test(val)) {
|
||||
throw new Error(`Invalid value for stream.intake: must be "queue.worker", a canonical session (<seat>@<rig>), or "off", got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"retention.batch_size": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
|
||||
throw new Error(`Invalid value for retention.batch_size: must be an integer >= 1, got "${raw}"`);
|
||||
@@ -1259,6 +1281,7 @@ export class ConfigStore {
|
||||
root: v("context.root") as string,
|
||||
systemWorld: v("context.system_world") as string,
|
||||
},
|
||||
specs: { builtinsVisible: v("specs.builtins_visible") as boolean },
|
||||
skills: {
|
||||
root: v("skills.root") as string,
|
||||
routerLine: { enabled: v("skills.router_line.enabled") as boolean },
|
||||
@@ -1385,6 +1408,9 @@ export class ConfigStore {
|
||||
wakeSwapGraceSeconds: v("queue.wake_swap_grace_seconds") as number,
|
||||
wakeHumanRung: v("queue.wake_human_rung") as string,
|
||||
},
|
||||
stream: {
|
||||
intake: v("stream.intake") as string,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,10 @@ export type FindingKind =
|
||||
// SPEC.md compatibility — a node carrying BOTH authored files. Advisory by
|
||||
// construction (low severity never flips the exit code): SPEC.md wins, and the
|
||||
// shadowed README.md is a state to notice, never a failure to gate on.
|
||||
| "shadowed_node_file";
|
||||
| "shadowed_node_file"
|
||||
// Another mission has a slice folder of the same name, which the slice list and the name-only routes
|
||||
// hide. Advisory (low severity): renaming is the person's call.
|
||||
| "slice_name_shared";
|
||||
|
||||
export interface AuditFinding {
|
||||
kind: FindingKind;
|
||||
|
||||
@@ -362,31 +362,23 @@ export function listMissions(missionsRoot: string): MissionInfo[] {
|
||||
const hasReadme = readmePath !== null;
|
||||
if (!hasReadme) continue;
|
||||
const frontmatter = readFrontmatter(readmePath);
|
||||
const slicesDir = path.join(absPath, "slices");
|
||||
const closedDir = path.join(absPath, "closed");
|
||||
const activeSliceCount = countSliceDirs(slicesDir);
|
||||
const closedSliceCount = countSliceDirs(closedDir);
|
||||
const id = pickIdFromFrontmatter(frontmatter);
|
||||
out.push({
|
||||
name: entry.name,
|
||||
absPath,
|
||||
readmePath: hasReadme ? readmePath : null,
|
||||
frontmatter,
|
||||
id,
|
||||
activeSliceCount,
|
||||
closedSliceCount,
|
||||
});
|
||||
out.push(withSliceCounts({ name: entry.name, absPath, readmePath, frontmatter, id }));
|
||||
}
|
||||
// Stable order: by mission name.
|
||||
out.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return out;
|
||||
}
|
||||
|
||||
function countSliceDirs(dir: string): number {
|
||||
if (!fs.existsSync(dir)) return 0;
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && SLICE_DIRNAME_RE.test(e.name))
|
||||
.length;
|
||||
/** The mission with each of its slices counted once, by its standing (the same one `slice ls` filters on). */
|
||||
function withSliceCounts(base: Omit<MissionInfo, "activeSliceCount" | "shippedSliceCount" | "closedSliceCount">): MissionInfo {
|
||||
const mission: MissionInfo = { ...base, activeSliceCount: 0, shippedSliceCount: 0, closedSliceCount: 0 };
|
||||
for (const { standing } of slicesWithStanding(mission)) {
|
||||
if (standing === "active") mission.activeSliceCount++;
|
||||
else if (standing === "shipped") mission.shippedSliceCount++;
|
||||
else mission.closedSliceCount++;
|
||||
}
|
||||
return mission;
|
||||
}
|
||||
|
||||
function pickIdFromFrontmatter(fm: Record<string, unknown>): string | null {
|
||||
@@ -425,15 +417,7 @@ export function findMission(missionsRoot: string, identifier: string): MissionIn
|
||||
function buildMissionInfo(missionsRoot: string, absPath: string): MissionInfo {
|
||||
const readmePath = resolveNodeFile(absPath);
|
||||
const frontmatter = readmePath ? readFrontmatter(readmePath) : {};
|
||||
return {
|
||||
name: path.basename(absPath),
|
||||
absPath,
|
||||
readmePath,
|
||||
frontmatter,
|
||||
id: pickIdFromFrontmatter(frontmatter),
|
||||
activeSliceCount: countSliceDirs(path.join(absPath, "slices")),
|
||||
closedSliceCount: countSliceDirs(path.join(absPath, "closed")),
|
||||
};
|
||||
return withSliceCounts({ name: path.basename(absPath), absPath, readmePath, frontmatter, id: pickIdFromFrontmatter(frontmatter) });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -444,35 +428,35 @@ export function listSlices(
|
||||
mission: MissionInfo,
|
||||
state: SliceState,
|
||||
): SliceInfo[] {
|
||||
const dirs: Array<{ root: string; bucket: "active" | "closed" }> = [];
|
||||
if (state === "active" || state === "shipped" || state === "all") {
|
||||
dirs.push({ root: path.join(mission.absPath, "slices"), bucket: "active" });
|
||||
}
|
||||
if (state === "closed" || state === "all") {
|
||||
dirs.push({ root: path.join(mission.absPath, "closed"), bucket: "closed" });
|
||||
}
|
||||
const out: SliceInfo[] = [];
|
||||
for (const { root } of dirs) {
|
||||
return slicesWithStanding(mission)
|
||||
.filter(({ standing }) => state === "all" || standing === state)
|
||||
.map(({ slice }) => slice);
|
||||
}
|
||||
|
||||
/** Where a slice stands. In closed/, it's closed. In place: shipped (or done) is delivered; a closed or superseded
|
||||
* status, or a superseded or retired stage, is an exit, so closed; anything else, blocked work included, is active. */
|
||||
export function sliceStanding(slice: SliceInfo, inClosedFolder: boolean): Exclude<SliceState, "all"> {
|
||||
if (inClosedFolder) return "closed";
|
||||
const status = (slice.status ?? "").toLowerCase();
|
||||
const stage = typeof slice.frontmatter.stage === "string" ? slice.frontmatter.stage.toLowerCase() : "";
|
||||
if (status === "done" || status.startsWith("shipped")) return "shipped";
|
||||
if (status.startsWith("closed") || status === "superseded" || stage === "superseded" || stage === "retired") return "closed";
|
||||
return "active";
|
||||
}
|
||||
|
||||
/** Every slice in the mission (slices/ and closed/), in order, each with its standing. */
|
||||
function slicesWithStanding(mission: MissionInfo): Array<{ slice: SliceInfo; standing: Exclude<SliceState, "all"> }> {
|
||||
const out: Array<{ slice: SliceInfo; standing: Exclude<SliceState, "all"> }> = [];
|
||||
for (const [folder, inClosedFolder] of [["slices", false], ["closed", true]] as const) {
|
||||
const root = path.join(mission.absPath, folder);
|
||||
if (!fs.existsSync(root)) continue;
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const m = SLICE_DIRNAME_RE.exec(entry.name);
|
||||
if (!m) continue;
|
||||
const sliceInfo = buildSliceInfo(mission, root, entry.name);
|
||||
out.push(sliceInfo);
|
||||
if (!entry.isDirectory() || !SLICE_DIRNAME_RE.test(entry.name)) continue;
|
||||
const slice = buildSliceInfo(mission, root, entry.name);
|
||||
out.push({ slice, standing: sliceStanding(slice, inClosedFolder) });
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => (a.nn ?? Number.MAX_SAFE_INTEGER) - (b.nn ?? Number.MAX_SAFE_INTEGER) || a.name.localeCompare(b.name));
|
||||
if (state === "active") {
|
||||
// active = not in closed/, and not already delivered in-place.
|
||||
return out.filter((s) => {
|
||||
const st = (s.status ?? "").toLowerCase();
|
||||
return st !== "done" && !st.startsWith("closed") && !st.startsWith("shipped");
|
||||
});
|
||||
}
|
||||
if (state === "shipped") {
|
||||
return out.filter((s) => (s.status ?? "").toLowerCase().startsWith("shipped"));
|
||||
}
|
||||
out.sort((a, b) => (a.slice.nn ?? Number.MAX_SAFE_INTEGER) - (b.slice.nn ?? Number.MAX_SAFE_INTEGER) || a.slice.name.localeCompare(b.slice.name));
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,10 @@ export interface MissionInfo {
|
||||
/** Minted dot-ID for this mission (read from frontmatter if present,
|
||||
* otherwise inferred from the folder name pattern). */
|
||||
id: string | null;
|
||||
/** Slices in flight (blocked included); delivered in place; and closed (closed/, or an exit in place). Each slice is
|
||||
* counted once, by the standing `slice ls --state` filters on. */
|
||||
activeSliceCount: number;
|
||||
shippedSliceCount: number;
|
||||
closedSliceCount: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface SeatLaunchAdditionsView {
|
||||
agentPromptReplacesDefault?: boolean;
|
||||
appendSystemPromptFile?: string;
|
||||
pluginDirs: string[];
|
||||
/** #2044: absent from a daemon before it, and when the seat has none. */
|
||||
pluginChannels?: string[];
|
||||
missing: string[];
|
||||
refused: string[];
|
||||
}
|
||||
@@ -17,6 +19,7 @@ export function seatLaunchAdditionLines(additions: SeatLaunchAdditionsView | nul
|
||||
...(additions.agent ? [`agent ${additions.agent} (--agent${additions.agentPromptReplacesDefault ? "; its prompt replaces Claude's default system prompt" : ""})`] : []),
|
||||
...(additions.appendSystemPromptFile ? [`prompt ${additions.appendSystemPromptFile} (--append-system-prompt-file)`] : []),
|
||||
...additions.pluginDirs.map((dir) => `plugin dir ${dir} (CLAUDE_CODE_PLUGIN_DIRS)`),
|
||||
...(additions.pluginChannels ?? []).map((entry) => `channel ${entry} (--channels)`),
|
||||
...additions.missing.map((file) => `missing ${file}: not passed to the launch`),
|
||||
...additions.refused.map((reason) => `refused ${reason}: not passed to the launch`),
|
||||
];
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("bootstrap source paths respect the daemon target", () => {
|
||||
// The responder reads relative paths from a different root, like an already-running daemon.
|
||||
server = http.createServer(async (req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
if (req.url === "/api/specs/library") {
|
||||
if (req.url === "/api/specs/library" || req.url === "/api/specs/library?include_builtins=true") {
|
||||
catalogReads++;
|
||||
res.end(JSON.stringify(catalog));
|
||||
return;
|
||||
@@ -184,7 +184,7 @@ describe("bootstrap source paths respect the daemon target", () => {
|
||||
("preserves catalog source paths (explicit URL=%s, plan=%s)", async (explicitUrl, plan) => {
|
||||
if (explicitUrl) vi.stubEnv("OPENRIG_URL", daemonUrl);
|
||||
await run("bootstrap", "catalog-rig", [plan ? "--plan" : "--yes"]);
|
||||
expect(catalogReads).toBe(1);
|
||||
expect(catalogReads).toBe(2);
|
||||
expect(requests[0]?.sourceRef).toBe(catalog[0]!.sourcePath);
|
||||
expect(requests[0]?.content).toBe("name: daemon\n");
|
||||
});
|
||||
@@ -192,7 +192,7 @@ describe("bootstrap source paths respect the daemon target", () => {
|
||||
it.each([false, true])("preserves unresolved bare-name fallback (explicit URL=%s)", async (explicitUrl) => {
|
||||
if (explicitUrl) vi.stubEnv("OPENRIG_URL", daemonUrl);
|
||||
await run("bootstrap", "unlisted", ["--plan"]);
|
||||
expect(catalogReads).toBe(1);
|
||||
expect(catalogReads).toBe(2);
|
||||
expect(requests[0]?.sourceRef).toBe("unlisted");
|
||||
});
|
||||
|
||||
@@ -208,7 +208,7 @@ describe("bootstrap source paths respect the daemon target", () => {
|
||||
if (explicitUrl) vi.stubEnv("OPENRIG_URL", daemonUrl);
|
||||
catalog.push({ ...catalog[0]!, id: "other-id", sourcePath: path.join(callerDir, "rig.yaml") });
|
||||
await run("bootstrap", "catalog-rig", ["--plan"]);
|
||||
expect(catalogReads).toBe(1);
|
||||
expect(catalogReads).toBe(2);
|
||||
expect(requests).toEqual([]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
@@ -400,7 +400,7 @@ describe("Bootstrap CLI", () => {
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer) => { body += chunk; });
|
||||
req.on("end", () => {
|
||||
if (url === "/api/specs/library") {
|
||||
if (url === "/api/specs/library" || url === "/api/specs/library?include_builtins=true") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify([{ id: "lib1", name: "my-lib-rig", sourcePath: "/specs/my-lib-rig.yaml", kind: "rig", version: "0.2", sourceType: "user_file" }]));
|
||||
} else if (url.includes("/api/bootstrap")) {
|
||||
|
||||
@@ -138,6 +138,7 @@ 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",
|
||||
@@ -211,6 +212,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
"retention.watchdog_keep_per_job",
|
||||
"retention.stream_days", // slice 22 outcome 9: stream retention
|
||||
"retention.stream_max_items",
|
||||
"stream.intake",
|
||||
"retention.batch_size",
|
||||
"queue.pickup_stall_threshold_minutes",
|
||||
// S02 — standing-stuck-sweep cadence + unclaimed-obligation age.
|
||||
@@ -234,6 +236,16 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
expect(() => store.set("daemon.db_conflict", "always")).toThrow(/warn or stop/);
|
||||
});
|
||||
|
||||
it("defaults stream.intake to queue.worker and validates destinations", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(store.get("stream.intake")).toBe("queue.worker");
|
||||
store.set("stream.intake", "off");
|
||||
expect(store.get("stream.intake")).toBe("off");
|
||||
store.set("stream.intake", "lead@kernel");
|
||||
expect(store.get("stream.intake")).toBe("lead@kernel");
|
||||
expect(() => store.set("stream.intake", "invalid-bare-seat")).toThrow(/Invalid value for stream.intake/);
|
||||
});
|
||||
|
||||
it("resolves context.root from the canonical default and OPENRIG_CONTEXT_ROOT", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(store.resolveWithSource("context.root")).toMatchObject({
|
||||
|
||||
@@ -63,6 +63,14 @@ describe("ConfigStore", () => {
|
||||
});
|
||||
|
||||
// Test 1
|
||||
it("persists the built-in inventory preference as a validated boolean", () => {
|
||||
const store = new ConfigStore(join(tmpDir, "config.json"));
|
||||
expect(store.resolveOne("specs.builtins_visible").value).toBe(true);
|
||||
store.set("specs.builtins_visible", "false");
|
||||
expect(store.resolve().specs.builtinsVisible).toBe(false);
|
||||
expect(() => store.set("specs.builtins_visible", "no")).toThrow();
|
||||
});
|
||||
|
||||
it("resolve() returns defaults when no config file and no env", () => {
|
||||
const store = new ConfigStore(join(tmpDir, "config.json"));
|
||||
const config = store.resolve();
|
||||
|
||||
@@ -484,12 +484,17 @@ 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).toBe(0);
|
||||
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.stderr}${result.stdout}`).toMatch(/Usage: rig/);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -6,6 +6,7 @@ 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.
|
||||
@@ -19,10 +20,24 @@ 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();
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// `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([]);
|
||||
});
|
||||
});
|
||||
@@ -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, waitForDeliveryOutcome } from "../src/commands/queue.js";
|
||||
import { resolveQueueBody, previewBody, previewCutMarker, waitForDeliveryOutcome } from "../src/commands/queue.js";
|
||||
import { createProgram } from "../src/index.js";
|
||||
|
||||
/**
|
||||
@@ -1204,6 +1204,17 @@ 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 });
|
||||
@@ -1849,7 +1860,8 @@ describe("rig queue CLI", () => {
|
||||
const printed = JSON.parse(logs[0]);
|
||||
expect(printed.bodyTruncated).toBe(true);
|
||||
expect(printed.bodyBytes).toBe(1000);
|
||||
expect(Array.from(printed.body).length).toBe(512);
|
||||
// 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]`);
|
||||
// #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");
|
||||
@@ -1892,7 +1904,24 @@ describe("rig queue CLI", () => {
|
||||
const printed = JSON.parse(logs[0]);
|
||||
expect(printed.bodyTruncated).toBe(true);
|
||||
expect(printed.bodyBytes).toBe(1000);
|
||||
expect(Array.from(printed.body).length).toBe(PREVIEW_MAX_CODEPOINTS);
|
||||
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]`);
|
||||
});
|
||||
|
||||
it("show --full --json: byte-identical COMPLETE item body (compatibility contract — no preview fields)", async () => {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// 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") });
|
||||
});
|
||||
});
|
||||
@@ -678,6 +678,17 @@ 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", () => {
|
||||
|
||||
@@ -326,6 +326,44 @@ 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"], missing: ["/s/gone"], refused: ["/s/linked is not inside this seat's storage"] };
|
||||
pluginDirs: ["/s/additions/plugins/review"], pluginChannels: ["plugin:example-channel@example"], 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,6 +491,7 @@ 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");
|
||||
});
|
||||
|
||||
@@ -793,6 +793,19 @@ 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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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", () => {
|
||||
@@ -10,6 +11,46 @@ 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");
|
||||
|
||||
@@ -285,6 +285,185 @@ 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"]);
|
||||
|
||||
@@ -7,6 +7,23 @@ 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(() => {});
|
||||
|
||||
@@ -60,6 +60,7 @@ const RIG_REVIEW = {
|
||||
describe("Specs CLI", () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let lastLibraryUrl = "";
|
||||
const libraryRequests: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -68,6 +69,7 @@ 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;
|
||||
@@ -137,6 +139,13 @@ 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 () => {
|
||||
@@ -165,6 +174,16 @@ 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 () => {
|
||||
@@ -231,7 +250,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 === "/api/specs/library?kind=rig") {
|
||||
if (url.startsWith("/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 {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1408,7 +1408,7 @@ describe("Up CLI", () => {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
const url = decodeURIComponent(req.url ?? "");
|
||||
if (url === "/api/specs/library?kind=rig") {
|
||||
if (url.startsWith("/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") {
|
||||
|
||||
@@ -320,6 +320,22 @@ 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 => {
|
||||
@@ -333,6 +349,7 @@ 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 () => {
|
||||
@@ -348,6 +365,31 @@ 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) => {
|
||||
@@ -372,6 +414,28 @@ 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,7 +537,9 @@ 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.
|
||||
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`.
|
||||
- **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,9 +98,10 @@ node "$SKILL_DIR/scripts/backup-sqlite.mjs" \
|
||||
--destination "/safe/path/openrig-before-upgrade.sqlite"
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Plan or apply safe managed-plugin refreshes
|
||||
|
||||
|
||||
+90
-14
@@ -4,6 +4,28 @@ 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];
|
||||
@@ -33,22 +55,76 @@ if (destinationPath.includes("'") || destinationPath.includes("\n")) {
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
||||
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(
|
||||
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") {
|
||||
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 {
|
||||
fail(
|
||||
check.stderr?.trim() || `backup integrity result was ${JSON.stringify(integrity)}`,
|
||||
"preserve the failed backup for diagnosis only; do not use it for rollback",
|
||||
"neither sqlite3 CLI nor node:sqlite is available to create a backup",
|
||||
"install sqlite3 CLI or run on Node with node:sqlite support",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ fleet can pick them up.
|
||||
|
||||
## What you do
|
||||
|
||||
- Inspect new stream items with `rig stream list --json`. Hint-destination
|
||||
- Inspect new stream items with `rig stream list --json`. When stream items carry `--hint-destination`, route by that hinted destination seat. 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,7 +19,8 @@ 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, mergeManagedBlock, perSeatRoleSkipWarning, DEFAULT_CLAUDE_MANAGED_BLOCK_FILE, type ClaudeManagedBlockFile } from "../domain/managed-blocks.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 { claudeResumeLaunchEnv, claudeResumeLaunchPrefix } from "./claude-resume-launch.js";
|
||||
import { shellQuote } from "./shell-quote.js";
|
||||
import { validateClaudeActivityHookDelivery, claudeActivityRelayPath, parseManagedClaudeNodeCommand } from "../domain/claude-activity-hooks.js";
|
||||
@@ -29,7 +30,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 } from "../domain/seat-launch-additions.js";
|
||||
import { seatLaunchAdditionArgs, seatLaunchAdditionShellArgs } from "../domain/seat-launch-additions.js";
|
||||
import { recordSeatSkillWrite, seatPluginSkillsLossNote, seatProjectionComplete, seatSkillWriteRefusal } from "../domain/seat-skill-storage.js";
|
||||
|
||||
export interface ClaudeAdapterFsOps {
|
||||
@@ -99,6 +100,8 @@ 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;
|
||||
@@ -125,6 +128,9 @@ 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;
|
||||
@@ -142,6 +148,7 @@ 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[]> {
|
||||
@@ -378,7 +385,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 = additionArgs.map((arg, index) => ` ${index % 2 === 0 ? arg : shellQuote(arg)}`).join(""); // flag, value pairs
|
||||
const additionArg = seatLaunchAdditionShellArgs(binding.launchAdditions, shellQuote); // each flag bare, each value quoted
|
||||
|
||||
// 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,
|
||||
@@ -1018,11 +1025,11 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
if (warning) warnings.push(warning);
|
||||
return false;
|
||||
}
|
||||
mergeManagedBlock(this.fs, targetPath, blockId, content, {
|
||||
warnings,
|
||||
// #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,
|
||||
replaceBlockIds: blockId === "openrig-start.md" ? ["using-openrig.md"] : [],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private detectDeliveryHint(path: string, content: string, orientation?: string): "guidance_merge" | "skill_install" | "send_text" {
|
||||
|
||||
@@ -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, type SeatLaunchAdditions } from "../domain/seat-launch-additions.js";
|
||||
import { seatLaunchAdditionArgs, seatLaunchAdditionShellArgs, 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 = additionArgs.map((arg, index) => ` ${index % 2 === 0 ? arg : shellQuote(arg)}`).join(""); // flag, value pairs
|
||||
const additionArg = seatLaunchAdditionShellArgs(launchAdditions, shellQuote); // each flag bare, each value quoted
|
||||
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 only with Claude's interactive prompt
|
||||
// visible, and after the untrusted screen classifiers have had their say.
|
||||
// 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.
|
||||
const mayBeWrappedComposer = finalProbe.status === "inconclusive"
|
||||
|| (finalProbe.status === "failed" && finalProbe.code === "returned_to_shell");
|
||||
if (/(^|\n)\s*❯/.test(finalContent) && mayBeWrappedComposer) {
|
||||
@@ -273,6 +273,15 @@ 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,6 +19,7 @@ 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";
|
||||
@@ -36,7 +37,8 @@ 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, mergeManagedBlock, perSeatRoleSkipWarning } from "../domain/managed-blocks.js";
|
||||
import { isPerSeatRoleGuidance, managedBlockText, perSeatRoleSkipWarning } from "../domain/managed-blocks.js";
|
||||
import { writeManagedGuidanceBlock, type ManagedBlockRecords } from "../domain/managed-block-records.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";
|
||||
@@ -68,7 +70,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 } from "../domain/seat-skill-storage.js";
|
||||
import { recordSeatSkillWrite, seatPluginSkillsLossNote, seatProjectionComplete, seatSkillWriteRefusal, SEAT_SKILLS_BOOTSTRAP_BLOCK_ID } 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";
|
||||
|
||||
@@ -196,6 +198,10 @@ 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: {
|
||||
@@ -239,7 +245,11 @@ 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;
|
||||
@@ -791,6 +801,7 @@ 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[] = [];
|
||||
@@ -803,7 +814,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
}
|
||||
|
||||
try {
|
||||
const didProject = this.projectEntry(entry, binding.cwd, warnings, binding.seatSkillsRoot, binding.seatSkillsRefused, this.homeFor(binding));
|
||||
const didProject = this.projectEntry(entry, binding.cwd, warnings, binding.seatSkillsRoot, binding.seatSkillsRefused, this.homeFor(binding), binding);
|
||||
if (didProject) {
|
||||
projected.push(entry.effectiveId);
|
||||
} else {
|
||||
@@ -833,8 +844,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
|
||||
switch (hint) {
|
||||
case "guidance_merge": {
|
||||
const targetPath = nodePath.join(binding.cwd, "AGENTS.md");
|
||||
const merged = this.mergeGuidance(targetPath, file.path, content, warnings);
|
||||
const merged = this.placeGuidance(binding.cwd, binding, file.path, content, warnings);
|
||||
if (!merged) continue; // rig-role skip: do not count as delivered
|
||||
break;
|
||||
}
|
||||
@@ -949,7 +959,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}`;
|
||||
: `codex${daemonArg}${postureArg}${networkArg} -C ${shellQuote(binding.cwd)}${gitDirArg}${seatFolderArg}${queueStateDirArg}${modelArg}${effortArg}${binding.guidanceLaunchInstructions ? this.developerInstructionsArg(binding.tmuxSession) : ""}`;
|
||||
|
||||
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" };
|
||||
@@ -1370,15 +1380,14 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
}
|
||||
|
||||
private projectEntry(entry: ProjectionEntry, cwd: string, warnings: string[], seatSkillsRoot?: string, seatSkillsRefused?: string,
|
||||
home?: string): boolean {
|
||||
home?: string, binding?: NodeBinding): 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.mergeGuidance(targetPath, entry.effectiveId, content, warnings, entry.absolutePath);
|
||||
return this.placeGuidance(cwd, binding, entry.effectiveId, content, warnings, entry.absolutePath);
|
||||
}
|
||||
|
||||
// HG-1.3 plugin runtime applicability filter (per DESIGN.md §5.1):
|
||||
@@ -1529,6 +1538,78 @@ 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
|
||||
@@ -1543,11 +1624,11 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
if (warning) warnings.push(warning);
|
||||
return false;
|
||||
}
|
||||
mergeManagedBlock(this.fs, targetPath, blockId, content, {
|
||||
warnings,
|
||||
// #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,
|
||||
replaceBlockIds: blockId === "openrig-start.md" ? ["using-openrig.md"] : [],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private detectDeliveryHint(path: string, content: string, orientation?: string): "guidance_merge" | "skill_install" | "send_text" {
|
||||
@@ -2123,6 +2204,21 @@ 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;
|
||||
|
||||
@@ -13,4 +13,9 @@ export class OmpRuntimeAdapter extends PiRuntimeAdapter {
|
||||
if (!tmuxSession) return null;
|
||||
return nodePath.join(piSeatPaths(this.stateRoot, tmuxSession).agentDir, "skills", effectiveId, "SKILL.md");
|
||||
}
|
||||
|
||||
/** #875: OMP keeps its guidance in the repository AGENTS.md. Pi's agent-folder loading was shown natively; OMP's wasn't. */
|
||||
override guidanceFileFor(): { file: string | null; note?: string } {
|
||||
return { file: null };
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,9 @@ 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, mergeManagedBlock, perSeatRoleSkipWarning } from "../domain/managed-blocks.js";
|
||||
import { isPerSeatRoleGuidance, perSeatRoleSkipWarning } from "../domain/managed-blocks.js";
|
||||
import { writeManagedGuidanceBlock, type ManagedBlockRecords } from "../domain/managed-block-records.js";
|
||||
import { guidanceFileSharedByLink } from "../domain/restore-guidance.js";
|
||||
import { observePiResourceTrust, observeOmpApprovalMode } from "../domain/permission-drift.js";
|
||||
import {
|
||||
piSeatPaths, parsePiRunnerState, buildPiRunnerCommand, buildPendingRunnerState,
|
||||
@@ -40,6 +42,8 @@ export interface PiAdapterFsOps {
|
||||
listFiles?(dirPath: string): string[];
|
||||
statMode?(path: string): number;
|
||||
chmod?(path: string, mode: number): void;
|
||||
/** Whether a path is a symlink and its hard-link count, or null when it doesn't exist (an lstat). */
|
||||
linkInfo?(path: string): { link: boolean; nlink: number } | null;
|
||||
}
|
||||
|
||||
export interface PiRuntimeAdapterDeps {
|
||||
@@ -63,6 +67,9 @@ 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 {
|
||||
@@ -77,8 +84,10 @@ 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;
|
||||
@@ -182,9 +191,10 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
switch (hint) {
|
||||
case "guidance_merge": {
|
||||
// ADDITIVE context-file merge (Omnigent best-practice: never
|
||||
// replace Pi's default system prompt). Pi reads AGENTS.md from
|
||||
// the managed cwd as a project context file.
|
||||
const targetPath = nodePath.join(binding.cwd, "AGENTS.md");
|
||||
// replace Pi's default system prompt). Pi reads AGENTS.md from its
|
||||
// agent folder and from the managed cwd as context files.
|
||||
const targetPath = this.guidanceTarget(binding, warnings);
|
||||
if (!targetPath) continue; // not written through a link: not counted as delivered
|
||||
const merged = this.mergeGuidance(targetPath, file.path, content, warnings);
|
||||
if (!merged) continue; // rig-role skip: do not count as delivered
|
||||
break;
|
||||
@@ -440,9 +450,32 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
/** #875: a Pi seat's managed guidance goes into its own agent folder's AGENTS.md, which Pi loads before the repository's
|
||||
* (PI_CODING_AGENT_DIR is that folder), so the person's tracked repository AGENTS.md is never written. The repository
|
||||
* file stays when there's no session yet, or when the folder or its AGENTS.md is a link: through a link, other sessions
|
||||
* (a person's own Pi home, a shared file) would read this rig's guidance. */
|
||||
guidanceFileFor(binding: NodeBinding): { file: string | null; note?: string } {
|
||||
if (!binding.tmuxSession) return { file: null };
|
||||
const file = nodePath.join(piSeatPaths(this.stateRoot, binding.tmuxSession).agentDir, "AGENTS.md");
|
||||
const linked = guidanceFileSharedByLink(file, (path) => this.fs.linkInfo?.(path) ?? null);
|
||||
return linked ? { file: null, note: `${this.runtime}: ${linked} is a link, so this seat's managed guidance stays in its repository AGENTS.md` } : { file };
|
||||
}
|
||||
|
||||
/** The file this launch merges guidance into: the one its startup chose (guidanceFileFor, or a restore's saved choice),
|
||||
* else the repository AGENTS.md in the managed cwd. Null when a chosen file has since become a link (#2432): nothing is
|
||||
* written through it, and nothing falls back to the repository file. */
|
||||
private guidanceTarget(binding: NodeBinding, warnings: string[]): string | null {
|
||||
if (!binding.guidanceFile) return nodePath.join(binding.cwd, "AGENTS.md");
|
||||
const linked = guidanceFileSharedByLink(binding.guidanceFile, (path) => this.fs.linkInfo?.(path) ?? null);
|
||||
if (!linked) return binding.guidanceFile;
|
||||
warnings.push(`${this.runtime}: ${linked} is now a link, so this seat's managed guidance wasn't written to ${binding.guidanceFile}, which other sessions could read`);
|
||||
return null;
|
||||
}
|
||||
|
||||
private projectEntry(entry: ProjectionEntry, binding: NodeBinding, warnings: string[]): boolean {
|
||||
if (entry.category === "guidance" && entry.mergeStrategy === "managed_block") {
|
||||
const targetPath = nodePath.join(binding.cwd, "AGENTS.md");
|
||||
const targetPath = this.guidanceTarget(binding, warnings);
|
||||
if (!targetPath) return false;
|
||||
const content = this.fs.readFile(entry.absolutePath);
|
||||
return this.mergeGuidance(targetPath, entry.effectiveId, content, warnings, entry.absolutePath);
|
||||
}
|
||||
@@ -500,10 +533,10 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
if (warning) warnings.push(warning);
|
||||
return false;
|
||||
}
|
||||
mergeManagedBlock(this.fs, targetPath, blockId, content, {
|
||||
warnings,
|
||||
// #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,
|
||||
replaceBlockIds: blockId === "openrig-start.md" ? ["using-openrig.md"] : [],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,8 @@ 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, mergeManagedBlock, perSeatRoleSkipWarning } from "../domain/managed-blocks.js";
|
||||
import { isPerSeatRoleGuidance, perSeatRoleSkipWarning } from "../domain/managed-blocks.js";
|
||||
import { writeManagedGuidanceBlock, type ManagedBlockRecords } from "../domain/managed-block-records.js";
|
||||
import {
|
||||
stubSeatSidecarPath, buildStubRunnerCommand, parseStubRunnerState,
|
||||
type StubRunnerState,
|
||||
@@ -56,6 +57,9 @@ 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
|
||||
@@ -74,8 +78,10 @@ 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";
|
||||
@@ -329,10 +335,10 @@ export class StubRuntimeAdapter implements RuntimeAdapter {
|
||||
if (warning) warnings.push(warning);
|
||||
return false;
|
||||
}
|
||||
mergeManagedBlock(this.fsOps, targetPath, blockId, content, {
|
||||
warnings,
|
||||
// #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,
|
||||
replaceBlockIds: blockId === "openrig-start.md" ? ["using-openrig.md"] : [],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,24 @@ 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
|
||||
@@ -73,6 +91,19 @@ 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
|
||||
@@ -83,9 +114,10 @@ export const execCommand: 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 = { windowsHide: true, maxBuffer: TMUX_MAX_BUFFER };
|
||||
const opts = tmuxExecOptions;
|
||||
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;
|
||||
@@ -100,6 +132,7 @@ export const execArgvCommand: ArgvExecFn = async (argv: string[]): Promise<strin
|
||||
return (await run(null)).stdout;
|
||||
}
|
||||
} catch (err) {
|
||||
warnTmuxDeadline(err, started, argv);
|
||||
throw withExecOutput(err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -919,7 +919,16 @@ export class TmuxAdapter {
|
||||
const identity = this.freshManaged.get(target)?.nodeId ?? target;
|
||||
const checkDraft = guard?.needsInputCheck(identity);
|
||||
const policy = checkDraft || options?.beforePaste ? {
|
||||
beforePaste: async () => { if (checkDraft) await guard!.beforeInput(identity, false, text); await 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?.();
|
||||
},
|
||||
afterPaste: async () => { if (checkDraft) await guard!.notePasted(identity, text); },
|
||||
} : undefined;
|
||||
return this.sendTextUnchecked(pane, text, () => { beforeWrite(); beforeInput?.(); }, options?.bracketed, policy);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
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";
|
||||
@@ -293,4 +297,8 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
activityGrantsSchema,
|
||||
environmentBindingsSchema,
|
||||
nodePermissionsToSchema,
|
||||
nodeStartupGuidanceFileSchema,
|
||||
managedBlockWritesSchema,
|
||||
outboxDispatchedIndexSchema,
|
||||
streamIntakeCursorSchema,
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/** The file a seat's launch chose for its managed guidance blocks, when that isn't the rule's file in its working directory
|
||||
* (a Pi seat's own agent folder AGENTS.md, #875). Null (the default, and every row written before this) means the rule's
|
||||
* file, so restore, teardown and inventory keep reading exactly what they read before. */
|
||||
export const nodeStartupGuidanceFileSchema: Migration = {
|
||||
name: "146_node_startup_guidance_file.sql",
|
||||
sql: "ALTER TABLE node_startup_context ADD COLUMN guidance_file TEXT;",
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
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)
|
||||
);
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
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);
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
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,6 +186,7 @@ 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);
|
||||
@@ -309,6 +310,7 @@ 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);
|
||||
@@ -780,7 +782,7 @@ export class BootstrapOrchestrator {
|
||||
}
|
||||
const failureWarnings = routingFailureWarnings(bundleRouting);
|
||||
stages.push({ stage: "route_bundle_contents", status: failureWarnings.length > 0 ? "failed" : "ok", detail: bundleRouting });
|
||||
warnings.push(...failureWarnings);
|
||||
warnings.push(...(bundleRouting.warnings ?? []).filter(w => !warnings.includes(w)), ...failureWarnings);
|
||||
return { ok: true };
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -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, type IntegrityFsOps } from "./bundle-integrity.js";
|
||||
import { verifyIntegrity, isHarnessLoginPath, 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,6 +124,7 @@ 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,
|
||||
@@ -131,7 +132,11 @@ 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;
|
||||
return unsafeArchiveEntryReason(p, type) === null;
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -184,7 +189,34 @@ export async function unpack(archivePath: string, outputDir: string): Promise<{
|
||||
];
|
||||
throw new Error(`Content integrity verification failed: ${details.join("; ")}`);
|
||||
}
|
||||
if (result.warnings) return { warnings: result.warnings };
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -262,6 +262,12 @@ 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,6 +26,7 @@ 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;
|
||||
@@ -98,7 +99,8 @@ export async function routeBundleContents(
|
||||
tmpDir = bundleRoot;
|
||||
let manifest: Record<string, unknown> | null = null;
|
||||
try {
|
||||
await unpack(bundlePath, bundleRoot);
|
||||
const unpacked = await unpack(bundlePath, bundleRoot);
|
||||
if (unpacked?.warnings?.length) routing.warnings = unpacked.warnings;
|
||||
const manifestPath = nodePath.join(bundleRoot, "bundle.yaml");
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
manifest = parsePodBundleManifest(fs.readFileSync(manifestPath, "utf-8")) as Record<string, unknown>;
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface BundleResolvedSource {
|
||||
packageRefMap: Record<string, ResolvedPackage>;
|
||||
manifest: BundleManifest;
|
||||
tempDir: string;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,7 +41,7 @@ export class LegacyBundleSourceResolver {
|
||||
|
||||
try {
|
||||
// Extract + verify (archive digest + content integrity)
|
||||
await unpack(bundlePath, tempDir);
|
||||
const unpacked = await unpack(bundlePath, tempDir);
|
||||
|
||||
// Parse bundle manifest
|
||||
const manifestPath = nodePath.join(tempDir, "bundle.yaml");
|
||||
@@ -98,7 +99,7 @@ export class LegacyBundleSourceResolver {
|
||||
packageRefMap[`./${entry.path}`] = result.resolved;
|
||||
}
|
||||
|
||||
return { specPath, resolvedPackages, packageRefMap, manifest, tempDir };
|
||||
return { specPath, resolvedPackages, packageRefMap, manifest, tempDir, warnings: unpacked?.warnings };
|
||||
} catch (err) {
|
||||
// Clean up on failure
|
||||
this.cleanup(tempDir);
|
||||
@@ -125,6 +126,7 @@ export interface PodBundleResolvedSource {
|
||||
specPath: string;
|
||||
manifest: PodBundleManifest;
|
||||
tempDir: string;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,7 +139,7 @@ export class PodBundleSourceResolver {
|
||||
|
||||
try {
|
||||
// Safe unpack with digest/symlink/integrity verification
|
||||
await unpack(bundlePath, tempDir);
|
||||
const unpacked = await unpack(bundlePath, tempDir);
|
||||
|
||||
const manifestPath = nodePath.join(tempDir, "bundle.yaml");
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
@@ -182,7 +184,7 @@ export class PodBundleSourceResolver {
|
||||
throw new Error(`Bundle missing rig spec at ${manifest.rigSpec}`);
|
||||
}
|
||||
|
||||
return { specPath, manifest, tempDir };
|
||||
return { specPath, manifest, tempDir, warnings: unpacked?.warnings };
|
||||
} 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 type { SessionTransport, SendResult } from "./session-transport.js";
|
||||
import { CAPTURE_READABLE_BUDGET_MS, type SessionTransport, type 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,6 +503,8 @@ 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;
|
||||
@@ -1368,11 +1370,11 @@ export class ClaudeCompactionEnforcer {
|
||||
};
|
||||
const send = async () => {
|
||||
check();
|
||||
// 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.)
|
||||
// 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.
|
||||
// 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, this.compactDeadline(attempt)! - this.now())), readinessFromPaneOnly: true } : {}) });
|
||||
{ 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 } : {}) });
|
||||
};
|
||||
let result: Pick<SendResult, "ok" | "reason" | "outcome" | "sent" | "inputPhase">;
|
||||
let guardRefused = false;
|
||||
@@ -1428,7 +1430,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 });
|
||||
const capture = await this.sessionTransport.capture(sessionName, { inputBox: true, readable: { width: 120, height: 40 } });
|
||||
return capture.inputBox?.status ?? capture.reason ?? "unknown";
|
||||
} catch { return "unknown"; }
|
||||
}
|
||||
@@ -1505,9 +1507,9 @@ export class ClaudeCompactionEnforcer {
|
||||
} catch { try { fs.rmSync(temporary, { force: true }); } catch { /* nothing to clean */ } }
|
||||
}
|
||||
|
||||
private persistStage(sessionName: string, phase: CompactionRecord["phase"]): void {
|
||||
private persistStage(sessionName: string, phase: CompactionRecord["phase"], reason?: string): void {
|
||||
const current = this.records.get(sessionName);
|
||||
if (current) this.persist(sessionName, current.record.attemptId, { phase, lastDelivery: undefined });
|
||||
if (current) this.persist(sessionName, current.record.attemptId, { phase, ...(reason !== undefined ? { reason } : {}), lastDelivery: undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1593,7 +1595,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" });
|
||||
this.persist(session, attempt.attemptId, { phase: attempt.compact === "uncertain" ? "compact_uncertain" : "ended", reason });
|
||||
if (attempt.mode === "manual") this.setManualStage(session, "skipped-or-failed", reason);
|
||||
}
|
||||
|
||||
@@ -1609,7 +1611,7 @@ export class ClaudeCompactionEnforcer {
|
||||
attempt.reason = "compaction_cancelled";
|
||||
attempt.controller.abort();
|
||||
}
|
||||
this.persistStage(session, "ended");
|
||||
this.persistStage(session, "ended", "compaction_cancelled");
|
||||
this.pendingPostCompactRestore.delete(session);
|
||||
this.pendingStageGeneration.delete(session);
|
||||
this.restoreCarry.delete(session);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// #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);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** 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,3 +2,5 @@
|
||||
// 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,6 +137,8 @@ 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"];
|
||||
@@ -158,6 +160,7 @@ 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]!;
|
||||
@@ -167,10 +170,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) >= input.columns) next.add(end);
|
||||
if (stringWidth(row) >= narrowest) next.add(end);
|
||||
if (text[end] === " ") {
|
||||
const word = /^\S+/.exec(text.slice(end + 1))?.[0] ?? "";
|
||||
if (word && stringWidth(row + " " + word) > input.columns) next.add(end + 1);
|
||||
if (word && stringWidth(row + " " + word) > narrowest) next.add(end + 1);
|
||||
}
|
||||
}
|
||||
offsets = next;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { dirname, isAbsolute } from "node:path";
|
||||
import nodePath, { 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
|
||||
@@ -11,19 +12,67 @@ export type DesiredClaudeActivityHooks =
|
||||
| { status: "known"; selected: boolean }
|
||||
| { status: "unknown"; evidence: string };
|
||||
|
||||
/** 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. */
|
||||
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. */
|
||||
export function resolveDesiredClaudeActivityHooks(
|
||||
agentRef: string | null,
|
||||
profile: string | null,
|
||||
fsOps: AgentResolverFsOps,
|
||||
saved?: { resolvedSpecName: string | null; resolvedStartupFiles: readonly unknown[] },
|
||||
): DesiredClaudeActivityHooks {
|
||||
if (!agentRef?.startsWith("path:") || !isAbsolute(agentRef.slice(5)) || !profile) {
|
||||
if (!agentRef || !profile) {
|
||||
return { status: "unknown", evidence: "Current agent reference/profile has no established absolute source location" };
|
||||
}
|
||||
const resolved = resolveAgentRef(agentRef, dirname(agentRef.slice(5)), fsOps);
|
||||
if (!resolved.ok) return { status: "unknown", evidence: "Current agent specification could not be resolved" };
|
||||
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 selected = resolveProfileRuntimeResources(resolved.resolved, resolved.imports, profile);
|
||||
if (!selected.ok) return { status: "unknown", evidence: selected.errors.join("; ") };
|
||||
return {
|
||||
@@ -33,15 +82,24 @@ export function resolveDesiredClaudeActivityHooks(
|
||||
};
|
||||
}
|
||||
|
||||
/** Read the live node record and resolve its current selection. A missing row
|
||||
* resolves to `unknown`: the caller reports the gap instead of projecting. */
|
||||
/** 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`. */
|
||||
export function createDesiredClaudeActivityHooksReader(
|
||||
db: Database.Database,
|
||||
fsOps: AgentResolverFsOps,
|
||||
): (nodeId: string) => DesiredClaudeActivityHooks {
|
||||
return (nodeId) => {
|
||||
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);
|
||||
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);
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,8 @@ export interface GuardedDeliverySummary {
|
||||
}
|
||||
|
||||
/** Historical attempts contain outcomes, never executable options or callbacks. */
|
||||
interface Receipt { ids: string[]; result?: SendResult; expectedIdentity?: import("./expected-conversation.js").ExpectedConversation }
|
||||
interface Receipt { ids: string[]; result?: SendResult; expectedIdentity?: import("./expected-conversation.js").ExpectedConversation;
|
||||
watchdog?: { nextAction?: string } }
|
||||
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">;
|
||||
@@ -34,7 +35,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
|
||||
const reason = entry.deliveryState === "retired" ? "message_retired" : receipt.result?.reason ?? receipt.watchdog?.nextAction
|
||||
?? (state === "sending" ? "delivery_in_progress" : state === "complete" ? "delivered" : "interrupted_write");
|
||||
return { id: receipt.ids[0]!, state, reason };
|
||||
}
|
||||
@@ -74,7 +75,9 @@ export class DraftAwareDelivery {
|
||||
/* Legacy bookkeeping cannot veto input; report its absence with the actual result. */
|
||||
}
|
||||
try {
|
||||
await this.guard.beforeInput(session);
|
||||
// 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);
|
||||
result = await this.deliver(session, text, { ...opts,
|
||||
onInputEffect: phase => { inputWritten = true; opts.onInputEffect?.(phase); },
|
||||
beforeWrite: () => {
|
||||
@@ -106,6 +109,9 @@ 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,6 +328,21 @@ 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,8 +134,9 @@ 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:";
|
||||
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.";
|
||||
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.";
|
||||
|
||||
/** 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 {
|
||||
@@ -597,7 +598,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"
|
||||
? "Reply in this thread after this question to answer it. Earlier and extra messages still reach the agent as conversation." : null;
|
||||
? HUMAN_REQUIRED_REPLY_HINT : 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,6 +19,7 @@ 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)); }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
// 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,10 +147,23 @@ 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: ${(e as Error).message}` };
|
||||
return { ok: false, status: 0, grantedScopes: [], json: {}, error: `web-api transport: ${withoutToken((e as Error).message, token)}` };
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,
|
||||
@@ -251,7 +264,7 @@ export async function downloadPrivateFile(
|
||||
return { ok: true as const, bytes };
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: (e as Error).message || "download failed" };
|
||||
return { ok: false, error: withoutToken((e as Error).message, token) || "download failed" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +302,49 @@ 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,6 +26,7 @@ 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";
|
||||
@@ -1006,6 +1007,11 @@ 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
|
||||
@@ -1200,6 +1206,7 @@ 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,7 +59,8 @@ export interface KernelBootStatus {
|
||||
* terminal/skipped states. */
|
||||
firstUnreadySince: string | null;
|
||||
/** Filename of the picked variant (rig.yaml / rig-claude-only.yaml /
|
||||
* rig-codex-only.yaml). null when no variant was selected. */
|
||||
* 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. */
|
||||
variant: string | null;
|
||||
/** Human-readable detail for auth_blocked / spec_missing /
|
||||
* bootstrap_failed / degraded states. null otherwise. */
|
||||
@@ -105,10 +106,11 @@ 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): void {
|
||||
setSkipped(detail: string, variant: string | null = null): void {
|
||||
this.cancelTimer();
|
||||
this.state = "skipped";
|
||||
this.detail = detail;
|
||||
this.variant = variant;
|
||||
this.firstUnreadySince = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,10 @@ 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
|
||||
@@ -98,7 +102,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);
|
||||
tracker.setSkipped(reason, recordedVariant);
|
||||
return tracker;
|
||||
}
|
||||
|
||||
@@ -111,14 +115,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(null, deps.restoreLostKernel(managed.rigId), managed.seats, { existingRestore: true });
|
||||
tracker.startBooting(recordedVariant, 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");
|
||||
: "kernel rig already managed", recordedVariant);
|
||||
if (managed.kind === "live") tracker.observeManagedKernel();
|
||||
return tracker;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
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;
|
||||
}
|
||||
@@ -107,11 +107,40 @@ 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 {
|
||||
const frame = (text: string) => text.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, "");
|
||||
return frame(body) === frame(written);
|
||||
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";
|
||||
}
|
||||
|
||||
/** Whether `content` holds a `blockId` block whose body is anything but `written`: someone edited it. */
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
// 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";
|
||||
@@ -100,6 +101,9 @@ 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).
|
||||
@@ -119,6 +123,12 @@ 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 {
|
||||
@@ -150,6 +160,41 @@ 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);
|
||||
}
|
||||
@@ -228,7 +273,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.stageWakeIntent(
|
||||
this.queueRepo.stageHandoffWake(
|
||||
created.qitemId,
|
||||
input.actorSession,
|
||||
created.destinationSession,
|
||||
@@ -573,6 +618,7 @@ 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 });
|
||||
@@ -580,6 +626,8 @@ 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}`, {
|
||||
@@ -624,6 +672,15 @@ 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",
|
||||
@@ -720,6 +777,14 @@ 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,5 +1,6 @@
|
||||
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";
|
||||
@@ -284,6 +285,23 @@ 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. */
|
||||
@@ -323,6 +341,9 @@ 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.
|
||||
@@ -366,6 +387,9 @@ 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.
|
||||
|
||||
@@ -848,9 +848,28 @@ 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). */
|
||||
@@ -994,7 +1013,8 @@ export function getNodeDetail(
|
||||
// The seat's guidance file as its runtime resolves it now, and what it holds, read once for the guidance entries.
|
||||
const guidanceEvidence = (): { file: string | null; content: string | null } => {
|
||||
const runtime = (ctxRow as { runtime?: string | null } | undefined)?.runtime ?? entry.runtime;
|
||||
const file = entry.cwd ? seatGuidanceFile(runtime, entry.cwd, new RigRepository(db).getRigClaudeManagedBlockFile(rigId)) : null;
|
||||
const recorded = (ctxRow as { guidance_file?: string | null } | undefined)?.guidance_file;
|
||||
const file = entry.cwd ? seatGuidanceFile(runtime, entry.cwd, new RigRepository(db).getRigClaudeManagedBlockFile(rigId), recorded) : null;
|
||||
let content: string | null = null;
|
||||
if (file) {
|
||||
try { content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null; } catch { content = null; }
|
||||
@@ -1216,6 +1236,7 @@ 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,
|
||||
@@ -1227,7 +1248,8 @@ export function seatNeedsAttention(
|
||||
|| entry.startupStatus === "failed"
|
||||
|| (arbitratedNeedsInput ?? activity?.state === "needs_input")
|
||||
|| entry.heldReason != null
|
||||
|| entry.latestError != null;
|
||||
|| entry.latestError != null
|
||||
|| (entry.startedWithoutRequired?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loaded 100 of 277 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user