Compare commits

..
Author SHA1 Message Date
dev60-review b19e30fdad fix: a never-registered target says so from the requester's side; unknown isn't printed as nobody
A target where nobody was ever registered (the registry reports noHumans) now gives the same refusal as an emptied one, worded for the requesting machine: register a person on the target. rig host pair prints the target's own explanation of who reads answers, so a target that couldn't tell isn't shown as having no agent.
2026-11-11 02:26:40 +00:00
dev60-review 4298304a98 feat: a pairing request says who it went to and who reads the answer
The target's pair-request reply now carries who was asked (person) and who
reads their answer (owner), or why nobody does yet (noReader, the target's
own words), and `rig host pair` prints them; an older target's reply keeps
the general text. With nobody registered on the target, its refusal says to
register a person there, with the command.
2026-11-11 02:07:19 +00:00
14 changed files with 107 additions and 149 deletions

No files matched your search

+4 -1
View File
@@ -367,7 +367,10 @@ registered person can approve (none, or several with no default; name one with
`--human`) (`packages/daemon/src/routes/hosts.ts:379-398`).
The request stays open on the target for as long as `--timeout` waits (600 seconds by default), and the person's
approval says how long and until when, in UTC. A target older than this CLI ignores the timeout and keeps the request
approval says how long and until when, in UTC. A target that reports who was asked (`person`) and who reads their answer (`owner`), or why
nobody does yet (`noReader`), has `rig host pair` name them; an older target's reply keeps the general text. With
nobody registered on the target, its refusal (409 `pair_human_required`) says to register a person there:
`rig gateway human add <entityId> --display-name <name> --delivery-class B --default`. A target older than this CLI ignores the timeout and keeps the request
10 minutes.
On a 0.7.11 or later target, its person answers the request in the TUI (`rig tui`, then `attention`: open it), on
+10 -12
View File
@@ -428,24 +428,22 @@ running seat only through the action in the last column.
### New guidance for a running seat
There are three ways for a seat to pick up new guidance:
These reach a running seat without a restart, in the order they reach the agent:
1. **Tell it.** `rig send <seat> "Read <file>: <why>"`, or `rig walk <seat> --through <files>` for several. The seat's
interruption settings can hold a message; `rig send` may report `retained, not delivered`.
1. **Tell it.** `rig send <seat> "Read <file>: <why>"`, or `rig walk <seat> --through <files>` for several. It's in the
conversation at the seat's next turn. The seat's reply is the only evidence that it read the file.
2. **The refocus hook**, where the seat runs OpenRig's `openrig-core` hooks (**Refocus** in `rig seat status` shows whether
the hook has recorded it). The hook's content comes from `OPENRIG_REFOCUS_CONTENT_REF` (a `rig context get` address),
else `OPENRIG_REFOCUS_CONTENT_FILE`, else `refocus/REFOCUS.md` under `OPENRIG_HOME`, else the shipped default. After
compaction the hook waits for an eligible prompt. For managed Claude compaction it skips the acknowledgement boundary,
and other prompts can arrive first and be skipped while restore is pending. Claude can also refocus after the
conversation grows past the hook's size threshold.
else `OPENRIG_REFOCUS_CONTENT_FILE`, else `refocus/REFOCUS.md` under `OPENRIG_HOME`, else the shipped default. It
reaches the agent at the next prompt after a compaction, and for Claude also once the conversation has grown past the
hook's size threshold.
3. **Its guidance file, loaded again:** for Claude at its next compaction, `/clear` or start, for Codex at its next start.
**Guidance** in `rig seat status` shows which of OpenRig's blocks were written after the session's recorded startup finish.
A file pointer leaves the read to the seat, under its own permission settings. `rig walk` reads the named files in the
calling process and sends their contents. A seat whose harness asks before reading a file outside its project (its own
`LEARNED.md` under the topology root, a plugin's skill) waits at that prompt until someone answers it, even though a hook
could deliver an excerpt of the same file. Finding a file, a hook picking it up, and the agent reading and using it are
separate facts.
A pointer or a message names a file; the seat still reads it itself, under its own permission settings. A seat whose
harness asks before reading a file outside its project (its own `LEARNED.md` under the topology root, a plugin's skill)
waits at that prompt until someone answers it, even though a hook could deliver an excerpt of the same file. Finding a
file, a hook picking up, and the agent reading and using it are separate facts.
Per harness: Claude Code's and Codex's permission choices are in the setup reference
([Claude Code](getting-started.md#claude-code-a-different-launch-flag),
+2 -6
View File
@@ -218,9 +218,5 @@ an open FYI, or a closed ask is not that receipt. The rig-wide reads and their l
and every catalog project's missions root. A row may omit `project:` or `mission:` only when its remaining
tags identify exactly one physical slice with a resolved project and mission. Ambiguous matches count nowhere,
even if first-wins discovery or a list filter leaves only one row visible; an explicit mismatched project or
mission never counts. Several `slice:` or `mission:` tags on one row count only when they all name the same
physical slice or mission (a slice's folder name and its frontmatter `id:`, say); a row that names two distinct
slices, missions or projects counts nowhere, while `qitemCount` can include it under several tagged slices,
subject to the existing project filter and the newest 500 rows per slice. Counts are recomputed on every list
request. `?refresh=1|true` invalidates the separate listing cache, not a stored queue-count snapshot. This
resolution does not change the returned slice list or filters.
mission never counts. Counts are recomputed on every list request. `?refresh=1|true` invalidates the separate
listing cache, not a stored queue-count snapshot. This resolution does not change the returned slice list or filters.
+10 -2
View File
@@ -862,7 +862,8 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
// retry loop forever. Values that work today keep their wait. The target
// keeps the request open as long as this side waits, and its approval says so.
const timeoutMs = resolvePairTimeoutMs(opts.timeout);
let issued: { pairId?: string; code?: string; approvalQitemId?: string; error?: string; message?: string };
let issued: { pairId?: string; code?: string; approvalQitemId?: string; error?: string; message?: string;
person?: string; owner?: string | null; ownerNote?: string; noReader?: string };
try {
const res = await httpPost(`${targetBase}/api/hosts/pair-request`, {
requester: `${userInfo().username}@${osHostname()}`,
@@ -884,7 +885,14 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
if (!opts.json) {
console.log(`Pairing code: ${issued.code}`);
console.log(`Waiting for approval on the target (its attention queue carries item ${issued.approvalQitemId ?? "?"}).`);
console.log("On the target, its person answers the pairing request in the TUI (rig tui, then attention), on Steering, or in Slack: with Approve or Deny, or in their own words. On a 0.7.11 or later target with a kernel operator, the operator reads the answer and decides (a stopped operator's answer waits for it); on a target with no kernel operator, Approve and Deny decide, and typed words are kept and the person is asked again. On an older target any typed reply approves.");
// A target that says who was asked and who reads the answer: name them. An older target keeps the general text.
if (issued.person) {
console.log(`The request went to ${issued.person} on the target.`);
console.log(issued.owner
? `${issued.owner} reads their answer and decides${issued.ownerNote ? ` (${issued.ownerNote}, so ${issued.owner} reads it instead)` : ""}.`
// The target's own words: none reads answers, or it couldn't tell. Never a categorical "nobody" over an unknown.
: `On the target: ${issued.noReader ?? "it didn't say whether an agent reads answers there"}. The request names how to decide directly on the target.`);
} else console.log("On the target, its person answers the pairing request in the TUI (rig tui, then attention), on Steering, or in Slack: with Approve or Deny, or in their own words. On a 0.7.11 or later target with a kernel operator, the operator reads the answer and decides (a stopped operator's answer waits for it); on a target with no kernel operator, Approve and Deny decide, and typed words are kept and the person is asked again. On an older target any typed reply approves.");
console.log(`On a 0.7.11 or later target, anyone at its terminal with its operator bearer token can also decide: rig host approve ${issued.code} (or rig host deny ${issued.code}), with OPENRIG_AUTH_BEARER_TOKEN set or --bearer <token>.`);
}
+20
View File
@@ -100,6 +100,26 @@ describe("rig host pair (OPR.0.4.6.MH1 FR-6)", () => {
expect(yaml).not.toContain("issued-bearer");
}, 15_000);
it("a target that says who was asked and who reads the answer is named; one where no agent reads says why", async () => {
const approved = [{ status: 200, body: { status: "approved", token: "issued-bearer" } }];
const owned = fakeDeps({ post: { status: 200, body: { pairId: "p-1", code: "424242", approvalQitemId: "qitem-1", person: "alex@external", owner: "lead@rig" } }, poll: approved });
const a = await capture(() => run(owned, ["pair", "vps-a:7433", "--id", "vps-a", "--timeout", "10"]));
expect(a.out.join("\n")).toContain("The request went to alex@external on the target.\nlead@rig reads their answer and decides.");
expect(a.out.join("\n")).not.toContain("On the target, its person answers the pairing request");
fs.rmSync(path.join(dir, "hosts.yaml"), { force: true }); fs.rmSync(path.join(dir, "secrets"), { recursive: true, force: true });
const free = fakeDeps({ post: { status: 200, body: { pairId: "p-2", code: "515151", approvalQitemId: "qitem-2", person: "alex@external", owner: null,
noReader: "no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no operator-agent@kernel)" } }, poll: approved });
const b = await capture(() => run(free, ["pair", "vps-b:7433", "--id", "vps-b", "--timeout", "10"]));
expect(b.out.join("\n")).toContain("On the target: no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no operator-agent@kernel). The request names how to decide directly on the target.");
// A target that couldn't tell is printed as unknown, never as "nobody reads".
fs.rmSync(path.join(dir, "hosts.yaml"), { force: true }); fs.rmSync(path.join(dir, "secrets"), { recursive: true, force: true });
const unknown = fakeDeps({ post: { status: 200, body: { pairId: "p-3", code: "616161", approvalQitemId: "qitem-3", person: "alex@external", owner: null,
noReader: "OpenRig couldn't tell whether an agent reads answers on this host" } }, poll: approved });
const c = await capture(() => run(unknown, ["pair", "vps-c:7433", "--id", "vps-c", "--timeout", "10"]));
expect(c.out.join("\n")).toContain("On the target: OpenRig couldn't tell whether an agent reads answers on this host. The request names how to decide directly on the target.");
expect(c.out.join("\n")).not.toContain("No agent reads answers there");
}, 15_000);
it("--timeout reaches the target, which keeps the request open that long", async () => {
const deps = fakeDeps({ poll: [{ status: 200, body: { status: "denied" } }] });
await capture(() => run(deps, ["pair", "http://vps-a:7433", "--timeout", "3600"]));
-25
View File
@@ -1,25 +0,0 @@
// The seat boundary. A seat's kind comes from its binding's `attachment_type`, the key the seat-kinds facade in #1429
// proposed (by rlch, credited). This first boundary covers today's kinds only: tmux, which is also the column's default
// and what a legacy row with no value means, and external_cli. Each seat's three responsibilities stay where they are:
// the runtime that keeps the process alive (RuntimeAdapter), the session that carries messages and turns to the agent
// (SessionTransport, which asks this resolver), and the view a person watches and types into (TerminalProvider).
/** Today's seat kinds. */
export type SeatKind = "tmux" | "external_cli";
/** A binding's kind as stored. `unknown` names a stored value that isn't one of today's kinds, and is never read as tmux. */
export type ResolvedSeatKind = { kind: SeatKind } | { kind: "unknown"; stored: string };
export function resolveSeatKind(stored: string | null | undefined): ResolvedSeatKind {
if (stored === null || stored === undefined || stored === "tmux") return { kind: "tmux" };
if (stored === "external_cli") return { kind: "external_cli" };
return { kind: "unknown", stored };
}
/** How OpenRig's inbound transport (send, capture and the activity probe) reaches a seat of this kind. An external CLI
* seat runs inside someone else's program, so OpenRig can't type into it or read it: `none`. An unknown stored kind
* keeps the path it takes today, the tmux one, so nothing changes for it here; whether it should is an open question
* for its owner, not something this boundary settles. */
export function seatInboundTransport(seat: ResolvedSeatKind): "tmux" | "none" {
return seat.kind === "external_cli" ? "none" : "tmux";
}
+14 -26
View File
@@ -51,7 +51,6 @@ import type { SlowOperationInstrumentation } from "./slow-op-recorder.js";
import { hashSentText, type CaptureObserverSink, type CaptureSlot, type ObservationInput, type ObservedBinding } from "./capture-observer.js";
import { isAttentionRequiredReadinessCode, type NodeBinding, type ReadinessOptions, type ReadinessResult } from "./runtime-adapter.js";
import { getLatestStartupFailedEvent, hasClaudeResumeIdentityWork, isRecoverableStartupFailure } from "./startup-orchestrator.js";
import { resolveSeatKind, seatInboundTransport } from "./seat-kinds.js";
// OPR.0.4.1.10 — send-readiness freshness. The runtime-hook store keeps a 5min freshness for activity
// DISPLAY, but "safe to send NOW" needs a tight window: a stale "idle" read must not authorize a send
@@ -449,18 +448,14 @@ function closesFramedPromptAbove(lines: string[], open: number, indent: number,
const codexFooter = (line: string): boolean => isComposerFooter(line)
|| /(?:\b\d+% context left\b|\bContext \[)/.test(line);
/** Preserve the styling used for autocomplete ghosts and the drawn cursor.
* Color channel values must never be interpreted as style codes. */
/** Preserve faint styling so autocomplete ghosts can be separated from authored
* text. Color channel values must never be interpreted as style codes. */
function styledTextLines(screen: string) {
let faint = false, inverse = false;
let faint = false;
return screen.replace(/\r\n/g, "\n").split("\n").map(raw => {
let text = "", offset = 0;
const faintCells: boolean[] = [], inverseCells: boolean[] = [];
const append = (value: string) => {
text += value;
faintCells.push(...Array<boolean>(value.length).fill(faint));
inverseCells.push(...Array<boolean>(value.length).fill(inverse));
};
const faintCells: boolean[] = [];
const append = (value: string) => { text += value; faintCells.push(...Array<boolean>(value.length).fill(faint)); };
for (const match of raw.matchAll(/\x1b\[([0-9;:]*)m/g)) {
append(raw.slice(offset, match.index));
const codes = match[1]!.split(";");
@@ -471,18 +466,15 @@ function styledTextLines(screen: string) {
const skip = codes[i + 1] === "5" ? 2 : codes[i + 1] === "2" ? (codes[i + 2] === "" ? 5 : 4) : 0;
// Color channels are not style codes. Ambiguous compound sequences
// provide no faint-style proof, rather than interpreting an RGB 2 as dim.
if (!skip || i + skip !== codes.length - 1) { faint = inverse = false; break; }
if (!skip || i + skip !== codes.length - 1) { faint = false; break; }
i += skip;
} else if (code === "" || code === "0") faint = inverse = false;
else if (code === "22") faint = false;
} else if (code === "" || code === "0" || code === "22") faint = false;
else if (code === "2") faint = true;
else if (code === "7") inverse = true;
else if (code === "27") inverse = false;
}
offset = match.index! + match[0].length;
}
append(raw.slice(offset));
return { text, faintCells, inverseCells };
return { text, faintCells };
});
}
@@ -577,11 +569,7 @@ export function inspectComposerInput(snapshot: ComposerSnapshot | null, runtime:
else if (cursorOffset >= 0 && !rows[cursorRow]!.slice(cursorOffset).trim()) visible[cursorRow] = rows[cursorRow]!.slice(0, cursorOffset);
}
const atStart = y === start && x === prefix;
// Claude can draw an inverse-video blank in the cursor cell (#2925). Only
// that single cell at the start is padding; ordinary/preceding spaces stay input.
const inverseCells = styled[start]!.inverseCells;
const cursorBlank = prompt.glyph === "❯" && rows[0] === " " && inverseCells[prefix] && !inverseCells[prefix - 1];
const empty = atStart && rows.length === 1 && (rows[0] === "" || cursorBlank);
const empty = atStart && rows.length === 1 && rows[0] === "";
const last = visible.length - 1;
const cursorAtEnd = y === start + last && x === prefix + stringWidth(visible[last] ?? "");
const label = visible.length === 1 && COMPOSER_COLLAPSED_PASTE_PATTERN.test(visible[0]!) ? visible[0]! : null;
@@ -981,7 +969,7 @@ export async function probeSessionActivity(input: {
};
}
const readTarget = binding?.pane ?? sessionName;
if (seatInboundTransport(resolveSeatKind(attachmentType)) === "none") {
if (attachmentType === "external_cli") {
return {
state: "unknown",
reason: "unsupported_attachment",
@@ -2581,7 +2569,7 @@ export class SessionTransport {
const binding = this.sessionRegistry.getBindingForNode(node.id);
const latestSession = latestByNode.get(node.id);
if (resolveSeatKind(binding?.attachmentType).kind === "external_cli" && binding?.externalSessionName) {
if (binding?.attachmentType === "external_cli" && binding.externalSessionName) {
targets.push({
sessionName: binding.externalSessionName,
rigName,
@@ -2912,7 +2900,7 @@ export class SessionTransport {
// External CLI seats never wait: main refuses them before anything else,
// and a probe or wait would only delay that refusal (or mistake it for
// an absent session).
if (seatInboundTransport(resolveSeatKind(meta.attachmentType)) === "none") {
if (meta.attachmentType === "external_cli") {
return { ok: false, failure: {
ok: false,
sessionName,
@@ -3106,7 +3094,7 @@ export class SessionTransport {
const waitMode = waitForIdleMs !== undefined;
let waitEvidence: Pick<SendResult, "activity" | "waitedMs" | "attempts"> = {};
if (seatInboundTransport(resolveSeatKind(sessionMeta.attachmentType)) === "none") {
if (sessionMeta.attachmentType === "external_cli") {
return {
ok: false,
sessionName,
@@ -4372,7 +4360,7 @@ export class SessionTransport {
readable?: { width: number; height: number }; readableBudget?: ReadableBudget }): Promise<CaptureResult> {
sessionName = physicalSessionName(this.db, sessionName);
const sessionMeta = this.getSessionMeta(sessionName);
if (seatInboundTransport(resolveSeatKind(sessionMeta.attachmentType)) === "none") {
if (sessionMeta.attachmentType === "external_cli") {
return {
ok: false,
sessionName,
+8 -2
View File
@@ -400,7 +400,11 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
}
const body = (await c.req.json<{ requester?: string; human?: string; timeoutMs?: number }>().catch(() => ({}))) as { requester?: string; human?: string; timeoutMs?: number };
const registry = (opts?.humanRegistry ?? loadHumanRegistry)();
if (!registry.ok) return c.json({ error: "pair_human_registry_unavailable", message: registry.error }, 409);
// Nobody registered on this host (never, or no longer): the fix is here, and the requester's CLI prints this.
const noPerson = { error: "pair_human_required", addresses: [] as string[],
message: "No person is registered on the target host, so nobody there can be asked to approve pairing. On the target, register one: rig gateway human add <entityId> --display-name <name> --delivery-class B --default (a connector --binding is optional). No approval row was created." };
if (!registry.ok) return registry.noHumans ? c.json(noPerson, 409) : c.json({ error: "pair_human_registry_unavailable", message: registry.error }, 409);
if (registry.entities.length === 0) return c.json(noPerson, 409);
// Named, or else the default person (chosen, or the only one); never the first of several.
const fallback = body.human ? null : resolveDefaultPerson(registry.entities).person;
const humans = body.human ? registry.entities.filter((human) => human.address === body.human) : fallback ? [fallback] : [];
@@ -439,7 +443,9 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
return c.json({ error: "pair_approval_item_failed", message }, 500);
}
return c.json({ pairId, code, approvalQitemId: qitemId });
// Who was asked, and who reads their answer here (or why nobody does yet), for the requester to show.
return c.json({ pairId, code, approvalQitemId: qitemId, person: rec.person, owner: rec.owner,
...(rec.owner ? (rec.ownerNote ? { ownerNote: rec.ownerNote } : {}) : { noReader: noReader(rec) }) });
});
router.get("/pair-request/:pairId", async (c) => {
@@ -162,15 +162,6 @@ describe("cursor-bound composer input", () => {
expect(inspectComposerInput(claude("\x1b[2;38;5;245mAsk Codex to do anything\x1b[0m", { x: 2, y: 2 })).state).toBe("empty");
});
it("recognizes only a single inverse-video Claude cursor blank at the prompt start", () => {
const cursor = { x: 2, y: 2 };
expect(inspectComposerInput(claude("\x1b[7m \x1b[0m", cursor))).toMatchObject({ state: "empty", rows: [""], cursorAtEnd: true });
for (const body of [" ", "\x1b[38;5;7m \x1b[0m", "\x1b[7m\x1b[27m ", "\x1b[7m \x1b[0mdraft"]) {
expect(inspectComposerInput(claude(body, cursor)).state).toBe("text");
}
expect(inspectComposerInput(claude("\x1b[7m \x1b[0m", { x: 3, y: 2 }))).toMatchObject({ state: "text", rows: [" "] });
});
it("requires the complete owned text and preserves significant spaces", () => {
const input = inspectComposerInput(claude("Run two commands\nthen report."));
expect(composerContainsOwnedText(input, "Run two commands\nthen report.")).toBe(true);
@@ -10,7 +10,7 @@ import { TmuxAdapter } from "../src/adapters/tmux.js";
import { shellQuote } from "../src/adapters/shell-quote.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { migrate } from "../src/db/migrate.js";
import { annotateInputBox, inspectComposerInput } from "../src/domain/session-transport.js";
import { inspectComposerInput } from "../src/domain/session-transport.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SeatDeliveryGuard, resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
@@ -133,7 +133,7 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
}
}, 30_000);
it.each(["update-banner", "cursor-block"])("submits a multiline paste with %s and keeps a real draft through tmux", async variant => {
it("submits a multiline paste under a combined update footer through real tmux", async () => {
const scratch = mkdtempSync(join(tmpdir(), "draft-banner-"));
const socket = join(scratch, "s");
const env = { ...process.env }; delete env.TMUX; delete env.TMUX_TMPDIR;
@@ -143,9 +143,8 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
const db = new Database(":memory:");
let transport: SessionTransport | undefined;
try {
const command = [process.execPath, composer, "claude", stateFile, variant].map(shellQuote).join(" ");
const [width, height] = variant === "cursor-block" ? ["68", "31"] : ["120", "40"];
const pane = (await tmux(["-f", "/dev/null", "new-session", "-d", "-P", "-F", "#{pane_id}", "-x", width, "-y", height, "-s", "worker@test", command])).trim();
const command = [process.execPath, composer, "claude", stateFile, "update-banner"].map(shellQuote).join(" ");
const pane = (await tmux(["-f", "/dev/null", "new-session", "-d", "-P", "-F", "#{pane_id}", "-x", "120", "-y", "40", "-s", "worker@test", command])).trim();
await vi.waitFor(() => expect(state().input).toBe(""), { timeout: 5000 });
migrate(db, ALL_MIGRATIONS);
db.exec(`INSERT INTO rigs(id,name) VALUES ('rig','test');
@@ -158,14 +157,7 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name)); adapter.deliveryGuard = guard;
transport = new SessionTransport({ db, tmuxAdapter: adapter, rigRepo: new RigRepository(db), sessionRegistry: new SessionRegistry(db) });
await guard.set("a", { mode: "draft-aware" }, "human@test", "protect fixture input");
if (variant === "cursor-block") {
await vi.waitFor(async () => {
const snapshot = await adapter.captureComposerSnapshot(pane);
expect(snapshot?.cursor).toEqual({ x: 2, y: 26, width: 68, height: 31 });
expect(snapshot!.screen.split("\n")[26]).toContain("❯\u00a0\x1b[7m ");
expect(annotateInputBox(snapshot!.screen, snapshot!.cursor, "claude-code")).toMatchObject({ status: "empty", confidence: "high" });
});
} else await vi.waitFor(async () => expect(inspectComposerInput(await adapter.captureComposerSnapshot(pane), "claude-code").state).toBe("empty"));
await vi.waitFor(async () => expect(inspectComposerInput(await adapter.captureComposerSnapshot(pane), "claude-code").state).toBe("empty"));
const text = "first line\nsecond line\nlast line";
const result = await transport.send("worker@test", text, { deliveryId: "banner", actorSession: "sender@test", verify: false });
expect(result, JSON.stringify(result)).toMatchObject({ ok: true, sent: true });
@@ -173,7 +165,7 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
await tmux(["send-keys", "-t", pane, "-l", "person's draft"]);
await vi.waitFor(() => expect(state().input).toBe("person's draft"));
expect(await transport.send("worker@test", "another message", { deliveryId: "draft", verify: false }))
.toMatchObject({ outcome: "retained", sent: false, reason: "draft_input_busy" });
.toMatchObject({ outcome: "retained", sent: false });
expect(state()).toEqual({ input: "person's draft", submissions: [text] });
} finally {
await transport?.guardedDelivery?.stop();
+3 -7
View File
@@ -5,23 +5,19 @@ import { StringDecoder } from "node:string_decoder";
import stringWidth from "string-width";
const [runtime, stateFile, footerVariant] = process.argv.slice(2);
const cursorBlock = footerVariant === "cursor-block";
const rule = cursorBlock ? `\x1b[38;5;244m${"─".repeat(68)}\x1b[39m` : "────────────────────────────────────────";
let input = "", pending = "", pasting = false, cursor = 0, ghost = false;
const submissions = [];
const decoder = new StringDecoder("utf8");
function render() {
const rows = ["Editable delivery fixture", ...submissions.slice(-3).flatMap(text => text.split("\n"))];
if (cursorBlock) while (rows.length < 25) rows.push("");
if (runtime === "claude") rows.push(rule);
if (runtime === "claude") rows.push("────────────────────────────────────────");
const start = rows.length;
const displayed = ghost && cursor === input.length ? `${input}\x1b[2m suggested follow-up\x1b[22m`
: input || (runtime === "codex" ? "\x1b[2mAsk Codex to do anything\x1b[22m" : cursorBlock ? "\x1b[7m \x1b[0m" : "");
: input || (runtime === "codex" ? "\x1b[2mAsk Codex to do anything\x1b[22m" : "");
const body = displayed.split("\n");
rows.push(`${runtime === "claude" ? "❯\u00a0" : "› "}${body[0]}`, ...body.slice(1).map(line => ` ${line}`));
rows.push(...(runtime === "claude" ? [rule, cursorBlock
? " \x1b[38;5;211m⏵⏵ bypass permissions on\x1b[38;5;246m (shift+tab to cycle)\x1b[0m" : "? for shortcuts"] : ["", "fixture · 90% context left"]));
rows.push(...(runtime === "claude" ? ["────────────────────────────────────────", "? for shortcuts"] : ["", "fixture · 90% context left"]));
if (runtime === "claude" && ghost) {
rows[rows.length - 1] = "⏵⏵ accept edits on (shift+tab to cycle) · ← for agents";
rows.push("✘ Auto-update failed: no write permission to npm prefix · Run claude doctor");
@@ -17,6 +17,7 @@ import net from "node:net";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync, statSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { loadHumanRegistry } from "../src/domain/gateway/human-registry.js";
import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
@@ -460,6 +461,35 @@ describe("pair-request — the target-side issuance handshake (FR-6)", () => {
expect(repo.list({ limit: 100 })[0]?.destinationSession).toBe("blair@external");
});
it("a target with nobody registered (never, or no longer) says so from the requester's side; several with no default keep today's text", async () => {
const neverHome = mkdtempSync(join(tmpdir(), "pair-never-registered-"));
try {
expect(loadHumanRegistry(neverHome)).toMatchObject({ ok: false, noHumans: true });
for (const registry of [() => loadHumanRegistry(neverHome), () => ({ ok: true as const, entities: [] })]) {
const res = await buildApp(repo, BEARER, registry).request("/api/hosts/pair-request", { method: "POST", body: "{}" });
expect(res.status).toBe(409);
expect(await res.json()).toEqual({ error: "pair_human_required", addresses: [],
message: "No person is registered on the target host, so nobody there can be asked to approve pairing. On the target, register one: rig gateway human add <entityId> --display-name <name> --delivery-class B --default (a connector --binding is optional). No approval row was created." });
}
expect(repo.list({ limit: 100 })).toHaveLength(0);
} finally { rmSync(neverHome, { recursive: true, force: true }); }
const several = await buildApp(repo, BEARER, () => ({ ok: true, entities: [human, { ...human, entityId: "blair", address: "blair@external" }] }))
.request("/api/hosts/pair-request", { method: "POST", body: "{}" });
expect(((await several.json()) as { message: string }).message).toContain("Pair approval needs one registered human, or a default when several exist.");
});
it("the reply names who was asked and who reads their answer, or why nobody does yet", async () => {
const res = await app.request("/api/hosts/pair-request", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requester: "tester@somewhere" }) });
expect(await res.json()).toMatchObject({ person: "alex@external", owner: null,
noReader: "no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no operator-agent@kernel)" });
vi.stubEnv("OPENRIG_AGENTS_OPERATOR_SESSION", "lead@rig");
try {
const owned = await (await app.request("/api/hosts/pair-request", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requester: "tester@somewhere" }) })).json() as Record<string, unknown>;
expect(owned).toMatchObject({ person: "alex@external", owner: "lead@rig" });
expect(owned.noReader).toBeUndefined();
} finally { vi.unstubAllEnvs(); }
});
it("issuance mints the ONE human approval moment: a human-routed qitem with code + summary + evidence_ref", async () => {
const create = vi.spyOn(repo, "create");
const { code, approvalQitemId } = await issue();
-24
View File
@@ -1,24 +0,0 @@
// The seat boundary's resolver: today's two kinds, and a stored value that's neither read as unknown, never as tmux.
import { describe, it, expect } from "vitest";
import { resolveSeatKind, seatInboundTransport } from "../src/domain/seat-kinds.js";
describe("resolveSeatKind", () => {
it.each([
[null, { kind: "tmux" }],
[undefined, { kind: "tmux" }],
["tmux", { kind: "tmux" }],
["external_cli", { kind: "external_cli" }],
["herdr", { kind: "unknown", stored: "herdr" }],
["", { kind: "unknown", stored: "" }],
["TMUX", { kind: "unknown", stored: "TMUX" }],
] as const)("reads %j as %j", (stored, expected) => {
expect(resolveSeatKind(stored)).toEqual(expected);
});
it("gives an external CLI seat no inbound transport, and every other kind today's tmux path", () => {
expect(seatInboundTransport({ kind: "external_cli" })).toBe("none");
expect(seatInboundTransport({ kind: "tmux" })).toBe("tmux");
// An unknown stored kind keeps the path it takes today; the resolver still names it unknown.
expect(seatInboundTransport({ kind: "unknown", stored: "herdr" })).toBe("tmux");
});
});
@@ -2741,27 +2741,6 @@ describe("SessionTransport", () => {
expect(hasSessionSpy).not.toHaveBeenCalled();
});
// A stored kind that isn't one of today's (only reachable by writing the column directly) takes today's path, the tmux
// one, for send and capture: the seat boundary adds no refusal and changes nothing for it.
it("a binding whose stored kind isn't tmux or external_cli sends and captures through tmux, as before the boundary", async () => {
const { node } = seedCanonicalRig();
db.prepare("UPDATE bindings SET attachment_type = 'herdr' WHERE node_id = ?").run(node.id);
const callOrder: string[] = [];
const tmux = mockTmux({
sendText: async () => { callOrder.push("sendText"); return { ok: true }; },
sendKeys: async (_t, keys) => { callOrder.push(`sendKeys:${keys.join(",")}`); return { ok: true }; },
capturePaneContent: async () => "line1",
});
const transport = createTransport(tmux);
const sent = await transport.send("dev-impl@my-rig", "hello");
expect(sent.reason).not.toBe("transport_unavailable");
expect(callOrder).toContain("sendText");
const captured = await transport.capture("dev-impl@my-rig");
expect(captured.ok).toBe(true);
expect(captured.content).toContain("line1");
});
// Test 11: resolveSessions by rig returns running sessions
it("resolveSessions by rig returns running sessions", async () => {
seedCanonicalRig();