mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 17:32:41 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90a01c499a | ||
|
|
3a783b9c3d | ||
|
|
9bd5b957e1 |
No files matched your search
+1
-2
@@ -174,8 +174,7 @@ stand-in follows.
|
||||
|
||||
## [0.7.7]
|
||||
|
||||
Published 2026-11-02 as `@openrig/[email protected]`, built from the commit tagged
|
||||
`v0.7.7` (`f3d8bac4`). 0.7.7 is a separate reliability patch for 0.7.6. Its three
|
||||
**Not yet published.** 0.7.7 is a separate reliability patch for 0.7.6. Its three
|
||||
fixes are also in 0.7.8.
|
||||
|
||||
OpenRig 0.7.7 is 0.7.6 with three reliability fixes for managed Claude seats: an
|
||||
|
||||
@@ -66,7 +66,7 @@ resolve → project → deliver pre-launch files → persist replay context →
|
||||
## 2. Execution and projection types
|
||||
|
||||
The member's optional `claude_mcp: none` is normalized as `claudeMcp` and
|
||||
persisted independently in `nodes.claude_mcp` (migration 142). The
|
||||
persisted independently in `nodes.claude_mcp` (migration 141). The
|
||||
expansion HTTP route preserves both `claude_mcp` and `claudeMcp`, including
|
||||
invalid values for the canonical validator to reject. An authored relaunch
|
||||
updates the seat choice after launch eligibility and startup-source resolution;
|
||||
|
||||
@@ -25,7 +25,7 @@ below `rig` (including groups and the hidden `restore apply` command), and
|
||||
**1,304 explicitly registered option objects**, including the root version
|
||||
option. Aliases do not add command objects; short/long spellings of one
|
||||
option do not add option objects. Commander-generated help is additional.
|
||||
These are counts for that source snapshot, not a claim about a deployed release. The provider-switch and set-runtime rows include the later account-home options; those additions are not included in the snapshot counts. The `environment` section and the `host edit`, `host remove` and `terminal attach` rows were added later from source at `522d635c`, and aren't in the snapshot counts either. With `environment`, main registers 91 top-level commands.
|
||||
These are counts for that source snapshot, not a claim about a deployed release. The provider-switch and set-runtime rows include the later account-home options; those additions are not included in the snapshot counts.
|
||||
|
||||
## Reading this reference
|
||||
|
||||
@@ -350,8 +350,6 @@ the sections that were not read are `null`.
|
||||
| `rig host add` | — | `--id <id>` **required**<br>`--transport <transport>` **required**<br>`--target <target>`<br>`--user <user>`<br>`--url <url>`<br>`--bearer-env <name>`<br>`--bearer-file <path>`<br>`--notes <text>`<br>`--json` |
|
||||
| `rig host select <id>` | — | `--json` |
|
||||
| `rig host rename <name>` | — | `--json` |
|
||||
| `rig host edit <id>` | — | `--target <target>`<br>`--user <user>`<br>`--url <url>`<br>`--notes <text>`<br>`--json` |
|
||||
| `rig host remove <id>` | — | `--json` |
|
||||
| `rig host pair <url>` | — | `--id <id>`<br>`--timeout <seconds>`<br>`--human <address>`<br>`--json` |
|
||||
| `rig host list` | `ls` | `--json` |
|
||||
| `rig host doctor <id>` | — | `--posture <profile>`<br>`--public-addr <ip>`<br>`--json` |
|
||||
@@ -543,7 +541,6 @@ do not establish connectivity to a live peer.
|
||||
| `rig terminal open <view>` | — | `--provider <name>`<br>`--session <name>`<br>`--window`<br>`--expected-plan <id>`<br>`--json` |
|
||||
| `rig terminal views` | — | `--json` |
|
||||
| `rig terminal status` | — | `--provider <name>`<br>`--session <name>`<br>`--json` |
|
||||
| `rig terminal attach <session>` | — | `--read-only` |
|
||||
|
||||
See [named terminal sessions](../reference/terminal-sessions.md) to select a
|
||||
Herdr session per request, including session-bound previews and window reuse.
|
||||
@@ -700,24 +697,6 @@ Only Claude and Codex tenures can be woken.
|
||||
| `rig community-harness` | — | — |
|
||||
| `rig community-harness list` | — | `--json` |
|
||||
|
||||
### environment
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig environment` | — | — |
|
||||
| `rig environment setup` | — | `--directory <path>` **required**<br>`--binding <id>`<br>`--seat <address>`<br>`--rig <name>`<br>`--mcp-url <url>`<br>`--guest-base-url <origin>`<br>`--artifact-root <path>`<br>`--json` |
|
||||
| `rig environment create <binding>` | — | `--seat <address>` **required**<br>`--node <id>`<br>`--gateway <ref>` **required**<br>`--workspace <ref>` **required**<br>`--name <name>` **required**<br>`--image <digest>` **required**<br>`--provider <ref>` **required**<br>`--policy <path>` **required**<br>`--guest-base-url <origin>` **required**<br>`--operation <id>`<br>`--json` |
|
||||
| `rig environment attach <binding>` | — | `--seat <address>` **required**<br>`--node <id>`<br>`--gateway <ref>` **required**<br>`--workspace <ref>` **required**<br>`--name <name>` **required**<br>`--image <digest>` **required**<br>`--provider <ref>` **required**<br>`--policy <path>` **required**<br>`--guest-base-url <origin>` **required**<br>`--operation <id>`<br>`--json`<br>`--resource-id <id>` **required**<br>`--fingerprint <value>` |
|
||||
| `rig environment inspect <binding>` | — | `--live`<br>`--json` |
|
||||
| `rig environment connect <binding>` | — | `--generation <id>` **required** |
|
||||
| `rig environment export <binding>` | — | `--request <path>` **required**<br>`--json` |
|
||||
| `rig environment checkpoint <binding>` | — | `--generation <id>` **required**<br>`--operation <id>` **required**<br>`--json` |
|
||||
| `rig environment recreate <binding>` | — | `--request <path>` **required**<br>`--json` |
|
||||
| `rig environment completion <binding>` | — | `--request <path>` **required**<br>`--json` |
|
||||
| `rig environment effect <binding>` | — | `--effect <name>` **required**<br>`--generation <id>`<br>`--resource-id <id>`<br>`--operation <id>` **required**<br>`--engine-socket <path>`<br>`--json` |
|
||||
|
||||
It drives the maintained OpenShell reference; see the [OpenShell example](../reference/community-harness-examples/openshell/README.md).
|
||||
|
||||
### fork
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|
||||
@@ -741,32 +741,6 @@ rig uses it, so it stays until then. To remove it sooner, by hand:
|
||||
3. Keep a copy of the file, remove only those lines, and compare the result with
|
||||
the copy: nothing else in the file should differ.
|
||||
|
||||
**The kernel's own folder.** A newly created default kernel works in
|
||||
`<workspace.root>/kernel`, beside your projects (`<workspace.root>/projects` by
|
||||
default) rather than above them. Claude reads `CLAUDE.md` from a seat's folder
|
||||
and from every folder above it, so a kernel working in the workspace root put its
|
||||
guidance into every Claude project seat below it. If that folder can't be made,
|
||||
the new kernel works in the workspace root, as before.
|
||||
|
||||
A kernel that already exists keeps its folders, usually `workspace.root`.
|
||||
Upgrading moves no seat, changes no restored conversation and removes no file.
|
||||
To move an existing kernel yourself, if you want to:
|
||||
|
||||
1. Save the new folder for each kernel seat with `rig seat set-cwd <seat> --cwd
|
||||
<workspace.root>/kernel --reason <text>`, using the absolute path. It creates
|
||||
the folder ([details](#change-an-existing-seats-working-directory)).
|
||||
2. Choose when each seat adopts it. A seat keeps its current folder until it
|
||||
starts again, and the new start doesn't continue its current native
|
||||
conversation: `rig seat launch <seat> --fresh --stop --reason <text>` starts a
|
||||
blank one, and `rig handover <seat> --source rebuild --reason <text>` carries
|
||||
durable context into a new one. Nothing does this for you.
|
||||
3. Tidy the old `<workspace.root>/CLAUDE.md` (or `AGENTS.md`) by hand, once no
|
||||
running rig's seat still works in that folder: a seat there still reads those
|
||||
blocks, and `rig down` checks the same before it removes them. Remove only
|
||||
OpenRig's managed blocks, each from its `<!-- BEGIN OpenRig MANAGED BLOCK: … -->`
|
||||
line through its `END` line, as in the steps above, and keep everything else,
|
||||
including your own text. Don't move or delete the whole file.
|
||||
|
||||
**Separate project context from role**
|
||||
|
||||
Don't put project documentation inside the role guidance. The role is about the agent's function; project context is about what the agent is working on. Use rig-level or pod-level startup files for project context.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Integration author contracts — DRAFT, first published with 0.7.6
|
||||
# Integration author contracts — DRAFT, published with 0.7.6
|
||||
|
||||
OpenRig composes agents into a team. An integration author supplies the pieces that let a new harness participate, or let an existing harness run in a new environment. This guide divides that work into two contracts:
|
||||
|
||||
@@ -9,16 +9,16 @@ These are draft author responsibilities. Some use implemented OpenRig interfaces
|
||||
|
||||
## Release and support status
|
||||
|
||||
This draft was published with **OpenRig 0.7.6** (tag [`v0.7.6`](https://github.com/mvschwarz/openrig/tree/v0.7.6), source `c00c6321`). Its implemented-interface baseline is commit [`b6bd4633`](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63). That base includes the runner declaration and `passes_model` changes. The published 0.7.6 adds release repairs and documentation on top of that base. **OpenRig 0.7.8** adds seat activity grants, OpenRig's side of the answered permission path and the maintained OpenShell reference driver (`rig environment`); the rows below that say 0.7.8 describe it. References below use the immutable base instead of a moving branch, except the community preset reference, which is the in-tree copy.
|
||||
This draft was published with **OpenRig 0.7.6** (tag [`v0.7.6`](https://github.com/mvschwarz/openrig/tree/v0.7.6), source `c00c6321`). Its implemented-interface baseline is commit [`b6bd4633`](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63). That base includes the runner declaration and `passes_model` changes. The published 0.7.6 adds release repairs and documentation on top of that base. References below use the immutable base instead of a moving branch.
|
||||
|
||||
| Area | Status | What the status means |
|
||||
| Area | Status at this baseline | What the status means |
|
||||
|---|---|---|
|
||||
| Community launcher presets, RigSpec members and AgentSpec role/startup declarations | Implemented interfaces | They compose configuration. A launcher must implement its declared effects; successful parsing does not prove delivery or native behavior. |
|
||||
| `runner: true` with `launch_result: true` | Released in 0.7.6 | The declaration is retained at launch, and Core uses the runner identity/activity path. It does not supply a runner executable. |
|
||||
| `passes_model: true` | Released in 0.7.6 | Passes the declared model through `OPENRIG_MODEL`. It does not observe or verify the effective native model. Cursor and Hermes examples leave it off pending native qualification. |
|
||||
| The dsh/ACP input, cancellation and answered permission recipe (a person answers, and the default is permit) | OpenRig's side released in 0.7.8; runner side not qualified | OpenRig asks a person for a runner's permission requests and returns the answer. No end-to-end dsh integration is established, and the native verdict is pending. Native reopen capabilities and the tools-off case remain implementer questions. |
|
||||
| The dsh/ACP input, cancellation and answered permission recipe (a person answers, and the default is permit) | Proposed author implementation | No end-to-end dsh integration is established. Native reopen capabilities and the tools-off case remain implementer questions. |
|
||||
| Exact native resume via an author launcher | Existing interface; integration-specific evidence | A requested conversation must reopen exactly or fail honestly. One provider/harness result does not establish portability to another. |
|
||||
| Environment preparation, projection, artifact export and owned-resource cleanup | OpenShell reference driver released in 0.7.8; generic seam proposed | The platform chapter makes the responsibilities reusable. Core ships one maintained reference driver, OpenShell 0.1.6, as `rig environment`. It doesn't provide a generic platform driver or `DeploySpec` loader. |
|
||||
| Environment preparation, projection, artifact export and owned-resource cleanup | Measured in a private wrapper; generic seam proposed | The platform chapter makes the responsibilities reusable. Core does not provide a generic platform driver or `DeploySpec` loader at this baseline. |
|
||||
| `HarnessSpec`, `DeploySpec`, generic lifecycle fields and wake-on-demand declarations | Proposed vocabulary or unsupported configuration | They are not new accepted YAML keys. Use existing composition and an explicit author implementation. |
|
||||
| Cross-host native resume, provider-wide cancellation or permission support | Unresolved unless qualified by an integration | Unsupported and untested are distinct from observed failure. Publish the actual boundary and version. |
|
||||
|
||||
@@ -50,7 +50,7 @@ The queue records who owns work. It does not prove that an old provider process
|
||||
|
||||
## Start from an existing example
|
||||
|
||||
For a terminal harness, begin with the [community preset reference](community-harness-presets.md) and the pinned [Cursor](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/cursor) or [Hermes](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/hermes) directory. Their presence is source guidance, not an effective-model or complete integration guarantee.
|
||||
For a terminal harness, begin with the pinned [community preset reference](https://github.com/mvschwarz/openrig/blob/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-presets.md) and the [Cursor](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/cursor) or [Hermes](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/hermes) directory. Their presence is source guidance, not an effective-model or complete integration guarantee.
|
||||
|
||||
For a structured runner, the [runner chapter](harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch) supplies a minimal preset, launch record and activity message. The executable named by the preset must still be implemented and installed by its author.
|
||||
|
||||
|
||||
@@ -5,9 +5,8 @@ It runs in the seat's pane, hosts `dsh --profile acp` over ACP on stdio, and doe
|
||||
section:
|
||||
- **identity:** the launch record, written once the conversation is open;
|
||||
- **one prompt per block:** each submitted block becomes one `session/prompt`, using bracketed paste in a TTY;
|
||||
- **turn boundaries:** reported as activity hooks, with a working beat every 10 s through a long, quiet turn;
|
||||
- **cancel:** `/abort` or Ctrl-C sends `session/cancel`, and a turn reads cancelled only when dsh says so. Type `/abort`
|
||||
in the seat's pane: one sent with `rig send` arrives inside a From/To envelope, so it's an ordinary prompt;
|
||||
- **turn boundaries:** reported as activity hooks;
|
||||
- **cancel:** `/abort` or Ctrl-C sends `session/cancel`, and a turn reads cancelled only when dsh says so;
|
||||
- **permissions:** each request goes to a person through OpenRig;
|
||||
- **resume:** it reopens the exact conversation, or says honestly that it couldn't.
|
||||
|
||||
@@ -22,19 +21,13 @@ own. [bpamiri/dsh-seat](https://github.com/bpamiri/dsh-seat) is a fuller dsh run
|
||||
`$OPENRIG_HOME/community-harnesses/`. Then declare a member with `runtime: dsh`.
|
||||
3. **Give the seat credentials.** Each seat gets its own dsh home, at `$OPENRIG_HOME/state/dsh/<node>/home`, because dsh
|
||||
rewrites its profile there on every start. Put a `.credentials.yaml` there, or put a provider key in the seat's
|
||||
`env:`. To use a home you prepared yourself, set `DSH_SEAT_HOME` in the member's `env:`; `DSH_SEAT_BIN` there names
|
||||
another `dsh` binary. The runner doesn't take dsh's own `DSH_HOME` from its environment, because your shell's startup
|
||||
files run after the seat's `env:` and could set it. A member's `env:` can't set `OPENRIG_*` names, which OpenRig
|
||||
reserves, but the runner still reads `OPENRIG_DSH_HOME` and `OPENRIG_DSH` when something exports them to it, such as
|
||||
a preset `command` wrapper.
|
||||
`env:`. To use a home you prepared yourself, set `OPENRIG_DSH_HOME`. `OPENRIG_DSH` names another `dsh` binary.
|
||||
|
||||
The runner's own defaults for dsh:
|
||||
- **Uploads:** the seat home gets a `cordis.patch.yml` that turns off dsh's session-log and plugin-list uploads to
|
||||
DeepSeek, and the runner starts dsh with `DSH_TELEMETRY_DISABLED=1`.
|
||||
- **Permission mode:** `DSH_PERMISSION_MODE` is `workspace-write` unless you set it.
|
||||
- **The hook token:** OpenRig's activity token isn't passed to dsh, whose shell tool inherits the runner's environment.
|
||||
- **Its version:** the launch record names dsh's version from `dsh --version`. If that fails or takes over 5 seconds, it
|
||||
names the version dsh's ACP handshake reports, marked as such: dsh 0.2.1-rc.2 reports 0.0.1 there.
|
||||
|
||||
## Permissions
|
||||
|
||||
@@ -44,11 +37,6 @@ The runner submits each request to OpenRig, which asks one person:
|
||||
|
||||
That person answers one of the options dsh offered, and the runner gives that answer to the same live request, once.
|
||||
|
||||
**When the person answers in their own words** rather than with a choice, a yes or a no, OpenRig permits by default and
|
||||
passes their words back as `said`. The runner gives dsh its option as usual, shows the words in the pane, and queues
|
||||
them like any other message, so dsh gets them after the current turn and after any message already waiting. The words
|
||||
are never the answer.
|
||||
|
||||
**When no one can be asked, the request is permitted** with dsh's own allow option: `allow_once` when dsh offers it,
|
||||
else `allow_always`. That's OpenRig's default. The runner does the same when OpenRig can't answer: an older OpenRig
|
||||
without this path, an OpenRig it can't reach, or a refusal other than `--deny`.
|
||||
@@ -65,10 +53,8 @@ OpenRig says the request isn't the seat's current occupant's (for example, after
|
||||
|
||||
## Before you declare resume
|
||||
|
||||
The preset leaves `resume` out, so a restore stops and asks, and a fresh launch starts a new conversation.
|
||||
`rig seat restart --keep-conversation` is for Codex seats only. The runner reopens a conversation by the method dsh
|
||||
advertises: `session/resume`, or `session/load`. When dsh advertises neither, it tries `session/resume`. It never starts
|
||||
a new conversation on a resume.
|
||||
The preset leaves `resume` out. The runner reopens a conversation by the method dsh advertises: `session/resume`, or
|
||||
`session/load`. When dsh advertises neither, it tries `session/resume`. It never starts a new conversation on a resume.
|
||||
|
||||
**Telling a missing conversation from one that won't reopen.** dsh `0.2.1-rc.2` answers `-32602` both for a
|
||||
conversation it doesn't have and for one already active elsewhere, so the runner never reads that error as missing.
|
||||
|
||||
@@ -8,7 +8,7 @@ launch_result: true
|
||||
first_input: true
|
||||
limits:
|
||||
- written for dsh 0.2.1-rc.2 (npm @deepseek-ai/dsh) over ACP protocol 1, with Node 22 or later, and checked against a stand-in agent only
|
||||
- each seat gets its own dsh home under $OPENRIG_HOME/state/dsh/<node>/home, holding a patch that turns off dsh's session-log and plugin-list uploads to DeepSeek; give it credentials (.credentials.yaml there) or a provider key in the seat's env; set DSH_SEAT_HOME in the member's env to use a home you prepared, and DSH_SEAT_BIN for another dsh binary (a member's env can't set OPENRIG_* names, and the runner doesn't read dsh's own DSH_HOME)
|
||||
- each seat gets its own dsh home under $OPENRIG_HOME/state/dsh/<node>/home, holding a patch that turns off dsh's session-log and plugin-list uploads to DeepSeek; give it credentials (.credentials.yaml there) or a provider key in the seat's env
|
||||
- DSH_PERMISSION_MODE defaults to workspace-write; a permission request goes to the default person or the seat's override (rig seat set-permissions-to), is permitted with dsh's allow_once (else allow_always) when no one can be asked or OpenRig can't answer, and is refused and held as needs input only after --deny
|
||||
- allow_always creates a standing grant in dsh, which OpenRig doesn't record or revoke; the runner prefers allow_once, and where dsh keeps the grant and for how long isn't checked
|
||||
- resume isn't declared until a real dsh has reopened a conversation and recalled it; the runner reopens by the method dsh advertises and reports missing only on dsh's own not-found error, not when dsh's session list names the conversation; dsh answers -32602 for a missing conversation (and for one active elsewhere), so a missing dsh conversation reads unconfirmed
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
//
|
||||
// It follows docs/reference/harness-runner-contract.md: it hosts `dsh --profile acp` over ACP on stdio, writes the
|
||||
// launch record once the conversation is open, turns each submitted block into one prompt, reports turn boundaries as
|
||||
// activity hooks with a working beat through a long, quiet turn, cancels on /abort or Ctrl-C, and sends permission
|
||||
// requests to OpenRig: a person answers, or OpenRig permits when no one can be asked, and so does the runner when OpenRig
|
||||
// can't answer (refusing and holding needs input only when the seat opted in to refusal). Copy it and maintain it as your
|
||||
// own.
|
||||
// activity hooks, cancels on /abort or Ctrl-C, and sends permission requests to OpenRig: a person answers, or OpenRig
|
||||
// permits when no one can be asked, and so does the runner when OpenRig can't answer (refusing and holding needs input
|
||||
// only when the seat opted in to refusal). Copy it and maintain it as your own.
|
||||
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -20,9 +19,6 @@ const RUNNER = "openrig-dsh-runner 1";
|
||||
const NAME = process.env.OPENRIG_RUNTIME || "dsh"; // the preset's name: OpenRig passes it, and checks the record against it
|
||||
const LIVENESS_SECONDS = 60;
|
||||
const BEAT_MS = 30_000;
|
||||
// The working beat (contract section 4): every 10 s, more often than half its 30 s lifetime.
|
||||
const WORK_LIVENESS_SECONDS = 30;
|
||||
const WORK_BEAT_MS = 10_000;
|
||||
const ACTIVE_EVERY_MS = 15_000;
|
||||
const POST_TIMEOUT_MS = 2_000;
|
||||
const WAIT_SECONDS = 25;
|
||||
@@ -207,12 +203,10 @@ const UPLOADS_OFF = [
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
/** Each seat gets its own writable dsh home (dsh rewrites its profile there on every start). DSH_SEAT_HOME, which a rig
|
||||
* spec's env: can set, or OPENRIG_DSH_HOME names one you prepared yourself, which is used as it is. Not dsh's own
|
||||
* DSH_HOME: a person's shell startup files can export it, and they run after the seat's env:. */
|
||||
/** Each seat gets its own writable dsh home (dsh rewrites its profile there on every start). OPENRIG_DSH_HOME names
|
||||
* one you prepared yourself, which is used as it is. */
|
||||
function seatHome() {
|
||||
const prepared = process.env.OPENRIG_DSH_HOME || process.env.DSH_SEAT_HOME;
|
||||
if (prepared) return prepared;
|
||||
if (process.env.OPENRIG_DSH_HOME) return process.env.OPENRIG_DSH_HOME;
|
||||
const seat = process.env.OPENRIG_NODE_ID || process.env.OPENRIG_SESSION_NAME || "default";
|
||||
const home = join(openrigHome(), "state", "dsh", seat, "home");
|
||||
mkdirSync(home, { recursive: true, mode: 0o700 });
|
||||
@@ -221,17 +215,6 @@ function seatHome() {
|
||||
return home;
|
||||
}
|
||||
|
||||
/** dsh's version as its CLI reports it, or null when it can't say. dsh 0.2.1-rc.2's ACP handshake reports 0.0.1.
|
||||
* SIGKILL at the timeout, because a synchronous call waits for the child to exit, and a child can ignore SIGTERM. */
|
||||
function cliVersion(bin, env) {
|
||||
try {
|
||||
const out = execFileSync(bin, ["--version"], { encoding: "utf8", env, timeout: 5_000, killSignal: "SIGKILL", stdio: ["ignore", "pipe", "ignore"] });
|
||||
return out.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?/)?.[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeLaunchRecord(fields) {
|
||||
const path = process.env.OPENRIG_LAUNCH_RESULT;
|
||||
if (!path) return;
|
||||
@@ -267,23 +250,16 @@ async function main() {
|
||||
agentEnv.DSH_PERMISSION_MODE ??= "workspace-write";
|
||||
if (agentEnv.DSH_TELEMETRY_DISABLED === undefined && agentEnv.DSH_TELEMETRY_MODE === undefined) agentEnv.DSH_TELEMETRY_DISABLED = "1";
|
||||
|
||||
// DSH_SEAT_BIN, which a rig spec's env: can set, or OPENRIG_DSH names another dsh binary.
|
||||
const bin = env.OPENRIG_DSH || env.DSH_SEAT_BIN || "dsh";
|
||||
const version = cliVersion(bin, agentEnv);
|
||||
const child = spawn(bin, ["--profile", "acp"], { cwd, env: agentEnv, stdio: ["pipe", "pipe", "inherit"] });
|
||||
const child = spawn(env.OPENRIG_DSH || "dsh", ["--profile", "acp"], { cwd, env: agentEnv, stdio: ["pipe", "pipe", "inherit"] });
|
||||
child.stdin.on("error", () => {});
|
||||
const peer = new AgentPeer(child, (line) => say(`[dsh] ${line}`));
|
||||
const state = { sessionId: null, turn: 0, running: false, cancelRequestedAt: null, holding: false, refused: false, queue: [], asks: new Set(), lastActive: 0, asking: 0, waiting: 0, resolvedAsk: null };
|
||||
const state = { sessionId: null, turn: 0, running: false, cancelRequestedAt: null, holding: false, refused: false, queue: [], asks: new Set(), lastActive: 0 };
|
||||
const inflight = new Set();
|
||||
const track = (promise) => { inflight.add(promise); promise.finally(() => inflight.delete(promise)); return promise; };
|
||||
let agentExited = false;
|
||||
let stopping = false;
|
||||
let heartbeat;
|
||||
let workBeat;
|
||||
let tty = false;
|
||||
// The turn may read working: it runs, nothing holds needs input, no permission request is being submitted or waits for its
|
||||
// permission_resolved, and dsh is alive. The beat and the agent's own reports both go by it.
|
||||
const working = () => state.running && !state.holding && state.asking === 0 && !state.resolvedAsk && !agentExited;
|
||||
|
||||
const restoreTty = () => {
|
||||
if (!tty) return;
|
||||
@@ -293,7 +269,6 @@ async function main() {
|
||||
};
|
||||
const finish = async (code) => {
|
||||
clearInterval(heartbeat);
|
||||
clearInterval(workBeat);
|
||||
restoreTty();
|
||||
await Promise.race([Promise.allSettled([...inflight, activity.flushed()]), sleep(2_000)]);
|
||||
process.exit(code);
|
||||
@@ -324,7 +299,7 @@ async function main() {
|
||||
peer.onNotification("session/update", (params) => {
|
||||
if (params.sessionId !== state.sessionId || !params.update) return;
|
||||
const update = params.update;
|
||||
const reporting = working();
|
||||
const reporting = state.running && !state.holding; // while needs input is held, nothing may read working
|
||||
if (update.sessionUpdate === "agent_message_chunk") {
|
||||
if (update.content?.type === "text") process.stdout.write(update.content.text);
|
||||
if (reporting && Date.now() - state.lastActive >= ACTIVE_EVERY_MS) { state.lastActive = Date.now(); activity.post("active", "agent_message"); }
|
||||
@@ -371,17 +346,10 @@ async function main() {
|
||||
const allow = options.find((option) => option.kind === "allow_once") ?? options.find((option) => option.kind === "allow_always");
|
||||
const permit = (why) => {
|
||||
if (!allow) return refuse(`${why}, and the agent offered no allow option`);
|
||||
state.holding = state.refused || state.waiting > 0; // another ask may still wait on a person
|
||||
state.holding = false;
|
||||
say(`[permission] permitted: ${why} (${allow.optionId})`);
|
||||
return { outcome: { outcome: "selected", optionId: allow.optionId } };
|
||||
};
|
||||
// A person who answered in their own words: OpenRig passes them back as `said`. They join the prompt queue like any
|
||||
// message, so dsh gets them after this turn and after any message already waiting. They're never the answer.
|
||||
const passOn = (said) => {
|
||||
if (typeof said !== "string" || !said.trim()) return;
|
||||
say(`[permission] the person also wrote: ${firstLine(said)}`);
|
||||
submit(`The person who answered your permission request (${toolCall.title}) also wrote:\n\n${said}`);
|
||||
};
|
||||
if (!askId) {
|
||||
if (submitted.body?.code === "denied_by_seat") return refuse("the seat's operator chose to refuse its permission requests");
|
||||
if (NOT_LIVE.includes(submitted.body?.code)) {
|
||||
@@ -392,12 +360,7 @@ async function main() {
|
||||
: submitted.status === 0 ? "OpenRig couldn't be reached" : `OpenRig couldn't ask anyone (${submitted.body?.code ?? submitted.status})`);
|
||||
}
|
||||
state.holding = true; // OpenRig recorded the seat's permission prompt when it opened the ask
|
||||
state.waiting += 1;
|
||||
say(`[permission] OpenRig asked a person (${askId}); waiting for their answer`);
|
||||
// This ask no longer waits on a person; the seat still needs input while another one does, or after its own refusal.
|
||||
const stopWaiting = () => { state.waiting -= 1; state.holding = state.refused || state.waiting > 0; };
|
||||
// The agent got an answer or a permit for this ask: once nothing else waits, permission_resolved says the turn goes on.
|
||||
const resolved = () => { state.resolvedAsk = { turn, askId }; };
|
||||
return new Promise((resolve) => {
|
||||
let done = false; // the agent has its answer
|
||||
let ended = false; // OpenRig was told the request is over
|
||||
@@ -408,7 +371,7 @@ async function main() {
|
||||
withdraw(askId, reason);
|
||||
if (done) return;
|
||||
done = true;
|
||||
stopWaiting(); // the turn ended the person's ask
|
||||
if (!state.refused) state.holding = false; // the turn ended the person's ask: no one is still being asked
|
||||
resolve(CANCELLED);
|
||||
} };
|
||||
state.asks.add(ask);
|
||||
@@ -424,23 +387,18 @@ async function main() {
|
||||
// A person's option, or OpenRig's permit when they closed it without one.
|
||||
if ((answer.state === "answered" || answer.state === "permitted") && offered(answer.optionId) && live()) {
|
||||
state.asks.delete(ask);
|
||||
stopWaiting();
|
||||
state.holding = false;
|
||||
say(`[permission] ${answer.state}: ${answer.optionId}`);
|
||||
resolve({ outcome: { outcome: "selected", optionId: answer.optionId } });
|
||||
resolved();
|
||||
passOn(answer.said);
|
||||
} else if (live() && (read.status === 200 ? !ENDED.includes(answer.state) : !NOT_LIVE.includes(answer.code))) {
|
||||
// OpenRig stopped answering, or answered in a way the contract doesn't list: nobody can be asked. The ask stays
|
||||
// tracked, so the turn's end still withdraws it.
|
||||
stopWaiting();
|
||||
resolve(permit(read.status === 0 ? "OpenRig couldn't be reached" : `OpenRig couldn't answer (${answer.code ?? read.status})`));
|
||||
if (!state.refused) resolved(); // permitted, not refused for want of an allow option
|
||||
} else {
|
||||
state.asks.delete(ask);
|
||||
stopWaiting(); // a person said no, or the request is over
|
||||
state.holding = false; // a person said no, or the request is over: no one is still waiting
|
||||
say(`[permission] not answered (${answer.reason ?? answer.state ?? answer.code ?? read.status}); answered cancelled`);
|
||||
resolve(CANCELLED);
|
||||
passOn(answer.said);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -453,19 +411,9 @@ async function main() {
|
||||
const options = Array.isArray(params.options) ? params.options : [];
|
||||
say(`\n[permission] dsh asks: ${toolCall.title} (${toolCall.kind}); options ${options.map((option) => option.optionId).join(", ")}`);
|
||||
if (!state.running || state.cancelRequestedAt) return CANCELLED;
|
||||
state.asking += 1; // no beat while a permission request is open: it may be waiting on a person
|
||||
return askOwner(requestId, toolCall, options).finally(() => { state.asking -= 1; setImmediate(settle); });
|
||||
return askOwner(requestId, toolCall, options);
|
||||
});
|
||||
|
||||
// Once nothing waits on a person or is being submitted, the seat holds no refusal of its own and dsh is alive, say the turn
|
||||
// goes on. It goes out after the agent has its answer, and before the next beat.
|
||||
const settle = () => {
|
||||
const done = state.resolvedAsk;
|
||||
if (!done || state.holding || state.asking > 0) return;
|
||||
state.resolvedAsk = null;
|
||||
if (state.running && state.turn === done.turn && !agentExited) activity.post("active", "permission_resolved", { turnId: String(done.turn), askId: done.askId });
|
||||
};
|
||||
|
||||
const abort = () => {
|
||||
if (!state.running) { say("[openrig-dsh] no turn is running"); return; }
|
||||
if (state.cancelRequestedAt) return;
|
||||
@@ -481,8 +429,6 @@ async function main() {
|
||||
state.turn += 1;
|
||||
state.holding = false;
|
||||
state.refused = false;
|
||||
state.waiting = 0;
|
||||
state.resolvedAsk = null;
|
||||
state.cancelRequestedAt = null;
|
||||
state.lastActive = 0;
|
||||
const turnId = String(state.turn);
|
||||
@@ -526,8 +472,7 @@ async function main() {
|
||||
return fatal(`dsh didn't complete the ACP handshake (${error.message})`, 1);
|
||||
}
|
||||
const capabilities = init?.agentCapabilities ?? {};
|
||||
const reported = init?.agentInfo?.version ? `${init.agentInfo.version} (as its ACP handshake reports it)` : "(version not reported)";
|
||||
const producer = `${RUNNER}; dsh ${version ?? reported}; ACP ${init?.protocolVersion ?? "?"}`;
|
||||
const producer = `${RUNNER}; dsh ${init?.agentInfo?.version ?? "(version not reported)"}; ACP ${init?.protocolVersion ?? "?"}`;
|
||||
const resumeRef = env.OPENRIG_RESUME_REF;
|
||||
// Whether dsh's own session list names a conversation, following its pages: true, false when every page was read and
|
||||
// none names it, or undefined when the list fails, answers in another shape, or runs past LIST_PAGES pages. An ACP
|
||||
@@ -577,12 +522,6 @@ async function main() {
|
||||
say("[openrig-dsh] each submitted message is one prompt; /abort or Ctrl-C cancels the running turn");
|
||||
|
||||
heartbeat = setInterval(() => { if (!state.running && !state.holding && !agentExited) postIdle("idle_heartbeat"); }, BEAT_MS);
|
||||
// While a turn runs and nothing waits on a person: the turn is still outstanding and dsh is alive. Not progress.
|
||||
workBeat = setInterval(() => {
|
||||
if (working()) {
|
||||
activity.post("active", "liveness", { turnId: String(state.turn), livenessSeconds: WORK_LIVENESS_SECONDS });
|
||||
}
|
||||
}, WORK_BEAT_MS);
|
||||
for (const signal of ["SIGHUP", "SIGTERM"]) {
|
||||
process.on(signal, () => {
|
||||
if (state.running && !state.cancelRequestedAt) peer.notify("session/cancel", { sessionId: state.sessionId });
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This chapter describes the placement and resource responsibilities of an integration author. The harness/runner chapter owns native protocol, model input, activity, attention, permission exchange and turn cancellation. This chapter owns where that harness runs, which files and credentials it can reach, how native state survives an environment change, and how results and resources return to their owners.
|
||||
|
||||
**Status:** a design contract for authors, with an existing-compatible composition and one measured private implementation. Since 0.7.8, OpenRig also ships one maintained reference driver, OpenShell 0.1.6, as `rig environment` (see the [OpenShell example](community-harness-examples/openshell/README.md)). The measured baseline used OpenRig 0.7.4, OpenShell 0.1.6 and Claude Code 2.1.310. 0.7.6 published this draft; neither it nor the reference driver implements a generic environment driver, a `DeploySpec` loader or a cross-host native-resume guarantee. Proposed operations below are not commands or accepted configuration keys. Capability support must be stated for the actual integration and version.
|
||||
**Status:** a design contract for authors, with an existing-compatible composition and one measured private implementation. The measured baseline used OpenRig 0.7.4, OpenShell 0.1.6 and Claude Code 2.1.310. 0.7.6 published this draft; publishing it does not implement a generic environment driver, a `DeploySpec` loader or a cross-host native-resume guarantee. Proposed operations below are not commands or accepted configuration keys. Capability support must be stated for the actual integration and version.
|
||||
|
||||
## Keep the identities and responsibilities separate
|
||||
|
||||
|
||||
@@ -55,8 +55,7 @@ together in that home; it needs its own working sign-in and doesn't relocate `~/
|
||||
> - Codex: hooks, workspace trust and team-selected config fragments (including MCP) in the selected
|
||||
> `CODEX_HOME/config.toml`, default `~/.codex/config.toml`. These affect other Codex sessions using that home.
|
||||
> - Claude Code: workspace trust in `~/.claude.json`, and activity hooks in the project's
|
||||
> `.claude/settings.local.json`, plus a status line unless you already have your own. `rig down` takes the hooks
|
||||
> and status line back out once no Claude seat uses that directory.
|
||||
> `.claude/settings.local.json`, plus a status line unless you already have your own.
|
||||
> - Discovery skills go in `~/.claude/skills` and `~/.agents/skills`.
|
||||
> - Each seat's `CLAUDE.md` or `AGENTS.md` gets OpenRig's managed blocks (culture, identity, onboarding). One more
|
||||
> line, pointing an agent at its seat skills, is added only on a yes: saved at setup, or set where the daemon runs
|
||||
|
||||
@@ -3,20 +3,17 @@
|
||||
> **Status.** This is a draft for contributors.
|
||||
> - **Released in 0.7.6:** the `runner` field and OpenRig's side of declaration, identity and activity
|
||||
> (sections 2-4).
|
||||
> - **Released in 0.7.8:** seat activity grants (section 4), and OpenRig's side of the answered permission path
|
||||
> (section 7).
|
||||
> - **Runner-author behavior, not yet qualified:** the input, cancellation and permission recipe in sections 5-7, and
|
||||
> the runner-side shutdown obligation in section 9. A dsh port implementing this recipe is not qualified.
|
||||
> - **Proposed runner-author behavior:** the input, cancellation and permission recipe in sections 5-7, and the
|
||||
> runner-side shutdown obligation in section 9. A dsh port implementing this recipe is not qualified.
|
||||
> - **Existing interfaces with author obligations:** launch-result identity, exact resume, first input and model
|
||||
> handoff. These interfaces do not establish that a particular runner implements them correctly.
|
||||
> - **Unchanged:** plain community presets and the built-in runtimes (Claude Code, Codex, Pi, OMP, terminal).
|
||||
>
|
||||
> No harness has been run end to end under this contract yet, and the native verdict is pending. The first candidate
|
||||
> is dsh over ACP.
|
||||
> No harness has been run end to end under this contract yet. The first candidate is dsh over ACP.
|
||||
>
|
||||
> Release status and pinned references are in the [joined author guide](author-contracts.md). The
|
||||
> [community presets reference](community-harness-presets.md) describes the implemented fields, including the runner
|
||||
> preset's exception for approvals; this chapter also labels runner-author behavior that isn't qualified.
|
||||
> [community presets reference](https://github.com/mvschwarz/openrig/blob/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-presets.md)
|
||||
> describes the implemented fields at that base; this chapter also labels proposed author behavior.
|
||||
|
||||
This chapter covers **which agent runs in a seat and how OpenRig talks to it**. *Where* that agent runs (a host, a
|
||||
sandbox, another machine) is the [execution platform chapter](execution-platform-contract.md). The two are separate on purpose:
|
||||
@@ -107,8 +104,6 @@ Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer
|
||||
|---|---|---|
|
||||
| You gave the agent the prompt for a submitted message | `UserPromptSubmit` / `accepted`, `turnId` = your prompt number | working |
|
||||
| The agent is working (message chunks, tool calls) | `active` / `agent_message`; `PreToolUse` / `<tool kind>` | working |
|
||||
| Optional working beat, from 0.7.9: the turn is still outstanding and the agent alive, with nothing new to report (a long tool or model call) | `active` / `liveness`, with `turnId`, `sequence` and `livenessSeconds` | working while beats keep coming |
|
||||
| From 0.7.9: you delivered an answer or permit to a request OpenRig had shown as needing input, and the agent continues the same turn (section 7) | `active` / `permission_resolved`, with `turnId` and `askId` | working |
|
||||
| You refused a permission request (section 7) | `Notification` / `permission_prompt` | needs input |
|
||||
| The turn ended | `Stop` / its stop reason (`end_turn`, `cancelled`, `max_tokens`, `refusal`) | idle. While you hold attention (section 7), don't post it: OpenRig reads any non-exit `Stop` as idle and clears needs input |
|
||||
| Optional idle heartbeat | `Stop` / `idle_heartbeat`, with `livenessSeconds` | idle while beats keep coming |
|
||||
@@ -119,41 +114,6 @@ Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer
|
||||
idle `Stop`, the turn's end as well as each beat. OpenRig stops reading the seat idle after 2 × `livenessSeconds` without
|
||||
a newer idle report. Without it, an idle report holds until your next post, even if your runner has died. Never send a
|
||||
non-exit `Stop` or a beat while holding permission attention.
|
||||
- **Working evidence lasts 15 seconds.** A working post (`UserPromptSubmit`, `active`, `PreToolUse`) decides the seat's
|
||||
activity for 15 s. After that the seat reads from its other evidence: the screen can still show work, but it never
|
||||
reads a runner seat idle, so with nothing else the seat reads unknown.
|
||||
- **The working beat (from 0.7.9)** keeps a long, quiet turn reading working. It says only that this turn's prompt is
|
||||
outstanding and the agent process you started is alive. It isn't progress.
|
||||
- **When to beat:** only while a prompt is outstanding and the agent is alive, more often than every
|
||||
`livenessSeconds` ÷ 2.
|
||||
- **When to stop:** stop at once on the turn's `Stop`, on a permission request that needs a person, or on the agent's
|
||||
exit, and post that boundary first.
|
||||
- **`livenessSeconds`** is how long one beat counts as working. OpenRig clamps it to 5-60 s, and uses 30 s when a beat
|
||||
has none. After that the beat stops counting, and the seat reads from its other evidence, as a working post's does.
|
||||
- **A beat counts only when all of these hold:**
|
||||
- it carries `generation`, equal to the seat's live occupant generation, and so did the `UserPromptSubmit` /
|
||||
`accepted` post that opened its turn;
|
||||
- its `turnId` is your latest `UserPromptSubmit` / `accepted` turn, and you haven't posted that turn's `Stop` or an
|
||||
exit;
|
||||
- it carries a `sequence`, greater than every earlier post's in the same generation that OpenRig counted, beats
|
||||
included;
|
||||
- the seat has no attention outstanding, such as a permission request waiting on a person.
|
||||
|
||||
So number every post with one `sequence` that rises across your whole runner process, beats included. OpenRig
|
||||
orders your posts by `occurredAt`, then `sequence`. A post older than one OpenRig already counted changes nothing:
|
||||
a late `Stop` for an earlier turn doesn't end the current one, and an older beat doesn't count. A post without a
|
||||
valid `occurredAt` is dated when it arrives, by OpenRig's clock, so it can read older than it is when your clock
|
||||
runs ahead. Give every post its `occurredAt`.
|
||||
- **After you deliver an answer or permit** to a request OpenRig had shown as needing input (section 7; it created an
|
||||
ask, and a permit at submit opens none), post `active` / `permission_resolved` with the turn's `turnId` and the
|
||||
`askId`, before your next beat. That clears the seat's attention. So does any other working or idle post OpenRig
|
||||
counts, and an operator's `rig seat clear-attention`. OpenRig doesn't match `permission_resolved` to its `askId`.
|
||||
Beats are ignored until attention clears.
|
||||
- **A beat that fails any of these is ignored.** The seat reads as it would without it. A beat never starts or extends
|
||||
a turn, never clears attention, and never stands in for a `Stop`.
|
||||
- **OpenRig keeps what it needs to judge beats in memory.** After a daemon restart, a beat already counted keeps its
|
||||
lifetime, but new beats are ignored until your next accepted prompt, and the seat reads as it would without them.
|
||||
- **A runner that sends no beats** behaves as before.
|
||||
- **Exit:** post `Stop` / `<preset name>_exited` only when you observe the agent's process exit.
|
||||
|
||||
- **The screen still counts,** but only as evidence of work. On its own it never reads a runner seat idle.
|
||||
@@ -176,17 +136,13 @@ Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer
|
||||
A refused post records nothing, so the seat's activity stays as it was, or unknown.
|
||||
|
||||
**The guest's boundary is its listener, not the grant.** A guest reaches OpenRig only through a guest-only listener that
|
||||
serves this route and section 7's three permission-request routes, and accepts only a grant. It must never reach the
|
||||
daemon's main port. The grant scopes activity posts and permission requests, and the main port's other routes don't
|
||||
check it.
|
||||
- The listener is off by default. Turn it on with `OPENRIG_GUEST_ACTIVITY_PORT`, which must differ from the daemon's
|
||||
main port or the daemon refuses to start.
|
||||
- It binds `OPENRIG_GUEST_ACTIVITY_HOST`, default `127.0.0.1`: set it to an address the guest can reach.
|
||||
serves this one route and accepts only a grant. It must never reach the daemon's main port. The grant scopes activity
|
||||
posts only, and the main port's other routes don't check it.
|
||||
|
||||
## 5. Input: one submitted message is one prompt
|
||||
|
||||
Sections 5-7 specify the first dsh/ACP runner recipe; no runner has been qualified against it yet. They do not change
|
||||
permission or input handling in existing built-in runtimes or require every possible runner protocol to use ACP.
|
||||
Sections 5-7 specify the proposed first dsh/ACP runner recipe. They do not change permission or input handling in
|
||||
existing built-in runtimes or require every possible runner protocol to use ACP.
|
||||
|
||||
- **Set up the pane:** put your TTY in raw mode and turn on bracketed paste (`\e[?2004h`).
|
||||
- **What arrives:** `rig send` and queue reminders paste the whole message, then press Enter. Everything up to that Enter
|
||||
@@ -206,8 +162,6 @@ permission or input handling in existing built-in runtimes or require every poss
|
||||
|
||||
- **On `/abort` or Ctrl-C:** send the agent's cancel (for ACP, the `session/cancel` notification, which has no reply),
|
||||
and record when you sent it.
|
||||
- A person cancels by typing `/abort` in the seat's pane. A `/abort` sent with `rig send` arrives inside a From/To
|
||||
envelope, so it's an ordinary prompt.
|
||||
- **Report "cancelled" only when the agent says so:** for ACP, when `session/prompt` returns `stopReason: "cancelled"`.
|
||||
Then post `Stop` / `cancelled`.
|
||||
- **Whether the provider stopped work or spend is unknown.** Don't claim it.
|
||||
@@ -298,15 +252,11 @@ that runs without asking gains no approval step. Ordinary conversation and `rig
|
||||
`refused` (with `reason`: `said_no` when the person said no and the agent offered no reject option, or
|
||||
`no_allow_offered` when it was closed without a choice and the agent offered no allow option), `withdrawn` or
|
||||
`stale`.
|
||||
- **From 0.7.9, `said`,** with `permitted` or `refused` (`no_allow_offered`), is the person's own words, when they
|
||||
answered in words that were neither a choice nor a plain yes or no. It never changes the outcome.
|
||||
- **A seat grant reads and withdraws only its own launch's requests.** Once its occupant is replaced, the grant is
|
||||
refused (`409 activity_grant_stale`), even during a read's wait.
|
||||
3. **Deliver** `{"outcome": {"outcome": "selected", "optionId": "<optionId>"}}` to the agent once, and only while that
|
||||
request is still its live one. For `refused`, `withdrawn` or `stale`, answer `cancelled` and hold no attention: no
|
||||
person is still waiting. A refusal of the read itself follows the refusals below. From 0.7.9, when the read carries
|
||||
`said`, queue those words for the agent as an ordinary prompt: they reach it after the current turn, behind any prompt
|
||||
already waiting. They're never the answer. A runner that drops them still keeps this contract.
|
||||
person is still waiting. A refusal of the read itself follows the refusals below.
|
||||
4. **Withdraw** when the turn is cancelled or ends, or the agent exits:
|
||||
`POST <base>/api/activity/permission-requests/<askId>/withdraw` with
|
||||
`{"reason": "turn_cancelled" | "turn_ended" | "exited"}`.
|
||||
|
||||
@@ -107,38 +107,6 @@ OpenRig doesn't observe a hosted member between its calls, so it never reads idl
|
||||
|
||||
`rig mcp token revoke grok` refuses its next request. The rows it worked, and their answers, stay.
|
||||
|
||||
## Assistants connected through a grant service
|
||||
|
||||
With `rig mcp serve --http --grant-service <url>`, an outside service answers each connection's key with its grant
|
||||
(`openrig.grant/v1`): the seat it acts as, its rigs, and the object/action pairs it may use. Each pair offers one tool,
|
||||
and nothing else is offered:
|
||||
|
||||
| Pair | Tool |
|
||||
|---|---|
|
||||
| `queue`/`create`, `queue`/`show`, `queue`/`list` | `rig_queue_create`, `rig_queue_show`, `rig_queue_list` |
|
||||
| `queue`/`claim`, `queue`/`update` | `rig_queue_claim`, `rig_queue_update` |
|
||||
| `team`/`list` | `rig_team` |
|
||||
| `artifact`/`read` | `rig_artifact_read` |
|
||||
|
||||
- **One seat per connected assistant:** the service gives each connection its own seat and keeps it across that
|
||||
connection's reconnects. The seat must be one of the rig's nodes; a node created without a launch is enough. The caller
|
||||
is exactly that seat.
|
||||
- **What it reaches:**
|
||||
- It hands work to its rigs' other seats, never to a person or another host's seat.
|
||||
- It reads the items it sent and the items addressed to it from those seats, never the team's internal items.
|
||||
- With `queue`/`claim` and `queue`/`update`, it claims and closes only items addressed to its own seat that it can read,
|
||||
and waits (blocked) only on an item it names and can read. Its answers hide what reading the item hides.
|
||||
- **Two assistants together:** a grant with `team`/`list` sees its rigs' seats with `rig_team`, and may address and hear from
|
||||
another connected assistant's seat. A grant without it never reaches a seat in the service's caller form
|
||||
(`cus-<id>-<connection>@<rig>`), so an existing connection keeps the reach it had.
|
||||
- **Messages are items:** a message is an item the receiver reads and closes, and a reply is an item back to its sender.
|
||||
- **What the sender can tell, from the item it reads:**
|
||||
- **Queued:** the item exists and is `pending` with no claim.
|
||||
- **Picked up:** the receiver's claim (`in-progress`).
|
||||
- **Answered:** closed `done`, with the answer in `resolution`.
|
||||
- Reading or listing an item records nothing, and nothing wakes an assistant, so an item stays queued until its receiver
|
||||
claims it.
|
||||
|
||||
## Limits today
|
||||
|
||||
- **Webhook triggers only:** OpenRig rings a webhook. Email and other triggers aren't supported yet. A member without a trigger
|
||||
|
||||
@@ -105,8 +105,7 @@ The daemon starts and boots the kernel with the coding agents you're signed in t
|
||||
**What starting OpenRig changes, for the coding agents you use** (paths are the usual defaults):
|
||||
- **For Codex:** hooks and trust in `~/.codex/config.toml`, when OpenRig or a Codex agent starts.
|
||||
- **For Claude Code:** workspace trust in `~/.claude.json`, and activity hooks in the project's
|
||||
`.claude/settings.local.json`, plus a status line unless you already have your own. `rig down` takes the hooks and
|
||||
status line back out once no Claude seat uses that directory.
|
||||
`.claude/settings.local.json`, plus a status line unless you already have your own.
|
||||
- **For both:** small discovery skills in `~/.claude/skills` and `~/.agents/skills`.
|
||||
- **Kernel access:** by default the kernel gets broader permissions than a project team gets by default. On Codex
|
||||
that's full access with no approval prompts. On Claude Code, its file tools, web fetches and operational commands,
|
||||
|
||||
@@ -18,7 +18,7 @@ node check-runner.mjs --runner 'my-runner --agent-command {agent}' --name my-har
|
||||
- **`--wait-ms 20000`** waits longer for each step (default 8000), for a runner whose agent takes a while to start. The check's shorter settling times, and the fake agent's own delays and holds, grow in proportion.
|
||||
- **`--pass-env NAME`** passes one more variable through. Otherwise the runner gets only `PATH`, `HOME`, `USER`,
|
||||
`SHELL`, `LANG` and `TMPDIR`, plus the OpenRig variables.
|
||||
- **The exit status** is 0 when nothing fails, 1 when a case fails, and 2 for a usage or setup problem. An advisory result (4c, 7c) never changes it.
|
||||
- **The exit status** is 0 when nothing fails, 1 when a case fails, and 2 for a usage or setup problem. An advisory result (7c) never changes it.
|
||||
|
||||
In an installed OpenRig, the check is at `$(npm root -g)/@openrig/cli/daemon/docs/reference/runner-conformance/`.
|
||||
|
||||
@@ -51,13 +51,11 @@ In an installed OpenRig, the check is at `$(npm root -g)/@openrig/cli/daemon/doc
|
||||
| 1b | 3, 4 | A handshake that fails at `initialize` or `session/new` (an error or an exit) writes no conversation in a launch record, and posts no accepted prompt or idle report |
|
||||
| 2 | 5, 6 | One pasted block becomes exactly one `session/prompt`, byte for byte. A line reading `/abort` inside a block isn't control. A block sent mid-turn waits for the next turn. A block that is exactly `/abort`, or Ctrl-C outside a paste, sends one `session/cancel`. `Stop/cancelled` is posted only when the agent itself returns `cancelled` |
|
||||
| 3 | 4 | `UserPromptSubmit/accepted` with a `turnId`, then `Stop/end_turn`. Every post carries the contract's fields, the hook token, this seat's identity and a rising `sequence`. `livenessSeconds`, when given, is from 1 to 3600 |
|
||||
| 3b | 4 | **Optional, from 0.7.9; skipped when the runner sends no beat.** In a long, quiet turn, each `active/liveness` beat names the accepted turn, `sequence` rises with the runner's own `occurredAt` across every post, beats come more often than every `livenessSeconds` ÷ 2, and none follows the turn's `Stop`. While a person is asked, no beat is posted; after the answer, `active/permission_resolved` names the turn and the ask, before any beat. A permit at submit opens no ask, so the quiet turn's beats go on |
|
||||
| 4 | 7 | When no one can be asked, the request is permitted: an OpenRig without the answered path (404) at once, and one the runner can't reach, each get the agent's `allow_once` option, never its `allow_always` while `allow_once` is offered. The turn then reports as usual, with no `permission_prompt`. Only `denied_by_seat`, or a request with no allow option, is answered at once with a refusal, never an allow option. `Notification/permission_prompt` is then posted, and for `denied_by_seat` the pane shows what was asked (the tool call's title). No idle `Stop` or beat is posted while that attention is held, even when `/abort` then cancels the turn. The next prompt is accepted as usual |
|
||||
| 4b | 7 | The live request is submitted with the hook token, its `requestId` as the agent's JSON-RPC id, passed unchanged, the accepted prompt's `turnId`, this conversation and generation, and the agent's options as offered. The runner doesn't post `permission_prompt` itself for a created ask, and each read waits at most 25 s. The chosen option, allow or deny, reaches the agent once, and the turn then reports as usual. OpenRig's `permitted`, at submit or at a read, reaches the agent the same way, and a read that carries the person's own words (`said`) still gives the agent the read's option. A refusal is read by its code, never its status: one section 7's table marks "permits" (`duplicate_request`, `activity_grant_pending`, `activity_grant_revoked`), a read that gets no answer, and a status or read state the section doesn't list (500, `deferred`) each give the agent its `allow_once` option. A refusal saying the request isn't the current occupant's (`stale_generation` at submit, `activity_grant_stale` on a read), and a `refused` (`said_no`), stale or withdrawn read, give the agent `cancelled`, and the turn then ends as usual, with nothing held. With no answer, the request stays pending with no idle report after the turn's accepted prompt, and a pasted message isn't an answer. `/abort` withdraws it with `turn_cancelled`, and an answer after that isn't delivered. A turn's end or the agent's exit while it waits withdraws it with `turn_ended` or `exited`. Once the ask is withdrawn no person is still waiting, so the turn's end is reported as usual. An ask the runner permitted after a read got no answer, or an unlisted one, may still be open, so it's withdrawn at the turn's end too |
|
||||
| 4c | 7 | **Advisory.** A read's `said`, the words of a person who answered in words, is queued to the agent as a prompt, after the turn and after any prompt already waiting. Section 7 lets a runner drop them and still keep the contract, so this result is shown as met or not met and never counts as a pass or a fail, or toward the exit status |
|
||||
| 4b | 7 | The live request is submitted with the hook token, its `requestId` as the agent's JSON-RPC id, passed unchanged, the accepted prompt's `turnId`, this conversation and generation, and the agent's options as offered. The runner doesn't post `permission_prompt` itself for a created ask, and each read waits at most 25 s. The chosen option, allow or deny, reaches the agent once, and the turn then reports as usual. OpenRig's `permitted`, at submit or at a read, reaches the agent the same way. A refusal is read by its code, never its status: one section 7's table marks "permits" (`duplicate_request`, `activity_grant_pending`, `activity_grant_revoked`), a read that gets no answer, and a status or read state the section doesn't list (500, `deferred`) each give the agent its `allow_once` option. A refusal saying the request isn't the current occupant's (`stale_generation` at submit, `activity_grant_stale` on a read), and a `refused` (`said_no`), stale or withdrawn read, give the agent `cancelled`, and the turn then ends as usual, with nothing held. With no answer, the request stays pending with no idle report, and a pasted message isn't an answer. `/abort` withdraws it with `turn_cancelled`, and an answer after that isn't delivered. A turn's end or the agent's exit while it waits withdraws it with `turn_ended` or `exited`. Once the ask is withdrawn no person is still waiting, so the turn's end is reported as usual. An ask the runner permitted after a read got no answer, or an unlisted one, may still be open, so it's withdrawn at the turn's end too |
|
||||
| 5 | 4 | Every post carries `OPENRIG_OCCUPANT_GENERATION` |
|
||||
| 6 | 8 | A resume asks the agent to reopen `OPENRIG_RESUME_REF` in the launch's working folder, and records that conversation. The agent's own not-found becomes `error: "missing"`. Any other failure is neither missing nor resumed. No case starts a new conversation |
|
||||
| 7 | 4 | An agent that dies mid-turn gets `Stop/<preset>_exited`, and no idle report for that turn. Posts are taken in the runner's own order, as OpenRig orders them (`occurredAt` when it's a time string, otherwise the post's arrival, then `sequence` when both are positive whole numbers), so an idle report from before the turn began doesn't count, however late it arrives |
|
||||
| 7 | 4 | An agent that dies mid-turn gets `Stop/<preset>_exited`, and no idle report after it |
|
||||
| 7b | 4 | A failed `session/prompt` posts `Notification/runtime_error`, not a permission request |
|
||||
| 7c | 9 | **Advisory.** Closing the pane ends the agent process. Section 9 marks this duty as proposed, and lets a runner list in `limits` what may keep running, so this result is shown as met or not met and never counts as a pass or a fail, or toward the exit status. The fake agent outlives a hangup here, so the runner has to end it |
|
||||
| 8 | 7 | An unknown request from the agent gets JSON-RPC `-32601`, and the next prompt still goes through |
|
||||
|
||||
@@ -133,8 +133,8 @@ function askState(h, entry) {
|
||||
const script = h.asks.script;
|
||||
if (entry.withdrawn) return script.late ?? { state: "withdrawn" };
|
||||
if (script.answer && Date.now() >= entry.created + (script.answer.afterMs ?? 0)) {
|
||||
const { state, optionId, reason, said } = script.answer;
|
||||
return { state, ...(optionId ? { optionId } : {}), ...(reason ? { reason } : {}), ...(said ? { said } : {}) };
|
||||
const { state, optionId, reason } = script.answer;
|
||||
return { state, ...(optionId ? { optionId } : {}), ...(reason ? { reason } : {}) };
|
||||
}
|
||||
return { state: "pending" };
|
||||
}
|
||||
@@ -242,19 +242,6 @@ async function launch(label, { scenario = {}, env = {}, asks } = {}) {
|
||||
// An ordinary send arrives inside an envelope, so a line in it that reads /abort is still just text.
|
||||
const message = (h, body) => `From: checker@runner-check\nTo: ${h.session}\n---\n${body}`;
|
||||
const nonExitStops = (h, after = 0) => h.hooks("Stop").filter((p) => p.at >= after && !String(p.body?.subtype ?? "").endsWith("_exited"));
|
||||
// Whether post `a` comes before post `b` in the runner's own order, as OpenRig orders them: by `occurredAt` when it's a
|
||||
// non-blank string that reads as a time, otherwise by its arrival; then by `sequence`, when both carry a positive whole number.
|
||||
const postedAt = (p) => {
|
||||
const value = p?.body?.occurredAt;
|
||||
const t = typeof value === "string" && value.trim() ? Date.parse(value) : NaN;
|
||||
return Number.isFinite(t) ? t : p?.at ?? 0;
|
||||
};
|
||||
const sequenceOf = (p) => { const value = p?.body?.sequence; return Number.isSafeInteger(value) && value > 0 ? value : null; };
|
||||
const precedes = (a, b) => postedAt(a) !== postedAt(b) ? postedAt(a) < postedAt(b)
|
||||
: sequenceOf(a) !== null && sequenceOf(b) !== null && sequenceOf(a) < sequenceOf(b);
|
||||
// Non-exit Stops the runner posted after `post` in that order. An idle report from before the turn began (a runner may report
|
||||
// idle at startup) is never one made during it, however late its POST arrives.
|
||||
const stopsAfter = (h, post) => nonExitStops(h).filter((p) => !post || !precedes(p, post));
|
||||
const ready = (h, ms) => waitFor(() => h.record()?.nativeRef && h.record(), ms);
|
||||
|
||||
function result(id, title, { proposed = false } = {}) {
|
||||
@@ -377,65 +364,6 @@ const cases = {
|
||||
return r;
|
||||
},
|
||||
|
||||
// The optional working beat (section 4). When a runner posts active/liveness in a quiet turn, each beat names the open
|
||||
// turn it belongs to, keeps the post order, comes often enough for its own lifetime, stops at the turn's end, and never
|
||||
// runs while a person is asked; after an answer, active/permission_resolved comes before the next beat. Order is read
|
||||
// from the runner's own sequence and times, so a slow delivery never reads as a broken runner.
|
||||
async "3b"() {
|
||||
const r = result("3b", "Working beat (optional): beats name their open turn, keep order, and stop at its end and while a person is asked");
|
||||
const quiet = scale(22000);
|
||||
const seqOf = (p) => Number(p?.body?.sequence);
|
||||
const at = (p) => Date.parse(p.body.occurredAt);
|
||||
const h = await launch("3b", { scenario: { turns: [{ silent: true, holdMs: quiet }, {}] } });
|
||||
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
|
||||
h.paste(message(h, "a long, quiet turn"));
|
||||
const accepted = await waitFor(() => h.hooks("UserPromptSubmit", "accepted")[0]);
|
||||
const end = await waitFor(() => h.hooks("Stop", "end_turn")[0], quiet + opts.waitMs);
|
||||
await sleep(scale(11000)); // room for a beat a runner wrongly sends after the Stop
|
||||
const beats = h.hooks("active", "liveness");
|
||||
if (!beats.length) return r.skip("the runner sends no working beat in a quiet turn; the beat is optional (section 4)");
|
||||
r.check("each beat names the turn it belongs to", accepted && beats.every((b) => b.body.turnId === accepted.body.turnId),
|
||||
`turnIds: ${beats.map((b) => b.body.turnId).join(", ")}`);
|
||||
// In the runner's own time order, not delivery's: a post delivered late still has to fit where the runner sent it.
|
||||
const byTime = (a, b) => at(a) - at(b) || seqOf(a) - seqOf(b);
|
||||
const timed = h.posts().slice().sort(byTime);
|
||||
r.check("sequence rises with the runner's own time across every post, beats included",
|
||||
timed.every((p, i) => Number.isFinite(seqOf(p)) && Number.isFinite(at(p)) && (i === 0 || seqOf(p) > seqOf(timed[i - 1]))),
|
||||
`sequences in occurredAt order: ${timed.map(seqOf).join(", ")}`);
|
||||
const life = (b) => (Number.isFinite(b.body.livenessSeconds) ? b.body.livenessSeconds : 30);
|
||||
const inTurn = beats.filter((b) => !end || seqOf(b) < seqOf(end)).sort(byTime);
|
||||
const gaps = inTurn.map((b, i) => at(b) - at(i === 0 ? accepted : inTurn[i - 1]));
|
||||
r.check("beats come more often than every livenessSeconds / 2", inTurn.every((b, i) => gaps[i] <= life(b) * 500 + 1000),
|
||||
`gaps by the runner's own times: ${gaps.join(", ")} ms`);
|
||||
r.check("no beat after the turn's Stop", end && beats.every((b) => seqOf(b) < seqOf(end)),
|
||||
`beats after Stop/end_turn: ${beats.filter((b) => end && seqOf(b) > seqOf(end)).length}`);
|
||||
|
||||
// A person is asked first, and answers after a while; the agent then carries on, quietly, in the same turn.
|
||||
const p = await launch("3b-asked", { scenario: { turns: [{ permission: true, silent: true, holdMs: quiet }, {}] },
|
||||
asks: { answer: { afterMs: scale(12000), state: "answered", optionId: "allow-once" } } });
|
||||
if (!r.check("asked: the runner opens its conversation", await ready(p), "no launch record")) return r;
|
||||
p.paste(message(p, "edit the notes"));
|
||||
const submitted = await waitFor(() => p.permissionCalls("POST").find((c) => c.path === "/api/activity/permission-requests"), scale(5000));
|
||||
if (!r.check("asked: the request is submitted", submitted, "no submit")) return r;
|
||||
await waitFor(() => p.hooks("Stop", "end_turn")[0], scale(12000) + quiet + opts.waitMs);
|
||||
const resolvedPost = p.hooks("active", "permission_resolved")[0];
|
||||
const before = p.hooks("active", "liveness").filter((b) => at(b) > submitted.at && (!resolvedPost || seqOf(b) < seqOf(resolvedPost)));
|
||||
r.check("asked: no beat while the ask waits on a person", before.length === 0, `${before.length} beats while it waited`);
|
||||
r.check("asked: after the answer, active/permission_resolved names the turn and the ask, before any beat",
|
||||
resolvedPost && resolvedPost.body.askId && resolvedPost.body.turnId === p.hooks("UserPromptSubmit", "accepted")[0]?.body.turnId,
|
||||
resolvedPost ? `permission_resolved: ${JSON.stringify({ turnId: resolvedPost.body.turnId, askId: resolvedPost.body.askId })}` : "no permission_resolved");
|
||||
|
||||
// OpenRig permits at submit when no one can be asked: no ask opens, so nothing waits and the beats go on.
|
||||
const q = await launch("3b-permitted", { scenario: { turns: [{ permission: true, silent: true, holdMs: quiet }, {}] },
|
||||
asks: { submit: { permitted: true } } });
|
||||
if (!r.check("permitted at submit: the runner opens its conversation", await ready(q), "no launch record")) return r;
|
||||
q.paste(message(q, "edit the notes"));
|
||||
const qEnd = await waitFor(() => q.hooks("Stop", "end_turn")[0], quiet + opts.waitMs);
|
||||
const qBeats = q.hooks("active", "liveness").filter((b) => !qEnd || seqOf(b) < seqOf(qEnd));
|
||||
r.check("permitted at submit: the quiet turn's beats go on, with no ask to resolve", qBeats.length > 0, "no beat in the quiet turn after the permit");
|
||||
return r;
|
||||
},
|
||||
|
||||
// Section 7's default is permit: when no one can be asked, the agent gets its own allow option at once, allow_once before
|
||||
// allow_always. Only the seat's opt-in refusal (denied_by_seat), or a request with no allow option, is refused at once,
|
||||
// shown, and held as needs input until the next prompt, even when the turn is cancelled.
|
||||
@@ -521,8 +449,7 @@ const cases = {
|
||||
const submitted = await waitFor(() => submits(h)[0], scale(3000));
|
||||
if (!r.check(`${label}: the request is submitted to /api/activity/permission-requests`, submitted, "no submit")) continue;
|
||||
const asked = h.sent("session/request_permission")[0];
|
||||
// The submit isn't one of the runner's ordered activity posts, so it can arrive before the accepted prompt does.
|
||||
const accepted = await waitFor(() => h.hooks("UserPromptSubmit", "accepted")[0], scale(3000));
|
||||
const accepted = h.hooks("UserPromptSubmit", "accepted")[0];
|
||||
const sent = submitted.body ?? {};
|
||||
if (label === "allow") {
|
||||
r.check("the submit passes OpenRig's checks", h.asks.submitted[0]?.problems.length === 0, h.asks.submitted[0]?.problems.join("; "));
|
||||
@@ -559,8 +486,6 @@ const cases = {
|
||||
const outcomes = [
|
||||
["permitted at submit", { submit: { permitted: true } }, "allow-once"],
|
||||
["permitted at read", { answer: { afterMs: 300, state: "permitted", optionId: "allow-once" } }, "allow-once"],
|
||||
// A person's own words come back beside the outcome, and never change the option delivered.
|
||||
["permitted at read, with the person's words", { answer: { afterMs: 300, state: "permitted", optionId: "allow-once", said: "only the notes file" } }, "allow-once"],
|
||||
["duplicate_request", { submit: { status: 409, code: "duplicate_request" } }, "allow-once"],
|
||||
["activity_grant_pending", { submit: { status: 409, code: "activity_grant_pending" } }, "allow-once"],
|
||||
["activity_grant_revoked", { submit: { status: 401, code: "activity_grant_revoked" } }, "allow-once"],
|
||||
@@ -605,8 +530,7 @@ const cases = {
|
||||
await sleep(scale(1500));
|
||||
r.check("pending: the runner keeps reading the ask", h.permissionCalls("GET").length >= 1, "no read of the ask");
|
||||
r.check("pending: nothing is delivered while no one has answered", answers(h).length === 0, `got ${JSON.stringify(outcome(h))}`);
|
||||
const turn = h.hooks("UserPromptSubmit", "accepted").at(-1);
|
||||
r.check("pending: no idle Stop while it waits", stopsAfter(h, turn).length === 0, stopsAfter(h, turn).map((p) => p.body.subtype).join(", "));
|
||||
r.check("pending: no idle Stop while it waits", nonExitStops(h).length === 0, nonExitStops(h).map((p) => p.body.subtype).join(", "));
|
||||
h.paste(message(h, "allow it"));
|
||||
await sleep(scale(800));
|
||||
r.check("pending: a pasted message isn't an answer", answers(h).length === 0, `got ${JSON.stringify(outcome(h))}`);
|
||||
@@ -649,22 +573,6 @@ const cases = {
|
||||
return r;
|
||||
},
|
||||
|
||||
// Section 7: a person who answers in their own words gets them passed on. OpenRig carries them as the read's `said`, and
|
||||
// a runner queues them for the agent like any other prompt, after the turn, never as the answer. A runner that drops
|
||||
// them still keeps the contract, so the result is advisory.
|
||||
async "4c"() {
|
||||
const r = result("4c", "The person's words: a read's said is queued to the agent as a prompt (advisory)", { proposed: true });
|
||||
const said = "Only the notes file, please. Leave the rest alone.";
|
||||
const h = await launch("4c", { scenario: { turns: [{ permission: true }, {}] },
|
||||
asks: { answer: { afterMs: 300, state: "permitted", optionId: "allow-once", said } } });
|
||||
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
|
||||
h.paste(message(h, "edit the notes"));
|
||||
if (!r.check("the first turn ends", await waitFor(() => h.hooks("Stop", "end_turn")[0], scale(6000)), "no Stop/end_turn")) return r;
|
||||
const later = await waitFor(() => h.prompts().slice(1).find((prompt) => prompt?.includes(said)), scale(4000));
|
||||
r.check("the person's words are queued to the agent as a prompt, after the turn", later, `prompts: ${JSON.stringify(h.prompts())}`);
|
||||
return r;
|
||||
},
|
||||
|
||||
async "5"() {
|
||||
const r = result("5", "Generation: every post carries the launch's occupant generation");
|
||||
const h = await launch("5");
|
||||
@@ -722,10 +630,7 @@ const cases = {
|
||||
if (!r.check("the agent exits mid-turn", died, "the agent didn't exit")) return r;
|
||||
const exited = await waitFor(() => h.hooks("Stop", `${opts.name}_exited`)[0], scale(4000));
|
||||
r.check(`Stop/${opts.name}_exited is posted`, exited, `posts: ${h.posts().map((p) => `${p.body?.hookEvent}/${p.body?.subtype}`).join(", ")}`);
|
||||
// Any idle report for the turn the agent died in counts, before or after the exit; one from before the turn doesn't.
|
||||
const turn = h.hooks("UserPromptSubmit", "accepted")[0];
|
||||
const idle = turn ? stopsAfter(h, turn) : nonExitStops(h, died.at);
|
||||
r.check("no idle report for the turn it died in", idle.length === 0, idle.map((p) => p.body.subtype).join(", "));
|
||||
r.check("no idle Stop after the exit", nonExitStops(h, died.at).length === 0, nonExitStops(h, died.at).map((p) => p.body.subtype).join(", "));
|
||||
return r;
|
||||
},
|
||||
|
||||
@@ -781,7 +686,7 @@ const cases = {
|
||||
},
|
||||
};
|
||||
|
||||
const order = ["1", "1b", "2", "3", "3b", "4", "4b", "4c", "5", "6", "7", "7b", "7c", "8", "9"];
|
||||
const order = ["1", "1b", "2", "3", "4", "4b", "5", "6", "7", "7b", "7c", "8", "9"];
|
||||
const selected = opts.cases ?? order;
|
||||
const unknown = selected.filter((id) => !order.includes(id));
|
||||
if (unknown.length) { console.error(`unknown case ${unknown.join(", ")}; cases are ${order.join(", ")}`); process.exit(2); }
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// stubborn keep running after a hangup or after input closes; only SIGTERM or SIGKILL ends it
|
||||
// turns one entry per `session/prompt`, in order (the last one repeats):
|
||||
// holdMs keep the turn open this long; `session/cancel` ends it early as "cancelled"
|
||||
// silent send no message chunk for this turn, so a held turn is entirely quiet (a long tool or model call)
|
||||
// ignoreCancel on `session/cancel`, keep the turn open and end it with stopReason (default end_turn) anyway
|
||||
// permission ask `session/request_permission` first, then carry on whatever the answer. As an object:
|
||||
// options the options offered (default: allow-once, allow_once; reject-once, reject_once)
|
||||
@@ -66,7 +65,7 @@ async function prompt(id, params) {
|
||||
turn += 1;
|
||||
if (spec.exit) exit(3, `turn ${turn}`);
|
||||
if (spec.error) { fail(id, -32603, "fake agent: this turn fails"); return; }
|
||||
if (!spec.silent) send({ jsonrpc: "2.0", method: "session/update",
|
||||
send({ jsonrpc: "2.0", method: "session/update",
|
||||
params: { sessionId: params.sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: `turn ${turn}` } } } });
|
||||
let stopReason = spec.stopReason ?? "end_turn";
|
||||
if (spec.permission) {
|
||||
|
||||
@@ -207,7 +207,7 @@ rig slack channel-map remove pr@my-rig
|
||||
their routing also keeps the conversations separate: new messages in a seat's own channel
|
||||
still go to the inbound destination unless its entry names `--inbound`. A conversation shared
|
||||
on purpose is fine.
|
||||
- Missed-message recovery (below) covers the default channel and every mapped channel, except a channel relayed to another machine.
|
||||
- Missed-message recovery (below) covers the default channel only.
|
||||
|
||||
Invite the app to every mapped channel (`/invite @OpenRig`). `rig slack verify` checks
|
||||
membership in the default channel and every mapped channel, and reports NOT ready until the
|
||||
|
||||
@@ -62,16 +62,3 @@ export function writeTextAtomically(path: string, content: string, label = "file
|
||||
if (owned) try { unlinkSync(temporary); } catch { /* Renamed or already removed. */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a file, failing with EEXIST rather than replacing anything already at the path, even a dangling link. */
|
||||
export function writeNewText(path: string, content: string): void {
|
||||
const fd = openSync(path, "wx", 0o666);
|
||||
try {
|
||||
writeFileSync(fd, content, "utf-8");
|
||||
} catch (error) {
|
||||
try { unlinkSync(path); } catch { /* Report the write failure. */ }
|
||||
throw error;
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
@@ -227,8 +227,7 @@ function renderBroadcastResults(data: Record<string, unknown>): void {
|
||||
if (r.ok) {
|
||||
console.log(`${r.sessionName}: sent`);
|
||||
} else {
|
||||
// A target that resolved to no sessions has no name to report.
|
||||
console.log(`${r.sessionName ? `${r.sessionName}: ` : ""}FAILED — ${r.error ?? "unknown error"}`);
|
||||
console.log(`${r.sessionName}: FAILED — ${r.error ?? "unknown error"}`);
|
||||
}
|
||||
if (typeof r.managedCompaction?.notice === "string") console.log(`Notice: ${r.managedCompaction.notice}`);
|
||||
}
|
||||
|
||||
@@ -88,8 +88,6 @@ interface TeardownResult {
|
||||
keptSkills?: string[];
|
||||
/** Managed guidance blocks a person edited, kept in their file as `<file>: <block id>`. */
|
||||
keptGuidance?: string[];
|
||||
/** Files in a Claude seat's cwd that keep OpenRig's additions because down couldn't safely change them, as `<file>: <why>`. */
|
||||
keptFiles?: string[];
|
||||
/** Seats joined with `rig attach` as an external CLI, which OpenRig doesn't stop. */
|
||||
leftRunning?: string[];
|
||||
paneOnly?: string[];
|
||||
@@ -297,10 +295,6 @@ the command warns and exits without stopping; re-run with --yes to confirm expli
|
||||
console.log(`Kept ${result.keptGuidance.length} edited OpenRig block(s) in guidance files; remove them by hand if you don't want them:`);
|
||||
for (const block of result.keptGuidance) console.log(` ${block}`);
|
||||
}
|
||||
if (result.keptFiles && result.keptFiles.length > 0) {
|
||||
console.log(`Kept ${result.keptFiles.length} file(s) that still hold OpenRig's additions; check them by hand:`);
|
||||
for (const file of result.keptFiles) console.log(` ${file}`);
|
||||
}
|
||||
|
||||
if (result.leftRunning && result.leftRunning.length > 0) {
|
||||
console.log(`Not stopped: ${result.leftRunning.join(", ")}. Joined with rig attach as an external CLI, which OpenRig doesn't stop or check.`);
|
||||
|
||||
@@ -128,10 +128,8 @@ export function environmentCommand(override?: EnvironmentCommandDeps): Command {
|
||||
.action(action(async (binding: string, opts: { live?: boolean }) => report(checked(await (await client()).get<Record<string, any>>(`/api/environments/${encodeURIComponent(binding)}${opts.live ? "?live=true" : ""}`)))));
|
||||
cmd.command("connect").argument("<binding>").requiredOption("--generation <id>").description("Open only the bound execution's viewer; closing it does not prove native or compute stopped")
|
||||
.action(action(async (id: string, opts: { generation: string }) => {
|
||||
// Launch and the guest latch already verify all projected files. Opening a viewer needs only one live
|
||||
// resource lookup (the driver bounds it at 30s), not another serial validation of every startup file.
|
||||
const c = await client(), b = checked(await c.get<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}?live=true`, { timeoutMs: 35_000 }));
|
||||
if (b.generation !== opts.generation) throw new Error("The selected environment generation changed; no viewer was opened");
|
||||
const c = await client(), b = checked(await c.get<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}?live=true`));
|
||||
checked(await c.post<Record<string, any>>(`/api/environments/${encodeURIComponent(id)}/validate`, { generation: opts.generation }));
|
||||
const args = ["--gateway", b.placement.gateway, "--workspace", b.placement.workspace, "sandbox", "connect", b.placement.name];
|
||||
await new Promise<void>((resolveExit, reject) => {
|
||||
const child = spawn("openshell", args, { stdio: "inherit" });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import nodePath from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { Command } from "commander";
|
||||
import { writeNewText, writeTextAtomically } from "../atomic-text-write.js";
|
||||
import { writeTextAtomically } from "../atomic-text-write.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
@@ -10,8 +10,6 @@ import { rigIdForHandle } from "../rig-handle.js";
|
||||
|
||||
export interface ExportDeps extends StatusDeps {
|
||||
writeFile: (path: string, content: string) => void;
|
||||
/** Must fail with EEXIST when the path is taken. */
|
||||
writeNewFile?: (path: string, content: string) => void;
|
||||
exists?: (path: string) => boolean;
|
||||
}
|
||||
|
||||
@@ -39,35 +37,18 @@ function copyCulture(file: string, root: string, outDir: string): void {
|
||||
console.log(`Copied culture to ${target}`);
|
||||
}
|
||||
|
||||
/** rig.yaml is also the name of an authored spec, and an export doesn't carry all of one (comments, a summary, startup
|
||||
* it couldn't read back). So a default export takes the first free name, created so that it can't replace a file that
|
||||
* appeared after the daemon was asked. Only a path the person named with -o may replace a file. */
|
||||
function writeDefaultExport(write: (path: string, content: string) => void, first: string, content: string): string {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const path = attempt === 1 ? first : attempt === 2 ? "rig-export.yaml" : `rig-export-${attempt - 1}.yaml`;
|
||||
try {
|
||||
write(path, content);
|
||||
if (attempt > 1) console.warn(`${first} already exists and was left unchanged. Use -o ${first} to replace it.`);
|
||||
return path;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function exportCommand(depsOverride?: ExportDeps): Command {
|
||||
const cmd = new Command("export").description("Export a rig spec as YAML");
|
||||
const getDeps = (): ExportDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
writeFile: (p, content) => writeTextAtomically(p, content, "export"),
|
||||
writeNewFile: writeNewText,
|
||||
exists: (p) => fs.existsSync(p),
|
||||
};
|
||||
|
||||
cmd
|
||||
.argument("<rig>", "Rig name or ID to export")
|
||||
.option("-o, --output <path>", "Output file path, replacing any file there. Without it, an existing rig.yaml is kept and the export goes to an unused rig-export*.yaml", "rig.yaml")
|
||||
.option("-o, --output <path>", "Output file path", "rig.yaml")
|
||||
.action(async (rigId: string, opts: { output: string }) => {
|
||||
const deps = getDeps();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
@@ -86,10 +67,8 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
|
||||
console.error(`Export failed: ${res.data}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
const written = cmd.getOptionValueSource("output") === "default"
|
||||
? writeDefaultExport(deps.writeNewFile ?? deps.writeFile, opts.output, res.data)
|
||||
: (deps.writeFile(opts.output, res.data), opts.output);
|
||||
console.log(`Exported to ${written}`);
|
||||
deps.writeFile(opts.output, res.data);
|
||||
console.log(`Exported to ${opts.output}`);
|
||||
for (const line of String(res.data).split("\n")) {
|
||||
if (line.startsWith("# Not carried: ")) console.log(line.slice(2));
|
||||
if (line.startsWith("# Member startup not carried for ")) console.warn(`Warning: ${line.slice(2)}`);
|
||||
@@ -113,7 +92,7 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
|
||||
const parsed = parse(res.data) as Record<string, unknown> | null;
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if (parsed["name"] === "starter") {
|
||||
console.warn(`Warning: this export still installs as 'starter'. Before sharing, rename the team's name in ${written}; changing the bundle name alone does not rename the installed rig.`);
|
||||
console.warn("Warning: this export still installs as 'starter'. Before sharing, rename the team's name in rig.yaml; changing the bundle name alone does not rename the installed rig.");
|
||||
}
|
||||
const pods = parsed["pods"] as Array<{
|
||||
id?: string;
|
||||
|
||||
@@ -173,9 +173,9 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
|
||||
.command("add <entityId>")
|
||||
.description("Add a human fragment (verb-add-only; the fragment is truth, the registry is a GENERATED projection)")
|
||||
.requiredOption("--display-name <name>", "Human-readable display name")
|
||||
.option(
|
||||
.requiredOption(
|
||||
"--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>",
|
||||
"Connector binding (repeatable); leave it out for a person with no connector, who's reached in the Feed and Steering only. With bindings, role primary|secondary, EXACTLY ONE primary. connectorRef and secretsRef are config/vault labels (e.g. for Slack: slack:openrig-personal:file:<env-path>:primary:handle=<id>; tokens come from 'rig slack setup --secrets-env-file', never inline). Optional handle=<platform id> makes the binding inbound-resolvable (unique per kind); omit it for outbound-only.",
|
||||
"Connector binding (repeatable); role primary|secondary, EXACTLY ONE primary. connectorRef and secretsRef are config/vault labels (e.g. for Slack: slack:openrig-personal:file:<env-path>:primary:handle=<id>; tokens come from 'rig slack setup --secrets-env-file', never inline). Optional handle=<platform id> makes the binding inbound-resolvable (unique per kind); omit it for outbound-only.",
|
||||
(v: string, acc: string[] = []) => { acc.push(v); return acc; },
|
||||
)
|
||||
.requiredOption("--delivery-class <A|B|C|D>", "Notification loudness class (the notifications register selection)")
|
||||
@@ -188,7 +188,7 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
|
||||
.option("--policy <text>", "Readable policy for agents working with this person (text, not enforced)")
|
||||
.option("--policy-file <path>", "Read the policy text from a file")
|
||||
.option("--default", "Make this person the default: the one escalations, the notice digest and other unaddressed needs go to")
|
||||
.action(async (entityId: string, opts: { displayName: string; binding?: string[]; deliveryClass: string; away?: boolean; replace?: boolean; reason: string; actor?: string; organization?: string; group?: string[]; policy?: string; policyFile?: string; default?: boolean }) => {
|
||||
.action(async (entityId: string, opts: { displayName: string; binding: string[]; deliveryClass: string; away?: boolean; replace?: boolean; reason: string; actor?: string; organization?: string; group?: string[]; policy?: string; policyFile?: string; default?: boolean }) => {
|
||||
// LAZY import the narrow daemon surface at invocation (dep rail 2).
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const { addHumanFragment, parseBindingSpec } = registry as unknown as {
|
||||
@@ -209,7 +209,7 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
|
||||
if (unknownGroup) { console.error(`refused: no group "${unknownGroup}" — create it with rig gateway group set ${unknownGroup} --organization … --name …`); process.exitCode = 1; return; }
|
||||
}
|
||||
const bindings: Record<string, unknown>[] = [];
|
||||
for (const spec of opts.binding ?? []) {
|
||||
for (const spec of opts.binding) {
|
||||
const b = parseBindingSpec(spec);
|
||||
if (!b.ok) { console.error(`--binding: ${b.error}`); process.exitCode = 1; return; }
|
||||
bindings.push(b.binding);
|
||||
@@ -284,9 +284,8 @@ Example:
|
||||
if (opts.json) { console.log(JSON.stringify({ ok: true, humans })); return; }
|
||||
if (res.humans.length === 0) { console.log("no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"); return; }
|
||||
for (const h of humans) {
|
||||
const inbound = h.bindings.inboundResolvable || !h.bindings.primary ? "" : " [outbound-only]";
|
||||
const primary = h.bindings.primary ? `primary ${h.bindings.primary.kind}:${h.bindings.primary.connectorRef}` : "no connector: Feed and Steering only";
|
||||
console.log(`${h.entityId} "${h.displayName}" class=${h.deliveryClass} ${h.availability} bindings=${h.bindings.count} (${primary}) delivery=${h.deliveryReadiness.state}${inbound}`);
|
||||
const inbound = h.bindings.inboundResolvable ? "" : " [outbound-only]";
|
||||
console.log(`${h.entityId} "${h.displayName}" class=${h.deliveryClass} ${h.availability} bindings=${h.bindings.count} (primary ${h.bindings.primary.kind}:${h.bindings.primary.connectorRef}) delivery=${h.deliveryReadiness.state}${inbound}`);
|
||||
if (!h.deliveryReadiness.ready) console.log(` delivery: ${h.deliveryReadiness.reason}${h.deliveryReadiness.nextAction ? `; next: ${h.deliveryReadiness.nextAction}` : ""}`);
|
||||
console.log(` ${"unknown" in h.quiet ? `quiet: unknown (${h.quiet.unknown})` : describeQuiet(h.quiet)}`);
|
||||
}
|
||||
|
||||
@@ -560,7 +560,6 @@ function printHostNextSteps(host: HostEntry): void {
|
||||
|
||||
export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
const cmd = new Command("host").description("Manage the multi-host registry (~/.openrig/hosts.yaml)");
|
||||
const rewritesRegistry = "\nIt rewrites ~/.openrig/hosts.yaml from the fields it knows, so comments, formatting, unknown entry keys and other top-level keys aren't kept.";
|
||||
|
||||
cmd
|
||||
.command("add")
|
||||
@@ -574,7 +573,6 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
.option("--bearer-file <path>", "File PATH holding the bearer token — http transport (optional pointer, never a value; at most one bearer flag)")
|
||||
.option("--notes <text>", "Free-form operator note")
|
||||
.option("--json", "JSON output")
|
||||
.addHelpText("after", rewritesRegistry)
|
||||
.action((opts: { id: string; transport: string; target?: string; user?: string; url?: string; bearerEnv?: string; bearerFile?: string; notes?: string; json?: boolean }) => {
|
||||
const rawEntry: Record<string, unknown> = { id: opts.id, transport: opts.transport };
|
||||
if (opts.target !== undefined) rawEntry["target"] = opts.target;
|
||||
@@ -705,7 +703,6 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
.option("--url <url>", "Remote daemon base URL — http transport")
|
||||
.option("--notes <text>", "Free-form operator note (empty string clears the value)")
|
||||
.option("--json", "JSON output")
|
||||
.addHelpText("after", rewritesRegistry)
|
||||
.action((id: string, opts: { target?: string; user?: string; url?: string; notes?: string; json?: boolean }) => {
|
||||
const changes: Record<string, unknown> = {};
|
||||
if (opts.target !== undefined) changes["target"] = opts.target;
|
||||
@@ -741,7 +738,6 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
.description("Remove a host entry (deletes only the registry entry — credential files, the learned identity and the selection stay as they are)")
|
||||
.argument("<id>", "A registered host id")
|
||||
.option("--json", "JSON output")
|
||||
.addHelpText("after", rewritesRegistry)
|
||||
.action((id: string, opts: { json?: boolean }) => {
|
||||
const res = removeHostEntry(id.trim());
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -580,16 +580,11 @@ to a 0600 file holding the token>. Only the path is kept, as the seat's referenc
|
||||
so the token never passes through OpenRig. First the daemon checks that the file would apply and that nothing Claude Code ranks
|
||||
above the token is set (a cloud provider, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY, an apiKeyHelper), and refuses with each one
|
||||
and its remedy if it is. When the token applies, the launch also unsets those names for Claude's process, so the pane shell's
|
||||
own exports can't outrank it. For a running seat, it takes a snapshot and checks that the snapshot resumes the seat's
|
||||
conversation. Then, holding the seat so no other stop, launch or switch runs on it in between, it sets the reference and
|
||||
relaunches the seat from that snapshot in its own pane: Claude ends there, the pane and its scrollback stay, and restore
|
||||
resumes the same conversation in it. Work in flight when it stopped doesn't carry over. Only a refusal before Claude was
|
||||
touched reads as unchanged; a relaunch that doesn't report a resumed conversation reads unknown, with the snapshot's recovery
|
||||
command, and isn't retried. The relaunch uses the seat's own effort choice (rig seat set-effort) when there is one; otherwise
|
||||
it asks to keep the running effort, and uses the seat's saved effort when it can't. The report's effort line says which.
|
||||
A stopped seat isn't relaunched, and no snapshot is taken: the reference is saved only when the seat's next launch would
|
||||
resume its conversation, and the report prints that launch (rig launch <rig> <seat> --snapshot-id <id>, or a plain rig launch
|
||||
when restore's periodic carry resumes it). Otherwise it refuses and nothing changes.
|
||||
own exports can't outrank it. It takes a snapshot and checks that the snapshot resumes the seat's conversation. Then, holding
|
||||
the seat so no other stop, launch or switch runs on it in between, it sets the reference and relaunches the seat from that
|
||||
snapshot in its own pane: Claude ends there, the pane and its scrollback stay, and restore resumes the same conversation in it.
|
||||
Work in flight when it stopped doesn't carry over. Only a refusal before Claude was touched reads as unchanged; a relaunch that
|
||||
doesn't report a resumed conversation reads unknown, with the snapshot's recovery command, and isn't retried.
|
||||
Seats sharing the config dir keep their own login: Claude Code reads the token from that one process's environment and doesn't
|
||||
save it. That environment is Claude's, so the tools, hooks and MCP servers it starts inherit the token, and one that prints its
|
||||
environment writes it into the transcript. A token-authenticated seat can make model requests but can't use claude.ai
|
||||
|
||||
@@ -356,17 +356,6 @@ function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
|
||||
// Priority is matched exactly where it matters (the founder view, attention ranks), so "Critical" would quietly file
|
||||
// the row as ordinary work. The daemon stores what it's given, so a documented value is normalized here. Other values
|
||||
// are kept as given: `high`, `normal` and `background` are in real use, and the mission view ranks some of them.
|
||||
const QUEUE_PRIORITIES = ["routine", "urgent", "critical"];
|
||||
function queuePriorityArg(value: string): string {
|
||||
const lower = value.toLowerCase();
|
||||
if (QUEUE_PRIORITIES.includes(lower)) return lower;
|
||||
console.error(`Note: --priority '${value}' is outside the documented choices (${QUEUE_PRIORITIES.join(", ")}); keeping it as given.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** #2276: the daemon's checks of a new row's human-facing fields (missing summary or evidence for a person,
|
||||
* notification, questions, reply-to). The queue repository raises them before the row's first write, inside the
|
||||
* create's transaction. A remote host's refusal arrives as remote_queue_write_failed instead, so it isn't one of these. */
|
||||
@@ -650,18 +639,6 @@ async function printQueueItemResult(
|
||||
// IMPL-SPEC §2.3-2.4.
|
||||
const SHOW_BODY_PREVIEW_MAX_CODEPOINTS = 512;
|
||||
|
||||
// A misspelled state ("in_progress") would otherwise read as an empty queue, not as a mistake: the list route matches
|
||||
// names as given. Each name is checked and trimmed the way the route trims it. This warns rather than refuses, so the
|
||||
// CLI's copy of the list doesn't stop it working with a newer daemon that knows more states.
|
||||
const QUEUE_STATE_NAMES = ["pending", "in-progress", "done", "blocked", "failed", "denied", "canceled", "handed-off"];
|
||||
function warnIfUnknownStates(value?: string): void {
|
||||
const names = (value ?? "").split(",").map((name) => name.trim()).filter(Boolean);
|
||||
const unknown = names.filter((name) => !QUEUE_STATE_NAMES.includes(name));
|
||||
if (unknown.length === 0) return;
|
||||
const effect = unknown.length === names.length ? " Nothing is filed under these names, so an empty result doesn't mean an empty queue." : "";
|
||||
console.error(`Warning: unrecognized --state '${unknown.join("', '")}'. Supported states: ${QUEUE_STATE_NAMES.join(", ")}.${effect}`);
|
||||
}
|
||||
|
||||
// Commander option parser. InvalidArgumentError (like positiveIntArg/enumArg) is what Commander
|
||||
// renders as its usual "option ... argument ... is invalid" error; a plain Error would escape parse.
|
||||
const WAKE_DURATION_FORMAT = "must be a positive integer with an optional s, m or h suffix (for example 90s, 15m, 168h)";
|
||||
@@ -934,7 +911,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--mission <id>", "First-class mission scope; translated to a mission:<id> tag (composes with --tags)")
|
||||
.option("--slice <id>", "First-class slice scope; translated to a slice:<id> tag (composes with --tags)")
|
||||
.option("--gate <role>", "OPR.0.4.3.16: mark this as a gate qitem; translated to a gate:<role> tag (role e.g. guard | spec-review | pm-lead | qa | human). The idle-gate watchdog reads this predicate. Composes with --tags.")
|
||||
.option("--priority <priority>", "Priority: routine | urgent | critical", queuePriorityArg, "routine")
|
||||
.option("--priority <priority>", "Priority: routine | urgent | critical", "routine")
|
||||
.option("--tier <tier>", "Tier (e.g. fast, routine, deep, critical) — drives SLA")
|
||||
.option("--tags <tags>", "Comma-separated tags (composes with --mission and --slice)")
|
||||
.option("--expires-at <iso>", "ISO timestamp at which the qitem expires")
|
||||
@@ -1408,7 +1385,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
.option("--body <text>", "New qitem body inline (use - to read from stdin; mutually exclusive with --body-file). Omit both to keep the source body.")
|
||||
.option("--body-file <path>", "Read the new qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class.")
|
||||
.option("--note <text>", "Transition note")
|
||||
.option("--priority <priority>", "Override priority for the new qitem: routine | urgent | critical", queuePriorityArg)
|
||||
.option("--priority <priority>", "Override priority for the new qitem")
|
||||
.option("--tier <tier>", "Override tier for the new qitem")
|
||||
.option("--tags <tags>", "Comma-separated tags for the new qitem")
|
||||
.option("--gate <role>", "OPR.0.4.3.16: mark the new qitem as gate work; translated to a gate:<role> tag (e.g. guard | spec-review). The idle-gate watchdog reads this predicate. Composes with --tags.")
|
||||
@@ -1512,7 +1489,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
.option("--body <text>", "New qitem body inline (use - to read from stdin; mutually exclusive with --body-file). Omit both to keep the source body.")
|
||||
.option("--body-file <path>", "Read the new qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class.")
|
||||
.option("--note <text>", "Transition note")
|
||||
.option("--priority <priority>", "Override priority for the new qitem: routine | urgent | critical", queuePriorityArg)
|
||||
.option("--priority <priority>", "Override priority for the new qitem")
|
||||
.option("--tier <tier>", "Override tier for the new qitem")
|
||||
.option("--tags <tags>", "Comma-separated tags for the new qitem")
|
||||
.option("--gate <role>", "OPR.0.4.3.16: mark the new qitem as gate work; translated to a gate:<role> tag (e.g. guard | spec-review). The idle-gate watchdog reads this predicate. Composes with --tags.")
|
||||
@@ -1795,7 +1772,6 @@ Examples:
|
||||
}
|
||||
if (opts.destination) params.set("destinationSession", opts.destination);
|
||||
if (opts.source) params.set("sourceSession", opts.source);
|
||||
warnIfUnknownStates(opts.state);
|
||||
if (opts.state) params.set("state", opts.state);
|
||||
if (opts.targetRepo) params.set("targetRepo", opts.targetRepo);
|
||||
if (opts.limit) params.set("limit", String(opts.limit));
|
||||
|
||||
@@ -167,19 +167,11 @@ interface SeatHandoverMutationResult {
|
||||
restorePacket?: { status: "unconfirmed"; reason: string };
|
||||
sideEffects: {
|
||||
departingSessionKilled: false;
|
||||
/** Absent from an older daemon, whose receipt reported both side effects as a fixed false. */
|
||||
departingProcess?: "ended-graceful" | "ended-forced" | "not-touched";
|
||||
startupContextDelivered: boolean;
|
||||
provenanceRecordWritten: boolean;
|
||||
provenanceRecordWritten: false;
|
||||
};
|
||||
}
|
||||
|
||||
const DEPARTING_PROCESS_LINES = {
|
||||
"ended-graceful": "The previous occupant's process exited on TERM; its tmux session was kept, and the successor runs in the same pane.",
|
||||
"ended-forced": "The previous occupant's process didn't exit on TERM and was killed; its tmux session was kept, and the successor runs in the same pane.",
|
||||
"not-touched": "The previous occupant's process wasn't touched: the successor was an existing session.",
|
||||
} as const;
|
||||
|
||||
interface SeatSwitchClientResponse {
|
||||
seat_ref: string;
|
||||
session: string;
|
||||
@@ -333,13 +325,6 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
|
||||
console.log(`Current occupant: ${result.currentOccupant}`);
|
||||
console.log(`Handover result: ${display(result.currentStatus.handoverResult)}`);
|
||||
console.log("Seat binding and inventory provenance were updated.");
|
||||
// An older daemon reports both side effects as a fixed false: say nothing rather than misreport them.
|
||||
if (result.sideEffects.departingProcess) {
|
||||
console.log(result.sideEffects.provenanceRecordWritten
|
||||
? "The handover's lineage record (occupant tenure) was written."
|
||||
: "The handover's lineage record (occupant tenure) was NOT written; check the daemon log, which names the first such failure since the daemon started. The seat is already bound to the successor, so don't run the handover again.");
|
||||
console.log(DEPARTING_PROCESS_LINES[result.sideEffects.departingProcess]);
|
||||
}
|
||||
// OPR.0.5.5.5 — the per-source execution record: what actually carried context.
|
||||
if (result.sourceOutcome?.mode === "fork") {
|
||||
console.log(`Native fork of ${result.sourceOutcome.forkedFrom}: the successor carries the incumbent conversation from its first byte.`);
|
||||
@@ -364,18 +349,18 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
|
||||
// live successor agent.
|
||||
console.log("The captured startup context (restore packet) was delivered to the successor.");
|
||||
}
|
||||
console.log("No conversation continuity was carried: the successor started a fresh conversation.");
|
||||
console.log("No conversation continuity, provenance markdown, or session stop was performed.");
|
||||
} else if (result.restorePacket?.status === "unconfirmed") {
|
||||
// The packet was pasted and Enter pressed: the successor may already be working on it, so don't send it again.
|
||||
console.log(`The captured startup context (restore packet) was sent to the successor, but its submission is unconfirmed: ${result.restorePacket.reason}.`);
|
||||
console.log("Check the successor's screen before sending the context again; it may already be working on it.");
|
||||
console.log("No conversation continuity was carried: the successor started a fresh conversation.");
|
||||
console.log("No conversation continuity, provenance markdown, or session stop was performed.");
|
||||
} else if (result.source.mode === "fork") {
|
||||
console.log("No packet delivery: fork context rides the native conversation itself.");
|
||||
} else {
|
||||
// discovered handover: the operator-prepared successor is already live, so no
|
||||
// separate context delivery is performed.
|
||||
console.log("OpenRig delivered no startup context and carried no conversation: the discovered successor was already running.");
|
||||
console.log("No conversation continuity, startup context delivery, provenance markdown, or session stop was performed.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1013,8 +998,6 @@ effort, Codex profile and native permission choice are cleared, not mapped; two
|
||||
processes run until cutover; messages sent to the seat during the switch are
|
||||
held, not typed, and need sending again afterwards.
|
||||
Codex and Claude Code, single-pane seats only. --dry-run lists the details.
|
||||
A seat that has never started has no pane to switch: declare the other runtime for it in its rig spec and bring the team
|
||||
up again from that spec. That's a new generation of the seat, without this seat's edges, settings or queued work.
|
||||
--clear-account-home returns to configured member/rig/daemon/native selection; it does not restore historical credentials.
|
||||
It uses a fresh successor and clears an account-home token reference only after confirmation. Do not combine it with
|
||||
--account-home or a new token reference. Anthropic's global active/default profile is not isolated by CLAUDE_CONFIG_DIR.
|
||||
@@ -1127,6 +1110,17 @@ A seat can switch itself (its CLI may end when it retires; the daemon finishes).
|
||||
console.log(`${name} already runs ${String(data["to"])}. Nothing changed.`);
|
||||
return;
|
||||
}
|
||||
if (data["firstStart"] === true) {
|
||||
const from = String(data["from"]), to = String(data["to"]);
|
||||
console.log(data["dryRun"]
|
||||
? `${name} has never started: its saved runtime would change from ${from} to ${to}, and its first start would use it. Nothing changes until you run it without --dry-run.`
|
||||
: `${name} has never started, so nothing was switched (no switch id): its saved runtime changed from ${from} to ${to}, and its first start uses it.`);
|
||||
const permission = data["permission"] as { mode: string; source: string } | undefined;
|
||||
if (permission) console.log(` permissions: ${permission.source === "explicit" ? `${permission.mode} (as given)` : "the seat's member and rig policy"}`);
|
||||
const dropped = (data["dropped"] as Array<{ setting: string; value: string }> | undefined) ?? [];
|
||||
if (dropped.length) console.log(`Cleared from ${from}: ${dropped.map((d) => `${d.setting} ${d.value}`).join("; ")}. Its first start takes the rest from its agent spec for ${to}.`);
|
||||
return;
|
||||
}
|
||||
const label = (source: string) => source === "explicit" ? "as given" : source === "agent_spec" ? "from the seat's agent spec" : "the runtime's default";
|
||||
const settings = (data["settings"] ?? {}) as Record<string, { value: string | null; source: string }>;
|
||||
if (data["dryRun"]) console.log(`Runtime switch plan for ${name}: ${String(data["from"])} -> ${String(data["to"])}. Nothing changes until you run it without --dry-run.`);
|
||||
|
||||
@@ -62,31 +62,6 @@ function bodyStartIndex(lines: string[]): number {
|
||||
return 0; // unterminated frontmatter — treat as no frontmatter
|
||||
}
|
||||
|
||||
/** Keep source indices while hiding code examples from the progress grammar. */
|
||||
function progressLines(lines: string[]): string[] {
|
||||
let fence: { marker: string; length: number } | null = null;
|
||||
const start = bodyStartIndex(lines);
|
||||
return lines.map((line, i) => {
|
||||
if (i < start) return "";
|
||||
const match = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
|
||||
if (match) {
|
||||
const marker = match[1]![0]!;
|
||||
const length = match[1]!.length;
|
||||
const tail = match[2]!;
|
||||
if (fence) {
|
||||
if (marker === fence.marker && length >= fence.length && /^[ \t\r]*$/.test(tail)) {
|
||||
fence = null;
|
||||
return line; // A new row may be inserted after the closing fence.
|
||||
}
|
||||
} else if (marker !== "`" || !tail.includes("`")) {
|
||||
fence = { marker, length };
|
||||
}
|
||||
return "";
|
||||
}
|
||||
return fence ? "" : line;
|
||||
});
|
||||
}
|
||||
|
||||
export interface ProgressEditResult {
|
||||
content: string;
|
||||
changed: boolean;
|
||||
@@ -117,35 +92,32 @@ export function addProgressRow(
|
||||
const newRow = `- [${statusIndicator(opts.status)}] ${text}`;
|
||||
const lines = content.split("\n");
|
||||
const start = bodyStartIndex(lines);
|
||||
const visible = progressLines(lines);
|
||||
|
||||
// Locate the target section heading.
|
||||
let sectionIdx = -1;
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const h = visible[i]!.match(HEADING_RE);
|
||||
const h = lines[i]!.match(HEADING_RE);
|
||||
if (h && h[1] === "##" && h[2]!.trim() === section) { sectionIdx = i; break; }
|
||||
}
|
||||
|
||||
if (sectionIdx === -1) {
|
||||
// Create the section, appended after the last existing content.
|
||||
let end = lines.length;
|
||||
while (end > start && visible[end - 1]!.trim() === "") end--;
|
||||
while (end > start && lines[end - 1]!.trim() === "") end--;
|
||||
const head = lines.slice(0, end);
|
||||
const suffix = lines.slice(end);
|
||||
const rebuilt = [...head, "", `## ${section}`, "", newRow, "",
|
||||
...(suffix.some(line => line.trim() !== "") ? suffix : [])].join("\n");
|
||||
const rebuilt = [...head, "", `## ${section}`, "", newRow, ""].join("\n");
|
||||
return { content: rebuilt, changed: true };
|
||||
}
|
||||
|
||||
// Section block = [sectionIdx+1, nextHeadingOrEOF).
|
||||
let blockEnd = lines.length;
|
||||
for (let i = sectionIdx + 1; i < lines.length; i++) {
|
||||
if (HEADING_RE.test(visible[i]!)) { blockEnd = i; break; }
|
||||
if (HEADING_RE.test(lines[i]!)) { blockEnd = i; break; }
|
||||
}
|
||||
|
||||
// Idempotency / conflict scan within the section.
|
||||
for (let i = sectionIdx + 1; i < blockEnd; i++) {
|
||||
const cb = visible[i]!.match(ROW_RE);
|
||||
const cb = lines[i]!.match(ROW_RE);
|
||||
if (cb && cb[3]!.trim() === text) {
|
||||
const current = indicatorToStatus(cb[2]!);
|
||||
if (current === opts.status) return { content, changed: false };
|
||||
@@ -160,7 +132,7 @@ export function addProgressRow(
|
||||
// Insert after the last non-blank line in the section block.
|
||||
let insertAt = sectionIdx + 1;
|
||||
for (let i = sectionIdx + 1; i < blockEnd; i++) {
|
||||
if (visible[i]!.trim() !== "") insertAt = i + 1;
|
||||
if (lines[i]!.trim() !== "") insertAt = i + 1;
|
||||
}
|
||||
lines.splice(insertAt, 0, newRow);
|
||||
return { content: lines.join("\n"), changed: true };
|
||||
@@ -183,11 +155,10 @@ export function setProgressRow(
|
||||
const text = opts.text.trim();
|
||||
const lines = content.split("\n");
|
||||
const start = bodyStartIndex(lines);
|
||||
const visible = progressLines(lines);
|
||||
|
||||
const matches: number[] = [];
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const cb = visible[i]!.match(ROW_RE);
|
||||
const cb = lines[i]!.match(ROW_RE);
|
||||
if (cb && cb[3]!.trim() === text) matches.push(i);
|
||||
}
|
||||
|
||||
|
||||
@@ -336,11 +336,10 @@ function scopeFor(access: McpAccess, seatOf: () => string | undefined): Scope {
|
||||
};
|
||||
}
|
||||
|
||||
/** The grant service names each connected assistant's seat cus-<id>-<connection>@<rig> (an existing connection's is
|
||||
* cus-<id>-caller@<rig>). v1 marks no seat as a caller, so for a grant without team/list an item from a seat of this
|
||||
* shape is never the team's question to it, and it can't hand a task to one. The shape only refuses; it never grants
|
||||
* anything, and a grant with team/list doesn't read it. */
|
||||
const CALLER_SHAPED = /^cus-[^@]+-[^@]+@[^@]+$/;
|
||||
/** The grant service names an external caller's seat cus-<id>-caller@<rig>. v1 marks no seat as a caller, so an item
|
||||
* from a seat of this shape is never the team's question to an external caller, and an external caller can't hand a
|
||||
* task to one. The shape never grants anything. */
|
||||
const CALLER_SHAPED = /^cus-.+-caller@[^@]+$/;
|
||||
|
||||
/** OpenRig's own tags that decide what a person asked about a row sees of its body: what Slack ingestion records about where
|
||||
* the row came from and which parts of its body OpenRig wrote (the sender's channel, the attachments block, the body's
|
||||
@@ -350,15 +349,11 @@ const CALLER_SHAPED = /^cus-[^@]+-[^@]+@[^@]+$/;
|
||||
const OPENRIG_PROVENANCE_TAG = /^(?:founder-slack|inbound|shared-channel|gate)$|^participant:|^slack-/;
|
||||
|
||||
/** An external grant (openrig.grant/v1): a requester, narrower than a work grant. The grant's own seat is the
|
||||
* only caller. It may hand tasks to its rigs' other seats (never a person or another host's seat, and without team/list
|
||||
* never a caller-shaped seat), read the items it sent and those addressed to it from those seats (never team-internal
|
||||
* items or other callers' items between themselves), change only the items addressed to its own seat (with the claim and
|
||||
* update pairs), follow a handed-on item only where it came back to it, and read the artifacts of its own items under
|
||||
* its named roots. */
|
||||
* only caller. It may hand tasks to its rigs' team seats (never a caller-shaped seat, a person or another host's
|
||||
* seat), read the items it sent and the team's questions to it (never team-internal or another caller's items), follow
|
||||
* a handed-on item only where it came back to it, and read the artifacts of its own items under its named roots. */
|
||||
function externalScope(access: McpAccess, seatOf: () => string | undefined): Scope {
|
||||
const rigs = new Set(access.rigs);
|
||||
// team/list makes the caller one of its rigs' participants: it may reach another connected assistant's seat.
|
||||
const participant = access.external?.objects.team?.includes("list") === true;
|
||||
const caller = (value: unknown): boolean => typeof value === "string" && value !== "" && value === seatOf();
|
||||
// A seat of one of its rigs on this instance: member@rig, never a person or another host's seat.
|
||||
const member = (value: unknown): boolean => {
|
||||
@@ -366,20 +361,17 @@ function externalScope(access: McpAccess, seatOf: () => string | undefined): Sco
|
||||
const parts = value.split("@");
|
||||
return parts.length === 2 && parts[0] !== "" && parts[1] !== "external" && rigs.has(parts[1]!);
|
||||
};
|
||||
const team = (value: unknown): boolean => member(value) && !caller(value) && (participant || !CALLER_SHAPED.test(value as string));
|
||||
const team = (value: unknown): boolean => member(value) && !caller(value) && !CALLER_SHAPED.test(value as string);
|
||||
const field = (row: unknown, name: "sourceSession" | "destinationSession"): unknown => ((row ?? {}) as Record<string, unknown>)[name];
|
||||
const item = (row: unknown): boolean => caller(field(row, "sourceSession"))
|
||||
|| (caller(field(row, "destinationSession")) && team(field(row, "sourceSession")));
|
||||
return {
|
||||
session: (value) => caller(value) || team(value),
|
||||
item,
|
||||
// Only an item addressed to its exact seat, and one it may read, is its own to change; the daemon's claim also checks
|
||||
// the claimant.
|
||||
owner: (row) => caller(field(row, "destinationSession")) && item(row),
|
||||
owner: () => false,
|
||||
rig: (name) => typeof name === "string" && rigs.has(name),
|
||||
root: (name) => typeof name === "string" && access.roots.includes(name),
|
||||
// A task goes to another seat of its rigs: never to the caller itself, and without team/list never to another
|
||||
// caller-shaped seat, which v1 can't tell apart.
|
||||
// A task goes to the team: never to the caller itself or to another caller-shaped seat, which v1 can't tell apart.
|
||||
destination: team,
|
||||
successor: caller,
|
||||
artifact: (row) => caller(field(row, "sourceSession")),
|
||||
@@ -488,8 +480,7 @@ export type McpAccess = {
|
||||
|
||||
/** The tool each external object/action pair offers. A pair not listed here offers nothing. */
|
||||
const EXTERNAL_TOOLS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
|
||||
queue: { create: "rig_queue_create", show: "rig_queue_show", list: "rig_queue_list", claim: "rig_queue_claim", update: "rig_queue_update" },
|
||||
team: { list: "rig_team" },
|
||||
queue: { create: "rig_queue_create", show: "rig_queue_show", list: "rig_queue_list" },
|
||||
artifact: { read: "rig_artifact_read" },
|
||||
};
|
||||
|
||||
@@ -573,16 +564,6 @@ const SHARED_INSTRUCTIONS = [
|
||||
];
|
||||
const INSTRUCTIONS = [...SHARED_INSTRUCTIONS, MEMBER_WORKFLOW].join("\n");
|
||||
const EXTERNAL_INSTRUCTIONS = SHARED_INSTRUCTIONS.join("\n");
|
||||
/** For an external grant that names claim and update: how it works the items addressed to it, messages included. */
|
||||
const EXTERNAL_INBOX_INSTRUCTIONS = [...SHARED_INSTRUCTIONS,
|
||||
"Items addressed to your seat are yours: rig_queue_list with owned set lists them. For each, claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update); the sender reads that answer on the same item. A message is an item too: read it, close it done, and reply, when it needs one, with a new item to its sender. Nothing promises to wake you, so check your items again.",
|
||||
].join("\n");
|
||||
|
||||
function instructionsFor(access: McpAccess | undefined): string {
|
||||
if (!access?.external) return INSTRUCTIONS;
|
||||
const tools = externalTools(access.external.objects);
|
||||
return tools.has("rig_queue_claim") && tools.has("rig_queue_update") ? EXTERNAL_INBOX_INSTRUCTIONS : EXTERNAL_INSTRUCTIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a DaemonResponse to MCP tool result.
|
||||
@@ -635,7 +616,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
const server = new McpServer({
|
||||
name: "openrig",
|
||||
version: CLI_VERSION,
|
||||
}, { instructions: instructionsFor(options.access) });
|
||||
}, { instructions: options.access?.external ? EXTERNAL_INSTRUCTIONS : INSTRUCTIONS });
|
||||
const { access } = options;
|
||||
const rostersFolder = options.rostersFolder ?? (() => nodePath.join(new ConfigStore().get("workspace.root") as string, "rosters"));
|
||||
const presenceFile = options.presenceFile ?? (() => mcpPresenceFilePath());
|
||||
@@ -1364,10 +1345,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
try {
|
||||
const denied = await itemOutOfScope(qitemId);
|
||||
if (denied) return denied;
|
||||
const res = await client.post(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {});
|
||||
// An external caller's claimed item goes through the same projection as show and list.
|
||||
const view = externalView(scope);
|
||||
return mapResult(view && res.status < 400 ? { ...res, data: projectForExternal(res.data, view) } : res, undefined, { structured: true });
|
||||
return mapResult(await client.post(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {}), undefined, { structured: true });
|
||||
} catch (err) {
|
||||
return caught(err);
|
||||
}
|
||||
@@ -1398,18 +1376,9 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
try {
|
||||
const denied = await itemOutOfScope(qitemId, "owner");
|
||||
if (denied) return denied;
|
||||
// An external caller waits only on an item it names and can read: parking on a person would ask them, parking on
|
||||
// another item would say whether it exists, and a park that names nothing would restore a blocker the team set.
|
||||
if (access?.external && (blockedOn || state === "blocked")) {
|
||||
const blocker = blockedOn?.startsWith("qitem-") ? await client.get(`/api/queue/${encodeURIComponent(blockedOn)}`) : null;
|
||||
if (!blocker || blocker.status >= 400 || !scope?.item(blocker.data)) return outOfScope(`Blocker ${blockedOn ?? "(none named)"}`);
|
||||
}
|
||||
const res = await client.post(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
|
||||
return mapResult(await client.post(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
|
||||
state, closureReason, closureTarget, summary, evidenceRef, blockedOn, wakeAfterSeconds, transitionNote: note, resolution, reopen,
|
||||
});
|
||||
// An external caller's updated item goes through the same projection as show and list.
|
||||
const view = externalView(scope);
|
||||
return mapResult(view && res.status < 400 ? { ...res, data: projectForExternal(res.data, view) } : res, undefined, { structured: true });
|
||||
}), undefined, { structured: true });
|
||||
} catch (err) {
|
||||
return caught(err);
|
||||
}
|
||||
@@ -1522,8 +1491,6 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
},
|
||||
async ({ rigId }) => {
|
||||
try {
|
||||
// An external caller names one of its grant's rigs, so a lookup never answers about another workspace's rig.
|
||||
if (access?.external && !scope?.rig(rigId)) return outOfScope(`Rig ${rigId}`);
|
||||
const rig = await rigIdFor(client, rigId);
|
||||
if ("content" in rig) return rig;
|
||||
const id = encodeURIComponent(rig.id);
|
||||
@@ -1544,9 +1511,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
async (seat) => {
|
||||
const rows = await client.get(`/api/queue/list?${new URLSearchParams({ destinationSession: seat, compact: "1", limit: String(PRESENCE_ROWS) }).toString()}`)
|
||||
.then((res) => (res.status < 400 ? res.data : null), () => null);
|
||||
// An external caller sees a ring only on an item it may read, so another pair's item isn't named.
|
||||
const visible = access?.external && Array.isArray(rows) ? rows.filter((row) => scope?.item(row)) : rows;
|
||||
return { lastCallAt: readPresence(presenceFile())[seat]?.lastCallAt ?? null, lastWake: newestWake(visible, seat),
|
||||
return { lastCallAt: readPresence(presenceFile())[seat]?.lastCallAt ?? null, lastWake: newestWake(rows, seat),
|
||||
lastWakeScope: `the latest ring among its ${PRESENCE_ROWS} most recent rows` };
|
||||
});
|
||||
return mapResult({ status: 200, data: { ...view, hostedMembers: hosted.members, ...(hosted.note ? { hostedMembersNote: hosted.note } : {}) } }, undefined, { structured: true });
|
||||
|
||||
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import http from "node:http";
|
||||
import { Command } from "commander";
|
||||
import { writeNewText, writeTextAtomically } from "../src/atomic-text-write.js";
|
||||
import { writeTextAtomically } from "../src/atomic-text-write.js";
|
||||
import { exportCommand } from "../src/commands/export.js";
|
||||
import { STATE_FILE } from "../src/daemon-lifecycle.js";
|
||||
import { allowFetchTarget, resetFetchAllowlist } from "./fetch-guard.js";
|
||||
@@ -35,23 +35,6 @@ it("preserves an existing export and removes staging after a partial failed writ
|
||||
expect(fs.readFileSync(target, "utf8")).toBe("name: original\n");
|
||||
expect(fs.readdirSync(dir)).toEqual(["rig.yaml"]);
|
||||
});
|
||||
it("writeNewText creates a file but never replaces one, even a dangling link", () => {
|
||||
dir = fs.mkdtempSync(join(tmpdir(), "export-atomic-"));
|
||||
const target = join(dir, "rig.yaml"), link = join(dir, "link.yaml");
|
||||
writeNewText(target, "name: first\n");
|
||||
expect(fs.readFileSync(target, "utf8")).toBe("name: first\n");
|
||||
expect(() => writeNewText(target, "name: second\n")).toThrow(expect.objectContaining({ code: "EEXIST" }));
|
||||
expect(fs.readFileSync(target, "utf8")).toBe("name: first\n");
|
||||
fs.symlinkSync(join(dir, "missing.yaml"), link);
|
||||
expect(() => writeNewText(link, "name: second\n")).toThrow(expect.objectContaining({ code: "EEXIST" }));
|
||||
expect(fs.existsSync(join(dir, "missing.yaml"))).toBe(false);
|
||||
});
|
||||
it("writeNewText leaves nothing behind after a failed write", () => {
|
||||
dir = fs.mkdtempSync(join(tmpdir(), "export-atomic-"));
|
||||
failure.active = true;
|
||||
expect(() => writeNewText(join(dir, "rig.yaml"), "name: new\n")).toThrow(/no space left/);
|
||||
expect(fs.readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
it("retains export symlinks and target permissions on successful replacement", () => {
|
||||
dir = fs.mkdtempSync(join(tmpdir(), "export-atomic-"));
|
||||
const target = join(dir, "saved.yaml"), link = join(dir, "rig.yaml");
|
||||
|
||||
@@ -326,7 +326,6 @@ describe("Broadcast CLI", () => {
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("No running sessions found");
|
||||
expect(output).toContain("0/0 delivered");
|
||||
expect(logs).toContainEqual(expect.stringMatching(/^FAILED — No running sessions found/));
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -344,23 +344,6 @@ describe("Down CLI", () => {
|
||||
expect(logs).toContain(" /work/project/CLAUDE.md: openrig-seat-skills.md");
|
||||
});
|
||||
|
||||
it("names each file in a Claude seat's cwd that still holds OpenRig's additions, and keeps the exit code", async () => {
|
||||
const kept = "/work/project/.openrig/context-collector.cjs: not the shipped collector's bytes, so it was kept";
|
||||
responseOverride = {
|
||||
status: 200,
|
||||
body: {
|
||||
rigId: "rig-1", sessionsKilled: 1, snapshotId: null, deleted: false, deleteBlocked: false, alreadyStopped: false, errors: [],
|
||||
keptFiles: [kept],
|
||||
},
|
||||
};
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "down", "rig-1"]);
|
||||
});
|
||||
expect(logs).toContain("Kept 1 file(s) that still hold OpenRig's additions; check them by hand:");
|
||||
expect(logs).toContain(` ${kept}`);
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ rigName: "workshop", isUniqueName: false },
|
||||
{},
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { spawn } from "node:child_process";
|
||||
import { environmentCommand } from "../src/commands/environment.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
vi.mock("node:child_process", async original => ({ ...await original<typeof import("node:child_process")>(), spawn: vi.fn() }));
|
||||
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); process.exitCode = undefined; });
|
||||
it.each(["slow-get", "slow-validate", "changed-generation"])("the viewer bounds its live lookup without the redundant validation deadline: %s", async mode => {
|
||||
vi.useFakeTimers(); const generation = mode === "changed-generation" ? "changed-generation" : "generation-one";
|
||||
const paths: string[] = [];
|
||||
const client = new DaemonClient("http://127.0.0.1:1", { sessionName: "worker@test", ownerProbe: async () => ({ state: "unknown" }) as never,
|
||||
fetchImpl: (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
paths.push(String(url)); const validation = init?.method === "POST";
|
||||
if (validation && mode === "changed-generation") return new Response(JSON.stringify({ error: "The prepared environment belongs to another generation" }), { status: 409 });
|
||||
if ((mode === "slow-get" && !validation) || (mode === "slow-validate" && validation)) {
|
||||
await new Promise<void>((resolve, reject) => { const timer = setTimeout(resolve, 6000);
|
||||
init?.signal?.addEventListener("abort", () => { clearTimeout(timer); reject(init.signal?.reason); }, { once: true }); });
|
||||
}
|
||||
return new Response(JSON.stringify({ generation, placement: { gateway: "gateway", workspace: "workspace", name: "worker" } }), { status: 200 });
|
||||
}) as typeof fetch });
|
||||
const lifecycleDeps: LifecycleDeps = { spawn: vi.fn(), fetch: vi.fn(async () => ({ ok: true })), kill: vi.fn(() => true),
|
||||
readFile: vi.fn((path: string) => path === STATE_FILE ? JSON.stringify({ pid: 123, port: 7433, db: "test.sqlite", startedAt: "2026-04-20T00:00:00Z" }) : null),
|
||||
writeFile: vi.fn(), removeFile: vi.fn(), exists: vi.fn((path: string) => path === STATE_FILE), mkdirp: vi.fn(), openForAppend: vi.fn(() => 3), isProcessAlive: vi.fn(() => true) };
|
||||
vi.mocked(spawn).mockImplementation(() => { const child = new EventEmitter(); queueMicrotask(() => child.emit("exit", 0)); return child as ReturnType<typeof spawn>; });
|
||||
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const run = environmentCommand({ lifecycleDeps, clientFactory: () => client }).parseAsync(["node", "rig", "connect", "binding-one", "--generation", "generation-one"]);
|
||||
await vi.advanceTimersByTimeAsync(6500); await run;
|
||||
if (mode !== "changed-generation") {
|
||||
expect(paths).toEqual(["http://127.0.0.1:1/api/environments/binding-one?live=true"]);
|
||||
expect(errors).not.toHaveBeenCalled(); expect(spawn).toHaveBeenCalledTimes(1);
|
||||
} else { expect(spawn).not.toHaveBeenCalled(); expect(errors).toHaveBeenCalledWith(expect.stringContaining("generation")); }
|
||||
});
|
||||
@@ -262,67 +262,6 @@ describe("rig export + import", () => {
|
||||
expect(writeFile).toHaveBeenCalledWith("rig.yaml", expect.any(String));
|
||||
});
|
||||
|
||||
describe("export: a default export never replaces a file", () => {
|
||||
const occupied = (...taken: string[]) => vi.fn((p: string) => {
|
||||
if (taken.includes(p)) throw Object.assign(new Error(`EEXIST: ${p}`), { code: "EEXIST" });
|
||||
});
|
||||
const run = async (deps: Partial<ExportDeps>, ...args: string[]) => {
|
||||
const program = new Command();
|
||||
program.addCommand(exportCommand(exportDeps(deps)));
|
||||
return captureLogs(() => program.parseAsync(["node", "rig", "export", "rig-1", ...args]));
|
||||
};
|
||||
|
||||
it("the first export writes rig.yaml", async () => {
|
||||
const writeNewFile = occupied();
|
||||
const logs = await run({ writeNewFile });
|
||||
expect(writeNewFile.mock.calls.map((c) => c[0])).toEqual(["rig.yaml"]);
|
||||
expect(logs).toContain("Exported to rig.yaml");
|
||||
expect(logs.join("\n")).not.toContain("left unchanged");
|
||||
});
|
||||
|
||||
it("an occupied rig.yaml sends the export to a sibling, and says which", async () => {
|
||||
const writeFile = vi.fn();
|
||||
const writeNewFile = occupied("rig.yaml");
|
||||
const exitCode = process.exitCode;
|
||||
const logs = await run({ writeFile, writeNewFile });
|
||||
expect(writeFile).not.toHaveBeenCalled();
|
||||
expect(writeNewFile).toHaveBeenLastCalledWith("rig-export.yaml", expect.any(String));
|
||||
expect(logs).toContain("Exported to rig-export.yaml");
|
||||
expect(logs.join("\n")).toContain("rig.yaml already exists and was left unchanged");
|
||||
expect(process.exitCode).toBe(exitCode);
|
||||
});
|
||||
|
||||
it("occupied siblings are kept too", async () => {
|
||||
const writeNewFile = occupied("rig.yaml", "rig-export.yaml", "rig-export-2.yaml");
|
||||
const logs = await run({ writeNewFile });
|
||||
expect(writeNewFile.mock.calls.map((c) => c[0])).toEqual(["rig.yaml", "rig-export.yaml", "rig-export-2.yaml", "rig-export-3.yaml"]);
|
||||
expect(logs).toContain("Exported to rig-export-3.yaml");
|
||||
});
|
||||
|
||||
it("-o replaces the file it names, including rig.yaml", async () => {
|
||||
const writeFile = vi.fn();
|
||||
const writeNewFile = occupied("rig.yaml");
|
||||
const logs = await run({ writeFile, writeNewFile }, "-o", "rig.yaml");
|
||||
expect(writeFile).toHaveBeenCalledWith("rig.yaml", expect.any(String));
|
||||
expect(writeNewFile).not.toHaveBeenCalled();
|
||||
expect(logs).toContain("Exported to rig.yaml");
|
||||
});
|
||||
|
||||
it("a real write failure stays a failure, with no success reported", async () => {
|
||||
const writeNewFile = vi.fn(() => { throw Object.assign(new Error("no space"), { code: "ENOSPC" }); });
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
try {
|
||||
const program = new Command();
|
||||
program.addCommand(exportCommand(exportDeps({ writeNewFile })));
|
||||
await expect(program.parseAsync(["node", "rig", "export", "rig-1"])).rejects.toThrow("no space");
|
||||
expect(writeNewFile).toHaveBeenCalledTimes(1);
|
||||
expect(log).not.toHaveBeenCalledWith(expect.stringContaining("Exported to"));
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("export: warns when culture_file or startup files do not resolve relative to output directory", async () => {
|
||||
const writeFile = vi.fn();
|
||||
const exists = vi.fn((p: string) => !p.includes("missing-culture.md") && !p.includes("missing-start.md") && !p.includes("optional-start.md"));
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
// Agent network, increment A: two assistants connected through the grant service (openrig.grant/v1), each with its own
|
||||
// seat, discover each other and the team, queue work to each other, claim it and return the result on the same item,
|
||||
// and come back to it without duplicates. Driven through rig mcp serve --http --grant-service and the daemon's real
|
||||
// routes; the fake grant service answers each connection's key with that connection's grant.
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { serve, type ServerType } from "@hono/node-server";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createTestApp } from "../../daemon/test/helpers/test-app.js";
|
||||
import { createDb } from "../../daemon/src/db/connection.js";
|
||||
import { migrate } from "../../daemon/src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
|
||||
import { EventBus } from "../../daemon/src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
|
||||
import { setSelfHostId } from "../../daemon/src/domain/hosts/fanout-contract.js";
|
||||
import { startMcpHttpServer } from "../src/mcp-http.js";
|
||||
|
||||
const SCHEMA = "openrig.grant/v1";
|
||||
const INSTANCE = "core-test-01";
|
||||
const RIG = "team-a";
|
||||
const LEAD = `lead@${RIG}`;
|
||||
/** Two connections of one account: each its own key, grant and seat. */
|
||||
const A = { key: `${"8977f2dc0000aaaa1111bbbb"}.cus_VVMAexample01.${"ab".repeat(24)}`, grant: "8977f2dc0000aaaa1111bbbb", seat: `cus-vvmaexample01-chatgpt@${RIG}` };
|
||||
const B = { key: `${"8977f2dc0000aaaa2222cccc"}.cus_VVMAexample01.${"cd".repeat(24)}`, grant: "8977f2dc0000aaaa2222cccc", seat: `cus-vvmaexample01-claude@${RIG}` };
|
||||
const NETWORK = { queue: ["create", "show", "list", "claim", "update"], team: ["list"], artifact: ["read"] };
|
||||
const V1 = { queue: ["create", "show", "list"], artifact: ["read"] };
|
||||
|
||||
const active = (who: typeof A, objects: Record<string, string[]>) => ({
|
||||
schema: SCHEMA, status: "active",
|
||||
principal: { id: "cus_VVMAexample01", kind: "external_caller", label: "External caller …mple01" },
|
||||
grant: { id: who.grant, version: 1, updated_at: "2026-11-02T05:00:00.000Z", expires_at: null, cache_seconds: 0, revocation: "per_request" },
|
||||
environment: { id: RIG, core_instance: INSTANCE, studio: "https://studio.example", lead: LEAD },
|
||||
seat: who.seat, rigs: [RIG], artifact_roots: [], access_class: "work", objects,
|
||||
});
|
||||
|
||||
describe("agent network increment A: OAuth-connected assistants work with each other and the team", () => {
|
||||
let db: Database.Database;
|
||||
let home: string;
|
||||
let repo: QueueRepository;
|
||||
let daemon: ServerType;
|
||||
let daemonUrl: string;
|
||||
let setup: ReturnType<typeof createTestApp>;
|
||||
let service: http.Server;
|
||||
let serviceUrl: string;
|
||||
/** What each connection's grant names, by key; a key set to "revoked" is answered revoked. */
|
||||
let grants: Map<string, Record<string, string[]> | "revoked">;
|
||||
const closers: Array<() => Promise<void>> = [];
|
||||
const saved = { home: process.env.OPENRIG_HOME, session: process.env.OPENRIG_SESSION_NAME };
|
||||
|
||||
beforeEach(async () => {
|
||||
db = createDb(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-network-"));
|
||||
process.env.OPENRIG_HOME = home;
|
||||
process.env.OPENRIG_SESSION_NAME = "env-seat@elsewhere";
|
||||
repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
|
||||
setup = createTestApp(db, { appDeps: { queueRepo: repo } });
|
||||
daemon = await new Promise<ServerType>((resolve) => { const s = serve({ fetch: setup.app.fetch, port: 0, hostname: "127.0.0.1" }, () => resolve(s)); });
|
||||
daemonUrl = `http://127.0.0.1:${(daemon.address() as AddressInfo).port}`;
|
||||
setSelfHostId(INSTANCE);
|
||||
grants = new Map([[A.key, NETWORK], [B.key, NETWORK]]);
|
||||
service = http.createServer((req, res) => {
|
||||
req.resume();
|
||||
req.on("end", () => {
|
||||
const key = String(req.headers.authorization ?? "").replace(/^Bearer /, "");
|
||||
const who = key === A.key ? A : key === B.key ? B : null;
|
||||
const objects = who ? grants.get(who.key) : undefined;
|
||||
const body = !who || !objects ? { schema: SCHEMA, status: "unknown" }
|
||||
: objects === "revoked" ? { schema: SCHEMA, status: "revoked" } : active(who, objects);
|
||||
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(body));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => service.listen(0, "127.0.0.1", resolve));
|
||||
serviceUrl = `http://127.0.0.1:${(service.address() as AddressInfo).port}/api/connector/v1/grant`;
|
||||
for (const member of ["cus-vvmaexample01-chatgpt", "cus-vvmaexample01-claude", "lead"]) seat(RIG, member);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const close of closers.splice(0).reverse()) await close();
|
||||
await new Promise<void>((resolve) => service.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => daemon.close(() => resolve()));
|
||||
setSelfHostId(null);
|
||||
db.close();
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
if (saved.home === undefined) delete process.env.OPENRIG_HOME; else process.env.OPENRIG_HOME = saved.home;
|
||||
if (saved.session === undefined) delete process.env.OPENRIG_SESSION_NAME; else process.env.OPENRIG_SESSION_NAME = saved.session;
|
||||
});
|
||||
|
||||
function seat(rigName: string, member: string) {
|
||||
const rig = setup.rigRepo.listRigs?.().find((r: { name: string }) => r.name === rigName) ?? setup.rigRepo.createRig(rigName);
|
||||
const node = setup.rigRepo.addNode(rig.id, `pod.${member}`, { role: "worker" });
|
||||
setup.sessionRegistry.registerSession(node.id, `${member}@${rigName}`);
|
||||
}
|
||||
|
||||
async function listen() {
|
||||
const served = await startMcpHttpServer({ daemonUrl, host: "127.0.0.1", port: 0, tokenFile: path.join(home, "mcp-http-tokens.json"),
|
||||
grantService: { url: serviceUrl, timeoutMs: 1_000 } });
|
||||
closers.push(() => served.close());
|
||||
return served;
|
||||
}
|
||||
|
||||
async function connect(url: string, who: typeof A) {
|
||||
const client = new Client({ name: "network-test", version: "1.0.0" });
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers: { Authorization: `Bearer ${who.key}` } } }));
|
||||
closers.push(() => client.close());
|
||||
const call = async (name: string, args: Record<string, unknown> = {}) => {
|
||||
const result = await client.callTool({ name, arguments: args });
|
||||
return { isError: result.isError === true, structured: result.structuredContent as Record<string, any>, text: (result.content as Array<{ text: string }>)[0]?.text ?? "" };
|
||||
};
|
||||
return Object.assign(call, { tools: async () => (await client.listTools()).tools.map((tool) => tool.name).sort() });
|
||||
}
|
||||
|
||||
const rows = () => (db.prepare("SELECT COUNT(*) AS n FROM queue_items").get() as { n: number }).n;
|
||||
const row = (qitemId: string) => repo.getById(qitemId)!;
|
||||
const createdId = (result: { structured: Record<string, any>; text: string }) => String(result.structured?.qitemId ?? JSON.parse(result.text).qitemId);
|
||||
|
||||
it("offers claim, update and rig_team only to a grant that names them; a v1 grant keeps exactly today's four tools", async () => {
|
||||
grants.set(B.key, V1);
|
||||
const served = await listen();
|
||||
expect(await (await connect(served.url, A)).tools()).toEqual(
|
||||
["rig_artifact_read", "rig_queue_claim", "rig_queue_create", "rig_queue_list", "rig_queue_show", "rig_queue_update", "rig_team"]);
|
||||
expect(await (await connect(served.url, B)).tools()).toEqual(["rig_artifact_read", "rig_queue_create", "rig_queue_list", "rig_queue_show"]);
|
||||
});
|
||||
|
||||
it("A discovers B and the team, queues work to B; B claims it and returns the result on the same item, which A reads", async () => {
|
||||
const served = await listen();
|
||||
const a = await connect(served.url, A);
|
||||
const b = await connect(served.url, B);
|
||||
const team = await a("rig_team", { rigId: RIG });
|
||||
expect(team.isError).toBe(false);
|
||||
expect(team.text).toContain(B.seat);
|
||||
expect(team.text).toContain(LEAD);
|
||||
|
||||
const sent = await a("rig_queue_create", { destination: B.seat, body: "Critique this plan.", tags: ["review"] });
|
||||
expect(sent.isError).toBe(false);
|
||||
const qitemId = createdId(sent);
|
||||
expect(row(qitemId)).toMatchObject({ sourceSession: A.seat, destinationSession: B.seat });
|
||||
|
||||
// What A reads at each step: queued (pending, unclaimed), picked up (B's claim), answered (done with a resolution).
|
||||
// B reading or listing the item changes none of it.
|
||||
const inbox = await b("rig_queue_list", { owned: true });
|
||||
expect(inbox.text).toContain(qitemId);
|
||||
expect((await b("rig_queue_show", { qitemId })).isError).toBe(false);
|
||||
expect((await a("rig_queue_show", { qitemId })).structured.item).toMatchObject({ state: "pending", claimedAt: null });
|
||||
expect((await b("rig_queue_claim", { qitemId })).isError).toBe(false);
|
||||
expect((await a("rig_queue_show", { qitemId })).structured.item).toMatchObject({ state: "in-progress", resolution: null });
|
||||
expect((await b("rig_queue_update", { qitemId, state: "done", resolution: "Two risks; see the notes.", closureReason: "no-follow-on" })).isError).toBe(false);
|
||||
expect(row(qitemId)).toMatchObject({ state: "done", resolution: "Two risks; see the notes." });
|
||||
|
||||
const followUp = await a("rig_queue_show", { qitemId });
|
||||
expect(followUp.isError).toBe(false);
|
||||
expect(followUp.text).toContain("Two risks; see the notes.");
|
||||
expect(followUp.text).toContain(B.seat);
|
||||
});
|
||||
|
||||
it("a message is an item the receiver reads and closes, and the reply is an item back, read by the first assistant", async () => {
|
||||
const served = await listen();
|
||||
const a = await connect(served.url, A);
|
||||
const b = await connect(served.url, B);
|
||||
const message = createdId(await a("rig_queue_create", { destination: B.seat, summary: "FYI: the plan changed", body: "We're shipping the smaller fix first.", tags: ["message"] }));
|
||||
expect((await b("rig_queue_show", { qitemId: message })).text).toContain("We're shipping the smaller fix first.");
|
||||
expect((await b("rig_queue_update", { qitemId: message, state: "done", closureReason: "no-follow-on", resolution: "Read." })).isError).toBe(false);
|
||||
const reply = createdId(await b("rig_queue_create", { destination: A.seat, summary: "Re: the plan changed", body: "Agreed; I'll review the fix.", tags: ["message"] }));
|
||||
expect(row(reply)).toMatchObject({ sourceSession: B.seat, destinationSession: A.seat });
|
||||
expect((await a("rig_queue_list", { owned: true })).text).toContain(reply);
|
||||
expect((await a("rig_queue_show", { qitemId: reply })).text).toContain("Agreed; I'll review the fix.");
|
||||
});
|
||||
|
||||
it("B hands work on to the team and reads the team's result; A's task and B's task are separate items, each with its own result", async () => {
|
||||
const served = await listen();
|
||||
const b = await connect(served.url, B);
|
||||
const delegated = createdId(await b("rig_queue_create", { destination: LEAD, body: "Implement the agreed change." }));
|
||||
repo.update({ qitemId: delegated, actorSession: LEAD, state: "done", closureReason: "no-follow-on", resolution: "Merged as abc123." } as never);
|
||||
expect((await b("rig_queue_show", { qitemId: delegated })).text).toContain("Merged as abc123.");
|
||||
});
|
||||
|
||||
it("a create retried with the same qitemId after a lost reply is one item, and a new session with the same key finds the same work", async () => {
|
||||
const served = await listen();
|
||||
const a = await connect(served.url, A);
|
||||
const first = createdId(await a("rig_queue_create", { destination: B.seat, body: "Once.", qitemId: "qitem-network-retry-1" }));
|
||||
const before = rows();
|
||||
const again = createdId(await a("rig_queue_create", { destination: B.seat, body: "Once.", qitemId: "qitem-network-retry-1" }));
|
||||
expect(again).toBe(first);
|
||||
expect(rows()).toBe(before);
|
||||
const reentered = await connect(served.url, A);
|
||||
expect((await reentered("rig_queue_list", {})).text).toContain(first);
|
||||
expect(rows()).toBe(before);
|
||||
});
|
||||
|
||||
it("two assistants calling at once each act as their own seat", async () => {
|
||||
const served = await listen();
|
||||
const [a, b] = await Promise.all([connect(served.url, A), connect(served.url, B)]);
|
||||
const [fromA, fromB] = await Promise.all([
|
||||
a("rig_queue_create", { destination: LEAD, body: "from A" }),
|
||||
b("rig_queue_create", { destination: LEAD, body: "from B" }),
|
||||
]);
|
||||
expect(row(createdId(fromA)).sourceSession).toBe(A.seat);
|
||||
expect(row(createdId(fromB)).sourceSession).toBe(B.seat);
|
||||
});
|
||||
|
||||
it("each changes only items addressed to its own seat, stays in its workspace, and a revoked connection changes nothing", async () => {
|
||||
seat("team-b", "lead");
|
||||
const served = await listen();
|
||||
const a = await connect(served.url, A);
|
||||
const b = await connect(served.url, B);
|
||||
const toB = createdId(await a("rig_queue_create", { destination: B.seat, body: "for B" }));
|
||||
const toTeam = (await repo.create({ sourceSession: LEAD, destinationSession: "worker@team-a", body: "team-internal", nudge: false } as never)).qitemId;
|
||||
const toA = (await repo.create({ sourceSession: LEAD, destinationSession: A.seat, body: "for A", nudge: false } as never)).qitemId;
|
||||
const refused = { isError: true, structured: { code: "out_of_scope" } };
|
||||
// A sent B's item and may read it; the daemon refuses its claim as someone else's.
|
||||
expect((await a("rig_queue_claim", { qitemId: toB })).isError).toBe(true);
|
||||
expect(await a("rig_queue_update", { qitemId: toB, state: "done", closureReason: "no-follow-on" })).toMatchObject(refused);
|
||||
expect(await b("rig_queue_claim", { qitemId: toTeam })).toMatchObject(refused);
|
||||
expect(await b("rig_queue_claim", { qitemId: toA })).toMatchObject(refused);
|
||||
expect(await b("rig_queue_show", { qitemId: toTeam })).toMatchObject(refused);
|
||||
expect(await a("rig_queue_create", { destination: "lead@team-b", body: "other workspace" })).toMatchObject(refused);
|
||||
expect(await a("rig_queue_create", { destination: "someone@external", body: "a person" })).toMatchObject(refused);
|
||||
expect(await a("rig_team", { rigId: "team-b" })).toMatchObject(refused);
|
||||
expect(row(toB).state).toBe("pending");
|
||||
|
||||
// B waits only on an item it can read: never on a person (who would be asked) or on the team's own item.
|
||||
expect((await b("rig_queue_claim", { qitemId: toB })).isError).toBe(false);
|
||||
for (const blockedOn of ["someone@external", toTeam, "qitem-network-missing"]) {
|
||||
expect((await b("rig_queue_update", { qitemId: toB, state: "blocked", blockedOn }))).toMatchObject({ isError: true, structured: { code: "out_of_scope" } });
|
||||
}
|
||||
expect(row(toB).state).toBe("in-progress");
|
||||
const ask = createdId(await b("rig_queue_create", { destination: LEAD, body: "Which of the two plans?" }));
|
||||
expect((await b("rig_queue_update", { qitemId: toB, state: "blocked", blockedOn: ask })).isError).toBe(false);
|
||||
expect(row(toB)).toMatchObject({ state: "blocked", blockedOn: ask });
|
||||
|
||||
grants.set(B.key, "revoked");
|
||||
const before = rows();
|
||||
await expect(b("rig_queue_update", { qitemId: toB, state: "in-progress" })).rejects.toThrow();
|
||||
expect(row(toB).state).toBe("blocked");
|
||||
expect(rows()).toBe(before);
|
||||
});
|
||||
|
||||
it("claim and update answer an assistant with what show shows: a blocker or closure target it can't read stays hidden", async () => {
|
||||
const served = await listen();
|
||||
const b = await connect(served.url, B);
|
||||
const internal = (await repo.create({ sourceSession: LEAD, destinationSession: "worker@team-a", body: "team-internal", nudge: false } as never)).qitemId;
|
||||
const mine = (await repo.create({ sourceSession: LEAD, destinationSession: B.seat, body: "for B", nudge: false } as never)).qitemId;
|
||||
repo.update({ qitemId: mine, actorSession: LEAD, state: "blocked", blockedOn: internal, closureReason: "blocked_on", closureTarget: internal, transitionNote: "waiting on the team" } as never);
|
||||
const shown = await b("rig_queue_show", { qitemId: mine });
|
||||
const noted = await b("rig_queue_update", { qitemId: mine, note: "seen it" });
|
||||
const claimed = await b("rig_queue_claim", { qitemId: mine });
|
||||
for (const result of [shown, noted, claimed]) {
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.text).not.toContain(internal);
|
||||
}
|
||||
expect(row(mine).state).toBe("in-progress");
|
||||
});
|
||||
|
||||
it("an assistant can't change an item addressed to it that it can't read: another workspace's, or another caller's under a partial grant", async () => {
|
||||
grants.set(B.key, { queue: ["create", "show", "list", "claim", "update"], artifact: ["read"] });
|
||||
const served = await listen();
|
||||
const b = await connect(served.url, B);
|
||||
const refused = { isError: true, structured: { code: "out_of_scope" } };
|
||||
const foreign = (await repo.create({ sourceSession: "lead@team-b", destinationSession: B.seat, body: "from another workspace", nudge: false } as never)).qitemId;
|
||||
const fromCaller = (await repo.create({ sourceSession: "cus-vvmaexample02-caller@team-a", destinationSession: B.seat, body: "from another caller", nudge: false } as never)).qitemId;
|
||||
for (const qitemId of [foreign, fromCaller]) {
|
||||
const before = repo.listTransitions(qitemId).length;
|
||||
expect(await b("rig_queue_update", { qitemId, note: "mine?" })).toMatchObject(refused);
|
||||
expect(await b("rig_queue_update", { qitemId, state: "done", closureReason: "no-follow-on" })).toMatchObject(refused);
|
||||
expect(row(qitemId).state).toBe("pending");
|
||||
expect(repo.listTransitions(qitemId).length).toBe(before);
|
||||
}
|
||||
});
|
||||
|
||||
it("an assistant's park names a blocker it can read: a bare re-park after a claim is refused, whether the team's blocker is open or closed", async () => {
|
||||
const served = await listen();
|
||||
const b = await connect(served.url, B);
|
||||
for (const closedAfterClaim of [false, true]) {
|
||||
const internal = (await repo.create({ sourceSession: LEAD, destinationSession: "worker@team-a", body: "team-internal", nudge: false } as never)).qitemId;
|
||||
const mine = (await repo.create({ sourceSession: LEAD, destinationSession: B.seat, body: "for B", nudge: false } as never)).qitemId;
|
||||
repo.update({ qitemId: mine, actorSession: LEAD, state: "blocked", blockedOn: internal, transitionNote: "waiting on the team" } as never);
|
||||
expect((await b("rig_queue_claim", { qitemId: mine })).isError).toBe(false);
|
||||
if (closedAfterClaim) repo.update({ qitemId: internal, actorSession: "worker@team-a", state: "done", closureReason: "no-follow-on" } as never);
|
||||
const reparked = await b("rig_queue_update", { qitemId: mine, state: "blocked" });
|
||||
expect(reparked).toMatchObject({ isError: true, structured: { code: "out_of_scope" } });
|
||||
expect(reparked.text).not.toContain(internal);
|
||||
expect(row(mine)).toMatchObject({ state: "in-progress", blockedOn: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("team discovery names a hosted member's last ring only when it's on an item the assistant may read", async () => {
|
||||
const workspace = path.join(home, "workspace");
|
||||
const savedWorkspace = process.env.OPENRIG_WORKSPACE_ROOT;
|
||||
process.env.OPENRIG_WORKSPACE_ROOT = workspace;
|
||||
try {
|
||||
fs.mkdirSync(path.join(workspace, "rosters"), { recursive: true });
|
||||
fs.writeFileSync(path.join(workspace, "rosters", "team.json"), JSON.stringify({
|
||||
version: 1, id: "team", name: "Team", purpose: "Ship the release", curator: { seat: LEAD, host: INSTANCE }, updated_at: "2026-11-02",
|
||||
members: [{ seat: `grok@${RIG}`, host: INSTANCE, capabilities: ["web research"], engagement: ["delegate"], use_when: "Current sources", why: "Searches the web", hosted: true }],
|
||||
}));
|
||||
const served = await listen();
|
||||
const a = await connect(served.url, A);
|
||||
const ring = (qitemId: string, at: string) =>
|
||||
db.prepare("UPDATE queue_items SET last_nudge_attempt = ?, last_nudge_result = ? WHERE qitem_id = ?").run(at, "wake-accepted:webhook answered 202", qitemId);
|
||||
const fromA = (await repo.create({ sourceSession: A.seat, destinationSession: `grok@${RIG}`, body: "Find the release date.", nudge: false } as never)).qitemId;
|
||||
const internal = (await repo.create({ sourceSession: LEAD, destinationSession: `grok@${RIG}`, body: "team-internal", nudge: false } as never)).qitemId;
|
||||
ring(fromA, "2026-11-02T11:00:00.000Z");
|
||||
ring(internal, "2026-11-02T12:00:00.000Z");
|
||||
const team = await a("rig_team", { rigId: RIG });
|
||||
expect(team.isError).toBe(false);
|
||||
expect(team.structured.hostedMembers).toMatchObject([{ session: `grok@${RIG}`, presence: { lastWake: { qitemId: fromA, at: "2026-11-02T11:00:00.000Z" } } }]);
|
||||
expect(team.text).not.toContain(internal);
|
||||
} finally {
|
||||
if (savedWorkspace === undefined) delete process.env.OPENRIG_WORKSPACE_ROOT; else process.env.OPENRIG_WORKSPACE_ROOT = savedWorkspace;
|
||||
}
|
||||
});
|
||||
|
||||
it("a v1 grant keeps today's rules: no task to or from another caller's seat", async () => {
|
||||
grants.set(A.key, V1);
|
||||
grants.set(B.key, V1);
|
||||
const served = await listen();
|
||||
const a = await connect(served.url, A);
|
||||
const refused = { isError: true, structured: { code: "out_of_scope" } };
|
||||
expect(await a("rig_queue_create", { destination: B.seat, body: "v1 can't" })).toMatchObject(refused);
|
||||
const fromB = (await repo.create({ sourceSession: B.seat, destinationSession: A.seat, body: "from another caller", nudge: false } as never)).qitemId;
|
||||
expect(await a("rig_queue_show", { qitemId: fromB })).toMatchObject(refused);
|
||||
});
|
||||
});
|
||||
@@ -127,33 +127,3 @@ it("does not mistake a deeper subsection for the requested H2 section", () => {
|
||||
expect(addProgressRow(source, { section: "Rail", text: "Release verified", status: "done" }).content)
|
||||
.toBe(source + "\n## Rail\n\n- [x] Release verified\n");
|
||||
});
|
||||
|
||||
|
||||
describe("progress edits preserve fenced examples", () => {
|
||||
it.each(["```md", "~~~md", "````md", " ```md"])("sets only the real row after %s", (opening) => {
|
||||
const marker = opening.trimStart().startsWith("~") ? "~" : "`";
|
||||
const closing = marker.repeat(5);
|
||||
const example = [opening, "## Rail", "- [ ] Ship it", marker.repeat(2), "- [ ] Still an example", closing].join("\n");
|
||||
const content = `# Progress\n${example}\n## Rail\n- [ ] Ship it\n`;
|
||||
const result = setProgressRow(content, { text: "Ship it", status: "done" });
|
||||
expect(result.content).toBe(`# Progress\n${example}\n## Rail\n- [x] Ship it\n`);
|
||||
});
|
||||
|
||||
it("does not update a row that exists only in a code block", () => {
|
||||
const content = "# Progress\n## Rail\n```md\n- [ ] Example\n```\n";
|
||||
expect(() => setProgressRow(content, { text: "Example", status: "done" })).toThrow(/No progress row/);
|
||||
});
|
||||
|
||||
it("ignores example headings and duplicates when adding a row", () => {
|
||||
const example = "```md\n## Rail\n- [x] Ship it\n```";
|
||||
const content = `# Progress\n${example}\n## Rail\n${example}\n\n## Notes\nKeep this.\n`;
|
||||
const result = addProgressRow(content, { section: "Rail", text: "Ship it", status: "active" });
|
||||
expect(result.content).toBe(`# Progress\n${example}\n## Rail\n${example}\n- [ ] Ship it\n\n## Notes\nKeep this.\n`);
|
||||
});
|
||||
|
||||
it("inserts outside an unclosed example at the end of the section", () => {
|
||||
const content = "# Progress\n## Rail\n- [ ] Existing\n\n```md\n## Example\n- [ ] Ship it\n";
|
||||
const result = addProgressRow(content, { section: "Rail", text: "Ship it", status: "active" });
|
||||
expect(result.content).toBe(content.replace("- [ ] Existing", "- [ ] Existing\n- [ ] Ship it"));
|
||||
});
|
||||
});
|
||||
@@ -77,15 +77,3 @@ describe("FR-4 — verb output parses into the expected UI tree", () => {
|
||||
expect(file.counts.blocked).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it.each(["```md\n## Rail\n- [ ] Ship it\n```\n", "```md\n## Rail\n- [ ] Ship it\n"])("adds and updates a real row without editing example %j", (example) => {
|
||||
const original = `# Progress\n\n${example}`;
|
||||
const added = addProgressRow(original, { section: "Rail", text: "Ship it", status: "active" }).content;
|
||||
const updated = setProgressRow(added, { text: "Ship it", status: "done" }).content;
|
||||
expect(updated).toContain(example);
|
||||
fs.writeFileSync(path.join(root, "PROGRESS.md"), updated);
|
||||
const file = indexerFor().scan().files[0]!;
|
||||
expect(file.counts).toMatchObject({ total: 1, done: 1 });
|
||||
expect(file.rows.filter(r => r.kind === "checkbox")).toMatchObject([{ text: "Ship it", status: "done" }]);
|
||||
});
|
||||
@@ -189,4 +189,13 @@ describe("rig seat set-runtime", () => {
|
||||
expect(errors.join("\n")).toMatch(message);
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
it("a seat that has never started says nothing was switched, with no switch id, and that its first start uses the new runtime", async () => {
|
||||
const { logs, exitCode } = await run({ status: 200, data: { ok: true, dryRun: false, changed: true, firstStart: true,
|
||||
seat: { ref: "dev.build@team" }, from: "claude-code", to: "codex", permission: { mode: "inherit", source: "member_or_rig_policy" },
|
||||
dropped: [{ setting: "model", value: "opus" }] } }, ["dev.build@team", "--runtime", "codex", "--reason", "start it on Codex"]);
|
||||
expect(logs).toContain("dev.build@team has never started, so nothing was switched (no switch id): its saved runtime changed from claude-code to codex, and its first start uses it.");
|
||||
expect(logs).toContain("Cleared from claude-code: model opus. Its first start takes the rest from its agent spec for codex.");
|
||||
expect(logs.join("\n")).not.toMatch(/switch undefined|Queue:/);
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -260,9 +260,8 @@ const HANDOVER_RESULT = {
|
||||
eventSeq: 42,
|
||||
sideEffects: {
|
||||
departingSessionKilled: false,
|
||||
departingProcess: "not-touched",
|
||||
startupContextDelivered: false,
|
||||
provenanceRecordWritten: true,
|
||||
provenanceRecordWritten: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -273,9 +272,8 @@ const HANDOVER_RESULT_FRESH = {
|
||||
discovery: { id: "disc-fresh", status: "claimed", tmuxSession: "dev-impl@seat-rig-h1SUCCID0", tmuxPane: "%9" },
|
||||
sideEffects: {
|
||||
departingSessionKilled: false,
|
||||
departingProcess: "ended-graceful",
|
||||
startupContextDelivered: true,
|
||||
provenanceRecordWritten: true,
|
||||
provenanceRecordWritten: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -679,9 +677,8 @@ describe("rig seat status", () => {
|
||||
currentStatus: { handoverResult: "complete" },
|
||||
sideEffects: {
|
||||
departingSessionKilled: false,
|
||||
departingProcess: "not-touched",
|
||||
startupContextDelivered: false,
|
||||
provenanceRecordWritten: true,
|
||||
provenanceRecordWritten: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -718,39 +715,12 @@ describe("rig seat status", () => {
|
||||
expect(output).toContain("Previous occupant: dev-impl@seat-rig");
|
||||
expect(output).toContain("Current occupant: successor-session");
|
||||
expect(output).toContain("Seat binding and inventory provenance were updated.");
|
||||
// The receipt's side effects, as they happened: the lineage record written, the old process left alone.
|
||||
expect(output).toContain("The handover's lineage record (occupant tenure) was written.");
|
||||
expect(output).toContain("The previous occupant's process wasn't touched: the successor was an existing session.");
|
||||
// startupContextDelivered:false — the output says nothing was delivered or carried...
|
||||
expect(output).toContain("OpenRig delivered no startup context and carried no conversation: the discovered successor was already running.");
|
||||
// startupContextDelivered:false — the message must include "startup context delivery" in the NOT-performed list.
|
||||
expect(output).toContain("No conversation continuity, startup context delivery, provenance markdown, or session stop was performed.");
|
||||
// ...and must NOT falsely claim a delivery happened.
|
||||
expect(output).not.toContain("restore packet) was delivered");
|
||||
});
|
||||
|
||||
it("handover human output says when the lineage record wasn't written, and that the old process was killed (rebuild)", async () => {
|
||||
const deps = makeDeps({ status: 200, data: { ...HANDOVER_RESULT, source: { mode: "rebuild", raw: "rebuild" },
|
||||
sourceOutcome: { mode: "rebuild", primedArtifacts: [], gaps: [] },
|
||||
sideEffects: { departingSessionKilled: false, departingProcess: "ended-forced", startupContextDelivered: true, provenanceRecordWritten: false } } }, []);
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCommand(deps).parseAsync(["node", "rig", "seat", "handover", "dev-impl@seat-rig", "--source", "rebuild", "--reason", "context wall"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(exitCode).toBeUndefined(); // the handover itself completed
|
||||
expect(output).toContain("The handover's lineage record (occupant tenure) was NOT written; check the daemon log, which names the first such failure since the daemon started. The seat is already bound to the successor, so don't run the handover again.");
|
||||
expect(output).toContain("The previous occupant's process didn't exit on TERM and was killed; its tmux session was kept, and the successor runs in the same pane.");
|
||||
expect(output).toContain("No conversation continuity was carried: the successor started a fresh conversation.");
|
||||
expect(output).not.toContain("session stop");
|
||||
});
|
||||
|
||||
it("handover human output from an older daemon, whose receipt has no departingProcess, claims neither side effect", async () => {
|
||||
const older = { departingSessionKilled: false, startupContextDelivered: false, provenanceRecordWritten: false };
|
||||
const deps = makeDeps({ status: 200, data: { ...HANDOVER_RESULT, sideEffects: older } }, []);
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCommand(deps).parseAsync(["node", "rig", "seat", "handover", "dev-impl@seat-rig", "--source", "discovered:disc-1", "--reason", "mvp-proof"]);
|
||||
});
|
||||
expect(logs.join("\n")).not.toMatch(/lineage record|previous occupant's process/);
|
||||
});
|
||||
|
||||
it("handover fresh human output HONESTLY reports the delivered startup context (B2)", async () => {
|
||||
const deps = makeDeps({ status: 200, data: HANDOVER_RESULT_FRESH }, []);
|
||||
|
||||
@@ -769,7 +739,7 @@ describe("rig seat status", () => {
|
||||
expect(output).toContain("Current occupant: dev-impl@seat-rig-h1SUCCID0");
|
||||
// The fresh path DID deliver context — the surface must say so, not deny it.
|
||||
expect(output).toContain("The captured startup context (restore packet) was delivered to the successor.");
|
||||
expect(output).not.toContain("OpenRig delivered no startup context");
|
||||
expect(output).not.toContain("No conversation continuity, startup context delivery,");
|
||||
});
|
||||
|
||||
it("handover fresh human output says an unconfirmed restore packet was sent, not that nothing was delivered", async () => {
|
||||
@@ -789,7 +759,7 @@ describe("rig seat status", () => {
|
||||
expect(output).toContain("Seat handover complete: dev-impl@seat-rig");
|
||||
expect(output).toContain(`its submission is unconfirmed: ${reason}.`);
|
||||
expect(output).toContain("Check the successor's screen before sending the context again");
|
||||
expect(output).not.toContain("OpenRig delivered no startup context");
|
||||
expect(output).not.toContain("No conversation continuity, startup context delivery,");
|
||||
expect(output).not.toContain("restore packet) was delivered");
|
||||
});
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ vi.mock("../src/daemon-lifecycle.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
function makeQueueDeps(): { deps: QueueDeps; calls: Array<{ method: string; path: string; body?: Record<string, unknown> }> } {
|
||||
const calls: Array<{ method: string; path: string; body?: Record<string, unknown> }> = [];
|
||||
function makeQueueDeps(): { deps: QueueDeps; calls: Array<{ method: string; path: string }> } {
|
||||
const calls: Array<{ method: string; path: string }> = [];
|
||||
return {
|
||||
calls,
|
||||
deps: {
|
||||
@@ -27,7 +27,7 @@ function makeQueueDeps(): { deps: QueueDeps; calls: Array<{ method: string; path
|
||||
clientFactory: () => ({
|
||||
get: vi.fn(async (path: string) => { calls.push({ method: "GET", path }); return { status: 200, data: [] }; }),
|
||||
getText: vi.fn(async () => ({ status: 200, data: "" })),
|
||||
post: vi.fn(async (path: string, body?: Record<string, unknown>) => { calls.push({ method: "POST", path, body }); return { status: 201, data: {} }; }),
|
||||
post: vi.fn(async (path: string) => { calls.push({ method: "POST", path }); return { status: 201, data: {} }; }),
|
||||
delete: vi.fn(async () => ({ status: 204, data: null })),
|
||||
postText: vi.fn(async () => ({ status: 200, data: "" })),
|
||||
postExpectText: vi.fn(async () => ({ status: 200, data: "" })),
|
||||
@@ -64,59 +64,6 @@ describe("Slice 15 — CLI contract honesty", () => {
|
||||
expect(parsed.error.message).toMatch(/positive integer/);
|
||||
});
|
||||
|
||||
const PRIORITY_VERBS: Array<[string, string[], string]> = [
|
||||
["create", ["--source", "a@r", "--destination", "b@r", "--summary", "s", "--body", "x"], "/api/queue/create"],
|
||||
["handoff", ["qitem-x", "--to", "b@r"], "/api/queue/qitem-x/handoff"],
|
||||
["handoff-and-complete", ["qitem-x", "--to", "b@r"], "/api/queue/qitem-x/handoff-and-complete"],
|
||||
];
|
||||
|
||||
async function sentPriority(verb: string, args: string[], path: string, flag: string[]) {
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "seat@rig"); // handoff verbs act as the calling seat
|
||||
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
try {
|
||||
const { deps, calls } = makeQueueDeps();
|
||||
const run = await runCli(["queue", verb, ...args, ...flag, "--json"], { queueDeps: deps });
|
||||
const call = calls.find((c) => c.path === path);
|
||||
expect(call, JSON.stringify({ calls, run })).toBeDefined();
|
||||
return { body: call!.body!, notes: stderr.mock.calls.map((c) => c.join(" ")).filter((l) => l.includes("--priority")) };
|
||||
} finally {
|
||||
stderr.mockRestore();
|
||||
stdout.mockRestore();
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
}
|
||||
|
||||
describe.each(PRIORITY_VERBS)("queue %s --priority", (verb, args, path) => {
|
||||
it.each([["Critical", "critical"], ["URGENT", "urgent"], ["Routine", "routine"], ["critical", "critical"]])(
|
||||
"normalizes the documented value %s without a note", async (given, expected) => {
|
||||
const { body, notes } = await sentPriority(verb, args, path, ["--priority", given]);
|
||||
expect(body.priority).toBe(expected);
|
||||
expect(notes).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["high", "normal", "background", "P1-Hot"])("keeps %s as given, with one note", async (given) => {
|
||||
const { body, notes } = await sentPriority(verb, args, path, ["--priority", given]);
|
||||
expect(body.priority).toBe(given);
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0]).toContain("outside the documented choices");
|
||||
expect(notes[0]).not.toContain("invalid");
|
||||
});
|
||||
});
|
||||
|
||||
it("omitting --priority keeps create's routine default, without a note", async () => {
|
||||
const [verb, args, path] = PRIORITY_VERBS[0]!;
|
||||
const { body, notes } = await sentPriority(verb, args, path, []);
|
||||
expect(body.priority).toBe("routine");
|
||||
expect(notes).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(PRIORITY_VERBS.slice(1))("omitting --priority on %s sends none, so the priority is inherited", async (verb, args, path) => {
|
||||
const { body, notes } = await sentPriority(verb, args, path, []);
|
||||
expect(body.priority).toBeUndefined();
|
||||
expect(notes).toEqual([]);
|
||||
});
|
||||
|
||||
it("without --json, the same failure is plain text on stderr (no JSON on stdout) + nonzero exit", async () => {
|
||||
const { deps } = makeQueueDeps();
|
||||
const { out, err, exitCode } = await runCli(["queue", "list", "--limit", "-1"], { queueDeps: deps });
|
||||
@@ -125,61 +72,6 @@ describe("Slice 15 — CLI contract honesty", () => {
|
||||
expect(err).toMatch(/positive integer/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["pending, in_progress", ["in_progress"], "pending, in_progress"],
|
||||
["bogus,also_bogus", ["bogus", "also_bogus"], "bogus,also_bogus"],
|
||||
])("queue list --state %j warns on stderr and still runs the query as given", async (state, unknown, sent) => {
|
||||
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
try {
|
||||
const { deps, calls } = makeQueueDeps();
|
||||
const { exitCode } = await runCli(["queue", "list", "--state", state, "--json"], { queueDeps: deps });
|
||||
expect(exitCode).toBe(0);
|
||||
const warning = stderr.mock.calls.map((c) => c.join(" ")).join("\n");
|
||||
for (const name of unknown) expect(warning).toContain(`'${name}'`);
|
||||
expect(warning).toContain("Supported states: pending, in-progress, done");
|
||||
if (state.includes("pending")) expect(warning).not.toContain("empty queue");
|
||||
else expect(warning).toContain("doesn't mean an empty queue");
|
||||
const listCalls = calls.filter((c: { path: string }) => c.path.startsWith("/api/queue/list"));
|
||||
expect(listCalls).toHaveLength(1);
|
||||
expect(new URLSearchParams(listCalls[0]!.path.split("?")[1]).get("state")).toBe(sent);
|
||||
expect(JSON.parse(stdout.mock.calls.map((c) => c.join(" ")).join("\n"))).toEqual([]);
|
||||
} finally {
|
||||
stderr.mockRestore();
|
||||
stdout.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["pending", " pending , in-progress ", "done,handed-off,failed,denied,canceled,blocked", "pending,", " "])(
|
||||
"queue list --state %j runs without a warning", async (state) => {
|
||||
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
try {
|
||||
const { deps, calls } = makeQueueDeps();
|
||||
const { exitCode } = await runCli(["queue", "list", "--state", state, "--json"], { queueDeps: deps });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).not.toHaveBeenCalled();
|
||||
expect(calls.filter((c: { path: string }) => c.path.startsWith("/api/queue/list"))).toHaveLength(1);
|
||||
} finally {
|
||||
stderr.mockRestore();
|
||||
stdout.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("queue list without --state sends no state filter and no warning", async () => {
|
||||
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
try {
|
||||
const { deps, calls } = makeQueueDeps();
|
||||
await runCli(["queue", "list", "--json"], { queueDeps: deps });
|
||||
expect(stderr).not.toHaveBeenCalled();
|
||||
expect(calls.find((c: { path: string }) => c.path.startsWith("/api/queue/list"))!.path).not.toContain("state=");
|
||||
} finally {
|
||||
stderr.mockRestore();
|
||||
stdout.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("the path is SHARED across families — a send-family error also emits JSON under --json", async () => {
|
||||
const { out, exitCode } = await runCli(["send", "--json", "--no-such-flag"]);
|
||||
expect(exitCode).toBe(1);
|
||||
|
||||
@@ -6,7 +6,7 @@ The image recipe is an input recipe, not a published prebuilt image or a reprodu
|
||||
|
||||
## Install the reference and work connection
|
||||
|
||||
Run the environment commands from your managed OpenRig seat. In a plain shell, set `OPENRIG_SESSION_NAME` to the actual acting `member@rig` address, for example `export OPENRIG_SESSION_NAME=operator-agent@kernel` when operating as that member. The CLI uses this existing convention to stamp `X-OpenRig-Session`; it attributes requests within the daemon's trusted transport, and is not authentication. The work and activity credentials below are separate authorities, not replacements for this actor attribution.
|
||||
Run the environment commands from your managed OpenRig seat. In a plain shell, set `OPENRIG_SESSION_NAME` to the actual acting `member@rig` address, for example `export OPENRIG_SESSION_NAME=operator@kernel` when operating as that member. The CLI uses this existing convention to stamp `X-OpenRig-Session`; it attributes requests within the daemon's trusted transport, and is not authentication. The work and activity credentials below are separate authorities, not replacements for this actor attribution.
|
||||
|
||||
Start an MCP HTTP listener using `rig mcp serve --http --help` and the normal member-grant file on this daemon host. Select a guest-reachable address and explicit port. Setup adds one non-owner, one-seat, one-rig work grant in that existing file and stores its bearer credential in a private managed file. It prints only the file reference. Existing files/presets with different bytes are preserved.
|
||||
|
||||
@@ -17,7 +17,7 @@ rig environment setup --directory ./openshell-example --binding worker-one \
|
||||
--guest-base-url http://host.openshell.internal:7782 --json
|
||||
```
|
||||
|
||||
Enable the daemon's separate guest activity listener with `OPENRIG_GUEST_ACTIVITY_PORT=7782` and a guest-reachable `OPENRIG_GUEST_ACTIVITY_HOST` in the daemon launch environment. The port must differ from the daemon's main port, or the daemon refuses to start, and the host defaults to `127.0.0.1`. It is disabled by default; this is an explicit additional listener, not a change to the main API's binding. The bind host must be an address on the daemon host that the guest can actually reach; a listener bound only to `127.0.0.1` is not reachable through a host alias that maps to a different interface. Set `OPENRIG_GUEST_ACTIVITY_HOST` to that selected host address and use its guest-visible name in `--guest-base-url`. With rootless networking, verify the host alias's route rather than assuming it reaches loopback. Bind the MCP listener to its separately selected guest-reachable address as well. The listener serves only `POST /api/activity/hooks` and the three permission-request routes (`POST /api/activity/permission-requests`, `GET /api/activity/permission-requests/<askId>` and `POST /api/activity/permission-requests/<askId>/withdraw`), using the seat/generation grant. It exposes no queue, config, event-stream or parked-seat reads and refuses the install-wide token. Use the actual guest-reachable MCP and guest-activity ports. Setup generates `policy.yaml` for those two selected host/port pairs, without blanket egress. Never grant the guest access to the daemon's main API port (normally 7433). OpenShell's REST policy applies to a host and port: the dedicated listener confines the surface, while its activity grant and the separate MCP work grant authorize their respective requests. The activity rule names `/usr/bin/node`, matching the selected plugin hook interpreter. Qualify that path in your image. The separate read-only `platform.claude.com` rule is for the pinned native startup check and carries no inference credential.
|
||||
Enable the daemon's separate guest activity listener with `OPENRIG_GUEST_ACTIVITY_PORT=7782` and a guest-reachable `OPENRIG_GUEST_ACTIVITY_HOST` in the daemon launch environment. It is disabled by default; this is an explicit additional listener, not a change to the main API's binding. The bind host must be an address on the daemon host that the guest can actually reach; a listener bound only to `127.0.0.1` is not reachable through a host alias that maps to a different interface. Set `OPENRIG_GUEST_ACTIVITY_HOST` to that selected host address and use its guest-visible name in `--guest-base-url`. With rootless networking, verify the host alias's route rather than assuming it reaches loopback. Bind the MCP listener to its separately selected guest-reachable address as well. The listener serves only `POST /api/activity/hooks` and the three permission-request routes (`POST /api/activity/permission-requests`, `GET /api/activity/permission-requests/<askId>` and `POST /api/activity/permission-requests/<askId>/withdraw`), using the seat/generation grant. It exposes no queue, config, event-stream or parked-seat reads and refuses the install-wide token. Use the actual guest-reachable MCP and guest-activity ports. Setup generates `policy.yaml` for those two selected host/port pairs, without blanket egress. Never grant the guest access to the daemon's main API port (normally 7433). OpenShell's REST policy applies to a host and port: the dedicated listener confines the surface, while its activity grant and the separate MCP work grant authorize their respective requests. The activity rule names `/usr/bin/node`, matching the selected plugin hook interpreter. Qualify that path in your image. The separate read-only `platform.claude.com` rule is for the pinned native startup check and carries no inference credential.
|
||||
|
||||
Import `claude-code-provider.yaml` using OpenShell's provider-profile commands, then create a provider with its `ANTHROPIC_API_KEY` environment lookup. See `openshell provider --help`; keep the master credential in provider custody, never an argument or source file. `create` passes that provider reference with `--no-auto-providers`. The guest helper saves only the provider-injected reference in a private file and uses Claude's `apiKeyHelper`. It does not copy an owner authentication home.
|
||||
|
||||
@@ -122,8 +122,6 @@ The first source tests use fake provider transport, the actual guest helper's fi
|
||||
|
||||
Driver protocol 1 is documented by `driver-v1.schema.json` and the daemon's `EnvironmentDriver` interface. Core owns placement provenance, operation intent, resolved-file selection, selected task completion and receipts; the driver owns fixed OpenShell CLI transport and guest path mapping. There is no generic plugin loader or alternative task store. Projection is bounded to 4096 files, 1 MiB per file and 32 MiB total. Unsupported runtime resources/subagents refuse the selected launch explicitly; ordinary host runtimes keep their existing behavior. Cross-host migration, RAM snapshots and arbitrary native-store portability remain outside this reference.
|
||||
|
||||
Startup uses a private, hash-checked `control/first-input.txt` file and a bounded initial prompt pointing Claude to it. Core prepares the guest with `--prepare --first-input-required`; an older helper rejects that option before publishing the launch latch. Rebuild the maintained image with this helper when updating the worker. Startup identity, projected role/context references and applicable text actions never pass through the host viewer's input. Native ingestion remains separately observed; a passed input is not proof that Claude read it. Viewer readiness checks the recorded guest execution, and opening the viewer checks live resource identity and generation without repeating full projection validation.
|
||||
|
||||
## Lifecycle and restore limits
|
||||
|
||||
The placement name is supplied by the caller. Create refuses an already-listed name, and each binding retains its one selected name; OpenRig does not reserve names globally across bindings. Immediately before stop/delete, OpenRig checks the saved immutable identity, then the pinned vendor call addresses the name and workspace. A deliberate reuse of that name between inspection and the vendor call could affect the replacement. The pinned CLI has no expected-id condition for these calls. Later reconciliation cannot undo that mutation.
|
||||
|
||||
@@ -111,11 +111,6 @@ def descriptor(root, pending=False):
|
||||
data = regular(root, file["path"])
|
||||
if len(data) != file["bytes"] or digest(data) != file["sha256"]:
|
||||
raise Refused("environment_projection_changed")
|
||||
first_input = value.get("firstInput")
|
||||
if first_input:
|
||||
data = regular(root, first_input["path"])
|
||||
if digest(data) != first_input["sha256"] or len(data) != first_input["bytes"]:
|
||||
raise Refused("environment_projection_changed")
|
||||
# P1-P3 never silently replace a requested native conversation with a fresh one.
|
||||
if value.get("resumeToken"):
|
||||
if value.get("nativeType") != "community:openshell-claude" or value["resumeToken"] != value["nativeId"]:
|
||||
@@ -182,11 +177,6 @@ def launch(root):
|
||||
argv.extend(["--effort", value["effort"]])
|
||||
if value.get("permissionMode"):
|
||||
argv.extend(["--permission-mode", value["permissionMode"]])
|
||||
if value.get("firstInput"):
|
||||
# A bounded file reference is the initial user turn; no startup bytes are typed through a host viewer.
|
||||
argv.append("Read " + str(safe(root, value["firstInput"]["path"])) + " as your selected OpenRig startup input. "
|
||||
"It contains your identity, role/context references and instructions. Use the authenticated OpenRig MCP tools "
|
||||
"inside this guest; host-only rig CLI commands in context require their MCP equivalents.")
|
||||
work = safe(root, "work")
|
||||
work.mkdir(exist_ok=True)
|
||||
os.chdir(work)
|
||||
@@ -232,7 +222,6 @@ def main():
|
||||
p.add_argument("--previous")
|
||||
p.add_argument("--mode", choices=("0600", "0700"), default="0600")
|
||||
p.add_argument("--prepare", action="store_true")
|
||||
p.add_argument("--first-input-required", action="store_true")
|
||||
p.add_argument("--identity", action="store_true")
|
||||
p.add_argument("--stop-native")
|
||||
p.add_argument("--generation")
|
||||
@@ -266,8 +255,6 @@ def main():
|
||||
return {"ok": True, "sha256": digest(data), "bytes": len(data)}
|
||||
if args.prepare:
|
||||
value = descriptor(root, pending=True)
|
||||
if args.first_input_required and not value.get("firstInput"):
|
||||
raise Refused("environment_descriptor_invalid")
|
||||
encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
||||
published = safe(root, "control/launch.json")
|
||||
if published.exists():
|
||||
|
||||
@@ -10,4 +10,3 @@ limits:
|
||||
launch_result: true
|
||||
resume: true
|
||||
passes_model: true
|
||||
first_input: true
|
||||
+3
-12
@@ -73,15 +73,6 @@ This is the system's truth-source for "how did the current occupant get
|
||||
there." Without it, the control plane shows the current occupant but
|
||||
not the legitimacy of the transition.
|
||||
|
||||
`rig seat handover`'s receipt reports it under `sideEffects`:
|
||||
`provenanceRecordWritten` says whether this handover's lineage record (its
|
||||
occupant tenure) exists, and `departingProcess` says how the old process
|
||||
ended (`ended-graceful`, `ended-forced`, or `not-touched` for a discovered
|
||||
successor). If `provenanceRecordWritten` is false, the seat is already bound
|
||||
to the successor and only its lineage record is missing: check the daemon
|
||||
log, which names the first such failure since the daemon started, and don't
|
||||
run the handover again, which would replace the occupant a second time.
|
||||
|
||||
## State models — independent
|
||||
|
||||
### Occupant-creation state (per candidate)
|
||||
@@ -94,7 +85,7 @@ run the handover again, which would replace the occupant a second time.
|
||||
|
||||
1. **Stable** — current occupant attached, no in-flight binding
|
||||
2. **Binding** — handover in progress
|
||||
3. **Bound** — handover succeeded; provenance record written (or missing, when the receipt says so: see failure mode 3)
|
||||
3. **Bound** — handover succeeded; provenance record written
|
||||
4. **Unchanged** — bind failed before completion; seat retains old occupant
|
||||
|
||||
A seat stays `Stable` even if multiple candidate-occupants were produced and discarded.
|
||||
@@ -103,7 +94,7 @@ A seat stays `Stable` even if multiple candidate-occupants were produced and dis
|
||||
|
||||
1. **Candidate creation failed** — `rebuild` couldn't synthesize from artifacts; `fork` couldn't resolve `session_source`; `fresh` couldn't launch. **Action**: bind operation does not begin; seat unchanged; provenance records the failed candidate-creation step.
|
||||
2. **Old occupant cannot be detached cleanly** — runtime hung, tmux locked, etc. **Action**: bind halts mid-flight; seat enters `Binding` state with explicit "halted" sub-status; operator alerted. **Do NOT auto-rollback by reattaching old-occupant if detach didn't complete cleanly.**
|
||||
3. **Bind succeeded but the lineage record didn't write** — disk/db error; the receipt says `provenanceRecordWritten: false`. **Action**: the binding is committed and the successor is the occupant; only its lineage record is missing. Report it as bound with the record missing, check the daemon log (it names the first such failure since the daemon started), and don't run the handover again.
|
||||
3. **Bind succeeded but provenance write failed** — disk/db error. **Action**: not durable until provenance writes; treat as `Binding` halted, not `Bound`.
|
||||
4. **Old occupant disposition unfulfillable** — operator requested `advise` (keep alive as advisor) but runtime can't keep old alive. **Action**: degrade to `retire` with explicit notification, OR fail if operator passed strict-disposition flag.
|
||||
5. **Concurrent handover attempts** — two operations target the same seat. **Action**: serialize by seat-id lock; second attempt refuses with clear error.
|
||||
|
||||
@@ -113,7 +104,7 @@ A seat stays `Stable` even if multiple candidate-occupants were produced and dis
|
||||
- **Do NOT introduce successor-suffix seat names** (`lead2`/`lead3`). Stable seat identity is the architectural goal. The live address stays stable. A retired tenure is distinguished by its ledger
|
||||
generation and exact history token; preserving it does not require a renamed live pane.
|
||||
|
||||
- **Do NOT report a handover's provenance record as written** unless its receipt says `provenanceRecordWritten: true`; a bound seat whose record is missing is reported as exactly that (failure mode 3).
|
||||
- **Do NOT report `seatBindingOutcome: handed_over`** if the provenance record didn't write durably.
|
||||
- **Do NOT auto-rollback a half-completed handover** by re-attaching the old occupant unless detach completed cleanly first.
|
||||
|
||||
## Composition and current command surface
|
||||
|
||||
@@ -1452,6 +1452,6 @@ deliberate writing.
|
||||
|
||||
**`rig --help`**, then `rig <verb> --help`.
|
||||
|
||||
Ninety-one verbs ship, and this page named most of them once. **You will not remember which — you
|
||||
Ninety verbs ship, and this page named most of them once. **You will not remember which — you
|
||||
are meant to remember only that the list is long enough to be worth reading before you build
|
||||
anything.** The question is never *how do I write this*; it is **does this already exist**.
|
||||
@@ -109,8 +109,7 @@ configurations, as below; do not take the pin from a cached page summary.
|
||||
handoff carries, such as a trust or hook-review prompt they declined.
|
||||
2. **Where it works.** If the folder is missing, ask which folder the team should
|
||||
work in (usually a clone of their repository) and use its absolute path. Your
|
||||
own working directory is OpenRig's: the workspace's `kernel` folder (on an older
|
||||
install, the workspace itself), not their project, so never launch with
|
||||
own working directory is OpenRig's workspace, not their project, so never launch with
|
||||
`--cwd .` from here.
|
||||
3. **Three teams, one recommendation.** Present starter, workshop and factory,
|
||||
recommend one with a short reason tied to their goal, and show each graph
|
||||
|
||||
@@ -22,8 +22,7 @@ import { subtreeNamesClaudeExecutable, claudeCommandSessionToken, listNativeProc
|
||||
import { isPerSeatRoleGuidance, mergeManagedBlock, perSeatRoleSkipWarning, DEFAULT_CLAUDE_MANAGED_BLOCK_FILE, type ClaudeManagedBlockFile } from "../domain/managed-blocks.js";
|
||||
import { claudeResumeLaunchEnv, claudeResumeLaunchPrefix } from "./claude-resume-launch.js";
|
||||
import { shellQuote } from "./shell-quote.js";
|
||||
import { validateClaudeActivityHookDelivery, claudeActivityRelayPath, parseManagedClaudeNodeCommand } from "../domain/claude-activity-hooks.js";
|
||||
import { isOwnedCollectorCommand, stripOwnedRelayHooks } from "../domain/claude-owned-settings.js";
|
||||
import { validateClaudeActivityHookDelivery, claudeActivityRelayPath, CLAUDE_ACTIVITY_RELAY_RELATIVE_PATH, parseManagedClaudeNodeCommand } from "../domain/claude-activity-hooks.js";
|
||||
import { isFilesystemRoot } from "../domain/cwd-resolution.js";
|
||||
import { observeClaudePermission } from "../domain/permission-drift.js";
|
||||
import { unresolvedClaudePermissionModes } from "../domain/native-permission-selection.js";
|
||||
@@ -1249,7 +1248,22 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
|
||||
// 1. Strip OpenRig-owned relay entries (EXACT node-command shape) from all events;
|
||||
// prune emptied groups + events. User-authored hooks are untouched.
|
||||
let changed = stripOwnedRelayHooks(hooks);
|
||||
let changed = false;
|
||||
for (const event of Object.keys(hooks)) {
|
||||
const groups = Array.isArray(hooks[event]) ? (hooks[event] as unknown[]) : null;
|
||||
if (!groups) continue;
|
||||
const keptGroups: unknown[] = [];
|
||||
for (const group of groups) {
|
||||
if (!isPlainObject(group) || !Array.isArray(group["hooks"])) { keptGroups.push(group); continue; }
|
||||
const groupHooks = group["hooks"] as unknown[];
|
||||
const keptHooks = groupHooks.filter((h) => !isOwnedRelayCommand(hookCommand(h)));
|
||||
if (keptHooks.length !== groupHooks.length) changed = true;
|
||||
if (keptHooks.length === 0) continue; // prune emptied group
|
||||
keptGroups.push({ ...group, hooks: keptHooks });
|
||||
}
|
||||
if (keptGroups.length === 0) delete hooks[event]; // prune emptied event
|
||||
else hooks[event] = keptGroups;
|
||||
}
|
||||
|
||||
// 2. Deliverable enable: copy the relay asset + upsert the owned entry for each
|
||||
// PREVALIDATED relay event (derived from the canonical manifest above).
|
||||
@@ -1295,6 +1309,11 @@ interface ActivityHookOutcome {
|
||||
settingsUnparseable: boolean;
|
||||
}
|
||||
|
||||
// OpenRig-owned relay path suffix. Ownership is the generated Node command whose single
|
||||
// argument ends with this path — a changed prefix still matches (replace, not duplicate); a
|
||||
// user command that merely contains the path (echo, or node with extra args) does NOT.
|
||||
const LEGACY_OWNED_RELAY_SUFFIX = "/.openrig/hooks/scripts/activity-relay.cjs";
|
||||
|
||||
function managedClaudeNodeCommand(executable: string, ...args: string[]): string {
|
||||
const quotedArgs = args.map(shellQuote).join(" ");
|
||||
const pinned = `${shellQuote(executable)} ${quotedArgs}`;
|
||||
@@ -1302,6 +1321,37 @@ function managedClaudeNodeCommand(executable: string, ...args: string[]): string
|
||||
return parseManagedClaudeNodeCommand(pinned) ? pinned : `node ${quotedArgs}`;
|
||||
}
|
||||
|
||||
function hookCommand(hook: unknown): string | undefined {
|
||||
return isPlainObject(hook) && typeof hook["command"] === "string" ? (hook["command"] as string) : undefined;
|
||||
}
|
||||
|
||||
// OpenRig-owned collectors use canonical POSIX-quoted paths. Recognise older unquoted
|
||||
// three/four-token commands too, but preserve commands composed with shell operators or
|
||||
// extra arguments. Old unquoted paths containing spaces cannot be identified unambiguously.
|
||||
const OWNED_COLLECTOR_SUFFIX = nodePath.sep + nodePath.join(".openrig", "context-collector.cjs");
|
||||
|
||||
function isOwnedCollectorCommand(cmd: string): boolean {
|
||||
if (/[\r\n]/.test(cmd)) return false;
|
||||
const parsed = parseManagedClaudeNodeCommand(cmd);
|
||||
if (parsed && (parsed.args.length === 2 || parsed.args.length === 3)) {
|
||||
return parsed.args[0]!.endsWith(OWNED_COLLECTOR_SUFFIX);
|
||||
}
|
||||
const tokens = cmd.trim().split(/\s+/);
|
||||
if ((tokens.length !== 3 && tokens.length !== 4) || tokens[0] !== "node") return false;
|
||||
if (tokens.some((token) => /[;&|<>`$()'"\\]/.test(token))) return false;
|
||||
return tokens[1]!.endsWith(OWNED_COLLECTOR_SUFFIX);
|
||||
}
|
||||
|
||||
function isOwnedRelayCommand(cmd: string | undefined): boolean {
|
||||
if (!cmd) return false;
|
||||
const parsed = parseManagedClaudeNodeCommand(cmd);
|
||||
if (!parsed || parsed.args.length !== 1) return false;
|
||||
const relay = parsed.args[0]!;
|
||||
return relay.endsWith("/plugins/openrig-core/hooks/scripts/activity-relay.cjs")
|
||||
|| relay.endsWith(LEGACY_OWNED_RELAY_SUFFIX)
|
||||
|| relay.endsWith(`/${CLAUDE_ACTIVITY_RELAY_RELATIVE_PATH}`);
|
||||
}
|
||||
|
||||
function hashContent(content: string): string {
|
||||
return createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
@@ -217,8 +217,6 @@ export class CommunityHarnessRuntimeAdapter implements RuntimeAdapter {
|
||||
if (!session || !(await this.deps.tmux.hasSession(session))) {
|
||||
return { ready: false, reason: "The seat's tmux session isn't there.", code: "session_missing" };
|
||||
}
|
||||
const contained = this.contained();
|
||||
if (contained) return contained.checkReady(binding);
|
||||
const command = await this.deps.tmux.getPaneCommand(session);
|
||||
if (!command || isShellForeground(command)) {
|
||||
return { ready: false, reason: `Community harness "${this.runtime}" isn't running: the pane is at a shell.`, code: "harness_not_running" };
|
||||
|
||||
@@ -13,9 +13,4 @@ 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 };
|
||||
}
|
||||
}
|
||||
@@ -15,15 +15,7 @@ export class OpenShellDriver implements EnvironmentDriver {
|
||||
readonly version = 1 as const;
|
||||
constructor(private readonly command: OpenShellCommand = async args => {
|
||||
try { return (await runFile("openshell", args, { timeout: 30_000, maxBuffer: 2 * 1024 * 1024 })).stdout; }
|
||||
catch (error) {
|
||||
const failure = error as { code?: unknown; stderr?: unknown };
|
||||
const oldHelper = args.includes("/opt/openrig/guest.py") && args.includes("--prepare") && args.includes("--first-input-required")
|
||||
&& typeof failure?.code === "number" && failure.code !== 0 && typeof failure.stderr === "string"
|
||||
&& /^guest\.py: error: unrecognized arguments: --first-input-required[ \t\r]*$/m.test(failure.stderr);
|
||||
throw new EnvironmentError("openshell_transport_unconfirmed", oldHelper
|
||||
? "The guest helper rejected --first-input-required and likely predates first input. Rebuild the maintained image with the guest.py shipped by this OpenRig version; see the OpenShell worker README. The saved launch remains unconfirmed; reconcile it before another launch."
|
||||
: "OpenShell did not return a confirmed result; reconcile the saved operation", 503);
|
||||
}
|
||||
catch { throw new EnvironmentError("openshell_transport_unconfirmed", "OpenShell did not return a confirmed result; reconcile the saved operation", 503); }
|
||||
}) {}
|
||||
private prefix(place: Placement): string[] { return ["--gateway", place.gateway, "--workspace", place.workspace, "sandbox"]; }
|
||||
private resource(value: unknown): EnvironmentResource {
|
||||
|
||||
@@ -384,8 +384,6 @@ export async function readPiSessionEntryIds(
|
||||
|
||||
export class RunnerCore {
|
||||
private streaming = false;
|
||||
/** Pi runs this runner has opened, each posted as an accepted turn (contract section 4): the working beat names it. */
|
||||
private turn = 0;
|
||||
private sessionFile: string | undefined;
|
||||
private sessionId: string | undefined;
|
||||
private lastEntryId: string | undefined;
|
||||
@@ -538,7 +536,6 @@ export class RunnerCore {
|
||||
const message = block.slice("/followup ".length);
|
||||
this.io.sendRpc({ type: "follow_up", message });
|
||||
this.io.mirrorLine(`you (follow-up) ▸ ${message}`);
|
||||
this.acceptIntoOpenRun();
|
||||
return;
|
||||
}
|
||||
// The runtime decides, not the runner: with streamingBehavior "steer", Pi and OMP run the prompt when idle and,
|
||||
@@ -547,17 +544,6 @@ export class RunnerCore {
|
||||
// when a prompt was consumed locally and started none. It labels the mirror line only.
|
||||
this.io.sendRpc({ type: "prompt", message: block, streamingBehavior: "steer" });
|
||||
this.io.mirrorLine(this.streaming ? `you (steer) ▸ ${block}` : `you ▸ ${block}`);
|
||||
this.acceptIntoOpenRun();
|
||||
}
|
||||
|
||||
/** A working beat counts only for the latest accepted turn, and each prompt the runner gives the agent is accepted with
|
||||
* its own number (contract section 4). One sent while a Pi run is open is posted as it's sent, and the run's end
|
||||
* closes it. One sent while idle is posted when its run opens (handleEvent), so a prompt Pi handles without a run opens
|
||||
* no turn. */
|
||||
private acceptIntoOpenRun(): void {
|
||||
if (this.runtime !== "pi" || !this.streaming) return;
|
||||
this.turn += 1;
|
||||
this.io.postActivity(this.activityPayload("UserPromptSubmit", "accepted", { turnId: String(this.turn) }));
|
||||
}
|
||||
|
||||
/** Child process exit — honest, loud, durable. */
|
||||
@@ -736,9 +722,8 @@ export class RunnerCore {
|
||||
* idle seat read `unknown` five minutes after its last turn, and a seat mid-turn read `unknown`
|
||||
* through a long silent LLM request (the seat-activity oracle stops a bare working hook from
|
||||
* deciding after 15s, HOOK_AUTHORITY_WINDOW_MS). Beat while ready and not waiting on the
|
||||
* operator: idle (Stop) when no turn is in flight, and while `streaming` says one is, the
|
||||
* contract's working beat (`active` / `liveness`, section 4) for the run's accepted turn, each
|
||||
* at most once per its own interval behind the newest real event. Same honesty
|
||||
* operator: idle (Stop) when no turn is in flight, streaming (active) while `streaming` says
|
||||
* one is, each at most once per its own interval behind the newest real event. Same honesty
|
||||
* rule: a runner that dies stops beating and the seat goes back to `unknown` (handlePiExit
|
||||
* clears `ready`, so no beat outlives the process it vouches for). Pi only; the OMP identity
|
||||
* handshake has its own retry.
|
||||
@@ -757,7 +742,7 @@ export class RunnerCore {
|
||||
const now = Date.parse(this.io.now());
|
||||
if (this.streaming) {
|
||||
if (this.lastActivityPostMs !== null && now - this.lastActivityPostMs < STREAM_HEARTBEAT_MS) return;
|
||||
this.io.postActivity(this.activityPayload("active", "liveness", { turnId: String(this.turn), livenessSeconds: WORK_LIVENESS_SECONDS }));
|
||||
this.io.postActivity(this.activityPayload("active", "streaming"));
|
||||
return;
|
||||
}
|
||||
// `streaming` follows only agent_start/agent_end; turn_start, compaction and auto-retry also report the agent
|
||||
@@ -824,15 +809,7 @@ export class RunnerCore {
|
||||
if (event.type === "turn_start") this.assistantErrorShown = false;
|
||||
}
|
||||
const mapped = mapPiEvent(event, this.runtime);
|
||||
// A working beat counts only for the latest accepted turn. Pi's runs start from this runner's prompts, so the run
|
||||
// that opens is posted as one, before its first event; a prompt Pi handles without a run opens none. A prompt sent
|
||||
// into an open run is accepted as it's sent (acceptIntoOpenRun).
|
||||
const opening = this.runtime === "pi" && mapped.streaming === true && !this.streaming;
|
||||
if (mapped.streaming !== undefined) this.streaming = mapped.streaming;
|
||||
if (opening) {
|
||||
this.turn += 1;
|
||||
this.io.postActivity(this.activityPayload("UserPromptSubmit", "accepted", { turnId: String(this.turn) }));
|
||||
}
|
||||
// OMP writes its JSONL lazily; re-announce identity after each turn until
|
||||
// the daemon confirms the token. Pi's get_state identity is final.
|
||||
if (event.type === "agent_end" && this.runtime === "omp") this.postSessionIdentity();
|
||||
@@ -925,7 +902,7 @@ export class RunnerCore {
|
||||
/** Each activity post's place in this runner's event order: posts aren't awaited, so they can arrive out of order. */
|
||||
private activitySequence = 0;
|
||||
|
||||
private activityPayload(hookEvent: string, subtype: string | null, extra: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
private activityPayload(hookEvent: string, subtype: string | null): Record<string, unknown> {
|
||||
this.lastPostedHook = hookEvent;
|
||||
this.lastActivityPostMs = Date.parse(this.io.now());
|
||||
// Any posted Stop is an idle report, so the next beat is due a full interval after it, not just after a beat.
|
||||
@@ -940,7 +917,6 @@ export class RunnerCore {
|
||||
subtype,
|
||||
// A Pi runner's idle report holds only while it keeps beating; the daemon expires it after two missed beats.
|
||||
...(this.runtime === "pi" && hookEvent === "Stop" && subtype !== "pi_exited" ? { livenessSeconds: IDLE_HEARTBEAT_MS / 1000 } : {}),
|
||||
...extra,
|
||||
occurredAt: this.io.now(),
|
||||
};
|
||||
}
|
||||
@@ -1098,8 +1074,6 @@ const IDLE_HEARTBEAT_MS = 3 * 60_000;
|
||||
const PI_HEARTBEAT_TICK_MS = 5_000;
|
||||
/** Minimum age of the newest activity post before the streaming beat may re-assert it. */
|
||||
const STREAM_HEARTBEAT_MS = 5_000;
|
||||
/** How long one working beat counts (contract section 4). Beats land 5–10 s apart, well inside half of it. */
|
||||
const WORK_LIVENESS_SECONDS = 30;
|
||||
|
||||
/** The Pi heartbeat scheduler — one 5s tick drives both beats (see PI_HEARTBEAT_TICK_MS).
|
||||
* Exported so tests drive the real interval with fake timers instead of calling heartbeat()
|
||||
|
||||
@@ -23,7 +23,6 @@ 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 { guidanceFileSharedByLink } from "../domain/restore-guidance.js";
|
||||
import { observePiResourceTrust, observeOmpApprovalMode } from "../domain/permission-drift.js";
|
||||
import {
|
||||
piSeatPaths, parsePiRunnerState, buildPiRunnerCommand, buildPendingRunnerState,
|
||||
@@ -41,8 +40,6 @@ 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 {
|
||||
@@ -185,10 +182,9 @@ 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 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
|
||||
// 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");
|
||||
const merged = this.mergeGuidance(targetPath, file.path, content, warnings);
|
||||
if (!merged) continue; // rig-role skip: do not count as delivered
|
||||
break;
|
||||
@@ -444,32 +440,9 @@ 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 = this.guidanceTarget(binding, warnings);
|
||||
if (!targetPath) return false;
|
||||
const targetPath = nodePath.join(binding.cwd, "AGENTS.md");
|
||||
const content = this.fs.readFile(entry.absolutePath);
|
||||
return this.mergeGuidance(targetPath, entry.effectiveId, content, warnings, entry.absolutePath);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { activityGrantsSchema } from "./migrations/143_activity_grants.js";
|
||||
import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js";
|
||||
import { nodePermissionsToSchema } from "./migrations/145_node_permissions_to.js";
|
||||
import { nodeStartupGuidanceFileSchema } from "./migrations/146_node_startup_guidance_file.js";
|
||||
import { 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";
|
||||
@@ -294,5 +293,4 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
activityGrantsSchema,
|
||||
environmentBindingsSchema,
|
||||
nodePermissionsToSchema,
|
||||
nodeStartupGuidanceFileSchema,
|
||||
];
|
||||
@@ -1,9 +0,0 @@
|
||||
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;",
|
||||
};
|
||||
@@ -132,9 +132,6 @@ export interface ActivityEvidence {
|
||||
/** How often the source promises to report while idle. A durable idle boundary holds for two of these, then lapses to
|
||||
* unknown: a runner that died without saying so stops reading idle. Absent: the boundary holds until the next. */
|
||||
livenessSeconds?: number;
|
||||
/** A counted working beat from a runner (the runner contract §4): how long it counts as working, 5-60 s, in place of
|
||||
* the hook window a bare working post has. */
|
||||
workingLivenessSeconds?: number;
|
||||
activity?: ActivityValue;
|
||||
needsInput?: NeedsInput;
|
||||
permissionRequest?: { id: string; hookAgentId?: string | null };
|
||||
|
||||
@@ -1185,11 +1185,7 @@ export class ClaudeCompactionEnforcer {
|
||||
this.reconcilePreparations();
|
||||
} while (attempt.status === "waiting");
|
||||
if (!idle.ok || attempt.status !== "waiting") {
|
||||
// The transport was given this attempt's remaining budget; name its timeout like preparation expiry.
|
||||
const reason = attempt.reason ?? (idle.ok ? "preparation_incomplete"
|
||||
: idle.reason === "wait_for_idle_timeout"
|
||||
? this.mapReady(attempt) ? "map_complete_seat_never_idle" : "preparation_incomplete"
|
||||
: idle.reason);
|
||||
const reason = attempt.reason ?? (!idle.ok ? idle.reason : "preparation_incomplete");
|
||||
return failed(reason);
|
||||
}
|
||||
const compact = await this.sendPreparedCompact(input, attempt, opts.skipMap === true);
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
// OpenRig's own additions in a Claude seat's cwd: the context collector `.openrig/context-collector.cjs`, its
|
||||
// `statusLine`, and the activity relay hooks in `.claude/settings.local.json`. Ownership is the exact generated
|
||||
// Node-command shape. The adapter's install and reconcile and rig down's cleanup (#875) share these matchers, so what
|
||||
// down takes back is exactly what a launch would recognise as its own.
|
||||
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import { CLAUDE_ACTIVITY_RELAY_RELATIVE_PATH, parseManagedClaudeNodeCommand } from "./claude-activity-hooks.js";
|
||||
|
||||
// OpenRig-owned relay path suffix. Ownership is the generated Node command whose single
|
||||
// argument ends with this path — a changed prefix still matches (replace, not duplicate); a
|
||||
// user command that merely contains the path (echo, or node with extra args) does NOT.
|
||||
const LEGACY_OWNED_RELAY_SUFFIX = "/.openrig/hooks/scripts/activity-relay.cjs";
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hookCommand(hook: unknown): string | undefined {
|
||||
return isPlainObject(hook) && typeof hook["command"] === "string" ? (hook["command"] as string) : undefined;
|
||||
}
|
||||
|
||||
// OpenRig-owned collectors use canonical POSIX-quoted paths. Recognise older unquoted
|
||||
// three/four-token commands too, but preserve commands composed with shell operators or
|
||||
// extra arguments. Old unquoted paths containing spaces cannot be identified unambiguously.
|
||||
const OWNED_COLLECTOR_SUFFIX = nodePath.sep + nodePath.join(".openrig", "context-collector.cjs");
|
||||
|
||||
export function isOwnedCollectorCommand(cmd: string): boolean {
|
||||
if (/[\r\n]/.test(cmd)) return false;
|
||||
const parsed = parseManagedClaudeNodeCommand(cmd);
|
||||
if (parsed && (parsed.args.length === 2 || parsed.args.length === 3)) {
|
||||
return parsed.args[0]!.endsWith(OWNED_COLLECTOR_SUFFIX);
|
||||
}
|
||||
const tokens = cmd.trim().split(/\s+/);
|
||||
if ((tokens.length !== 3 && tokens.length !== 4) || tokens[0] !== "node") return false;
|
||||
if (tokens.some((token) => /[;&|<>`$()'"\\]/.test(token))) return false;
|
||||
return tokens[1]!.endsWith(OWNED_COLLECTOR_SUFFIX);
|
||||
}
|
||||
|
||||
export function isOwnedRelayCommand(cmd: string | undefined): boolean {
|
||||
if (!cmd) return false;
|
||||
const parsed = parseManagedClaudeNodeCommand(cmd);
|
||||
if (!parsed || parsed.args.length !== 1) return false;
|
||||
const relay = parsed.args[0]!;
|
||||
return relay.endsWith("/plugins/openrig-core/hooks/scripts/activity-relay.cjs")
|
||||
|| relay.endsWith(LEGACY_OWNED_RELAY_SUFFIX)
|
||||
|| relay.endsWith(`/${CLAUDE_ACTIVITY_RELAY_RELATIVE_PATH}`);
|
||||
}
|
||||
|
||||
/** Strip OpenRig-owned relay entries (EXACT node-command shape) from all events, hook by hook: a group keeps its other
|
||||
* hooks and fields, and an emptied group or event is pruned. User-authored hooks are untouched. Returns whether an
|
||||
* owned entry was removed. */
|
||||
export function stripOwnedRelayHooks(hooks: Record<string, unknown>): boolean {
|
||||
let changed = false;
|
||||
for (const event of Object.keys(hooks)) {
|
||||
const groups = Array.isArray(hooks[event]) ? (hooks[event] as unknown[]) : null;
|
||||
if (!groups) continue;
|
||||
const keptGroups: unknown[] = [];
|
||||
for (const group of groups) {
|
||||
if (!isPlainObject(group) || !Array.isArray(group["hooks"])) { keptGroups.push(group); continue; }
|
||||
const groupHooks = group["hooks"] as unknown[];
|
||||
const keptHooks = groupHooks.filter((h) => !isOwnedRelayCommand(hookCommand(h)));
|
||||
if (keptHooks.length !== groupHooks.length) changed = true;
|
||||
if (keptHooks.length === 0) continue; // prune emptied group
|
||||
keptGroups.push({ ...group, hooks: keptHooks });
|
||||
}
|
||||
if (keptGroups.length === 0) delete hooks[event]; // prune emptied event
|
||||
else hooks[event] = keptGroups;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Take OpenRig's own entries out of parsed Claude settings: its relay hooks, and the `type` and `command` its collector
|
||||
* install wrote into `statusLine`. The person's other `statusLine` fields and every other setting stay. Returns
|
||||
* whether anything changed. */
|
||||
export function removeOwnedClaudeSettings(settings: Record<string, unknown>): boolean {
|
||||
let changed = false;
|
||||
const hooks = settings["hooks"];
|
||||
if (isPlainObject(hooks) && stripOwnedRelayHooks(hooks)) {
|
||||
changed = true;
|
||||
if (Object.keys(hooks).length === 0) delete settings["hooks"];
|
||||
}
|
||||
const statusLine = settings["statusLine"];
|
||||
if (isPlainObject(statusLine) && typeof statusLine["command"] === "string" && isOwnedCollectorCommand(statusLine["command"])) {
|
||||
const rest = { ...statusLine };
|
||||
delete rest["command"];
|
||||
if (rest["type"] === "command") delete rest["type"];
|
||||
if (Object.keys(rest).length > 0) settings["statusLine"] = rest;
|
||||
else delete settings["statusLine"];
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function removeIfEmpty(dir: string, isShared: (path: string) => boolean): void {
|
||||
try {
|
||||
if (!isShared(dir) && fs.lstatSync(dir).isDirectory() && fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
|
||||
} catch {
|
||||
// Missing, or not ours to judge: leave it.
|
||||
}
|
||||
}
|
||||
|
||||
const SHARED = "another seat's directory still uses it through a link, so it was kept";
|
||||
|
||||
/** rig down (#875), for a cwd no Claude seat still uses: take back what OpenRig added there for Claude.
|
||||
* - Its entries leave `.claude/settings.local.json`, which is kept even when that leaves `{}`, since nothing records
|
||||
* whether OpenRig created it. Settings that don't parse as an object are left as they are.
|
||||
* - The collector goes only while its bytes are the shipped collector's.
|
||||
* - `.openrig` and `.claude` go only if they're then empty, never recursively.
|
||||
* - A target `isShared` names (one another seat's directory reaches through a symlink or a hardlink) is left as it is.
|
||||
* Returns what was kept for the person to look at. */
|
||||
export function cleanupOwnedClaudeCwd(cwd: string, collectorAssetPath: string | null | undefined,
|
||||
isShared: (path: string) => boolean = () => false): string[] {
|
||||
const kept: string[] = [];
|
||||
const settingsPath = nodePath.join(cwd, ".claude", "settings.local.json");
|
||||
if (fs.existsSync(settingsPath) && isShared(settingsPath)) kept.push(`${settingsPath}: ${SHARED}`);
|
||||
else if (fs.existsSync(settingsPath)) {
|
||||
const text = fs.readFileSync(settingsPath, "utf-8");
|
||||
let parsed: unknown;
|
||||
try { parsed = text.trim() === "" ? {} : JSON.parse(text); } catch { parsed = undefined; }
|
||||
if (!isPlainObject(parsed)) kept.push(`${settingsPath}: not a JSON settings object, so it was left as it is`);
|
||||
else if (removeOwnedClaudeSettings(parsed)) fs.writeFileSync(settingsPath, JSON.stringify(parsed, null, 2), "utf-8");
|
||||
}
|
||||
const collectorPath = nodePath.join(cwd, ".openrig", "context-collector.cjs");
|
||||
if (fs.existsSync(collectorPath) && isShared(collectorPath)) kept.push(`${collectorPath}: ${SHARED}`);
|
||||
else if (fs.existsSync(collectorPath)) {
|
||||
let shipped: Buffer | null = null;
|
||||
try { shipped = collectorAssetPath ? fs.readFileSync(collectorAssetPath) : null; } catch { shipped = null; }
|
||||
if (!shipped) kept.push(`${collectorPath}: couldn't be compared with the shipped collector, so it was kept`);
|
||||
else if (fs.lstatSync(collectorPath).isFile() && fs.readFileSync(collectorPath).equals(shipped)) fs.unlinkSync(collectorPath);
|
||||
else kept.push(`${collectorPath}: not the shipped collector's bytes, so it was kept`);
|
||||
}
|
||||
for (const dir of [".openrig", ".claude"]) removeIfEmpty(nodePath.join(cwd, dir), isShared);
|
||||
return kept;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export type EnvironmentCheckpoint = {
|
||||
destinationCwd: string; nativeState: string; artifacts: Array<{ path: string; source: string; sha256: string; bytes: number; taskId: string }>; files: Array<{ path: string; source: string; sha256: string; bytes: number }>;
|
||||
};
|
||||
export type EffectReceipt = { version: 1; operationId: string; bindingId: string; generation: string | null; resourceId: string;
|
||||
effect: EnvironmentEffect; state: "confirmed" | "pending" | "unconfirmed"; remaining?: DependentObservation; detail?: string; stage?: string; dispatched?: false | "unknown"; error?: { name: string; code?: string; message: string } };
|
||||
effect: EnvironmentEffect; state: "confirmed" | "pending" | "unconfirmed"; remaining?: DependentObservation; detail?: string };
|
||||
type Operation = { binding_id: string; kind: string; request_json: string; state: string; receipt_json: string | null; effect_id: string | null };
|
||||
const stable = (v: unknown): string => JSON.stringify(v, (_key, x) => x && typeof x === "object" && !Array.isArray(x)
|
||||
? Object.fromEntries(Object.entries(x).sort(([a], [b]) => a.localeCompare(b))) : x);
|
||||
@@ -92,7 +92,6 @@ export class EnvironmentLifecycle {
|
||||
const base = { version: 1 as const, operationId: input.operationId, bindingId: id, generation: input.generation, resourceId: binding.resource!.id, effect: input.effect, ...(input.requestedBy ? { requestedBy: input.requestedBy } : {}) };
|
||||
const result = (state: EffectReceipt["state"], extra: Partial<EffectReceipt> = {}): EffectReceipt => ({ ...base, state, ...extra });
|
||||
const confirm = (extra: Partial<EffectReceipt> = {}) => { const receipt = result("confirmed", extra); this.save(input.operationId, receipt); return receipt; };
|
||||
let stage = "selection";
|
||||
// Re-check at Core's call boundary AND inside the driver's final identity await.
|
||||
// Only a known pre-dispatch refusal releases the claim; transport uncertainty never does.
|
||||
const dispatch = async (run: (allowed: () => boolean) => Promise<void>): Promise<boolean> => {
|
||||
@@ -105,7 +104,7 @@ export class EnvironmentLifecycle {
|
||||
return eligible;
|
||||
};
|
||||
try { if (!allowed()) throw new Error("selection changed"); await run(allowed); }
|
||||
catch (error) { if (refused) { this.platform.db.prepare("UPDATE environment_operations SET state='intent' WHERE operation_id=? AND state='requested'").run(input.operationId); return false; } if (input.effect === "delete-owned") throw error; }
|
||||
catch { if (refused) { this.platform.db.prepare("UPDATE environment_operations SET state='intent' WHERE operation_id=? AND state='requested'").run(input.operationId); return false; } }
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
@@ -116,7 +115,6 @@ export class EnvironmentLifecycle {
|
||||
}
|
||||
if (input.effect === "delete-owned") {
|
||||
if (!this.driver.deleteEnvironment || !this.driver.cleanupState) refuse("environment_effect_unsupported", "This driver cannot verify workload/supervisor/volume cleanup", 501);
|
||||
stage = "provider-list";
|
||||
let listed = await this.driver.list(binding.placement);
|
||||
if (!listed.complete) return result("unconfirmed", { detail: "Provider listing is incomplete" });
|
||||
if (listed.resources.some(row => row.name === binding.resource!.name && row.id !== binding.resource!.id)) refuse("environment_identity_changed", "The resource name was reused; nothing was deleted");
|
||||
@@ -124,7 +122,6 @@ export class EnvironmentLifecycle {
|
||||
let inventory = this.operation(inventoryId)?.receipt_json;
|
||||
if (this.driver.captureCleanup && !inventory) {
|
||||
if (!input.engineSocket) refuse("environment_engine_required", "Select the local provider's rootless Podman socket before delete", 400);
|
||||
stage = "cleanup-inventory";
|
||||
const captured = await this.driver.captureCleanup(binding, input.engineSocket);
|
||||
this.intent(binding, inventoryId, "cleanup-inventory", { resourceId: binding.resource!.id, engineSocket: input.engineSocket });
|
||||
this.save(inventoryId, captured); inventory = JSON.stringify(captured);
|
||||
@@ -133,14 +130,11 @@ export class EnvironmentLifecycle {
|
||||
if (found) {
|
||||
this.platform.assertResource(binding, found);
|
||||
select();
|
||||
stage = "delete-dispatch";
|
||||
if (!await dispatch(allowed => this.driver.deleteEnvironment!(binding, op.effect_id!, allowed)))
|
||||
return result("pending", { detail: "The selection changed before dispatch; no delete was sent" });
|
||||
stage = "provider-absence";
|
||||
listed = await this.driver.list(binding.placement);
|
||||
}
|
||||
if (!listed.complete || listed.resources.some(row => row.id === binding.resource!.id || row.name === binding.resource!.name)) return result("pending", { detail: "Exact provider absence is not confirmed" });
|
||||
stage = "dependent-cleanup";
|
||||
const remaining = await this.driver.cleanupState(binding, inventory ? JSON.parse(inventory) : undefined);
|
||||
if (Object.values(remaining).some(value => value !== false)) return result("unconfirmed", { remaining, detail: "Provider absence does not establish dependent cleanup" });
|
||||
return confirm({ remaining });
|
||||
@@ -165,11 +159,7 @@ export class EnvironmentLifecycle {
|
||||
return !after.running ? confirm() : result("pending", { detail: "Native TERM requested; execution is still present" });
|
||||
} catch (error) {
|
||||
if (error instanceof EnvironmentError) throw error;
|
||||
if (input.effect !== "delete-owned") return result("unconfirmed", { detail: "The original effect could not be observed; its intent remains, and it was not retried" });
|
||||
const dispatched = this.operation(input.operationId)?.state === "intent" ? false : "unknown" as const;
|
||||
const cause = error as Error & { code?: string };
|
||||
return result("unconfirmed", { stage, dispatched, error: { name: cause.name ?? "Error", ...(typeof cause.code === "string" ? { code: cause.code } : {}), message: cause.message ?? String(error) },
|
||||
detail: dispatched === false ? "Stopped before effect dispatch; the saved intent remains for reconciliation" : "The original effect could not be observed; its intent remains, and it was not retried" });
|
||||
return result("unconfirmed", { detail: "The original effect could not be observed; its intent remains, and it was not retried" });
|
||||
}
|
||||
}
|
||||
checkpointRecord(id: string): EnvironmentCheckpoint | null {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathS
|
||||
import { join, posix } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ProjectionPlan } from "./projection-planner.js";
|
||||
import type { NodeBinding, ProjectionResult, ResolvedStartupFile, StartupDeliveryResult, HarnessLaunchResult, ReadinessResult } from "./runtime-adapter.js";
|
||||
import type { NodeBinding, ProjectionResult, ResolvedStartupFile, StartupDeliveryResult, HarnessLaunchResult } from "./runtime-adapter.js";
|
||||
import type { TmuxAdapter } from "../adapters/tmux.js";
|
||||
import { shellQuote } from "../adapters/shell-quote.js";
|
||||
import { EnvironmentPlatform, EnvironmentError, regularBytes, sha256, type ProjectionFile, type ProjectionReceipt } from "./environment-platform.js";
|
||||
@@ -137,18 +137,7 @@ export class EnvironmentStartup {
|
||||
return { delivered: files.length, failed: [] };
|
||||
} finally { rmSync(stage, { recursive: true }); }
|
||||
}
|
||||
async checkReady(binding: NodeBinding): Promise<ReadinessResult> {
|
||||
try {
|
||||
const selected = this.selected(binding);
|
||||
if (selected.generation !== binding.launchGeneration) throw new Error("The selected guest generation changed");
|
||||
const native = new EnvironmentLifecycle(this.platform, undefined, this.stageRoot).native(selected);
|
||||
const observed = await (this.platform.driver as OpenShellDriver).guest(selected, ["--identity"]);
|
||||
if (observed.nativeId !== native.nativeId || observed.generation !== native.generation || observed.running !== true)
|
||||
throw new Error("The selected guest execution is not running");
|
||||
return { ready: true };
|
||||
} catch (error) { return { ready: false, code: "harness_not_running", reason: `Environment worker is not ready: ${(error as Error).message}` }; }
|
||||
}
|
||||
async launch(binding: NodeBinding, opts: { resumeToken?: string; resumeType?: string; forkSource?: unknown; firstInputText?: string; beforeNativeDispatch?: () => boolean }, tmux: Pick<TmuxAdapter, "sendShellCommand">): Promise<HarnessLaunchResult> {
|
||||
async launch(binding: NodeBinding, opts: { resumeToken?: string; resumeType?: string; forkSource?: unknown; beforeNativeDispatch?: () => boolean }, tmux: Pick<TmuxAdapter, "sendShellCommand">): Promise<HarnessLaunchResult> {
|
||||
if (opts.forkSource) return { ok: false, recovery: "attention_required", error: "Environment fork is unsupported; no fresh conversation was started." };
|
||||
const checkpoint = this.retainedResume(binding, opts);
|
||||
const selected = this.selected(binding), generation = binding.launchGeneration!;
|
||||
@@ -166,9 +155,7 @@ export class EnvironmentStartup {
|
||||
throw new EnvironmentError("environment_native_unconfirmed", "The original native identity is unconfirmed; no grant or execution was replaced");
|
||||
const receipt = { ...request, nativeType: "community:openshell-claude", destinationCwd: posix.join(selected.guestRoot, "work"), nativeState: posix.join(selected.guestRoot, "native"), checkpoint: checkpoint?.checkpointId ?? null, predecessorResourceId: checkpoint?.predecessorResourceId ?? null };
|
||||
this.platform.db.prepare("UPDATE environment_operations SET state = 'confirmed', receipt_json = ? WHERE operation_id = ?").run(JSON.stringify(receipt), operationId);
|
||||
return { ok: true, resumeToken: request.nativeId, resumeType: "community:openshell-claude", resumeProvenance: "launcher",
|
||||
...(request.firstInput ? { firstInput: { path: posix.join(selected.guestRoot, request.firstInput.path), submission: "unconfirmed" as const } } : {}),
|
||||
warnings: ["The original native identity was reconciled; viewer readiness still requires its existing observation and no viewer input was replayed."] };
|
||||
return { ok: true, resumeToken: request.nativeId, resumeType: "community:openshell-claude", resumeProvenance: "launcher", warnings: ["The original native identity was reconciled; viewer readiness still requires its existing observation and no viewer input was replayed."] };
|
||||
}
|
||||
const older = this.platform.db.prepare("SELECT operation_id FROM environment_operations WHERE binding_id = ? AND kind = 'launch' AND operation_id <> ? AND state IN ('requested','confirmed')").get(selected.bindingId, operationId);
|
||||
if (older && !checkpoint) throw new EnvironmentError("environment_prior_execution_unconfirmed", "A prior native execution must be reconciled through lifecycle/restore before another launch");
|
||||
@@ -211,13 +198,7 @@ export class EnvironmentStartup {
|
||||
this.platform.db.prepare("UPDATE environment_operations SET request_json = ? WHERE operation_id = ? AND state = 'intent'").run(JSON.stringify({ ...request, activityGrantId: activity.grantId }), operationId);
|
||||
await putPrivate("activity.json", JSON.stringify(activity.env));
|
||||
await putPrivate("coordination.json", coordination.toString("utf8"));
|
||||
const firstInput = opts.firstInputText === undefined ? undefined : { path: "control/first-input.txt", sha256: sha256(opts.firstInputText), bytes: Buffer.byteLength(opts.firstInputText) };
|
||||
if (firstInput) {
|
||||
await putPrivate("first-input.txt", opts.firstInputText!);
|
||||
this.platform.db.prepare("UPDATE environment_operations SET request_json = ? WHERE operation_id = ? AND state = 'intent'")
|
||||
.run(JSON.stringify({ ...request, activityGrantId: activity.grantId, firstInput }), operationId);
|
||||
}
|
||||
const descriptor = { ...request, files, ...(firstInput ? { firstInput } : {}), ...(checkpoint ? { resumeToken: checkpoint.nativeId, nativeType: checkpoint.nativeType, checkpoint: { nativeId: checkpoint.nativeId, nativeType: checkpoint.nativeType, files: [...checkpoint.files, ...(checkpoint.artifacts ?? [])].map(({ path, sha256, bytes }) => ({ path, sha256, bytes })) } } : {}), model: binding.model, effort: binding.effort, permissionMode: binding.permissionMode,
|
||||
const descriptor = { ...request, files, ...(checkpoint ? { resumeToken: checkpoint.nativeId, nativeType: checkpoint.nativeType, checkpoint: { nativeId: checkpoint.nativeId, nativeType: checkpoint.nativeType, files: [...checkpoint.files, ...(checkpoint.artifacts ?? [])].map(({ path, sha256, bytes }) => ({ path, sha256, bytes })) } } : {}), model: binding.model, effort: binding.effort, permissionMode: binding.permissionMode,
|
||||
plugins: files.filter(file => file.path.includes("/.claude/plugins/") && file.path.endsWith("/.claude-plugin/plugin.json"))
|
||||
.map(file => posix.join(selected.guestRoot, file.path.slice(0, -"/.claude-plugin/plugin.json".length))) };
|
||||
await putPrivate("launch.pending.json", JSON.stringify(descriptor));
|
||||
@@ -226,11 +207,9 @@ export class EnvironmentStartup {
|
||||
if (!(this.platform.driver instanceof OpenShellDriver)) throw new EnvironmentError("environment_launch_unsupported", "This driver does not implement native launch preparation");
|
||||
const claimed = this.platform.db.prepare("UPDATE environment_operations SET state = 'requested' WHERE operation_id = ? AND state = 'intent'").run(operationId);
|
||||
if (!claimed.changes) throw new EnvironmentError("environment_native_unconfirmed", "The saved native launch intent needs reconciliation; no second launch was attempted");
|
||||
await this.platform.driver.guest(selected, ["--prepare", ...(firstInput ? ["--first-input-required"] : [])]);
|
||||
await this.platform.driver.guest(selected, ["--prepare"]);
|
||||
const command = ["rig", "environment", "connect", selected.bindingId, "--generation", generation].map(shellQuote).join(" ");
|
||||
// Binding and generation identifiers keep this command below the short-input bound. Replace the host
|
||||
// shell itself: a viewer failure must close this pane, never leave a shell for later startup input.
|
||||
const sent = await tmux.sendShellCommand(binding.tmuxSession!, `exec ${command}`, undefined, { stageIfLong: true });
|
||||
const sent = await tmux.sendShellCommand(binding.tmuxSession!, command, undefined, { stageIfLong: true, execInScript: true });
|
||||
if (!sent.ok) throw new EnvironmentError("environment_viewer_unconfirmed", "The native execution may have started; its viewer dispatch is unconfirmed");
|
||||
let observed: Record<string, any> | undefined;
|
||||
const deadline = Date.now() + 30_000;
|
||||
@@ -241,8 +220,7 @@ export class EnvironmentStartup {
|
||||
if (observed?.nativeId !== nativeId || observed.generation !== generation || observed.running !== true) throw new EnvironmentError("environment_native_unconfirmed", "The native process identity is not confirmed; nothing was restarted");
|
||||
const receipt = { ...request, activityGrantId: activity.grantId, nativeType: "community:openshell-claude", destinationCwd: posix.join(selected.guestRoot, "work"), nativeState: posix.join(selected.guestRoot, "native"), checkpoint: checkpoint?.checkpointId ?? null, predecessorResourceId: checkpoint?.predecessorResourceId ?? null };
|
||||
this.platform.db.prepare("UPDATE environment_operations SET state = 'confirmed', receipt_json = ? WHERE operation_id = ?").run(JSON.stringify(receipt), operationId);
|
||||
return { ok: true, resumeToken: nativeId, resumeType: "community:openshell-claude", resumeProvenance: "launcher",
|
||||
...(firstInput ? { firstInput: { path: posix.join(selected.guestRoot, firstInput.path), submission: "unconfirmed" as const } } : {}) };
|
||||
return { ok: true, resumeToken: nativeId, resumeType: "community:openshell-claude", resumeProvenance: "launcher" };
|
||||
} finally { rmSync(stage, { recursive: true }); }
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,7 @@ export interface HumanDeliveryReadiness {
|
||||
enabled: boolean;
|
||||
active: boolean;
|
||||
ready: boolean;
|
||||
/** Absent for a person with no connector binding. */
|
||||
connector?: { kind: string; ref: string };
|
||||
connector: { kind: string; ref: string };
|
||||
reason: string;
|
||||
nextAction: string | null;
|
||||
checkedAt: string;
|
||||
@@ -37,14 +36,8 @@ export async function resolveHumanDeliveryReadiness(
|
||||
input: HumanDeliveryReadinessInput,
|
||||
deps: HumanDeliveryReadinessDeps = {},
|
||||
): Promise<HumanDeliveryReadiness> {
|
||||
const primary = input.human.connectorBindings.find((binding) => binding.role === "primary");
|
||||
const primary = input.human.connectorBindings.find((binding) => binding.role === "primary")!;
|
||||
const checkedAt = (deps.now?.() ?? new Date()).toISOString();
|
||||
// A person with no connector binding is reached in the Feed and Steering only: never ready for Slack, and no connector.
|
||||
if (!primary) {
|
||||
const enabled = input.config.enabled;
|
||||
return { state: "not-ready", configured: false, enabled, active: enabled && input.gatewayState === "active", ready: false, checkedAt,
|
||||
reason: "this person has no connector binding, so they're reached in the Feed and Steering only, not Slack", nextAction: null };
|
||||
}
|
||||
const base = {
|
||||
connector: { kind: primary.kind, ref: primary.connectorRef },
|
||||
checkedAt,
|
||||
|
||||
@@ -181,16 +181,14 @@ export function validateHumanFragment(raw: unknown): ValidateResult {
|
||||
if (address !== `${entityId}@${ADDRESS_DOMAIN}`) {
|
||||
return { ok: false, error: `address "${address}" must be the registered ref "${entityId}@${ADDRESS_DOMAIN}" (the <entityId>@external convention)` };
|
||||
}
|
||||
// A person may have no connector binding (left out, or an empty list): they're reached in the Feed and Steering only.
|
||||
if (connectorBindings !== undefined && !Array.isArray(connectorBindings)) {
|
||||
return { ok: false, error: "connectorBindings must be a list (empty, or left out, for a person with no connector)" };
|
||||
if (!Array.isArray(connectorBindings) || connectorBindings.length < 1) {
|
||||
return { ok: false, error: "connectorBindings must be a non-empty list (>=1)" };
|
||||
}
|
||||
const listed: unknown[] = Array.isArray(connectorBindings) ? connectorBindings : [];
|
||||
|
||||
const bindings: HumanConnectorBinding[] = [];
|
||||
let primaryCount = 0;
|
||||
for (let i = 0; i < listed.length; i++) {
|
||||
const b = listed[i];
|
||||
for (let i = 0; i < connectorBindings.length; i++) {
|
||||
const b = connectorBindings[i];
|
||||
if (!isObj(b)) return { ok: false, error: `connectorBindings[${i}] must be a mapping` };
|
||||
const ubk = unknownKey(b, ALLOWED_BINDING_KEYS);
|
||||
if (ubk) return { ok: false, error: `connectorBindings[${i}] has unknown key "${ubk}" — allowed: ${[...ALLOWED_BINDING_KEYS].join(", ")}` };
|
||||
@@ -218,8 +216,8 @@ export function validateHumanFragment(raw: unknown): ValidateResult {
|
||||
if (b.handle !== undefined) { handle = b.handle as string; binding.handle = handle; }
|
||||
bindings.push(binding);
|
||||
}
|
||||
// EXACTLY ONE primary binding per entity with any binding = the default delivery channel.
|
||||
if (bindings.length > 0 && primaryCount !== 1) {
|
||||
// EXACTLY ONE primary binding per entity = the default delivery channel.
|
||||
if (primaryCount !== 1) {
|
||||
return { ok: false, error: `exactly one connectorBinding must have role "primary" (found ${primaryCount}) — it is the default delivery channel` };
|
||||
}
|
||||
// A6 v3 pin-1 (within-fragment): a handle is UNIQUE per kind across this human's bindings —
|
||||
@@ -491,8 +489,7 @@ export interface InflightItem {
|
||||
|
||||
export interface HumanBindingsSummary {
|
||||
count: number;
|
||||
/** Null for a person with no connector binding: they're reached in the Feed and Steering only. */
|
||||
primary: { kind: string; connectorRef: string } | null;
|
||||
primary: { kind: string; connectorRef: string };
|
||||
/** true iff any binding carries a handle (outbound-only otherwise). */
|
||||
inboundResolvable: boolean;
|
||||
}
|
||||
@@ -595,7 +592,7 @@ export function listHumans(home: string = getOpenRigHome()): ListHumansResult {
|
||||
const proj = projectHumans(home);
|
||||
if (!proj.ok) return { ok: false, error: proj.error };
|
||||
const humans: HumanSummary[] = proj.entities.map((e) => {
|
||||
const primary = e.connectorBindings.find((b) => b.role === "primary");
|
||||
const primary = e.connectorBindings.find((b) => b.role === "primary")!;
|
||||
return {
|
||||
entityId: e.entityId,
|
||||
displayName: e.displayName,
|
||||
@@ -605,7 +602,7 @@ export function listHumans(home: string = getOpenRigHome()): ListHumansResult {
|
||||
availability: e.prefs.availability ?? (e.prefs.away === true ? "away" : "available"),
|
||||
bindings: {
|
||||
count: e.connectorBindings.length,
|
||||
primary: primary ? { kind: primary.kind, connectorRef: primary.connectorRef } : null,
|
||||
primary: { kind: primary.kind, connectorRef: primary.connectorRef },
|
||||
inboundResolvable: e.connectorBindings.some((b) => b.handle !== undefined),
|
||||
},
|
||||
fragmentPath: fragmentPathFor(e.entityId, home),
|
||||
|
||||
@@ -375,10 +375,7 @@ export class InboundRouter {
|
||||
const resumedPark = correlated?.state === "in-progress";
|
||||
const askOpen = correlated?.state === "pending" || parkOpen;
|
||||
const closed = correlated?.state === "done" || correlated?.state === "handed-off";
|
||||
// A reply routed to another person (a root recorded with a person as its seat) isn't an answer to the asker: it stays
|
||||
// words for whoever it reached.
|
||||
const settles = route.correlationQitemId === undefined || this.deps.queue.routesToAsker?.(route.correlationQitemId, route.destination) !== false;
|
||||
const answerable = !ev.recoveredThreadReply && !asChannel && route.correlationQitemId !== undefined && settles;
|
||||
const answerable = !ev.recoveredThreadReply && !asChannel && route.correlationQitemId !== undefined;
|
||||
// An absorbed event has its own identity even when the first answer came from a button or Steering.
|
||||
const consumedKey = (answerId: string) => `answer-event:${JSON.stringify([eventId, route.correlationQitemId, who.source, answerId])}`;
|
||||
// A closed ask can hold several returned answers (one per park). A replay finds the one this event returned, or the
|
||||
@@ -419,14 +416,14 @@ export class InboundRouter {
|
||||
summary,
|
||||
body,
|
||||
...(route.correlationQitemId ? { inReplyTo: route.correlationQitemId } : {}),
|
||||
...(askOpen && answerable ? { humanAnswer: true } : {}),
|
||||
...(askOpen && !ev.recoveredThreadReply && !asChannel ? { humanAnswer: true, typedReply: true } : {}),
|
||||
});
|
||||
} catch (e) {
|
||||
this.deps.log?.(`qitem create failed ts=${ts}: ${(e as Error).message}`);
|
||||
return { landed: false, reason: "create_failed" };
|
||||
}
|
||||
let replyResolution: "resolved" | "resolved-uncertain" | "already-resolved" | "not-applicable" | undefined;
|
||||
if (!closed && route.correlationQitemId && this.deps.resolveHumanReply && answerable) {
|
||||
if (!closed && route.correlationQitemId && this.deps.resolveHumanReply && !ev.recoveredThreadReply && !asChannel) {
|
||||
try {
|
||||
// Reusing the durable answer consumed this event's words. Record that before close, but
|
||||
// keep the raw event unmarked until completion so an interrupted close can still retry.
|
||||
@@ -563,10 +560,7 @@ export class InboundRouter {
|
||||
if (live) await acknowledge(CLICK_REPLIES.unmapped);
|
||||
return { status: "ignored", reason: "unmapped-message" };
|
||||
}
|
||||
// A decision parked on this person takes the current question's answer, as a typed reply does: a re-park gets its own.
|
||||
const parkOpen = this.deps.queue.getQitem?.(qitemId)?.state === "blocked"
|
||||
&& this.deps.queue.answersPark?.(qitemId, who.source, action?.action_ts) === true;
|
||||
const existing = parkOpen ? this.deps.queue.getEpisodeAnswer?.(qitemId, who.source) : this.deps.queue.getHumanAnswer?.(qitemId, who.source);
|
||||
const existing = this.deps.queue.getHumanAnswer?.(qitemId, who.source);
|
||||
if (existing) {
|
||||
try {
|
||||
const resolution = await this.deps.resolveHumanReply?.({ qitemId, actorSession: who.source, decision: humanAnswerText(existing.body), messageTs: action?.action_ts });
|
||||
@@ -585,16 +579,10 @@ export class InboundRouter {
|
||||
const recorded = this.deps.recordHumanAnswer?.({ qitemId, actorSession: who.source, ...picked, clickedTs: action?.action_ts });
|
||||
if (!recorded || recorded.status !== "recorded") {
|
||||
const reason = recorded?.reason ?? "answers-unavailable";
|
||||
// Another person's committed answer is the answer: a park on the person resumes with it through the resolver.
|
||||
if (recorded?.answeredBy) {
|
||||
try { await this.deps.resolveHumanReply?.({ qitemId, actorSession: who.source, decision: "", messageTs: action?.action_ts }); }
|
||||
catch (e) { this.deps.log?.(`answer continuation failed qitem=${qitemId}: ${(e as Error).message}`); }
|
||||
}
|
||||
// A closed decision holding this person's clicked answer: typically Slack's redelivery of the click that closed
|
||||
// it, after that click's own confirmation failed to post. It was recorded, so say that.
|
||||
if (live) {
|
||||
await acknowledge(recorded?.answerOnRecord ? CLICK_REPLIES.onRecord
|
||||
: recorded?.answeredBy ? escapeSlackText(`${recorded.answeredBy} already answered this, so your choice wasn't recorded.`)
|
||||
: recorded?.decidedForThem ? escapeSlackText(redactSecrets(decidedForThemReply(recorded.decidedForThem))) : clickNotRecordedReply(reason));
|
||||
}
|
||||
return { status: "ignored", reason };
|
||||
|
||||
@@ -66,6 +66,8 @@ export interface CreateQitemInput {
|
||||
inReplyTo?: string;
|
||||
/** A current typed/button answer, never a reaction or a recovered late message. */
|
||||
humanAnswer?: boolean;
|
||||
/** The answer is a typed thread reply: on a decision parked on its person, its row belongs to the current question. */
|
||||
typedReply?: boolean;
|
||||
}
|
||||
|
||||
/** What the inbound router needs: land a durable qitem, get its id (or throw). */
|
||||
@@ -79,9 +81,6 @@ export interface InboundQueuePort {
|
||||
getEpisodeAnswer?(qitemId: string, actorSession: string): { qitemId: string; body: string } | null;
|
||||
/** Every answer this person returned to the ask, oldest first, so a replay after close can find its own. */
|
||||
listHumanAnswers?(qitemId: string, actorSession: string): Array<{ qitemId: string; body: string }>;
|
||||
/** Whether a reply routed to `destination` can answer the row: routed to a person, only when that person is the row's
|
||||
* asking seat (the sender of an ask to a person). A root recorded with a person as its seat routes to someone else. */
|
||||
routesToAsker?(qitemId: string, destination: string): boolean;
|
||||
}
|
||||
|
||||
/** What the outbound driver needs: the current human-alert set, full items included. */
|
||||
@@ -225,13 +224,11 @@ export function makeQueuePorts(
|
||||
const people = registeredPeople((opts.loadHumanRegistry ?? (() => loadHumanRegistry()))());
|
||||
if (!people) return null;
|
||||
const channels = (opts.sharedChannels ?? (() => sharedChannelAddresses()))();
|
||||
// A person with no connector binding is reached in the Feed and Steering only, so nothing posts for them.
|
||||
const slackPeople = people.filter((person) => person.connectorBindings.length > 0);
|
||||
const episodes = queueRepo.transitionLog.activeOwnerNotificationEpisodes(includePosted);
|
||||
return episodes.flatMap(({ qitemId, transition, posted }) => {
|
||||
const row = queueRepo.getById(qitemId);
|
||||
if (!row) return [];
|
||||
const item = project(row, transition, slackPeople, channels);
|
||||
const item = project(row, transition, people, channels);
|
||||
return item ? [{ item, posted }] : [];
|
||||
});
|
||||
};
|
||||
@@ -244,15 +241,10 @@ export function makeQueuePorts(
|
||||
},
|
||||
getEpisodeAnswer: (qitemId, actorSession) => queueRepo.currentEpisodeAnswer(qitemId, actorSession),
|
||||
listHumanAnswers: (qitemId, actorSession) => queueRepo.listHumanAnswers(qitemId, actorSession),
|
||||
routesToAsker: (qitemId, destination) => {
|
||||
const item = queueRepo.getById(qitemId);
|
||||
return !queueRepo.isPersonAddress(destination) || (item !== null && destination === item.sourceSession);
|
||||
},
|
||||
async createQitem(input: CreateQitemInput): Promise<string> {
|
||||
const ask = input.inReplyTo ? queueRepo.getById(input.inReplyTo) : null;
|
||||
// The asked person's answer, or another registered person's: either takes the ask's one shared answer identity.
|
||||
const directAnswer = input.humanAnswer && ask && ask.humanIntent !== "update" && queueRepo.isPersonAddress(ask.destinationSession)
|
||||
&& queueRepo.answersAsk(ask, input.source) && queueRepo.isPersonAddress(input.source);
|
||||
const directAnswer = input.humanAnswer && ask && ask.destinationSession === input.source && ask.humanIntent !== "update"
|
||||
&& queueRepo.isPersonAddress(input.source);
|
||||
const createInput = {
|
||||
qitemId: input.qitemId,
|
||||
sourceSession: input.source,
|
||||
@@ -264,15 +256,11 @@ export function makeQueuePorts(
|
||||
inboundReplyTo: input.inReplyTo ?? null,
|
||||
gatewayInbound: true,
|
||||
};
|
||||
let created = directAnswer && input.inReplyTo
|
||||
const created = directAnswer && input.inReplyTo
|
||||
? await queueRepo.createHumanAnswer({ ...createInput, inboundReplyTo: input.inReplyTo,
|
||||
// A parked decision's answer, typed or clicked, belongs to its current question, so a re-park gets its own.
|
||||
...(ask?.state === "blocked" ? { episodeKey: queueRepo.currentHumanRequiredEpisode(input.inReplyTo)?.key } : {}) })
|
||||
// A parked decision's answer belongs to its current question, so a re-park gets its own.
|
||||
...(input.typedReply && ask?.state === "blocked" ? { episodeKey: queueRepo.currentHumanRequiredEpisode(input.inReplyTo)?.key } : {}) })
|
||||
: await queueRepo.create(createInput);
|
||||
// Another registered person's answer already settled the ask: these words still reach the agent, as correspondence.
|
||||
if (directAnswer && created.qitemId !== input.inReplyTo && created.sourceSession !== input.source) {
|
||||
created = await queueRepo.create({ ...createInput, tags: createInput.tags.filter((tag) => tag !== "human-answer") });
|
||||
}
|
||||
return (created as unknown as { qitemId: string }).qitemId;
|
||||
},
|
||||
async listHumanAlerts(filter: AlertFilterOpts): Promise<QueueItem[]> {
|
||||
|
||||
@@ -41,7 +41,6 @@ import { startSocketInbound, type SocketInboundHandle, type WsLike } from "./soc
|
||||
import { HUMAN_ALERT_TAG, loadHumanRegistry, resolveSlackHandle } from "../human-registry.js";
|
||||
import { sharedChannelAddresses, sharedChannelFor } from "../participants.js";
|
||||
import { hasLiveHumanGate, isBlockerLive, type QueueRepository } from "../../queue-repository.js";
|
||||
import { humanAnswerText } from "../../human-questions.js";
|
||||
import { formatReplyToChoice, formatReplyToThreadMissing, REPLY_TO_CHOICE_ACTOR, REPLY_TO_CHAIN_LIMIT, type ReplyToChoice } from "../../reply-to-choice.js";
|
||||
import { parseSessionName } from "../../session-name.js";
|
||||
import type { FetchImpl } from "./slack-api.js";
|
||||
@@ -100,29 +99,25 @@ export function makeHumanReplyResolver(
|
||||
const source = queueRepo.getById(input.qitemId);
|
||||
if (source?.humanIntent === "update") return "not-applicable";
|
||||
if (!contract) return "not-applicable";
|
||||
// Any registered person's reply resumes a question parked on another: one trusted workspace, recorded as them on that
|
||||
// person's ask (the write contract names both).
|
||||
// A different person's reply to a question parked on someone else already reached the agent as their own message
|
||||
// (the inbound row, linked to the question). It doesn't resume the question, which stays parked on its person.
|
||||
if (source?.state === "blocked" && queueRepo.askedSomeoneElse(input.actorSession, source.blockedOn)) return "not-applicable";
|
||||
const uncertainDecision = `${input.decision}\n\n---\nOpenRig timing context (Slack reply ${JSON.stringify([input.actorSession, input.messageTs ?? null])}): This reply resumed the question, but its ordering against the posted question could not be confirmed. Resuming is not approval; read the person's words in context.`;
|
||||
// An acknowledgement retry must not resolve again. Match this reply's durable
|
||||
// context, not an older episode's generic resolved marker.
|
||||
if (slackMicros(input.messageTs) !== null && queueRepo.transitionLog.listForQitem(input.qitemId)
|
||||
// A different person's resume carries their attribution after the timing context, so match that too.
|
||||
.some((transition) => transition.ownerNotificationKind === "human-decision-resolved"
|
||||
&& (transition.transitionNote === uncertainDecision || transition.transitionNote?.startsWith(`${uncertainDecision}; by `) === true))) {
|
||||
.some((transition) => transition.ownerNotificationKind === "human-decision-resolved" && transition.transitionNote === uncertainDecision)) {
|
||||
return "resolved-uncertain";
|
||||
}
|
||||
let uncertain = false;
|
||||
let parkedOnAsked = false;
|
||||
let episodeKey: string | undefined;
|
||||
let digestReceipt = false;
|
||||
// A park on a person by any address (a registered person's own address, as their Slack rows carry it, or a
|
||||
// legacy human seat) answers only from a reply written after its current question posted.
|
||||
if (source?.state === "blocked" && source.blockedOn && queueRepo.isPersonAddress(source.blockedOn)) {
|
||||
parkedOnAsked = queueRepo.isPersonAddress(source.destinationSession) && queueRepo.answersAsk(source, input.actorSession);
|
||||
parkedOnAsked = source.destinationSession === input.actorSession;
|
||||
const episode = queueRepo.currentHumanRequiredEpisode(input.qitemId);
|
||||
if (!episode) return "not-applicable";
|
||||
const { key, receipt } = episode;
|
||||
episodeKey = key;
|
||||
// A final receipt attests delivery even when reconciliation lost its timestamp.
|
||||
// Without one, ONLY a unique exact current-episode primary proves anything.
|
||||
const primary = !receipt && home ? pendingPrimaryReceipt(home, input.qitemId, key) : null;
|
||||
@@ -131,22 +126,9 @@ export function makeHumanReplyResolver(
|
||||
const replied = slackMicros(input.messageTs);
|
||||
if (posted !== null && replied !== null && replied <= posted) return "not-applicable";
|
||||
uncertain = Boolean(receipt?.digest) || posted === null || replied === null;
|
||||
digestReceipt = Boolean(receipt?.digest);
|
||||
}
|
||||
|
||||
try {
|
||||
// One answer per question: another person's committed return for this question is the answer. Resume with it, as
|
||||
// theirs; this reply stays the conversation it already became.
|
||||
const winner = episodeKey ? queueRepo.committedAnswerFromAnother(input.qitemId, input.actorSession, episodeKey) : null;
|
||||
if (winner) {
|
||||
// Its typed words are the question's answer, stored as its own resume would: it was taken as the answer only after
|
||||
// the question posted, so only a digest receipt leaves its order uncertain.
|
||||
const words = humanAnswerText(winner.body);
|
||||
const stores = queueRepo.isPersonAddress(source!.destinationSession) && queueRepo.answersAsk(source!, winner.sourceSession) && !digestReceipt;
|
||||
await contract.act({ verb: "resolve", qitemId: input.qitemId, actorSession: winner.sourceSession, decision: words,
|
||||
...(stores ? { answerText: words } : {}) });
|
||||
return "not-applicable";
|
||||
}
|
||||
// A question parked on the person it asked takes their typed reply as its answer, stored in the resume's own
|
||||
// transaction. A click's return stores nothing here, and a reply whose order is uncertain stays words in context.
|
||||
await contract.act({ verb: "resolve", ...input, decision: uncertain ? uncertainDecision : input.decision,
|
||||
|
||||
@@ -31,10 +31,9 @@ export type HumanAnswers = Record<string, string | TypedHumanReply>;
|
||||
export type RecordHumanAnswerResult =
|
||||
| { status: "recorded"; answers: HumanAnswers; complete: boolean; questions: HumanQuestion[] }
|
||||
/** answerOnRecord: the decision is no longer pending, and this asked human's clicked answer is the one it holds.
|
||||
* decidedForThem: it was decided on this asked human's behalf, inside their mode's decision room.
|
||||
* answeredBy: another person's committed answer is the ask's answer; nothing of this person's was recorded. */
|
||||
* decidedForThem: it was decided on this asked human's behalf, inside their mode's decision room. */
|
||||
| { status: "not-applicable"; reason: string; answerOnRecord?: true;
|
||||
decidedForThem?: { decidedBy: string; modeName: string; chose: string; reversible: boolean }; answeredBy?: string };
|
||||
decidedForThem?: { decidedBy: string; modeName: string; chose: string; reversible: boolean } };
|
||||
|
||||
export const MAX_HUMAN_QUESTIONS = 4;
|
||||
export const MIN_OPTIONS = 2;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import nodePath from "node:path";
|
||||
import os from "node:os";
|
||||
import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
import type { RigRepository } from "./rig-repository.js";
|
||||
import type { BootstrapOrchestrator } from "./bootstrap-orchestrator.js";
|
||||
@@ -45,11 +45,12 @@ export interface KernelBootDeps {
|
||||
* inject a fixture root). Production callsite resolves to the
|
||||
* packaged `packages/daemon/specs/` location. */
|
||||
specsDir: string;
|
||||
/** The workspace root (the resolved workspace.root setting; tests inject a fixture path). A new kernel's members run
|
||||
* in its `kernel` folder, or here when that can't be prepared (`prepareNewKernelCwd`), and that's passed to
|
||||
* BootstrapOrchestrator.bootstrap as its cwdOverride. Kernel members run against the operator's environment, not the
|
||||
* daemon installation tree: without an override, the bootstrap path refuses with `cwd is inside the OpenRig
|
||||
* installation`. */
|
||||
/** cwdOverride passed to BootstrapOrchestrator.bootstrap. Kernel
|
||||
* members run against the operator's environment, not the daemon
|
||||
* installation tree — without this override, the bootstrap path
|
||||
* refuses with `cwd is inside the OpenRig installation`. Production
|
||||
* callsite passes the resolved workspace.root setting; tests inject
|
||||
* a fixture path. */
|
||||
cwdOverride: string;
|
||||
/** Runtime auth probe — defaults to live shellouts; tests inject. */
|
||||
probeRuntimes?: () => Promise<RuntimeProbeResult>;
|
||||
@@ -150,7 +151,7 @@ export async function bootKernelIfNeeded(deps: KernelBootDeps): Promise<KernelBo
|
||||
sourceRef: specPath,
|
||||
sourceKind: "rig_spec",
|
||||
autoApprove: true,
|
||||
cwdOverride: prepareNewKernelCwd(deps.cwdOverride, log),
|
||||
cwdOverride: deps.cwdOverride,
|
||||
});
|
||||
tracker.startBooting(variant, bootstrapPromise, expectedKernelSeats(specPath));
|
||||
return tracker;
|
||||
@@ -226,19 +227,6 @@ export function classifyManagedKernel(rigRepo: RigRepository, sessionRegistry: S
|
||||
}
|
||||
}
|
||||
|
||||
/** The folder a newly created kernel works in: `<workspace.root>/kernel`, beside the projects (`<workspace.root>/projects`
|
||||
* by default) rather than above them, so the guidance a kernel seat writes in its folder (a Claude seat's CLAUDE.md)
|
||||
* isn't read by the project seats below. It's made here, inside an existing workspace root. When it can't be (a file is
|
||||
* in the way, or there's no workspace root), the kernel works in the workspace root as before, and nothing is refused.
|
||||
* An existing kernel keeps the folders it was created with. */
|
||||
export function prepareNewKernelCwd(workspaceRoot: string, log: (level: "warn", message: string) => void = defaultLog): string {
|
||||
const cwd = nodePath.join(workspaceRoot, "kernel");
|
||||
try { mkdirSync(cwd); } catch { /* it's already there, or the workspace root isn't */ }
|
||||
try { if (statSync(cwd).isDirectory()) return cwd; } catch { /* nothing there */ }
|
||||
log("warn", `kernel-boot: couldn't prepare ${cwd} for the new kernel; it works in ${workspaceRoot}`);
|
||||
return workspaceRoot;
|
||||
}
|
||||
|
||||
/** True when a rig named `kernel` already exists in the DB. */
|
||||
export function kernelAlreadyManaged(rigRepo: RigRepository): boolean {
|
||||
const rigs = rigRepo.listRigs();
|
||||
|
||||
@@ -2,7 +2,6 @@ import type Database from "better-sqlite3";
|
||||
import type { TmuxAdapter } from "../adapters/tmux.js";
|
||||
import { resolveCurrentSessionAddress } from "./session-registry.js";
|
||||
import { assessNativeResumeProbe } from "./native-resume-probe.js";
|
||||
import { verifyCodexPaneProcess, type NativeProcessLister } from "./native-process-lineage.js";
|
||||
|
||||
export interface HookReviewObservation {
|
||||
destination: string;
|
||||
@@ -15,8 +14,7 @@ export interface HookReviewObservation {
|
||||
* startup error, generic attention flag or scrollback can start an alert. */
|
||||
export function makeKernelHookReviewReader(deps: {
|
||||
db: Database.Database;
|
||||
tmux: Pick<TmuxAdapter, "listPanes" | "readAllPaneProcesses" | "capturePaneScreen" | "getPanePid">;
|
||||
listProcesses?: NativeProcessLister;
|
||||
tmux: Pick<TmuxAdapter, "listPanes" | "readAllPaneProcesses" | "capturePaneScreen">;
|
||||
now?: () => Date;
|
||||
}): () => Promise<HookReviewObservation[]> {
|
||||
return async () => {
|
||||
@@ -41,24 +39,17 @@ export function makeKernelHookReviewReader(deps: {
|
||||
if (before.some(p => p.id === row.pane)) {
|
||||
const process = (await deps.tmux.readAllPaneProcesses())?.get(row.pane);
|
||||
pid = process?.pid ?? null;
|
||||
if (!pid || process?.dead !== false) throw new Error("native pane is not positively live Codex");
|
||||
const proofInput = { target: row.pane, tmux: deps.tmux, listProcesses: deps.listProcesses };
|
||||
const native = process.command === "codex" ? null : await verifyCodexPaneProcess(proofInput);
|
||||
if (process.command !== "codex" && native?.panePid !== pid) throw new Error("native pane is not positively live Codex");
|
||||
if (!pid || process?.dead !== false || process.command !== "codex") throw new Error("native pane is not positively live Codex");
|
||||
const screen = await deps.tmux.capturePaneScreen(row.pane);
|
||||
// A stable launcher PID alone cannot exclude a replaced Codex child during capture.
|
||||
const currentNative = native ? await verifyCodexPaneProcess(proofInput) : null;
|
||||
const after = await deps.tmux.listPanes(row.destination, true);
|
||||
const current = deps.db.prepare("SELECT id, tmux_session, tmux_pane FROM bindings WHERE node_id = ?").get(row.nodeId) as
|
||||
{ id: string; tmux_session: string; tmux_pane: string } | undefined;
|
||||
const session = resolveCurrentSessionAddress(deps.db, row.destination);
|
||||
const currentProcess = (await deps.tmux.readAllPaneProcesses())?.get(row.pane);
|
||||
if (currentProcess?.pid === pid && currentProcess.dead === false && currentProcess.command === process.command
|
||||
&& (!native || (currentNative?.panePid === pid && currentNative.fingerprint === native.fingerprint)) && after.some(p => p.id === row.pane)
|
||||
if (currentProcess?.pid === pid && currentProcess.dead === false && currentProcess.command === "codex" && after.some(p => p.id === row.pane)
|
||||
&& current?.id === row.bindingId && current.tmux_session === row.destination && current.tmux_pane === row.pane
|
||||
&& session?.sessionId === row.sessionId && session.nodeId === row.nodeId && screen !== null) {
|
||||
const probe = assessNativeResumeProbe({ runtime: "codex", paneCommand: currentProcess.command, paneContent: screen,
|
||||
codexProcessVerified: native !== null });
|
||||
const probe = assessNativeResumeProbe({ runtime: "codex", paneCommand: currentProcess.command, paneContent: screen });
|
||||
state = probe.code === "hook_trust_gate" ? "blocked" : probe.status === "resumed" ? "clear" : "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
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";
|
||||
import { isTypedGateBlocker, type QueueRepository, type QueueItem } from "../queue-repository.js";
|
||||
import type { PersistedEvent } from "../types.js";
|
||||
import {
|
||||
MissionControlActionLog,
|
||||
@@ -50,11 +50,12 @@ import {
|
||||
} from "./mission-control-action-log.js";
|
||||
import { isHumanSeatSessionRef } from "../session-name.js";
|
||||
import { humanAnswerText, formatHumanAnswers, unansweredQuestions, type HumanAnswers } from "../human-questions.js";
|
||||
import { resolveDefaultPerson } from "../gateway/participants.js";
|
||||
import { runnerPermissionOrigin } from "../runner-permissions.js";
|
||||
|
||||
/** The terminal's act, which names no responder: the generic human seat the web UI also records, read as explicitly
|
||||
* unattributed, never a person. */
|
||||
export const UNNAMED_PRIMARY_USER = UNATTRIBUTED_ACTOR;
|
||||
/** The primary user when several people are registered and none is the default, or when no one is registered and the
|
||||
* claimed actor is a shared channel: the generic human seat the web UI also records. */
|
||||
export const UNNAMED_PRIMARY_USER = "human@host";
|
||||
/** Verbs that settle what a person was asked. */
|
||||
const SETTLING_VERBS = new Set<MissionControlVerb>(["approve", "deny", "resolve", "answer"]);
|
||||
|
||||
@@ -95,10 +96,9 @@ export interface MissionControlActionInput {
|
||||
optionId?: string;
|
||||
/** Operator-supplied audit context. */
|
||||
auditNotes?: Record<string, unknown>;
|
||||
/** `primary-user`: the person at the terminal acts (the TUI), whoever was asked. The terminal's claimed actor is the
|
||||
* Feed's recipient-derived value, not who typed, so the act is recorded as human@host, explicitly unattributed, never
|
||||
* 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. */
|
||||
/** `primary-user`: the person at the terminal acts (the TUI), whoever was asked. The actor recorded is the primary user
|
||||
* (the default person), never the claimed actor, and the audit notes say whose ask it was. Only a request with no
|
||||
* transport identity carries it. */
|
||||
authority?: "primary-user";
|
||||
/**
|
||||
* For handoff: opt-in best-effort wake. Default true (PL-004 Phase A R1
|
||||
@@ -432,8 +432,8 @@ export class MissionControlWriteContract {
|
||||
{ qitemId: input.qitemId, state: source.state, blockedOn: source.blockedOn },
|
||||
);
|
||||
}
|
||||
// Only a person was asked; a typed gate has no asked person. Another registered person settles it as themselves, and
|
||||
// the record names the person asked.
|
||||
// Only a person was asked; a typed gate has no asked person. asActor refuses a registered person settling another
|
||||
// person's park (answer_not_recorded), except the primary user at the terminal.
|
||||
const acting = this.asActor(input, humanPark ? source.blockedOn : null);
|
||||
input = acting.input;
|
||||
|
||||
@@ -538,9 +538,9 @@ export class MissionControlWriteContract {
|
||||
throw new MissionControlWriteContractError("qitem_not_found", `qitem ${input.qitemId} not found`, { qitemId: input.qitemId });
|
||||
}
|
||||
const primaryUser = input.authority === "primary-user";
|
||||
// Any registered person answers any ask, recorded as themselves; a runner's request keeps its own notes.
|
||||
// A runner's permission request takes any registered person's answer, so it isn't "someone else's" to settle.
|
||||
input = this.asActor(input, runnerPermissionOrigin(ask) && this.queueRepo.answersAsk(ask, input.actorSession) ? null : ask.destinationSession).input;
|
||||
// The asked person or another registered person answers, or the person at the terminal does on anyone's ask.
|
||||
// The asked person answers, or the primary user does on anyone's ask (and any registered person on a runner's request).
|
||||
if ((!this.queueRepo.answersAsk(ask, input.actorSession) && !primaryUser) || ask.humanIntent === "update" || !this.queueRepo.isPersonAddress(ask.destinationSession)) {
|
||||
throw new MissionControlWriteContractError("answer_not_recorded",
|
||||
`qitem ${input.qitemId} isn't an ask to ${input.actorSession}, so ${input.actorSession} can't answer it.`, { qitemId: input.qitemId });
|
||||
@@ -557,10 +557,6 @@ export class MissionControlWriteContract {
|
||||
return this.recordAnswer(input, before, decision, { outcome: "answered", unanswered: [],
|
||||
message: `Already answered; the original answer went to ${existing.destinationSession} as ${existing.qitemId}. No new reply was sent.` }, existing.qitemId);
|
||||
}
|
||||
// One answer per ask: another person's committed return is the answer. Before anything of this person's is recorded,
|
||||
// finish settling the ask with it and say who answered; nothing of theirs is recorded or sent.
|
||||
const other = this.queueRepo.committedAnswerFromAnother(input.qitemId, input.actorSession);
|
||||
if (other) this.answeredByAnother(input, other, primaryUser);
|
||||
if (ask.state !== "pending") {
|
||||
throw new MissionControlWriteContractError("qitem_already_terminal",
|
||||
`qitem ${input.qitemId} is ${ask.state}, not waiting on an answer (it may already be answered), so nothing was recorded or sent.`,
|
||||
@@ -598,16 +594,12 @@ export class MissionControlWriteContract {
|
||||
}
|
||||
// The asker reads who answered, and whose ask it was: never the asked person when someone else answered.
|
||||
const forAsked = ask.destinationSession !== input.actorSession ? ask.destinationSession : null;
|
||||
const unattributed = primaryUser && input.actorSession === UNNAMED_PRIMARY_USER;
|
||||
const answeredBy = !forAsked ? `Answered by ${input.actorSession}`
|
||||
: unattributed ? `Answered at the terminal by someone OpenRig can't name (${UNNAMED_PRIMARY_USER}), unattributed, on ${forAsked}'s ask`
|
||||
: `Answered by ${input.actorSession}, ${primaryUser ? "the primary user at the terminal" : "a registered person"}, on ${forAsked}'s ask`;
|
||||
let returned: QueueItem;
|
||||
try {
|
||||
returned = await this.queueRepo.createHumanAnswer({
|
||||
sourceSession: input.actorSession, destinationSession: ask.sourceSession,
|
||||
body: `${reply}\n${answeredBy} in OpenRig.`,
|
||||
summary: `${forAsked && unattributed ? `Unattributed (${UNNAMED_PRIMARY_USER})` : input.actorSession} answered${forAsked ? ` ${forAsked}'s ask` : ""}: ${ask.summary ?? input.qitemId}`,
|
||||
body: `${reply}\nAnswered by ${input.actorSession}${forAsked ? `, the primary user at the terminal, on ${forAsked}'s ask` : ""} in OpenRig.`,
|
||||
summary: `${input.actorSession} answered${forAsked ? ` ${forAsked}'s ask` : ""}: ${ask.summary ?? input.qitemId}`,
|
||||
tags: ["human-answer", ...(ask.tags ?? []).filter((t) => /^(project|mission|slice):/.test(t))],
|
||||
inboundReplyTo: input.qitemId, ...(input.notify === false ? { nudge: false } : {}),
|
||||
});
|
||||
@@ -618,8 +610,6 @@ export class MissionControlWriteContract {
|
||||
{ qitemId: input.qitemId });
|
||||
}
|
||||
const replyId = returned.qitemId;
|
||||
// Two people answering at once: one answer returns, and the other is told whose it was.
|
||||
if (replyId !== input.qitemId && returned.sourceSession !== input.actorSession) this.answeredByAnother(input, returned, primaryUser);
|
||||
// A runner's permission ask is its own answer record (no reply row comes back), so typed words are the person's own.
|
||||
const decision = chose || resume
|
||||
? formatHumanAnswers(questions, this.queueRepo.getById(input.qitemId)?.humanAnswers ?? {}).join("; ")
|
||||
@@ -633,16 +623,6 @@ export class MissionControlWriteContract {
|
||||
return this.recordAnswer(input, before, decision, { outcome: "answered", unanswered: [] }, replyId);
|
||||
}
|
||||
|
||||
/** Another person's committed return already answers the ask: settle the ask with it if it's still pending (the
|
||||
* repository closes it as theirs), then tell this person who answered and where it went. */
|
||||
private answeredByAnother(input: MissionControlActionInput, other: QueueItem, primaryUser: boolean): never {
|
||||
const ask = this.queueRepo.getById(input.qitemId);
|
||||
if (ask?.state === "pending") this.queueRepo.resolveDirectHumanReply({ qitemId: input.qitemId, actorSession: input.actorSession, decision: "", primaryUser });
|
||||
throw new MissionControlWriteContractError("qitem_already_terminal",
|
||||
`${other.sourceSession} already answered qitem ${input.qitemId}; their answer went to ${other.destinationSession} as ${other.qitemId}. ${input.actorSession}'s answer wasn't recorded or sent.`,
|
||||
{ qitemId: input.qitemId, answeredBy: other.sourceSession, replyQitemId: other.qitemId });
|
||||
}
|
||||
|
||||
/** The audit row and event for one answer (its queue writes are the repository's own). */
|
||||
private recordAnswer(input: MissionControlActionInput, before: Record<string, unknown>, reason: string,
|
||||
answer: NonNullable<MissionControlActionResult["answer"]>, createdQitemId: string | null = null): MissionControlActionResult {
|
||||
@@ -663,11 +643,11 @@ export class MissionControlWriteContract {
|
||||
}
|
||||
|
||||
/**
|
||||
* Who acts, and on whose ask. One trusted workspace, equal authority: any registered person settles an ask that went to
|
||||
* another, recorded as themselves, and the transition and audit notes name the person asked. With primary-user
|
||||
* authority the terminal names no responder (its claimed actor is the Feed's recipient-derived value), so the act is
|
||||
* human@host, explicitly unattributed, never the default person or the claim, whether or not anyone is registered. A
|
||||
* shared channel never acts on a row, for any verb: its
|
||||
* Who acts, and on whose ask. With primary-user authority the actor is the primary user: the default person, or, with
|
||||
* several people and none chosen, the generic human seat. With no one registered it's the claimed actor, as before,
|
||||
* unless that's a shared channel: then it's the generic human seat. The audit notes record the authority and the person
|
||||
* asked, and the transition says so when they differ. Without it, a registered person can't settle what was asked of
|
||||
* another registered person: that's someone else's answer. A shared channel never acts on a row, for any verb: its
|
||||
* messages are correspondence, so the asking seat or the person at the terminal acts on a channel's row.
|
||||
*/
|
||||
private asActor(input: MissionControlActionInput, asked: string | null): { input: MissionControlActionInput; note: string } {
|
||||
@@ -679,16 +659,19 @@ export class MissionControlWriteContract {
|
||||
{ qitemId: input.qitemId, channel: input.actorSession });
|
||||
}
|
||||
const someoneElse = SETTLING_VERBS.has(input.verb) ? this.queueRepo.askedSomeoneElse(input.actorSession, asked) : null;
|
||||
if (!someoneElse) return { input, note: "" };
|
||||
return { input: { ...input, auditNotes: { ...input.auditNotes, asked: someoneElse } },
|
||||
note: `; by ${input.actorSession}, a registered person, on ${someoneElse}'s ask` };
|
||||
if (someoneElse) {
|
||||
throw new MissionControlWriteContractError("answer_not_recorded",
|
||||
`qitem ${input.qitemId} was asked of ${someoneElse}, not ${input.actorSession}, so ${input.actorSession} can't settle it.`,
|
||||
{ qitemId: input.qitemId, asked: someoneElse });
|
||||
}
|
||||
return { input, note: "" };
|
||||
}
|
||||
// The terminal names no responder, whatever it claims and whoever is registered: the act is unattributed.
|
||||
const actor = UNNAMED_PRIMARY_USER;
|
||||
const registry = this.queueRepo.humanRegistry();
|
||||
const people = registry.ok ? registry.entities : [];
|
||||
const actor = people.length ? resolveDefaultPerson(people).person?.address ?? UNNAMED_PRIMARY_USER
|
||||
: channel ? UNNAMED_PRIMARY_USER : input.actorSession;
|
||||
const auditNotes = { ...input.auditNotes, authority: "primary-user", asked };
|
||||
const note = !asked || asked === actor ? ""
|
||||
: actor === UNNAMED_PRIMARY_USER ? `; by someone at the terminal OpenRig can't name (${UNNAMED_PRIMARY_USER}), unattributed, on ${asked}'s ask`
|
||||
: `; by ${actor}, the primary user at the terminal, on ${asked}'s ask`;
|
||||
const note = asked && asked !== actor ? `; by ${actor}, the primary user at the terminal, on ${asked}'s ask` : "";
|
||||
return { input: { ...input, actorSession: actor, auditNotes }, note };
|
||||
}
|
||||
|
||||
|
||||
@@ -994,8 +994,7 @@ 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 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;
|
||||
const file = entry.cwd ? seatGuidanceFile(runtime, entry.cwd, new RigRepository(db).getRigClaudeManagedBlockFile(rigId)) : null;
|
||||
let content: string | null = null;
|
||||
if (file) {
|
||||
try { content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null; } catch { content = null; }
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
} from "./queue-wake-repository.js";
|
||||
import { WatchdogJobsRepository } from "./watchdog-jobs-repository.js";
|
||||
import { armQueueWait, backOffQueueWait, refreshQueueWaits, evaluateQueueWait, retargetQueueWait, isQueueWait } from "./queue-wait-backoff.js";
|
||||
import { parseHumanQuestions, unansweredQuestions, describeTypedReplyPlacement, formatHumanAnswers, humanAnswerText, type HumanQuestion, type HumanAnswers, type TypedHumanReply, type RecordHumanAnswerResult } from "./human-questions.js";
|
||||
import { parseHumanQuestions, unansweredQuestions, describeTypedReplyPlacement, type HumanQuestion, type HumanAnswers, type TypedHumanReply, type RecordHumanAnswerResult } from "./human-questions.js";
|
||||
import { slackMicros } from "./gateway/slack/state-store.js";
|
||||
|
||||
/** A direct ask's answer row ID: from the ask, or from the ask and the park episode its answer belongs to. */
|
||||
@@ -117,9 +117,6 @@ const FALLBACK_ACTOR = "system:queue-fallback";
|
||||
* external condition that is NOT a qitem and NOT a human seat; these prefixes make such a gate a
|
||||
* first-class, compact-visible, downstream-classifiable blocker (the ruling detail rides a transition). */
|
||||
export const TYPED_GATE_BLOCKER_PREFIXES = ["fold:", "auth:", "external:"] as const;
|
||||
|
||||
/** The actor a terminal act is recorded as when it names no responder: explicitly unattributed, never a person. */
|
||||
export const UNATTRIBUTED_ACTOR = "human@host";
|
||||
export function typedGateBlockerPrefix(value: string): string | null {
|
||||
return TYPED_GATE_BLOCKER_PREFIXES.find((p) => value.startsWith(p)) ?? null;
|
||||
}
|
||||
@@ -542,9 +539,6 @@ export interface QueueListOptions {
|
||||
handedOffFrom?: string;
|
||||
/** Items updated at or after this time: an ISO-8601 UTC timestamp in the stored form (toISOString). */
|
||||
updatedSince?: string;
|
||||
/** Pick and order rows by their latest update instead of their creation, so a bounded window of closed items holds
|
||||
* the ones closed most recently. */
|
||||
sortByUpdated?: boolean;
|
||||
}
|
||||
|
||||
/** Adds the handoff-successor and updated-since filters both list paths share. */
|
||||
@@ -1853,9 +1847,6 @@ export class QueueRepository {
|
||||
const qitemId = humanAnswerId(input.inboundReplyTo, episodeKey);
|
||||
const existing = episodeKey ? this.getById(qitemId) : this.getHumanAnswer(input.inboundReplyTo, input.sourceSession);
|
||||
if (existing) return existing;
|
||||
// One reply per ask: another person's answer already returned is the answer, so it comes back, and the caller says so.
|
||||
const answered = this.getById(qitemId);
|
||||
if (answered) return answered;
|
||||
return this.create({ ...create, qitemId, tags: [...new Set([...(input.tags ?? []), "human-answer"])] });
|
||||
}
|
||||
|
||||
@@ -2177,11 +2168,11 @@ export class QueueRepository {
|
||||
return registry.ok && resolveRegisteredHumanAddress(destination, registry.entities) !== null;
|
||||
}
|
||||
|
||||
/** Who may answer an ask: the asked person, and any other registered person in this workspace, who's recorded as
|
||||
* themselves with the asked person kept (one trusted workspace, equal authority). A shared channel never answers. */
|
||||
/** Who may answer an ask: the asked person; and on a runner's permission request, any registered person, since a
|
||||
* person's yes counts however it arrives there. A shared channel never answers. */
|
||||
answersAsk(item: QueueItem, actorSession: string): boolean {
|
||||
if (item.destinationSession === actorSession) return true;
|
||||
if (this.isSharedChannel(actorSession)) return false;
|
||||
if (!runnerPermissionOrigin(item) || this.isSharedChannel(actorSession)) return false;
|
||||
const registry = this.loadHumanRegistryFn();
|
||||
return registry.ok && resolveRegisteredHumanAddress(actorSession, registry.entities) !== null;
|
||||
}
|
||||
@@ -2196,8 +2187,8 @@ export class QueueRepository {
|
||||
return this.loadHumanRegistryFn();
|
||||
}
|
||||
|
||||
/** The asked person, when a different registered person is acting on what was asked of them, so the record names both;
|
||||
* else null. An agent, the asked person under another spelling, and an ask to no registered person aren't someone else. */
|
||||
/** The asked person, when a different registered person is acting on what was asked of them; else null. An agent, the
|
||||
* asked person under another spelling, and an ask to no registered person aren't someone else. */
|
||||
askedSomeoneElse(actorSession: string, askedSession: string | null | undefined): string | null {
|
||||
const registry = this.loadHumanRegistryFn();
|
||||
if (!registry.ok) return null;
|
||||
@@ -3802,56 +3793,29 @@ export class QueueRepository {
|
||||
* button set is already final; its resolve continuation must not overwrite those answers.
|
||||
*/
|
||||
resolveDirectHumanReply(input: { qitemId: string; actorSession: string; decision: string; primaryUser?: boolean }): boolean {
|
||||
let mine = true;
|
||||
const result = this.db.transaction(() => {
|
||||
const item = this.getById(input.qitemId);
|
||||
if (item?.state !== "pending" || item.humanIntent === "update"
|
||||
|| (!this.answersAsk(item, input.actorSession) && !input.primaryUser)
|
||||
|| !this.isPersonAddress(item.destinationSession)) return null;
|
||||
// One answer per ask: another person's committed return is the answer, so the ask closes as theirs, with their words,
|
||||
// and this caller's don't settle it (false tells the caller so).
|
||||
const winner = this.committedAnswerFromAnother(item.qitemId, input.actorSession);
|
||||
if (winner) {
|
||||
mine = false;
|
||||
const questions = item.humanQuestions ?? [];
|
||||
const decision = questions.length && unansweredQuestions(questions, item.humanAnswers ?? {}).length === 0
|
||||
? formatHumanAnswers(questions, item.humanAnswers ?? {}).join("; ") : humanAnswerText(winner.body);
|
||||
return this.closeDirectAsk(item, winner.sourceSession, decision, !this.answersAsk(item, winner.sourceSession));
|
||||
}
|
||||
return this.closeDirectAsk(item, input.actorSession, input.decision, input.primaryUser === true);
|
||||
|
||||
let transitionNote = item.destinationSession === input.actorSession ? "direct human reply received"
|
||||
: input.primaryUser ? `reply from ${input.actorSession}, the primary user at the terminal, on ${item.destinationSession}'s ask; not answered by ${item.destinationSession}`
|
||||
: `reply from ${input.actorSession}, a registered person, on the runner permission request asked of ${item.destinationSession}`;
|
||||
const placed = this.placeTypedReply(item, input.decision);
|
||||
if (placed) transitionNote += `; ${placed}`;
|
||||
return this.updateInTransactionalContext({
|
||||
qitemId: input.qitemId,
|
||||
actorSession: input.actorSession,
|
||||
state: "done",
|
||||
closureReason: "no-follow-on",
|
||||
transitionNote,
|
||||
ownerNotificationKind: "human-decision-resolved",
|
||||
});
|
||||
})();
|
||||
if (!result) return false;
|
||||
for (const event of result.persistedEvents) this.eventBus.notifySubscribers(event);
|
||||
return mine;
|
||||
}
|
||||
|
||||
/** Another person's committed return to this ask: its shared reply (for a park's question, `episodeKey`'s). It's the
|
||||
* answer, so a different person's later attempt settles with it rather than their own. Null when there's none, or it's
|
||||
* this actor's. */
|
||||
committedAnswerFromAnother(qitemId: string, actorSession: string, episodeKey?: string): QueueItem | null {
|
||||
const row = this.getById(humanAnswerId(qitemId, episodeKey));
|
||||
return row && row.replyTo === qitemId && row.sourceSession !== actorSession ? row : null;
|
||||
}
|
||||
|
||||
/** Close a pending direct ask as `actor`, its typed words under the first unanswered question. Inside a transaction. */
|
||||
private closeDirectAsk(item: QueueItem, actor: string, decision: string, primaryUser: boolean) {
|
||||
const asked = item.destinationSession;
|
||||
let transitionNote = asked === actor ? "direct human reply received"
|
||||
: primaryUser && actor === UNATTRIBUTED_ACTOR
|
||||
? `reply from someone at the terminal OpenRig can't name (${UNATTRIBUTED_ACTOR}), unattributed, on ${asked}'s ask; not answered by ${asked}`
|
||||
: primaryUser ? `reply from ${actor}, the primary user at the terminal, on ${asked}'s ask; not answered by ${asked}`
|
||||
: runnerPermissionOrigin(item) ? `reply from ${actor}, a registered person, on the runner permission request asked of ${asked}`
|
||||
: `reply from ${actor}, a registered person, on ${asked}'s ask; not answered by ${asked}`;
|
||||
const placed = this.placeTypedReply(item, decision);
|
||||
if (placed) transitionNote += `; ${placed}`;
|
||||
return this.updateInTransactionalContext({
|
||||
qitemId: item.qitemId,
|
||||
actorSession: actor,
|
||||
state: "done",
|
||||
closureReason: "no-follow-on",
|
||||
transitionNote,
|
||||
ownerNotificationKind: "human-decision-resolved",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Store a typed reply under the first unanswered question, keeping earlier clicks; the placement note, or null. */
|
||||
@@ -3890,7 +3854,7 @@ export class QueueRepository {
|
||||
clickedTs?: string }): RecordHumanAnswerResult {
|
||||
if (!this.hasHumanQuestionsColumn) return { status: "not-applicable", reason: "schema" };
|
||||
if (this.closeStaleRunnerPermission(input.qitemId)) return { status: "not-applicable", reason: "state-canceled" };
|
||||
const result = this.db.transaction((): RecordHumanAnswerResult => {
|
||||
return this.db.transaction(() => {
|
||||
const item = this.getById(input.qitemId);
|
||||
if (!item?.humanQuestions?.length) return { status: "not-applicable" as const, reason: "no-questions" };
|
||||
if (item.state !== "pending" && !this.answersPark(item, input.actorSession, input.clickedTs)) {
|
||||
@@ -3903,18 +3867,10 @@ export class QueueRepository {
|
||||
...(decidedForThem ? { decidedForThem } : {}) };
|
||||
}
|
||||
if (!this.answersAsk(item, input.actorSession) && !input.primaryUser) return { status: "not-applicable" as const, reason: "not-the-asked-human" };
|
||||
// One answer per question: another person's committed return is the answer (a park's, for its current question), so
|
||||
// nothing of this person's is recorded.
|
||||
const episodeKey = item.state === "pending" ? undefined : this.currentHumanRequiredEpisode(item.qitemId)?.key;
|
||||
const winner = item.state === "pending" || episodeKey ? this.committedAnswerFromAnother(item.qitemId, input.actorSession, episodeKey) : null;
|
||||
if (winner) return { status: "not-applicable" as const, reason: "answered", answeredBy: winner.sourceSession };
|
||||
const question = item.humanQuestions.find((q) => q.id === input.questionId);
|
||||
if (!question?.options.some((o) => o.id === input.optionId)) return { status: "not-applicable" as const, reason: "unknown-option" };
|
||||
const recorded = item.humanAnswers ?? {};
|
||||
// A park's set is final once this park has its answer: until then a complete set is an earlier park's, and a click
|
||||
// on the re-park records over it.
|
||||
if (unansweredQuestions(item.humanQuestions, recorded).length === 0
|
||||
&& (item.state === "pending" || this.currentEpisodeAnswer(item.qitemId, input.actorSession))) {
|
||||
if (unansweredQuestions(item.humanQuestions, recorded).length === 0) {
|
||||
return { status: "recorded" as const, answers: recorded, complete: true, questions: item.humanQuestions };
|
||||
}
|
||||
const answers: HumanAnswers = { ...(item.humanAnswers ?? {}), [input.questionId]: input.optionId };
|
||||
@@ -3922,19 +3878,13 @@ export class QueueRepository {
|
||||
const complete = unansweredQuestions(item.humanQuestions, answers).length === 0;
|
||||
return { status: "recorded" as const, answers, complete, questions: item.humanQuestions };
|
||||
})();
|
||||
// A pending ask's interrupted close finishes here, as theirs; a park resumes with it through the reply resolver.
|
||||
if (result.status === "not-applicable" && result.answeredBy) {
|
||||
this.resolveDirectHumanReply({ qitemId: input.qitemId, actorSession: input.actorSession, decision: "", primaryUser: input.primaryUser });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** A decision parked on the person it asked takes their click or typed reply, or another registered person's, as its
|
||||
* answer (makeHumanReplyResolver resumes it): the park is on a person, and the answer came after its current question
|
||||
* posted. */
|
||||
/** A decision parked on the person it asked takes that person's click or typed reply as its answer (makeHumanReplyResolver
|
||||
* resumes it): the park is on them by any of their addresses, and the answer came after its current question posted. */
|
||||
answersPark(item: QueueItem, actorSession: string, answeredTs: string | undefined): boolean {
|
||||
if (item.state !== "blocked" || !item.blockedOn || !this.isPersonAddress(item.blockedOn)) return false;
|
||||
if (!this.isPersonAddress(item.destinationSession) || !this.answersAsk(item, actorSession)) return false;
|
||||
if (item.destinationSession !== actorSession || this.askedSomeoneElse(actorSession, item.blockedOn)) return false;
|
||||
const posted = slackMicros(this.currentHumanRequiredEpisode(item.qitemId)?.receipt?.messageTs);
|
||||
const answered = slackMicros(answeredTs);
|
||||
return posted !== null && answered !== null && answered > posted;
|
||||
@@ -4185,10 +4135,9 @@ export class QueueRepository {
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const columns = opts?.compact ? this.compactColumns() : "*";
|
||||
const useActiveFirst = !!(opts?.rig || opts?.asSession || opts?.activeOnly);
|
||||
const newest = opts?.sortByUpdated ? "ts_updated DESC" : "ts_created DESC";
|
||||
const orderBy = useActiveFirst
|
||||
? `CASE WHEN state IN ('pending', 'in-progress', 'blocked') THEN 0 ELSE 1 END, ${newest}`
|
||||
: newest;
|
||||
? "CASE WHEN state IN ('pending', 'in-progress', 'blocked') THEN 0 ELSE 1 END, ts_created DESC"
|
||||
: "ts_created DESC";
|
||||
params.push(limit);
|
||||
|
||||
const rows = this.db
|
||||
@@ -4627,8 +4576,6 @@ export class QueueRepository {
|
||||
: item.state === "blocked"
|
||||
? resolveRegisteredHumanAddress(item.blockedOn, registry.entities)
|
||||
: resolveRegisteredHumanAddress(item.destinationSession, registry.entities);
|
||||
// A person with no connector is reached in the Feed and Steering only: nothing is posted to them, so nothing is owed.
|
||||
if (humanAddress && registry.entities.find((entity) => entity.address === humanAddress)?.connectorBindings.length === 0) return "inactive";
|
||||
// An ask placed on Steering without an alert (RECORD) owes no post: its ledger reports a real receipt (a posting
|
||||
// dial set to RECORD posts it) and never a missing one.
|
||||
return humanAddress
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
// Guidance problems never block the exact resume. What can't be put back is reported.
|
||||
|
||||
import nodePath from "node:path";
|
||||
import { lstatSync } from "node:fs";
|
||||
import type Database from "better-sqlite3";
|
||||
import {
|
||||
MANAGED_BLOCK_START,
|
||||
MANAGED_BLOCK_END,
|
||||
@@ -48,51 +46,19 @@ export interface SeatGuidance {
|
||||
items: GuidanceItem[];
|
||||
}
|
||||
|
||||
/** The file a seat's runtime merges its managed guidance blocks into, whatever a spec's `target` declares: the file its
|
||||
* launch recorded (`recorded`, a Pi seat's agent folder AGENTS.md, #875), else the rig's Claude file for Claude,
|
||||
* `AGENTS.md` for Codex, Pi and omp, or null for a runtime this doesn't know. Teardown, its live-file protection, exact
|
||||
* resume and node inventory all read the target from here. */
|
||||
/** The file a seat's runtime merges its managed guidance blocks into, whatever a spec's `target` declares: the rig's
|
||||
* Claude file for Claude, `AGENTS.md` for Codex, or null for a runtime this doesn't know. */
|
||||
export function seatGuidanceFile(
|
||||
runtime: string | null | undefined,
|
||||
cwd: string,
|
||||
claudeManagedBlockFile: string | null | undefined,
|
||||
recorded?: string | null,
|
||||
): string | null {
|
||||
if (recorded) return recorded;
|
||||
const fileName = runtime === "claude-code"
|
||||
? (claudeManagedBlockFile ?? DEFAULT_CLAUDE_MANAGED_BLOCK_FILE)
|
||||
: runtime === "codex" || runtime === "pi" || runtime === "omp" ? "AGENTS.md" : null;
|
||||
: runtime === "codex" ? "AGENTS.md" : null;
|
||||
return fileName ? nodePath.join(cwd, fileName) : null;
|
||||
}
|
||||
|
||||
export type LinkInfo = (path: string) => { link: boolean; nlink: number } | null;
|
||||
|
||||
/** An lstat of a path: whether it's a symlink and its hard-link count, or null when it doesn't exist. */
|
||||
export const lstatLinkInfo: LinkInfo = (path) => {
|
||||
try { const st = lstatSync(path); return { link: st.isSymbolicLink(), nlink: st.nlink }; } catch { return null; }
|
||||
};
|
||||
|
||||
/** #875: a seat's own guidance file (a Pi seat's `<seat>/agent/AGENTS.md`) is its alone only while neither the file, its
|
||||
* folder nor that folder's parent is a symlink and the file has one name. Otherwise other sessions (a person's own Pi
|
||||
* home, a shared file) could read what's written there. Returns the linked path, or null. Checked when a launch chooses
|
||||
* the file and again wherever a recorded file is written, since a link can be made after the launch recorded it. */
|
||||
export function guidanceFileSharedByLink(file: string, linkInfo: LinkInfo): string | null {
|
||||
for (const path of [nodePath.dirname(nodePath.dirname(file)), nodePath.dirname(file), file]) {
|
||||
const info = linkInfo(path);
|
||||
if (info && (info.link || (path === file && info.nlink > 1))) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The guidance file a node's last launch recorded (#875), or null: none recorded, or a database from before the column. */
|
||||
export function recordedGuidanceFile(db: Pick<Database.Database, "prepare">, nodeId: string | null | undefined): string | null {
|
||||
if (!nodeId) return null;
|
||||
try {
|
||||
const row = db.prepare("SELECT guidance_file FROM node_startup_context WHERE node_id = ?").get(nodeId) as { guidance_file: string | null } | undefined;
|
||||
return row?.guidance_file || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Whether guidance text holds this id's block as it is written: a BEGIN marker followed later by its END, in the
|
||||
* current or legacy form. For reporting an installed file; restore's own `blockState` is unchanged. */
|
||||
export function hasCompleteGuidanceBlock(content: string, blockId: string): boolean {
|
||||
@@ -113,7 +79,7 @@ export function seatGuidance(
|
||||
readFile: (path: string) => string,
|
||||
): SeatGuidance | null {
|
||||
if (!startupCtx || !cwd) return null;
|
||||
const targetPath = seatGuidanceFile(startupCtx.runtime, cwd, claudeManagedBlockFile, startupCtx.guidanceFile);
|
||||
const targetPath = seatGuidanceFile(startupCtx.runtime, cwd, claudeManagedBlockFile);
|
||||
if (!targetPath) return null;
|
||||
const items = new Map<string, GuidanceItem>();
|
||||
for (const saved of startupCtx.projectionEntries) {
|
||||
|
||||
@@ -33,7 +33,7 @@ import { resumeProbeEvidence, type NativeResumeProbeEvidence } from "./native-re
|
||||
import { observeClaudeDelivery, observeClaudeResumeLaunch, processArgv, verifyClaudePaneProcess, verifyClaudePaneRuntime, verifyPaneIdleShell, type ClaudeLaunchedProcess } from "./native-process-lineage.js";
|
||||
import { readNativeExecutablePaths } from "./native-process-executable.js";
|
||||
import { reanchorBuiltinStartupFile, reanchorShippedProjectionEntry } from "./builtin-startup-files.js";
|
||||
import { applySeatSkillsLineRule, seatGuidance, restoreMissingGuidance, missingGuidanceBlocks, guidanceFileSharedByLink, lstatLinkInfo } from "./restore-guidance.js";
|
||||
import { applySeatSkillsLineRule, seatGuidance, restoreMissingGuidance, missingGuidanceBlocks } from "./restore-guidance.js";
|
||||
import type { DesiredClaudeActivityHooks } from "./desired-claude-activity-hooks.js";
|
||||
import { readFootprintAnswer } from "./footprint-choice.js";
|
||||
import { SettingsStore } from "./user-settings/settings-store.js";
|
||||
@@ -1424,9 +1424,6 @@ export class RestoreOrchestrator {
|
||||
launchResult: { session: import("./types.js").Session; binding: import("./types.js").Binding },
|
||||
warnings: string[] | undefined,
|
||||
activityHookGaps: string[] | undefined,
|
||||
// The directory the seat was launched in (snapshot cwd, else live node
|
||||
// cwd, else "." — resolved by the caller, which owns the live lookup).
|
||||
launchCwd: string,
|
||||
): Promise<void> {
|
||||
let desired: DesiredClaudeActivityHooks;
|
||||
try {
|
||||
@@ -1461,9 +1458,9 @@ export class RestoreOrchestrator {
|
||||
};
|
||||
try {
|
||||
const result = await activityAdapter.project({
|
||||
runtime: "claude-code", cwd: launchCwd, entries: [marker],
|
||||
runtime: "claude-code", cwd: node.cwd ?? ".", entries: [marker],
|
||||
startup: { files: [], actions: [] }, conflicts: [], noOps: [], diagnostics: [],
|
||||
}, { ...launchResult.binding, cwd: launchCwd });
|
||||
}, { ...launchResult.binding, cwd: node.cwd ?? "." });
|
||||
warnings?.push(...(result.warnings ?? []));
|
||||
if (result.projected.includes(marker.effectiveId)) {
|
||||
const detail = "saved startup context had no Claude activity-hook selection; the current seat specification selects it, so the current marker was re-applied without replaying startup content.";
|
||||
@@ -1497,12 +1494,6 @@ export class RestoreOrchestrator {
|
||||
try { return readFootprintAnswer(this.routerLineSettings ?? new SettingsStore()).state === "yes"; } catch { return false; }
|
||||
}
|
||||
|
||||
/** The recorded guidance file follows the snapshot a seat was exactly resumed from (#2432); nothing else in its saved
|
||||
* startup selection changes. A database from before the column has nothing to record. */
|
||||
private recordRestoredGuidanceFile(nodeId: string, file: string | null): void {
|
||||
try { this.db.prepare("UPDATE node_startup_context SET guidance_file = ? WHERE node_id = ?").run(file, nodeId); } catch { /* no column */ }
|
||||
}
|
||||
|
||||
private checkRestoredGuidance(
|
||||
result: RestoreNodeResult,
|
||||
node: SnapshotData["nodes"][number],
|
||||
@@ -1514,15 +1505,7 @@ export class RestoreOrchestrator {
|
||||
if (!["resumed", "fresh-primed", "fresh", "rebuilt", "attention_required"].includes(result.status)) return result;
|
||||
let guidance: ReturnType<typeof seatGuidance>;
|
||||
try {
|
||||
// The seat was launched in the snapshot node's cwd when set, else the
|
||||
// live node's cwd — the same resolved launch directory the hook
|
||||
// projection and startup replay use. Passing node.cwd alone returns
|
||||
// no guidance at all when the snapshot node has no cwd. No "." fallback:
|
||||
// when neither cwd exists the old skip is kept, so no guidance file is
|
||||
// written into the daemon's own working directory.
|
||||
const guidanceCwd = node.cwd
|
||||
?? this.rigRepo.getRig(rigId)?.nodes.find(current => current.logicalId === node.logicalId)?.cwd;
|
||||
guidance = seatGuidance(data.nodeStartupContext?.[node.id] ?? null, guidanceCwd,
|
||||
guidance = seatGuidance(data.nodeStartupContext?.[node.id] ?? null, node.cwd,
|
||||
this.rigRepo.getRigClaudeManagedBlockFile(rigId), existsSync, (path) => readFileSync(path, "utf-8"));
|
||||
} catch (err) {
|
||||
warnings?.push(`Restore: ${node.logicalId}: could not check its guidance: ${(err as Error).message}`);
|
||||
@@ -1692,16 +1675,6 @@ export class RestoreOrchestrator {
|
||||
// deliberately skips the resume attempt; its launch reports `fresh-primed`.
|
||||
const liveRuntime = this.rigRepo.getRig(rigId)?.nodes.find(current => current.logicalId === node.logicalId
|
||||
&& (!selected || current.id === node.id))?.runtime ?? node.runtime;
|
||||
// The seat was launched in the snapshot node's cwd when set; NodeLauncher
|
||||
// otherwise falls back to the live node's cwd, and to the daemon's working
|
||||
// directory when neither is set (tmux new-session without -c inherits it;
|
||||
// "." is the codebase-wide convention for that case). Projecting hooks
|
||||
// with the snapshot cwd alone would write another project's settings when
|
||||
// the live node moved the seat.
|
||||
const launchCwd = node.cwd
|
||||
?? this.rigRepo.getRig(rigId)?.nodes.find(current => current.logicalId === node.logicalId
|
||||
&& (!selected || current.id === node.id))?.cwd
|
||||
?? ".";
|
||||
const restorePolicy = effectiveRestorePolicy(seatFacts(data, node.id, node.runtime, liveRuntime), session?.restorePolicy);
|
||||
const noConversation = isOrdinaryTerminalSeat(seatFacts(data, node.id, node.runtime, liveRuntime));
|
||||
// A terminal's restore is its shell and its saved startup: one whose startup failed wasn't restored whole.
|
||||
@@ -1731,26 +1704,17 @@ export class RestoreOrchestrator {
|
||||
// re-apply the CURRENT marker, marker-only: no profile-wide resolution,
|
||||
// no guidance, skills or startup replay, and the saved context is never
|
||||
// rewritten. A seat whose current spec does not select hooks stays silent.
|
||||
// The no-context fallback is scoped: it applies only to a legacy
|
||||
// (non-pod) agent whose live runtime still matches the snapshot's. A
|
||||
// runtime changed after the snapshot would otherwise read the snapshot's
|
||||
// runtime and write Claude hooks into another agent's working folder
|
||||
// (with a saved context, the compatibility gate already stops that
|
||||
// mismatch), and a pod-aware agent must get no write from this recovery
|
||||
// at all — it still ends at the "Harness not started" gate, unchanged.
|
||||
const activityAdapter = opts?.adapters?.["claude-code"];
|
||||
const recoveryRuntime = startupCtx?.runtime
|
||||
?? (!isPodAware && liveRuntime === node.runtime ? node.runtime : null);
|
||||
if (resumeRequested && resumeToken && launchResult && recoveryRuntime === "claude-code") {
|
||||
const entries = (startupCtx?.projectionEntries ?? []).filter(e =>
|
||||
if (resumeRequested && resumeToken && launchResult && startupCtx?.runtime === "claude-code") {
|
||||
const entries = startupCtx.projectionEntries.filter(e =>
|
||||
e.category === "runtime_resource" && e.resourceType === "claude_activity_hooks");
|
||||
if (entries.length > 0 && activityAdapter) try {
|
||||
const result = await activityAdapter.project({
|
||||
runtime: "claude-code", cwd: launchCwd,
|
||||
runtime: "claude-code", cwd: node.cwd ?? ".",
|
||||
entries: entries.map(e => ({ ...e, category: "runtime_resource" as const, classification: "safe_projection" as const,
|
||||
mergeStrategy: e.mergeStrategy as import("./projection-planner.js").ProjectionEntry["mergeStrategy"] })),
|
||||
startup: { files: [], actions: [] }, conflicts: [], noOps: [], diagnostics: [],
|
||||
}, { ...launchResult.binding, cwd: launchCwd });
|
||||
}, { ...launchResult.binding, cwd: node.cwd ?? "." });
|
||||
warnings?.push(...(result.warnings ?? []));
|
||||
for (const skipped of result.skipped) warnings?.push(`Restore activity hooks: skipped ${skipped}; saved hooks could not be reapplied.`);
|
||||
for (const failure of result.failed) warnings?.push(`Restore activity hooks: ${failure.error}`);
|
||||
@@ -1758,7 +1722,7 @@ export class RestoreOrchestrator {
|
||||
// Activity delivery remains best-effort, as on an ordinary fresh launch.
|
||||
warnings?.push(`Restore activity hooks: ${(error as Error).message}`);
|
||||
} else if (entries.length === 0) {
|
||||
await this.restoreCurrentActivityHooks(node, activityAdapter, launchResult, warnings, activityHookGaps, launchCwd);
|
||||
await this.restoreCurrentActivityHooks(node, activityAdapter, launchResult, warnings, activityHookGaps);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1770,13 +1734,7 @@ export class RestoreOrchestrator {
|
||||
if (resumeRequested && resumeToken && launchResult) {
|
||||
let guidance: ReturnType<typeof seatGuidance> = null;
|
||||
try {
|
||||
// Same resolved directory as launchCwd, without its "." fallback:
|
||||
// when neither the snapshot nor the live node has a cwd, keep the
|
||||
// old skip instead of writing guidance into the daemon's folder.
|
||||
const guidanceCwd = node.cwd
|
||||
?? this.rigRepo.getRig(rigId)?.nodes.find(current => current.logicalId === node.logicalId
|
||||
&& (!selected || current.id === node.id))?.cwd;
|
||||
guidance = seatGuidance(startupCtx, guidanceCwd, this.rigRepo.getRigClaudeManagedBlockFile(rigId), existsSync, (path) => readFileSync(path, "utf-8"));
|
||||
guidance = seatGuidance(startupCtx, node.cwd, this.rigRepo.getRigClaudeManagedBlockFile(rigId), existsSync, (path) => readFileSync(path, "utf-8"));
|
||||
} catch (err) {
|
||||
// Guidance never blocks the exact resume; the post-launch check discloses the gap.
|
||||
warnings?.push(`Restore guidance: ${node.logicalId}: could not read its saved guidance selection: ${(err as Error).message}`);
|
||||
@@ -1786,16 +1744,6 @@ export class RestoreOrchestrator {
|
||||
guidance = rule.guidance;
|
||||
if (rule.excluded === "linked") warnings?.push(`Restore guidance: ${node.logicalId}: the seat-skills line wasn't put back, because ${guidance.targetPath} is a link.`);
|
||||
}
|
||||
// #2432: the record names the file this resumed seat uses, the snapshot's choice, which can be older than the
|
||||
// database's: an older snapshot restored exactly after a newer launch resumes on its repository file.
|
||||
if (startupCtx) this.recordRestoredGuidanceFile(node.id, startupCtx.guidanceFile ?? null);
|
||||
// #2432: a recorded file that became a link after its launch gets nothing put back through it; the check after the
|
||||
// launch reports the blocks it lacks.
|
||||
const linked = startupCtx?.guidanceFile && guidance ? guidanceFileSharedByLink(guidance.targetPath, lstatLinkInfo) : null;
|
||||
if (linked && guidance) {
|
||||
warnings?.push(`Restore guidance: ${node.logicalId}: ${linked} is now a link, so nothing was put back in ${guidance.targetPath}, which other sessions could read.`);
|
||||
guidance = null;
|
||||
}
|
||||
if (guidance && guidance.items.length > 0) {
|
||||
const repair = restoreMissingGuidance(guidance, {
|
||||
exists: existsSync,
|
||||
@@ -1828,7 +1776,7 @@ export class RestoreOrchestrator {
|
||||
} catch { /* best-effort: without it a rotation stays unproved, as before */ }
|
||||
}
|
||||
if (selected && !selected.beforeNative(launchedSessionId, launchResult?.binding.id)) return { nodeId: node.id, logicalId: node.logicalId, status: "attention_required", error: "The selected seat, settings or history changed after shell creation; native resume was not dispatched." };
|
||||
const resumeOutcome = await this.attemptResume(node.id, sessionName, resumeType, resumeToken, launchCwd, this.liveCodexProfile(rigId, node, selected?.nodeId), modelForRestore(), this.resolveRestorePosture(node.id, rigId), effortForRestore(), warnings, node.advisor,
|
||||
const resumeOutcome = await this.attemptResume(node.id, sessionName, resumeType, resumeToken, node.cwd ?? "/", this.liveCodexProfile(rigId, node, selected?.nodeId), modelForRestore(), this.resolveRestorePosture(node.id, rigId), effortForRestore(), warnings, node.advisor,
|
||||
selected ? () => selected.beforeNative(launchedSessionId, launchResult?.binding.id) : undefined, opts?.claudeExecutable);
|
||||
if (resumeOutcome.kind === "resumed") {
|
||||
baseStatus = "resumed";
|
||||
@@ -2037,7 +1985,7 @@ export class RestoreOrchestrator {
|
||||
// Build fresh projection plan (all safe_projection; replaySkip says which seat skills are left alone)
|
||||
const plan: import("./projection-planner.js").ProjectionPlan = {
|
||||
runtime: startupCtx.runtime,
|
||||
cwd: launchCwd,
|
||||
cwd: node.cwd ?? ".",
|
||||
entries: filteredEntries.map((e) => ({
|
||||
...e,
|
||||
classification: "safe_projection" as const,
|
||||
@@ -2052,11 +2000,9 @@ export class RestoreOrchestrator {
|
||||
|
||||
const binding = {
|
||||
...launchResult.binding,
|
||||
// #875: the guidance file this seat's launch recorded; an older snapshot recorded none, so its file stays the rule's.
|
||||
guidanceFile: startupCtx.guidanceFile ?? null,
|
||||
...(data.environmentBindingsByNode?.[node.id] ? { environmentRestore: data.environmentBindingsByNode[node.id] } : {}),
|
||||
...(opts?.claudeExecutable ? { claudeExecutable: opts.claudeExecutable } : {}),
|
||||
cwd: launchCwd,
|
||||
cwd: node.cwd ?? ".",
|
||||
codexConfigProfile: this.liveCodexProfile(rigId, node, selected?.nodeId) ?? undefined,
|
||||
// OPR.0.4.8.3 Seam B: the pod-aware restore path binds the restored posture too
|
||||
// (both restore paths consume persisted provenance — preflight surface 3).
|
||||
|
||||
@@ -8,9 +8,7 @@ import { RigNotFoundError } from "./errors.js";
|
||||
import type { ResumeMetadataRefresher } from "./resume-metadata-refresher.js";
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import { removeManagedBlocksFromFile } from "./managed-blocks.js";
|
||||
import { recordedGuidanceFile, seatGuidanceFile } from "./restore-guidance.js";
|
||||
import { cleanupOwnedClaudeCwd } from "./claude-owned-settings.js";
|
||||
import { removeManagedBlocksFromFile, DEFAULT_CLAUDE_MANAGED_BLOCK_FILE } from "./managed-blocks.js";
|
||||
import { stopTranscriptRotation } from "./transcript-rotation.js";
|
||||
import { findOtherSessionOwner } from "./session-owner.js";
|
||||
import { communityHarnessOf } from "./built-in-runtimes.js";
|
||||
@@ -31,10 +29,6 @@ export interface TeardownResult {
|
||||
/** Managed guidance blocks left in a guidance file because a person edited them, as `<file>: <block id>`. Present
|
||||
* only when something was kept. */
|
||||
keptGuidance?: string[];
|
||||
/** #875: files in a Claude seat's cwd that still hold OpenRig's additions because down couldn't safely change them
|
||||
* (settings that don't parse, a collector that isn't the shipped one), as `<file>: <why>`. Present only when
|
||||
* something was kept. */
|
||||
keptFiles?: string[];
|
||||
/** Sessions of seats joined with `rig attach` as an external CLI, which down doesn't stop or check. Without --delete
|
||||
* each keeps its claim as it was, whether or not the app still runs. Present only when there was one. */
|
||||
leftRunning?: string[];
|
||||
@@ -60,8 +54,6 @@ interface TeardownDeps {
|
||||
eventBus: EventBus;
|
||||
resumeMetadataRefresher?: ResumeMetadataRefresher;
|
||||
serviceOrchestrator?: import("./service-orchestrator.js").ServiceOrchestrator;
|
||||
/** The shipped Claude context collector. A cwd's copy is removed only while its bytes match this file. */
|
||||
claudeCollectorAssetPath?: string;
|
||||
}
|
||||
|
||||
interface LatestNodeSession {
|
||||
@@ -134,7 +126,6 @@ export class RigTeardownOrchestrator {
|
||||
// Additional intent is best-effort; keep down's existing result if it cannot be recorded.
|
||||
await this.recordAlreadyStoppedIntent(rigId).catch(() => {});
|
||||
this.cleanupManagedGuidanceFiles(rigId, currentLiveGuidanceTargets(), result);
|
||||
this.cleanupClaudeCwds(rigId, result);
|
||||
result.alreadyStopped = true;
|
||||
// Still tear down services even if no agent sessions are running
|
||||
if (this.deps.serviceOrchestrator) {
|
||||
@@ -227,7 +218,7 @@ export class RigTeardownOrchestrator {
|
||||
if (!ownedByAnotherRig) stopTranscriptRotation(session.sessionName);
|
||||
// Success or already gone — update DB atomically
|
||||
this.atomicNodeCleanup(session);
|
||||
this.cleanupManagedGuidanceFileForNode(rigId, session.runtime, session.cwd, currentLiveGuidanceTargets(), result, session.nodeId);
|
||||
this.cleanupManagedGuidanceFileForNode(rigId, session.runtime, session.cwd, currentLiveGuidanceTargets(), result);
|
||||
if (killResult.ok) result.sessionsKilled++;
|
||||
if (killResult.ok && communityHarnessOf(session.communityHarness, session.runtime)) (result.paneOnly ??= []).push(session.sessionName);
|
||||
} else {
|
||||
@@ -239,7 +230,6 @@ export class RigTeardownOrchestrator {
|
||||
// Stop live sessions before probing historical rows; bookkeeping must not fail down.
|
||||
await this.recordAlreadyStoppedIntent(rigId).catch(() => {});
|
||||
this.cleanupManagedGuidanceFiles(rigId, currentLiveGuidanceTargets(), result);
|
||||
this.cleanupClaudeCwds(rigId, result);
|
||||
|
||||
// 5b. Tear down services if they exist
|
||||
if (this.deps.serviceOrchestrator) {
|
||||
@@ -398,19 +388,19 @@ export class RigTeardownOrchestrator {
|
||||
|
||||
private liveGuidanceTargets(rigId: string): Set<string> {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT n.id, n.rig_id, n.runtime, n.cwd FROM nodes n
|
||||
SELECT n.rig_id, n.runtime, n.cwd FROM nodes n
|
||||
JOIN rigs r ON r.id = n.rig_id
|
||||
WHERE r.archived_at IS NULL AND r.id <> ? AND n.cwd IS NOT NULL
|
||||
`).all(rigId) as Array<{ id: string; rig_id: string; runtime: string | null; cwd: string }>;
|
||||
`).all(rigId) as Array<{ rig_id: string; runtime: string | null; cwd: string }>;
|
||||
const targets = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const target = this.guidanceTargetPath(row.rig_id, row.runtime, row.cwd, row.id);
|
||||
const target = this.guidanceTargetPath(row.rig_id, row.runtime, row.cwd);
|
||||
if (target) targets.add(this.guidancePathKey(target));
|
||||
}
|
||||
// A failed kill or inconclusive probe leaves this rig's session live.
|
||||
// Its file may also be shared with a sibling that stopped successfully.
|
||||
for (const session of this.getLatestLiveSessions(rigId)) {
|
||||
const target = this.guidanceTargetPath(rigId, session.runtime, session.cwd, session.nodeId);
|
||||
const target = this.guidanceTargetPath(rigId, session.runtime, session.cwd);
|
||||
if (target) targets.add(this.guidancePathKey(target));
|
||||
}
|
||||
return targets;
|
||||
@@ -418,69 +408,25 @@ export class RigTeardownOrchestrator {
|
||||
|
||||
private cleanupManagedGuidanceFiles(rigId: string, liveGuidanceTargets: ReadonlySet<string>, result: TeardownResult): void {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT id, runtime, cwd
|
||||
SELECT DISTINCT runtime, cwd
|
||||
FROM nodes
|
||||
WHERE rig_id = ?
|
||||
`).all(rigId) as Array<{ id: string; runtime: string | null; cwd: string | null }>;
|
||||
`).all(rigId) as Array<{ runtime: string | null; cwd: string | null }>;
|
||||
for (const row of rows) {
|
||||
this.cleanupManagedGuidanceFileForNode(rigId, row.runtime, row.cwd, liveGuidanceTargets, result, row.id);
|
||||
this.cleanupManagedGuidanceFileForNode(rigId, row.runtime, row.cwd, liveGuidanceTargets, result);
|
||||
}
|
||||
}
|
||||
|
||||
/** #875: take OpenRig's Claude collector, status line and relay hooks back out of each of this rig's Claude cwds that
|
||||
* no seat still uses, and report what had to be kept. */
|
||||
private cleanupClaudeCwds(rigId: string, result: TeardownResult): void {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT DISTINCT cwd FROM nodes WHERE rig_id = ? AND runtime = 'claude-code' AND cwd IS NOT NULL
|
||||
`).all(rigId) as Array<{ cwd: string }>;
|
||||
if (rows.length === 0) return;
|
||||
const inUse = this.claudeTargetsInUse(rigId);
|
||||
const done = new Set<string>();
|
||||
for (const { cwd } of rows) {
|
||||
const key = this.guidancePathKey(cwd);
|
||||
if (inUse.has(key) || done.has(key)) continue;
|
||||
done.add(key); // one cleanup per directory, however many of this rig's seats name it
|
||||
try {
|
||||
const isShared = (path: string) => inUse.has(this.guidancePathKey(path));
|
||||
for (const entry of cleanupOwnedClaudeCwd(cwd, this.deps.claudeCollectorAssetPath, isShared)) {
|
||||
if (!result.keptFiles?.includes(entry)) (result.keptFiles ??= []).push(entry);
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Claude settings cleanup in ${cwd}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What a Claude seat may still use, by the same rule as guidance files: the cwd of a Claude seat of any other rig that
|
||||
* isn't archived, or of one of this rig's Claude seats still live (a failed stop, an inconclusive probe, an attached
|
||||
* CLI). Each cwd's settings file, collector, `.claude` and `.openrig` are keyed by their own identity too, so a target
|
||||
* another directory reaches through a symlink or a hardlink counts as in use. */
|
||||
private claudeTargetsInUse(rigId: string): Set<string> {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT n.cwd FROM nodes n
|
||||
JOIN rigs r ON r.id = n.rig_id
|
||||
WHERE r.archived_at IS NULL AND r.id <> ? AND n.runtime = 'claude-code' AND n.cwd IS NOT NULL
|
||||
`).all(rigId) as Array<{ cwd: string }>;
|
||||
const cwds = rows.map((row) => row.cwd);
|
||||
for (const session of this.getLatestLiveSessions(rigId)) {
|
||||
if (session.runtime === "claude-code" && session.cwd) cwds.push(session.cwd);
|
||||
}
|
||||
const keys = new Set<string>();
|
||||
for (const cwd of cwds) {
|
||||
for (const target of [".", ".claude", nodePath.join(".claude", "settings.local.json"), ".openrig", nodePath.join(".openrig", "context-collector.cjs")]) {
|
||||
keys.add(this.guidancePathKey(nodePath.join(cwd, target)));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** The file a seat reads its managed guidance from: the one its last launch recorded (#875), else the rule's. */
|
||||
private guidanceTargetPath(rigId: string, runtime: string | null, cwd: string | null, nodeId?: string | null): string | null {
|
||||
private guidanceTargetPath(rigId: string, runtime: string | null, cwd: string | null): string | null {
|
||||
if (!runtime || !cwd) {
|
||||
return null;
|
||||
}
|
||||
// #25: clean only the rig's selected Claude file; the other file is never touched.
|
||||
return seatGuidanceFile(runtime, cwd, this.deps.rigRepo.getRigClaudeManagedBlockFile(rigId), recordedGuidanceFile(this.db, nodeId));
|
||||
return runtime === "claude-code"
|
||||
? nodePath.join(cwd, this.deps.rigRepo.getRigClaudeManagedBlockFile(rigId) ?? DEFAULT_CLAUDE_MANAGED_BLOCK_FILE)
|
||||
: runtime === "codex"
|
||||
? nodePath.join(cwd, "AGENTS.md")
|
||||
: null;
|
||||
}
|
||||
|
||||
private guidancePathKey(path: string): string {
|
||||
@@ -498,16 +444,11 @@ export class RigTeardownOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
/** Takes OpenRig's unedited blocks out of the file the seat's launch recorded and out of the rule's file, where an
|
||||
* older launch of the same seat wrote them (#875): a recorded file never leaves blocks behind in the repository. */
|
||||
private cleanupManagedGuidanceFileForNode(rigId: string, runtime: string | null, cwd: string | null, liveGuidanceTargets: ReadonlySet<string>, result: TeardownResult, nodeId?: string | null): void {
|
||||
const targets = new Set([this.guidanceTargetPath(rigId, runtime, cwd, nodeId), this.guidanceTargetPath(rigId, runtime, cwd)]);
|
||||
for (const targetPath of targets) {
|
||||
if (targetPath) this.cleanupManagedGuidanceFile(targetPath, liveGuidanceTargets, result);
|
||||
private cleanupManagedGuidanceFileForNode(rigId: string, runtime: string | null, cwd: string | null, liveGuidanceTargets: ReadonlySet<string>, result: TeardownResult): void {
|
||||
const targetPath = this.guidanceTargetPath(rigId, runtime, cwd);
|
||||
if (!targetPath) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupManagedGuidanceFile(targetPath: string, liveGuidanceTargets: ReadonlySet<string>, result: TeardownResult): void {
|
||||
if (liveGuidanceTargets.has(this.guidancePathKey(targetPath))) return;
|
||||
// The seat-skills line's block is removed only while it still holds the line; a person's edit is kept and reported.
|
||||
const { keptEdited } = removeManagedBlocksFromFile({
|
||||
|
||||
@@ -50,13 +50,6 @@ function typedYesOrNo(answer: unknown): "yes" | "no" | null {
|
||||
return SAID_YES.has(words) ? "yes" : SAID_NO.has(words) ? "no" : null;
|
||||
}
|
||||
|
||||
/** A person's typed reply that says more than a plain yes or no: their own words, whole. */
|
||||
function typedWords(answer: unknown): string | null {
|
||||
if (!answer || typeof answer !== "object" || (answer as { kind?: unknown }).kind !== "typed-reply" || typedYesOrNo(answer)) return null;
|
||||
const words = String((answer as { text?: unknown }).text ?? "").trim();
|
||||
return words || null;
|
||||
}
|
||||
|
||||
export type PermissionOwnerRefusal = { ok: false; code: "permissions_to_not_a_person" | "permissions_to_unavailable"; message: string };
|
||||
|
||||
/** The registered person `ref` names, as their canonical address. Only a person answers in this version: a seat address
|
||||
@@ -109,8 +102,8 @@ export type SubmitResult =
|
||||
| { ok: false; code: SubmitRefusalCode; message: string };
|
||||
export type ReadResult =
|
||||
| { ok: true; state: "pending" | "withdrawn" | "stale" }
|
||||
| { ok: true; state: "answered" | "permitted"; optionId: string; said?: string }
|
||||
| { ok: true; state: "refused"; reason: "no_allow_offered" | "said_no"; said?: string }
|
||||
| { ok: true; state: "answered" | "permitted"; optionId: string }
|
||||
| { ok: true; state: "refused"; reason: "no_allow_offered" | "said_no" }
|
||||
| { ok: false; code: "not_found"; message: string };
|
||||
export type WithdrawResult = ReadResult | { ok: false; code: "invalid_permission_request"; message: string };
|
||||
|
||||
@@ -268,14 +261,14 @@ export class RunnerPermissions {
|
||||
if (changedId === askId || changedId === liveId) release();
|
||||
});
|
||||
try {
|
||||
const now = this.withWords(askId, this.read(seat, askId));
|
||||
const now = this.read(seat, askId);
|
||||
if (!now.ok || now.state !== "pending" || waitMs <= 0) return now;
|
||||
const asked = this.deps.queueRepo.getById(askId)!;
|
||||
liveId = this.liveEnd(asked)?.qitemId ?? askId;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
await Promise.race([changed, new Promise<void>((resolve) => { timer = setTimeout(resolve, waitMs); })]);
|
||||
if (timer) clearTimeout(timer);
|
||||
return this.withWords(askId, this.read(seat, askId));
|
||||
return this.read(seat, askId);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
@@ -294,15 +287,6 @@ export class RunnerPermissions {
|
||||
return { ok: true, state: "withdrawn" };
|
||||
}
|
||||
|
||||
/** A person who closed the ask with their own words, not a choice or a plain yes or no: the read passes them back as
|
||||
* `said`, from the row that holds the answer, for the runner to give the agent. The outcome and option are unchanged. */
|
||||
private withWords(askId: string, result: ReadResult): ReadResult {
|
||||
if (!result.ok || !(result.state === "permitted" || (result.state === "refused" && result.reason === "no_allow_offered"))) return result;
|
||||
const asked = this.deps.queueRepo.getById(askId);
|
||||
const said = typedWords((asked ? this.liveEnd(asked) : null)?.humanAnswers?.[QUESTION_ID]);
|
||||
return said ? { ...result, said } : result;
|
||||
}
|
||||
|
||||
/** A person who routes or hands the ask to a colleague means the colleague decides: follow each handoff (a handed-off
|
||||
* row, or one completed with closure handed_off_to) along its own edge, to the row handed from it at the handed-to
|
||||
* address. Null when that can't be followed on this host. */
|
||||
|
||||
@@ -17,10 +17,6 @@ export interface NodeBinding extends Binding {
|
||||
/** Claude-only, session-scoped Advisor model. */
|
||||
advisor?: string;
|
||||
codexConfigProfile?: string;
|
||||
/** #875: the file this launch merges its managed guidance blocks into, chosen once at a fresh launch
|
||||
* (`guidanceFileFor`) or carried from a restore's saved startup context. Null means the rule's file in `cwd`;
|
||||
* undefined means not chosen yet. */
|
||||
guidanceFile?: string | null;
|
||||
/** A staged account-home selection; null explicitly returns to configured defaults before commit. */
|
||||
accountHome?: string | null;
|
||||
accountToken?: import("./claude-seat-token.js").ClaudeSeatTokenRef | null;
|
||||
@@ -228,7 +224,7 @@ export interface ForkSource {
|
||||
*/
|
||||
export interface RuntimeAdapter {
|
||||
/** Optional environment-driver v1 operations. Absence means unsupported; the host launch path is unchanged. */
|
||||
readonly environment?: Pick<import("./environment-platform.js").EnvironmentPlatform, "protocolVersion" | "create" | "attach" | "project" | "validate" | "forNode">;
|
||||
readonly environment?: Pick<import("./environment-platform.js").EnvironmentPlatform, "protocolVersion" | "create" | "attach" | "project" | "validate">;
|
||||
preflightEnvironmentStartup?(binding: NodeBinding, opts: { resumeToken?: string; resumeType?: string; forkSource?: unknown }): Promise<void>;
|
||||
prepareEnvironmentStartup?(files: ResolvedStartupFile[], binding: NodeBinding): Promise<StartupDeliveryResult>;
|
||||
/** Claude's managed capability/launch seam, shared with seat selection. */
|
||||
@@ -238,9 +234,6 @@ export interface RuntimeAdapter {
|
||||
* the Claude project tree, for "already in place" detection. Absent = the
|
||||
* instantiator's default target. */
|
||||
skillTargetPath?(tmuxSession: string | null, effectiveId: string): string | null;
|
||||
/** #875: where this seat's managed guidance should go, decided at a fresh launch and recorded with its startup context.
|
||||
* Absent, or a null file, keeps the rule's file in the working directory; a note says why the seat's own file wasn't used. */
|
||||
guidanceFileFor?(binding: NodeBinding): { file: string | null; note?: string };
|
||||
|
||||
/** List currently installed/projected resources for a node. */
|
||||
listInstalled(binding: NodeBinding): Promise<InstalledResource[]>;
|
||||
|
||||
@@ -672,11 +672,9 @@ export class SeatActivityService {
|
||||
if (trust === "positive-only" && evd.activity !== "working") continue;
|
||||
// A durable rung's idle or attention boundary holds; a bare working boundary still expires, then falls to the
|
||||
// rungs below, so a turn whose end was lost can't read working forever.
|
||||
// A runner's counted working beat lasts its own livenessSeconds instead (the runner contract §4).
|
||||
if (rung === "lifecycle-hooks" && !(this.durableBoundaries(seat, rung) && (evd.activity !== "working"
|
||||
|| (this.latestByRung(seat, rung)?.needsInput?.count ?? 0) > 0))
|
||||
&& nowMs - Date.parse(evd.observedAt) > (evd.activity === "working" && evd.workingLivenessSeconds
|
||||
? evd.workingLivenessSeconds * 1000 : HOOK_AUTHORITY_WINDOW_MS)) continue;
|
||||
&& nowMs - Date.parse(evd.observedAt) > HOOK_AUTHORITY_WINDOW_MS) continue;
|
||||
// An idle boundary whose source promised to keep reporting holds for two reports. A source that goes quiet (a runner
|
||||
// killed or out of memory) stops reading idle, but only when nothing is waiting on a person: attention holds.
|
||||
if (rung === "lifecycle-hooks" && evd.activity === "idle-at-prompt" && evd.livenessSeconds
|
||||
|
||||
@@ -78,14 +78,9 @@ export interface SeatHandoverMutationResult {
|
||||
handoverAt: string;
|
||||
eventSeq: number;
|
||||
sideEffects: {
|
||||
/** The departing tmux session is kept: a successor respawns into its pane, and a discovered one runs in its own. */
|
||||
departingSessionKilled: false;
|
||||
/** How the previous occupant's process ended: it exited on TERM, or was killed after the TERM window, before the
|
||||
* successor started in its pane. A discovered successor leaves it alone. */
|
||||
departingProcess: "ended-graceful" | "ended-forced" | "not-touched";
|
||||
startupContextDelivered: boolean;
|
||||
/** This handover's lineage record (its occupant tenure) exists. Its write is best-effort, so it's read back. */
|
||||
provenanceRecordWritten: boolean;
|
||||
provenanceRecordWritten: false;
|
||||
};
|
||||
restorePacket?: { status: "unconfirmed"; reason: string };
|
||||
/** OPR.0.5.5.5 — per-source execution outcome. fork: the resolved fork origin;
|
||||
@@ -435,7 +430,6 @@ export class SeatHandoverService {
|
||||
reason,
|
||||
operator,
|
||||
contextDelivered: false,
|
||||
departingProcess: "not-touched",
|
||||
launchToken: null,
|
||||
occupantGeneration: null,
|
||||
appliedLaunch: null,
|
||||
@@ -699,7 +693,6 @@ export class SeatHandoverService {
|
||||
reason,
|
||||
operator,
|
||||
contextDelivered,
|
||||
departingProcess: launch.retireeExit === "forced" ? "ended-forced" : "ended-graceful",
|
||||
restorePacket,
|
||||
// B2 (launched/fresh): the launch-scraped resume token captured by the
|
||||
// successor launcher is persisted atomically at commit (provenance scrape).
|
||||
@@ -729,7 +722,6 @@ export class SeatHandoverService {
|
||||
reason: string;
|
||||
operator: string | null;
|
||||
contextDelivered: boolean;
|
||||
departingProcess: SeatHandoverMutationResult["sideEffects"]["departingProcess"];
|
||||
restorePacket?: SeatHandoverMutationResult["restorePacket"];
|
||||
/** Launch-scraped resume token for a fresh successor (persisted at commit). */
|
||||
launchToken: { token: string; resumeType?: string } | null;
|
||||
@@ -839,7 +831,6 @@ export class SeatHandoverService {
|
||||
operator: input.operator,
|
||||
discovered,
|
||||
contextDelivered: input.contextDelivered,
|
||||
departingProcess: input.departingProcess,
|
||||
restorePacket: input.restorePacket,
|
||||
launchToken: input.launchToken,
|
||||
occupantGeneration: input.occupantGeneration,
|
||||
@@ -1149,7 +1140,6 @@ export class SeatHandoverService {
|
||||
operator: string | null;
|
||||
discovered: ReturnType<DiscoveryRepository["getDiscoveredSession"]> & NonNullable<unknown>;
|
||||
contextDelivered: boolean;
|
||||
departingProcess: SeatHandoverMutationResult["sideEffects"]["departingProcess"];
|
||||
restorePacket?: SeatHandoverMutationResult["restorePacket"];
|
||||
launchToken: { token: string; resumeType?: string } | null;
|
||||
occupantGeneration: string | null;
|
||||
@@ -1181,7 +1171,6 @@ export class SeatHandoverService {
|
||||
// (the name is reused), so this is the only point the retiree's generation is resolvable.
|
||||
const retiringGeneration =
|
||||
this.sessionRegistry.currentOccupantGenerationForSession(input.latestSession.session_name) ?? undefined;
|
||||
const tenureBefore = this.readOccupantTenure(input.node.id);
|
||||
// atom-B: a seat handover mints a HANDOVER-kind occupant generation (not the default 'adopt').
|
||||
const newSession = this.sessionRegistry.registerClaimedSession(
|
||||
input.node.id,
|
||||
@@ -1189,10 +1178,6 @@ export class SeatHandoverService {
|
||||
"handover",
|
||||
input.occupantGeneration,
|
||||
);
|
||||
// The receipt's lineage record: that mint is best-effort (a failure is logged and swallowed), so read it back.
|
||||
const tenure = this.readOccupantTenure(input.node.id);
|
||||
const provenanceRecordWritten = tenure !== null && tenure.kind === "handover" && tenure.id !== tenureBefore?.id
|
||||
&& (input.occupantGeneration === null || tenure.generationUuid === input.occupantGeneration);
|
||||
// W3: registerClaimedSession mints the successor generation. Only now may
|
||||
// the exact adapter-returned launch effect be attached to that tenure.
|
||||
// The store is best-effort, so observation persistence can never make an
|
||||
@@ -1263,10 +1248,10 @@ export class SeatHandoverService {
|
||||
...(input.sourceOutcome ? { sourceOutcome: input.sourceOutcome } : {}),
|
||||
...(input.launchAdditionNotes?.length ? { launchAdditionNotes: input.launchAdditionNotes } : {}),
|
||||
});
|
||||
return { newSessionId: newSession.id, previousSessionIdsSuperseded, event, provenanceRecordWritten };
|
||||
return { newSessionId: newSession.id, previousSessionIdsSuperseded, event };
|
||||
});
|
||||
|
||||
let committed: { newSessionId: string; previousSessionIdsSuperseded: string[]; event: PersistedEvent; provenanceRecordWritten: boolean };
|
||||
let committed: { newSessionId: string; previousSessionIdsSuperseded: string[]; event: PersistedEvent };
|
||||
try {
|
||||
committed = tx();
|
||||
// S19 ruling 01530 — the SOLE narrow call: after the commit lands, the activity
|
||||
@@ -1343,19 +1328,13 @@ export class SeatHandoverService {
|
||||
...(input.restorePacket ? { restorePacket: input.restorePacket } : {}),
|
||||
sideEffects: {
|
||||
departingSessionKilled: false,
|
||||
departingProcess: input.departingProcess,
|
||||
startupContextDelivered: input.contextDelivered,
|
||||
provenanceRecordWritten: committed.provenanceRecordWritten,
|
||||
provenanceRecordWritten: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The node's current occupant tenure, or null when the ledger can't be read (then nothing proves a write). */
|
||||
private readOccupantTenure(nodeId: string): ReturnType<SessionRegistry["currentOccupantTenure"]> {
|
||||
try { return this.sessionRegistry.currentOccupantTenure(nodeId); } catch { return null; }
|
||||
}
|
||||
|
||||
private upsertBinding(nodeId: string, fields: { tmuxSession: string; tmuxWindow: string | null; tmuxPane: string | null }): void {
|
||||
const existing = this.db.prepare("SELECT id FROM bindings WHERE node_id = ?").get(nodeId) as { id: string } | undefined;
|
||||
if (existing) {
|
||||
|
||||
@@ -125,7 +125,14 @@ export type SetRuntimeResult =
|
||||
/** The seat already runs that runtime and no settings were given: nothing to do. */
|
||||
| ({ ok: true; dryRun: boolean; changed: false } & Pick<RuntimeSwitchPlan, "seat" | "from" | "to">)
|
||||
| ({ ok: true; dryRun: false; changed: true; switchId: string; generation: string; previousGeneration: string;
|
||||
claimsMoved: number; openRows: number; confirmation: { note: string; at: string }; recoveryPending: boolean } & RuntimeSwitchPlan);
|
||||
claimsMoved: number; openRows: number; confirmation: { note: string; at: string }; recoveryPending: boolean } & RuntimeSwitchPlan)
|
||||
/** A materialized seat that has never started: its saved runtime changed, and nothing was switched. */
|
||||
| ({ ok: true; dryRun: boolean; changed: true; firstStart: true }
|
||||
& Pick<RuntimeSwitchPlan, "seat" | "from" | "to" | "accountHome" | "accountToken" | "permission" | "dropped">);
|
||||
|
||||
/** A seat that has never started and wasn't materialized takes its runtime from the rig spec it was created from. */
|
||||
const DECLARED_FIRST_START = "It has never started, so its first start takes the runtime its rig spec declares: declare the other runtime there "
|
||||
+ "and create the seat again. A seat from rig import --materialize-only can change runtime before its first start.";
|
||||
|
||||
interface SwitchRow {
|
||||
id: string; node_id: string; canonical_session: string; staging_session: string;
|
||||
@@ -135,12 +142,6 @@ interface SwitchRow {
|
||||
committed_at: string | null; outcome: string | null; attention: string | null; created_at: string;
|
||||
}
|
||||
|
||||
/** What to do about a seat with no pane to switch. */
|
||||
const NO_PANE = "it has no bound tmux pane";
|
||||
const NO_PANE_GUIDANCE = "If the seat is stopped, start it first, then switch it. If it has never started, there's nothing to switch: "
|
||||
+ "declare the other runtime for this member in its rig spec and bring the team up again from that spec. That makes a new "
|
||||
+ "generation of the seat, and it doesn't carry this seat's edges, settings or queued work the way a switch does.";
|
||||
|
||||
const COSTS = [
|
||||
"A new conversation: native history isn't converted; only the handover carries over.",
|
||||
"Same address, different terminal: the seat's session and address stay, its pane and scrollback change.",
|
||||
@@ -251,15 +252,18 @@ export class SeatRuntimeSwitchService {
|
||||
}
|
||||
const predecessorGeneration = sessionRegistry.currentOccupantTenure(node.id)?.generationUuid;
|
||||
const running = sessionRegistry.getSessionsForRig(node.rigId).filter((session) => session.nodeId === node.id && session.status === "running");
|
||||
const unsupported = !sessionName || !binding?.tmuxPane ? NO_PANE
|
||||
// A seat that has never started has no conversation, occupant or native history for a switch to carry.
|
||||
const firstStart = this.neverStarted(node.id, node.rigId);
|
||||
const unsupported = firstStart === "materialized" ? null
|
||||
: !sessionName || !binding?.tmuxPane ? "it has no bound tmux pane"
|
||||
: binding.attachmentType && binding.attachmentType !== "tmux" ? `it's attached through ${binding.attachmentType}, not tmux`
|
||||
: !predecessorGeneration ? "its current occupant generation isn't recorded"
|
||||
: running.length !== 1 || running[0]!.sessionName !== sessionName ? "it doesn't have exactly one running session at its address"
|
||||
: !node.cwd || !nodePath.isAbsolute(node.cwd) ? "its working directory isn't an absolute path"
|
||||
: null;
|
||||
if (unsupported) return refuse("runtime_switch_unsupported", `${seatRef} can't switch runtime: ${unsupported}.`,
|
||||
unsupported === NO_PANE ? NO_PANE_GUIDANCE : undefined);
|
||||
try {
|
||||
firstStart === "declared" ? DECLARED_FIRST_START : undefined);
|
||||
if (firstStart !== "materialized") try {
|
||||
const [panes, windows] = await Promise.all([tmux.listPanes(sessionName!), tmux.listWindows(sessionName!)]);
|
||||
if (windows.length !== 1 || panes.length !== 1 || panes[0]!.id !== binding!.tmuxPane) {
|
||||
return refuse("runtime_switch_unsupported", `${seatRef} can't switch runtime: only a single-window, single-pane seat can, and its session has ${windows.length} window(s) and ${panes.length} pane(s).`);
|
||||
@@ -284,6 +288,9 @@ export class SeatRuntimeSwitchService {
|
||||
: validateNativePermissionSelection(target, mode);
|
||||
}
|
||||
} catch (error) { return refuse("invalid_runtime_settings", (error as Error).message); }
|
||||
if (firstStart === "materialized") {
|
||||
return this.saveFirstStartRuntime({ input: { ...input, reason }, node, seatRef, target, explicit, selection, sourceSelection, base, refuse });
|
||||
}
|
||||
|
||||
let materials: RuntimeSwitchMaterials;
|
||||
try {
|
||||
@@ -334,6 +341,71 @@ export class SeatRuntimeSwitchService {
|
||||
}
|
||||
|
||||
/** Stage and confirm the successor before commit; failed preparation retains the predecessor and records cleanup needs. */
|
||||
/** "materialized": a seat from rig import --materialize-only that has never started, exactly as its first start checks
|
||||
* it (no binding, session, occupant or saved startup context). "declared": one that has never started but has no
|
||||
* materialized source. null: a seat with history. */
|
||||
private neverStarted(nodeId: string, rigId: string): "materialized" | "declared" | null {
|
||||
const { db, rigRepo, sessionRegistry } = this.deps;
|
||||
if (sessionRegistry.getBindingForNode(nodeId) || sessionRegistry.currentOccupantTenure(nodeId)
|
||||
|| sessionRegistry.getSessionsForRig(rigId).some((session) => session.nodeId === nodeId)
|
||||
|| db.prepare("SELECT 1 FROM node_startup_context WHERE node_id = ?").get(nodeId)) return null;
|
||||
return rigRepo.getRigStartupLayers(rigId)?.materializedSources?.some((entry) => entry.nodeIds.includes(nodeId)) ? "materialized" : "declared";
|
||||
}
|
||||
|
||||
/** A materialized seat that has never started: nothing runs, so nothing is switched (no successor, switch record or
|
||||
* switch id). Its saved runtime changes, and the source runtime's settings are cleared as a switch clears them; its
|
||||
* first start resolves the rest for the new runtime. The node row, and the member in its launch recipe that a
|
||||
* restore's first start reads, both change, with a snapshot, so every way of starting it agrees. */
|
||||
private saveFirstStartRuntime(ctx: {
|
||||
input: SetRuntimeInput & { reason: string }; node: NonNullable<ReturnType<RigRepository["getRig"]>>["nodes"][number]; seatRef: string;
|
||||
target: SwitchRuntime; explicit: { model?: string; effort?: string; codexConfigProfile?: string };
|
||||
selection: NativePermissionSelection | null; sourceSelection: { runtime: string; mode: string } | null;
|
||||
base: Pick<RuntimeSwitchPlan, "seat" | "from" | "to" | "accountHome">; refuse: (code: string, message: string) => RuntimeSwitchRefusal;
|
||||
}): SetRuntimeResult {
|
||||
const { db, rigRepo, sessionRegistry, eventBus } = this.deps;
|
||||
const { input, node, target, explicit, selection } = ctx;
|
||||
const dropped = [...(["model", "effort", "advisor", "codexConfigProfile"] as const)
|
||||
.filter((key) => node[key] && !(key !== "advisor" && explicit[key]))
|
||||
.map((key) => ({ setting: key === "codexConfigProfile" ? "Codex profile" : key, value: node[key]! })),
|
||||
...(ctx.sourceSelection ? [{ setting: "native permission choice", value: `${ctx.sourceSelection.runtime}:${ctx.sourceSelection.mode}` }] : [])];
|
||||
const result = (dryRun: boolean): SetRuntimeResult => ({ ok: true, dryRun, changed: true, firstStart: true, ...ctx.base,
|
||||
...(input.accountToken ? { accountToken: input.accountToken } : {}),
|
||||
permission: selection ? { mode: selection.mode, source: "explicit" } : { mode: "inherit", source: "member_or_rig_policy" }, dropped });
|
||||
if (input.dryRun) return result(true);
|
||||
const notifications: PersistedEvent[] = [];
|
||||
try {
|
||||
db.transaction(() => {
|
||||
if (this.neverStarted(node.id, node.rigId) !== "materialized"
|
||||
|| (db.prepare("SELECT runtime FROM nodes WHERE id = ?").get(node.id) as { runtime: string } | undefined)?.runtime !== node.runtime) {
|
||||
throw new Error("it started or changed meanwhile");
|
||||
}
|
||||
db.prepare("UPDATE nodes SET runtime = ?, model = ?, effort = ?, effort_choice = NULL, advisor = NULL, codex_config_profile = ? WHERE id = ?")
|
||||
.run(target, explicit.model ?? null, explicit.effort ?? null, target === "codex" ? explicit.codexConfigProfile ?? null : null, node.id);
|
||||
db.prepare("UPDATE nodes SET account_home = ?, claude_token_ref = CASE WHEN ? THEN ? ELSE claude_token_ref END WHERE id = ?")
|
||||
.run(input.accountHome ?? null, input.accountHome !== undefined || node.accountHome ? 1 : 0,
|
||||
input.accountToken ? JSON.stringify(input.accountToken) : null, node.id);
|
||||
new NativePermissionStore(db).write(node.id, selection, input.requestedBy?.trim() || ctx.seatRef, input.reason);
|
||||
if (target === "codex" && selection) clearCodexMemberPermissions(db, node.id);
|
||||
// A restore's first start builds the member from this recipe, not the node row.
|
||||
const recipe = rigRepo.getNodeLaunchRecipe(node.id);
|
||||
const dot = node.logicalId.indexOf(".");
|
||||
const member = recipe?.rigSpec.pods.find((pod) => pod.id === node.logicalId.slice(0, dot))?.members
|
||||
.find((candidate) => candidate.id === node.logicalId.slice(dot + 1));
|
||||
if (recipe && member) {
|
||||
member.runtime = target;
|
||||
delete member.model; delete member.effort; delete member.advisor; delete member.codexConfigProfile;
|
||||
db.prepare("UPDATE nodes SET launch_recipe_json = ? WHERE id = ?").run(JSON.stringify(recipe), node.id);
|
||||
}
|
||||
new SnapshotCapture({ db, rigRepo, sessionRegistry, eventBus, snapshotRepo: new SnapshotRepository(db), checkpointStore: new CheckpointStore(db) })
|
||||
.captureSnapshot(node.rigId, "runtime-switch", { deferNotify: (persisted) => notifications.push(persisted) });
|
||||
})();
|
||||
} catch (error) {
|
||||
return ctx.refuse("runtime_switch_unsupported", `${ctx.seatRef} can't change runtime: ${(error as Error).message}. Nothing changed.`);
|
||||
}
|
||||
for (const event of notifications) eventBus.notifySubscribers(event);
|
||||
return result(false);
|
||||
}
|
||||
|
||||
private async execute(ctx: {
|
||||
input: SetRuntimeInput & { reason: string }; node: NonNullable<ReturnType<RigRepository["getRig"]>>["nodes"][number]; seatRef: string; sessionName: string;
|
||||
predecessorPane: string; predecessorGeneration: string; target: SwitchRuntime; materials: RuntimeSwitchMaterials;
|
||||
|
||||
@@ -724,8 +724,8 @@ export interface SkillRouterFootprint {
|
||||
* Slice 20 seam: the exact preview of the bootstrap line's footprint, before any write: the line verbatim, the exact text
|
||||
* written (the line between OpenRig's markers) and where it goes, and each file
|
||||
* it would go into (a Claude seat's rig file, `CLAUDE.md` or `CLAUDE.local.md`; a Codex seat's `AGENTS.md`), one entry
|
||||
* per file with every seat sharing it. Pure: no filesystem or settings read. The file rule is `seatGuidanceFile`'s
|
||||
* (restore-guidance.ts) for the two runtimes that get the line, Claude and Codex, repeated here.
|
||||
* per file with every seat sharing it. Pure: no filesystem or settings read. The file rule is main's
|
||||
* `seatGuidanceFile` (restore-guidance.ts), repeated here until this branch carries it.
|
||||
*/
|
||||
export function describeSkillRouterFootprint(seats: FootprintSeat[]): SkillRouterFootprint {
|
||||
const byPath = new Map<string, { path: string; runtime: "claude-code" | "codex"; seats: string[] }>();
|
||||
|
||||
@@ -107,13 +107,12 @@ export class SnapshotCapture {
|
||||
const nodeStartupContext: Record<string, import("./types.js").NodeStartupSnapshot | null> = {};
|
||||
for (const node of rig.nodes) {
|
||||
const ctx = this.db.prepare("SELECT * FROM node_startup_context WHERE node_id = ?")
|
||||
.get(node.id) as { projection_entries_json: string; resolved_files_json: string; startup_actions_json: string; runtime: string; guidance_file?: string | null } | undefined;
|
||||
.get(node.id) as { projection_entries_json: string; resolved_files_json: string; startup_actions_json: string; runtime: string } | undefined;
|
||||
nodeStartupContext[node.id] = ctx ? {
|
||||
projectionEntries: JSON.parse(ctx.projection_entries_json),
|
||||
resolvedStartupFiles: JSON.parse(ctx.resolved_files_json),
|
||||
startupActions: JSON.parse(ctx.startup_actions_json),
|
||||
runtime: ctx.runtime,
|
||||
...(ctx.guidance_file ? { guidanceFile: ctx.guidance_file } : {}),
|
||||
} : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -409,13 +409,6 @@ export class StartupOrchestrator {
|
||||
? { parentToken: input.forkSource.value.trim(), isRestore: input.isRestore } : undefined;
|
||||
const launchGeneration = this.sessionRegistry.currentOccupantTenure(input.nodeId)?.generationUuid;
|
||||
if (input.adapter.environment) input = { ...input, binding: { ...input.binding, launchGeneration: input.binding.launchGeneration ?? launchGeneration } };
|
||||
// #875: a fresh launch decides once where its managed guidance goes; a restore carries its saved choice (null for an
|
||||
// older snapshot), so the adapter isn't asked again and an existing seat isn't silently repointed.
|
||||
if (input.binding.guidanceFile === undefined && input.adapter.guidanceFileFor) {
|
||||
const chosen = input.adapter.guidanceFileFor(input.binding);
|
||||
if (chosen.note) warnings.push(chosen.note);
|
||||
input = { ...input, binding: { ...input.binding, guidanceFile: chosen.file } };
|
||||
}
|
||||
|
||||
// 1. Mark pending
|
||||
this.sessionRegistry.updateStartupStatus(input.sessionId, "pending");
|
||||
@@ -572,10 +565,6 @@ export class StartupOrchestrator {
|
||||
JSON.stringify(input.startupActions),
|
||||
input.adapter.runtime,
|
||||
);
|
||||
// The chosen guidance file rides with the context it was chosen for; the row above leaves it null otherwise.
|
||||
if (input.binding.guidanceFile) {
|
||||
this.db.prepare("UPDATE node_startup_context SET guidance_file = ? WHERE node_id = ?").run(input.binding.guidanceFile, input.nodeId);
|
||||
}
|
||||
} catch (error) {
|
||||
return this.fail(input, "failed", [`Startup context persistence failed: ${String(error)}`], undefined, false, undefined, false, "persistence", true);
|
||||
}
|
||||
@@ -607,24 +596,7 @@ export class StartupOrchestrator {
|
||||
this.sessionRegistry.recordResumeLaunch(input.sessionId, launchResumeToken ?? null);
|
||||
} catch { /* best-effort: without it a rotation stays unproved, as before */ }
|
||||
}
|
||||
if (input.adapter.environment) {
|
||||
const selected = input.adapter.environment.forNode(input.nodeId, input.binding.tmuxSession ?? undefined);
|
||||
if (!selected) throw new Error("The selected environment destination is unavailable");
|
||||
launchChallenge = continuityOutcome === "fresh" && startupProof.mode === "authenticated" ? issueStartupChallenge(this.eventBus, {
|
||||
rigId: input.rigId, nodeId: input.nodeId, contractSource: JSON.stringify(input.resolvedStartupFiles),
|
||||
}) : null;
|
||||
const actions = input.startupActions.filter(a => a.type === "send_text" && !isSessionIdentityAction(a)
|
||||
&& a.appliesOn.includes(context) && !(input.isRestore && !a.idempotent));
|
||||
const guestText = (text: string) => input.binding.cwd && input.binding.cwd !== "/"
|
||||
? text.split(input.binding.cwd).join(`${selected.guestRoot}/work`) : text;
|
||||
const parts = [
|
||||
...(identityAction ? [`OpenRig-generated identity context (not a project task):\n${guestText(identityAction.value)}\nEnd of generated identity context.`] : []),
|
||||
...actions.filter(a => a.phase === "after_ready").map(a => guestText(a.value)),
|
||||
"Read the projected CLAUDE.md and its selected startup files in this guest.",
|
||||
...postLaunchFiles.map(file => `Read ${selected.guestRoot}/work/.openrig/startup/${encodeURIComponent(file.path)}.`),
|
||||
...actions.filter(a => a.phase === "after_files").map(a => guestText(a.value)), ...(launchChallenge ? [launchChallenge.promptBlock] : [])];
|
||||
launchPrompt = { prompt: parts.join("\n\n"), remainingFiles: [] };
|
||||
} else if (input.adapter instanceof CommunityHarnessRuntimeAdapter && input.adapter.firstInput()
|
||||
if (input.adapter instanceof CommunityHarnessRuntimeAdapter && input.adapter.firstInput()
|
||||
&& !launchResumeToken && continuityOutcome === "fresh" && identityAction) {
|
||||
launchChallenge = startupProof.mode === "authenticated" ? issueStartupChallenge(this.eventBus, {
|
||||
rigId: input.rigId, nodeId: input.nodeId, contractSource: JSON.stringify(input.resolvedStartupFiles),
|
||||
@@ -855,11 +827,6 @@ export class StartupOrchestrator {
|
||||
// A selected proof still works without a session_identity action: deliver
|
||||
// its standalone prompt after the post-launch contract files below.
|
||||
const consumedActions = new Set<StartupAction>();
|
||||
const guestInputPassed = !!input.adapter.environment && !!input.firstInputPassed;
|
||||
if (guestInputPassed) {
|
||||
postLaunchFiles = []; // All resolved files were projected before the guest's initial input.
|
||||
for (const action of input.startupActions) if (action.type === "send_text") consumedActions.add(action);
|
||||
}
|
||||
let challengeOnlyPrompt: string | null = null;
|
||||
if (continuityOutcome === "fresh" && identityAction) {
|
||||
const initialPrompt = input.firstInputPassed && launchPrompt
|
||||
@@ -871,7 +838,7 @@ export class StartupOrchestrator {
|
||||
}
|
||||
postLaunchFiles = initialPrompt.remainingFiles;
|
||||
if (challenge) await this.sendProofInstruction(deliveryInput);
|
||||
} else if (challenge && !guestInputPassed) {
|
||||
} else if (challenge) {
|
||||
challengeOnlyPrompt = challenge.promptBlock;
|
||||
}
|
||||
|
||||
@@ -890,7 +857,7 @@ export class StartupOrchestrator {
|
||||
if (continuityOutcome !== "fresh") {
|
||||
const preloadActions = input.startupActions.filter(
|
||||
(a) =>
|
||||
!isSessionIdentityAction(a) && !consumedActions.has(a) &&
|
||||
!isSessionIdentityAction(a) &&
|
||||
a.type === "send_text" &&
|
||||
a.phase === "after_ready" &&
|
||||
a.appliesOn.includes(context) &&
|
||||
@@ -938,7 +905,7 @@ export class StartupOrchestrator {
|
||||
}
|
||||
|
||||
// 8. Execute after_files actions
|
||||
const afterFilesResult = await this.executeActions(deliveryInput, "after_files", consumedActions);
|
||||
const afterFilesResult = await this.executeActions(deliveryInput, "after_files");
|
||||
if (!afterFilesResult.ok) {
|
||||
return owed(afterFilesResult.errors) ?? this.fail(deliveryInput, "failed", afterFilesResult.errors, undefined, false, undefined, false, "after_files", true);
|
||||
}
|
||||
@@ -1334,9 +1301,6 @@ export class StartupOrchestrator {
|
||||
}
|
||||
|
||||
private async sendInteractiveText(input: StartupDeliveryInput, text: string, source: StartupSubmissionDiagnostic["source"], actionIndex?: number): Promise<StartupSendFailure | null> {
|
||||
// Contained startup is passed at launch through the verified guest input file. A viewer can exit
|
||||
// between any observation and input; never use its host terminal as a fallback destination.
|
||||
if (input.adapter.environment) return { error: "Environment startup input was not passed through the guest launch; no host input was sent" };
|
||||
// After a refused turn nothing more is typed into the seat. The held input is counted, and startNode reports it as
|
||||
// owed (ahead of any later error) before it would mark the seat ready, so returning here never reads as delivered.
|
||||
if (input.adapter.runtime === "claude-code" && input.sendOrder > 0 && (input.refusedTurn ??= await this.refusedTurn(input))) {
|
||||
|
||||
@@ -98,8 +98,6 @@ export type SuccessorLaunchResult =
|
||||
resumeToken?: string;
|
||||
resumeType?: string;
|
||||
appliedLaunch?: AppliedLaunchObservation;
|
||||
/** How the departing occupant's process ended at the cutover: it exited on TERM, or was killed after the TERM window. */
|
||||
retireeExit: "graceful" | "forced";
|
||||
/**
|
||||
* OPR.0.4.6.02 S1 — non-fatal tmux option-default warnings from the
|
||||
* fresh successor's launch (mouse/status/clipboard). Present only when
|
||||
@@ -317,7 +315,6 @@ export class SuccessorSessionLauncher {
|
||||
resumeToken: started.resumeToken,
|
||||
resumeType: started.resumeType,
|
||||
appliedLaunch: started.appliedLaunch,
|
||||
retireeExit: terminated.path,
|
||||
...(respawned.warnings ? { warnings: respawned.warnings } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -410,21 +410,15 @@ export class TranscriptStore {
|
||||
try {
|
||||
const stat = fstatSync(fd);
|
||||
const CHUNK_SIZE = 64 * 1024;
|
||||
let remainder: string[] = [];
|
||||
let remainder = "";
|
||||
|
||||
for (let offset = 0; offset < stat.size; offset += CHUNK_SIZE) {
|
||||
const readSize = Math.min(CHUNK_SIZE, stat.size - offset);
|
||||
const buf = readTranscriptChunk(fd, readSize, offset);
|
||||
// StringDecoder handles incomplete multibyte sequences at chunk boundaries
|
||||
const lines = decoder.write(buf).split("\n");
|
||||
// Scan only the new chunk. Joining and splitting the growing remainder
|
||||
// on every read makes a long captured line quadratic.
|
||||
if (lines.length === 1) {
|
||||
remainder.push(lines[0]!);
|
||||
continue;
|
||||
}
|
||||
lines[0] = remainder.join("") + lines[0];
|
||||
remainder = [lines.pop() ?? ""];
|
||||
const chunk = remainder + decoder.write(buf);
|
||||
const lines = chunk.split("\n");
|
||||
remainder = lines.pop() ?? "";
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const stripped = this.stripAnsi(rawLine);
|
||||
@@ -438,7 +432,7 @@ export class TranscriptStore {
|
||||
}
|
||||
|
||||
// Flush any remaining bytes from the decoder
|
||||
const finalChunk = remainder.join("") + decoder.end();
|
||||
const finalChunk = remainder + decoder.end();
|
||||
if (finalChunk) {
|
||||
const stripped = this.stripAnsi(finalChunk);
|
||||
for (const subLine of stripped.split("\n")) {
|
||||
|
||||
@@ -389,8 +389,6 @@ export interface NodeStartupSnapshot {
|
||||
resolvedStartupFiles: import("./runtime-adapter.js").ResolvedStartupFile[];
|
||||
startupActions: StartupAction[];
|
||||
runtime: string;
|
||||
/** #875: the guidance file this seat's launch chose and recorded. Absent (every older snapshot) means the rule's file. */
|
||||
guidanceFile?: string;
|
||||
}
|
||||
|
||||
export interface SnapshotData {
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* The runner contract's working beat (section 4): `active` / `liveness` says only that a runner's accepted prompt is
|
||||
* still outstanding and its agent alive, so a long, quiet turn keeps reading working past the 15 s a bare working post
|
||||
* decides for. It isn't progress, and it never starts or extends a turn, clears attention or stands in for a `Stop`.
|
||||
*
|
||||
* A beat counts only when it carries the seat's live occupant generation, for that generation's latest accepted turn
|
||||
* that hasn't ended, with a sequence above every earlier counted post's, and while the activity service shows no
|
||||
* attention outstanding. Attention is read from the service itself, so any post that clears it there (the runner's
|
||||
* `active` / `permission_resolved` after delivering an answer, another counted working or idle post, an operator's
|
||||
* clear) lets that turn's beats count again. The gate orders posts as the service orders a runner's durable
|
||||
* boundaries, so an older post arriving late changes neither. Anything else is ignored, which is how the seat reads
|
||||
* without beats.
|
||||
*
|
||||
* The gate keeps what it saw in memory. After a daemon restart beats are ignored until the runner's next accepted
|
||||
* prompt, so the seat reads as it would without them.
|
||||
*/
|
||||
|
||||
import { hookPrecedes } from "./agent-activity-store.js";
|
||||
|
||||
export const WORKING_LIVENESS_DEFAULT_SECONDS = 30;
|
||||
export const WORKING_LIVENESS_MIN_SECONDS = 5;
|
||||
export const WORKING_LIVENESS_MAX_SECONDS = 60;
|
||||
|
||||
export type WorkingLivenessIgnored =
|
||||
| "no_turn" | "stale_generation" | "turn_ended" | "wrong_turn" | "no_sequence" | "out_of_order" | "attention_outstanding";
|
||||
|
||||
export function isWorkingLivenessBeat(hookEvent: unknown, subtype: unknown): boolean {
|
||||
return hookEvent === "active" && subtype === "liveness";
|
||||
}
|
||||
|
||||
/** How long one beat counts as working: the beat's own livenessSeconds clamped to 5-60 s, or 30 s without one. A value
|
||||
* out of range still gives bounded evidence; it's never a refusal. */
|
||||
export function workingLivenessSeconds(raw: unknown): number {
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) return WORKING_LIVENESS_DEFAULT_SECONDS;
|
||||
return Math.min(WORKING_LIVENESS_MAX_SECONDS, Math.max(WORKING_LIVENESS_MIN_SECONDS, Math.round(raw)));
|
||||
}
|
||||
|
||||
/** A counted post that isn't a beat, as the gate needs it. */
|
||||
export interface WorkingLivenessPost {
|
||||
generation: string | null;
|
||||
hookEvent: string;
|
||||
subtype: string | null;
|
||||
turnId: string | null;
|
||||
sequence: number | null;
|
||||
/** When it happened, as the activity service orders it. */
|
||||
eventAt: string | null;
|
||||
/** The runner's report that its agent process ended. */
|
||||
exit: boolean;
|
||||
}
|
||||
|
||||
interface SeatTurn {
|
||||
generation: string | null;
|
||||
/** The latest accepted turn, with the generation its prompt was accepted under. */
|
||||
turn: { id: string; generation: string | null } | null;
|
||||
open: boolean;
|
||||
sequence: number | null;
|
||||
latest: { eventAt: string | null; sourceSequence: number | null } | null;
|
||||
}
|
||||
|
||||
export class WorkingLivenessGate {
|
||||
private readonly seats = new Map<string, SeatTurn>();
|
||||
|
||||
/** Every post the activity service counts for a seat, other than beats. A new generation starts the seat over; a post
|
||||
* older than the latest one changes nothing, as the service drops it. */
|
||||
observe(nodeId: string, post: WorkingLivenessPost): void {
|
||||
let seat = this.seats.get(nodeId);
|
||||
if (!seat || (post.generation && seat.generation && post.generation !== seat.generation)) {
|
||||
seat = { generation: post.generation, turn: null, open: false, sequence: null, latest: null };
|
||||
this.seats.set(nodeId, seat);
|
||||
}
|
||||
const at = { eventAt: post.eventAt, sourceSequence: post.sequence };
|
||||
if (seat.latest && hookPrecedes(at, seat.latest)) return;
|
||||
seat.latest = at;
|
||||
if (post.generation) seat.generation = post.generation;
|
||||
if (post.sequence !== null) seat.sequence = Math.max(seat.sequence ?? 0, post.sequence);
|
||||
if (post.hookEvent === "UserPromptSubmit" && post.subtype === "accepted" && post.turnId) {
|
||||
seat.turn = { id: post.turnId, generation: post.generation };
|
||||
seat.open = true;
|
||||
}
|
||||
if (post.hookEvent === "Stop" || post.exit) seat.open = false;
|
||||
}
|
||||
|
||||
/** Whether a beat counts. `liveGeneration` is the seat's current occupant's, and `attention` whether the activity
|
||||
* service shows the seat waiting on a person. It changes nothing: `count` does, once the beat is recorded. */
|
||||
admit(nodeId: string, beat: { generation: string | null; liveGeneration: string | null; turnId: string | null;
|
||||
sequence: number | null; eventAt: string | null; attention: boolean }):
|
||||
{ ok: true } | { ok: false; reason: WorkingLivenessIgnored } {
|
||||
const seat = this.seats.get(nodeId);
|
||||
if (!seat?.turn) return { ok: false, reason: "no_turn" };
|
||||
if (!beat.generation || beat.generation !== beat.liveGeneration || beat.generation !== seat.turn.generation) {
|
||||
return { ok: false, reason: "stale_generation" };
|
||||
}
|
||||
if (!seat.open) return { ok: false, reason: "turn_ended" };
|
||||
if (beat.turnId !== seat.turn.id) return { ok: false, reason: "wrong_turn" };
|
||||
if (beat.sequence === null) return { ok: false, reason: "no_sequence" };
|
||||
const at = { eventAt: beat.eventAt, sourceSequence: beat.sequence };
|
||||
if (seat.sequence === null || beat.sequence <= seat.sequence || (seat.latest && hookPrecedes(at, seat.latest))) {
|
||||
return { ok: false, reason: "out_of_order" };
|
||||
}
|
||||
if (beat.attention) return { ok: false, reason: "attention_outstanding" };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** A beat that was admitted and recorded, at the time the store recorded for it (its `occurredAt`, or when it arrived).
|
||||
* One older than the latest counted post is dropped, as `observe` and the activity service drop it. Otherwise it
|
||||
* raises the seat's sequence, so a delayed or repeated beat doesn't count again, and later posts are ordered against
|
||||
* its time. */
|
||||
count(nodeId: string, beat: { sequence: number | null; eventAt: string | null }): void {
|
||||
const seat = this.seats.get(nodeId);
|
||||
if (!seat || beat.sequence === null) return;
|
||||
const at = { eventAt: beat.eventAt, sourceSequence: beat.sequence };
|
||||
if (seat.latest && hookPrecedes(at, seat.latest)) return;
|
||||
seat.sequence = Math.max(seat.sequence ?? 0, beat.sequence);
|
||||
seat.latest = at;
|
||||
}
|
||||
}
|
||||
@@ -174,7 +174,6 @@ function validateFile(
|
||||
|
||||
let parsed: Record<string, unknown> | null;
|
||||
try {
|
||||
if (fm.body === null) throw new Error("unterminated frontmatter: missing closing --- delimiter");
|
||||
parsed = parseYaml(fm.body) as Record<string, unknown> | null;
|
||||
} catch (err) {
|
||||
out.gaps.push({
|
||||
@@ -187,7 +186,7 @@ function validateFile(
|
||||
});
|
||||
return out;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
out.gaps.push({
|
||||
filePath,
|
||||
relativePath,
|
||||
@@ -230,7 +229,7 @@ function validateFile(
|
||||
|
||||
interface FrontmatterExtraction {
|
||||
found: boolean;
|
||||
body: string | null;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** Extract YAML frontmatter delimited by `---` lines. The opening `---`
|
||||
@@ -246,5 +245,5 @@ function extractFrontmatter(raw: string): FrontmatterExtraction {
|
||||
return { found: true, body: lines.slice(1, i).join("\n") };
|
||||
}
|
||||
}
|
||||
return { found: true, body: null };
|
||||
return { found: false, body: "" };
|
||||
}
|
||||
@@ -195,7 +195,7 @@ export function startWakeLadderScheduler(deps: {
|
||||
usageLimitJitterSeconds?: number;
|
||||
seatActivityService?: Pick<import("./domain/seat-activity-service.js").SeatActivityService, "getSeatState">;
|
||||
agentActivityStore?: Pick<import("./domain/agent-activity-store.js").AgentActivityStore, "getLatestForNode">;
|
||||
tmuxAdapter?: Pick<import("./adapters/tmux.js").TmuxAdapter, "listPanes" | "readAllPaneProcesses" | "capturePaneScreen" | "getPanePid">;
|
||||
tmuxAdapter?: Pick<import("./adapters/tmux.js").TmuxAdapter, "listPanes" | "readAllPaneProcesses" | "capturePaneScreen">;
|
||||
gatewaySubsystem?: { status?: () => { state?: unknown }; dispatch: (op: string, entityBindingRef: string, payload: unknown, opts?: { decisionId?: string }) => import("./domain/gateway/dispatcher.js").DispatchResult };
|
||||
}): WakeLadderScheduler | null {
|
||||
const queueRepo = deps.queueRepo;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Hono, type Context } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import type { AgentActivityStore } from "../domain/agent-activity-store.js";
|
||||
import { hookEventState, isRunnerExit } from "../domain/agent-activity-store.js";
|
||||
import { WorkingLivenessGate, isWorkingLivenessBeat, workingLivenessSeconds, type WorkingLivenessIgnored } from "../domain/working-liveness.js";
|
||||
import type { EventBus } from "../domain/event-bus.js";
|
||||
import { verifyStartupProof } from "../domain/startup-proof.js";
|
||||
import { ActivityGrantStore, grantGeneration, type ActivityGrant } from "../domain/activity-grants.js";
|
||||
@@ -35,8 +34,6 @@ import {
|
||||
* exclusion); the turn's working/idle stays whatever other evidence says. */
|
||||
export function evidenceFromHookActivity(input: {
|
||||
seatNodeId: string;
|
||||
/** A counted working beat: how long it counts as working, in place of the hook window. */
|
||||
workingLivenessSeconds?: number;
|
||||
sessionName: string;
|
||||
runtime: string | null;
|
||||
activity: AgentActivity;
|
||||
@@ -52,7 +49,6 @@ export function evidenceFromHookActivity(input: {
|
||||
observedAt: input.activity.eventAt ?? input.activity.sampledAt,
|
||||
...(input.activity.sourceSequence ? { sourceSequence: input.activity.sourceSequence } : {}),
|
||||
...(input.activity.livenessSeconds ? { livenessSeconds: input.activity.livenessSeconds } : {}),
|
||||
...(input.workingLivenessSeconds ? { workingLivenessSeconds: input.workingLivenessSeconds } : {}),
|
||||
...(input.route ? { route: input.route } : {}),
|
||||
};
|
||||
if (input.activity.reason === "permission_request_pending") {
|
||||
@@ -96,10 +92,7 @@ export function restoredRunnerHookEvidence(
|
||||
const state = !latest.stale ? latest.state
|
||||
: latest.reason === "stale_runtime_hook"
|
||||
? hookEventState(latest.rawEvent, latest.rawSubtype, runtime, latest.communityRunner === true) : "unknown";
|
||||
// A working beat keeps the lifetime it was admitted with, as when it was posted.
|
||||
return evidenceFromHookActivity({ seatNodeId: seat.seatNodeId, sessionName: seat.sessionName, runtime,
|
||||
...(isWorkingLivenessBeat(latest.rawEvent, latest.rawSubtype)
|
||||
? { workingLivenessSeconds: workingLivenessSeconds(latest.livenessSeconds) } : {}),
|
||||
activity: { ...latest, state }, seq: 0 });
|
||||
}
|
||||
|
||||
@@ -112,20 +105,6 @@ export function hookRelayRoute(value: unknown): EvidenceRoute | undefined {
|
||||
|
||||
// Per-source monotonic seq for ingested hook evidence (the relay does not mint one).
|
||||
const hookEvidenceSeq = new Map<string, number>();
|
||||
// What each seat's runner has said about its turns, for deciding whether a working beat counts (the runner contract §4).
|
||||
const workingLiveness = new WorkingLivenessGate();
|
||||
function sourceSequence(body: Record<string, unknown>): number | null {
|
||||
return typeof body.sequence === "number" && Number.isSafeInteger(body.sequence) && body.sequence > 0 ? body.sequence : null;
|
||||
}
|
||||
/** The seat's current occupant generation, as a runner's permission request checks it; null when none resolves. */
|
||||
function liveOccupantGeneration(c: Context, store: AgentActivityStore, nodeId: string): string | null {
|
||||
const registry = (c.get("sessionRegistry" as never) as SessionRegistry | undefined) ?? new SessionRegistry(store.db);
|
||||
try {
|
||||
return registry.currentOccupantTenure(nodeId)?.generationUuid ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function nextHookSeq(key: string): number {
|
||||
const next = (hookEvidenceSeq.get(key) ?? 0) + 1;
|
||||
hookEvidenceSeq.set(key, next);
|
||||
@@ -621,30 +600,13 @@ async function postHook(c: Context, installTokenAccepted: boolean): Promise<Resp
|
||||
}
|
||||
|
||||
const recorded = recordHookActivity(c, store, body);
|
||||
if ("ignored" in recorded) return c.json({ ok: true, ignored: recorded.ignored });
|
||||
return "refused" in recorded ? recorded.refused : c.json({ ok: true, activity: recorded.activity });
|
||||
}
|
||||
|
||||
/** Record one hook event for its seat and feed it to the activity oracle: the tail of the hook ingress, also used when a
|
||||
* runner's permission request is submitted (its permission prompt). */
|
||||
function recordHookActivity(c: Context, store: AgentActivityStore, body: Record<string, unknown>):
|
||||
{ activity: AgentActivity } | { refused: Response } | { ignored: WorkingLivenessIgnored } {
|
||||
const oracle = c.get("seatActivityService" as never) as
|
||||
| import("../domain/seat-activity-service.js").SeatActivityService
|
||||
| undefined;
|
||||
// A working beat counts only for the live occupant's open turn, in order and outside attention. One that doesn't is
|
||||
// ignored before anything is recorded, so it can't change what the seat or its send gate reads.
|
||||
const beat = isWorkingLivenessBeat(body.hookEvent, body.subtype);
|
||||
if (beat) {
|
||||
const sessionName = stringOrNull(body.sessionName);
|
||||
const nodeId = stringOrNull(body.nodeId) ?? (sessionName ? store.resolveSession({ sessionName })?.nodeId ?? null : null);
|
||||
const admitted = nodeId
|
||||
? workingLiveness.admit(nodeId, { generation: stringOrNull(body.generation), liveGeneration: liveOccupantGeneration(c, store, nodeId),
|
||||
turnId: stringOrNull(body.turnId), sequence: sourceSequence(body), eventAt: stringOrNull(body.occurredAt),
|
||||
attention: (oracle?.getSeatState(nodeId)?.needsInput.count ?? 0) > 0 })
|
||||
: { ok: false as const, reason: "no_turn" as const };
|
||||
if (!admitted.ok) return { ignored: admitted.reason };
|
||||
}
|
||||
{ activity: AgentActivity } | { refused: Response } {
|
||||
const result = store.recordHookEvent({
|
||||
runtime: stringOrNull(body.runtime),
|
||||
sessionName: stringOrNull(body.sessionName),
|
||||
@@ -659,11 +621,9 @@ function recordHookActivity(c: Context, store: AgentActivityStore, body: Record<
|
||||
turnId: stringOrNull(body.turnId),
|
||||
...(body.hookAgentId === null || (typeof body.hookAgentId === "string" && body.hookAgentId.trim().length > 0)
|
||||
? { hookAgentId: body.hookAgentId } : {}),
|
||||
sourceSequence: sourceSequence(body),
|
||||
// A counted beat is recorded with the lifetime it was admitted with, so a restore reads it back the same.
|
||||
livenessSeconds: beat ? workingLivenessSeconds(body.livenessSeconds)
|
||||
: typeof body.livenessSeconds === "number" && Number.isSafeInteger(body.livenessSeconds)
|
||||
&& body.livenessSeconds > 0 && body.livenessSeconds <= 3600 ? body.livenessSeconds : null,
|
||||
sourceSequence: typeof body.sequence === "number" && Number.isSafeInteger(body.sequence) && body.sequence > 0 ? body.sequence : null,
|
||||
livenessSeconds: typeof body.livenessSeconds === "number" && Number.isSafeInteger(body.livenessSeconds)
|
||||
&& body.livenessSeconds > 0 && body.livenessSeconds <= 3600 ? body.livenessSeconds : null,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
@@ -675,6 +635,9 @@ function recordHookActivity(c: Context, store: AgentActivityStore, body: Record<
|
||||
// normalized) event becomes ladder evidence on the lifecycle-hooks rung. The store
|
||||
// remains the raw-event recorder (startup-proof, delivery verification); arbitration
|
||||
// happens only in SeatActivityService.
|
||||
const oracle = c.get("seatActivityService" as never) as
|
||||
| import("../domain/seat-activity-service.js").SeatActivityService
|
||||
| undefined;
|
||||
const emitted = result.event as { nodeId?: string; sessionName?: string; runtime?: string } | undefined;
|
||||
// Recording a historical hook is valid, but its raw activity cannot staff the
|
||||
// current oracle. The store may resolve nodeId to a newer session even when
|
||||
@@ -707,25 +670,10 @@ function recordHookActivity(c: Context, store: AgentActivityStore, body: Record<
|
||||
runtimeRungInventory(runtime, currentSession?.community_harness ?? null, currentSession?.community_runner === 1),
|
||||
);
|
||||
}
|
||||
if (beat) {
|
||||
workingLiveness.count(emitted.nodeId, { sequence: result.activity.sourceSequence ?? null,
|
||||
eventAt: result.activity.eventAt ?? result.activity.sampledAt ?? null });
|
||||
} else {
|
||||
workingLiveness.observe(emitted.nodeId, {
|
||||
generation: result.activity.generation ?? null,
|
||||
hookEvent: result.activity.rawEvent ?? (typeof body.hookEvent === "string" ? body.hookEvent : ""),
|
||||
subtype: result.activity.rawSubtype ?? stringOrNull(body.subtype),
|
||||
turnId: result.activity.turnId ?? null,
|
||||
sequence: result.activity.sourceSequence ?? null,
|
||||
eventAt: result.activity.eventAt ?? result.activity.sampledAt ?? null,
|
||||
exit: result.activity.rawEvent === "Stop" && isRunnerExit(result.activity.rawSubtype, runtime, result.activity.communityRunner === true),
|
||||
});
|
||||
}
|
||||
const evidence = evidenceFromHookActivity({
|
||||
seatNodeId: emitted.nodeId,
|
||||
sessionName: emitted.sessionName,
|
||||
runtime,
|
||||
...(beat ? { workingLivenessSeconds: workingLivenessSeconds(body.livenessSeconds) } : {}),
|
||||
activity: result.activity,
|
||||
seq: nextHookSeq(`${emitted.nodeId}:${runtime ?? "unknown-runtime"}:hooks`),
|
||||
route: hookRelayRoute(body.relayRoute),
|
||||
|
||||
@@ -12,7 +12,6 @@ import { readMissionReadiness } from "../domain/proof/judgments.js";
|
||||
import { WorkflowInstanceStore } from "../domain/workflow-instance-store.js";
|
||||
import { WorkflowStepTrailLog } from "../domain/workflow-step-trail-log.js";
|
||||
import { feedPerspective } from "../domain/feed-perspective.js";
|
||||
import { resolveRegisteredHumanAddress } from "../domain/gateway/human-registry.js";
|
||||
import { UNNAMED_PRIMARY_USER } from "../domain/mission-control/mission-control-write-contract.js";
|
||||
|
||||
export function attentionRoutes(): Hono {
|
||||
@@ -47,19 +46,11 @@ export function attentionRoutes(): Hono {
|
||||
const asked = new Set(rows.slice(0, 1000).filter(q => queue.isCurrentHumanAsk(q.qitemId)).map(q => q.qitemId));
|
||||
const quiet = new Set(rows.slice(0, 1000).filter(q => asked.has(q.qitemId) && queue.humanAskWithoutAlert(q.qitemId)).map(q => q.qitemId));
|
||||
const current = rows.slice(0, 1000).filter(q => isHumanSeatSessionRef(q.destinationSession) || asked.has(q.qitemId) || q.state === "blocked" && isHumanSeatSessionRef(q.blockedOn ?? ""));
|
||||
// A person with no connector binding is asked on Steering and in the Feed only: nothing is posted to them in Slack.
|
||||
const people = queue.humanRegistry();
|
||||
const noConnector = (address: string | null | undefined): boolean => {
|
||||
if (!people.ok) return false;
|
||||
const person = resolveRegisteredHumanAddress(address, people.entities);
|
||||
return people.entities.some((entity) => entity.address === person && entity.connectorBindings.length === 0);
|
||||
};
|
||||
// Whether the request went to Slack: its own receipt first, then whether it was ever to be sent.
|
||||
const slackLine = (qitemId: string, unbound: boolean): string => {
|
||||
const slackLine = (qitemId: string): string => {
|
||||
const delivery = queue.deliveryOutcomeFor(qitemId);
|
||||
if (delivery) return delivery.outcome === "posted" ? "posted (posted isn't read)" : `${delivery.outcome}: ${delivery.detail}`;
|
||||
return unbound ? "not sent: this person has no connector binding, so they're asked on Steering and in the Feed only"
|
||||
: quiet.has(qitemId) ? "not sent: created without --alert, and this person's Slack subscription is urgent-only" : "no receipt yet";
|
||||
return quiet.has(qitemId) ? "not sent: created without --alert, and this person's Slack subscription is urgent-only" : "no receipt yet";
|
||||
};
|
||||
// Keep an opened request inspectable after its source is resolved, without
|
||||
// retaining it in the current Action required feed or inventing an inbox.
|
||||
@@ -85,15 +76,13 @@ export function attentionRoutes(): Hono {
|
||||
...(toPerson ? { asker: q.sourceSession } : {}),
|
||||
...(questions ? { humanQuestions: questions, humanAnswers: q.humanAnswers ?? {} } : {}),
|
||||
summary: q.summary || q.body.trim().split(/\r?\n/).find(Boolean) || "Request summary unavailable", urgency: q.priority, unblocks, at: q.tsUpdated, scope: p ? `project ${p.id}` : "instance · project unknown", project: p ? { id: p.id, root: p.root } : null, source: `/api/queue/${encodeURIComponent(q.qitemId)}` };
|
||||
const unbound = noConnector(toPerson ? q.destinationSession : q.blockedOn);
|
||||
const notSent = unbound || quiet.has(q.qitemId);
|
||||
const route = unanswered
|
||||
? `Decision route: answer its questions here, choosing an option or typing a reply${notSent ? "; it wasn't sent to Slack" : ", or in the correlated Slack message"}.`
|
||||
? `Decision route: answer its questions here, choosing an option or typing a reply${quiet.has(q.qitemId) ? "; it wasn't sent to Slack" : ", or in the correlated Slack message"}.`
|
||||
: questions && q.state === "pending" ? "Decision route: its questions are all answered, but the answers haven't gone back to the asker yet: send them from here."
|
||||
: notSent ? "Decision route: answer on Steering; it wasn't sent to Slack." : "Decision route: follow the correlated human request in Slack.";
|
||||
: quiet.has(q.qitemId) ? "Decision route: answer on Steering; it wasn't sent to Slack." : "Decision route: follow the correlated human request in Slack.";
|
||||
add(item, () => {
|
||||
const mode = posture?.resolve({ qitemId: q.qitemId });
|
||||
return ["Request:", q.body, ...(q.humanDetail ? ["Supplemental detail:", q.humanDetail] : []), `State: ${q.state}`, `To: ${q.destinationSession}`, `From: ${q.sourceSession}`, `Blocked on: ${q.blockedOn ?? "none"}`, `Posture: ${mode?.posture ?? "unknown"} · ${mode?.reason ?? "posture source unavailable"}`, `Evidence reference: ${q.evidenceRef ?? "none recorded"}`, `Slack: ${slackLine(q.qitemId, unbound)}`, `${route} Inspect: rig queue show ${q.qitemId} --full`, "Queue history (read/delivery is not approval):", ...queue.listTransitions(q.qitemId).map(t => JSON.stringify(t))];
|
||||
return ["Request:", q.body, ...(q.humanDetail ? ["Supplemental detail:", q.humanDetail] : []), `State: ${q.state}`, `To: ${q.destinationSession}`, `From: ${q.sourceSession}`, `Blocked on: ${q.blockedOn ?? "none"}`, `Posture: ${mode?.posture ?? "unknown"} · ${mode?.reason ?? "posture source unavailable"}`, `Evidence reference: ${q.evidenceRef ?? "none recorded"}`, `Slack: ${slackLine(q.qitemId)}`, `${route} Inspect: rig queue show ${q.qitemId} --full`, "Queue history (read/delivery is not approval):", ...queue.listTransitions(q.qitemId).map(t => JSON.stringify(t))];
|
||||
}, () => {
|
||||
if (!q.evidenceRef || !path.isAbsolute(q.evidenceRef) && (!p || /^[a-z][a-z\d+.-]*:/i.test(q.evidenceRef))) return [];
|
||||
const hash = q.evidenceRef.indexOf("#"), anchor = hash < 0 ? "" : q.evidenceRef.slice(hash);
|
||||
|
||||
@@ -1179,7 +1179,6 @@ export function queueRoutes(): Hono {
|
||||
activeOnly,
|
||||
handedOffFrom,
|
||||
updatedSince,
|
||||
sortByUpdated: c.req.query("sort") === "updated",
|
||||
});
|
||||
return c.json(items);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { PodRigInstantiator } from "../domain/rigspec-instantiator.js";
|
||||
import type { ResumeMetadataRefresher } from "../domain/resume-metadata-refresher.js";
|
||||
import type { TmuxAdapter } from "../adapters/tmux.js";
|
||||
import type { Node, RigWithRelations, Snapshot, SnapshotData } from "../domain/types.js";
|
||||
import { defaultProbeRuntimes, prepareNewKernelCwd } from "../domain/kernel-boot.js";
|
||||
import { defaultProbeRuntimes } from "../domain/kernel-boot.js";
|
||||
import { SettingsStore } from "../domain/user-settings/settings-store.js";
|
||||
import { buildRestorePlanPreview, collectPreviewSessionRows } from "../domain/restore-plan-preview.js";
|
||||
import { assessCurrentStateRehydrateEligibility } from "../domain/rehydrate-eligibility.js";
|
||||
@@ -215,7 +215,7 @@ startupRoutes.post("/kernel", (c) => exclusive(c, "kernel", async () => {
|
||||
const root = kernelRoot();
|
||||
const source = readFileSync(root + (body.runtime === "codex" ? "rig-codex-only.yaml" : "rig-claude-only.yaml"), "utf8");
|
||||
const result = await dep<PodRigInstantiator>(c, "podInstantiator").materialize(source, root, {
|
||||
cwdOverride: prepareNewKernelCwd(new SettingsStore().resolveConfig().workspaceRoot),
|
||||
cwdOverride: new SettingsStore().resolveConfig().workspaceRoot,
|
||||
});
|
||||
return c.json(result.ok ? { ok: true, rigId: result.result.rigId, message: "Kernel prepared. Choose the seats to start." } : result, result.ok ? 200 : 409);
|
||||
}));
|
||||
|
||||
@@ -16,9 +16,6 @@ import { wrapPaneEnvelope } from "../lib/pane-envelope.js";
|
||||
const UNKNOWN_SENDER_NOTICE =
|
||||
"Delivered without sender identity: this request carried no X-OpenRig-Session header, so your recipient has no way of knowing who sent it. Follow up and sign it: from a seat shell the header stamps automatically; from anywhere else, send it as a declared sender, `rig queue create --source <you>@external --destination <seat> --summary \"<one line>\" --body-file <file>` (recorded as declared; a registered human gets a reply route), or join this shell to a seat, `eval \"$(rig attach --self --rig <rigId> --node <logicalId> --print-env)\"`, so its sends carry that seat's name (`rig attach --help` covers adding a new member).";
|
||||
|
||||
// A fan-out where every recipient holds the text hasn't delivered yet, but the text still arrives unsigned.
|
||||
const UNKNOWN_SENDER_HELD_NOTICE = UNKNOWN_SENDER_NOTICE.replace(/^Delivered/, "Held for delivery");
|
||||
|
||||
// The unknown-sender From: marker, DERIVED from the canonical envelope wrapper
|
||||
// (pane-envelope.ts SENDER_FALLBACK is unexported and its literal is
|
||||
// canonicity-guarded to exactly two twin sites) — deriving at module load keeps
|
||||
@@ -431,10 +428,9 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
|
||||
}
|
||||
|
||||
// S2 (OPR.0.5.4.3) sender-side honesty: an unattributed fan-out's response carries the
|
||||
// sign-it notice for the CLI renderers to surface. Attributed fan-outs are unchanged. A fan-out that reached nobody
|
||||
// (an unknown rig, every send refused) doesn't carry it, but a retained send is typed later and is still unsigned.
|
||||
if (!derivedActor && result.sent + (result.retained ?? 0) > 0) {
|
||||
return c.json({ ...result, warning: result.sent > 0 ? UNKNOWN_SENDER_NOTICE : UNKNOWN_SENDER_HELD_NOTICE });
|
||||
// sign-it notice for the CLI renderers to surface. Attributed fan-outs are unchanged.
|
||||
if (!derivedActor) {
|
||||
return c.json({ ...result, warning: UNKNOWN_SENDER_NOTICE });
|
||||
}
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
@@ -204,7 +204,6 @@ import { withCommunityHarnessResolver, withCommunityHarnesses } from "./adapters
|
||||
import { ensureActivityHookToken, writeActivityEndpointFile, deriveActivityUrl, readActivityEndpointFile } from "./domain/activity-endpoint.js";
|
||||
import { recoverSeatSkillAttempts } from "./domain/seat-skill-apply.js";
|
||||
import { restoreExistingRigUnattended } from "./domain/existing-rig-restore.js";
|
||||
import { lstatLinkInfo } from "./domain/restore-guidance.js";
|
||||
import {
|
||||
getCompatibleOpenRigPath,
|
||||
getDefaultOpenRigPath,
|
||||
@@ -957,8 +956,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
|
||||
const startupOrchestrator = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter, readFile: (p: string) => fs.readFileSync(p, "utf-8") });
|
||||
const runtimeSettings = new ContextPackSettingsStore().resolveConfig();
|
||||
const claudeCollectorAssetPath = nodePath.resolve(import.meta.dirname, "../assets/claude-statusline-context.cjs");
|
||||
const claudeAdapter = new ClaudeCodeAdapter({ tmux: tmuxAdapter, seatLaunchEnvironment, claudeManagedLaunch, restoreReadPolicy, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), copyFile: replaceFile, listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, readdir: (dir: string) => fs.readdirSync(dir), statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: os.homedir() }, stateDir: OPENRIG_HOME, collectorAssetPath: claudeCollectorAssetPath, autoDriveProviderPrompts: runtimeSettings.recoveryAutoDriveProviderPrompts, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), claudeHooksManifestPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/claude.json"), recordProjection: (targetPath: string, content: string) => projectionManifestStore.record({ targetPath, lastHash: hashContent(content), writtenAt: new Date().toISOString() }) });
|
||||
const claudeAdapter = new ClaudeCodeAdapter({ tmux: tmuxAdapter, seatLaunchEnvironment, claudeManagedLaunch, restoreReadPolicy, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), copyFile: replaceFile, listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, readdir: (dir: string) => fs.readdirSync(dir), statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: os.homedir() }, stateDir: OPENRIG_HOME, collectorAssetPath: nodePath.resolve(import.meta.dirname, "../assets/claude-statusline-context.cjs"), autoDriveProviderPrompts: runtimeSettings.recoveryAutoDriveProviderPrompts, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), claudeHooksManifestPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/claude.json"), recordProjection: (targetPath: string, content: string) => projectionManifestStore.record({ targetPath, lastHash: hashContent(content), writtenAt: new Date().toISOString() }) });
|
||||
const codexAdapter = new CodexRuntimeAdapter({ prepareTeamWorkspace, prepareSeatFolder, tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: daemonHome }, codexHome: configuredCodexHome, seatHome: (nodeId: string, ignoreAccountHome?: boolean) => seatRuntimeHome(db, nodeId, "codex", ignoreAccountHome).home, launchPath: process.env.PATH, detectDaemonSupport: codexDaemonSupportProbe(process.env.PATH, undefined, configuredCodexHome), readNetworkDefault: readCodexNetworkDefault, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), resolveCorePluginDir: () => vendoredCorePluginDir(), resolveCorePluginSelected: (nodeId: string) => Boolean(seatCodexCorePluginDir({ id: nodeId, runtime: "codex" }, db)), resolveHooksEnabled: async () => {
|
||||
const { SettingsStore } = await import("./domain/user-settings/settings-store.js");
|
||||
return new SettingsStore().resolveOne("runtime.codex.hooks_enabled").value as boolean;
|
||||
@@ -973,7 +971,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
} });
|
||||
// OPR.0.4.6.PI1 — the RPC-first Pi adapter (runner-in-a-pane). Same fsOps
|
||||
// shape as the Codex adapter; seat isolation roots under piStateRoot.
|
||||
const piAdapter = new PiRuntimeAdapter({ tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), linkInfo: lstatLinkInfo }, stateRoot: piStateRoot, runnerEntryPath: piRunnerEntryPath, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled });
|
||||
const piAdapter = new PiRuntimeAdapter({ tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m) }, stateRoot: piStateRoot, runnerEntryPath: piRunnerEntryPath, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled });
|
||||
const ompAdapter = new OmpRuntimeAdapter({ tmux: tmuxAdapter, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; } }, stateRoot: ompStateRoot, runnerEntryPath: piRunnerEntryPath });
|
||||
// OPR.0.5.1.1 — the stub runtime adapter (Pi-shaped node-script runner in a pane).
|
||||
// Same fsOps shape as Pi; the compiled runner entry lives in the daemon dist.
|
||||
@@ -1269,8 +1267,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// workspace, not the daemon installation tree. Without this
|
||||
// cwdOverride, BootstrapOrchestrator refuses with
|
||||
// "cwd is inside the OpenRig installation". Use the resolved
|
||||
// workspace.root setting as the per-operator default; a new kernel
|
||||
// works in its `kernel` folder (prepareNewKernelCwd).
|
||||
// workspace.root setting as the per-operator default.
|
||||
cwdOverride: runtimeSettings.workspaceRoot,
|
||||
degradedTimeoutMs,
|
||||
// A kernel a reboot left down comes back the way `rig up kernel --existing` brings it back.
|
||||
@@ -1542,7 +1539,6 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
}),
|
||||
teardownOrchestrator: new RigTeardownOrchestrator({
|
||||
db, rigRepo, sessionRegistry, tmuxAdapter, snapshotCapture, eventBus, resumeMetadataRefresher, serviceOrchestrator,
|
||||
claudeCollectorAssetPath,
|
||||
}),
|
||||
podInstantiator,
|
||||
podBundleSourceResolver,
|
||||
|
||||
@@ -2789,79 +2789,3 @@ describe("a seat's own permission envelope (argv only, not native enforcement)",
|
||||
expect(command).not.toContain("exclude_slash_tmp");
|
||||
});
|
||||
});
|
||||
|
||||
// #875 Codex: on a fresh launch of a supported Codex, OpenRig's managed guidance goes in as the launch's developer
|
||||
// instructions (`-c developer_instructions`, Codex 0.164.0), so the person's tracked repository AGENTS.md and the Codex
|
||||
// home are left alone. Anything else keeps the repository AGENTS.md, as before.
|
||||
describe("a fresh Codex seat's managed guidance as launch developer instructions (#875)", () => {
|
||||
const HOME = "/codex-home";
|
||||
const adapterFor = (fs: CodexAdapterFsOps, tmux = mockTmux(), codexVersion: string | null = "0.164.0") => new CodexRuntimeAdapter({
|
||||
tmux, fsOps: fs, codexHome: HOME, listProcesses: () => [], sleep: async () => {},
|
||||
resolveManagedConfigSettings: async () => ({ enabled: false, ...(codexVersion ? { codexVersion } : {}) }),
|
||||
});
|
||||
const plan = (entries: ProjectionEntry[]): ProjectionPlan => ({ runtime: "codex", cwd: "/project", entries,
|
||||
startup: { files: [], actions: [] }, conflicts: [], noOps: [], diagnostics: [] } as unknown as ProjectionPlan);
|
||||
const guidance = (effectiveId: string, absolutePath: string) => makeEntry({ category: "guidance", effectiveId, mergeStrategy: "managed_block", absolutePath });
|
||||
const startupFile = (path: string, absolutePath: string): ResolvedStartupFile => ({ path, absolutePath, ownerRoot: nodePath.dirname(absolutePath),
|
||||
deliveryHint: "guidance_merge", required: true, appliesOn: ["fresh_start"] });
|
||||
const instructionsIn = (command: string): string | undefined => {
|
||||
const arg = /-c 'developer_instructions=([^']*)'/.exec(command)?.[1];
|
||||
return arg === undefined ? undefined : JSON.parse(arg) as string;
|
||||
};
|
||||
|
||||
it("chooses launch instructions only for a fresh launch of Codex 0.164.0 or later with none configured", async () => {
|
||||
expect(await adapterFor(mockFs()).guidanceFileFor(makeBinding())).toEqual({ file: null, launchInstructions: true });
|
||||
expect(await adapterFor(mockFs(), mockTmux(), "0.165.2").guidanceFileFor(makeBinding())).toEqual({ file: null, launchInstructions: true });
|
||||
expect(await adapterFor(mockFs()).guidanceFileFor(makeBinding(), { resuming: true })).toEqual({ file: null });
|
||||
expect(await adapterFor(mockFs(), mockTmux(), "0.163.9").guidanceFileFor(makeBinding())).toEqual({ file: null });
|
||||
expect(await adapterFor(mockFs(), mockTmux(), null).guidanceFileFor(makeBinding())).toEqual({ file: null });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the Codex home config", `${HOME}/config.toml`, 'developer_instructions = "Mine."\n'],
|
||||
["a profile in the Codex home config", `${HOME}/config.toml`, '[profiles.work]\ndeveloper_instructions = "Mine."\n'],
|
||||
["the project config", "/project/.codex/config.toml", 'developer_instructions = "Mine."\n'],
|
||||
["an unreadable config layer", `${HOME}/config.toml`, "this is = = not toml"],
|
||||
])("keeps the repository AGENTS.md when %s sets developer_instructions or can't be read, and says why", async (_case, path, text) => {
|
||||
const chosen = await adapterFor(mockFs({ [path]: text })).guidanceFileFor(makeBinding());
|
||||
expect(chosen.launchInstructions).toBeUndefined();
|
||||
expect(chosen.file).toBeNull();
|
||||
expect(chosen.note).toMatch(/repository AGENTS\.md/);
|
||||
});
|
||||
|
||||
it("sends the guidance with the fresh launch, writes neither the repository nor the home AGENTS.md, and keeps roles and the seat-skills line out", async () => {
|
||||
const fs = mockFs({ "/spec/culture.md": "Team culture.", "/spec/SOP.md": "How work flows here.", "/spec/rig-role.md": "Seat role text.",
|
||||
"/spec/openrig-seat-skills.md": "Seat skills line." }) as CodexAdapterFsOps & { _store: Record<string, string> };
|
||||
const tmux = mockTmux();
|
||||
const adapter = adapterFor(fs, tmux);
|
||||
const binding = { ...makeBinding(), guidanceFile: null, guidanceLaunchInstructions: true } as NodeBinding;
|
||||
await adapter.project(plan([guidance("culture.md", "/spec/culture.md"), guidance("rig-role", "/spec/rig-role.md")]), binding);
|
||||
await adapter.deliverStartup([startupFile("SOP.md", "/spec/SOP.md")], binding);
|
||||
expect("/project/AGENTS.md" in fs._store).toBe(false);
|
||||
expect(`${HOME}/AGENTS.md` in fs._store).toBe(false);
|
||||
// The person's yes to the seat-skills line was for its AGENTS.md footprint, so it keeps that file.
|
||||
await adapter.deliverStartup([startupFile("openrig-seat-skills.md", "/spec/openrig-seat-skills.md")], binding);
|
||||
expect(fs._store["/project/AGENTS.md"]).toContain("Seat skills line.");
|
||||
expect((await adapter.launchHarness(binding, { name: "dev-qa@test-rig" })).ok).toBe(true);
|
||||
const sent = (tmux.sendText as ReturnType<typeof vi.fn>).mock.calls.map((call) => call[1] as string).find((call) => call.includes(" -C '/project'"))!;
|
||||
const text = instructionsIn(sent);
|
||||
expect(text).toMatch(/^OpenRig seat guidance for this session follows\. Where it conflicts with AGENTS\.md guidance in the repository or the Codex home, follow AGENTS\.md\./);
|
||||
expect(text).toContain("Team culture.");
|
||||
expect(text).toContain("How work flows here.");
|
||||
expect(text).not.toContain("Seat role text.");
|
||||
expect(text).not.toContain("Seat skills line.");
|
||||
// Still read as a fresh launch by the process-lineage readers.
|
||||
expect(codexCommandResumeToken(sent)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("a launch without the choice (a resume, a restore, an older Codex) sends no developer instructions and merges into AGENTS.md as before", async () => {
|
||||
const fs = mockFs({ "/spec/SOP.md": "How work flows here." }) as CodexAdapterFsOps & { _store: Record<string, string> };
|
||||
const tmux = mockTmux();
|
||||
const adapter = adapterFor(fs, tmux);
|
||||
await adapter.deliverStartup([startupFile("SOP.md", "/spec/SOP.md")], { ...makeBinding(), guidanceFile: null } as NodeBinding);
|
||||
expect(fs._store["/project/AGENTS.md"]).toContain("How work flows here.");
|
||||
await adapter.launchHarness({ ...makeBinding(), guidanceFile: null } as NodeBinding, { name: "dev-qa@test-rig" });
|
||||
const sent = (tmux.sendText as ReturnType<typeof vi.fn>).mock.calls.map((call) => call[1] as string).find((call) => call.includes(" -C '/project'"))!;
|
||||
expect(sent).not.toContain("developer_instructions");
|
||||
});
|
||||
});
|
||||
@@ -18,12 +18,6 @@ import { appendFileSync } from "node:fs";
|
||||
import { createInterface } from "node:readline";
|
||||
const log = (entry) => appendFileSync(process.env.FAKE_LOG, JSON.stringify(entry) + "\\n");
|
||||
const send = (message) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n");
|
||||
// Like dsh 0.2.1-rc.2: its CLI reports its real version, and its ACP handshake reports 0.0.1.
|
||||
if (process.argv.includes("--version")) {
|
||||
// FAKE_VERSION=stuck: a version lookup that ignores SIGTERM and doesn't exit for 30 seconds.
|
||||
if (process.env.FAKE_VERSION === "stuck") { process.on("SIGTERM", () => {}); setTimeout(() => process.exit(0), 30_000); await new Promise(() => {}); }
|
||||
process.stdout.write("dsh 0.2.1-rc.2\\n"); process.exit(0);
|
||||
}
|
||||
log({ start: { argv: process.argv.slice(2), DSH_HOME: process.env.DSH_HOME ?? null, DSH_PERMISSION_MODE: process.env.DSH_PERMISSION_MODE ?? null,
|
||||
DSH_TELEMETRY_DISABLED: process.env.DSH_TELEMETRY_DISABLED ?? null, token: "OPENRIG_ACTIVITY_HOOK_TOKEN" in process.env } });
|
||||
process.stdout.write("dsh: a log line that isn't JSON\\n");
|
||||
@@ -36,7 +30,7 @@ createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
const reply = (result) => send({ id: message.id, result });
|
||||
const fail = (code, text) => send({ id: message.id, error: { code, message: text } });
|
||||
switch (message.method) {
|
||||
case "initialize": return reply({ protocolVersion: 1, agentCapabilities: JSON.parse(process.env.FAKE_CAPS ?? "{}"), agentInfo: { name: "dsh", version: "0.0.1" } });
|
||||
case "initialize": return reply({ protocolVersion: 1, agentCapabilities: JSON.parse(process.env.FAKE_CAPS ?? "{}"), agentInfo: { name: "dsh", version: "0.2.1-rc.2" } });
|
||||
case "session/new": session = "sess-new-1"; return reply({ sessionId: session });
|
||||
case "session/resume": case "session/load": {
|
||||
if (process.env.FAKE_RESUME === "missing") return fail(-32002, "Resource not found");
|
||||
@@ -61,30 +55,6 @@ createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
const text = message.params.prompt.map((part) => part.text).join("");
|
||||
const end = (stopReason) => reply({ stopReason });
|
||||
if (text.includes("EXIT")) process.exit(0);
|
||||
const two = ["TWOASK", "TWOTALK", "TWOQUIT"].find((word) => text.includes(word));
|
||||
if (two) {
|
||||
// Two permission requests at once; the turn ends once both are answered. After the first answer, TWOTALK says
|
||||
// something and starts a tool, and TWOQUIT exits.
|
||||
let left = 2;
|
||||
const answered = () => {
|
||||
left -= 1;
|
||||
if (left === 1 && two === "TWOTALK") { say("first answered"); update({ sessionUpdate: "tool_call", toolCallId: "t6", title: "Read e.txt", kind: "read", status: "pending" }); }
|
||||
if (left === 1 && two === "TWOQUIT") setTimeout(() => process.exit(0), 200);
|
||||
if (left === 0) { say("both answered"); end("end_turn"); }
|
||||
};
|
||||
for (const [id, file] of [["perm-5", "e.txt"], ["perm-6", "f.txt"]]) {
|
||||
waiting.set(id, answered);
|
||||
send({ id, method: "session/request_permission", params: { sessionId: session, toolCall: { toolCallId: id, title: "Edit " + file, kind: "edit" },
|
||||
options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }, { optionId: "reject", name: "Reject", kind: "reject_once" }] } });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (text.includes("QUIETASK")) {
|
||||
// Asks, then works on quietly after the answer until it's cancelled.
|
||||
waiting.set("perm-4", (response) => { say("outcome " + JSON.stringify(response.result?.outcome ?? null)); held = () => end("cancelled"); });
|
||||
return send({ id: "perm-4", method: "session/request_permission", params: { sessionId: session, toolCall: { toolCallId: "t5", title: "Edit d.txt", kind: "edit" },
|
||||
options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }, { optionId: "reject", name: "Reject", kind: "reject_once" }] } });
|
||||
}
|
||||
if (text.includes("PERMIT")) {
|
||||
say("checking");
|
||||
waiting.set("perm-1", (response) => { say("outcome " + JSON.stringify(response.result?.outcome ?? null)); end(cancelled ? "cancelled" : "end_turn"); });
|
||||
@@ -114,15 +84,15 @@ createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
});
|
||||
`;
|
||||
|
||||
type Permissions = "absent" | "deny" | "stale" | "revoked" | "broken" | "permitted" | "answer" | "words" | "saidno" | "unknown" | "readfail" | "pending" | "lost" | "late" | "two" | "twoslow";
|
||||
interface Hook { auth: string | undefined; body: Record<string, unknown>; at: number }
|
||||
type Permissions = "absent" | "deny" | "stale" | "revoked" | "broken" | "permitted" | "answer" | "saidno" | "unknown" | "readfail" | "pending" | "lost";
|
||||
interface Hook { auth: string | undefined; body: Record<string, unknown> }
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
afterEach(() => { for (const cleanup of cleanups.splice(0).reverse()) cleanup(); });
|
||||
|
||||
async function fakeOpenRig(permissions: Permissions) {
|
||||
const hooks: Hook[] = [];
|
||||
const submits: Array<{ auth: string | undefined; body: Record<string, unknown>; at: number }> = [];
|
||||
const submits: Array<{ auth: string | undefined; body: Record<string, unknown> }> = [];
|
||||
const withdraws: Array<{ askId: string; body: Record<string, unknown> }> = [];
|
||||
const server: Server = createServer((req, res) => {
|
||||
let raw = "";
|
||||
@@ -131,47 +101,25 @@ async function fakeOpenRig(permissions: Permissions) {
|
||||
const json = (status: number, body: unknown) => { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
|
||||
const url = new URL(req.url ?? "/", "http://openrig.test");
|
||||
const body = raw ? JSON.parse(raw) as Record<string, unknown> : {};
|
||||
if (req.method === "POST" && url.pathname === "/api/activity/hooks") { hooks.push({ auth: req.headers.authorization, body, at: Date.now() }); return json(200, { ok: true }); }
|
||||
if (req.method === "POST" && url.pathname === "/api/activity/hooks") { hooks.push({ auth: req.headers.authorization, body }); return json(200, { ok: true }); }
|
||||
if (permissions === "absent") return json(404, { error: "not found" });
|
||||
if (req.method === "POST" && url.pathname === "/api/activity/permission-requests") {
|
||||
submits.push({ auth: req.headers.authorization, body, at: Date.now() });
|
||||
submits.push({ auth: req.headers.authorization, body });
|
||||
if (permissions === "deny") return json(409, { ok: false, code: "denied_by_seat", error: "This seat's operator chose to refuse its runner's permission requests." });
|
||||
if (permissions === "stale") return json(409, { ok: false, code: "stale_generation", error: "This request isn't from the seat's current occupant." });
|
||||
if (permissions === "broken") return json(500, { ok: false, code: "internal_error", error: "Something went wrong." });
|
||||
if (permissions === "revoked") return json(401, { ok: false, code: "activity_grant_revoked", error: "This activity grant was revoked; nothing was asked." });
|
||||
if (permissions === "permitted") return json(200, { ok: true, state: "permitted", optionId: "allow", reason: "no one can be asked: no person is registered" });
|
||||
if (permissions === "two") return json(200, { ok: true, askId: `ask-${submits.length}`, created: true });
|
||||
// Two asks, and OpenRig takes 1.5 s to answer the second one's submit.
|
||||
if (permissions === "twoslow") {
|
||||
const askId = `ask-${submits.length}`;
|
||||
if (askId === "ask-2") { setTimeout(() => json(200, { ok: true, askId, created: true }), 1_500); return; }
|
||||
return json(200, { ok: true, askId, created: true });
|
||||
}
|
||||
return json(200, { ok: true, askId: "ask-1", created: true });
|
||||
}
|
||||
const withdraw = url.pathname.match(/^\/api\/activity\/permission-requests\/([^/]+)\/withdraw$/);
|
||||
if (req.method === "POST" && withdraw) {
|
||||
withdraws.push({ askId: withdraw[1]!, body });
|
||||
// With two asks, a withdraw is answered after 500 ms, so a runner that's exiting is still there to post after it.
|
||||
if (permissions === "two") { setTimeout(() => json(200, { ok: true, state: "withdrawn" }), 500); return; }
|
||||
return json(200, { ok: true, state: "withdrawn" });
|
||||
}
|
||||
// Two asks: the first is answered at once, the second 2 s after it was submitted.
|
||||
const two = url.pathname.match(/^\/api\/activity\/permission-requests\/(ask-[12])$/);
|
||||
if ((permissions === "two" || permissions === "twoslow") && req.method === "GET" && two) {
|
||||
if (two[1] === "ask-1" || Date.now() - submits[1]!.at >= 2_000) return json(200, { ok: true, state: "answered", optionId: "allow" });
|
||||
setTimeout(() => json(200, { ok: true, state: "pending" }), 100);
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && withdraw) { withdraws.push({ askId: withdraw[1]!, body }); return json(200, { ok: true, state: "withdrawn" }); }
|
||||
if (req.method === "GET" && url.pathname === "/api/activity/permission-requests/ask-1") {
|
||||
if (permissions === "answer") return json(200, { ok: true, state: "answered", optionId: "allow" });
|
||||
if (permissions === "words") return json(200, { ok: true, state: "permitted", optionId: "allow", said: "Only a.txt, please." });
|
||||
if (permissions === "saidno") return json(200, { ok: true, state: "refused", reason: "said_no" });
|
||||
if (permissions === "unknown") return json(200, { ok: true, state: "deferred" });
|
||||
if (permissions === "readfail") return json(500, { ok: false, code: "internal_error", error: "Something went wrong." });
|
||||
if (permissions === "lost") return json(404, { ok: false, code: "not_found", error: "No permission request ask-1 for this seat." });
|
||||
// A person who answers after 11 s, longer than one beat interval.
|
||||
if (permissions === "late" && Date.now() - submits[0]!.at >= 11_000) return json(200, { ok: true, state: "answered", optionId: "allow" });
|
||||
setTimeout(() => json(200, { ok: true, state: "pending" }), 100);
|
||||
return;
|
||||
}
|
||||
@@ -184,7 +132,7 @@ async function fakeOpenRig(permissions: Permissions) {
|
||||
return { url: `http://127.0.0.1:${address.port}`, hooks, submits, withdraws };
|
||||
}
|
||||
|
||||
function start(openrig: { url: string }, env: Record<string, string> = {}, binVar = "OPENRIG_DSH") {
|
||||
function start(openrig: { url: string }, env: Record<string, string> = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "dsh-example-"));
|
||||
const fake = join(dir, "fake-dsh.mjs");
|
||||
writeFileSync(fake, FAKE_DSH);
|
||||
@@ -193,7 +141,7 @@ function start(openrig: { url: string }, env: Record<string, string> = {}, binVa
|
||||
const launchResult = join(dir, "launch", "result.json");
|
||||
mkdirSync(join(dir, "launch"));
|
||||
const child: ChildProcess = spawn(process.execPath, [RUNNER], { cwd: dir, stdio: "pipe", env: {
|
||||
PATH: process.env["PATH"]!, [binVar]: fake, FAKE_LOG: logFile, OPENRIG_HOME: join(dir, "home"),
|
||||
PATH: process.env["PATH"]!, OPENRIG_DSH: fake, FAKE_LOG: logFile, OPENRIG_HOME: join(dir, "home"),
|
||||
OPENRIG_URL: openrig.url, OPENRIG_ACTIVITY_HOOK_TOKEN: "test-token",
|
||||
OPENRIG_SESSION_NAME: "dev-dsh@test-rig", OPENRIG_NODE_ID: "node-1", OPENRIG_OCCUPANT_GENERATION: "gen-1", OPENRIG_RUNTIME: "dsh",
|
||||
OPENRIG_LAUNCH_RESULT: launchResult, OPENRIG_LAUNCH_ATTEMPT: "attempt-1", ...env,
|
||||
@@ -231,8 +179,7 @@ const responseTo = (log: Array<Record<string, unknown>>, id: string) => log.find
|
||||
describe("the documented dsh example runner", () => {
|
||||
it("opens a conversation, writes the launch record, and only then reads ready, with uploads off and no hook token for dsh", async () => {
|
||||
const openrig = await fakeOpenRig("absent");
|
||||
// A DSH_HOME the person's shell exports (its startup files run after the seat's env:) isn't the seat's home.
|
||||
const seat = start(openrig, { DSH_HOME: join(tmpdir(), "a-persons-own-dsh-home") });
|
||||
const seat = start(openrig);
|
||||
const record = await waitFor(seat.launch, "the launch record");
|
||||
expect(record).toMatchObject({ format: 1, attempt: "attempt-1", harness: "dsh", nativeRef: "sess-new-1" });
|
||||
expect(String(record.producer)).toMatch(/openrig-dsh-runner .*dsh 0\.2\.1-rc\.2.*ACP 1/);
|
||||
@@ -250,23 +197,6 @@ describe("the documented dsh example runner", () => {
|
||||
expect(idle.auth).toBe("Bearer test-token");
|
||||
});
|
||||
|
||||
it("takes its dsh binary and home from DSH_SEAT_BIN and DSH_SEAT_HOME, which a rig spec's env: can set, over an exported DSH_HOME", async () => {
|
||||
const openrig = await fakeOpenRig("absent");
|
||||
const home = mkdtempSync(join(tmpdir(), "dsh-prepared-home-"));
|
||||
cleanups.push(() => rmSync(home, { recursive: true, force: true }));
|
||||
const seat = start(openrig, { DSH_SEAT_HOME: home, DSH_HOME: join(tmpdir(), "a-persons-own-dsh-home") }, "DSH_SEAT_BIN");
|
||||
await waitFor(seat.launch, "the launch record");
|
||||
expect((seat.log()[0] as { start: Record<string, unknown> }).start).toMatchObject({ argv: ["--profile", "acp"], DSH_HOME: home });
|
||||
});
|
||||
|
||||
it("starts without waiting on a version lookup that ignores SIGTERM, and names the handshake's version", async () => {
|
||||
const openrig = await fakeOpenRig("absent");
|
||||
const seat = start(openrig, { FAKE_VERSION: "stuck" });
|
||||
const record = await waitFor(seat.launch, "the launch record", 15_000);
|
||||
expect(record).toMatchObject({ nativeRef: "sess-new-1" });
|
||||
expect(String(record.producer)).toMatch(/dsh 0\.0\.1 \(as its ACP handshake reports it\); ACP 1/);
|
||||
}, 30_000);
|
||||
|
||||
it("sends one submitted block as one prompt and reports working, then idle, in order", async () => {
|
||||
const openrig = await fakeOpenRig("absent");
|
||||
const seat = start(openrig);
|
||||
@@ -315,45 +245,6 @@ process.stdout.write(JSON.stringify(chunks.map((chunk) => framer.push(chunk).fil
|
||||
expect(requests(seat.log(), "session/prompt")).toHaveLength(1);
|
||||
});
|
||||
|
||||
// The working beat (contract section 4) runs on its own 10 s interval, so these two cells take that long.
|
||||
it("beats through a long, quiet turn, naming the accepted turn, and stops at the turn's end", async () => {
|
||||
const openrig = await fakeOpenRig("absent");
|
||||
const seat = start(openrig);
|
||||
await waitFor(seat.launch, "the launch record");
|
||||
seat.type("HOLD this turn");
|
||||
const accepted = await waitFor(() => has(openrig.hooks, "UserPromptSubmit/accepted"), "the accepted prompt");
|
||||
const beat = await waitFor(() => has(openrig.hooks, "active/liveness"), "a working beat", 12_000);
|
||||
expect(beat.body).toMatchObject({ turnId: "1", livenessSeconds: 30, generation: "gen-1", runtime: "dsh" });
|
||||
expect(Number(beat.body.sequence)).toBeGreaterThan(Number(accepted.body.sequence));
|
||||
expect(typeof beat.body.occurredAt).toBe("string");
|
||||
seat.type("/abort");
|
||||
await waitFor(() => has(openrig.hooks, "Stop/cancelled"), "the cancelled turn end");
|
||||
const stopAt = seen(openrig.hooks).indexOf("Stop/cancelled");
|
||||
await new Promise((done) => setTimeout(done, 10_500)); // one more beat interval
|
||||
expect(seen(openrig.hooks).slice(stopAt + 1)).not.toContain("active/liveness");
|
||||
}, 35_000);
|
||||
|
||||
it("holds the beat while a person's ask is open, then posts permission_resolved before beating again", async () => {
|
||||
const openrig = await fakeOpenRig("late");
|
||||
const seat = start(openrig);
|
||||
await waitFor(seat.launch, "the launch record");
|
||||
seat.type("QUIETASK an edit");
|
||||
const answer = await waitFor(() => responseTo(seat.log(), "perm-4"), "the person's answer, given to the agent", 16_000);
|
||||
expect(answer.result).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
|
||||
const resolved = await waitFor(() => has(openrig.hooks, "active/permission_resolved"), "permission_resolved");
|
||||
expect(resolved.body).toMatchObject({ turnId: "1", askId: "ask-1" });
|
||||
// The ask was open for more than a beat interval, and no beat went out while it was.
|
||||
const asked = openrig.submits[0]!.at;
|
||||
expect(resolved.at - asked).toBeGreaterThanOrEqual(10_000);
|
||||
expect(openrig.hooks.filter((hook) => hook.body.subtype === "liveness" && hook.at > asked && hook.at < resolved.at)).toEqual([]);
|
||||
// The same turn works on quietly after the answer, and beats again.
|
||||
const after = await waitFor(() => openrig.hooks.find((hook) => hook.body.subtype === "liveness" && hook.at > resolved.at), "a beat after the answer", 12_000);
|
||||
expect(after.body.turnId).toBe("1");
|
||||
expect(Number(after.body.sequence)).toBeGreaterThan(Number(resolved.body.sequence));
|
||||
seat.type("/abort");
|
||||
await waitFor(() => has(openrig.hooks, "Stop/cancelled"), "the cancelled turn end");
|
||||
}, 45_000);
|
||||
|
||||
it.each(["absent", "permitted"] as const)("permits with the agent's own allow when no one can be asked (OpenRig %s)", async (permissions) => {
|
||||
const openrig = await fakeOpenRig(permissions);
|
||||
const seat = start(openrig);
|
||||
@@ -363,18 +254,16 @@ process.stdout.write(JSON.stringify(chunks.map((chunk) => framer.push(chunk).fil
|
||||
expect(answer.result).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
|
||||
await waitFor(() => has(openrig.hooks, "Stop/end_turn"), "the turn's idle report");
|
||||
expect(has(openrig.hooks, "Notification/permission_prompt")).toBeUndefined();
|
||||
expect(has(openrig.hooks, "active/permission_resolved"), "OpenRig opened no ask").toBeUndefined();
|
||||
expect(seat.out()).toMatch(/\[permission\] permitted/);
|
||||
});
|
||||
|
||||
// An ask OpenRig opened (the last two) showed the seat needing input, so the permit is followed by permission_resolved.
|
||||
it.each([
|
||||
["OpenRig can't be reached", "absent", { OPENRIG_URL: "http://127.0.0.1:1" }, false],
|
||||
["the submit is answered with a status the contract doesn't list", "broken", {}, false],
|
||||
["the submit's grant was revoked, which can happen to the live occupant", "revoked", {}, false],
|
||||
["reading the answer gets a state the contract doesn't list", "unknown", {}, true],
|
||||
["reading the answer gets an unexpected reply", "lost", {}, true],
|
||||
] as const)("permits with the agent's own allow when %s, since nobody can be asked", async (_case, permissions, env, opened) => {
|
||||
["OpenRig can't be reached", "absent", { OPENRIG_URL: "http://127.0.0.1:1" }],
|
||||
["the submit is answered with a status the contract doesn't list", "broken", {}],
|
||||
["the submit's grant was revoked, which can happen to the live occupant", "revoked", {}],
|
||||
["reading the answer gets a state the contract doesn't list", "unknown", {}],
|
||||
["reading the answer gets an unexpected reply", "lost", {}],
|
||||
] as const)("permits with the agent's own allow when %s, since nobody can be asked", async (_case, permissions, env) => {
|
||||
const openrig = await fakeOpenRig(permissions);
|
||||
const seat = start(openrig, env);
|
||||
await waitFor(seat.launch, "the launch record");
|
||||
@@ -382,13 +271,6 @@ process.stdout.write(JSON.stringify(chunks.map((chunk) => framer.push(chunk).fil
|
||||
const answer = await waitFor(() => responseTo(seat.log(), "perm-1"), "the runner's answer to the permission request");
|
||||
expect(answer.result).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
|
||||
expect(has(openrig.hooks, "Notification/permission_prompt")).toBeUndefined();
|
||||
if (opened) {
|
||||
const resolved = await waitFor(() => has(openrig.hooks, "active/permission_resolved"), "permission_resolved");
|
||||
expect(resolved.body).toMatchObject({ turnId: "1", askId: "ask-1" });
|
||||
} else {
|
||||
await waitFor(() => /turn end/.test(seat.out()), "the turn's end in the pane");
|
||||
expect(has(openrig.hooks, "active/permission_resolved"), "OpenRig opened no ask").toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -403,7 +285,6 @@ process.stdout.write(JSON.stringify(chunks.map((chunk) => framer.push(chunk).fil
|
||||
expect(answer.result).toEqual({ outcome: { outcome: "cancelled" } });
|
||||
await waitFor(() => has(openrig.hooks, "Stop/end_turn"), "the turn's idle report");
|
||||
expect(has(openrig.hooks, "Notification/permission_prompt")).toBeUndefined();
|
||||
expect(has(openrig.hooks, "active/permission_resolved"), "nothing was answered or permitted").toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the seat's own refusal held through a cancel, so the cancelled turn reads needs input, not idle", async () => {
|
||||
@@ -484,84 +365,6 @@ process.stdout.write(JSON.stringify(chunks.map((chunk) => framer.push(chunk).fil
|
||||
await waitFor(() => has(openrig.hooks, "Stop/end_turn"), "the turn's idle report");
|
||||
expect(has(openrig.hooks, "Notification/permission_prompt")).toBeUndefined();
|
||||
expect(openrig.withdraws).toEqual([]);
|
||||
// The person's answer reached the agent, so the seat's needs input clears for the rest of the turn, before its end.
|
||||
const events = seen(openrig.hooks);
|
||||
expect(has(openrig.hooks, "active/permission_resolved")!.body).toMatchObject({ turnId: "1", askId: "ask-1" });
|
||||
expect(events.indexOf("active/permission_resolved")).toBeLessThan(events.indexOf("Stop/end_turn"));
|
||||
});
|
||||
|
||||
it("gives the agent its option, then queues the person's own words as a prompt, after the turn", async () => {
|
||||
const openrig = await fakeOpenRig("words");
|
||||
const seat = start(openrig);
|
||||
await waitFor(seat.launch, "the launch record");
|
||||
seat.type("PERMIT an edit");
|
||||
const answer = await waitFor(() => responseTo(seat.log(), "perm-1"), "the runner's answer to the permission request");
|
||||
expect(answer.result).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
|
||||
const prompts = () => requests(seat.log(), "session/prompt")
|
||||
.map((entry) => (entry.params as { prompt: Array<{ text: string }> }).prompt.map((part) => part.text).join(""));
|
||||
const next = await waitFor(() => prompts()[1], "the person's words as a queued prompt");
|
||||
expect(next).toContain("Only a.txt, please.");
|
||||
expect(next).toContain("Edit a.txt");
|
||||
expect(prompts()).toHaveLength(2);
|
||||
expect(seat.out()).toContain("Only a.txt, please.");
|
||||
});
|
||||
|
||||
// Two asks open at once: the first answer reaches the agent at once, but the seat still waits on a person for the second,
|
||||
// so nothing that clears needs input is posted until that one is answered too; then permission_resolved goes out once.
|
||||
it("keeps the seat needing input while a second ask waits, and resolves once, after the last answer", async () => {
|
||||
const openrig = await fakeOpenRig("two");
|
||||
const seat = start(openrig);
|
||||
await waitFor(seat.launch, "the launch record");
|
||||
seat.type("TWOASK two edits");
|
||||
for (const id of ["perm-5", "perm-6"]) {
|
||||
const answer = await waitFor(() => responseTo(seat.log(), id), `the answer to ${id}`, 8_000);
|
||||
expect(answer.result).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
|
||||
}
|
||||
await waitFor(() => has(openrig.hooks, "Stop/end_turn"), "the turn's end");
|
||||
expect(openrig.submits).toHaveLength(2);
|
||||
const resolved = openrig.hooks.filter((hook) => hook.body.subtype === "permission_resolved");
|
||||
expect(resolved.map((hook) => hook.body.turnId)).toEqual(["1"]);
|
||||
expect(resolved[0]!.at - openrig.submits[1]!.at).toBeGreaterThanOrEqual(2_000); // after the later ask's answer
|
||||
// From the first ask on (the submit isn't one of the runner's ordered posts, so it can arrive before the accepted prompt).
|
||||
const from = Math.max(openrig.submits[0]!.at, has(openrig.hooks, "UserPromptSubmit/accepted")!.at);
|
||||
const between = openrig.hooks.filter((hook) => hook.at > from && hook.at < resolved[0]!.at);
|
||||
expect(between.map((hook) => `${String(hook.body.hookEvent)}/${String(hook.body.subtype)}`)).toEqual([]);
|
||||
});
|
||||
|
||||
// The first answer reaches the agent before OpenRig has answered the second ask's submit, though it has already recorded
|
||||
// that prompt, so what the agent says and does in between mustn't read working.
|
||||
it("reports nothing that reads working while a second ask is still being submitted", async () => {
|
||||
const openrig = await fakeOpenRig("twoslow");
|
||||
const seat = start(openrig);
|
||||
await waitFor(seat.launch, "the launch record");
|
||||
seat.type("TWOTALK two edits");
|
||||
for (const id of ["perm-5", "perm-6"]) {
|
||||
const answer = await waitFor(() => responseTo(seat.log(), id), `the answer to ${id}`, 8_000);
|
||||
expect(answer.result).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
|
||||
}
|
||||
await waitFor(() => has(openrig.hooks, "Stop/end_turn"), "the turn's end");
|
||||
expect(seat.out()).toContain("first answered");
|
||||
expect(seat.out()).toContain("OpenRig asked a person (ask-2)");
|
||||
expect(seat.out().indexOf("first answered")).toBeLessThan(seat.out().indexOf("OpenRig asked a person (ask-2)"));
|
||||
const resolved = openrig.hooks.filter((hook) => hook.body.subtype === "permission_resolved");
|
||||
expect(resolved.map((hook) => hook.body.turnId)).toEqual(["1"]);
|
||||
const from = Math.max(openrig.submits[0]!.at, has(openrig.hooks, "UserPromptSubmit/accepted")!.at);
|
||||
expect(seen(openrig.hooks.filter((hook) => hook.at > from && hook.at < resolved[0]!.at))).toEqual([]);
|
||||
});
|
||||
|
||||
// The agent exits after the first answer while the second ask still waits on a person: nothing after the exit says the
|
||||
// turn goes on.
|
||||
it("posts no permission_resolved after the agent's exit", async () => {
|
||||
const openrig = await fakeOpenRig("two");
|
||||
const seat = start(openrig);
|
||||
await waitFor(seat.launch, "the launch record");
|
||||
seat.type("TWOQUIT two edits");
|
||||
await waitFor(() => responseTo(seat.log(), "perm-5") ?? responseTo(seat.log(), "perm-6"), "the first answer", 8_000);
|
||||
await seat.exited;
|
||||
expect(openrig.withdraws.map((entry) => entry.body)).toEqual([{ reason: "exited" }]);
|
||||
const events = seen(openrig.hooks);
|
||||
expect(events.at(-1)).toBe("Stop/dsh_exited");
|
||||
expect(events).not.toContain("active/permission_resolved");
|
||||
});
|
||||
|
||||
it("withdraws a pending ask when the turn is cancelled, and answers the agent cancelled", async () => {
|
||||
|
||||
@@ -341,34 +341,6 @@ function routeFixture(f:ReturnType<typeof fixture>, cwd?:string){
|
||||
return ()=>app.request('/api/compaction/trigger',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session:seat})});
|
||||
}
|
||||
|
||||
for(const map of ['complete','removed'])it(`manual route classifies an idle-wait timeout with a ${map} map`,async()=>{
|
||||
const f=fixture();f.onSleep(async()=>{stageMap(f);f.activity('busy');});
|
||||
// The transport reports its own elapsed wait before the enforcer's clock observes the deadline.
|
||||
const idle=vi.spyOn(f.transport,'waitUntilIdle').mockImplementationOnce(async(_session,remaining)=>{
|
||||
expect(remaining).toBeGreaterThan(0);
|
||||
const a=f.e.getPreparationState(seat)!;
|
||||
if(map==='removed')for(const path of [a.mapPath,a.mapPath+'.tmp']){
|
||||
rmSync(path,{force:true});expect(existsSync(path)).toBe(false);
|
||||
}
|
||||
return {ok:false,reason:'wait_for_idle_timeout',waitedMs:remaining,attempts:1} as any;
|
||||
});
|
||||
const response=await routeFixture(f)();const body=await response.json();
|
||||
expect(response.status).toBe(409);
|
||||
expect(body.reason).toBe(map==='complete'?'map_complete_seat_never_idle':'preparation_incomplete');
|
||||
expect(body.error).toContain(map==='complete'?'map complete, seat never idle':'restore map was not completed');
|
||||
expect(body.preparation).toMatchObject({delivery:'delivered',compact:'not_sent'});
|
||||
expect(f.e.getPreparationState(seat)).toMatchObject({status:'stopped',reason:body.reason});
|
||||
expect(idle).toHaveBeenCalledTimes(1);expect(compacts(f)).toHaveLength(0);expect(f.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('manual route preserves a non-timeout idle-wait refusal after map completion',async()=>{
|
||||
const f=fixture();f.onSleep(async()=>{stageMap(f);f.activity('busy');});
|
||||
vi.spyOn(f.transport,'waitUntilIdle').mockResolvedValueOnce({ok:false,reason:'transport_unavailable'} as any);
|
||||
const response=await routeFixture(f)();const body=await response.json();
|
||||
expect(response.status).toBe(409);expect(body.reason).toBe('transport_unavailable');
|
||||
expect(compacts(f)).toHaveLength(0);expect(f.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('preparation receipt: unresolved permission at deadline discloses the delivered prep and exact attempt',async()=>{
|
||||
const f=fixture();f.onSleep(async()=>{stageMap(f);f.activity('needs_input');});
|
||||
const res=await routeFixture(f)();const body=await res.json();
|
||||
|
||||
@@ -196,7 +196,7 @@ describe("refused: nothing is decided for the person", () => {
|
||||
});
|
||||
|
||||
describe("the person's own answers stay theirs", () => {
|
||||
it("recordHumanAnswer still refuses an agent seat, and takes the asked person's choice", async () => {
|
||||
it("recordHumanAnswer still refuses anyone but the asked person", async () => {
|
||||
const f = fixture();
|
||||
const row = await f.ask();
|
||||
expect(f.repo.recordHumanAnswer({ qitemId: row.qitemId, actorSession: "designer@rig", questionId: "design", optionId: "a" }))
|
||||
|
||||
@@ -267,27 +267,3 @@ describe("finite completion is selected work plus a verified actual export", ()
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
it.each(["provider-list", "cleanup-inventory"])("reports the actual pre-dispatch stage and error without claiming a delete: %s", async stage => {
|
||||
const f = await fixture(); const error = Object.assign(new Error("fixture engine connection refused"), { code: "ECONNREFUSED" });
|
||||
if (stage === "provider-list") vi.spyOn(f.driver, "list").mockRejectedValue(error);
|
||||
else Object.assign(f.driver, { captureCleanup: async () => { throw error; } });
|
||||
const request = { effect: "delete-owned", operationId: "delete-before-dispatch", generation: "generation-one", engineSocket: "/fixture/engine.sock" };
|
||||
for (let observation = 0; observation < 2; observation++) {
|
||||
const result = await f.request("/binding-one/effect", request);
|
||||
expect(result.body).toMatchObject({ state: "unconfirmed", stage, dispatched: false, error: { code: "ECONNREFUSED", message: "fixture engine connection refused" } });
|
||||
expect(f.counts().deleteCalls).toBe(0);
|
||||
const row = f.db.prepare("SELECT state FROM environment_operations WHERE operation_id=?").get(request.operationId) as { state: string };
|
||||
expect(row.state).toBe("intent");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps an uncertain delete requested and reconciles the same operation without another mutation", async () => {
|
||||
const f = await fixture(); const deletion = vi.spyOn(f.driver, "deleteEnvironment").mockRejectedValue(new Error("fixture reply lost at delete boundary"));
|
||||
const request = { effect: "delete-owned", operationId: "delete-uncertain", generation: "generation-one" };
|
||||
const first = await f.request("/binding-one/effect", request);
|
||||
expect(first.body).toMatchObject({ state: "unconfirmed", stage: "delete-dispatch", dispatched: "unknown", error: { message: "fixture reply lost at delete boundary" } });
|
||||
expect((f.db.prepare("SELECT state FROM environment_operations WHERE operation_id=?").get(request.operationId) as { state: string }).state).toBe("requested");
|
||||
await f.request("/binding-one/effect", request);
|
||||
expect(deletion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, realpathSync, readFileSync, writeFileSync, rmSync, mkdirSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -74,41 +72,6 @@ it("reconciles a lost latch reply without issuing another grant or rewriting an
|
||||
expect(f.activity).toHaveBeenCalledTimes(1); expect(f.guest.mock.calls.filter(call => call[1][0] === "--prepare")).toHaveLength(1);
|
||||
expect(sha256(f.files.get("/sandbox/openrig/control/launch.pending.json")!)).toBe(before);
|
||||
});
|
||||
it.each(["old-helper", "transport"])("preserves launch custody and distinguishes %s prepare failure guidance", async failure => {
|
||||
const f = await fixture(), bin = join(f.root, "bin"), calls = join(f.root, "vendor-calls.jsonl");
|
||||
mkdirSync(bin);
|
||||
const stderr = failure === "old-helper"
|
||||
? "usage: guest.py [-h] [--prepare]\nguest.py: error: unrecognized arguments: --first-input-required\n"
|
||||
: "connection reset by peer\n";
|
||||
// A local stand-in exercises the real driver's execFile error boundary; no vendor/provider runs.
|
||||
writeFileSync(join(bin, "openshell"), `#!${process.execPath}
|
||||
const fs = require("node:fs"), args = process.argv.slice(2);
|
||||
fs.appendFileSync(${JSON.stringify(calls)}, JSON.stringify(args) + "\\n");
|
||||
if (args[5] === "get") process.stdout.write(${JSON.stringify(JSON.stringify(f.resource))});
|
||||
else if (args.includes("--prepare")) { process.stderr.write(${JSON.stringify(stderr)}); process.exitCode = 2; }
|
||||
else { process.stderr.write("unexpected stand-in command"); process.exitCode = 9; }
|
||||
`, { mode: 0o700 });
|
||||
const realDriver = new OpenShellDriver();
|
||||
Object.assign(f.platform.driver, { guest: realDriver.guest.bind(realDriver) });
|
||||
const previousPath = process.env.PATH;
|
||||
process.env.PATH = `${bin}:${previousPath ?? ""}`;
|
||||
try {
|
||||
const error = await f.startup.launch(f.binding, { firstInputText: "Selected startup input" }, f.tmux).catch(error => error);
|
||||
expect(error).toMatchObject({ code: "openshell_transport_unconfirmed" });
|
||||
expect(f.db.prepare("SELECT state FROM environment_operations WHERE kind='launch'").all()).toEqual([{ state: "requested" }]);
|
||||
expect(f.tmux.sendShellCommand).not.toHaveBeenCalled();
|
||||
const dispatched = readFileSync(calls, "utf8").trim().split("\n").map(line => JSON.parse(line) as string[]);
|
||||
expect(dispatched.filter(args => args.includes("--prepare"))).toHaveLength(1);
|
||||
expect(dispatched.at(-1)).toContain("--first-input-required");
|
||||
if (failure === "old-helper") {
|
||||
expect(error.message).toContain("predates first input");
|
||||
expect(error.message).toContain("Rebuild");
|
||||
expect(error.message).toContain("README");
|
||||
} else {
|
||||
expect(error.message).toBe("OpenShell did not return a confirmed result; reconcile the saved operation");
|
||||
}
|
||||
} finally { if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; }
|
||||
});
|
||||
it("blocks a changed required source before issuing a grant or preparing native", async () => {
|
||||
const f = await fixture(), role = join(f.root, "role.md"); writeFileSync(role, "selected source");
|
||||
await f.startup.deliverStartup([{ path: "role.md", absolutePath: role, ownerRoot: f.root, deliveryHint: "send_text", required: true, appliesOn: ["fresh_start"] }], f.binding);
|
||||
@@ -269,51 +232,3 @@ it.each(["unchanged", "missing-source", "changed", "added", "removed", "stale-de
|
||||
else { expect(restored).toBe(false); expect(restoredNode?.status).not.toBe("resumed"); }
|
||||
expect(textInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// No provider or native binary: use the real contained adapter and launch journal.
|
||||
it.each(["unavailable", "different-generation", "stopped", "ready-at-shell"])("contained readiness observes the selected guest, not the connect process: %s", async kind => {
|
||||
const f = await fixture(); await f.startup.launch(f.binding, {}, f.tmux);
|
||||
const row = f.db.prepare("SELECT receipt_json FROM environment_operations WHERE kind='launch'").get() as { receipt_json: string };
|
||||
const native = JSON.parse(row.receipt_json);
|
||||
f.guest.mockImplementation(async () => {
|
||||
if (kind === "unavailable") throw new Error("guest identity unavailable");
|
||||
return { ...native, generation: kind === "different-generation" ? "other" : native.generation, running: kind !== "stopped", ok: true };
|
||||
});
|
||||
const adapter = new CommunityHarnessRuntimeAdapter("openshell-claude", { environmentStartup: f.startup,
|
||||
tmux: { ...f.tmux, hasSession: async () => true, getPaneCommand: async () => kind === "ready-at-shell" ? "bash" : "node" } });
|
||||
expect((await adapter.checkReady(f.binding)).ready).toBe(kind === "ready-at-shell");
|
||||
});
|
||||
it("passes generated first input through verified guest files and execs the short viewer command", async () => {
|
||||
const f = await fixture();
|
||||
const opts = { firstInputText: "Selected identity; read projected role.md. Literal $(not-a-host-command)." };
|
||||
const result = await f.startup.launch(f.binding, opts, f.tmux);
|
||||
expect(result.firstInput).toMatchObject({ submission: "unconfirmed" });
|
||||
const descriptor = JSON.parse(f.files.get("/sandbox/openrig/control/launch.pending.json")!.toString());
|
||||
expect(descriptor.firstInput).toMatchObject({ path: "control/first-input.txt", sha256: sha256(opts.firstInputText) });
|
||||
expect(f.files.get("/sandbox/openrig/control/first-input.txt")!.toString()).toBe(opts.firstInputText);
|
||||
const command = f.tmux.sendShellCommand.mock.calls[0] as unknown as [string, string];
|
||||
expect(command[1]).toMatch(/^exec /); expect(command[1]).not.toContain("Selected identity");
|
||||
});
|
||||
|
||||
it("the real short-command transport leaves no host shell when the environment viewer exits", async () => {
|
||||
const f = await fixture(), bin = join(f.root, "bin"); mkdirSync(bin);
|
||||
writeFileSync(join(bin, "rig"), "#!/bin/sh\nexit 7\n", { mode: 0o700 });
|
||||
const transport = new TmuxAdapter(async () => ""); let invocation = "";
|
||||
vi.spyOn(transport, "sendText").mockImplementation(async (_target, text) => { invocation = text; return { ok: true }; });
|
||||
vi.spyOn(transport, "sendKeys").mockResolvedValue({ ok: true });
|
||||
await f.startup.launch(f.binding, {}, { sendShellCommand: transport.sendShellCommand.bind(transport) });
|
||||
const marker = join(f.root, "host-fell-through");
|
||||
expect(invocation).toContain("environment"); expect(Buffer.byteLength(invocation)).toBeLessThanOrEqual(512);
|
||||
try { execFileSync("/bin/sh", ["-c", `${invocation}\ntouch '${marker}'`], { env: { ...process.env, PATH: `${bin}:/usr/bin:/bin` }, stdio: "ignore" }); } catch {}
|
||||
expect(existsSync(marker)).toBe(false);
|
||||
});
|
||||
it("a lost prepare reply reconciles the original first input instead of typing it again", async () => {
|
||||
const f = await fixture(); f.losePrepare(); const opts = { firstInputText: "selected immutable input" };
|
||||
await expect(f.startup.launch(f.binding, opts, f.tmux)).rejects.toThrow("Lost preparation reply");
|
||||
const before = sha256(f.files.get("/sandbox/openrig/control/first-input.txt")!);
|
||||
const result = await f.startup.launch(f.binding, opts, f.tmux);
|
||||
expect(result.firstInput).toMatchObject({ path: "/sandbox/openrig/control/first-input.txt", submission: "unconfirmed" });
|
||||
expect(sha256(f.files.get("/sandbox/openrig/control/first-input.txt")!)).toBe(before);
|
||||
expect(f.guest.mock.calls.filter(call => call[1][0] === "--prepare")).toHaveLength(1);
|
||||
expect(f.tmux.sendShellCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -3,15 +3,10 @@
|
||||
// catches it.
|
||||
// node minimal-acp-runner.mjs --agent <command> [--break <name>]... [--read-wait <seconds>|none] [--withdraw-after-stop]
|
||||
// --read-wait keeps the contract another way: it reads with that waitSeconds (or none) and sleeps between pending reads.
|
||||
// --read-retries <n> keeps it another way too: a read refusal the runner would permit on (a code section 7 marks "permits",
|
||||
// or one it doesn't list) is retried n times, 1.5 s apart, before the runner permits; section 7 sets no time limit for that.
|
||||
// A refusal section 7 cancels or holds on (a request that isn't the current occupant's, or the seat's opt-in to hold) is
|
||||
// acted on at once.
|
||||
// --read-retries <n> keeps it another way too: a read OpenRig answers with a status other than 200 is retried n times, 1.5 s apart, before the
|
||||
// runner acts on it; section 7 sets no time limit for that.
|
||||
// --withdraw-after-stop keeps it another way too: a cancelled or ended turn's ask is withdrawn after the turn's Stop is
|
||||
// posted, not before; the contract doesn't order the two posts.
|
||||
// --startup-idle <ms> keeps it another way too: with no first input, it reports idle once it's ready, as a runner may, and
|
||||
// that first POST goes out <ms> late, holding the posts behind it, as a slow first request does. The agent gets each
|
||||
// prompt without waiting on its accepted post, so it can die before the late report arrives.
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { request } from "node:http";
|
||||
@@ -23,40 +18,22 @@ const readWait = argv.includes("--read-wait") ? argv[argv.indexOf("--read-wait")
|
||||
const readQuery = readWait === "none" ? "" : `?waitSeconds=${readWait}`;
|
||||
const withdrawAfterStop = argv.includes("--withdraw-after-stop");
|
||||
const readRetries = argv.includes("--read-retries") ? Number(argv[argv.indexOf("--read-retries") + 1]) : 0;
|
||||
const startupIdleMs = argv.includes("--startup-idle") ? Number(argv[argv.indexOf("--startup-idle") + 1]) : null;
|
||||
// --beat: the optional working beat (section 4). While a turn is open and nothing waits on a person, post active/liveness
|
||||
// every 10 s with livenessSeconds 30; after delivering an answer to an ask OpenRig opened, post active/permission_resolved
|
||||
// first. Breaks: beat-after-stop, beat-during-ask, no-resolve, and beat-time-backwards (each beat says it happened 15 s
|
||||
// before it did, so its sequence no longer rises with the runner's own time).
|
||||
const beatMode = argv.includes("--beat");
|
||||
let beatTurn = null;
|
||||
// With --withdraw-after-stop: the ask a cancelled or ended turn withdraws once its Stop is posted.
|
||||
let deferredWithdraw = null;
|
||||
// --break stop-before-end: a non-exit Stop posted while the ask waits stands in for the turn's end, which is then not posted.
|
||||
let stoppedEarly = false;
|
||||
// The current turn's accepted prompt's occurredAt.
|
||||
let acceptedAt = null;
|
||||
if (beatMode) setInterval(() => {
|
||||
if (beatTurn !== null && (!attention || breaks.has("beat-during-ask"))) void post("active", "liveness", { turnId: String(beatTurn), livenessSeconds: 30,
|
||||
...(breaks.has("beat-time-backwards") ? { occurredAt: new Date(Date.now() - 15_000).toISOString() } : {}) });
|
||||
}, 10_000).unref();
|
||||
const resolved = (ask) => { if (beatMode && !breaks.has("no-resolve")) void post("active", "permission_resolved", { turnId: String(turn), askId: ask.askId }); };
|
||||
const env = process.env;
|
||||
const name = env.OPENRIG_RUNTIME ?? "runner";
|
||||
|
||||
// Activity posts go one at a time, in order, and never hold up the agent.
|
||||
// With --break idle-string-sequence-on-exit the first post is numbered 2, so a "1" falls below the accepted prompt.
|
||||
let sequence = breaks.has("idle-string-sequence-on-exit") ? 1 : 0;
|
||||
let sequence = 0;
|
||||
let posting = Promise.resolve();
|
||||
let holdNextPostMs = 0;
|
||||
function post(hookEvent, subtype, extra = {}) {
|
||||
const hold = holdNextPostMs;
|
||||
holdNextPostMs = 0;
|
||||
const body = JSON.stringify({ sessionName: env.OPENRIG_SESSION_NAME, nodeId: env.OPENRIG_NODE_ID, runtime: name,
|
||||
generation: breaks.has("wrong-generation") ? "gen-other" : env.OPENRIG_OCCUPANT_GENERATION,
|
||||
...(breaks.has("no-sequence") ? {} : { sequence: ++sequence }), occurredAt: new Date().toISOString(), hookEvent, subtype, ...extra });
|
||||
const headers = { "content-type": "application/json", ...(breaks.has("no-token") ? {} : { authorization: `Bearer ${env.OPENRIG_ACTIVITY_HOOK_TOKEN}` }) };
|
||||
posting = posting.then(() => hold && new Promise((resolve) => setTimeout(resolve, hold))).then(() => new Promise((resolve) => {
|
||||
posting = posting.then(() => new Promise((resolve) => {
|
||||
const req = request(`${env.OPENRIG_URL}/api/activity/hooks`, { method: "POST", headers, timeout: 2000 }, (res) => { res.resume(); res.on("end", resolve); });
|
||||
req.on("error", resolve);
|
||||
req.on("timeout", () => { req.destroy(); resolve(); });
|
||||
@@ -121,13 +98,7 @@ agent.stdout.on("data", (chunk) => {
|
||||
let exiting = false;
|
||||
agent.on("exit", async () => {
|
||||
if (exiting) return;
|
||||
beatTurn = null;
|
||||
await withdrawLive("exited");
|
||||
// --break idle-low-sequence-on-exit: an idle Stop for the dead turn, numbered below its accepted prompt but later in time.
|
||||
if (breaks.has("idle-low-sequence-on-exit")) await post("Stop", "idle_heartbeat", { livenessSeconds: 30, sequence: 1 });
|
||||
// --break idle-string-sequence-on-exit: an idle Stop for the dead turn at its accepted prompt's time, numbered below it but
|
||||
// as a string, which OpenRig doesn't read as a sequence.
|
||||
if (breaks.has("idle-string-sequence-on-exit")) await post("Stop", "idle_heartbeat", { livenessSeconds: 30, occurredAt: acceptedAt, sequence: "1" });
|
||||
await post("Stop", breaks.has("idle-on-exit") ? "end_turn" : `${name}_exited`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -147,8 +118,7 @@ let cancelSent = false;
|
||||
const queue = [];
|
||||
|
||||
// Permissions. Every request goes to OpenRig's answered path, and the runner delivers the option that comes back: the
|
||||
// person's, or OpenRig's permit. A person's own words (`said`) join the prompt queue, after the turn. A refusal,
|
||||
// at submit or while reading, is read by its code: the seat's opt-in refuses and
|
||||
// person's, or OpenRig's permit. A refusal, at submit or while reading, is read by its code: the seat's opt-in refuses and
|
||||
// holds attention; one that says the request isn't the current occupant's answers cancelled and holds nothing, as a
|
||||
// refused, stale or withdrawn read does; anything else (404, unreachable, a "permits" code or one the contract doesn't
|
||||
// list) permits with the agent's allow_once, else allow_always, or refuses and holds when there's no allow option. An
|
||||
@@ -190,13 +160,7 @@ async function onPermission(msg) {
|
||||
// A person is asked. OpenRig records the seat's permission prompt, so the runner doesn't post it.
|
||||
attention = true;
|
||||
if (breaks.has("self-prompt")) void post("Notification", "permission_prompt");
|
||||
// --break beat-during-ask beats as soon as the ask waits too, so the check sees it whatever the 10 s timer's phase.
|
||||
if (breaks.has("beat-during-ask") && beatTurn !== null) void post("active", "liveness", { turnId: String(beatTurn), livenessSeconds: 30 });
|
||||
if (breaks.has("stop-before-end")) { stoppedEarly = true; void post("Stop", "idle_heartbeat", { livenessSeconds: 30 }); }
|
||||
// --break stop-low-sequence: an idle Stop while the ask waits, numbered below the accepted prompt but later in time.
|
||||
if (breaks.has("stop-low-sequence")) void post("Stop", "idle_heartbeat", { livenessSeconds: 30, sequence: 1 });
|
||||
// --break stop-numeric-time: an idle Stop while the ask waits, its occurredAt a number, which OpenRig dates by arrival.
|
||||
if (breaks.has("stop-numeric-time")) void post("Stop", "idle_heartbeat", { livenessSeconds: 30, occurredAt: 0 });
|
||||
// done: the agent has its answer. open: OpenRig's ask may still be open, so it's withdrawn at the turn's end.
|
||||
const ask = { id: msg.id, askId: submitted.body.askId, offered: options.map((o) => o.optionId), done: false, open: true };
|
||||
live = ask;
|
||||
@@ -205,8 +169,7 @@ async function onPermission(msg) {
|
||||
if (!busy || turn !== askedInTurn) { await withdrawLive("turn_ended"); return; }
|
||||
for (;;) {
|
||||
let read = await api("GET", `/api/activity/permission-requests/${encodeURIComponent(ask.askId)}${readQuery}`);
|
||||
const permitsOn = (r) => r.status >= 300 && !NOT_CURRENT.has(r.body?.code) && !HOLDS.has(r.body?.code);
|
||||
for (let tries = 0; permitsOn(read) && tries < readRetries && !ask.done; tries++) {
|
||||
for (let tries = 0; read.status >= 300 && tries < readRetries && !ask.done; tries++) {
|
||||
await new Promise((done) => setTimeout(done, 1500));
|
||||
read = await api("GET", `/api/activity/permission-requests/${encodeURIComponent(ask.askId)}${readQuery}`);
|
||||
}
|
||||
@@ -215,17 +178,10 @@ async function onPermission(msg) {
|
||||
if (state === "pending") { if (readWait !== "25") await new Promise((done) => setTimeout(done, 200)); continue; }
|
||||
// A listed answer means OpenRig closed its ask; anything else leaves it open, to withdraw at the turn's end.
|
||||
if (["answered", "permitted", "refused", "withdrawn", "stale"].includes(state)) ask.open = false;
|
||||
// A person who answered in words: their words join the prompt queue in order, after this turn, never as the answer.
|
||||
const said = state && typeof read.body?.said === "string" && read.body.said.trim() ? read.body.said : null;
|
||||
if (said && !breaks.has("drop-words")) queue.push(`The person who answered your permission request (${toolCall?.title}) also wrote:\n\n${said}`);
|
||||
if (said && breaks.has("said-as-answer") && !ask.done) {
|
||||
refuse(ask.id);
|
||||
attention = false;
|
||||
} else if ((state === "answered" || state === "permitted") && ask.offered.includes(read.body.optionId)) {
|
||||
if ((state === "answered" || state === "permitted") && ask.offered.includes(read.body.optionId)) {
|
||||
select(ask.id, read.body.optionId);
|
||||
if (breaks.has("deliver-twice")) select(ask.id, read.body.optionId);
|
||||
attention = false;
|
||||
resolved(ask);
|
||||
} else if (!ask.done) {
|
||||
// A refused, withdrawn or stale read, or a refusal saying the request isn't the current occupant's: it can't take an
|
||||
// answer, and no person is still waiting, so it's cancelled and nothing is held. The seat's opt-in refuses and holds.
|
||||
@@ -238,7 +194,7 @@ async function onPermission(msg) {
|
||||
else if (cancelled || (!state && breaks.has("cancel-on-read-error")) || (state && breaks.has("cancel-on-unlisted-state"))) {
|
||||
refuse(ask.id); attention = breaks.has("hold-on-cancel");
|
||||
}
|
||||
else { permit(); attention = !allowOf(options); if (!attention) resolved(ask); }
|
||||
else { permit(); attention = !allowOf(options); }
|
||||
}
|
||||
ask.done = true;
|
||||
if (live === ask && (!ask.open || breaks.has("forget-permitted-ask"))) live = null;
|
||||
@@ -271,15 +227,11 @@ async function prompt(text) {
|
||||
cancelSent = false;
|
||||
turn += 1;
|
||||
stoppedEarly = false;
|
||||
beatTurn = turn;
|
||||
show(`you ▸ ${text.split("\n")[0]}`);
|
||||
attention = false;
|
||||
acceptedAt = new Date().toISOString();
|
||||
const accepted = post("UserPromptSubmit", "accepted", { turnId: String(turn), occurredAt: acceptedAt });
|
||||
if (startupIdleMs === null) await accepted; // with --startup-idle, the agent doesn't wait on the post, as in dsh's runner
|
||||
await post("UserPromptSubmit", "accepted", { turnId: String(turn) });
|
||||
const res = await call("session/prompt", { sessionId, prompt: [{ type: "text", text }] });
|
||||
await withdrawLive("turn_ended");
|
||||
if (!breaks.has("beat-after-stop")) beatTurn = null;
|
||||
if (res.error) {
|
||||
if (!breaks.has("no-error-post")) await post("Notification", "runtime_error");
|
||||
} else {
|
||||
@@ -361,10 +313,6 @@ async function main() {
|
||||
process.stdout.write("\x1b[?2004h");
|
||||
process.stdin.on("data", onInput);
|
||||
show("ready");
|
||||
if (startupIdleMs !== null && !env.OPENRIG_FIRST_INPUT) {
|
||||
holdNextPostMs = startupIdleMs;
|
||||
void post("Stop", "idle_heartbeat", { livenessSeconds: 30 });
|
||||
}
|
||||
if (env.OPENRIG_FIRST_INPUT) {
|
||||
const text = readFileSync(env.OPENRIG_FIRST_INPUT, "utf8");
|
||||
void prompt(breaks.has("first-input-trim") ? text.trim() : text);
|
||||
|
||||
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user