Compare commits

..
Author SHA1 Message Date
dev-guard 2a9c204f2b fix(compaction): record content-free input refusal diagnostics 2026-11-12 07:38:54 +00:00
Mike Schwarz f06edfa410 fix(daemon): avoid recorder and clipboard waits on the main thread (#3019)
Synchronous instrumentation waited for a worker fsync before every wrapped operation, adding up to 250 ms per span to the daemon's main thread. The recorder now enqueues begin and end records through its existing acknowledged worker path: writes stay ordered, write and worker failures stay visible, and flush and close keep their durable-drain checks. Begin is no longer guaranteed durable before the operation starts, so an immediate process crash can lose that record, and a live worker that stalls without acknowledging has no acknowledgement timeout, heartbeat or bound on pending appends; the shutdown drain deadline reports that as a failure. The Linux clipboard discovery probe is bounded at 250 ms, and a probe its deadline kills warns and is retried instead of being memoized.
2026-11-11 23:03:00 -08:00
Mike Schwarz 8c371e5041 fix(tui): show Feed times in the configured time zone (#3022)
The Feed printed raw ISO timestamps on each row, in a request's detail, on a delivered update's "Delivered:" or "Accepted by bridge:" line, and in "Read at", while the header already drew times with displayTime in the configured zone (ui.timezone). The Feed now uses the same function and formats when the page is drawn, not when it's read, since the zone setting arrives after start-up. A delivered update's detail prints its time once, under the delivery label, instead of twice. A missing time reads "time unknown", as elsewhere in the TUI, and the default zone is unchanged.
2026-11-11 22:42:23 -08:00
Mike Schwarz dd7cf38f59 feat: show whether a connected assistant is checking in (#3023)
A connected assistant (a hosted member outside the terminal) picks up work when it reads its own queue, and nothing showed whether it still was, so a member whose schedule had stopped looked the same as one working fine. Each hosted member now has a check-in status: checking in, overdue, no check-in yet, on request, or unknown. It compares the member's own recorded inbox reads with the cadence its roster entry can state (check_in_minutes): a last check-in no older than twice the cadence is checking in, an older one is overdue and shows its evidence, no cadence reads unknown with the last check-in, on request needs check_in_on_request: true, and with no check-in recorded it's no check-in yet. A cadence is the person's statement, so neither reading proves the assistant's schedule ran. An unusable check_in_minutes never stops the roster loading; that member reads unknown with the reason. It's served read-only at /api/queue/hosted-members behind the existing queue host and origin boundary, the stuck sweep's standing notice states it, and the shared wording is exported as @openrig/daemon/connection-contract. It's a status, never a delivery gate. Check-ins from any call and recorded revocation (disconnected) are follow-ups.
2026-11-11 22:03:01 -08:00
Mike Schwarz f48d5a7296 docs(getting-started): start one stopped kernel seat with rig launch, and say what --fresh loses (#3031)
The guide said a kernel with only some seats stopped starts again with rig up kernel --existing, which refuses while any kernel seat is live. It now says to start one stopped seat with rig launch kernel <logical id> (--plan first), which restores that seat from a restore-usable snapshot and resumes its saved conversation, reporting a seat it can't resume as waiting for a decision, and that rig seat launch <seat> --fresh starts a new conversation and carries none of the seat's earlier one.
2026-11-11 22:01:33 -08:00
Mike Schwarz 6f525ccbc7 fix(plugins): https repository URLs and a PNG icon for Claude's directory (#3021)
Claude's plugin directory doesn't accept the github: shorthand in a manifest's repository field: claude plugin validate --strict warns LISTING_URL_SCHEME, and the link is dropped from the listing preview. The openrig plugin's three manifests now name https://github.com/mvschwarz/openrig, and openrig-core's two name https://github.com/mvschwarz/openrig-plugins, its reserved publish home, which holds only a LICENSE today; each keeps its target, and repository stays listing metadata that OpenRig only shows. The openrig plugin also gains .claude-plugin/icon.png for Claude's directory.
2026-11-11 21:57:18 -08:00
Mike Schwarz 8dac2356c0 fix(daemon): vendor a bundled plugin's files byte for byte (#3025)
When the daemon started, it vendored each bundled plugin by reading every file as UTF-8 text and writing it back as text, so a binary file came out changed (the openrig plugin's 53,823-byte PNG icon became 90,241 bytes of different data). The vendor service's file interface gains optional readBytes and writeBytes, and the daemon's filesystem, now nodePluginVendorFs() beside the service, provides both, so the install, a refresh and its backup hash check, the equal-version mode repair and a skill's projection into the global skill roots all work on bytes. Manifests and the version marker are still read as text, and a text file's bytes and its UTF-8 text give the same digest, so the skip-if-equal checks behave as before.
2026-11-11 21:56:31 -08:00
Mike Schwarz 7f8d59ba16 fix(daemon): a guidance file keeps one copy of each managed block (#3020)
Managed-block markers were matched byte for byte, so a marker an editor or formatter rewrapped read as absent and each launch appended another copy, a file that collected copies kept and rewrote all of them, and rig package install appended beside an incomplete marker. The launch's guidance write, restore and package install now read blocks through one reader (findManagedBlocks): a rewrapped marker still reads as its block and is put back in OpenRig's form, later copies are removed with their separating blank lines, and a person's edited copy is never overwritten. A marker that lost its pair is never written beside: a launch and a restore name it (guidance_block_markers_damaged), and package install leaves that file as it is, names it in its notes, which the CLI and bootstrap print, and installs the rest; an install whose only entries were left this way still verifies as entries_skipped. Tidying a block left in place is best effort, and a file that can't be written is named. A formatter that rewrites a marker's words, not just its spacing, isn't recognised.
2026-11-11 21:51:53 -08:00
Coder8124 aae4395b25 fix(daemon): detect Codex's usage-limit banner with a curly apostrophe (#2879)
The detector for Codex's usage-limit banner expected a straight apostrophe in "You've", while Codex prints the typographic one (U+2019), so a Codex seat that hit its limit recorded no limit event: nothing parked it, and nothing resumed it after the reset. The pattern now accepts either apostrophe, and the reset text, its wrap onto the next lines and the existing false-positive guards behave the same for both forms. For #2839.
2026-11-11 21:06:31 -08:00
60 changed files with 1638 additions and 516 deletions

No files matched your search

+5
View File
@@ -405,6 +405,11 @@ Each startup file and action specifies when it applies:
- a launch replaces it, as launches always did, and names it (`guidance_block_refreshed_unrecorded`);
- an exact resume or a handover leaves it, and names it as possibly stale (`guidance_block_may_be_stale`).
**A file keeps one copy of each block.**
- **Touched markers:** a marker an editor or formatter rewrapped, with spaces or line breaks inside the comment, still reads as its block, and the next write puts it back in OpenRig's form.
- **Collected copies:** they're reduced to the first. Beside a copy you edited, OpenRig's own unedited copies and exact repeats are removed, and every different edited copy stays (`guidance_block_copies_removed`).
- **A marker that lost its pair:** OpenRig can't tell where that block starts or ends, so it adds no copy beside it and names it (`guidance_block_markers_damaged`). Put back the missing marker or remove the partial block, and OpenRig's next write keeps one block. `rig package install` leaves such a file as it is, names it in its result (`notes`), and installs the rest. A marker without its pair beside a complete block is named too (`guidance_block_marker_unpaired`), and the block is written.
An exact resume doesn't type anything into the resumed conversation. So there, the restore result names the block (`guidanceNotes`), but the seat isn't told.
Use this to avoid re-sending context that a fresh conversation doesn't need twice. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both, so a seat that comes back fresh gets it again.
-7
View File
@@ -82,13 +82,6 @@ All of them are server-sent events (`text/event-stream`):
`?replay=all`, no `replay`, or any other value sends the whole log first. A `Last-Event-ID` header (what
`EventSource` sends when it reconnects) sends the events after that id instead, whatever `replay` says.
Live events that arrive during the replay follow it, without duplicates.
When a person clicks an answer on an ask with questions (in Steering, the Feed, Slack or an MCP client), the stream
carries `queue.answer_recorded`: `{ qitemId, actorSession, questionId, answered, questions, complete, summary }`.
`complete: false` means the set is still partial: the ask stays open, and its answers haven't gone back yet. The
event carries ids and counts, not the choice; read the ask for its `humanAnswers`. A click that changes nothing (the
same option again) has no event, and neither does a typed reply, which settles the ask through its own
`queue.updated`. The event is recorded after the answer: if it can't be recorded, the answer still stands, so a
client that re-reads the ask after a gap still sees it.
- **`GET /api/activity/events`**: live only, no replay or ids. Each message is named after its event type:
`seat.activity_changed`, `seat.rung_health`, `proof.judged` or `proof.sources_changed`. It tells a view to read
again; it carries no derived state.
+10 -3
View File
@@ -342,9 +342,16 @@ startup reconcile leaves when every kernel terminal is gone, for example after a
reboot. It does not detect a reboot as such. It uses the same restore as
`rig up kernel --existing`, and `rig status` reports it as `Kernel: booting`
until the seats are ready. A kernel stopped with `rig down kernel`, including
one whose seats were already detached, stays down on later starts. So do a kernel
with only some seats stopped and seats you unclaimed. Start one again with
`rig up kernel --existing`.
one whose seats were already detached, stays down on later starts; start it again
with `rig up kernel --existing`. A seat you stopped or unclaimed also stays down.
While the kernel's other seats are running, `rig up kernel --existing` refuses
(it restores only a kernel with no live session), so start one stopped seat with
`rig launch kernel <logical id>` (`--plan` first). It restores that seat from the
kernel's latest restore-usable snapshot and resumes its saved conversation; a seat
it can't resume is reported as waiting for a decision, not as started.
`rig seat launch <seat> --fresh --reason "…"` instead starts a new conversation
with no continuity source: use it only when you want a fresh occupant, since the
seat's earlier conversation isn't carried.
When the tmux server is gone, an explicit `rig down` of any rig can start an
empty server and probe again. It records the stop and clears eligible bindings
only after confirming the sessions are absent. This can leave an empty tmux
+27
View File
@@ -112,6 +112,33 @@ OpenRig doesn't observe a hosted member between its calls, so it never reads idl
- `lastWake`: its latest ring and the result, among its 50 most recent rows.
- `rig roster show` and `find` show the last call; the last ring is in `rig_team`.
### Check-in status
A connected assistant (a roster member marked `hosted: true`) checks in each time it reads its own pending inbox (through the queue list, as the MCP list tool does
when it lists the member's own work). Other calls it makes don't count yet. Its status compares its last check-in with
what its roster entry states: a cadence (`check_in_minutes`), or that it checks in when a person asks it
(`check_in_on_request: true`):
| Status | Meaning |
|---|---|
| `checking-in` | its last check-in is no older than twice its stated cadence |
| `check-in-overdue` | its last check-in is older than that: its schedule may be off or waiting for an approval |
| `no-check-in-yet` | no check-in recorded for it on this daemon, whatever its entry states |
| `on-request` | its entry says it checks in when a person asks it |
| `disconnected` | reserved for a recorded revocation or removal of its connection. Nothing records one yet, so a revoked assistant reads overdue (or unknown, with no cadence) |
| `unknown` | its entry states neither, its statement can't be used, or its check-ins couldn't be read; the reason says which, and its last check-in is shown. OpenRig assumes no cadence, so this never reads idle or overdue |
Each place it's shown gives the evidence with it, for example `overdue: last check-in 2026-11-12T13:05:00Z, expected
every 60 min; its schedule may be off or waiting for an approval`.
A stated cadence is its person's statement: neither reading proves the assistant's own schedule ran, or that it picked
anything up. It's a status only. Work addressed to a member that isn't checking in still queues and is rung as before, a check-in
isn't pickup (pickup is its claim), and no check-in doesn't mean it's idle, failed or safe. `GET /api/queue/hosted-members`
gives this daemon's host id and each member on this host with its status, last check-in and stated cadence (with the
reason when it's unknown); `rig roster show` and `find` show it as `Check-in:`, for a team with or without terminal seats.
When the member has unclaimed work, the standing notice described above states the same status in its `check-ins:`
line; when that notice is filed doesn't change.
## Revoke
`rig mcp token revoke grok` refuses its next request. The rows it worked, and their answers, stay.
+5
View File
@@ -92,3 +92,8 @@ A member marked `"hosted": true` is a hosted member: a tool outside the terminal
`show` and `find` say it's hosted rather than unmatched. See [Hosted members](hosted-members.md).
Its entry can declare `wake.webhook`, the secret names for a trigger OpenRig rings when work lands for it
(see [Ring it when work lands](hosted-members.md#ring-it-when-work-lands)).
It can also declare `check_in_minutes`, how often it's expected to check its queue (a positive whole number of minutes;
an hourly schedule is `60`), or `check_in_on_request: true` when it checks in only when a person asks it. That's what
its person set up, not something OpenRig observes. With neither, its check-in status reads unknown: OpenRig doesn't
assume a cadence. A value that can't be used doesn't stop the roster loading: that member's check-in
status reads unknown, with the reason. See [Check-in status](hosted-members.md#check-in-status).
+12
View File
@@ -226,6 +226,18 @@ retry is scheduled. The reasons are:
one with a reused `deliveryId`; the result warns older callers that this field no
longer deduplicates input. The CLI does not generate a deduplication key.
The last recorded managed-compaction input refusal appears as `inputReadFailure` in the attempt
record and `rig compact --state`: the preparation or compact stage, `composer`
(the explicit composer hold) or `draft-aware` check, reader status, confidence,
a fixed read-failure reason and cursor flags. An owed restore step reports it in
`owedRestore.lastDelivery`. These fields contain no input text, suggestions,
terminal frames or exception messages. Confidence and timeout fields are `null`
when that reader does not supply them; a known composer read timeout reports
`timedOut: true`. A generic `capture_unavailable` does not identify the failed
capture operation. Older records lack this diagnostic. It neither changes the
refusal nor retries an attempt; missing metadata cannot establish which check
caused a historical refusal.
Clearing a draft, changing modes or restarting does not flush saved messages or
callbacks. To try again, send a current request: it rechecks the recipient and input.
A caller abort before paste cancels input; after a write may have started, the
+6
View File
@@ -137,6 +137,7 @@ export function packageCommand(depsOverride?: StatusDeps): Command {
conflicts?: Array<{ existingPath: string; reason: string }>;
verification?: { passed: boolean };
policyRejected?: Array<{ entry: { exportType: string; exportName: string }; reason: string }>;
notes?: string[];
error?: string;
errors?: string[];
code?: string;
@@ -204,6 +205,11 @@ export function packageCommand(depsOverride?: StatusDeps): Command {
console.log(` ${r.entry.exportType.padEnd(12)} ${r.entry.exportName.padEnd(20)} ${r.reason}`);
}
}
if (res.data.notes && res.data.notes.length > 0) {
console.log("Left as it is:");
for (const note of res.data.notes) console.log(` ${note}`);
}
});
// rig package rollback <installId>
+49 -2
View File
@@ -5,6 +5,10 @@ import { fileURLToPath } from "node:url";
import { execFile } from "node:child_process";
import { ConfigStore } from "../config-store.js";
import { mcpPresenceFilePath, readPresence } from "../mcp-presence.js";
import { CHECK_IN_DISPLAY, describeCheckIn, type CheckInStatus } from "@openrig/daemon/connection-contract";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { DaemonClient } from "../client.js";
import { realDeps } from "./daemon.js";
import { parseRoster, readRosters, type Roster, type RosterMember, type RosterWake } from "@openrig/daemon/rosters";
// One roster parser, shared with the daemon (a hosted member's wake route reads the same files).
@@ -17,6 +21,23 @@ export interface RosterInventory {
sources: Row[];
/** This host's id, from the local daemon's own nodes; null when it has none to read it from. */
localHostId?: string | null;
/** The local daemon's hosted members with their check-in status, and that daemon's own host id (the host whose
* check-ins these are); null when it couldn't be read. */
checkIns?: { hostId: string | null; members: Row[] } | null;
}
/** Each hosted member's check-in status, from the local daemon. Null when the daemon isn't running or doesn't answer
* (an older daemon has no such read): every hosted member's status then reads unknown. */
export async function readHostedCheckIns(): Promise<RosterInventory["checkIns"]> {
try {
const status = await getDaemonStatus(realDeps());
if (status.state !== "running") return null;
const res = await new DaemonClient(getDaemonUrl(status)).get<unknown>("/api/queue/hosted-members");
return res.status < 400 && object(res.data) && Array.isArray(res.data.members)
? { hostId: text(res.data.hostId), members: res.data.members.filter(object) } : null;
} catch {
return null;
}
}
const object = (v: unknown): v is Row => !!v && typeof v === "object" && !Array.isArray(v);
const text = (v: unknown): string | null => typeof v === "string" && v.trim() ? v : null;
@@ -49,7 +70,30 @@ export async function readRosterInventory(): Promise<RosterInventory> {
sources.push({ scope, status: "unavailable", error: result.stderr.trim() || (error as Error).message });
}
}
return { readAt: new Date().toISOString(), nodes, sources, localHostId };
return { readAt: new Date().toISOString(), nodes, sources, localHostId, checkIns: await readHostedCheckIns() };
}
function hostedCheckIn(member: RosterMember, inventory: RosterInventory): Row {
const unknown = (reason: string) => ({ status: "unknown", lastCheckInAt: null, expectedMinutes: member.check_in_minutes ?? null,
onRequest: member.check_in_on_request === true, reason });
// The daemon names its own host with its check-ins, so a team with no terminal seat still matches its members.
if (!inventory.checkIns) return unknown("this host's daemon gave no check-in status");
if (!inventory.checkIns.hostId || member.host !== inventory.checkIns.hostId) return unknown("it's listed on another host, whose check-ins this host doesn't hold");
const row = inventory.checkIns.members.find((m) => m.seat === member.seat);
return row && typeof row.status === "string" && row.status in CHECK_IN_DISPLAY ? row
: unknown("this host's daemon gave no status for it");
}
/** A connected assistant's check-in in one human line with its evidence, in the daemon's own words
* (`describeCheckIn`): "overdue: last check-in 13:05, expected every 60 min; its schedule may be off or waiting for an
* approval". A status this CLI doesn't know reads unknown. */
export function formatCheckIn(checkIn: Row): string {
const known = typeof checkIn.status === "string" && checkIn.status in CHECK_IN_DISPLAY;
return describeCheckIn({ status: known ? checkIn.status as CheckInStatus : "unknown",
lastCheckInAt: typeof checkIn.lastCheckInAt === "string" ? checkIn.lastCheckInAt : null,
expectedMinutes: typeof checkIn.expectedMinutes === "number" ? checkIn.expectedMinutes : null,
onRequest: checkIn.onRequest === true,
...(known && typeof checkIn.reason === "string" ? { reason: checkIn.reason } : {}) });
}
export function enrichRosterMember(member: RosterMember, inventory: RosterInventory) {
@@ -93,6 +137,9 @@ export function enrichRosterMember(member: RosterMember, inventory: RosterInvent
// is in rig_team.
...(member.hosted ? { lastCallAt: inventory.localHostId && member.host === inventory.localHostId
? readPresence(mcpPresenceFilePath())[member.seat]?.lastCallAt ?? null : null } : {}),
// Whether it's checking its queue, for a member on this host; another host's member, or a status this daemon
// couldn't give, reads unknown.
...(member.hosted ? { checkIn: hostedCheckIn(member, inventory) } : {}),
} };
}
@@ -134,7 +181,7 @@ export function rosterCommand(deps: Deps = {}): Command {
else {
const members = (verb === "show" ? (result.roster as Row).members : result.matches) as Array<ReturnType<typeof enrichRosterMember> & { roster?: { id: string } }>;
if (verb === "show") { const r = result.roster as Roster; console.log(`${r.name} — ${r.purpose}\nCurator: ${r.curator.seat} (${r.curator.host})`); }
for (const m of members) console.log(`${m.seat} (${m.host})${m.roster ? ` [${m.roster.id}]` : ""}\n ${m.engagement.join(", ")}: ${m.capabilities.join(", ")}\n When: ${m.use_when}\n Why: ${m.why}${m.caveat ? `\n Caveat: ${m.caveat}` : ""}\n ${m.live.status}: runtime ${m.live.runtime ?? "unknown"}; configured model ${m.live.configuredModel ?? "unknown"}; last observed model ${formatModelObservation(m.live.modelObservation)}\n Activity: ${m.live.agentActivity.state}; sampled ${m.live.agentActivity.sampledAt ?? "unknown"}${m.hosted ? `\n Last call: ${m.live.lastCallAt ?? "unknown"}` : ""}\n ${m.live.reason}`);
for (const m of members) console.log(`${m.seat} (${m.host})${m.roster ? ` [${m.roster.id}]` : ""}\n ${m.engagement.join(", ")}: ${m.capabilities.join(", ")}\n When: ${m.use_when}\n Why: ${m.why}${m.caveat ? `\n Caveat: ${m.caveat}` : ""}\n ${m.live.status}: runtime ${m.live.runtime ?? "unknown"}; configured model ${m.live.configuredModel ?? "unknown"}; last observed model ${formatModelObservation(m.live.modelObservation)}\n Activity: ${m.live.agentActivity.state}; sampled ${m.live.agentActivity.sampledAt ?? "unknown"}${m.hosted ? `\n Last call: ${m.live.lastCallAt ?? "unknown"}\n Check-in: ${formatCheckIn(m.live.checkIn ?? {})}` : ""}\n ${m.live.reason}`);
if (!members.length) console.log("No matching members.");
const obs = result.observations as { sources: Row[] };
for (const source of obs.sources) if (source.status !== "ok") console.error(`Observation gap (${source.scope}): ${source.warning ?? source.error ?? source.status}`);
+22
View File
@@ -207,6 +207,20 @@ function createMockDaemon() {
}));
return;
}
if (sourceRef.includes("skipped")) {
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({
installId: "inst-skip-1",
packageId: "pkg-1",
packageName: "guidance-pkg",
applied: [],
deferred: [],
conflicts: [],
verification: { passed: true },
notes: ["guidance_block_markers_damaged: /repo/CLAUDE.md: guidance-pkg has a BEGIN or END marker without its pair"],
}));
return;
}
if (sourceRef.includes("merge")) {
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({
@@ -383,6 +397,14 @@ describe("rig package", () => {
expect(output).toContain("inst-merge-1");
});
it("install with a guidance file left as it is: names the file and the reason, exit 0", async () => {
const { logs, exitCode } = await captureLogs(() => makeProgram().parseAsync(["node", "rig", "package", "install", "/skipped/path", "--target", "/repo", "--allow-merge"]));
const output = logs.join("\n");
expect(output).toContain("Left as it is:");
expect(output).toContain(" guidance_block_markers_damaged: /repo/CLAUDE.md: guidance-pkg has a BEGIN or END marker without its pair");
expect(exitCode).toBeUndefined();
});
// Test 8: install conflicts (409) → exitCode 1
it("install conflicts: prints conflicts, exitCode 1", async () => {
const { logs, exitCode } = await captureLogs(() => makeProgram().parseAsync(["node", "rig", "package", "install", "/conflict/path", "--target", "/repo"]));
+53 -1
View File
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFile, execFileSync } from "node:child_process";
import { promisify } from "node:util";
import { enrichRosterMember, parseRoster, readRosters, rosterCommand, type RosterInventory } from "../src/commands/roster.js";
import { enrichRosterMember, formatCheckIn, parseRoster, readRosters, rosterCommand, type RosterInventory } from "../src/commands/roster.js";
const member = { seat: "editor@studio", host: "host-a", capabilities: ["colour grading"], engagement: ["consult"], use_when: "Choose a look", why: "Knows the footage", caveat: "Advice only" };
const authored = { version: 1, id: "production", name: "Production", purpose: "Make a film", curator: { seat: "lead@studio", host: "host-a" }, updated_at: "2026-09-01", members: [member] };
@@ -133,3 +133,55 @@ it("does not choose the first duplicate route when activity observations conflic
expect(enrichRosterMember(member, { readAt: "2026-10-01T00:00:02Z", nodes: [idle, unclocked], sources: [] }).live.agentActivity)
.toEqual({ state: "idle", reason: null, sampledAt: null });
});
it("keeps any positive whole check-in cadence; one that can't be used still loads, with its reason and no cadence", () => {
const hourly = { ...member, seat: "bot@team", hosted: true, check_in_minutes: 60 };
expect(parseRoster({ ...authored, members: [hourly, { ...hourly, seat: "fast@team", check_in_minutes: 1 }] }).members)
.toEqual([hourly, { ...hourly, seat: "fast@team", check_in_minutes: 1 }]);
for (const bad of [{ ...hourly, check_in_minutes: 0 }, { ...hourly, check_in_minutes: 1.5 }, { ...hourly, check_in_minutes: "60" },
{ ...member, check_in_minutes: 60 }]) {
const [loaded] = parseRoster({ ...authored, members: [bad] }).members;
expect(loaded).not.toHaveProperty("check_in_minutes");
expect(loaded!.check_in_problem).toMatch(/check_in_minutes/);
}
});
it("shows a hosted member's check-in status from this host's daemon; another host's member, or no answer, reads unknown", () => {
const bot = { ...member, seat: "bot@team", host: "host-a", hosted: true, check_in_minutes: 60 };
const checkIns = [{ seat: "bot@team", rung: false, status: "check-in-overdue", lastCheckInAt: "2026-11-12T09:00:00.000Z", expectedMinutes: 60, reporter: "openrig-observed" }];
const here = { ...inventory([]), localHostId: "host-a", checkIns: { hostId: "host-a", members: checkIns } };
expect(enrichRosterMember(bot, here).live.checkIn).toMatchObject({ status: "check-in-overdue", lastCheckInAt: "2026-11-12T09:00:00.000Z" });
expect(formatCheckIn(enrichRosterMember(bot, here).live.checkIn!))
.toBe("overdue: last check-in 2026-11-12T09:00:00.000Z, expected every 60 min; its schedule may be off or waiting for an approval");
for (const [missing, reason] of [[{ ...here, checkIns: null }, "this host's daemon gave no check-in status"],
[{ ...here, checkIns: { hostId: "host-b", members: checkIns } }, "it's listed on another host, whose check-ins this host doesn't hold"],
[{ ...here, checkIns: { hostId: null, members: checkIns } }, "it's listed on another host, whose check-ins this host doesn't hold"],
[{ ...here, checkIns: { hostId: "host-a", members: [{ ...checkIns[0], status: "invented" }] } }, "this host's daemon gave no status for it"]] as const) {
expect(enrichRosterMember(bot, missing).live.checkIn).toEqual({ status: "unknown", lastCheckInAt: null, expectedMinutes: 60, onRequest: false, reason });
}
// The roster line is the daemon's own sentence for each status.
expect(formatCheckIn({ status: "unknown", lastCheckInAt: null, expectedMinutes: null, reason: "its check-ins couldn't be read" }))
.toBe("unknown (its check-ins couldn't be read)");
expect(formatCheckIn({ status: "no-check-in-yet", lastCheckInAt: null, expectedMinutes: null })).toBe("no check-in yet; no check-in cadence declared");
expect(formatCheckIn({ status: "no-check-in-yet", lastCheckInAt: null, expectedMinutes: 60 })).toBe("no check-in yet; expected every 60 min");
expect(formatCheckIn({ status: "on-request", lastCheckInAt: "2026-11-12T09:00:00.000Z", expectedMinutes: null, onRequest: true }))
.toBe("on request: it checks its queue when a person asks it; last check-in 2026-11-12T09:00:00.000Z");
expect(formatCheckIn({ status: "invented", lastCheckInAt: null, expectedMinutes: null })).toBe("unknown (its check-in status couldn't be read)");
expect(enrichRosterMember(member, here).live).not.toHaveProperty("checkIn");
});
it("a team with no terminal seat still shows its members' check-ins: the daemon names its own host with them", () => {
const bot = { ...member, seat: "bot@team", host: "host-a", hosted: true, check_in_minutes: 60 };
const fresh = { seat: "bot@team", rung: false, status: "checking-in", lastCheckInAt: "2026-11-12T09:55:00.000Z", expectedMinutes: 60, reporter: "openrig-observed" };
const noSeats: RosterInventory = { ...inventory([]), localHostId: null, checkIns: { hostId: "host-a", members: [fresh] } };
expect(enrichRosterMember(bot, noSeats).live.checkIn).toMatchObject({ status: "checking-in", lastCheckInAt: fresh.lastCheckInAt });
});
it("keeps check_in_on_request: true; any other value, both statements at once, or a member not marked hosted loads with its reason", () => {
const asked = { ...member, seat: "bot@team", hosted: true, check_in_on_request: true };
expect(parseRoster({ ...authored, members: [asked] }).members).toEqual([asked]);
for (const [bad, reason] of [[{ ...asked, check_in_on_request: false }, /isn't true/], [{ ...asked, check_in_minutes: 60 }, /both/],
[{ ...member, check_in_on_request: true }, /connected assistant/]] as const) {
const [loaded] = parseRoster({ ...authored, members: [bad] }).members;
expect(loaded).not.toHaveProperty("check_in_on_request");
expect(loaded).not.toHaveProperty("check_in_minutes");
expect(loaded!.check_in_problem).toMatch(reason);
}
});
@@ -7,7 +7,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig-plugins",
"repository": "github:mvschwarz/openrig-plugins",
"repository": "https://github.com/mvschwarz/openrig-plugins",
"license": "Apache-2.0",
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
"skills": "./skills",
@@ -7,7 +7,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig-plugins",
"repository": "github:mvschwarz/openrig-plugins",
"repository": "https://github.com/mvschwarz/openrig-plugins",
"license": "Apache-2.0",
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
"skills": "./skills",
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

@@ -7,7 +7,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig",
"repository": "github:mvschwarz/openrig",
"repository": "https://github.com/mvschwarz/openrig",
"license": "Apache-2.0",
"keywords": ["openrig", "agents", "team", "delegation", "mcp"],
"skills": "./skills",
@@ -7,7 +7,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig",
"repository": "github:mvschwarz/openrig",
"repository": "https://github.com/mvschwarz/openrig",
"license": "Apache-2.0",
"keywords": [
"openrig",
@@ -8,7 +8,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig",
"repository": "github:mvschwarz/openrig",
"repository": "https://github.com/mvschwarz/openrig",
"license": "Apache-2.0",
"keywords": ["openrig", "agents", "team", "delegation", "mcp"]
}
+4
View File
@@ -31,6 +31,10 @@
"types": "./dist/rosters-surface.d.ts",
"import": "./dist/rosters-surface.js"
},
"./connection-contract": {
"types": "./dist/connection-contract-surface.d.ts",
"import": "./dist/connection-contract-surface.js"
},
"./gateway-protocol": {
"types": "./dist/gateway-protocol-surface.d.ts",
"import": "./dist/gateway-protocol-surface.js"
+5 -3
View File
@@ -1,3 +1,4 @@
import type { InputReadDiagnostic } from "../domain/input-read-diagnostic.js";
import { DeliveryGuardError, type SeatDeliveryGuard } from "../domain/seat-delivery-guard.js";
import { writeFile as fsWriteFile, unlink as fsUnlink } from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -77,7 +78,7 @@ export function defaultTmuxFileOps(): TmuxFileOps {
export type TmuxResult =
| { ok: true; warnings?: string[]; stopPhase?: StopPhase }
| { ok: false; code: string; message: string; stopPhase?: StopPhase };
| { ok: false; code: string; message: string; stopPhase?: StopPhase; inputReadFailure?: InputReadDiagnostic };
/** Furthest external stop boundary reached; an attempted command can have an uncertain effect. */
export type StopPhase = "none" | "detach_attempted" | "detached" | "kill_attempted";
@@ -246,7 +247,7 @@ function isTmuxTransportAbsentError(err: unknown): boolean {
}
function classifyWriteError(err: unknown): TmuxResult {
if (err instanceof DeliveryGuardError) return { ok: false, code: err.code, message: err.message };
if (err instanceof DeliveryGuardError) return { ok: false, code: err.code, message: err.message, ...(err.inputReadFailure ? { inputReadFailure: err.inputReadFailure } : {}) };
if (!(err instanceof Error)) {
return { ok: false, code: "unknown", message: String(err) };
}
@@ -600,7 +601,8 @@ export class TmuxAdapter {
return guard.input(identity, () => write(pane, () => guard.checkInput(identity), bound.nodeId));
});
} catch (error) {
return { ok: false, code: (error as { code?: string }).code ?? "guard_target_unknown", message: String((error as Error).message) };
return { ok: false, code: (error as { code?: string }).code ?? "guard_target_unknown", message: String((error as Error).message),
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}) };
}
}
@@ -0,0 +1,3 @@
// The `@openrig/daemon/connection-contract` surface: the shared outcome and status words, so the CLI and the daemon
// read them from one place.
export * from "./domain/connection-contract.js";
@@ -643,6 +643,9 @@ export class BootstrapOrchestrator {
if (!outcome.ok) {
errors.push(`Package install failed for '${ref}' (${runtime}): ${outcome.message}`);
packageInstallFailed = true;
} else {
// A file the install left as it is, and why: the rest installed, and the rig still imports.
for (const note of outcome.notes ?? []) warnings.push(`Package '${ref}' (${runtime}): ${note}`);
}
}
}
@@ -1,3 +1,4 @@
import type { InputReadDiagnostic } from "./input-read-diagnostic.js";
import type { TerminalActor, TerminalPolicyStore } from "./terminal-policy.js";
import { TerminalPolicyRefusal } from "./terminal-policy.js";
import { DeliveryGuardError } from "./seat-delivery-guard.js";
@@ -425,6 +426,7 @@ type PendingPostCompactStage = "turn_boundary" | "restore_prompt" | "compliance_
/** Safe delivery diagnostics: never retain pane contents or an exception message. */
export interface RestoreDeliveryAttempt {
inputReadFailure?: InputReadDiagnostic;
stage: PendingPostCompactStage;
at: string;
ok: boolean;
@@ -461,6 +463,7 @@ export interface ObservedCompaction {
detail: string;
}
export interface PreparationState {
inputReadFailure?: InputReadDiagnostic & { stage: "preparation" | "compact" };
/** The reading that held this preparation send; never includes draft text. */
inputBox?: SendResult["heldInputBoxReading"];
policyActor?: TerminalActor;
@@ -565,6 +568,7 @@ interface PreparationFailureRun {
}
interface CompactionRecord {
inputReadFailure?: PreparationState["inputReadFailure"];
preparationFailures?: PreparationFailureRun;
inputBox?: PreparationState["inputBox"];
/** The daemon process that last wrote it. */
@@ -1000,6 +1004,7 @@ export class ClaudeCompactionEnforcer {
} catch (error) {
this.recordRestoreFailure(input.sessionName, stage, record, {
ok: false, reason: error instanceof DeliveryGuardError ? error.code : "send_failed",
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}),
}, error instanceof Error ? error.name : typeof error);
if (error instanceof DeliveryGuardError) return { triggered: false, reason: error.code === "typing_guard_enabled" ? "typing_guard_enabled" : "guard_target_unknown" };
throw error;
@@ -1027,7 +1032,7 @@ export class ClaudeCompactionEnforcer {
private recordRestoreFailure(
session: string, stage: PendingPostCompactStage | undefined,
record: { file: string; record: CompactionRecord } | undefined,
result: Pick<SendResult, "ok" | "reason" | "outcome" | "activity" | "heldInputBox">, errorClass?: string,
result: Pick<SendResult, "ok" | "reason" | "outcome" | "activity" | "heldInputBox" | "inputReadFailure">, errorClass?: string,
): void {
if (!stage || !record || this.records.get(session) !== record || this.pendingPostCompactRestore.get(session) !== stage) return;
const prior = record.record.lastDelivery;
@@ -1042,6 +1047,7 @@ export class ClaudeCompactionEnforcer {
const lastDelivery: RestoreDeliveryAttempt = {
stage, at: new Date(this.now()).toISOString(), ok: result.ok, reason: result.reason ?? "send_failed",
...(result.outcome ? { outcome: result.outcome } : {}),
...(result.inputReadFailure ? { inputReadFailure: result.inputReadFailure } : {}),
...(result.activity ? { activity: { state: result.activity.state, reason: result.activity.reason, evidenceSource: result.activity.evidenceSource } } : {}),
...(errorClass ? { errorClass } : {}), failures: waiting ? 0 : (prior?.failures ?? 0) + 1,
...(held ? { inputBox: held } : {}), ...(unreadable ? { unreadable } : {}),
@@ -1598,7 +1604,7 @@ export class ClaudeCompactionEnforcer {
if (error instanceof DeliveryGuardError) {
const written = (error as { inputWritten?: boolean }).inputWritten === true;
const attempted = (error as { inputAttempted?: boolean }).inputAttempted === true;
prep = { ok: false, sent: written, reason: error.code,
prep = { ok: false, sent: written, reason: error.code, inputReadFailure: error.inputReadFailure,
inputPhase: written ? "written" as const : attempted ? "attempted" as const : "not_reached" as const };
} else prep = null;
}
@@ -1611,7 +1617,8 @@ export class ClaudeCompactionEnforcer {
// Neither a failed response nor its loss proves no paste/Enter. Never replay uncertainty.
attempt.delivery = prep?.ok && prep.outcome !== "retained" ? "delivered" : notSent ? "not_sent" : "uncertain";
attempt.inputBox = prep?.heldInputBoxReading;
this.persist(input.sessionName, attempt.attemptId, { delivery: attempt.delivery, sends: attempt.sends, inputBox: attempt.inputBox });
if (prep?.inputReadFailure) attempt.inputReadFailure = { stage: "preparation", ...prep.inputReadFailure };
this.persist(input.sessionName, attempt.attemptId, { delivery: attempt.delivery, sends: attempt.sends, inputBox: attempt.inputBox, inputReadFailure: attempt.inputReadFailure });
// The replacement request went out (or may have): the ended attempt's gate is used up.
if (!notSent && attempt.afterEnded !== undefined && this.pendingPreCompactPrep.get(input.sessionName) === attempt) this.lastEnded.delete(input.sessionName);
if (this.pendingPreCompactPrep.get(input.sessionName) !== attempt || attempt.controller.signal.aborted) return;
@@ -1661,7 +1668,7 @@ export class ClaudeCompactionEnforcer {
return this.sessionTransport.send(input.sessionName, composed.command,
{ openrigNotice: true, allowDraftWhileIdle: true, beforeWrite, ...(attempt.deadlineAt !== null ? { waitForIdleMs: attempt.prepared ? idleWait : Math.max(1, Math.min(idleWait, this.compactDeadline(attempt)! - this.now())), readinessFromPaneOnly: true } : {}) });
};
let result: Pick<SendResult, "ok" | "reason" | "outcome" | "sent" | "inputPhase" | "activity" | "heldInputBox">;
let result: Pick<SendResult, "ok" | "reason" | "outcome" | "sent" | "inputPhase" | "activity" | "heldInputBox" | "inputReadFailure">;
let guardRefused = false;
try {
const guard = this.sessionTransport.deliveryGuard;
@@ -1672,11 +1679,16 @@ export class ClaudeCompactionEnforcer {
const attempted = (error as { inputAttempted?: boolean } | null)?.inputAttempted === true;
guardRefused = error instanceof DeliveryGuardError && !written && !attempted;
result = { ok: false, reason: error instanceof DeliveryGuardError ? error.code : "delivery_uncertain",
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}),
...(written ? { sent: true, inputPhase: "written" as const } : attempted ? { inputPhase: "attempted" as const } : {}) };
} finally {
delete attempt.compactInFlight;
}
if (!result.ok || result.outcome === "retained") {
if (result.inputReadFailure) {
attempt.inputReadFailure = { stage: "compact", ...result.inputReadFailure };
this.persist(input.sessionName, attempt.attemptId, { inputReadFailure: attempt.inputReadFailure });
}
const phase = result.inputPhase;
// A write that was issued, or text already in the input, is never "nothing sent".
const writeAttempted = phase === "attempted" || phase === "written" || result.sent === true;
@@ -1927,7 +1939,7 @@ export class ClaudeCompactionEnforcer {
compact: "not_sent", sends: record.sends,
deadlineAt: record.deadlineAt ?? (record.delivery === "not_sent" ? null : record.updatedAt + AUTO_PREP_WAIT_MS_DEFAULT),
policyWasEnabled: true, preparationRoot: record.preparationRoot, controller: new AbortController(),
compactInstruction: record.compactInstruction, inputBox: record.inputBox,
compactInstruction: record.compactInstruction, inputBox: record.inputBox, inputReadFailure: record.inputReadFailure,
// A held attempt comes back held, with its recorded hold (never stamped again) and its map's age and usage.
...(held ? { prepared: true as const, preparedAt: record.preparedAt ?? record.updatedAt, preparedReason: record.preparedReason ?? "map_complete_seat_never_idle" } : {}),
...(held ? { mapReadyAt: record.mapReadyAt ?? record.preparedAt ?? record.updatedAt } : record.mapReadyAt !== undefined ? { mapReadyAt: record.mapReadyAt } : {}),
@@ -42,6 +42,102 @@ export interface ReportedFact<F extends string> {
reporter: Reporter;
}
/** Whether a member outside any terminal is checking its queue. It's a status, never a refusal: work addressed to the
* member queues and is rung exactly as before, a check-in isn't pickup (pickup is a claim), and no check-in isn't
* idle, failure or safe. */
export type CheckInStatus =
/** Its last check-in is no older than twice its stated cadence (`CHECK_IN_OVERDUE_CADENCES`). */
| "checking-in"
/** Its last check-in is older than twice its stated cadence: its schedule may be off or waiting for an approval. */
| "check-in-overdue"
/** No check-in recorded for it. */
| "no-check-in-yet"
/** Its setup says it checks its queue when a person asks it (`check_in_on_request`), not on a schedule. */
| "on-request"
/** Its connection was revoked or removed, as recorded. Never inferred from silence. Nothing records a revocation yet,
* so nothing emits it: a revoked member stops checking in and reads overdue. */
| "disconnected"
/** Its setup states no cadence, its stated one can't be used, or its check-ins couldn't be read (the reason says
* which). Never idle and never overdue: with no stated cadence, OpenRig assumes none. */
| "unknown";
/** How each check-in status reads to a person. */
export const CHECK_IN_DISPLAY: Readonly<Record<CheckInStatus, string>> = {
"checking-in": "checking in",
"check-in-overdue": "overdue",
"no-check-in-yet": "no check-in yet",
"on-request": "on request",
"disconnected": "disconnected",
"unknown": "unknown",
};
/** The one rule for a stated cadence: a last check-in no older than this many cadences reads checking in, and an older
* one overdue. One missed run, plus its schedule's own jitter, isn't overdue. A cadence is its person's statement, so
* neither reading proves a vendor schedule or a pickup. */
export const CHECK_IN_OVERDUE_CADENCES = 2;
export interface MemberCheckIn {
status: CheckInStatus;
/** Its own latest read of its pending inbox, as OpenRig observed it. */
lastCheckInAt: string | null;
/** The cadence its person stated at setup; null when none was. */
expectedMinutes: number | null;
/** Its setup says it checks in when a person asks it. */
onRequest: boolean;
reporter: Reporter;
/** Why the status is unknown, when it is. */
reason?: string;
}
/** A connected assistant's check-in status from what its setup states (a cadence, or on request) and its last observed
* check-in (`seen`, `never`, or `unknown` when the record can't be read). First match wins: disconnected, unknown (an
* unusable statement or an unreadable record), no check-in yet, on request, then checking in or overdue by its cadence,
* else unknown because it states none. Nothing passes `revokedAt` yet, so nothing reads disconnected. */
export function memberCheckInStatus(input: {
checkInMinutes: number | null | undefined;
/** Its setup says it checks in on request rather than on a schedule. */
onRequest?: boolean;
/** Why the stated cadence can't be used, from the roster. */
cadenceProblem?: string | null;
checkIn: { state: "seen"; at: string } | { state: "never" } | { state: "unknown" };
revokedAt?: string | null;
now: Date;
}): MemberCheckIn {
const expectedMinutes = input.checkInMinutes ?? null;
const lastCheckInAt = input.checkIn.state === "seen" ? input.checkIn.at : null;
const onRequest = input.onRequest === true;
const unknown = (reason: string): MemberCheckIn => ({ status: "unknown", lastCheckInAt, expectedMinutes, onRequest, reporter: "openrig-observed", reason });
const known = (status: CheckInStatus): MemberCheckIn => ({ status, lastCheckInAt, expectedMinutes, onRequest, reporter: "openrig-observed" });
if (input.revokedAt) return known("disconnected");
if (input.cadenceProblem) return unknown(input.cadenceProblem);
if (input.checkIn.state === "unknown") return unknown("its check-ins couldn't be read");
if (input.checkIn.state === "never") return known("no-check-in-yet");
if (onRequest) return known("on-request");
if (expectedMinutes === null) return unknown(NO_CADENCE_STATED);
const age = input.now.getTime() - Date.parse(input.checkIn.at);
if (!Number.isFinite(age)) return unknown("its last check-in's time couldn't be read");
return known(age <= expectedMinutes * CHECK_IN_OVERDUE_CADENCES * 60_000 ? "checking-in" : "check-in-overdue");
}
/** Why a connected assistant whose setup states neither a cadence nor on request reads unknown. */
export const NO_CADENCE_STATED = "no check-in cadence declared";
/** A connected assistant's check-in in one line for a person, with its evidence: "overdue: last check-in 13:05,
* expected every 60 min; its schedule may be off or waiting for an approval". */
export function describeCheckIn(c: Pick<MemberCheckIn, "status" | "lastCheckInAt" | "expectedMinutes" | "onRequest" | "reason">): string {
const last = c.lastCheckInAt ? `last check-in ${c.lastCheckInAt}` : null;
const expected = c.expectedMinutes !== null ? `expected every ${c.expectedMinutes} min`
: c.onRequest ? "checks in when a person asks it" : NO_CADENCE_STATED;
switch (c.status) {
case "checking-in": return `checking in: ${last}, ${expected}`;
case "check-in-overdue": return `overdue: ${last}, ${expected}; its schedule may be off or waiting for an approval`;
case "no-check-in-yet": return `no check-in yet; ${expected}`;
case "on-request": return `on request: it checks its queue when a person asks it${last ? `; ${last}` : ""}`;
case "disconnected": return "disconnected: its connection was revoked or removed";
case "unknown": return `unknown (${c.reason ?? "its check-in status couldn't be read"})${last ? `; ${last}` : ""}`;
}
}
/** The route a wake took. `none` means no route can carry it (a pull-only member, an address nothing holds). */
export type WakeRoute = "terminal" | "gateway" | "hosted-trigger" | "none";
@@ -95,7 +95,8 @@ export class DraftAwareDelivery {
: guardError ? error.message : "The delivery attempt ended without a reliable write result. Repeating the request may duplicate input.";
result = { ok: false, sessionName: session, outcome: "failed", inputPhase,
...(inputWritten || inputPhase === "not_reached" ? { sent: inputWritten } : {}),
reason: guardError ? error.code : "delivery_indeterminate", error: detail };
reason: guardError ? error.code : "delivery_indeterminate", error: detail,
...(guardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}) };
}
if (inputWritten) result = { ...result, sent: true };
const preInputRefusal = ["draft_input_busy", "draft_input_unknown", "draft_input_changed", "draft_wake_superseded",
@@ -0,0 +1,47 @@
/** Reporting only: never retain composer text, suggestions, frames, hints or exception messages. */
export interface InputReadDiagnostic {
check: "composer" | "draft-aware";
status: "empty" | "text" | "typed" | "likely-suggestion" | "typed-with-suggestion" | "unknown" | "no-input-box";
confidence: "high" | "medium" | "low" | "unknown" | null;
reason: string;
cursor: { present: boolean; valid: boolean | null; atEnd: boolean | null };
/** null means this reader does not report whether a timeout occurred. */
timedOut: boolean | null;
}
const reasons = new Set(["capture_unavailable", "pane_in_mode", "invalid_cursor", "incomplete_frame",
"ambiguous_composer", "unrecognized_composer", "read_timeout", "read_budget_spent", "cursor_moved",
"cursor_unavailable", "cursor_outside", "unrecognized_input_box", "input_not_clear",
"paste_controls", "paste_long", "paste_viewport"]);
/** Copy only fixed vocabulary and cursor flags. Unknown reader reasons remain content-free. */
export function inputReadDiagnostic(check: InputReadDiagnostic["check"], reading: {
state?: string; status?: string; confidence?: string; unknownReason?: string;
cursor?: { x: number; y: number; width: number; height: number } | null;
cursorAtEnd?: boolean; evidence?: string[];
} | null, reason?: string): InputReadDiagnostic {
const evidence = reading?.evidence ?? [];
const timeout = evidence.includes("input box reads timed out");
const derived = reason ?? reading?.unknownReason ?? (timeout ? "read_timeout"
: evidence.includes("input box budget spent") ? "read_budget_spent"
: evidence.includes("cursor moved during the capture") ? "cursor_moved"
: evidence.includes("cursor unavailable") ? "cursor_unavailable"
: evidence.some(e => e === "cursor outside the input box" || e === "cursor seen once outside the input box") ? "cursor_outside"
: reading?.status === "no-input-box" ? "unrecognized_input_box"
: reading?.status === "unknown" || !reading ? "capture_unavailable" : "input_not_clear");
const status = reading?.status ?? reading?.state;
const confidence = reading?.confidence;
const cursor = reading?.cursor;
return {
check,
status: ["empty", "text", "typed", "likely-suggestion", "typed-with-suggestion", "unknown", "no-input-box"].includes(status ?? "")
? status as InputReadDiagnostic["status"] : "unknown",
confidence: ["high", "medium", "low", "unknown"].includes(confidence ?? "")
? confidence as InputReadDiagnostic["confidence"] : null,
reason: reasons.has(derived) ? derived : "capture_unavailable",
cursor: { present: !!cursor, valid: cursor ? [cursor.x, cursor.y, cursor.width, cursor.height].every(Number.isInteger)
&& cursor.x >= 0 && cursor.y >= 0 && cursor.width > cursor.x && cursor.height > cursor.y : null,
atEnd: typeof reading?.cursorAtEnd === "boolean" ? reading.cursorAtEnd : null },
timedOut: timeout ? true : null,
};
}
+30 -26
View File
@@ -4,6 +4,7 @@ import type { InstallPlanEntry } from "./install-planner.js";
import type { PolicyResult } from "./install-policy.js";
import type { RefinedInstallPlan } from "./conflict-detector.js";
import { InstallRepository, type JournalEntry } from "./install-repository.js";
import { damagedManagedBlockNote, findManagedBlocks, managedBlockBodyMatches, mergeManagedBlockText } from "./managed-blocks.js";
export interface EngineFsOps {
readFile: (filePath: string) => string;
@@ -19,6 +20,8 @@ export interface InstallResult {
applied: JournalEntry[];
deferred: InstallPlanEntry[];
conflicts: InstallPlanEntry[];
/** Entries left as they were, each with the reason: a guidance file whose block has a marker without its pair. */
notes: string[];
}
export interface RollbackResult {
@@ -52,10 +55,19 @@ export class InstallEngine {
): InstallResult {
const install = this.installRepo.createInstall(packageId, targetRoot, "project_shared", bootstrapId);
const applied: JournalEntry[] = [];
const notes: string[] = [];
const backupRoot = path.join(targetRoot, ".rigged-backups", install.id);
try {
for (const entry of policyResult.approved) {
// A guidance block with a marker that lost its pair can't be replaced, and another copy beside it is the
// duplicate to avoid: that file is left as it is and named, and the rest of the install goes on.
const unreadable = entry.exportType === "guidance" ? this.unreadableGuidanceBlock(entry, plan.packageName) : null;
if (unreadable) {
notes.push(unreadable);
continue;
}
// Ensure target directory
this.fs.mkdirp(path.dirname(entry.targetPath));
@@ -121,6 +133,7 @@ export class InstallEngine {
applied,
deferred: plan.deferred,
conflicts: plan.conflicts,
notes,
};
}
@@ -170,6 +183,14 @@ export class InstallEngine {
return { restored, deleted };
}
/** The note for a guidance entry whose target holds a marker of the package's block without its pair, and no complete
* copy, or null. */
private unreadableGuidanceBlock(entry: InstallPlanEntry, packageName: string): string | null {
if (!entry.sourcePath || !this.fs.exists(entry.targetPath)) return null;
const found = findManagedBlocks(this.fs.readFile(entry.targetPath), packageName);
return found.blocks.length === 0 && found.unpaired > 0 ? damagedManagedBlockNote(entry.targetPath, packageName) : null;
}
private applyGuidanceMerge(entry: InstallPlanEntry, packageName: string): void {
if (!entry.sourcePath) return;
@@ -178,34 +199,17 @@ export class InstallEngine {
if (this.fs.exists(entry.targetPath)) {
const existing = this.fs.readFile(entry.targetPath);
const startMarker = BLOCK_START(packageName);
const endMarker = BLOCK_END(packageName);
const legacyStart = `<!-- BEGIN RIGGED MANAGED BLOCK: ${packageName} -->`;
const legacyEnd = `<!-- END RIGGED MANAGED BLOCK: ${packageName} -->`;
// Prefer the OpenRig form; fall back to legacy markers from
// prior installs so re-applying a guidance package over a
// pre-rename file replaces the old block instead of appending a
// duplicate.
let startIdx = existing.indexOf(startMarker);
let endIdx = existing.indexOf(endMarker, startIdx + startMarker.length);
let matchedEndLength = endMarker.length;
if (startIdx === -1 || endIdx === -1) {
const legacyStartIdx = existing.indexOf(legacyStart);
const legacyEndIdx = existing.indexOf(legacyEnd, legacyStartIdx + legacyStart.length);
if (legacyStartIdx !== -1 && legacyEndIdx !== -1) {
startIdx = legacyStartIdx;
endIdx = legacyEndIdx;
matchedEndLength = legacyEnd.length;
}
}
if (startIdx !== -1 && endIdx !== -1) {
// Update existing block (rewrites legacy markers to OpenRig form).
const updated = existing.slice(0, startIdx) + block + existing.slice(endIdx + matchedEndLength);
this.fs.writeFile(entry.targetPath, updated);
} else {
const found = findManagedBlocks(existing, packageName);
if (found.blocks.length === 0) {
// Insert new block at end
this.fs.writeFile(entry.targetPath, existing + "\n\n" + block + "\n");
} else {
// Update the first copy in place, in the OpenRig form (legacy markers too). A later copy goes when it repeats the
// first copy's text or the package's; a copy with other text, which someone wrote, stays.
const first = found.blocks[0]!.body;
const updated = mergeManagedBlockText(existing, packageName, block, [],
(body) => !managedBlockBodyMatches(body, first) && !managedBlockBodyMatches(body, content));
if (updated !== null) this.fs.writeFile(entry.targetPath, updated);
}
} else {
// New file
+13 -1
View File
@@ -45,7 +45,9 @@ export class InstallVerifier {
this.fs = fs;
}
verify(installId: string): VerificationResult {
/** `skipped`: entries the install deliberately left as they were (its result's notes), so an install whose every entry was
* skipped verifies as that, not as an empty journal. */
verify(installId: string, opts: { skipped?: number } = {}): VerificationResult {
const install = this.installRepo.getInstall(installId);
if (!install) {
return {
@@ -76,6 +78,16 @@ export class InstallVerifier {
const journal = this.installRepo.getJournalEntries(installId);
const applyEntries = journal.filter((e) => e.action !== "rollback");
// Every entry left as it was on purpose, each named in the install's notes: nothing to check, and nothing missing.
if (applyEntries.length === 0 && (opts.skipped ?? 0) > 0) {
return {
passed: true,
installId,
entries: [],
statusCheck: { ...statusCheck, name: "entries_skipped", passed: true, expected: "applied entries, or skips named in notes", actual: `0 applied, ${opts.skipped} skipped` },
};
}
// Empty journal for applied install is a verification failure
if (applyEntries.length === 0) {
return {
@@ -2,7 +2,8 @@ import { createHash } from "node:crypto";
import fs from "node:fs";
import nodePath from "node:path";
import type Database from "better-sqlite3";
import { frameManagedBlockBody, managedBlockStanding, mergeManagedBlock, type ManagedBlockMergeFsOps, type ManagedBlockStanding } from "./managed-blocks.js";
import { damagedManagedBlockNote, frameManagedBlockBody, managedBlockBodyMatches, managedBlockStanding, mergeManagedBlock, pruneManagedBlockCopies,
withCanonicalMarkers, type ManagedBlockMergeFsOps, type ManagedBlockStanding } from "./managed-blocks.js";
/** #2456: OpenRig's record of what it last wrote in each managed guidance block, so a later start, exact resume, handover
* or runtime switch can tell a block it wrote (refreshed when its source changes) from a block a person edited (kept). */
@@ -92,6 +93,8 @@ export function decideManagedBlock(content: string, blockId: string, source: str
const standing = managedBlockStanding(content, blockId, source, recorded, managedBlockBodyHash);
const named = opts.sourcePath ?? "its source";
if (standing === "absent") return { standing, write: !opts.onlyIfPresent };
// A marker without its pair: writing would add a second copy beside the block that can't be read, at every start.
if (standing === "damaged") return { standing, write: false, note: damagedManagedBlockNote(targetPath, blockId) };
if (unreadable && standing === "unrecorded") {
return { standing: "unknown", write: false,
note: `guidance_block_record_unreadable: ${targetPath}: ${blockId} wasn't refreshed from ${named} because OpenRig couldn't read its write record` };
@@ -103,10 +106,36 @@ export function decideManagedBlock(content: string, blockId: string, source: str
return { standing, write: true };
}
/** A block that isn't rewritten (current, edited or unrecorded) can still carry extra copies, or markers an editor
* rewrapped: take out the copies that repeat a kept one, and OpenRig's own (its source's text, or what it last wrote)
* beside an edited one, so the file keeps one copy of each distinct text, and put the markers in OpenRig's form. Nothing
* a person wrote is removed. Best effort: a file that can't be read or written is named in `notes` and left as it is,
* and the block's own decision stands. Returns whether the file changed. */
export function pruneKeptManagedBlock(files: ManagedBlockMergeFsOps, targetPath: string, blockId: string, source: string,
records: ManagedBlockRecords | undefined, notes?: string[]): boolean {
let recorded: string | undefined;
try { recorded = records?.get(targetPath, blockId); } catch { /* unreadable: only the source's text counts as OpenRig's */ }
try {
const existing = files.exists(targetPath) ? files.readFile(targetPath) : "";
const pruned = pruneManagedBlockCopies(existing, blockId,
(body) => managedBlockBodyMatches(body, source) || (recorded !== undefined && managedBlockBodyHash(body) === recorded));
const tidy = withCanonicalMarkers(pruned ?? existing, blockId);
if (tidy === existing) return false;
files.writeFile(targetPath, tidy);
if (pruned !== null) {
notes?.push(`guidance_block_copies_removed: ${targetPath}: ${blockId} had repeated copies; the repeats were removed and every edited copy was kept`);
}
return true;
} catch (err) {
notes?.push(`guidance_block_copies_not_removed: ${targetPath}: ${blockId}'s repeated copies or markers couldn't be tidied (${(err as Error).message}); the file was left as it is`);
return false;
}
}
/** #2456: the one write of a managed guidance block that every adapter's guidance merge goes through. Without `records` it's
* the plain merge, as before. With them, `decideManagedBlock` decides; a kept block is named in `warnings` and nothing is
* written. A written block is recorded at once, so a later failure in the same start can't lose its record. Returns
* whether the block was written. */
* the plain merge, as before. With them, `decideManagedBlock` decides; a kept block is named in `warnings`, and only its
* repeated copies are taken out (`pruneKeptManagedBlock`). A written block is recorded at once, so a later failure in the
* same start can't lose its record. Returns whether the block was written. */
export function writeManagedGuidanceBlock(files: ManagedBlockMergeFsOps, targetPath: string, blockId: string, content: string, opts: {
records?: ManagedBlockRecords; unrecorded?: "keep" | "refresh"; onlyIfPresent?: boolean; sourcePath?: string;
warnings?: string[]; replaceBlockIds?: string[];
@@ -115,15 +144,19 @@ export function writeManagedGuidanceBlock(files: ManagedBlockMergeFsOps, targetP
// Without records, the plain merge as before; a refresh-only call (`onlyIfPresent`) then writes nothing.
if (!opts.records) {
if (opts.onlyIfPresent) return false;
merge();
return true;
return merge();
}
const existing = files.exists(targetPath) ? files.readFile(targetPath) : "";
const decision = decideManagedBlock(existing, blockId, content, opts.records, targetPath,
{ unrecorded: opts.unrecorded ?? "refresh", onlyIfPresent: opts.onlyIfPresent, sourcePath: opts.sourcePath });
if (decision.note) opts.warnings?.push(decision.note);
if (!decision.write) return false;
merge();
if (!decision.write) {
if (decision.standing === "edited" || decision.standing === "unrecorded") {
pruneKeptManagedBlock(files, targetPath, blockId, content, opts.records, opts.warnings);
}
return false;
}
if (!merge()) return false;
recordManagedBlockWrite(opts.records, targetPath, blockId, content, opts.warnings);
return true;
}
+143 -45
View File
@@ -41,15 +41,95 @@ export function managedBlockText(blockId: string, content: string): string {
/** What `mergeManagedBlock` puts between a file's existing text and a block it adds at the end. */
export const MANAGED_BLOCK_APPEND_SEPARATOR = "\n\n";
/** A BEGIN or END marker for `blockId`, in the current or the legacy form, as an editor or formatter may leave it: any
* run of whitespace, line breaks included, between its words. */
function markerSource(kind: "BEGIN" | "END", blockId: string): string {
return `<!--\\s*${kind}\\s+(?:OpenRig|RIGGED)\\s+MANAGED\\s+BLOCK:\\s*${escapeRegex(blockId)}\\s*-->`;
}
/** One `blockId` block in a file: where it and its body start and end, and the text between its markers. */
export interface FoundManagedBlock { start: number; end: number; bodyStart: number; bodyEnd: number; body: string }
/** Every `blockId` block in `content`, in order: each BEGIN paired with the next marker when that marker is an END. A
* marker left without a pair (an END with no BEGIN before it, or a BEGIN followed by another BEGIN or by nothing) is
* counted in `unpaired`: where its block ends, or starts, can't be read. */
export function findManagedBlocks(content: string, blockId: string): { blocks: FoundManagedBlock[]; unpaired: number } {
const markers = [...content.matchAll(new RegExp(`(${markerSource("BEGIN", blockId)})|${markerSource("END", blockId)}`, "g"))]
.map((m) => ({ begin: m[1] !== undefined, index: m.index!, length: m[0].length }));
const blocks: FoundManagedBlock[] = [];
let unpaired = 0;
for (let i = 0; i < markers.length; i++) {
const marker = markers[i]!;
const next = markers[i + 1];
if (marker.begin && next && !next.begin) {
const bodyStart = marker.index + marker.length;
blocks.push({ start: marker.index, end: next.index + next.length, bodyStart, bodyEnd: next.index, body: content.slice(bodyStart, next.index) });
i++;
} else {
unpaired++;
}
}
return { blocks, unpaired };
}
/** The note for a block OpenRig didn't write because a marker of it has lost its pair. */
export function damagedManagedBlockNote(targetPath: string, blockId: string): string {
return `guidance_block_markers_damaged: ${targetPath}: ${blockId} has a BEGIN or END marker without its pair, so OpenRig `
+ "didn't add another copy; restore the missing marker or remove the partial block, and OpenRig's next write keeps one block";
}
/**
* `existing` with `blockId`'s block written as `block`, or null when it can't be written without a second copy: the file
* has a marker of that block without its pair, and no complete copy. The first copy is replaced in place and a later copy
* is removed with the separator before it, so a file that collected copies keeps one; with `keepLater`, a later copy it
* names stays, unless it repeats one already kept. Blocks of `replaceBlockIds` (older ids) are removed. A file with none
* of these gets the block at its end.
*/
export function mergeManagedBlockText(existing: string, blockId: string, block: string, replaceBlockIds: readonly string[] = [],
keepLater?: (body: string) => boolean): string | null {
const own = findManagedBlocks(existing, blockId);
if (own.blocks.length === 0 && own.unpaired > 0) return null;
const keptLater: string[] = [];
const edits: Array<{ start: number; end: number; text: string }> = [];
own.blocks.forEach((found, i) => {
if (i === 0) edits.push({ start: found.start, end: found.end, text: block });
else if (keepLater?.(found.body) && !keptLater.some((body) => managedBlockBodyMatches(body, found.body))) keptLater.push(found.body);
else edits.push({ start: withSeparator(existing, found.start), end: found.end, text: "" });
});
for (const id of new Set(replaceBlockIds)) {
if (id === blockId) continue;
for (const found of findManagedBlocks(existing, id).blocks) edits.push({ start: found.start, end: found.end, text: "" });
}
if (edits.length === 0) return `${existing}${MANAGED_BLOCK_APPEND_SEPARATOR}${block}`;
// A block of one id inside another's is left to the outer edit.
const ordered: typeof edits = [];
for (const edit of edits.sort((a, b) => a.start - b.start)) if (edit.start >= (ordered.at(-1)?.end ?? 0)) ordered.push(edit);
let updated = existing;
// The block goes in by slicing, never a replacement string, so `$&` and the like in authored content stay as written.
for (const edit of ordered.reverse()) updated = `${updated.slice(0, edit.start)}${edit.text}${updated.slice(edit.end)}`;
if (own.blocks.length === 0) updated = `${updated.trim()}\n\n${block}`.trim();
// End with one newline only when the file doesn't already: re-merging on every launch must not add a line each time
// (#2372), and blank lines a person left after the last block stay.
return updated.endsWith("\n") ? updated : `${updated}\n`;
}
/** Where a later copy's removal starts: before the newlines that separate it from the text above, so removing the copy
* takes the separator the writer put there. */
function withSeparator(content: string, start: number): number {
let from = start;
while (from > 0 && content[from - 1] === "\n") from--;
return from;
}
/** Write `blockId`'s block into a guidance file (see `mergeManagedBlockText`). Returns false, writing nothing and naming
* the block in `warnings`, when a marker of it has lost its pair. */
export function mergeManagedBlock(
fs: ManagedBlockMergeFsOps,
targetPath: string,
blockId: string,
content: string,
options?: MergeManagedBlockOptions,
): void {
const begin = MANAGED_BLOCK_START(blockId);
const end = MANAGED_BLOCK_END(blockId);
): boolean {
const block = managedBlockText(blockId, content);
if (!fs.exists(targetPath)) {
@@ -57,49 +137,39 @@ export function mergeManagedBlock(
fs.writeFile(targetPath, block);
const warnings = excludeNewGeneratedFiles(nodePath.dirname(targetPath), [targetPath]);
options?.warnings?.push(...warnings);
return;
return true;
}
const existing = fs.readFile(targetPath);
const allReplaceIds = Array.from(new Set([blockId, ...(options?.replaceBlockIds ?? [])]));
const replaceableIds = allReplaceIds.filter((id) => {
const candidateBegin = MANAGED_BLOCK_START(id);
const candidateEnd = MANAGED_BLOCK_END(id);
const legacyBegin = LEGACY_BLOCK_START(id);
const legacyEnd = LEGACY_BLOCK_END(id);
return (
(existing.includes(candidateBegin) && existing.includes(candidateEnd)) ||
(existing.includes(legacyBegin) && existing.includes(legacyEnd))
);
});
if (replaceableIds.length > 0) {
let updated = existing;
for (const id of replaceableIds) {
const candidateBegin = MANAGED_BLOCK_START(id);
const candidateEnd = MANAGED_BLOCK_END(id);
const regex = new RegExp(`${escapeRegex(candidateBegin)}[\\s\\S]*?${escapeRegex(candidateEnd)}`, "g");
// A replacer function inserts the block literally; a replacement string
// would expand `$$`, `$&`, `` $` `` and `$'` in the authored content.
const replacement = id === blockId ? block : "";
updated = updated.replace(regex, () => replacement);
// Legacy marker variant from prior installs — replace with the
// OpenRig form (or strip when not the active block id).
const legacyBegin = LEGACY_BLOCK_START(id);
const legacyEnd = LEGACY_BLOCK_END(id);
const legacyRegex = new RegExp(`${escapeRegex(legacyBegin)}[\\s\\S]*?${escapeRegex(legacyEnd)}`, "g");
updated = updated.replace(legacyRegex, () => replacement);
}
if (!updated.includes(begin) || !updated.includes(end)) {
updated = `${updated.trim()}\n\n${block}`.trim();
}
// End with one newline only when the file doesn't already: re-merging on every launch must not add a line each time
// (#2372), and blank lines a person left after the last block stay.
fs.writeFile(targetPath, updated.endsWith("\n") ? updated : `${updated}\n`);
return;
const merged = mergeManagedBlockText(existing, blockId, block, options?.replaceBlockIds ?? []);
if (merged === null) {
options?.warnings?.push(damagedManagedBlockNote(targetPath, blockId));
return false;
}
fs.writeFile(targetPath, merged);
if (findManagedBlocks(existing, blockId).unpaired > 0) options?.warnings?.push(unpairedMarkerNote(targetPath, blockId));
return true;
}
fs.writeFile(targetPath, `${existing}${MANAGED_BLOCK_APPEND_SEPARATOR}${block}`);
/** The note for a marker without its pair left beside a block that was written. */
export function unpairedMarkerNote(targetPath: string, blockId: string): string {
return `guidance_block_marker_unpaired: ${targetPath}: ${blockId} was written, and a BEGIN or END marker of it without its `
+ "pair was left as it is; remove it or restore its pair";
}
/** `content` with each `blockId` block whose markers an editor rewrapped written back in OpenRig's own form, its body
* exactly as it is. Markers already in the current or legacy form stay as they are. */
export function withCanonicalMarkers(content: string, blockId: string): string {
let updated = content;
for (const found of findManagedBlocks(content, blockId).blocks.reverse()) {
const begin = content.slice(found.start, found.bodyStart);
const end = content.slice(found.bodyEnd, found.end);
const exact = (begin === MANAGED_BLOCK_START(blockId) && end === MANAGED_BLOCK_END(blockId))
|| (begin === LEGACY_BLOCK_START(blockId) && end === LEGACY_BLOCK_END(blockId));
if (exact) continue;
updated = `${updated.slice(0, found.start)}${MANAGED_BLOCK_START(blockId)}${found.body}${MANAGED_BLOCK_END(blockId)}${updated.slice(found.end)}`;
}
return updated;
}
/** Each `blockId` block in `content` (current markers): the whole block and the text between its markers. */
@@ -136,19 +206,47 @@ export function managedBlockOpenings(content: string, blockId: string): number {
* - `current`: it holds the source's text;
* - `stale`: it holds what OpenRig last wrote, and the source has changed since, so it's OpenRig's to refresh;
* - `edited`: it holds something else, which someone wrote by hand;
* - `unrecorded`: it differs from the source and OpenRig has no record of writing it, so which of the two is unknown.
* - `unrecorded`: it differs from the source and OpenRig has no record of writing it, so which of the two is unknown;
* - `damaged`: it holds a marker of the block without its pair, and no complete copy, so the block can't be read.
* A block written more than once counts as edited when any copy is. */
export type ManagedBlockStanding = "absent" | "current" | "stale" | "edited" | "unrecorded";
export type ManagedBlockStanding = "absent" | "current" | "stale" | "edited" | "unrecorded" | "damaged";
export function managedBlockStanding(content: string, blockId: string, source: string, recordedHash: string | undefined,
hash: (body: string) => string): ManagedBlockStanding {
const bodies = managedBlockBodies(content, blockId);
if (bodies.length === 0) return "absent";
const found = findManagedBlocks(content, blockId);
const bodies = found.blocks.map((block) => block.body);
if (bodies.length === 0) return found.unpaired > 0 ? "damaged" : "absent";
if (bodies.every((body) => managedBlockBodyMatches(body, source))) return "current";
if (recordedHash === undefined) return "unrecorded";
return bodies.every((body) => hash(body) === recordedHash) ? "stale" : "edited";
}
/** `content` with the extra copies of a `blockId` block taken out where no one's text is lost: a later copy whose body
* repeats an earlier copy's, and, beside a copy that's kept, a copy that `isOpenRigs` says holds only what OpenRig
* wrote. Every other copy stays where it is. Null when there's nothing to take out. */
export function pruneManagedBlockCopies(content: string, blockId: string, isOpenRigs: (body: string) => boolean): string | null {
const { blocks } = findManagedBlocks(content, blockId);
if (blocks.length < 2) return null;
const kept: FoundManagedBlock[] = [];
const removed: FoundManagedBlock[] = [];
for (const block of blocks) {
if (kept.some((other) => managedBlockBodyMatches(other.body, block.body))) removed.push(block);
else kept.push(block);
}
const people = kept.filter((block) => !isOpenRigs(block.body));
if (people.length > 0) for (const block of kept.filter((b) => isOpenRigs(b.body))) removed.push(block);
if (removed.length === 0) return null;
let updated = content;
for (const block of removed.sort((a, b) => b.start - a.start)) {
// A copy goes with the newlines before it; one that starts the file goes with the newlines after it instead.
const from = withSeparator(content, block.start);
let to = block.end;
if (from === 0) while (updated[to] === "\n") to++;
updated = `${updated.slice(0, from)}${updated.slice(to)}`;
}
return updated;
}
/** Whether `content` holds a `blockId` block whose body is anything but `written`: someone edited it. */
export function hasEditedManagedBlock(content: string, blockId: string, written: string): boolean {
return [...content.matchAll(managedBlockPattern(blockId))].some((m) => !managedBlockBodyMatches(m[1]!, written));
@@ -8,7 +8,7 @@ import { detectConflicts } from "./conflict-detector.js";
import { applyPolicy } from "./install-policy.js";
export type PackageInstallOutcome =
| { ok: true; installId: string; packageId: string; applied: number; deferred: number }
| { ok: true; installId: string; packageId: string; applied: number; deferred: number; notes?: string[] }
| { ok: false; code: "conflict_blocked"; message: string }
| { ok: false; code: "policy_rejected"; message: string }
| { ok: false; code: "manifest_hash_mismatch"; message: string }
@@ -101,7 +101,7 @@ export class PackageInstallService {
}
// Verify
const verification = this.installVerifier.verify(result.installId);
const verification = this.installVerifier.verify(result.installId, { skipped: result.notes?.length ?? 0 });
if (!verification.passed) {
return { ok: false, code: "verification_failed", message: "Post-apply verification failed" };
}
@@ -112,6 +112,7 @@ export class PackageInstallService {
packageId: pkg.id,
applied: result.applied.length,
deferred: result.deferred.length,
...(result.notes?.length ? { notes: result.notes } : {}),
};
}
}
@@ -21,12 +21,17 @@
// per founder authorization 2026-05-10 (LICENSE only); 404 is the
// expected normal-state response until a separate publish authorization.
import fs from "node:fs";
import nodePath from "node:path";
import { createHash, randomUUID } from "node:crypto";
export interface PluginVendorFs {
readFile(path: string): string;
writeFile(path: string, content: string): void;
/** A file's raw bytes. With `writeBytes`, a plugin's files are copied and compared byte for byte, so a binary file
* (an icon) arrives intact. Optional: without them files are read and written as text. */
readBytes?(path: string): Uint8Array;
writeBytes?(path: string, content: Uint8Array): void;
/** Copy raw bytes, failing if the recovery path already exists. */
copyFileExclusive(source: string, target: string): void;
exists(path: string): boolean;
@@ -160,7 +165,7 @@ export class PluginVendorService {
const destPath = nodePath.join(targetDir, relPath);
if (
this.fs.exists(destPath) &&
hashContent(this.fs.readFile(destPath)) === hashContent(this.fs.readFile(srcPath))
hashContent(this.read(destPath)) === hashContent(this.read(srcPath))
) {
this.preserveMode(srcPath, destPath);
}
@@ -170,7 +175,7 @@ export class PluginVendorService {
}
}
const updates: Array<[string, string]> = this.fs.listFiles(sourceDir).map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]);
const updates: Array<[string, FileContent]> = this.fs.listFiles(sourceDir).map(rel => [rel, this.read(nodePath.join(sourceDir, rel))]);
if (!this.preserveBeforeRefresh(targetDir, updates)) return;
this.fs.mkdirp(targetDir);
@@ -179,23 +184,34 @@ export class PluginVendorService {
const destPath = nodePath.join(targetDir, relPath);
// Hash-skip: only write if content differs (idempotent re-runs). Reconcile mode even on
// skip — a byte-identical copy staged earlier may still carry the wrong (default) mode.
if (this.fs.exists(destPath) && hashContent(this.fs.readFile(destPath)) === hashContent(content)) {
if (this.fs.exists(destPath) && hashContent(this.read(destPath)) === hashContent(content)) {
this.preserveMode(srcPath, destPath);
continue;
}
this.fs.mkdirp(nodePath.dirname(destPath));
this.fs.writeFile(destPath, content);
this.write(destPath, content);
this.preserveMode(srcPath, destPath);
}
}
/** A plugin file as the fs reads it exactly: bytes when it can, else text. */
private read(path: string): FileContent {
return this.fs.readBytes ? this.fs.readBytes(path) : this.fs.readFile(path);
}
private write(path: string, content: FileContent): void {
if (typeof content === "string") this.fs.writeFile(path, content);
else if (this.fs.writeBytes) this.fs.writeBytes(path, content);
else this.fs.writeFile(path, Buffer.from(content).toString("utf-8"));
}
/** Preserve every replaced file before touching a target, including its version authority. */
private preserveBeforeRefresh(targetDir: string, updates: Array<[string, string]>): boolean {
private preserveBeforeRefresh(targetDir: string, updates: Array<[string, FileContent]>): boolean {
const backupDir = nodePath.join(nodePath.dirname(this.userPluginsDir), "backups", "managed-refresh", randomUUID());
try {
for (const [rel, content] of new Map(updates)) {
const dest = nodePath.join(targetDir, rel);
if (!this.fs.exists(dest) || hashContent(this.fs.readFile(dest)) === hashContent(content)) continue;
if (!this.fs.exists(dest) || hashContent(this.read(dest)) === hashContent(content)) continue;
const backup = nodePath.join(backupDir, rel);
this.fs.mkdirp(nodePath.dirname(backup));
this.fs.copyFileExclusive(dest, backup);
@@ -276,7 +292,7 @@ export class PluginVendorService {
continue;
}
}
const updates: Array<[string, string]> = files.map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]);
const updates: Array<[string, FileContent]> = files.map(rel => [rel, this.read(nodePath.join(sourceDir, rel))]);
if (!this.preserveBeforeRefresh(targetDir, [...updates, [GLOBAL_VENDOR_VERSION, `${sourceVersion}\n`]])) continue;
this.fs.mkdirp(targetDir);
for (const [relPath, content] of updates) {
@@ -284,13 +300,13 @@ export class PluginVendorService {
const destPath = nodePath.join(targetDir, relPath);
if (
this.fs.exists(destPath) &&
hashContent(this.fs.readFile(destPath)) === hashContent(content)
hashContent(this.read(destPath)) === hashContent(content)
) {
this.preserveMode(srcPath, destPath);
continue;
}
this.fs.mkdirp(nodePath.dirname(destPath));
this.fs.writeFile(destPath, content);
this.write(destPath, content);
this.preserveMode(srcPath, destPath);
}
this.fs.writeFile(versionMarker, `${sourceVersion}\n`);
@@ -339,6 +355,37 @@ export class PluginVendorService {
}
}
function hashContent(content: string): string {
/** A plugin file's content: its bytes, or its text from an fs that only reads text. */
type FileContent = string | Uint8Array;
/** The same digest for a text and for its UTF-8 bytes, so a text compare and a byte compare agree. */
function hashContent(content: FileContent): string {
return createHash("sha256").update(content).digest("hex");
}
/** The daemon's own filesystem for vendoring: files are copied and compared as bytes. */
export function nodePluginVendorFs(): PluginVendorFs {
return {
readFile: (p) => fs.readFileSync(p, "utf-8"),
writeFile: (p, c) => fs.writeFileSync(p, c, "utf-8"),
readBytes: (p) => fs.readFileSync(p),
writeBytes: (p, c) => fs.writeFileSync(p, c),
copyFileExclusive: (src, dest) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL),
exists: (p) => fs.existsSync(p),
mkdirp: (p) => { fs.mkdirSync(p, { recursive: true }); },
listFiles: (dir) => {
const r: string[] = [];
/** Collect relative file paths recursively. */
const 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) => fs.statSync(p).mode,
chmod: (p, m) => fs.chmodSync(p, m),
};
}
@@ -19,7 +19,8 @@ export interface CodexLimitBanner {
evidence: string;
}
const BANNER_LINE_RE = /^\s*■ You've hit your usage limit\b/;
// Codex prints a typographic apostrophe (U+2019); older builds use the straight one.
const BANNER_LINE_RE = /^\s*■ You['’]ve hit your usage limit\b/u;
const RESET_RE = /try again at ([^.!\n]+)/i;
// Fenced code blocks: banner-shaped text an agent printed is quoted content,
+33 -16
View File
@@ -85,7 +85,8 @@ import { parseReplyToChoice, parseReplyToThreadMissing, formatReplyToChoice, des
import { classifyDestination, type DestinationClass } from "./gateway/destination-resolver.js";
import { join } from "node:path";
import { SettingsStore } from "./user-settings/settings-store.js";
import { hostedWakeFor, isHostedMemberAt, type RosterWake } from "./rosters.js";
import { hostedMembersAt, hostedWakeFor, isHostedMemberAt, type RosterMember, type RosterWake } from "./rosters.js";
import { memberCheckInStatus, type MemberCheckIn } from "./connection-contract.js";
import { hostedRingMayHaveArrived, ringHostedWake } from "./hosted-wake.js";
import { effectiveGateRoles } from "./gate-predicate.js";
import {
@@ -651,6 +652,16 @@ function defaultIsHostedMember(destination: string): boolean {
}
}
/** The hosted members the authored rosters list on this host. Empty when the rosters or this host's id can't be read. */
function defaultHostedMembers(): RosterMember[] {
try {
const root = new SettingsStore().resolveOne("workspace.root").value;
return typeof root === "string" && root ? hostedMembersAt(join(root, "rosters"), getSelfHostId()) : [];
} catch {
return [];
}
}
/** A session's latest read of its own pending inbox: when it was seen, never seen, or unknown (the record can't be read). */
export type InboxCheckIn = { state: "seen"; at: string } | { state: "never" } | { state: "unknown" };
@@ -1098,6 +1109,7 @@ export class QueueRepository {
private readonly hostedWakeFn: (destination: string) => RosterWake | null;
/** Whether the rosters list an address as a hosted member, trigger or not. */
private readonly hostedMemberFn: (destination: string) => boolean;
private readonly hostedMembersFn: () => RosterMember[];
private readonly permissionDecider?: (ask: QueueItem, requester: string) => { session: string; describe?: string } | null;
private readonly hostedWakeFetch: typeof fetch | undefined;
/** When this process last wrote each session's inbox check-in, so a seat that polls often writes at most once a minute. */
@@ -1151,6 +1163,7 @@ export class QueueRepository {
sharedChannels?: () => ReadonlyArray<{ address: string }>;
hostedWake?: (destination: string) => RosterWake | null;
isHostedMember?: (destination: string) => boolean;
hostedMembers?: () => RosterMember[];
hostedWakeFetch?: typeof fetch;
/** Who decides a runner's permission ask after its person answers, while the runner waits (startup wires the
* receiving-agent order, `permissionDeciderFor`). Absent, or null for an ask: the ask closes with the answer, as
@@ -1174,6 +1187,7 @@ export class QueueRepository {
this.sharedChannelsFn = opts?.sharedChannels ?? (() => sharedChannelAddresses());
this.hostedWakeFn = opts?.hostedWake ?? defaultHostedWake;
this.hostedMemberFn = opts?.isHostedMember ?? defaultIsHostedMember;
this.hostedMembersFn = opts?.hostedMembers ?? defaultHostedMembers;
this.permissionDecider = opts?.permissionDecider;
this.hostedWakeFetch = opts?.hostedWakeFetch;
this.hasTargetRepoColumn = detectQueueColumn(db, "target_repo");
@@ -4400,8 +4414,6 @@ 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" };
// Set inside the transaction when this click changed the answers (a declared type, so the closure's write is seen).
let changed = null as { summary: string | null; answered: number } | null;
const result = this.db.transaction((): RecordHumanAnswerResult => {
const item = this.getById(input.qitemId);
if (!item?.humanQuestions?.length) return { status: "not-applicable" as const, reason: "no-questions" };
@@ -4432,21 +4444,8 @@ export class QueueRepository {
const answers: HumanAnswers = { ...(item.humanAnswers ?? {}), [input.questionId]: input.optionId };
this.db.prepare("UPDATE queue_items SET human_answers = ? WHERE qitem_id = ?").run(JSON.stringify(answers), input.qitemId);
const complete = unansweredQuestions(item.humanQuestions, answers).length === 0;
if (item.humanAnswers?.[input.questionId] !== input.optionId) {
changed = { summary: item.summary ?? null, answered: item.humanQuestions.length - unansweredQuestions(item.humanQuestions, answers).length };
}
return { status: "recorded" as const, answers, complete, questions: item.humanQuestions };
})();
// The click is on the ask before anyone hears of it; a failed event never takes it back or holds up its return.
if (changed && result.status === "recorded") {
try {
this.eventBus.emit({ type: "queue.answer_recorded", qitemId: input.qitemId, actorSession: input.actorSession,
questionId: input.questionId, answered: changed.answered, questions: result.questions.length,
complete: result.complete, summary: changed.summary });
} catch (error) {
console.warn(`[queue] ${input.qitemId}'s recorded answer has no event`, error);
}
}
// 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 });
@@ -5286,6 +5285,24 @@ export class QueueRepository {
}
}
/** Each hosted member on this host with its check-in status: its stated cadence against its own inbox reads. A status
* only; nothing here changes how work reaches it. */
hostedMemberCheckIns(now: Date = new Date()): Array<{ seat: string; rung: boolean } & MemberCheckIn> {
return this.hostedMembersFn().map((m) => ({ seat: m.seat, rung: m.wake !== undefined,
...memberCheckInStatus({ checkInMinutes: m.check_in_minutes, onRequest: m.check_in_on_request, cadenceProblem: m.check_in_problem,
checkIn: this.lastInboxCheckIn(m.seat), now }) }));
}
/** One member's check-in status, as `hostedMemberCheckIns` gives it. A seat the rosters don't list here (or can't be
* read for) states no cadence, so it reads by its check-ins alone. */
hostedMemberCheckIn(seat: string, now: Date = new Date()): MemberCheckIn {
let member: RosterMember | undefined;
try { member = this.hostedMembersFn().find((m) => this.isSameSession(m.seat, seat)); } catch { member = undefined; }
// The roster's spelling of the seat, as the list read uses, so both read the same check-ins.
return memberCheckInStatus({ checkInMinutes: member?.check_in_minutes, onRequest: member?.check_in_on_request,
cadenceProblem: member?.check_in_problem, checkIn: this.lastInboxCheckIn(member?.seat ?? seat), now });
}
/** This session's latest recorded check-in under any of its names. */
lastInboxCheckIn(session: string): InboxCheckIn {
try {
@@ -116,7 +116,6 @@ export const PRUNABLE_EVENT_TYPES = [
"queue.claimed",
"queue.unclaimed",
"queue.handed_off",
"queue.answer_recorded",
// One delivery-evidence record per send, and one prompt observation per prompt hook; kept only as long as the other
// high-volume event types.
"transport.delivery_evidence",
+30 -14
View File
@@ -30,11 +30,11 @@ import { resolveOperatorSeat, resolveRecoveryAgentSeat, resolveSessionNodeId } f
import { nodeExecutorStoppedReason } from "./node-inventory.js";
import { isBoundTerminalSeat } from "./terminal-seat.js";
import type Database from "better-sqlite3";
import { createLinkedSuccessorLookup, deriveCrossHostSuccessorId, hasLinkedSuccessor, matchesRecordedHandoffTarget, QueueRepositoryError, type InboxCheckIn, type QueueItem, type QueueRepository } from "./queue-repository.js";
import { createLinkedSuccessorLookup, deriveCrossHostSuccessorId, hasLinkedSuccessor, matchesRecordedHandoffTarget, QueueRepositoryError, type QueueItem, type QueueRepository } from "./queue-repository.js";
import { stalledPickupFinding } from "./queue-pickup.js";
import { SettingsStore } from "./user-settings/settings-store.js";
import { loadHostRegistry } from "./hosts/hosts-registry-reader.js";
import { readWakeOutcome } from "./connection-contract.js";
import { describeCheckIn, readWakeOutcome, type MemberCheckIn } from "./connection-contract.js";
export const STUCK_SWEEP_INTERVAL_KEY = "queue.stuck_sweep_interval_seconds";
export const DEFAULT_STUCK_SWEEP_INTERVAL_SECONDS = 300;
@@ -621,20 +621,36 @@ function unclaimedBacklogText(seat: string, rows: BacklogRow[], thresholdMinutes
* and what to do. Its work's age runs from each row's own time, which a check-in never resets. Built from the rows' fixed
* facts and the member's check-in state, never a current check-in's exact time, so a pass rewrites it only when the rows
* or that state change. */
function polledBacklogText(member: string, rows: BacklogRow[], thresholdMinutes: number, checkIn: InboxCheckIn,
function polledBacklogText(member: string, rows: BacklogRow[], thresholdMinutes: number, checkIn: MemberCheckIn,
now: Date): { summary: string; body: string } {
const { sorted, listed, count } = backlogListing(rows);
const recent = checkIn.state === "seen" && now.getTime() - Date.parse(checkIn.at) <= thresholdMinutes * 60_000;
const checkIns = recent
? `checking in: it read its own inbox within the last ${thresholdMinutes} min`
: checkIn.state === "seen"
? `no check-in observed since ${checkIn.at}`
: checkIn.state === "never" ? "no check-in observed yet" : "unknown: its check-ins couldn't be read";
// The member's shared check-in status, in the same words `rig roster` uses (`describeCheckIn`). Only the words follow
// it; when this notice is filed doesn't. Where that line would carry the time of a check-in that keeps moving (checking
// in, on request, unknown), the notice says instead whether the member read its inbox within the threshold, so a
// check-in rewrites the notice only when it changes that, never its time alone. An overdue or never-seen member's line
// is fixed until it checks in, so it keeps its evidence.
const at = checkIn.lastCheckInAt;
const lately = at !== null && now.getTime() - Date.parse(at) <= thresholdMinutes * 60_000;
const recency = at === null ? "" : lately ? `; it read its own inbox within the last ${thresholdMinutes} min` : `; no check-in since ${at}`;
const recent = checkIn.status === "checking-in" || ((checkIn.status === "on-request" || checkIn.status === "unknown") && lately);
const checkIns = ((): string => {
switch (checkIn.status) {
case "checking-in": return `checking in: its last check-in is within twice its expected ${checkIn.expectedMinutes} min`;
case "on-request": return `on request: it checks its queue when a person asks it${recency}`;
case "unknown": return `unknown (${checkIn.reason ?? "its check-in status couldn't be read"})${recency}`;
case "check-in-overdue":
case "no-check-in-yet":
case "disconnected":
return describeCheckIn(checkIn);
}
})();
const why = recent
? "it's checking in, but this work is still unclaimed. Check whether it can do this work, or route it to another seat"
: checkIn.state === "unknown"
? "nobody has claimed these rows, and whether it's reading its queue isn't known. Ask its owner, or route the work to another seat"
: "nobody has claimed these rows, and no read of its own inbox has been observed in that time. Ask its owner to check it's running, or route the work to another seat";
? "it's reading its own inbox, but this work is still unclaimed. Check whether it can do this work, or route it to another seat"
: checkIn.status === "check-in-overdue"
? "nobody has claimed these rows, and it hasn't checked in as often as its setup expects. Ask its owner to check its schedule or a pending approval, or route the work to another seat"
: checkIn.status === "unknown" && at === null
? "nobody has claimed these rows, and whether it's reading its queue isn't known. Ask its owner, or route the work to another seat"
: "nobody has claimed these rows, and no read of its own inbox has been observed in that time. Ask its owner to check it's running, or route the work to another seat";
return {
summary: `Stuck sweep: unclaimed-backlog on ${member} (${count})`,
body:
@@ -1495,7 +1511,7 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
// A polling member's notice says whether it's checking in; an agent seat's names the route to add instead.
const polled = rows.length > 0 ? rows.some(polledUnclaimed) : pollsForWork(seat);
const backlogText = (standsFor: BacklogRow[]) => polled
? polledBacklogText(seat, standsFor, ageMinutes, deps.queueRepo.lastInboxCheckIn(seat), now)
? polledBacklogText(seat, standsFor, ageMinutes, deps.queueRepo.hostedMemberCheckIn(seat, now), now)
: unclaimedBacklogText(seat, standsFor, ageMinutes, node);
// A polling member's notice goes where its rows' findings went: the person, else their creator when they share one,
// else the operator agent. With none, it stays with the member, which reads it when it next checks.
+34 -28
View File
@@ -17,25 +17,21 @@ import nodePath from "node:path";
import { lstatSync } from "node:fs";
import type Database from "better-sqlite3";
import {
MANAGED_BLOCK_START,
MANAGED_BLOCK_END,
DEFAULT_CLAUDE_MANAGED_BLOCK_FILE,
findManagedBlocks,
mergeManagedBlock,
stripManagedBlocks,
type ManagedBlockMergeFsOps,
isPerSeatRoleGuidance,
isPerSeatRoleStartupFile,
} from "./managed-blocks.js";
import { decideManagedBlock, recordManagedBlockWrite, type ManagedBlockRecords } from "./managed-block-records.js";
import { decideManagedBlock, pruneKeptManagedBlock, recordManagedBlockWrite, type ManagedBlockRecords } from "./managed-block-records.js";
import { resolveConcreteHint } from "./runtime-adapter.js";
import { reanchorBuiltinStartupFile, reanchorShippedProjectionEntry, withContextEntryFloor } from "./builtin-startup-files.js";
import type { NodeStartupSnapshot } from "./types.js";
import { SEAT_SKILLS_BOOTSTRAP_BLOCK_ID } from "./seat-skill-storage.js";
import { guidanceFileIsLink } from "./footprint-choice.js";
const LEGACY_BLOCK_START = (id: string) => `<!-- BEGIN RIGGED MANAGED BLOCK: ${id} -->`;
const LEGACY_BLOCK_END = (id: string) => `<!-- END RIGGED MANAGED BLOCK: ${id} -->`;
export interface GuidanceItem {
blockId: string;
@@ -97,15 +93,10 @@ export function recordedGuidanceFile(db: Pick<Database.Database, "prepare">, nod
} 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. */
/** Whether guidance text holds this id's block as it is written: a BEGIN marker followed by its END, in the current or
* legacy form, read as the writer reads it (`findManagedBlocks`). For reporting an installed file. */
export function hasCompleteGuidanceBlock(content: string, blockId: string): boolean {
const ordered = (start: string, end: string): boolean => {
const begin = content.indexOf(start);
return begin >= 0 && content.indexOf(end, begin + start.length) >= 0;
};
return ordered(MANAGED_BLOCK_START(blockId), MANAGED_BLOCK_END(blockId))
|| ordered(LEGACY_BLOCK_START(blockId), LEGACY_BLOCK_END(blockId));
return findManagedBlocks(content, blockId).blocks.length > 0;
}
/** The guidance file a seat's managed blocks live in, and the blocks it should hold on restore. */
@@ -199,14 +190,13 @@ export function applySeatSkillsLineRule(guidance: SeatGuidance, answeredYes: boo
type BlockState = "complete" | "absent" | "incomplete";
/** A block counts only with both its markers (current or legacy form). A lone marker is incomplete:
* writing next to it is unsafe, because cleanup strips from a BEGIN to the next END. */
/** A block counts only with both its markers (current or legacy form), read as the writer reads them. A lone marker is
* incomplete: writing next to it is unsafe, because cleanup strips from a BEGIN to the next END, and it would add a
* second copy beside one that can't be read. */
function blockState(content: string, blockId: string): BlockState {
const current = [content.includes(MANAGED_BLOCK_START(blockId)), content.includes(MANAGED_BLOCK_END(blockId))];
const legacy = [content.includes(LEGACY_BLOCK_START(blockId)), content.includes(LEGACY_BLOCK_END(blockId))];
if ((current[0] && current[1]) || (legacy[0] && legacy[1])) return "complete";
if (current[0] || current[1] || legacy[0] || legacy[1]) return "incomplete";
return "absent";
const found = findManagedBlocks(content, blockId);
if (found.blocks.length > 0) return "complete";
return found.unpaired > 0 ? "incomplete" : "absent";
}
function blockStates(guidance: SeatGuidance, fs: Pick<ManagedBlockMergeFsOps, "exists" | "readFile">): Map<string, BlockState> {
@@ -228,11 +218,13 @@ export interface GuidanceRepair {
refreshed: string[];
/** #2456: blocks left as they are although they differ from their source, each named with the reason
* (`guidance_block_edited`, `guidance_block_may_be_stale` or `guidance_block_record_unreadable`). The seat runs them as
* they are. A block written whose record wasn't saved is named here too (`guidance_block_record_not_saved`). */
* they are. A block written whose record wasn't saved is named here too (`guidance_block_record_not_saved`), and so are
* a kept block's removed repeats (`guidance_block_copies_removed`). */
kept: string[];
}
const KEPT_GUIDANCE_PREFIXES = ["guidance_block_edited: ", "guidance_block_may_be_stale: ", "guidance_block_record_unreadable: "];
const KEPT_GUIDANCE_PREFIXES = ["guidance_block_edited: ", "guidance_block_may_be_stale: ", "guidance_block_record_unreadable: ",
"guidance_block_markers_damaged: "];
/** #2456: the one line a seat is told when a managed guidance block was left as it is (edited by hand, or possibly stale),
* naming each one, or undefined when none was. */
@@ -261,7 +253,8 @@ export function appendIsCleanupSafe(content: string, blocks: Array<{ blockId: st
/** Put back the missing managed guidance blocks. With `records` (#2456), a block already there that OpenRig wrote is
* refreshed from its changed source, and one edited since, or with no record, is kept and named (`decideManagedBlock`).
* Without them a block already there is left as it is, as before. Any other text in the file is left exactly as it is,
* Without them a block already there keeps its text. Either way a block left in place loses its exact repeats, and
* OpenRig's own copies beside an edited one, and rewrapped markers go back to OpenRig's form (`pruneKeptManagedBlock`). Any other text in the file is left exactly as it is,
* and nothing is written unless cleanup would later remove exactly what was written. Never throws: a failure becomes a
* reported gap. */
export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockMergeFsOps, records?: ManagedBlockRecords): GuidanceRepair {
@@ -279,17 +272,27 @@ export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockM
for (const item of guidance.items) {
const state = states.get(item.blockId);
if (state === "complete") {
if (!records || item.unresolved) continue;
if (item.unresolved) continue;
let source: string;
try {
if (!fs.exists(item.sourcePath)) continue;
source = fs.readFile(item.sourcePath);
} catch { continue; }
// Without records the block's text stays as it is; only its exact repeats and rewrapped markers are tidied.
if (!records) {
pruneKeptManagedBlock(fs, guidance.targetPath, item.blockId, source, undefined, repair.kept);
continue;
}
// #2456: the same decision every adapter's guidance write makes; an exact resume and a handover keep an unrecorded block.
const decision = decideManagedBlock(existing ?? "", item.blockId, source, records, guidance.targetPath, { unrecorded: "keep", sourcePath: item.sourcePath });
if (decision.note) repair.kept.push(decision.note);
else if (decision.standing === "current") recordManagedBlockWrite(records, guidance.targetPath, item.blockId, source, repair.kept);
else if (decision.write) toWrite.push({ blockId: item.blockId, content: source, refresh: true });
if (decision.write && decision.standing !== "current") {
toWrite.push({ blockId: item.blockId, content: source, refresh: true });
continue;
}
if (decision.standing === "current") recordManagedBlockWrite(records, guidance.targetPath, item.blockId, source, repair.kept);
// A block left as it is (current, edited or unrecorded) keeps one copy of each distinct text, in OpenRig's markers.
pruneKeptManagedBlock(fs, guidance.targetPath, item.blockId, source, records, repair.kept);
continue;
}
if (state === "incomplete") {
@@ -313,6 +316,9 @@ export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockM
if (toWrite.length === 0) return repair;
// A file that doesn't exist yet holds no text to lose. An existing one is written only if the
// next rig down would remove exactly what is added.
// Read again: removing a kept block's repeats above may have changed the file.
try { existing = fs.exists(guidance.targetPath) ? fs.readFile(guidance.targetPath) : null; }
catch (err) { repair.gaps.push(`could not read ${guidance.targetPath}: ${(err as Error).message}`); return repair; }
if (existing !== null && !appendIsCleanupSafe(existing, toWrite)) {
for (const block of toWrite) {
repair.gaps.push(`${block.blockId}: not written, because ${guidance.targetPath} has a managed-block marker that the next rig down would strip together with other text, so adding blocks there could cost that text`);
@@ -321,7 +327,7 @@ export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockM
}
for (const block of toWrite) {
try {
mergeManagedBlock(fs, guidance.targetPath, block.blockId, block.content);
if (!mergeManagedBlock(fs, guidance.targetPath, block.blockId, block.content, { warnings: repair.gaps })) continue;
if (records) recordManagedBlockWrite(records, guidance.targetPath, block.blockId, block.content, repair.kept);
(block.refresh ? repair.refreshed : repair.restored).push(block.blockId);
} catch (err) {
+30 -1
View File
@@ -26,6 +26,14 @@ export interface RosterMember extends Reference {
hosted?: boolean;
/** A hosted member's trigger, rung when a row lands for it. */
wake?: RosterWake;
/** How often a hosted member is expected to check its queue, in minutes, as its person set it up (an hourly schedule
* is 60). Stated, not observed: absent means it checks only when a person prompts it. */
check_in_minutes?: number;
/** Its person set it to check in when asked, not on a schedule. */
check_in_on_request?: true;
/** Why an authored `check_in_minutes` can't be used. The roster still loads; the member's check-in status reads
* unknown with this reason. */
check_in_problem?: string;
}
export interface Roster {
version: 1;
@@ -65,9 +73,19 @@ export function parseRoster(value: unknown): Roster {
|| (m.hosted !== undefined && typeof m.hosted !== "boolean")) {
throw new Error("Each member needs seat, host, capabilities, engagement, use_when and why (optional caveat, and hosted: true or false)");
}
// A cadence that can't be used never stops the roster loading: the member's check-in status reads unknown instead.
const cadence = m.check_in_minutes, onRequest = m.check_in_on_request;
const cadenceProblem = cadence === undefined && onRequest === undefined ? null
: m.hosted !== true ? "check_in_minutes and check_in_on_request are for a connected assistant (a member marked hosted: true)"
: onRequest !== undefined && onRequest !== true ? `check_in_on_request ${JSON.stringify(onRequest)} isn't true`
: onRequest === true && cadence !== undefined ? "it states both check_in_minutes and check_in_on_request"
: cadence !== undefined && (!Number.isInteger(cadence) || (cadence as number) <= 0) ? `check_in_minutes ${JSON.stringify(cadence)} isn't a positive whole number of minutes`
: null;
return { seat: m.seat, host: m.host, capabilities: m.capabilities, engagement: m.engagement,
use_when: m.use_when as string, why: m.why as string, ...(m.caveat === undefined ? {} : { caveat: m.caveat as string }),
...(m.hosted === true ? { hosted: true } : {}), ...(m.wake === undefined ? {} : { wake: parseWake(m.wake) }) };
...(m.hosted === true ? { hosted: true } : {}), ...(m.wake === undefined ? {} : { wake: parseWake(m.wake) }),
...(cadenceProblem ? { check_in_problem: cadenceProblem }
: { ...(cadence === undefined ? {} : { check_in_minutes: cadence as number }), ...(onRequest === true ? { check_in_on_request: true as const } : {}) }) };
});
return { version: 1, id: value.id as string, name: value.name as string, purpose: value.purpose as string,
curator: { seat: value.curator.seat, host: value.curator.host }, updated_at: value.updated_at as string, members };
@@ -103,6 +121,17 @@ export function hostedWakeFor(destination: string, folder: string, selfHostId: s
return null;
}
/** The hosted members the rosters list on this host, one per seat: where two rosters list the same seat, the first by
* file name wins. Empty when this host's id is unknown. */
export function hostedMembersAt(folder: string, selfHostId: string | null): RosterMember[] {
if (!selfHostId) return [];
const bySeat = new Map<string, RosterMember>();
for (const roster of readRosters(folder).rosters) {
for (const m of roster.members) if (m.hosted === true && m.host === selfHostId && !bySeat.has(m.seat)) bySeat.set(m.seat, m);
}
return [...bySeat.values()];
}
/** Whether a roster on this host lists this address as a hosted member, with or without a trigger. */
export function isHostedMemberAt(destination: string, folder: string, selfHostId: string | null): boolean {
if (!selfHostId) return false;
@@ -1,3 +1,4 @@
import { inputReadDiagnostic, type InputReadDiagnostic } from "./input-read-diagnostic.js";
import { hasOtherSeatHistory, isStoppedSeatPredecessor, resolveCurrentSessionAddress } from "./session-registry.js";
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
@@ -28,7 +29,7 @@ interface Lease {
}
export class DeliveryGuardError extends Error {
constructor(readonly code: string, message: string) { super(message); }
constructor(readonly code: string, message: string, readonly inputReadFailure?: InputReadDiagnostic) { super(message); }
// Hono's error protocol also preserves this typed refusal on lifecycle routes.
getResponse(): Response {
@@ -440,14 +441,14 @@ export class SeatDeliveryGuard {
this.checkInput(name);
return observed;
};
const unknown = () => new DeliveryGuardError("draft_input_unknown", "The current input cannot be read. Automatic delivery is held.");
const unknown = (reading: ComposerInput | null) => new DeliveryGuardError("draft_input_unknown", "The current input cannot be read. Automatic delivery is held.", inputReadDiagnostic("draft-aware", reading));
let input = await read();
// Claude may still be redrawing this lease's own paste: one bounded re-read before refusing its Enter.
if (submit && lease.staged && (!input || input.state === "unknown")) { await this.sleep(OWN_INPUT_REREAD_MS); input = await read(); }
if (!input || input.state === "unknown") {
// No Enter was sent, so the paste stays there unsubmitted, and the next delivery may recognize it as OpenRig's.
if (submit && lease.staged) this.leftovers.set(target.nodeId, { target: lease.target, humanEpoch: lease.humanEpoch, ...lease.staged });
throw unknown();
throw unknown(input);
}
if (leftover === "recognize" && !submit && this.ownsLeftover(target, input)) { lease.ownLeftover = "recognized"; return; }
if (typeof leftover === "object" && lease.ownLeftover === "recognized") {
@@ -466,7 +467,7 @@ export class SeatDeliveryGuard {
lease.ownLeftover = "submitted";
await this.sleep(OWN_INPUT_REREAD_MS);
const after = await read();
if (!after || after.state === "unknown") throw unknown();
if (!after || after.state === "unknown") throw unknown(after);
input = after;
}
}
@@ -489,7 +490,7 @@ export class SeatDeliveryGuard {
: uncertainty === "long"
? "OpenRig couldn't confirm ownership of Codex's collapsed long paste. Send a shorter message; nothing was pasted. Turning this seat's typing guard off explicitly disables draft protection."
: "OpenRig couldn't confirm the complete message would stay visible in this Codex viewport. Open a larger terminal or shorten the message, then retry this request; nothing was pasted.";
throw new DeliveryGuardError("draft_input_unknown", message);
throw new DeliveryGuardError("draft_input_unknown", message, inputReadDiagnostic("draft-aware", input, `paste_${uncertainty}`));
}
lease.inputFrame = input.frame;
}
@@ -1,3 +1,4 @@
import { inputReadDiagnostic, type InputReadDiagnostic } from "./input-read-diagnostic.js";
import { expectedConversation, sameConversation, resolveExpectedConversation, type ExpectedConversation } from "./expected-conversation.js";
import type { ContextUsageStore } from "./context-usage-store.js";
import { physicalSessionName } from "./session-registry.js";
@@ -2176,6 +2177,7 @@ export interface SendResult {
heldInputBox?: InputBoxAnnotation["status"];
/** The existing preserve-input reading, without the draft or suggestion text. No extra capture. */
heldInputBoxReading?: Pick<InputBoxAnnotation, "status" | "confidence" | "evidence" | "cursor">;
inputReadFailure?: InputReadDiagnostic;
}
/** One reading of a Claude Code or Codex input box: `exact` (the sent text, word boundaries kept), `ambiguous`
@@ -2823,6 +2825,7 @@ export class SessionTransport {
sent: written ? true : attempted ? undefined : false,
inputPhase: written ? "written" : attempted ? "attempted" : "not_reached",
reason: (error as { code?: string }).code ?? "expected_identity_unavailable", error: (error as Error).message,
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}),
...(receiptUnconfirmed ? { warning: "Conditional input receipt could not be finalized; inspect before retrying." } : {}) };
}
}
@@ -2944,17 +2947,20 @@ export class SessionTransport {
if ((error as { inputWritten?: boolean }).inputWritten === true) {
// Only a throw before the Enter key reached the adapter positively means Enter was never sent.
const enter = (error as { enterAttempted?: boolean }).enterAttempted === true ? "the submission (Enter) outcome is unconfirmed" : "Enter was not sent";
return { ok: false, sessionName, sent: true, outcome: "failed", inputPhase: "written", reason: (error as { code?: string }).code ?? "submit_refused",
return { ok: false, sessionName, sent: true, outcome: "failed", inputPhase: "written",
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}), reason: (error as { code?: string }).code ?? "submit_refused",
error: `Text was written to '${sessionName}' but ${enter}: ${(error as Error).message}` };
}
// A throw after the text write was issued: whether it landed is unknown, so it is not "nothing sent" either.
if ((error as { inputAttempted?: boolean }).inputAttempted === true) {
return { ok: false, sessionName, outcome: "failed", inputPhase: "attempted", reason: (error as { code?: string }).code ?? "delivery_indeterminate",
return { ok: false, sessionName, outcome: "failed", inputPhase: "attempted",
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}), reason: (error as { code?: string }).code ?? "delivery_indeterminate",
error: `A text write to '${sessionName}' was issued, but its result was lost: ${(error as Error).message}` };
}
const noInputCode = error instanceof OutboxHandlerError && error.code === "retained_quota_full" ? "retained_quota_full" as const
: error instanceof DeliveryGuardError && error.code === "terminal_seat" ? "terminal_seat" as const : undefined;
return { ok: false, sessionName, sent: false, reason: (error as { code?: string }).code ?? "guard_unavailable", error: (error as Error).message,
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}),
...(noInputCode ? { noInputCode, inputPhase: "not_reached" as const } : {}) };
}
}
@@ -3350,7 +3356,7 @@ export class SessionTransport {
}
if (!submitResult.ok && !enterIssued && submitResult.code === CALLER_CANCELLED && cancel?.aborted) return callerCancelled(sessionName);
if (!submitResult.ok) {
return { ok: false, sessionName, inputPhase: enterIssued ? "attempted" : "not_reached", ...(!enterIssued ? { sent: false } : {}), reason: submitResult.code?.startsWith("expected_identity_") ? submitResult.code : "submit_failed", outcome: "failed", error: enterIssued
return { ok: false, sessionName, ...(submitResult.inputReadFailure ? { inputReadFailure: submitResult.inputReadFailure } : {}), inputPhase: enterIssued ? "attempted" : "not_reached", ...(!enterIssued ? { sent: false } : {}), reason: submitResult.code?.startsWith("expected_identity_") ? submitResult.code : "submit_failed", outcome: "failed", error: enterIssued
? `submitOnly: Enter reported a failure for '${sessionName}'; submission is unconfirmed: ${submitResult.message}`
: `submitOnly: Enter was not sent to '${sessionName}': ${submitResult.message}` };
}
@@ -3700,7 +3706,7 @@ export class SessionTransport {
heldInputBoxReading = { status, confidence, evidence, cursor };
}
throw new DeliveryGuardError(box?.status === "typed" ? "draft_input_busy" : "draft_input_unknown",
`Managed input waits for an empty composer (it reads ${box?.status ?? "unknown"}). Your draft was left untouched; inspect the pane before retrying.`);
`Managed input waits for an empty composer (it reads ${box?.status ?? "unknown"}). Your draft was left untouched; inspect the pane before retrying.`, inputReadDiagnostic("composer", box));
}
if (!clear) pastedOverInputBox = box?.status ?? "unknown";
} : undefined;
@@ -3735,6 +3741,7 @@ export class SessionTransport {
ok: false,
sessionName,
reason: policyRefusal ? textResult.code : "send_failed",
...(textResult.inputReadFailure ? { inputReadFailure: textResult.inputReadFailure } : {}),
outcome: "failed",
// The adapter's draft checks run before the paste (only file/buffer preparation precedes them), and its
// after-paste observation cannot fail the write, so a draft_* receipt proves the target input was never reached.
@@ -3803,6 +3810,7 @@ export class SessionTransport {
ok: false,
sessionName,
reason: policyRefusal ? submitResult.code : "submit_failed",
...(submitResult.inputReadFailure ? { inputReadFailure: submitResult.inputReadFailure } : {}),
outcome: "failed",
inputPhase: "written",
error: policyRefusal
+5 -32
View File
@@ -3,7 +3,6 @@ import { performance } from "node:perf_hooks";
import { Worker } from "node:worker_threads";
import type { MiddlewareHandler } from "hono";
export const SLOW_OPERATION_BARRIER_TIMEOUT_MS = 250;
export const SLOW_OPERATION_THRESHOLD_MS = 250;
export const SLOW_OPERATION_ROTATION_BYTES = 1024 * 1024;
export const SLOW_OPERATION_ROTATION_COUNT = 3;
@@ -89,7 +88,6 @@ interface SlowOpRecorderOptions {
maxBytes?: number;
rotationCount?: number;
slowThresholdMs?: number;
barrierTimeoutMs?: number;
}
interface Span {
@@ -141,13 +139,7 @@ const WORKER_SOURCE = String.raw`
ok = false;
error = caught instanceof Error ? caught.message : String(caught);
}
if (message.signal) {
const state = new Int32Array(message.signal);
Atomics.store(state, 0, ok ? 1 : 2);
Atomics.notify(state, 0);
} else if (message.id) {
parentPort.postMessage({ id: message.id, ok, error });
}
if (message.id) parentPort.postMessage({ id: message.id, ok, error });
});
`;
@@ -157,7 +149,6 @@ export class SlowOpRecorder implements SlowOperationInstrumentation {
private readonly maxBytes: number;
private readonly rotationCount: number;
private readonly slowThresholdMs: number;
private readonly barrierTimeoutMs: number;
private readonly pending = new Map<string, { resolve: () => void; reject: (error: unknown) => void }>();
private degraded: SlowOperationSnapshot = { healthy: true };
private degradedHandler?: (snapshot: Required<Pick<SlowOperationSnapshot, "reason" | "site">>) => void;
@@ -181,7 +172,6 @@ export class SlowOpRecorder implements SlowOperationInstrumentation {
this.maxBytes = options.maxBytes ?? SLOW_OPERATION_ROTATION_BYTES;
this.rotationCount = options.rotationCount ?? SLOW_OPERATION_ROTATION_COUNT;
this.slowThresholdMs = options.slowThresholdMs ?? SLOW_OPERATION_THRESHOLD_MS;
this.barrierTimeoutMs = options.barrierTimeoutMs ?? SLOW_OPERATION_BARRIER_TIMEOUT_MS;
this.worker = new Worker(WORKER_SOURCE, { eval: true, execArgv: [] });
this.worker.unref();
this.worker.on("message", (message: { id?: string; ok?: boolean }) => {
@@ -220,27 +210,10 @@ export class SlowOpRecorder implements SlowOperationInstrumentation {
beginSyncSpan(site: string): Span {
const span: Span = { spanId: randomUUID(), site, startedAt: performance.now() };
const signal = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
const state = new Int32Array(signal);
try {
this.worker.postMessage({
type: "append",
signal,
logPath: this.logPath,
maxBytes: this.maxBytes,
rotationCount: this.rotationCount,
record: { v: 1, ts: new Date().toISOString(), spanId: span.spanId, phase: "begin", site },
});
} catch {
// Synchronous postMessage failure means the Worker is gone: degrade
// through the terminal transition but still return the span so the
// wrapped operation runs to completion (its value/error stays exact).
this.handleTerminalFailure("recorder_post_failed", site);
return span;
}
const wait = Atomics.wait(state, 0, 0, this.barrierTimeoutMs);
if (wait === "timed-out") this.markDegraded("begin_barrier_timeout", site);
else if (Atomics.load(state, 0) !== 1) this.markDegraded("begin_barrier_failed", site);
// Queue before the operation without waiting on worker startup or fsync. The
// worker can write even while the main thread is wedged; only flush/close
// acknowledge durability. A crash before the worker appends can lose begin.
this.appendAsync({ v: 1, ts: new Date().toISOString(), spanId: span.spanId, phase: "begin", site });
return span;
}
@@ -1,6 +1,6 @@
import { spawnSync } from "node:child_process";
import { execFile } from "node:child_process";
import type { TmuxAdapter } from "../adapters/tmux.js";
import { runSyncSite } from "./sync-site-wrap.js";
import { runAsyncSite } from "./sync-site-wrap.js";
export interface TmuxOptionDefaultsDeps {
tmuxAdapter: TmuxAdapter;
@@ -17,11 +17,11 @@ export interface TmuxOptionDefaultsDeps {
*/
platform?: NodeJS.Platform;
/**
* OPR.0.4.6.02 S1 — cheap `command -v <bin>` probe for the linux
* copy-command fallback chain. Defaults to a real shell probe; injectable
* OPR.0.4.6.02 S1 — asynchronous `command -v <bin>` probe for the linux
* copy-command fallback chain. Defaults to a bounded asynchronous shell probe; injectable
* for deterministic tests.
*/
hasCommand?: (bin: string) => boolean;
hasCommand?: (bin: string) => boolean | Promise<boolean>;
}
/**
@@ -44,7 +44,7 @@ export class TmuxOptionDefaultsApplier {
private tmuxAdapter: TmuxAdapter;
private readTmuxOptionDefaults: () => { statusBar: boolean };
private platform: NodeJS.Platform;
private hasCommand: (bin: string) => boolean;
private hasCommand: (bin: string) => boolean | Promise<boolean>;
private serverDefaultsAsserted = false;
constructor(deps: TmuxOptionDefaultsDeps) {
@@ -120,7 +120,14 @@ export class TmuxOptionDefaultsApplier {
warnings.push(`tmux "set-clipboard" server option not set: ${clip.message}`);
}
const copyCommand = resolveCopyCommand(this.platform, this.hasCommand);
let copyCommand: string | null;
try {
copyCommand = await resolveCopyCommand(this.platform, this.hasCommand);
} catch {
this.serverDefaultsAsserted = false;
warnings.push("Clipboard lookup interrupted (250 ms limit); copy-command left unchanged (OSC 52 if unset). Will retry on the next fresh session.");
return;
}
if (copyCommand !== null) {
const cc = await this.tmuxAdapter.setServerOption("copy-command", copyCommand);
if (!cc.ok) {
@@ -141,14 +148,14 @@ export class TmuxOptionDefaultsApplier {
* Pure: platform + a `command -v` probe in, the command string (or null)
* out. One table, no side effects — the applier decides whether to write.
*/
export function resolveCopyCommand(
export async function resolveCopyCommand(
platform: NodeJS.Platform,
hasCommand: (bin: string) => boolean,
): string | null {
hasCommand: (bin: string) => boolean | Promise<boolean>,
): Promise<string | null> {
if (platform === "darwin") return "pbcopy";
if (platform === "linux") {
if (hasCommand("wl-copy")) return "wl-copy";
if (hasCommand("xclip")) return "xclip -selection clipboard -i";
if (await hasCommand("wl-copy")) return "wl-copy";
if (await hasCommand("xclip")) return "xclip -selection clipboard -i";
return null;
}
return null;
@@ -156,16 +163,18 @@ export function resolveCopyCommand(
/**
* OPR.0.4.6.02 S1 — default `command -v <bin>` probe (POSIX shell builtin).
* Returns true when the binary resolves on PATH. Cheap + best-effort; any
* failure (spawn error, non-shell env) reads as absent.
* Returns true when the binary resolves on PATH. A killed probe rejects so
* the applier can warn and retry; other failures read as absent.
*/
function defaultHasCommand(bin: string): boolean {
async function defaultHasCommand(bin: string): Promise<boolean> {
try {
const r = runSyncSite("tmux_options.command_v", () =>
spawnSync(`command -v ${bin}`, { shell: true, stdio: "ignore" })
);
return r.status === 0;
} catch {
return await runAsyncSite("tmux_options.command_v", () => new Promise<boolean>((resolve, reject) => {
execFile("/bin/sh", ["-c", 'command -v "$1"', "--", bin], {
timeout: 250, killSignal: "SIGKILL", maxBuffer: 1024,
}, error => error?.killed ? reject(error) : resolve(!error));
}));
} catch (error) {
if ((error as { killed?: boolean } | null)?.killed) throw error;
return false;
}
}
-4
View File
@@ -330,10 +330,6 @@ export type RigEvent =
// consumers on /api/views/:name/sse when ANY queue state mutation
// changes a view result-set, not just create/handoff/claim/unclaim.
| { type: "queue.updated"; qitemId: string; fromState: string; toState: string; closureReason: string | null; closureTarget: string | null; actorSession: string; summary: string | null }
// A person's clicked answer recorded on an ask with questions: `answered` of `questions` now have an answer, and
// `complete` says whether that's all of them. A partial set is a change to the ask, not its answer: the ask stays open
// until the set is complete and returned.
| { type: "queue.answer_recorded"; qitemId: string; actorSession: string; questionId: string; answered: number; questions: number; complete: boolean; summary: string | null }
// Coordination primitive (PL-004 Phase B) — project (classifier) / view.
// project.classified: emitted when a stream item is successfully projected.
// classifier.lease_*: lifecycle of the daemon-enforced single-writer lease.
+3 -1
View File
@@ -273,7 +273,7 @@ packagesRoutes.post("/install", async (c) => {
}
// Verify
const verification = installVerifier.verify(result.installId);
const verification = installVerifier.verify(result.installId, { skipped: result.notes?.length ?? 0 });
if (!verification.passed) {
eventBus.emit({ type: "package.install_failed", packageName: resolved.manifest.name, code: "verification_failed", message: "Post-apply verification failed" });
return c.json({
@@ -281,6 +281,7 @@ packagesRoutes.post("/install", async (c) => {
code: "verification_failed",
installId: result.installId,
verification,
...(result.notes?.length ? { notes: result.notes } : {}),
}, 500);
}
@@ -302,6 +303,7 @@ packagesRoutes.post("/install", async (c) => {
conflicts: result.conflicts,
verification,
...(policyResult.rejected.length > 0 ? { policyRejected: policyResult.rejected } : {}),
...(result.notes?.length ? { notes: result.notes } : {}),
}, 201);
});
+5
View File
@@ -1441,6 +1441,11 @@ export function queueRoutes(): Hono {
return c.json(items);
});
// Each connected assistant on this host and whether it's checking its queue, with this daemon's own host id: the check-ins
// are this host's, so a reader matches members to it without needing a terminal seat to learn it. A status only:
// work addressed to an assistant that isn't checking in still queues and is rung as before. Precedes /:qitemId.
app.get("/hosted-members", (c) => c.json({ hostId: getSelfHostId(), members: getRepo(c).hostedMemberCheckIns() }));
// GET /undelivered — surfaces pending create-path nudge failures plus active
// human-notification episodes whose gateway delivery ledger failed or never posted.
// Rig-scoped + bounded + compact-by-default, mirroring /overdue.
+2 -23
View File
@@ -1049,30 +1049,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// (repo currently empty as of 2026-05-10; vendored
// copy is the source of truth at v0).
try {
const { PluginVendorService } = await import("./domain/plugin-vendor-service.js");
const { PluginVendorService, nodePluginVendorFs } = await import("./domain/plugin-vendor-service.js");
const vendoredAssetsDir = nodePath.resolve(import.meta.dirname, "../assets/plugins");
const userPluginsDir = getDefaultOpenRigPath("plugins");
const realFs = {
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"),
copyFileExclusive: (src: string, dest: string) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL),
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),
};
const httpClient = async (url: string, opts?: { timeoutMs?: number }) => {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), opts?.timeoutMs ?? 5000);
@@ -1086,7 +1065,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
const vendorService = new PluginVendorService({
vendoredAssetsDir,
userPluginsDir,
fs: realFs,
fs: nodePluginVendorFs(),
httpClient,
logger: (...args) => console.log("[openrig]", ...args),
});
@@ -1,134 +0,0 @@
// Route-level: the real Mission Control route and write contract, the real /api/events stream and a migrated SQLite.
// A click on an ask with questions, from Steering or from Slack, is one queue.answer_recorded event that says how much
// of the set is answered; a typed reply, which settles the ask, is not one.
import { afterEach, describe, expect, it, vi } from "vitest";
import Database from "better-sqlite3";
import { Hono } from "hono";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js";
import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js";
import { missionControlRoutes } from "../src/routes/mission-control.js";
import { eventsRoute } from "../src/routes/events.js";
const PERSON = "human-test@external", ASKER = "requester@test-rig";
const registry = { ok: true as const, entities: [{ entityId: "human-test", class: "human" as const, displayName: "Test person", address: PERSON,
connectorBindings: [{ kind: "slack" as const, connectorRef: "primary", secretsRef: "unused", role: "primary" as const, handle: "UTEST" }],
prefs: { deliveryClass: "A" as const } }] };
const questions = [
{ id: "ship", question: "Ship it?", options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }] },
{ id: "when", question: "When?", options: [{ id: "today", label: "Today" }, { id: "monday", label: "Monday" }] },
];
const closes: Array<() => void> = [];
afterEach(() => {
for (const close of closes.splice(0).reverse()) close();
vi.restoreAllMocks();
});
async function fixture() {
const db = new Database(":memory:");
migrate(db, ALL_MIGRATIONS);
const bus = new EventBus(db);
const repo = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => registry,
transport: { send: async () => ({ ok: true, verified: true, sent: true }) } });
const contract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo: repo, actionLog: new MissionControlActionLog(db) });
const app = new Hono();
app.use("*", async (c, next) => {
c.set("queueRepo" as never, repo as never); c.set("eventBus" as never, bus as never);
c.set("missionControlWriteContract" as never, contract as never);
await next();
});
app.route("/api/mission-control", missionControlRoutes());
app.route("/api/events", eventsRoute);
closes.push(() => db.close());
const ask = async () => (await repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Two questions", summary: "Ship and when",
humanIntent: "decision", humanQuestions: questions, evidenceRef: "/fixture/decision", nudge: false })).qitemId;
// The person at the terminal, as the TUI sends it: no seat header, the primary user's authority.
const steering = async (body: Record<string, string>) => {
const response = await app.request("/api/mission-control/action", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ verb: "answer", actorSession: PERSON, authority: "primary-user", ...body }) });
return { status: response.status, body: await response.json() as Record<string, unknown> };
};
// What an app following /api/events from its watermark reads: the stored events after it.
const eventsAfter = async (watermark: number) => {
const response = await app.request("/api/events", { headers: { "Last-Event-ID": String(watermark) } });
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let text = "";
const deadline = Date.now() + 300;
while (Date.now() < deadline) {
const chunk = await Promise.race([reader.read(), new Promise<null>((resolve) => setTimeout(() => resolve(null), deadline - Date.now()))]);
if (!chunk || chunk.done) break;
text += decoder.decode(chunk.value, { stream: true });
}
await reader.cancel().catch(() => {});
return text.split("\n").filter((line) => line.startsWith("data:")).map((line) => JSON.parse(line.slice(5)) as Record<string, unknown>);
};
const recorded = async (watermark: number) => (await eventsAfter(watermark)).filter((event) => event.type === "queue.answer_recorded");
return { bus, repo, ask, steering, recorded, eventsAfter };
}
describe("queue.answer_recorded: a person's click on an ask with questions", () => {
it("one event per click that changes the answers, partial until the set is complete, from Steering and from Slack", async () => {
const f = await fixture();
const qitemId = await f.ask();
let watermark = f.bus.maxSeq();
const partial = await f.steering({ qitemId, questionId: "ship", optionId: "yes" });
expect(partial.status).toBe(200);
expect(f.repo.getById(qitemId)).toMatchObject({ state: "pending", humanAnswers: { ship: "yes" } });
const [first, ...noMore] = await f.recorded(watermark);
expect(noMore).toEqual([]);
// Partial: it says so, and counts the set. Ids and counts only: the person's choice stays on the row.
// The person at the terminal OpenRig can't name is recorded as such, as on the answer itself.
expect(first).toMatchObject({ type: "queue.answer_recorded", qitemId, actorSession: "human@host", questionId: "ship",
answered: 1, questions: 2, complete: false, summary: "Ship and when" });
expect(JSON.stringify(first)).not.toMatch(/"yes"|Yes|optionId/);
// The same click again changes nothing, so there's nothing to hear.
watermark = f.bus.maxSeq();
expect(f.repo.recordHumanAnswer({ qitemId, actorSession: PERSON, questionId: "ship", optionId: "yes" })).toMatchObject({ status: "recorded", complete: false });
expect(await f.recorded(watermark)).toEqual([]);
// A Slack-style click (the inbound router's call) completes the set.
watermark = f.bus.maxSeq();
expect(f.repo.recordHumanAnswer({ qitemId, actorSession: PERSON, questionId: "when", optionId: "monday" }))
.toMatchObject({ status: "recorded", complete: true });
expect(await f.recorded(watermark)).toEqual([expect.objectContaining({ qitemId, questionId: "when", answered: 2, questions: 2, complete: true })]);
});
it("a typed reply settles the ask through its own transitions and records no click event (the control)", async () => {
const f = await fixture();
const qitemId = await f.ask();
const watermark = f.bus.maxSeq();
const typed = await f.steering({ qitemId, decision: "Ship it on Monday" });
expect(typed.status).toBe(200);
expect(f.repo.getById(qitemId)?.state).toBe("done");
const events = await f.eventsAfter(watermark);
expect(events.some((event) => event.type === "queue.updated" && event.qitemId === qitemId)).toBe(true);
expect(events.filter((event) => event.type === "queue.answer_recorded")).toEqual([]);
});
it("fails open: a click whose event can't be recorded is still on the ask, and its answer goes back", async () => {
const f = await fixture();
const qitemId = await f.ask();
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const emit = f.bus.emit.bind(f.bus);
vi.spyOn(f.bus, "emit").mockImplementation((event) => {
if (event.type === "queue.answer_recorded") throw new Error("events table unavailable");
return emit(event);
});
const partial = await f.steering({ qitemId, questionId: "ship", optionId: "no" });
expect(partial.status).toBe(200);
const complete = await f.steering({ qitemId, questionId: "when", optionId: "today" });
expect(complete.status).toBe(200);
expect(f.repo.getById(qitemId)).toMatchObject({ state: "done", humanAnswers: { ship: "no", when: "today" } });
expect(f.repo.list({ limit: 50 }).filter((item) => item.replyTo === qitemId)).toHaveLength(1);
expect(warn).toHaveBeenCalledWith(expect.stringContaining(`${qitemId}'s recorded answer has no event`), expect.any(Error));
});
});
@@ -1387,6 +1387,38 @@ exports:
expect(result.errors.some((e) => e.includes("skipped"))).toBe(true);
});
it("a package whose only guidance entry is left as it is still imports the rig, with the note in warnings", async () => {
writePkg(path.join(tmpDir, "test-pkg"), VALID_MANIFEST_YAML, { "skills/helper/SKILL.md": "# Helper" });
const guidanceManifest = `
schema_version: 1
name: guidance-pkg
version: "1.0.0"
summary: Guidance only
compatibility:
runtimes: [claude-code]
exports:
guidance:
- source: guidance/rules.md
name: rules
kind: claude_md
supported_scopes: [project_shared]
default_scope: project_shared
merge_strategy: managed_block
`.trim();
writePkg(path.join(tmpDir, "guidance-pkg"), guidanceManifest, { "guidance/rules.md": "Follow these rules." });
const damaged = "# Project\n\n<!-- BEGIN OpenRig MANAGED BLOCK: guidance-pkg -->\nhalf a block\n";
fs.writeFileSync(path.join(tmpDir, "CLAUDE.md"), damaged);
const specPath = writeSpec(SPEC_WITH_PACKAGES_YAML.replace(" - ./test-pkg", " - ./test-pkg\n - ./guidance-pkg"));
const result = await buildOrchestrator().bootstrap({ mode: "apply", sourceRef: specPath, autoApprove: true });
expect(fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf-8")).toBe(damaged);
expect(result.errors).toEqual([]);
expect(result.status).toBe("completed");
expect(result.stages.find((s) => s.stage === "import_rig")?.status).not.toBe("skipped");
expect(result.warnings).toContainEqual(expect.stringMatching(/^Package '\.\/guidance-pkg' \(claude-code\): guidance_block_markers_damaged: .*CLAUDE\.md: guidance-pkg has a BEGIN or END marker without its pair/));
});
// === P7-T05: Bundle source tests ===
// T25: Real bundle bootstrap happy path
@@ -43,6 +43,16 @@ describe("detectCodexLimitBanner", () => {
expect(detectCodexLimitBanner(BANNER)).toMatchObject({ resetText: "4:14 AM" });
});
it("matches the typographic apostrophe Codex prints (U+2019)", () => {
const pane = [
"■ You’ve hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit",
"https://chatgpt.com/settings/usage to purchase more credits or try again at Nov 1st, 2026 12:30 AM.",
].join("\n");
expect(detectCodexLimitBanner(pane)).toMatchObject({
resetText: "Nov 1st, 2026 12:30 AM",
});
});
it("tolerates leading indent but still requires the marker first", () => {
expect(detectCodexLimitBanner(" ■ You've hit your usage limit. try again at 4:14 AM.")).toMatchObject({
resetText: "4:14 AM",
@@ -3,6 +3,9 @@
// per poll, filling the seat's held quota. Real SessionTransport, real draft-aware delivery and outbox on an
// in-memory database; the tmux adapter records commands instead of running them.
import Database from "better-sqlite3";
import { Hono } from "hono";
import { inputReadDiagnostic } from "../src/domain/input-read-diagnostic.js";
import { compactionRoutes } from "../src/routes/compaction.js";
import stringWidth from "string-width";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -83,7 +86,11 @@ async function fixture(opts: { pane: string | null; mode?: "off" | "draft-aware"
const effects = () => ({ compactPastes: writes.filter((w) => w.startsWith("paste:/compact")).length, prepPastes: writes.filter((w) => w.startsWith("paste:") && !w.startsWith("paste:/compact")).length, enters: writes.filter((w) => w === "enter").length });
const compactRows = (state?: string) => (db.prepare("SELECT outbox_id AS id, delivery_state AS state FROM outbox_entries WHERE body LIKE '/compact%'").all() as Array<{ id: string; state: string }>)
.filter((r) => !state || r.state === state);
return { db, e, transport, tmux, writes, compactRows, rows, effects, stageMap, body: () => body,
return { db, e, transport, tmux, writes, compactRows, rows, effects, stageMap, recover: () => {
const next = new ClaudeCompactionEnforcer({ resolveClaudeCompactionPolicy: () => policy } as any, transport,
{ openrigHome: home, now: () => clock, resolveOccupantGeneration: () => "g1" });
next.recoverSeat(input); return next;
}, body: () => body,
onStage: (fn: () => void) => { onStage = fn; }, activity: (s: string) => { activity = s; }, draft: (text: string) => { body = text; } };
}
@@ -361,3 +368,95 @@ it("the managed idle wait treats only a recognized draft according to the ordina
expect(await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true })).toMatchObject({ triggered: true });
expect(f.effects()).toMatchObject({ prepPastes: 1, compactPastes: 1, enters: 2 });
});
it.each(["preparation", "compact"] as const)("records the %s refusal through the existing transport without content or input", async stage => {
const f = await fixture({ pane: "%1", mode: stage === "preparation" ? "draft-aware" : "off" });
const send = f.transport.send.bind(f.transport);
vi.spyOn(f.transport, "send").mockImplementation(async (s, text, opts) => {
if (stage === "preparation") {
vi.mocked(f.tmux.captureComposerSnapshot).mockResolvedValue({ ...frame("PRIVATE_INPUT https://private.invalid/gateway"), inMode: true });
} else if (text.startsWith("/compact")) {
vi.spyOn(f.transport as any, "captureInputBox").mockResolvedValue({ status: "unknown", confidence: "unknown", cursor: null,
evidence: ["input box reads timed out"], input: { text: "PRIVATE_INPUT" }, hint: "https://private.invalid/gateway" });
return send(s, text, { ...opts, preserveInput: true });
}
return send(s, text, opts);
});
await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true });
const state = f.e.getPreparationState(seat)!;
const expected = stage === "preparation"
? { stage, check: "draft-aware", status: "unknown", confidence: null, reason: "pane_in_mode", timedOut: null,
cursor: { present: true, valid: true, atEnd: false } }
: { stage, check: "composer", status: "unknown", confidence: "unknown", reason: "read_timeout", timedOut: true,
cursor: { present: false, valid: null, atEnd: null } };
expect(state).toMatchObject({ inputReadFailure: expected, compact: "not_sent" });
expect(f.effects()).toEqual({ compactPastes: 0, prepPastes: stage === "preparation" ? 0 : 1, enters: stage === "preparation" ? 0 : 1 });
const record = readFileSync(join(dirname(state.mapPath), COMPACTION_RECORD_FILE), "utf8");
expect(JSON.parse(record)).toMatchObject({ inputReadFailure: expected });
const app = new Hono();
app.use("*", async (c, next) => { c.set("compactionEnforcer" as never, f.e as never); c.set("db" as never, f.db as never); await next(); });
app.route("/api/compaction", compactionRoutes());
const response = await app.request(`/api/compaction/state?session=${seat}`);
const body = await response.text();
expect(response.status).toBe(200);
expect(JSON.parse(body)).toMatchObject({ preparation: { inputReadFailure: expected } });
expect(record + body).not.toMatch(/PRIVATE_INPUT|private\.invalid/);
});
it("distinguishes an opted-in composer timeout from the separate draft-aware check", async () => {
const f = await fixture({ pane: "%1", mode: "off" });
const send = f.transport.send.bind(f.transport);
vi.spyOn(f.transport as any, "captureInputBox").mockResolvedValue({ status: "unknown", confidence: "unknown", cursor: null,
evidence: ["input box reads timed out"], input: { text: "PRIVATE_INPUT" }, suggestion: { text: "PRIVATE_SUGGESTION" }, hint: "https://private.invalid/gateway" });
vi.spyOn(f.transport, "send").mockImplementation((s, text, opts) => send(s, text, { ...opts, preserveInput: true }));
await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true });
const state = f.e.getPreparationState(seat)!;
expect(state).toMatchObject({ delivery: "not_sent", inputReadFailure: { stage: "preparation", check: "composer", reason: "read_timeout", timedOut: true,
status: "unknown", confidence: "unknown", cursor: { present: false, valid: null, atEnd: null } } });
const record = readFileSync(join(dirname(state.mapPath), COMPACTION_RECORD_FILE), "utf8");
expect(record).not.toMatch(/PRIVATE_INPUT|PRIVATE_SUGGESTION|private\.invalid/);
expect(f.writes).toEqual([]);
});
it("retains a held completed map's compact diagnostic across record recovery", async () => {
const f = await fixture({ pane: "%1", mode: "off" });
await f.e.maybeAutoCompact(input); f.stageMap();
const send = f.transport.send.bind(f.transport);
vi.spyOn(f.transport as any, "captureInputBox").mockResolvedValue({ status: "unknown", confidence: "unknown", cursor: null, evidence: ["cursor unavailable"] });
vi.spyOn(f.transport, "send").mockImplementation((s, text, opts) => send(s, text, { ...opts, preserveInput: true }));
expect(await f.e.maybeAutoCompact(input)).toMatchObject({ triggered: false, reason: "prepared_awaiting_window" });
const state = f.e.getPreparationState(seat)!;
expect(state).toMatchObject({ prepared: true, compact: "not_sent", inputReadFailure: { stage: "compact", check: "composer", reason: "cursor_unavailable", timedOut: null } });
const recordPath = join(dirname(state.mapPath), COMPACTION_RECORD_FILE);
const record = JSON.parse(readFileSync(recordPath, "utf8"));
// A new enforcer in this process deliberately ignores this process's live record; model a predecessor daemon.
writeFileSync(recordPath, JSON.stringify({ ...record, daemon: "previous-daemon" }));
expect(f.recover().getPreparationState(seat)?.inputReadFailure).toEqual(state.inputReadFailure);
expect(f.effects()).toEqual({ compactPastes: 0, prepPastes: 1, enters: 1 });
});
it("records the same diagnostic on an owed restore step without advancing it", async () => {
const f = await fixture({ pane: "%1", mode: "off" });
await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true });
await f.e.maybeAutoCompact(input);
expect(f.e.getOwedRestore(seat)?.stage).toBe("restore_prompt");
const before = f.effects(), send = f.transport.send.bind(f.transport);
vi.spyOn(f.transport as any, "captureInputBox").mockResolvedValue({ status: "unknown", confidence: "unknown", cursor: null, evidence: ["cursor unavailable"] });
vi.spyOn(f.transport, "send").mockImplementation((s, text, opts) => send(s, text, { ...opts, preserveInput: true }));
await f.e.maybeAutoCompact(input);
expect(f.e.getOwedRestore(seat)).toMatchObject({ stage: "restore_prompt", lastDelivery: { stage: "restore_prompt", reason: "draft_input_unknown",
inputReadFailure: { check: "composer", reason: "cursor_unavailable", timedOut: null } } });
expect(f.effects()).toEqual(before);
});
it.each([
{ state: "unknown", unknownReason: "invalid_cursor", cursor: { x: -1, y: 0, width: 80, height: 24 }, reason: "invalid_cursor", valid: false },
{ state: "unknown", unknownReason: "ambiguous_composer", reason: "ambiguous_composer", valid: null },
{ state: "unknown", unknownReason: "PRIVATE_INPUT https://private.invalid/gateway", reason: "capture_unavailable", valid: null },
])("diagnostic vocabulary excludes content and reports existing cursor validity ($reason)", ({ reason, valid, ...reading }) => {
const safe = inputReadDiagnostic("draft-aware", { ...reading, rows: ["PRIVATE_INPUT"], suggestionRows: ["PRIVATE_SUGGESTION"], frame: "https://private.invalid/gateway" } as any);
expect(safe).toMatchObject({ reason, cursor: { valid } });
expect(JSON.stringify(safe)).not.toMatch(/PRIVATE_INPUT|PRIVATE_SUGGESTION|private\.invalid|rows|frame/);
});
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { readWakeOutcome } from "../src/domain/connection-contract.js";
import { describeCheckIn, memberCheckInStatus, readWakeOutcome } from "../src/domain/connection-contract.js";
import { NUDGE_FAIL_PREFIX } from "../src/domain/policies/parked-owner-consumer.js";
describe("readWakeOutcome: every recorded nudge result reads as a delivery fact", () => {
@@ -51,3 +51,73 @@ describe("readWakeOutcome: every recorded nudge result reads as a delivery fact"
expect(readWakeOutcome(undefined)).toBeNull();
});
});
describe("memberCheckInStatus: a hosted member's check-in status", () => {
const now = new Date("2026-11-12T12:00:00.000Z");
const seenAgo = (minutes: number) => ({ state: "seen" as const, at: new Date(now.getTime() - minutes * 60_000).toISOString() });
const status = (checkInMinutes: number | null, checkIn: Parameters<typeof memberCheckInStatus>[0]["checkIn"], revokedAt?: string) =>
memberCheckInStatus({ checkInMinutes, checkIn, now, ...(revokedAt ? { revokedAt } : {}) }).status;
it("with a stated cadence, one rule: checking in up to exactly twice it, overdue a moment after", () => {
const at = (ms: number) => ({ state: "seen" as const, at: new Date(now.getTime() - ms).toISOString() });
expect(status(60, seenAgo(5))).toBe("checking-in");
expect(status(60, seenAgo(90))).toBe("checking-in"); // past one cadence is not yet overdue
expect(status(60, at(2 * 60 * 60_000))).toBe("checking-in");
expect(status(60, at(2 * 60 * 60_000 + 1))).toBe("check-in-overdue");
expect(status(1, at(2 * 60_000 + 1))).toBe("check-in-overdue"); // any positive cadence works the same way
});
it("a stated cadence that can't be used reads unknown with its reason, ahead of every other reading", () => {
expect(memberCheckInStatus({ checkInMinutes: null, cadenceProblem: "check_in_minutes \"60\" isn't a positive whole number of minutes",
checkIn: seenAgo(5), now })).toMatchObject({ status: "unknown", reason: expect.stringContaining("check_in_minutes") });
expect(memberCheckInStatus({ checkInMinutes: 60, checkIn: { state: "unknown" }, now })).toMatchObject({ status: "unknown", reason: "its check-ins couldn't be read" });
});
it("with no stated cadence it reads unknown with its last check-in, never on request, idle or overdue", () => {
expect(memberCheckInStatus({ checkInMinutes: null, checkIn: seenAgo(5), now }))
.toMatchObject({ status: "unknown", reason: "no check-in cadence declared", lastCheckInAt: seenAgo(5).at });
expect(status(null, seenAgo(10_000))).toBe("unknown");
});
it("on request only when its setup says so, whatever its last check-in's age", () => {
const onRequest = (checkIn: Parameters<typeof memberCheckInStatus>[0]["checkIn"]) =>
memberCheckInStatus({ checkInMinutes: null, onRequest: true, checkIn, now });
expect(onRequest(seenAgo(5)).status).toBe("on-request");
expect(onRequest(seenAgo(10_000))).toMatchObject({ status: "on-request", onRequest: true });
expect(onRequest({ state: "never" }).status).toBe("no-check-in-yet");
expect(memberCheckInStatus({ checkInMinutes: undefined, checkIn: seenAgo(5), now })).toMatchObject({ lastCheckInAt: seenAgo(5).at, expectedMinutes: null });
});
it("no recorded check-in reads never, with or without a cadence; an unreadable record reads unknown, never idle or overdue", () => {
expect(status(60, { state: "never" })).toBe("no-check-in-yet");
expect(memberCheckInStatus({ checkInMinutes: null, checkIn: { state: "never" }, now })).toMatchObject({ status: "no-check-in-yet", expectedMinutes: null });
expect(status(60, { state: "unknown" })).toBe("unknown");
expect(status(60, { state: "seen", at: "not a time" })).toBe("unknown");
});
it("disconnected comes only from a recorded revocation, ahead of everything else; silence never produces it", () => {
expect(status(60, seenAgo(5), "2026-11-12T11:00:00.000Z")).toBe("disconnected");
expect(status(60, seenAgo(100_000))).toBe("check-in-overdue");
expect(status(null, { state: "never" })).not.toBe("disconnected");
});
});
describe("describeCheckIn: one line, with its evidence", () => {
const base = { lastCheckInAt: "2026-11-12T13:05:00.000Z", expectedMinutes: 60, onRequest: false };
it("overdue carries its last check-in, its expected cadence and the suspected reason", () => {
expect(describeCheckIn({ ...base, status: "check-in-overdue" }))
.toBe("overdue: last check-in 2026-11-12T13:05:00.000Z, expected every 60 min; its schedule may be off or waiting for an approval");
expect(describeCheckIn({ ...base, status: "checking-in" })).toBe("checking in: last check-in 2026-11-12T13:05:00.000Z, expected every 60 min");
});
it("names what setup says when there's no check-in, and gives an unknown its reason and last check-in", () => {
expect(describeCheckIn({ ...base, status: "no-check-in-yet", lastCheckInAt: null })).toBe("no check-in yet; expected every 60 min");
expect(describeCheckIn({ ...base, status: "no-check-in-yet", lastCheckInAt: null, expectedMinutes: null, onRequest: true }))
.toBe("no check-in yet; checks in when a person asks it");
expect(describeCheckIn({ ...base, status: "no-check-in-yet", lastCheckInAt: null, expectedMinutes: null }))
.toBe("no check-in yet; no check-in cadence declared");
expect(describeCheckIn({ ...base, status: "on-request", expectedMinutes: null, onRequest: true }))
.toBe("on request: it checks its queue when a person asks it; last check-in 2026-11-12T13:05:00.000Z");
expect(describeCheckIn({ ...base, status: "unknown", expectedMinutes: null, reason: "no check-in cadence declared" }))
.toBe("unknown (no check-in cadence declared); last check-in 2026-11-12T13:05:00.000Z");
});
});
@@ -234,6 +234,48 @@ describe("InstallEngine", () => {
expect(fs.readFileSync(targetPath, "utf8")).toBe(`${prefix}<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\nUpdated guidance.\n<!-- END OpenRig MANAGED BLOCK: test-pkg -->\n# Footer\n`);
});
it("keeps one copy of its block: the first is updated in place and the repeats go", () => {
const pkg = seedPackage();
const sourcePath = writeSource("guidance/AGENTS.md", "Updated guidance.");
const targetPath = path.join(repoRoot, "AGENTS.md");
const old = "<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\nOld content.\n<!-- END OpenRig MANAGED BLOCK: test-pkg -->";
fs.writeFileSync(targetPath, `# Header\n\n${old}\n\n${old}\n# Footer\n`);
const entry = makeEntry({ exportType: "guidance", classification: "managed_merge", targetPath, sourcePath });
new InstallEngine(installRepo, realFs(tmpDir)).apply(makePolicy([entry]), makePlan([entry]), pkg.id, repoRoot);
expect(fs.readFileSync(targetPath, "utf8")).toBe(
"# Header\n\n<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\nUpdated guidance.\n<!-- END OpenRig MANAGED BLOCK: test-pkg -->\n# Footer\n");
});
it("keeps a later copy someone edited, and every different edit, with the text between", () => {
const pkg = seedPackage();
const sourcePath = writeSource("guidance/AGENTS.md", "Updated guidance.");
const targetPath = path.join(repoRoot, "AGENTS.md");
const copy = (body: string) => `<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\n${body}\n<!-- END OpenRig MANAGED BLOCK: test-pkg -->`;
fs.writeFileSync(targetPath, `# Header\n\n${copy("Old content.")}\n\nmine\n\n${copy("Old content, with my rule.")}\n\n${copy("Another edit.")}\n`);
const entry = makeEntry({ exportType: "guidance", classification: "managed_merge", targetPath, sourcePath });
new InstallEngine(installRepo, realFs(tmpDir)).apply(makePolicy([entry]), makePlan([entry]), pkg.id, repoRoot);
expect(fs.readFileSync(targetPath, "utf8")).toBe(
`# Header\n\n${copy("Updated guidance.")}\n\nmine\n\n${copy("Old content, with my rule.")}\n\n${copy("Another edit.")}\n`);
});
it("installs the rest when a guidance block lost its END: that file is left as it is and named, with no copy added", () => {
const pkg = seedPackage();
const skillSource = writeSource("skills/foo/SKILL.md", "# Foo Skill");
const skillTarget = path.join(repoRoot, ".agents/skills/foo/SKILL.md");
const guidanceSource = writeSource("guidance/AGENTS.md", "Updated guidance.");
const guidanceTarget = path.join(repoRoot, "AGENTS.md");
const text = "# Header\n<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\nOld content.\n# Footer\n";
fs.writeFileSync(guidanceTarget, text);
const skill = makeEntry({ exportName: "foo", targetPath: skillTarget, sourcePath: skillSource });
const guidance = makeEntry({ exportType: "guidance", classification: "managed_merge", targetPath: guidanceTarget, sourcePath: guidanceSource });
const result = new InstallEngine(installRepo, realFs(tmpDir)).apply(makePolicy([skill, guidance]), makePlan([skill, guidance]), pkg.id, repoRoot);
expect(fs.readFileSync(guidanceTarget, "utf8")).toBe(text);
expect(fs.readFileSync(skillTarget, "utf8")).toBe("# Foo Skill");
expect(result.applied.map((e) => e.targetPath)).toEqual([skillTarget]);
expect(result.notes).toEqual([expect.stringMatching(/^guidance_block_markers_damaged: .*AGENTS\.md: test-pkg has a BEGIN or END marker without its pair/)]);
expect(installRepo.getInstall(result.installId)?.status).toBe("applied");
});
// Test 7: Backup created before overwrite
it("backup created before overwrite", () => {
const pkg = seedPackage();
@@ -0,0 +1,195 @@
// A guidance file keeps exactly one copy of each OpenRig-managed block, however often OpenRig writes it: after an editor
// or formatter touched its markers, after copies collected, and when a marker lost its pair. A person's edit is kept.
import { describe, expect, it } from "vitest";
import {
managedBlockStanding,
managedBlockText,
mergeManagedBlock,
type ManagedBlockMergeFsOps,
} from "../src/domain/managed-blocks.js";
import { managedBlockBodyHash, writeManagedGuidanceBlock, type ManagedBlockRecords } from "../src/domain/managed-block-records.js";
import { restoreMissingGuidance } from "../src/domain/restore-guidance.js";
const FILE = "/repo/AGENTS.md";
const SOURCE = "/specs/culture.md";
/** An in-memory guidance file and source, so nothing here touches a real instruction file. */
function memory(files: Record<string, string>): ManagedBlockMergeFsOps & { files: Record<string, string> } {
return {
files,
exists: (p) => p in files,
readFile: (p) => files[p]!,
writeFile: (p, content) => { files[p] = content; },
};
}
function recordsOf(entries: Record<string, string> = {}): ManagedBlockRecords & { entries: Record<string, string> } {
return {
entries,
get: (file, id) => entries[`${file}#${id}`],
set: (file, id, body) => { entries[`${file}#${id}`] = managedBlockBodyHash(body); },
};
}
const count = (text: string, id = "CULTURE.md") => text.split(`<!-- BEGIN OpenRig MANAGED BLOCK: ${id} -->`).length - 1;
describe("the writer keeps one copy", () => {
it("reads markers a formatter rewrapped as the block, and replaces it in place at every launch", () => {
const touched = "# Notes\n\n<!--\n BEGIN OpenRig MANAGED BLOCK: CULTURE.md\n-->\nold culture\n<!-- END OpenRig MANAGED BLOCK: CULTURE.md -->\n\nafter\n";
const fs = memory({ [FILE]: touched });
const sizes: number[] = [];
const written: unknown[] = [];
for (let launch = 0; launch < 4; launch++) {
written.push(mergeManagedBlock(fs, FILE, "CULTURE.md", "new culture"));
sizes.push(fs.files[FILE]!.length);
}
expect(fs.files[FILE]).toBe(`# Notes\n\n${managedBlockText("CULTURE.md", "new culture")}\n\nafter\n`);
expect(new Set(sizes).size).toBe(1);
expect(written).toEqual([true, true, true, true]);
});
it("collapses copies that collected to the first, and keeps a person's text before, between and after them", () => {
const block = managedBlockText("CULTURE.md", "culture");
const fs = memory({ [FILE]: `# Notes\n\n${block}\n\nperson text\n\n${block}\n\n${block}\n\nthe end\n` });
mergeManagedBlock(fs, FILE, "CULTURE.md", "culture");
expect(fs.files[FILE]).toBe(`# Notes\n\n${block}\n\nperson text\n\nthe end\n`);
});
it.each([
["a BEGIN whose END was lost", "# Notes\n\n<!-- BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nculture\n<!-- END OpenRig MANAGED BLOCK -->\n"],
["an END whose BEGIN was lost", "# Notes\n\n<!-- BEGIN MANAGED BLOCK: CULTURE.md -->\nculture\n<!-- END OpenRig MANAGED BLOCK: CULTURE.md -->\n"],
])("adds no copy beside %s, at any launch, and names it", (_name, text) => {
const fs = memory({ [FILE]: text });
const warnings: string[] = [];
const written: unknown[] = [];
for (let launch = 0; launch < 3; launch++) written.push(mergeManagedBlock(fs, FILE, "CULTURE.md", "culture", { warnings }));
expect(fs.files[FILE]).toBe(text);
expect(warnings).toHaveLength(3);
expect(written).toEqual([false, false, false]);
expect(warnings[0]).toMatch(/^guidance_block_markers_damaged: \/repo\/AGENTS\.md: CULTURE\.md has a BEGIN or END marker without its pair/);
});
it("still replaces a complete copy when a lost marker sits beside it, leaves that marker, and names it", () => {
const lone = "<!-- BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nstray\n";
const fs = memory({ [FILE]: `${lone}\n${managedBlockText("CULTURE.md", "old")}\n` });
const warnings: string[] = [];
const written = mergeManagedBlock(fs, FILE, "CULTURE.md", "new", { warnings });
expect(fs.files[FILE]).toBe(`${lone}\n${managedBlockText("CULTURE.md", "new")}\n`);
expect(written).toBe(true);
expect(warnings).toEqual([expect.stringMatching(/^guidance_block_marker_unpaired: \/repo\/AGENTS\.md: CULTURE\.md was written/)]);
});
it("appends to a file with no marker at all, as before", () => {
const fs = memory({ [FILE]: "# Notes\n" });
mergeManagedBlock(fs, FILE, "CULTURE.md", "culture");
expect(fs.files[FILE]).toBe(`# Notes\n\n\n${managedBlockText("CULTURE.md", "culture")}`);
});
it("reads a lost marker as damaged, not absent", () => {
expect(managedBlockStanding("<!-- BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nculture\n", "CULTURE.md", "culture", undefined, managedBlockBodyHash))
.toBe("damaged");
expect(managedBlockStanding("# no block\n", "CULTURE.md", "culture", undefined, managedBlockBodyHash)).toBe("absent");
});
});
describe("a launch's guidance write keeps a person's edit and drops only repeats", () => {
const launch = (fs: ManagedBlockMergeFsOps, records: ManagedBlockRecords, source: string, warnings: string[]) =>
writeManagedGuidanceBlock(fs, FILE, "CULTURE.md", source, { records, sourcePath: SOURCE, warnings });
it("refreshes OpenRig's own copies to one", () => {
const old = managedBlockText("CULTURE.md", "old culture");
const fs = memory({ [FILE]: `# Notes\n\n${old}\n\n${old}\n` });
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("old culture") });
expect(launch(fs, records, "new culture", [])).toBe(true);
expect(fs.files[FILE]).toBe(`# Notes\n\n${managedBlockText("CULTURE.md", "new culture")}\n`);
});
it("keeps copies a formatter changed as edits, and leaves one of them", () => {
const formatted = managedBlockText("CULTURE.md", "- culture, reformatted");
const fs = memory({ [FILE]: `# Notes\n\n${formatted}\n\n${formatted}\n\n${formatted}\n` });
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("* culture") });
const warnings: string[] = [];
expect(launch(fs, records, "* culture", warnings)).toBe(false);
expect(fs.files[FILE]).toBe(`# Notes\n\n${formatted}\n`);
expect(warnings.map((w) => w.split(":")[0])).toEqual(["guidance_block_edited", "guidance_block_copies_removed"]);
});
it("keeps a person's edited copy and drops OpenRig's unedited copy beside it", () => {
const edited = managedBlockText("CULTURE.md", "culture, with my change");
const fs = memory({ [FILE]: `${managedBlockText("CULTURE.md", "culture")}\n\n${edited}\n` });
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("culture") });
expect(launch(fs, records, "culture", [])).toBe(false);
expect(fs.files[FILE]).toBe(`${edited}\n`);
});
it("keeps two different edited copies: it can't tell which one is meant", () => {
const text = `${managedBlockText("CULTURE.md", "edit one")}\n\n${managedBlockText("CULTURE.md", "edit two")}\n`;
const fs = memory({ [FILE]: text });
expect(launch(fs, recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("culture") }), "culture", [])).toBe(false);
expect(fs.files[FILE]).toBe(text);
});
it("records nothing and adds no copy beside a lost marker", () => {
const text = "<!-- BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nculture\n";
const fs = memory({ [FILE]: text });
const records = recordsOf();
const warnings: string[] = [];
const written: unknown[] = [];
for (let i = 0; i < 3; i++) written.push(launch(fs, records, "culture", warnings));
expect(fs.files[FILE]).toBe(text);
expect(written).toEqual([false, false, false]);
expect(records.entries).toEqual({});
expect(warnings.every((w) => w.startsWith("guidance_block_markers_damaged: "))).toBe(true);
});
it("keeps going when the file can't be written: the edited block is kept as decided, and the tidy is named", () => {
const formatted = managedBlockText("CULTURE.md", "- culture, reformatted");
const text = `${formatted}\n\n${formatted}\n`;
const fs = memory({ [FILE]: text });
fs.writeFile = () => { throw new Error("EACCES: permission denied"); };
const warnings: string[] = [];
expect(launch(fs, recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("* culture") }), "* culture", warnings)).toBe(false);
expect(fs.files[FILE]).toBe(text);
expect(warnings.map((w) => w.split(":")[0])).toEqual(["guidance_block_edited", "guidance_block_copies_not_removed"]);
});
});
describe("restore keeps one copy", () => {
it("removes repeats of a current block, and a second restore changes nothing", () => {
const current = managedBlockText("CULTURE.md", "* culture");
const fs = memory({ [FILE]: `# Notes\n\n${current}\n\nmine\n\n${current}\n`, [SOURCE]: "* culture" });
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("* culture") });
const guidance = { targetPath: FILE, items: [{ blockId: "CULTURE.md", sourcePath: SOURCE }] };
restoreMissingGuidance(guidance, fs, records);
const once = fs.files[FILE];
restoreMissingGuidance(guidance, fs, records);
expect(once).toBe(`# Notes\n\n${current}\n\nmine\n`);
expect(fs.files[FILE]).toBe(once);
});
it("without write records keeps the block's text, and still removes its exact repeats and puts rewrapped markers back", () => {
const rewrapped = "<!--\n BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nmy edit\n<!-- END OpenRig MANAGED BLOCK: CULTURE.md -->";
const fs = memory({ [FILE]: `${rewrapped}\n\n${managedBlockText("CULTURE.md", "my edit")}\n`, [SOURCE]: "* culture" });
restoreMissingGuidance({ targetPath: FILE, items: [{ blockId: "CULTURE.md", sourcePath: SOURCE }] }, fs);
expect(fs.files[FILE]).toBe(`${managedBlockText("CULTURE.md", "my edit")}\n`);
});
it("removes a kept block's repeats, and puts back nothing beside a lost marker", () => {
const formatted = managedBlockText("CULTURE.md", "- culture, reformatted");
const fs = memory({
[FILE]: `# Notes\n\n${formatted}\n\n${formatted}\n\n<!-- BEGIN OpenRig MANAGED BLOCK: ROLE.md -->\nrole\n`,
[SOURCE]: "* culture",
"/specs/role.md": "role",
});
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("* culture") });
const repair = restoreMissingGuidance({ targetPath: FILE, items: [
{ blockId: "CULTURE.md", sourcePath: SOURCE },
{ blockId: "ROLE.md", sourcePath: "/specs/role.md" },
] }, fs, records);
expect(fs.files[FILE]).toBe(`# Notes\n\n${formatted}\n\n<!-- BEGIN OpenRig MANAGED BLOCK: ROLE.md -->\nrole\n`);
expect(count(fs.files[FILE]!)).toBe(1);
expect(repair.restored).toEqual([]);
expect(repair.gaps.join("\n")).toContain("ROLE.md: /repo/AGENTS.md holds only part of this block");
expect(repair.kept.map((k) => k.split(":")[0])).toEqual(["guidance_block_edited", "guidance_block_copies_removed"]);
});
});
@@ -90,7 +90,8 @@ describe("openrig-core plugin — manifest shape (HG-2.2)", () => {
// Hook + skills wiring
expect(manifest["skills"]).toBe("./skills");
expect(manifest["hooks"]).toBe("./hooks/claude.json");
expect(manifest["repository"]).toMatch(/github:mvschwarz\/openrig-plugins/);
// An https URL: Claude's plugin directory drops a `github:` shorthand from its listing.
expect(manifest["repository"]).toBe("https://github.com/mvschwarz/openrig-plugins");
expect(manifest["license"]).toBeDefined();
});
@@ -322,6 +322,32 @@ roles:
expect(claudeMd).toContain("# Existing content");
});
it("POST /api/packages/install whose only guidance entry is left as it is → 201 with the note, verified", async () => {
writePkg(pkgDir, GUIDANCE_MANIFEST_YAML, {
"guidance/rules.md": "Follow these rules.",
});
const damaged = "# Existing content\n\n<!-- BEGIN OpenRig MANAGED BLOCK: guidance-pkg -->\nhalf a block\n";
fs.writeFileSync(path.join(targetDir, "CLAUDE.md"), damaged);
const res = await app.request("/api/packages/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceRef: pkgDir,
targetRoot: targetDir,
runtime: "claude-code",
allowMerge: true,
}),
});
expect(fs.readFileSync(path.join(targetDir, "CLAUDE.md"), "utf-8")).toBe(damaged);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.applied).toEqual([]);
expect(body.notes).toEqual([expect.stringMatching(/^guidance_block_markers_damaged: .*CLAUDE\.md: guidance-pkg has a BEGIN or END marker without its pair/)]);
expect(body.verification.passed).toBe(true);
});
// --- Test 8: POST /install mixed policy: skills approved, guidance rejected ---
it("POST /api/packages/install mixed policy → 201 with applied + policyRejected", async () => {
writePkg(pkgDir, MIXED_MANIFEST_YAML, {
@@ -0,0 +1,74 @@
// Vendoring copies a bundled plugin into ~/.openrig/plugins byte for byte, binary files included: a plugin's icon.png
// must arrive exactly as it ships, and a text file must arrive as it always did.
import fs from "node:fs";
import os from "node:os";
import nodePath from "node:path";
import { createHash } from "node:crypto";
import { afterEach, describe, expect, it } from "vitest";
import { nodePluginVendorFs, PluginVendorService } from "../src/domain/plugin-vendor-service.js";
const dirs: string[] = [];
afterEach(() => { for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
/** A PNG header and bytes that aren't valid UTF-8, so a text round trip would change them. */
const ICON = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from([0xff, 0xfe, 0x80, 0x00, 0xc3, 0x28, 0xa0, 0xa1])]);
const sha = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex");
const httpClient = async () => ({ ok: false, status: 404 });
function scratch(): { assets: string; plugins: string } {
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "openrig-vendor-binary-"));
dirs.push(root);
return { assets: nodePath.join(root, "assets"), plugins: nodePath.join(root, "home", "plugins") };
}
function shipPlugin(assets: string, version: string, icon = ICON): void {
const plugin = nodePath.join(assets, "openrig");
fs.mkdirSync(nodePath.join(plugin, ".claude-plugin"), { recursive: true });
fs.mkdirSync(nodePath.join(plugin, "skills", "demo"), { recursive: true });
fs.writeFileSync(nodePath.join(plugin, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "openrig", version, description: "Team — work" }));
fs.writeFileSync(nodePath.join(plugin, ".claude-plugin", "icon.png"), icon);
fs.writeFileSync(nodePath.join(plugin, "skills", "demo", "SKILL.md"), "# Demo — a skill\n");
fs.writeFileSync(nodePath.join(plugin, "skills", "demo", "badge.png"), icon);
}
const service = (assets: string, plugins: string) =>
new PluginVendorService({ vendoredAssetsDir: assets, userPluginsDir: plugins, fs: nodePluginVendorFs(), httpClient });
describe("vendoring a plugin with a binary file", () => {
it("installs the icon byte for byte, and its text files as before", async () => {
const { assets, plugins } = scratch();
shipPlugin(assets, "0.1.0");
await service(assets, plugins).ensureVendored("openrig");
const installed = fs.readFileSync(nodePath.join(plugins, "openrig", ".claude-plugin", "icon.png"));
expect(sha(installed)).toBe(sha(ICON));
expect(fs.readFileSync(nodePath.join(plugins, "openrig", "skills", "demo", "SKILL.md"), "utf-8")).toBe("# Demo — a skill\n");
});
it("leaves an identical install alone at the same version, and refreshes a newer one exactly, keeping the old bytes", async () => {
const { assets, plugins } = scratch();
shipPlugin(assets, "0.1.0");
await service(assets, plugins).ensureVendored("openrig");
await service(assets, plugins).ensureVendored("openrig");
const backups = nodePath.join(nodePath.dirname(plugins), "backups");
expect(fs.existsSync(backups)).toBe(false);
const newer = Buffer.concat([ICON, Buffer.from([0xfd, 0xbe])]);
fs.rmSync(assets, { recursive: true });
shipPlugin(assets, "0.2.0", newer);
await service(assets, plugins).ensureVendored("openrig");
expect(sha(fs.readFileSync(nodePath.join(plugins, "openrig", ".claude-plugin", "icon.png")))).toBe(sha(newer));
const kept = fs.readdirSync(nodePath.join(backups, "managed-refresh"))
.map((id) => nodePath.join(backups, "managed-refresh", id, ".claude-plugin", "icon.png"))
.filter((path) => fs.existsSync(path));
expect(kept.map((path) => sha(fs.readFileSync(path)))).toEqual([sha(ICON)]);
});
it("projects a skill's binary file into a global skill root byte for byte", () => {
const { assets, plugins } = scratch();
shipPlugin(assets, "0.1.0");
const root = nodePath.join(nodePath.dirname(plugins), "global-skills");
service(assets, plugins).ensureSkillDirGlobally(nodePath.join(assets, "openrig", "skills", "demo"), "demo", "0.1.0", [root]);
expect(sha(fs.readFileSync(nodePath.join(root, "demo", "badge.png")))).toBe(sha(ICON));
expect(fs.readFileSync(nodePath.join(root, "demo", "SKILL.md"), "utf-8")).toBe("# Demo — a skill\n");
});
});
@@ -11,7 +11,7 @@ import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { QueueRepository, type QueueItem } from "../src/domain/queue-repository.js";
import { EventBus } from "../src/domain/event-bus.js";
import { queueRoutes } from "../src/routes/queue.js";
import type { RosterWake } from "../src/domain/rosters.js";
import type { RosterMember, RosterWake } from "../src/domain/rosters.js";
import { createStuckSweepStatus, runStuckSweep, STUCK_SWEEP_FINDING_TAG, unclaimedBacklogTag } from "../src/domain/queue-stuck-sweep.js";
const MEMBER = "helper-a@hub";
@@ -23,12 +23,13 @@ describe("stuck sweep: a hosted member that polls for its work", () => {
let repo: QueueRepository;
function makeRepo(opts: { hostedWake?: (d: string) => RosterWake | null; resolveSessionAddresses?: (n: string) => string[];
validateRig?: (d: string) => boolean } = {}): QueueRepository {
validateRig?: (d: string) => boolean; hostedMembers?: () => RosterMember[] } = {}): QueueRepository {
return new QueueRepository(db, bus, {
validateRig: opts.validateRig ?? (() => true),
loadHumanRegistry: () => ({ ok: true, entities: [] }),
isHostedMember: (d) => d === MEMBER,
hostedWake: opts.hostedWake ?? (() => null),
hostedMembers: opts.hostedMembers ?? (() => []),
...(opts.resolveSessionAddresses ? { resolveSessionAddresses: opts.resolveSessionAddresses } : {}),
});
}
@@ -114,16 +115,88 @@ describe("stuck sweep: a hosted member that polls for its work", () => {
expect(more).toEqual([]);
expect(notice!.destinationSession).toBe(PERSON);
expect(notice!.sourceSession).toBe(MEMBER);
expect(notice!.body).toContain(`check-ins: no check-in observed since ${at.toISOString()}`);
expect(notice!.body).toContain(`check-ins: unknown (no check-in cadence declared); no check-in since ${at.toISOString()}`);
expect(notice!.body).toContain("no read of its own inbox has been observed in that time. Ask its owner to check it's running, or route the work to another seat");
for (const row of rows) expect(notice!.body).toContain(`- ${row.qitemId}, unclaimed since ${row.tsCreated}, from lead@hub: work for ${MEMBER}`);
expect(notice!.body).not.toMatch(/dead/i);
});
/** The member as its roster lists it, with the cadence its person stated at setup (or none). */
const asListed = (checkInMinutes?: number): RosterMember[] => [{ seat: MEMBER, host: "hub", capabilities: ["research"],
engagement: ["delegate"], use_when: "research", why: "it researches", hosted: true,
...(checkInMinutes === undefined ? {} : { check_in_minutes: checkInMinutes }) }];
it("a stated cadence decides only the notice's check-in line, never when the notice is filed: overdue past twice it, checking in within it", async () => {
const at = new Date(Date.now() - 3 * 3_600_000);
repo = makeRepo({ hostedMembers: () => asListed(60) });
repo.recordInboxCheckIn(MEMBER, at);
await work(MEMBER, { ageMinutes: 61 });
await sweep();
expect(notices()[0]!.body).toContain(`check-ins: overdue: last check-in ${at.toISOString()}, expected every 60 min; its schedule may be off or waiting for an approval`);
expect(notices()[0]!.body).toContain("why: nobody has claimed these rows, and it hasn't checked in as often as its setup expects.");
// The same three-hour-old check-in is on time for a member expected every four hours, though older than the
// unclaimed age that judges a member with no stated cadence.
db.close();
db = new Database(":memory:");
migrate(db, ALL_MIGRATIONS);
bus = new EventBus(db);
repo = makeRepo({ hostedMembers: () => asListed(240) });
seedSeat("lead@hub");
repo.recordInboxCheckIn(MEMBER, at);
await work(MEMBER, { ageMinutes: 61 });
await sweep();
expect(notices()[0]!.body).toContain("check-ins: checking in: its last check-in is within twice its expected 240 min");
});
it("the notice says what the hosted-members read says: an unusable cadence reads unknown with its reason, even with a fresh check-in", async () => {
const member = { ...asListed()[0]!, check_in_problem: "check_in_minutes \"60\" isn't a positive whole number of minutes" };
repo = makeRepo({ hostedMembers: () => [member] });
repo.recordInboxCheckIn(MEMBER, new Date(Date.now() - 5 * 60_000));
await work(MEMBER, { ageMinutes: 61 });
await sweep();
expect(repo.hostedMemberCheckIns()[0]).toMatchObject({ status: "unknown", reason: member.check_in_problem });
expect(notices()[0]!.body).toContain(`check-ins: unknown (${member.check_in_problem}); it read its own inbox within the last 60 min`);
expect(notices()[0]!.body).not.toContain("checking in");
});
it("check-in status is a status, never a refusal: work for an overdue or never-seen member queues and is recorded as before", async () => {
repo = makeRepo({ hostedMembers: () => asListed(60) });
repo.recordInboxCheckIn(MEMBER, new Date(Date.now() - 5 * 3_600_000));
const overdue = await repo.create({ sourceSession: "lead@hub", destinationSession: MEMBER, body: "work", summary: "work" });
const unlisted = makeRepo();
const baseline = await unlisted.create({ sourceSession: "lead@hub", destinationSession: MEMBER, body: "work", summary: "work" });
expect(repo.getById(overdue.qitemId)).toMatchObject({ state: "pending", destinationSession: MEMBER });
expect(repo.getById(overdue.qitemId)!.lastNudgeResult).toBe(unlisted.getById(baseline.qitemId)!.lastNudgeResult);
expect(repo.hostedMemberCheckIns().map((m) => m.status)).toEqual(["check-in-overdue"]);
});
it("the hosted-members read gives each member's status, last check-in and stated cadence", async () => {
const members = [...asListed(60), { ...asListed()[0]!, seat: "helper-b@hub", check_in_on_request: true as const },
{ ...asListed(30)[0]!, seat: "helper-c@hub" }, { ...asListed()[0]!, seat: "helper-d@hub" }];
repo = makeRepo({ hostedMembers: () => members });
const at = new Date(Date.now() - 10 * 60_000);
repo.recordInboxCheckIn(MEMBER, at);
repo.recordInboxCheckIn("helper-b@hub", at);
repo.recordInboxCheckIn("helper-d@hub", at);
const res = await appFor(repo).request("/api/queue/hosted-members");
expect(res.status).toBe(200);
const body = await res.json() as { hostId: unknown; members: unknown };
expect(body).toHaveProperty("hostId");
expect(body.members).toEqual([
{ seat: MEMBER, rung: false, status: "checking-in", lastCheckInAt: at.toISOString(), expectedMinutes: 60, onRequest: false, reporter: "openrig-observed" },
{ seat: "helper-b@hub", rung: false, status: "on-request", lastCheckInAt: at.toISOString(), expectedMinutes: null, onRequest: true, reporter: "openrig-observed" },
{ seat: "helper-c@hub", rung: false, status: "no-check-in-yet", lastCheckInAt: null, expectedMinutes: 30, onRequest: false, reporter: "openrig-observed" },
// States neither a cadence nor on request: unknown, with its last check-in, never on request, idle or overdue.
{ seat: "helper-d@hub", rung: false, status: "unknown", lastCheckInAt: at.toISOString(), expectedMinutes: null, onRequest: false,
reporter: "openrig-observed", reason: "no check-in cadence declared" },
]);
});
it("a member that never checked in says so", async () => {
await work(MEMBER, { ageMinutes: 61 });
await sweep();
expect(notices()[0]!.body).toContain("check-ins: no check-in observed yet");
expect(notices()[0]!.body).toContain("check-ins: no check-in yet; no check-in cadence declared");
});
it("polling without claiming: old work still surfaces, and the member's check-ins neither reset its age nor rewrite the notice", async () => {
@@ -132,8 +205,8 @@ describe("stuck sweep: a hosted member that polls for its work", () => {
await poll();
await sweep();
const [notice] = notices();
expect(notice!.body).toContain("check-ins: checking in: it read its own inbox within the last 60 min");
expect(notice!.body).toContain("it's checking in, but this work is still unclaimed");
expect(notice!.body).toContain("check-ins: unknown (no check-in cadence declared); it read its own inbox within the last 60 min");
expect(notice!.body).toContain("it's reading its own inbox, but this work is still unclaimed");
expect(notice!.body).toContain(`- ${old.qitemId}, unclaimed since ${old.tsCreated}`);
expect(notice!.body).not.toContain(young.qitemId);
expect(perRow()).toEqual([]);
@@ -186,7 +259,7 @@ describe("stuck sweep: a hosted member that polls for its work", () => {
expect(errors.mock.calls.filter((call) => String(call[0]).includes("inbox check-in not recorded"))).toHaveLength(1);
expect(repo.lastInboxCheckIn(MEMBER)).toEqual({ state: "unknown" });
await sweep();
expect(notices()[0]!.body).toContain("check-ins: unknown: its check-ins couldn't be read");
expect(notices()[0]!.body).toContain("check-ins: unknown (its check-ins couldn't be read)");
});
it("after a restart the stale check-in is still known, and the first new read records at once", async () => {
+49 -83
View File
@@ -5,7 +5,7 @@ import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import type { Worker } from "node:worker_threads";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { createDaemon } from "../src/startup.js";
import { createSlowOpRequestMiddleware } from "../src/domain/slow-op-recorder.js";
@@ -67,12 +67,37 @@ afterEach(() => {
});
describe("SlowOpRecorder locked instrumentation contract", () => {
it("returns promptly while begin acknowledgements are delayed", async () => {
const mod = await loadRecorderModule();
if (!mod) throw new Error("recorder module missing");
const logPath = path.join(tempDir(), "pending.jsonl");
const recorder = new mod.SlowOpRecorder({ logPath });
const worker: Worker = (recorder as any).worker;
const post = worker.postMessage;
const held: unknown[] = [];
worker.postMessage = message => {
if (message?.record?.phase === "begin") held.push(message);
else post.call(worker, message);
};
try {
const start = performance.now();
const values = [1, 2, 3].map(value => recorder.runSync("test.delayed.begin", () => value));
const elapsedMs = performance.now() - start;
expect(values).toEqual([1, 2, 3]);
expect(held).toHaveLength(3);
expect(elapsedMs).toBeLessThan(100);
} finally {
worker.postMessage = post;
for (const message of held) post.call(worker, message);
await recorder.close();
}
});
it("pins the absolute boundary, rotation, filename, and secret-safe record contract", async () => {
const mod = await loadRecorderModule();
expect(mod, "slow-op-recorder production module is missing").not.toBeNull();
if (!mod) return;
expect(mod.SLOW_OPERATION_BARRIER_TIMEOUT_MS).toBe(250);
expect(mod.SLOW_OPERATION_THRESHOLD_MS).toBe(250);
expect(mod.SLOW_OPERATION_ROTATION_BYTES).toBe(1024 * 1024);
expect(mod.SLOW_OPERATION_ROTATION_COUNT).toBe(3);
@@ -85,9 +110,6 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
process.env.OPENRIG_TEST_SECRET = secret;
try {
const value = recorder.runSync("test.sync.boundary", () => {
const records = fs.readFileSync(logPath, "utf8");
expect(records).toContain('"phase":"begin"');
expect(records).toContain('"site":"test.sync.boundary"');
return 41;
});
expect(value).toBe(41);
@@ -107,88 +129,34 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
}
});
it("leaves a durable open span after the begin barrier and exposes fail-visible degradation", async () => {
it("flushes a queued open span and exposes acknowledged write failure in health", async () => {
const mod = await loadRecorderModule();
expect(mod, "slow-op-recorder production module is missing").not.toBeNull();
if (!mod) return;
if (!mod) throw new Error("recorder module missing");
const dir = tempDir();
const logPath = path.join(dir, "slow-operations.jsonl");
const logPath = path.join(dir, "open.jsonl");
const recorder = new mod.SlowOpRecorder({ logPath });
const span = recorder.beginSyncSpan("test.sync.wedge");
expect(span).toBeTruthy();
const records = await readRecords(logPath);
expect(records).toHaveLength(1);
expect(records[0]).toMatchObject({ phase: "begin", site: "test.sync.wedge" });
expect(recorder.snapshot()).toMatchObject({ healthy: true });
recorder.beginSyncSpan("test.sync.wedge");
await recorder.flush();
expect(await readRecords(logPath)).toMatchObject([{ phase: "begin", site: "test.sync.wedge" }]);
await recorder.close();
const degraded = new mod.SlowOpRecorder({
logPath: path.join(dir, "degraded.jsonl"),
barrierTimeoutMs: 0,
});
const degraded = new mod.SlowOpRecorder({ logPath: dir });
const oldNoKernel = process.env.OPENRIG_NO_KERNEL;
process.env.OPENRIG_NO_KERNEL = "1";
const daemon = await createTestDaemon({
dbPath: ":memory:",
tmuxExec: async () => "",
cmuxExec: async () => "",
slowOpRecorder: degraded,
} as never);
const daemon = await createTestDaemon({ dbPath: ":memory:", tmuxExec: async () => "", cmuxExec: async () => "", slowOpRecorder: degraded } as never);
try {
const worker: Worker = (degraded as any).worker;
const postMessage = worker.postMessage;
let intercepted = 0;
let withheldSignal: SharedArrayBuffer | undefined;
try {
worker.postMessage = (message, transferList) => {
if (message?.type === "append"
&& message.record?.phase === "begin"
&& message.record?.site === "test.sync.degraded"
&& message.signal instanceof SharedArrayBuffer) {
intercepted++;
const { signal, ...withoutSignal } = message;
withheldSignal = signal;
// Append through the real worker, but withhold this acknowledgement.
return postMessage.call(worker, withoutSignal, transferList);
}
return postMessage.call(worker, message, transferList);
};
expect(degraded.runSync("test.sync.degraded", () => "continued")).toBe("continued");
} finally {
worker.postMessage = postMessage;
}
expect(intercepted).toBe(1);
expect(worker.postMessage).toBe(postMessage);
expect(withheldSignal).toBeInstanceOf(SharedArrayBuffer);
expect(new Int32Array(withheldSignal!)[0]).toBe(0);
expect(degraded.snapshot()).toMatchObject({
healthy: false,
reason: "begin_barrier_timeout",
site: "test.sync.degraded",
});
const healthResponse = await daemon.app.request("/healthz");
expect(await healthResponse.json()).toMatchObject({
status: "ok",
slowOperations: {
healthy: false,
reason: "begin_barrier_timeout",
site: "test.sync.degraded",
},
});
expect(degraded.runSync("test.sync.degraded", () => "continued")).toBe("continued");
await expect(degraded.flush()).rejects.toBeInstanceOf(mod.SlowOpRecorderWriteError);
expect(degraded.snapshot()).toMatchObject({ healthy: false, reason: "recorder_write_failed", site: "recorder.worker" });
const response = await daemon.app.request("/healthz");
expect(await response.json()).toMatchObject({ status: "ok", slowOperations: { healthy: false, reason: "recorder_write_failed" } });
const observations = daemon.deps.streamStore!.list();
expect(observations).toHaveLength(1);
expect(observations[0]!.body).toContain("begin_barrier_timeout");
expect(observations[0]!.body).toContain("test.sync.degraded");
await degraded.flush();
const degradedRecords = await readRecords(path.join(dir, "degraded.jsonl"));
expect(degradedRecords.filter((record) => record.phase === "begin"
&& record.site === "test.sync.degraded")).toHaveLength(1);
expect(observations[0]!.body).toContain("recorder_write_failed");
} finally {
daemon.eventLoopMonitor.stop();
daemon.db.close();
await degraded.close();
await degraded.close().catch(() => {});
if (oldNoKernel === undefined) delete process.env.OPENRIG_NO_KERNEL;
else process.env.OPENRIG_NO_KERNEL = oldNoKernel;
}
@@ -211,20 +179,17 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
});
expect(value).toBe("continued");
expect(ran).toBe(true);
expect(recorder.snapshot()).toEqual({
healthy: false,
reason: "begin_barrier_failed",
site: "test.sync.observer_failure",
});
await expect(recorder.flush()).rejects.toBeInstanceOf(mod.SlowOpRecorderWriteError);
expect(recorder.snapshot()).toEqual({ healthy: false, reason: "recorder_write_failed", site: "recorder.worker" });
} finally {
// This recorder's log path is intentionally unwritable (to force the
// begin-barrier failure above), so close() now correctly rejects on the
// asynchronously acknowledged write failure above), so close() now correctly rejects on the
// latched lost-write durability — tolerate it in teardown.
await recorder.close().catch(() => {});
}
});
it("durably records an open span before a main-thread sync wedge is killed", async () => {
it("writes a queued begin while the main thread is wedged, before a later kill", async () => {
const dir = tempDir();
const logPath = path.join(dir, "crash.jsonl");
fs.writeFileSync(logPath, "", { mode: 0o600 });
@@ -278,6 +243,7 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
});
});
expect(callbackEvidence).toEqual({ isMainThread: true, threadId: 0 });
await vi.waitFor(async () => expect(await readRecords(logPath)).toMatchObject([{ phase: "begin", site: "test.sync.crash" }]));
child.kill("SIGKILL");
await once(child, "exit");
@@ -351,7 +317,7 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
expect(JSON.stringify(requests)).not.toContain("must-not-appear");
});
it("wraps all nine accepted sync calls without moving them off the main thread", () => {
it("keeps the accepted slow-operation site labels", () => {
const srcRoot = path.resolve(import.meta.dirname, "../src");
for (const [relativePath, sites] of expectedSites) {
const source = fs.readFileSync(path.join(srcRoot, relativePath), "utf8");
@@ -496,7 +462,7 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
expect((await app.request("/definitely-missing")).status).toBe(404);
});
it("drives the real tmux command-v spawnSync site through a hermetic daemon", async () => {
it("drives the asynchronous tmux command-v site through a hermetic daemon", async () => {
const mod = await loadRecorderModule();
expect(mod, "slow-op-recorder production module is missing").not.toBeNull();
if (!mod) return;
@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
import { TmuxOptionDefaultsApplier } from "../src/domain/tmux-option-defaults.js";
const execute = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", () => ({ execFile: execute }));
beforeEach(() => { execute.mockReset(); });
describe("clipboard discovery deadline", () => {
it.each([null, "custom-copy"])("warns and retries a killed default probe, preserving %j", async initial => {
let copyCommand = initial;
const setServerOption = vi.fn(async (name: string, value: string) => {
if (name === "copy-command") copyCommand = value;
return { ok: true };
});
const adapter = { setSessionOption: async () => ({ ok: true }), setServerOption } as unknown as TmuxAdapter;
execute.mockImplementationOnce((_file, _args, options, callback) => {
expect(options).toMatchObject({ timeout: 250, killSignal: "SIGKILL" });
callback(Object.assign(new Error("probe deadline"), { killed: true }));
}).mockImplementationOnce((_file, _args, _options, callback) => callback(null));
const applier = new TmuxOptionDefaultsApplier({ tmuxAdapter: adapter, platform: "linux" });
const warnings = await applier.applyToFreshSession("first@test");
expect(warnings).toHaveLength(1);
expect(warnings[0]).toMatch(/clipboard.*250 ms/i);
expect(warnings[0]).toMatch(/retry/i);
expect(copyCommand).toBe(initial);
expect(execute).toHaveBeenCalledTimes(1);
expect(await applier.applyToFreshSession("next@test")).toEqual([]);
expect(copyCommand).toBe("wl-copy");
expect(execute).toHaveBeenCalledTimes(2);
expect(await applier.applyToFreshSession("later@test")).toEqual([]);
expect(execute).toHaveBeenCalledTimes(2);
});
it("memoizes confirmed absence without a deadline warning", async () => {
execute.mockImplementation((_file, _args, _options, callback) => callback(Object.assign(new Error("absent"), { code: 1 })));
const setServerOption = vi.fn(async () => ({ ok: true }));
const adapter = { setSessionOption: async () => ({ ok: true }), setServerOption } as unknown as TmuxAdapter;
const applier = new TmuxOptionDefaultsApplier({ tmuxAdapter: adapter, platform: "linux" });
expect(await applier.applyToFreshSession("first@test")).toEqual([]);
expect(await applier.applyToFreshSession("next@test")).toEqual([]);
expect(execute).toHaveBeenCalledTimes(2);
expect(setServerOption).not.toHaveBeenCalledWith("copy-command", expect.anything());
});
});
@@ -23,6 +23,24 @@ function mockAdapter(overrides?: {
}
describe("TmuxOptionDefaultsApplier", () => {
it("awaits an asynchronous clipboard lookup without holding unrelated work", async () => {
const { adapter, setServerOption } = mockAdapter();
let release!: (present: boolean) => void;
const pending = new Promise<boolean>(resolve => { release = resolve; });
const probe = vi.fn((bin: string) => bin === "wl-copy" ? pending : Promise.resolve(true));
let finished = false;
const applier = new TmuxOptionDefaultsApplier({ tmuxAdapter: adapter, platform: "linux", hasCommand: probe });
const applied = applier.applyToFreshSession("async-clipboard@test").then(value => { finished = true; return value; });
try {
await new Promise(resolve => setImmediate(resolve));
expect(finished).toBe(false);
expect(setServerOption).not.toHaveBeenCalledWith("copy-command", expect.anything());
} finally { release(false); }
expect(await applied).toEqual([]);
expect(probe.mock.calls.map(([bin]) => bin)).toEqual(["wl-copy", "xclip"]);
expect(setServerOption).toHaveBeenCalledWith("copy-command", "xclip -selection clipboard -i");
});
it("sets mouse on + status off (default) on the given session via SESSION scope only", async () => {
const { adapter, setSessionOption, setServerOption } = mockAdapter();
// default reader (omitted) → statusBar false.
@@ -109,20 +127,20 @@ describe("TmuxOptionDefaultsApplier", () => {
});
});
describe("resolveCopyCommand (pure per-platform table)", () => {
it("darwin → pbcopy", () => {
expect(resolveCopyCommand("darwin", () => false)).toBe("pbcopy");
describe("resolveCopyCommand (pure per-platform table)", async () => {
it("darwin → pbcopy", async () => {
expect(await resolveCopyCommand("darwin", () => false)).toBe("pbcopy");
});
it("linux → wl-copy when present", () => {
expect(resolveCopyCommand("linux", (b) => b === "wl-copy")).toBe("wl-copy");
it("linux → wl-copy when present", async () => {
expect(await resolveCopyCommand("linux", (b) => b === "wl-copy")).toBe("wl-copy");
});
it("linux → xclip when wl-copy absent but xclip present", () => {
expect(resolveCopyCommand("linux", (b) => b === "xclip")).toBe("xclip -selection clipboard -i");
it("linux → xclip when wl-copy absent but xclip present", async () => {
expect(await resolveCopyCommand("linux", (b) => b === "xclip")).toBe("xclip -selection clipboard -i");
});
it("linux → null when neither present (OSC 52 fallback)", () => {
expect(resolveCopyCommand("linux", () => false)).toBeNull();
it("linux → null when neither present (OSC 52 fallback)", async () => {
expect(await resolveCopyCommand("linux", () => false)).toBeNull();
});
it("other platforms → null", () => {
expect(resolveCopyCommand("win32", () => true)).toBeNull();
it("other platforms → null", async () => {
expect(await resolveCopyCommand("win32", () => true)).toBeNull();
});
});
+11 -5
View File
@@ -1,6 +1,7 @@
import type { AttentionRead, AttentionItem } from "@openrig/daemon/attention";
import type { Action, FleetSnapshot, ViewState } from "../types.js";
import { listItem, wrapDetailLines, type ContentLine } from "../detail.js";
import { displayTime } from "../time.js";
/** The bounded, confirmed-delivery or bridge-acceptance projection served by S02; no client classification. */
export interface DeliveredHumanUpdates {
@@ -11,6 +12,8 @@ export interface DeliveredHumanUpdates {
truncated: boolean;
}
const BRIDGE_ACCEPTED_SCOPE = " · accepted by bridge";
function isBridgeAcceptedUpdate(q: DeliveredHumanUpdates["items"][number]): boolean {
return q.deliveryOutcome === "accepted" || q.deliveryReceipt.startsWith("relay-owner-notification-accepted");
}
@@ -38,13 +41,13 @@ export function composeHumanUpdates(attention: AttentionRead | null, updates: De
const baseScope = projects.length === 1 ? `project ${projects[0]} (queue tag)` : "instance · project unknown";
const item: AttentionItem = { id: `human-update:${q.qitemId}`, kind: "update", summary: q.summary || q.body.trim().split(/\r?\n/).find(Boolean) || (accepted ? "Accepted by bridge" : "Delivered update"),
recipient: q.destinationSession, urgency: "update", unblocks: null, at: q.deliveredAt,
scope: accepted ? `${baseScope} · accepted by bridge` : baseScope, project: null,
scope: accepted ? `${baseScope}${BRIDGE_ACCEPTED_SCOPE}` : baseScope, project: null,
source: `/api/queue/${encodeURIComponent(q.qitemId)}` };
if (!read.items.some(current => current.id === `queue-update:${q.qitemId}`)) read.items.push(item);
if (wanted === item.id && read.detail?.item.id !== item.id) {
read.detailError = null;
read.detail = { item, lines: [q.body, ...(q.humanDetail ? ["Supplemental detail:", q.humanDetail] : []),
`From: ${q.sourceSession}`, accepted ? `Accepted by bridge: ${q.deliveredAt}` : `Delivered: ${q.deliveredAt}`, `Delivery receipt: ${q.deliveryReceipt}`, `Queue: ${q.qitemId}`, `Evidence: ${q.evidenceRef ?? "none recorded"}`],
`From: ${q.sourceSession}`, `Delivery receipt: ${q.deliveryReceipt}`, `Queue: ${q.qitemId}`, `Evidence: ${q.evidenceRef ?? "none recorded"}`],
files: q.evidenceRef?.startsWith("/") ? [{ label: "Update evidence", path: q.evidenceRef }] : [] };
}
}
@@ -71,7 +74,10 @@ export function attentionLines(state: ViewState, snap: FleetSnapshot, width: num
if (d.item.recipient) lines.push({ text: `To: ${d.item.recipient}` });
if (d.item.id.startsWith("human-update:") || d.item.id.startsWith("queue-update:")) lines.push({ text: "No action needed" });
if (d.item.unblocks) lines.push({ text: `Unblocks: ${d.item.unblocks}` });
lines.push({ text: `Scope: ${d.item.scope}` }, { text: `Observed: ${d.item.at ?? "unknown"}` });
// Times are drawn in the person's zone (ui.timezone), as the header's are; a delivered update's time is when it was
// delivered or accepted by the bridge.
const when = !d.item.id.startsWith("human-update:") ? "Observed" : d.item.scope.endsWith(BRIDGE_ACCEPTED_SCOPE) ? "Accepted by bridge" : "Delivered";
lines.push({ text: `Scope: ${d.item.scope}` }, { text: `${when}: ${displayTime(d.item.at, state.timeZone)}` });
lines.push(...questionLines(d.item).map(text => ({ text })));
lines.push(...d.lines.map(text => ({ text })));
for (const f of d.files) lines.push(listItem(f.label, { type: "attention-source", path: f.path }));
@@ -124,10 +130,10 @@ export function attentionLines(state: ViewState, snap: FleetSnapshot, width: num
if (i.humanQuestions?.length) lines.push({ text: ` Questions: ${i.humanQuestions.length}, ${unansweredCount(i)} unanswered` });
if (i.id.startsWith("human-update:") || i.id.startsWith("queue-update:")) lines.push({ text: " No action needed" });
if (i.unblocks) lines.push({ text: ` Unblocks: ${i.unblocks}` });
lines.push({ text: ` ${i.scope} · ${i.at ?? "time unknown"}` });
lines.push({ text: ` ${i.scope} · ${displayTime(i.at, state.timeZone)}` });
}
}
lines.push({ text: "" }, { text: `Read at ${read.readAt}` }, ...read.sources.map(s => ({ text: `${s.source}: ${s.state} · ${s.detail}` })));
lines.push({ text: "" }, { text: `Read at ${displayTime(read.readAt, state.timeZone)}` }, ...read.sources.map(s => ({ text: `${s.source}: ${s.state} · ${s.detail}` })));
}
lines.push({ text: "" }, { text: "Viewing is not approval. Each request's detail says whether it went to Slack and where to answer it." });
return wrapDetailLines(lines, width);
@@ -50,6 +50,24 @@ it("renders an unsent local FYI without inventing an approval or a delivery rece
expect(text).not.toContain("Delivery receipt:");
});
it("draws Feed times in the person's zone, as the header does, and redraws them when the zone changes", () => {
const item = current(), attention = read([item]);
attention.detail = { item, lines: ["Current queue body"], files: [] };
const merged = composeHumanUpdates(attention, delivered("other"), item.id)!;
const draw = (timeZone: string, attentionOpen: string | null) => attentionLines(
{ ...createViewState({ instanceId: "owned-fixture", timeZone }).get(), section: "needs" as const, attentionOpen },
{ ...emptySnapshot(), attentionRead: merged }, 140).map(l => l.text).join("\n");
const london = draw("Europe/London", null);
expect(london).toContain("instance · 2026-10-01 13:00:00 GMT+1");
expect(london).toContain("instance · project unknown · 2026-10-08 13:00:00 GMT+1");
expect(london).toContain("Read at 2026-10-09 13:00:00 GMT+1");
expect(london).not.toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:/);
// The same read, drawn after the zone setting arrives: nothing is re-read, and nothing keeps the earlier zone.
const angeles = draw("America/Los_Angeles", item.id);
expect(angeles).toContain("Observed: 2026-10-01 05:00:00 PDT");
expect(angeles).not.toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:|GMT\+1/);
});
it("does not retain delivered history beyond a successful empty receipt window when only current updates fail", () => {
const prior = composeHumanUpdates(read([]), delivered())!;
const failed = read([]);
@@ -155,8 +173,9 @@ it("labels relay bridge acceptance as accepted by the bridge rather than Deliver
],
};
const openedBridge = composeHumanUpdates(read([]), bridgeUpdates, "human-update:bridge-fyi")!;
const bridgeDetailLines = openedBridge.detail?.lines.join("\n") ?? "";
expect(bridgeDetailLines).toContain("Accepted by bridge: 2026-10-08T14:00:00Z");
const detailState = (id: string) => ({ ...createViewState({ instanceId: "owned-fixture", timeZone: "UTC" }).get(), section: "needs" as const, attentionOpen: id });
const bridgeDetailLines = attentionLines(detailState("human-update:bridge-fyi"), { ...emptySnapshot(), attentionRead: openedBridge }, 140).map(l => l.text).join("\n");
expect(bridgeDetailLines).toContain("Accepted by bridge: 2026-10-08 14:00:00 UTC");
expect(bridgeDetailLines).not.toContain("Delivered:");
expect(openedBridge.sources.find(s => s.source === "delivered updates")?.detail).toContain("accepted by the bridge");
expect(openedBridge.sources.find(s => s.source === "delivered updates")?.detail).not.toContain("confirmed delivered updates");
@@ -168,8 +187,8 @@ it("labels relay bridge acceptance as accepted by the bridge rather than Deliver
// Slack delivered wording remains unchanged
const openedSlack = composeHumanUpdates(read([]), delivered("slack-fyi"), "human-update:slack-fyi")!;
const slackDetailLines = openedSlack.detail?.lines.join("\n") ?? "";
expect(slackDetailLines).toContain("Delivered: 2026-10-08T12:00:00Z");
const slackDetailLines = attentionLines(detailState("human-update:slack-fyi"), { ...emptySnapshot(), attentionRead: openedSlack }, 140).map(l => l.text).join("\n");
expect(slackDetailLines).toContain("Delivered: 2026-10-08 12:00:00 UTC");
expect(slackDetailLines).not.toContain("Accepted by bridge:");
expect(openedSlack.sources.find(s => s.source === "delivered updates")?.detail).toBe(
"Latest 20 confirmed delivered updates; retained receipt window.",