Compare commits

...
23 Commits
Author SHA1 Message Date
dev-driver d95f9c8cb6 Merge remote-tracking branch 'origin/main' into fix/terminal-seat-send-exact-text
# Conflicts:
#	packages/cli/src/commands/send.ts
2026-10-24 03:28:27 +00:00
dev-driver 760024dd7c test(daemon): send the draft-aware queue wakes to an agent seat throughout
Main's newer draft-aware delivery tests send their queue wakes to the
fixture's terminal seat. This branch types no queue wake into a terminal
seat, so after the merge those wakes typed nothing: some tests failed, and
others (a recovery that should leave a wake alone, or skip closed work)
passed only because nothing could be typed.

Every queue-wake test in the file now uses an agent seat, as this branch's
earlier tests already did. The pane double echoes a sent message under the
prompt the way an agent pane does, so an agent seat's wake verifies as
delivered; a terminal seat's pane is unchanged. Every expectation from main
is kept, and the coalesced-wake test goes back to "delivered".
2026-10-24 03:19:01 +00:00
dev-driver 65e0696892 Merge remote-tracking branch 'origin/main' into fix/terminal-seat-send-exact-text 2026-10-24 02:35:11 +00:00
dev-driver 1d92174e26 fix(send): an urgent send to a terminal seat types the exact text without the urgent line
Main's urgent sends type an "Urgent (marked by …):" line above the text. For a
terminal seat, whose shell would run that line, the exact text is now typed byte
for byte, as an explicit prompt answer already is. The urgent mark still admits
the input at the seat's interruption level and stays in the result.

Also retitles a test: a watchdog notice to a terminal seat carries only its
marker; with no delivery guard in that test it types as given, while a seat's
interruption level (the terminal default holds OpenRig's own notices) is the
guard's.
2026-10-24 02:35:06 +00:00
dev-driver 2493794d1b Merge remote-tracking branch 'origin/main' into fix/terminal-seat-send-exact-text
# Conflicts:
#	packages/cli/src/commands/send.ts
#	packages/daemon/src/domain/session-transport.ts
#	packages/daemon/src/domain/watchdog-delivery.ts
#	packages/daemon/src/routes/transport.ts
#	packages/daemon/src/startup.ts
2026-10-24 02:34:51 +00:00
dev-driver a24237e6d6 test(daemon): pin the agent-seat coalesced wake's unconfirmed outcome
With an agent runtime, verification looks for the message in the agent's
transcript echo, which this pane double doesn't render, so the one
submitted coalesced wake reads indeterminate rather than delivered. It is
still typed once and both original rows keep their bodies.
2026-10-20 10:15:30 +00:00
dev-driver f02dbd49a0 test(daemon): send the draft-aware queue wakes to an agent seat
A queue wake to a terminal seat types nothing before any draft check, so
the four queue-wake tests in draft-aware-delivery now give the worker an
agent runtime. The terminal-seat wake result stays covered in
gateway-destination-routing.
2026-10-20 09:18:55 +00:00
dev-driver aacaa8159e fix(send): stop refusing OpenRig's own notices to a terminal seat
A watchdog or model-monitor notice to a terminal seat is typed as before
again, instead of being refused with terminal_seat. Holding OpenRig's
notices for terminal members, while deliberate sends still go, becomes a
per-seat setting in the interruption-level work, rather than a permanent
refusal here.

The internal openrigNotice marker stays: only the watchdog and the model
monitor set it, no HTTP send can, and it changes nothing by itself. The
two fixes stay as reviewed: a deliberate send to a bound terminal seat
types its exact text without the envelope lines, and a queue reminder to
one types nothing and leaves the task queued (failed:terminal-seat).

Tests: the two refusal-only tests are removed; the terminal-node and
composed-name tests now pin the notice being typed; a new control shows a
marked notice to a bound terminal seat types its text unchanged.
2026-10-20 08:47:26 +00:00
dev-driver b07f76b768 Merge origin/main into fix/terminal-seat-send-exact-text
Conflicts resolved, with #1100's behaviour unchanged:
- session-transport.ts: main's send() now wraps sendWithoutCompactionNotice
  to add the managed-compaction notice. The terminal-seat checks (the
  exact-text swap and the notice refusal) go first in the public send(),
  so their recursive send still gets main's notice. SendResult keeps both
  envelopeOmitted and main's managedCompaction and inputPhase.
- startup.ts: main moved the watchdog's deliver into makeWatchdogDeliver;
  its two sends (the lone wake and the digest) now carry openrigNotice,
  as the inline deliver did.
- routes/transport.ts: both exactText and main's caller signal.
- cli send.ts: both the exact-text note and main's compaction notice.
- arteries.md: main's rows, with #1100's notice sentence.
2026-10-20 08:47:14 +00:00
v-openrig-build 2abe5d4d4a fix(transport): preserve explicit text and classify current terminal bindings 2026-10-17 10:45:35 +00:00
dev-driverandClaude Opus 5.5 46059e6267 test(queue): close the owner-notification test the rebase left open
The rebase that brought in the terminal-seat wake tests dropped the
closing lines of the test and describe block before them, so the file
ended inside a block and failed to load.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 09:20:49 +00:00
dev-driver 6ad55f3bb3 fix(send): choose exact text only for a terminal seat bound to the address being sent to
The exact-text swap used the same terminal-seat lookup as queue wakes, whose newest-session and canonical-name fallback doesn't prove the address is the input a terminal is reading now. Exact text is now selected only when the delivery guard's unique current target is a terminal node whose binding names this tmux session or pane. A moved, unbound or ambiguous target keeps the text it was given: no substitution and no new refusal. Queue wakes and OpenRig's own notices keep the existing lookup.

The owned-shell framing test gains controls for a moved binding, an unbound canonical name, an ambiguous binding, a bound terminal with no session-history row, and an old client's unmarked envelope-looking text.

From dev-guard's patch on the #1100 reuse lineage.
2026-10-17 08:59:49 +00:00
dev-driver 64c07ff3ff test(send): close the wait-for-idle test the rebase left open
The rebase kept both sides of an additive conflict, and main's last wait-for-idle test lost its closing line, which nested the terminal exact-text tests inside it.
2026-10-17 08:59:49 +00:00
v-openrig-build 9d7d40f218 test: reproduce terminal send framing in an owned shell 2026-10-17 08:59:49 +00:00
dev-driver aa379f44b8 fix(send): identify OpenRig's own notices exactly, not by an @system suffix
The terminal-seat refusal matched any actor ending in `@system`, which also
caught an ordinary seat in a rig named `system`. The watchdog and the model
monitor now mark their sends with an internal `openrigNotice` option, which
no HTTP send can set, and only those are refused for a terminal seat. A
person's send keeps the exact-text path whatever its rig is called.
2026-10-17 08:59:49 +00:00
dev-driver cb6087c1ce fix(send): one terminal-seat lookup for the send swap, @system refusal and queue wakes
SessionTransport.send's exact-text swap and @system refusal read only the
exact session row, while the queue's wake check also recognised a seat by
its composed canonical name. send() doesn't require a session row, so a
terminal seat known only by that name still got a person's full envelope
or a watchdog notice. All three now use one isTerminalSeat (newest session
row, else the composed name, false when the database can't answer).

docs/as-built/arteries.md: the delivery artery notes that an OpenRig notice
to a terminal seat is refused too.
2026-10-17 08:59:48 +00:00
dev-driver 12b9a842f3 test(send): migrate watchdog history inline; restate #142's terminal negative
The watchdog test needs watchdog_history, which watchdog suites migrate
inline. #142's negative pinned that a watchdog notice still types into a
terminal node, which is what the previous commit removes. Its point stays:
the bare-shell guard never refuses a terminal node, so a person's send
still types. OpenRig's own watchdog notice is now refused as a terminal
seat.
2026-10-17 08:59:48 +00:00
dev-driver 6da8f5e903 fix(send): OpenRig's own notices never type into a terminal seat
The watchdog (`watchdog@system`) and the model-divergence monitor
(`model-monitor@system`) sent through SessionTransport.send with no terminal
check, so a job or notice aimed at a `runtime: terminal` seat was typed into
its shell. send() now refuses an `@system` actor to a terminal seat before
anything is typed (`reason: terminal_seat`, naming the seat and why). It is
the one place that already knows the runtime, and every `@system` sender
goes through it. Queue wakes stop earlier, in performWakeSend, and a
person's own send keeps its exact text.

The watchdog records the delivery as failed with that reason, and the job
fires again on its schedule with the same refusal. The model monitor marks
that channel failed and still reaches its other targets.
2026-10-17 08:59:48 +00:00
dev-driver f10e343eb6 chore(queue): put hasTerminalTransport's doc comment back on hasTerminalTransport 2026-10-17 08:59:48 +00:00
dev-driver 88cadcbd74 fix(queue): recognise a terminal seat by its composed name too
isTerminalSeat read only the exact sessions row, while hasTerminalTransport
also recognises a bound node by its composed canonical name. A terminal seat
known only by that name was still typed into. It now falls back to the same
composed resolution.

The registered-human-with-a-pane floor keeps its point (pane-bound, never
gateway-owned). Its pane is a terminal node, so its evidence is now the
terminal-seat result rather than a typed send.
2026-10-17 08:59:48 +00:00
dev-driver 061822c816 fix(queue): never type a queue wake into a terminal seat
A queue row or handoff addressed to a `runtime: terminal` seat typed its
wake-up, the envelope plus the row's summary, into that seat's pane. A
summary such as `x; <cmd>` reached a shell prompt as a command line, and
in `rig tui` the paste was run as a TUI command. A terminal seat can't
claim a row, so nothing relies on that wake.

performWakeSend now checks the destination's runtime before building any
text, which covers live wakes, frozen wake intents, resolve bodies and
ladder retries. Nothing is typed, and the row stays queued. The result is
an honest `failed:terminal-seat:` naming why, which keeps the row on the
wake ladder: its retries type nothing, and it then escalates to the
destination's orchestrator.
2026-10-17 08:59:48 +00:00
dev-driver aef37577ed fix(send): validate exactText once at the send route, no non-null assertion 2026-10-17 08:59:48 +00:00
dev-driver 48af9fa8c9 fix(send): a terminal seat gets the exact text, never the From/To envelope
`rig send` to a `runtime: terminal` seat typed the From/To/Sent envelope
into that seat's shell, which ran every line. A terminal recipient now
gets the exact text, as `--raw` gives today. Agent seats keep the
envelope, and the prompt guard is unchanged.

The decision sits in the daemon's SessionTransport.send(), which knows
the recipient's runtime and serves both the send route and the fan-out.
The CLI keeps sending the enveloped `text` and adds `exactText`, so an
older daemon behaves exactly as before. The swap happens before the
typing guard, whose outbox body, hash and delivery-ID identity key on
the text actually typed. The result carries `envelopeOmitted`: the CLI
prints a short note, `--json` carries the field, `--verify` checks the
exact text, and the route's outbox record holds what was typed. The
delivered-latency segment is skipped for exact text, so no line of it
is edited.
2026-10-17 08:59:48 +00:00
13 changed files with 579 additions and 31 deletions

No files matched your search

+2
View File
@@ -519,6 +519,8 @@ All three must be present together. Any partial combination is a validation erro
A terminal seat no longer waits for a `--fresh` decision just because it has no resume token: on restore it relaunches fresh and re-runs its startup actions. The runtime, identity and replay prerequisites still apply. A runtime mismatch still waits for a decision, an ambiguous occupant still fails, and a missing required replay file still blocks.
`rig send` to a terminal node types the exact text, without the From/To envelope that agent seats get, because the shell would run the envelope lines. The sender's output says so.
### agent_ref Rules
- Must start with `local:` or `path:`
+14 -6
View File
@@ -306,7 +306,9 @@ The extra reads add up to about 500 ms before Enter and 500 ms before the reply
A mid-task/busy target now sends-with-advisory by default (busy is not a block);
--force is a back-compat no-op and never bypasses the interactive-prompt/permission
guard. Use --raw to send exact text/keystrokes
without the From/To envelope (e.g. a slash command); it is still guarded. Use
without the From/To envelope (e.g. a slash command); it is still guarded. A
terminal seat (runtime: terminal) always gets the exact text, because its shell
would run the envelope lines; the output says so. Use
--dangerously-interact --reason "<why>" to DELIBERATELY drive a prompt (select an
option or approve a permission) — the only override of the prompt guard; it
implies --raw and is audit-logged. The answer is typed as keystrokes: letters may
@@ -518,7 +520,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
let res: { status: number; data: Record<string, unknown> };
try {
res = await client.post<Record<string, unknown>>("/api/transport/send", {
session, text: outboundText, verify: opts.verify, force: opts.force, waitForIdleMs,
session, text: outboundText, exactText: raw ? undefined : payload, verify: opts.verify, force: opts.force, waitForIdleMs,
dangerouslyInteract: opts.dangerouslyInteract, typeWithoutChecking: opts.typeWithoutChecking, reason: opts.reason, actorSession: senderSession ?? null,
...(opts.urgent ? { urgent: true } : {}),
}, transportRequestOptions(waitForIdleMs));
@@ -550,7 +552,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
// return alone when --verify asked for consumption.
let effect: EffectCheck | undefined;
if (opts.verify && res.status < 400) {
effect = await classifyDeliveryEffect(client, session, stagedIdentityFor(payload, outboundText), waitForIdleMs, res.data["promptInteraction"], opts.dangerouslyInteract);
effect = await classifyDeliveryEffect(client, session, stagedIdentityFor(payload, res.data["envelopeOmitted"] ? payload : outboundText), waitForIdleMs, res.data["promptInteraction"], opts.dangerouslyInteract);
}
if (opts.json) {
@@ -580,6 +582,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
}
console.log(`Sent to ${session}`);
if (res.data["envelopeOmitted"]) console.log(exactTextNote(session));
printCompactionNotice(console.log);
// OPR.0.4.3.28 correction — an `unknown`-telemetry send now PROCEEDS with a non-blocking
// advisory (was a fail-closed refusal). Surface it on the human output, not only in --json.
@@ -644,6 +647,10 @@ interface StagedIdentity {
payloadHead: string;
}
function exactTextNote(session: string): string {
return `Note: sent as exact text, without the From/To envelope, because ${session} is a terminal seat.`;
}
function stagedIdentityFor(payload: string, expectedStagedText: string): StagedIdentity {
return {
expectedStagedText,
@@ -907,7 +914,7 @@ async function runHttpHostSend(
const outboundText = raw ? text : wrapSendBody(originSender, session, text, { stampISO: new Date().toISOString() });
const result = await runRemoteHttpOp(host.id, "POST", "/api/transport/send", {
session, text: outboundText, verify: opts.verify, force: opts.force, waitForIdleMs,
session, text: outboundText, exactText: raw ? undefined : text, verify: opts.verify, force: opts.force, waitForIdleMs,
dangerouslyInteract: opts.dangerouslyInteract, typeWithoutChecking: opts.typeWithoutChecking, reason: opts.reason, actorSession: senderSession ?? null,
...(opts.urgent ? { urgent: true } : {}),
}, deps, { timeoutMs: (waitForIdleMs ?? 0) + CROSS_HOST_SEND_TIMEOUT_MS });
@@ -952,6 +959,7 @@ async function runHttpHostSend(
return;
}
console.log(`Sent to ${session}`);
if (data["envelopeOmitted"]) console.log(exactTextNote(session));
const advisory = data["warning"] as string | undefined;
if (advisory) {
console.log(`Advisory: ${advisory}`);
@@ -1101,7 +1109,7 @@ async function runFanOutSend(params: {
}
const data = res.data;
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; outcome?: string; managedCompaction?: { notice?: unknown }; existingInput?: unknown }>) ?? [];
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; outcome?: string; envelopeOmitted?: boolean; managedCompaction?: { notice?: unknown }; existingInput?: unknown }>) ?? [];
// Context beside a recipient's own result: it was in a managed compaction or restore when the send ran.
const printNotice = (r: (typeof results)[number]) => {
if (typeof r.managedCompaction?.notice === "string") console.log(`Notice: ${r.managedCompaction.notice}`);
@@ -1127,7 +1135,7 @@ async function runFanOutSend(params: {
} else if (ec && !ec.checked) {
console.log(`${r.sessionName}: sent (effect UNCHECKED: ${ec.why} — transport verdict only)`);
} else {
console.log(`${r.sessionName}: sent`);
console.log(`${r.sessionName}: sent${r.envelopeOmitted ? " as exact text, without the From/To envelope (terminal seat)" : ""}`);
}
printNotice(r);
}
@@ -218,6 +218,7 @@ describe("send --host (http branch)", () => {
expect(body.session).toBe("dev-impl@my-rig"); // BR-1: never member@rig@host
expect(String(body.text)).toContain("To: dev-impl@my-rig");
expect(String(body.text)).toContain("hello");
expect(body.exactText).toBe("hello"); // the remote daemon types this if the seat is a terminal
expect(call.options?.headers?.Authorization).toBe("Bearer test-token");
expect(captured.stdoutLines).toContain("[via host=vps-b (http://vps-b:7433)]");
expect(captured.stdoutLines).toContain("Sent to dev-impl@my-rig");
+41
View File
@@ -232,6 +232,47 @@ describe("Send CLI", () => {
expect(logs.join("\n")).toContain("Sent to dev-impl@my-rig");
});
it("a terminal seat: sends exactText beside the envelope, prints the note, and --verify checks the exact text", async () => {
const posts: Array<{ path: string; body: Record<string, unknown> }> = [];
let captures = 0;
const client = {
post: async (path: string, body: Record<string, unknown>) => {
posts.push({ path, body });
if (path === "/api/transport/capture") {
captures += 1;
return { status: 200, data: { ok: true, content: captures === 1 ? "❯ make test" : "❯ " } };
}
if (body["submitOnly"]) return { status: 200, data: { ok: true, submitOnly: true } };
return { status: 200, data: { ok: true, sessionName: "infra-ui@term-rig", verified: true, outcome: "delivered", envelopeOmitted: true } };
},
} as unknown as DaemonClient;
const { logs } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "infra-ui@term-rig", "make test", "--verify",
]);
});
const sends = posts.filter((p) => p.path === "/api/transport/send");
// An older daemon ignores exactText and still types the enveloped text.
expect(sends[0]!.body["text"]).toContain("From: sender@my-rig");
expect(sends[0]!.body["exactText"]).toBe("make test");
// The staged check and its one guarded Enter look for what the terminal seat was given.
const submit = sends.find((p) => p.body["submitOnly"]);
expect(submit?.body["expectedStagedText"]).toBe("make test");
const output = logs.join("\n");
expect(output).toContain("Sent to infra-ui@term-rig");
expect(output).toContain("Note: sent as exact text, without the From/To envelope, because infra-ui@term-rig is a terminal seat.");
});
it("--raw sends no exactText", async () => {
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "send", "dev-impl@my-rig", "/help", "--raw"]);
});
expect(lastSendBody!["text"]).toBe("/help");
expect(lastSendBody!["exactText"]).toBeUndefined();
});
it.each(["", " \t\n"])("refuses an empty or whitespace-only direct message before transport", async (message) => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "send", "dev-impl@my-rig", message]);
@@ -13,6 +13,7 @@ import type { QueueWakeFacts } from "./draft-aware-delivery.js";
import { derivePickup, type PickupReceipt } from "./queue-pickup.js";
import { lastMeaningfulTransition, readWaitingView, type WaitingView, type WaitingActivityReader } from "./queue-waiting.js";
import { wrapPaneEnvelope } from "../lib/pane-envelope.js";
import { isBoundTerminalSeat } from "./terminal-seat.js";
import { renderQueueHandoffNudge } from "./queue-nudge-text.js";
import { getSelfHostId } from "./hosts/fanout-contract.js";
import { parseSessionName, isHumanSeatSessionRef } from "./session-name.js";
@@ -1596,6 +1597,16 @@ export class QueueRepository {
if (destClass.class === "unroutable") {
return { classified: "failed", nudgeResult: destClass.teaching };
}
// A terminal seat runs a shell or a TUI, not an agent. Typed wake text would run there as a
// command line (it carries the row's summary), and nothing there can claim the row. So nothing
// is typed; the row stays queued and visible. `failed:` keeps it on the wake ladder, whose
// retries come back here and type nothing before it escalates.
if (isBoundTerminalSeat(this.db, destinationSession)) {
return {
classified: "failed",
nudgeResult: `failed:terminal-seat: '${destinationSession}' is a terminal seat (runtime: terminal), not an agent, so no wake was typed into it and nothing there can claim this row. The row stays queued; route it to an agent seat.`,
};
}
const stampISO = new Date().toISOString();
let text: string;
if (prebuiltText !== undefined) {
@@ -36,6 +36,7 @@ import type { AgentActivity } from "./types.js";
import { DeliveryGuardError, type AdmissionInput, type GuardTarget, type SeatDeliveryGuard } from "./seat-delivery-guard.js";
import { withUrgentLine } from "./urgent-line.js";
import { wrapPaneEnvelope, appendDeliveredSegment, type EnvelopeScope } from "../lib/pane-envelope.js";
import { isBoundTerminalSeat } from "./terminal-seat.js";
import { getSelfHostId } from "./hosts/fanout-contract.js";
import { SeatIdentityStore } from "./seat-identity-store.js";
import { isShellForeground } from "./shell-classifier.js";
@@ -1640,6 +1641,13 @@ export interface SendOpts {
* renders as "[Pasted text #N +X lines]"; X must match this count for the placeholder to count
* as evidence of THIS piece. */
expectedStagedLineCount?: number;
/** The text without the From/To envelope. When set and the recipient is a terminal seat
* (`runtime: terminal`), send() types this instead of `text`, so the shell never runs the
* envelope lines. Agent recipients keep `text`. */
exactText?: string;
/** Internal: send() is typing `exactText` to a terminal seat, so its bytes stay exactly as given: no urgent line, which
* the shell would run. An urgent mark still admits the input and stays in the result. Never accepted from HTTP. */
typedExactly?: true;
}
const CALLER_CANCELLED = "caller_cancelled";
@@ -1717,6 +1725,8 @@ export interface SendResult {
waitedMs?: number;
attempts?: number;
sent?: boolean;
/** The recipient is a terminal seat, so `exactText` was typed without the From/To envelope. */
envelopeOmitted?: true;
/** Context, not a status: the seat was in a managed compaction or restore OpenRig had recorded when this send ran. The
* send's own result (ok, outcome, error) is unchanged by it, and it never says the restore finished. */
managedCompaction?: { stage: string; observed: "before_send" | "after_send"; notice: string };
@@ -2205,6 +2215,15 @@ export class SessionTransport {
* send, add a notice saying so. The notice is context beside the send's own result, which it never changes.
*/
async send(sessionName: string, text: string, opts?: SendOpts): Promise<SendResult> {
// A terminal seat's shell would run the envelope lines as commands. Swap before the guard,
// whose outbox body, hash and delivery-ID identity all key on the text actually typed.
// Without the envelope there's no Sent: line, so stampISO is dropped too: the delivered-latency
// segment must never edit a line of the exact text.
if (opts?.exactText !== undefined && !opts.dangerouslyInteract) {
const { exactText, ...rest } = opts;
if (!isBoundTerminalSeat(this.db, sessionName)) return this.send(sessionName, text, rest);
return { ...(await this.send(sessionName, exactText, { ...rest, stampISO: undefined, typedExactly: true })), envelopeOmitted: true };
}
const observe = () => { try { return this.compactionObserver?.(sessionName) ?? null; } catch { return null; } };
const before = observe();
const result = await this.sendWithoutCompactionNotice(sessionName, text, opts);
@@ -2220,8 +2239,9 @@ export class SessionTransport {
// Shown on delivery, and kept with a held message: who marked it urgent.
console.info(`[transport] urgent send to ${sessionName}, marked by ${opts.urgent.by}`);
// An explicit prompt answer is typed as keys, and a submit-only retry types no text at all: the label's letters would
// act as keystrokes (a menu shortcut, say). Their bytes stay exactly as given; the mark stays in the result and the log.
const rawKeys = opts.dangerouslyInteract === true || opts.submitOnly === true;
// act as keystrokes (a menu shortcut, say). A terminal seat's exact text would run the label as a command. Their bytes
// stay exactly as given; the mark stays in the result and the log.
const rawKeys = opts.dangerouslyInteract === true || opts.submitOnly === true || opts.typedExactly === true;
const result = await this.sendClassified(sessionName, rawKeys ? text : withUrgentLine(text, opts.urgent.by), opts);
return { ...result, urgent: opts.urgent };
}
@@ -3781,6 +3801,7 @@ export class SessionTransport {
// (h) thread the resolved stampISO so send()'s delivered-latency calc measures from the SAME
// compose stamp the envelope carries (opts may not have carried one; the local stampISO is truth).
const result = await this.send(session.sessionName, perRecipientText, { ...opts, stampISO,
exactText: opts?.envelopeSender ? text : undefined,
deliveryId: opts?.deliveryId ? `${opts.deliveryId}:${session.sessionName}` : undefined });
results.push(result);
}
@@ -0,0 +1,40 @@
import type Database from "better-sqlite3";
import { resolveGuardTarget } from "./seat-delivery-guard.js";
/** Exact-text selection needs the same unique current target as input delivery,
* plus a binding to the actual tmux address. Historical rows and unbound
* canonical names alone do not establish what is receiving this input. */
export function isBoundTerminalSeat(db: Database.Database, sessionName: string): boolean {
try {
const target = resolveGuardTarget(db, sessionName);
if (!target) return false;
const current = db.prepare(
`SELECT n.runtime AS runtime FROM nodes n JOIN bindings b ON b.node_id = n.id
WHERE n.id = ? AND (b.tmux_session = ? OR b.tmux_pane = ?)`,
).get(target.nodeId, sessionName, sessionName) as { runtime: string | null } | undefined;
return current?.runtime === "terminal";
} catch {
return false;
}
}
/** A `runtime: terminal` node, by its newest session row or else its composed canonical name
* (`<logical id with dashes>@<rig>`). A database that can't answer reads as not terminal. */
export function isTerminalSeat(db: Database.Database, sessionName: string): boolean {
try {
const exact = db.prepare(
`SELECT n.runtime AS runtime FROM sessions s JOIN nodes n ON n.id = s.node_id
WHERE s.session_name = ? ORDER BY s.id DESC LIMIT 1`,
).get(sessionName) as { runtime: string | null } | undefined;
if (exact) return exact.runtime === "terminal";
const at = sessionName.lastIndexOf("@");
if (at <= 0) return false;
const composed = db.prepare(
`SELECT n.runtime AS runtime FROM nodes n JOIN rigs r ON r.id = n.rig_id
WHERE r.name = ? AND REPLACE(n.logical_id, '.', '-') = ? LIMIT 1`,
).get(sessionName.slice(at + 1), sessionName.slice(0, at)) as { runtime: string | null } | undefined;
return composed?.runtime === "terminal";
} catch {
return false;
}
}
+5 -1
View File
@@ -41,6 +41,8 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
submitOnly?: boolean;
expectedStagedText?: string;
expectedStagedLineCount?: number;
/** The unwrapped text; typed instead of `text` when the recipient is a terminal seat. */
exactText?: string;
urgent?: boolean;
}>();
@@ -101,6 +103,7 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
return c.json({ ok: false, error: resolved.error }, status);
}
const exactText = typeof body.exactText === "string" ? body.exactText : undefined;
const result = await transport.send(body.session, body.text ?? "", {
deliveryId: body.deliveryId,
verify: body.verify,
@@ -118,6 +121,7 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
submitOnly: body.submitOnly,
expectedStagedText: body.expectedStagedText,
expectedStagedLineCount: body.expectedStagedLineCount,
exactText,
// The caller's request: if it goes away while the send still waits, nothing is typed later.
signal: c.req.raw.signal,
});
@@ -183,7 +187,7 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
const entry = outbox.record({
senderSession: derivedActor,
destinationSession: body.session,
body: body.text,
body: result.envelopeOmitted && exactText !== undefined ? exactText : body.text,
identityProvenance: "transport:v1",
});
// The input was dispatched successfully; mark its audit row as broadcast does.
@@ -48,6 +48,9 @@ function fixture(saved?: Buffer, opts: { runtime?: string; idleHook?: boolean }
const observations: unknown[] = [];
const hooks: { load?: () => void | Promise<void>; paste?: () => void | Promise<void>; sleep?: (ms: number) => void | Promise<void> } = {};
let staged = "";
// A terminal pane shows a submitted line as typed; an agent pane echoes a sent message under its prompt, as Claude does.
const echo = (node: string, body: string) => (db.prepare("SELECT runtime FROM nodes WHERE id=?").get(node) as { runtime: string }).runtime === "terminal"
? `${body}\n` : `${body.split("\n").map((row, i) => (i ? " " : "❯ ") + row).join("\n")}\n`;
const tmux = new TmuxAdapter(async command => {
if (command.includes("load-buffer")) await hooks.load?.();
const node = command.includes("%2") ? "b" : "a";
@@ -58,7 +61,7 @@ function fixture(saved?: Buffer, opts: { runtime?: string; idleHook?: boolean }
if (command.includes("send-keys")) {
writes.push(`keys:${node}:${command}`);
if (command.includes("Enter")) {
submissions.push(bodies[node] ?? ""); history[node] += `${bodies[node]}\n`; bodies[node] = "";
submissions.push(bodies[node] ?? ""); history[node] += echo(node, bodies[node] ?? ""); bodies[node] = "";
}
}
return "";
@@ -107,7 +110,7 @@ describe("current draft-aware delivery through the real transport", () => {
});
it("submits its delayed collapsed paste once, leaving the next queue wake deliverable", async () => {
const f = fixture(); await f.enable();
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable();
const text = "first line\nsecond line\nlast line";
let readsAfterPaste = 0;
vi.mocked(f.tmux.captureComposerSnapshot).mockImplementation(async name => {
@@ -342,7 +345,7 @@ describe("current draft-aware delivery through the real transport", () => {
describe("supported seat and queue integration", () => {
it.each(["failed", "indeterminate", "sending"])("startup recovery attempts a still-current %s queue wake", async state => {
const f = fixture(); await f.enable();
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable();
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "queued work", nudge: false });
f.repo.stageWakeIntent(item.qitemId, "sender@test", "worker@test", null, true);
const id = "wake-intent-" + item.qitemId;
@@ -364,7 +367,7 @@ describe("supported seat and queue integration", () => {
});
it.each(["delivered", "retained", "retired"])("startup recovery leaves a %s queue wake alone", async state => {
const f = fixture(); await f.enable();
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable();
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "queued work", nudge: false });
f.repo.stageWakeIntent(item.qitemId, "sender@test", "worker@test", null, true);
const id = "wake-intent-" + item.qitemId;
@@ -378,7 +381,7 @@ describe("supported seat and queue integration", () => {
it.each(["closed", "claimed", "reassigned", "absent-pane", "replacement"].flatMap(state =>
[false, true].map(draftAware => ({ state, draftAware }))))("recovery does not paste for $state work (draft-aware: $draftAware)", async ({ state, draftAware }) => {
const f = fixture(); if (draftAware) await f.enable();
const f = fixture(undefined, { runtime: "claude-code" }); if (draftAware) await f.enable();
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "queued work", nudge: false });
f.repo.stageWakeIntent(item.qitemId, "sender@test", "worker@test", null, true);
const id = "wake-intent-" + item.qitemId;
@@ -396,7 +399,7 @@ describe("supported seat and queue integration", () => {
});
it("tries a failed recovery once per pass even when the native result stays unknown", async () => {
const f = fixture(); await f.enable();
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable();
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "queued work", nudge: false });
f.repo.stageWakeIntent(item.qitemId, "sender@test", "worker@test", null, true);
const id = "wake-intent-" + item.qitemId;
@@ -410,7 +413,7 @@ describe("supported seat and queue integration", () => {
});
it("keeps earlier uncertainty when a recovery fails before input", async () => {
const f = fixture();
const f = fixture(undefined, { runtime: "claude-code" });
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "work", nudge: false });
const id = "wake-intent-" + item.qitemId;
f.repo.stageWakeIntent(item.qitemId, "sender@test", "worker@test", null, true);
@@ -423,7 +426,7 @@ describe("supported seat and queue integration", () => {
});
it("does not describe an earlier attempt for a never-attempted pending wake", async () => {
const f = fixture();
const f = fixture(undefined, { runtime: "claude-code" });
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "work", nudge: false });
f.repo.stageWakeIntent(item.qitemId, "sender@test", "worker@test", null, true);
await f.repo.drainPendingWakeIntents();
@@ -432,7 +435,7 @@ describe("supported seat and queue integration", () => {
});
it("reads current recovery candidates without visiting historical uncertain notifications", async () => {
const f = fixture();
const f = fixture(undefined, { runtime: "claude-code" });
const old = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "old work", nudge: false });
f.db.prepare("UPDATE queue_items SET state='done' WHERE qitem_id=?").run(old.qitemId);
f.db.transaction(() => { for (let i = 0; i < 600; i++) f.outbox.record({ outboxId: `wake-intent-history-${i}`, senderSession: "sender@test", destinationSession: "worker@test", body: "historical", auditPointer: old.qitemId }); })();
@@ -448,7 +451,7 @@ describe("supported seat and queue integration", () => {
it.each(["draft_input_changed", "delivery_indeterminate", "submit_failed", "send_failed"])(
"keeps a committed wake's %s outcome indeterminate in the queue", async reason => {
const f = fixture(); await f.enable();
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable();
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "queued work", nudge: false });
const id = "wake-intent-" + item.qitemId;
f.repo.stageWakeIntent(item.qitemId, "sender@test", "worker@test", null, true);
@@ -468,7 +471,7 @@ describe("supported seat and queue integration", () => {
);
it.each(["ladder", "legacy-boot"])("delivers a %s nudge without a precommitted outbox entry", async path => {
const f = fixture(); await f.enable();
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable();
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "queued work", nudge: false });
if (path === "ladder") await f.repo.maybeNudge(item.qitemId, "worker@test", true, "sender@test");
else await f.repo.recoverLostNudge(item.qitemId, "worker@test", "sender@test");
@@ -479,7 +482,7 @@ describe("supported seat and queue integration", () => {
});
it.each(["current", "closed", "reassigned"])("checks the %s queue facts without saved delivery bookkeeping", async state => {
const f = fixture(); await f.enable();
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable();
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "queued work", nudge: false });
f.db.exec("CREATE TRIGGER unavailable_nudge_receipt BEFORE UPDATE OF guard_delivery ON outbox_entries BEGIN SELECT RAISE(ABORT, 'fixture receipt unavailable'); END;");
f.hooks.load = () => {
@@ -549,8 +552,9 @@ describe("supported seat and queue integration", () => {
expect(f.submissions).toEqual(["sibling message", "incoming message"]);
});
// Queue wakes go to an agent seat: a wake to a terminal seat types nothing before any draft check (gateway-destination-routing covers it).
it("a held queue wake remains on the existing queue recovery path without a second scheduler", async () => {
const f = fixture(); await f.enable(); f.bodies.a = "draft";
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable(); f.bodies.a = "draft";
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "queued work" });
await vi.waitFor(() => expect(f.repo.getById(item.qitemId)!.lastNudgeResult).toBe("retained:draft_aware"));
const id = "wake-intent-" + item.qitemId;
@@ -561,7 +565,7 @@ describe("supported seat and queue integration", () => {
});
it("rechecks queue applicability before paste", async () => {
const f = fixture(); await f.enable();
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable();
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "queued work", nudge: false });
const id = "wake-intent-" + item.qitemId; f.repo.stageWakeIntent(item.qitemId, "sender@test", "worker@test", null, true);
f.hooks.load = () => { f.db.prepare("UPDATE queue_items SET state='done' WHERE qitem_id=?").run(item.qitemId); };
@@ -571,7 +575,7 @@ describe("supported seat and queue integration", () => {
});
it.each([false, true])("coalesced wakes preserve every original ID and body (held: %s)", async held => {
const f = fixture(); await f.enable(); if (held) f.bodies.a = "draft"; const ids: string[] = [];
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable(); if (held) f.bodies.a = "draft"; const ids: string[] = [];
for (let i = 0; i < 2; i++) {
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "work " + i, nudge: false });
f.repo.stageWakeIntent(item.qitemId, "sender@test", "worker@test", null, true);
@@ -28,6 +28,8 @@ import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js"
import { queueTransitionsArchiveSchema } from "../src/db/migrations/054_queue_transitions_archive.js";
import { ownerNotificationLevelsSchema } from "../src/db/migrations/076_owner_notification_levels.js";
import { externalCliAttachmentSchema } from "../src/db/migrations/019_external_cli_attachment.js";
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
import { occupantTenuresSchema } from "../src/db/migrations/060_occupant_tenures.js";
import { EventBus } from "../src/domain/event-bus.js";
import { OutboxHandler } from "../src/domain/outbox-handler.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
@@ -58,7 +60,7 @@ function makeHarness(opts?: { registryReadable?: () => boolean; entities?: () =>
migrate(db, [
coreSchema, bindingsSessionsSchema, externalCliAttachmentSchema, eventsSchema, queueItemsSchema,
queueTransitionsSchema, outboxEntriesSchema, queueTransitionsArchiveSchema,
ownerNotificationLevelsSchema,
ownerNotificationLevelsSchema, rigArchiveSchema, occupantTenuresSchema,
]);
const bus = new EventBus(db);
const sends: Array<{ session: string; text: string }> = [];
@@ -184,8 +186,9 @@ describe("OPR.0.5.6.14 — one destination resolver, no fall-through", () => {
body: "pane-backed founder seat",
});
await vi.waitFor(() => expect(h.repo.getById(item.qitemId)!.lastNudgeResult).not.toBeNull());
expect(h.sends.map((send) => send.session)).toEqual(["human-founder@rig1"]);
expect(h.repo.getById(item.qitemId)!.lastNudgeResult).toMatch(/^failed:/);
// Pane-bound, not gateway-owned. The pane is a terminal seat, so the wake is not typed into it.
expect(h.sends).toHaveLength(0);
expect(h.repo.getById(item.qitemId)!.lastNudgeResult).toMatch(/^failed:terminal-seat: /);
});
it("PANELLESS TOPOLOGY IS NOT A TERMINAL: an external_cli-bound registered human stays gateway-routable", async () => {
@@ -618,3 +621,77 @@ describe("GET /api/queue/:qitemId carries the owner notification level", () => {
h.db.close();
});
});
// A terminal seat runs a shell or a TUI, not an agent: a typed wake would run as a command line.
describe("a terminal seat never gets a typed queue wake", () => {
let h: ReturnType<typeof makeHarness>;
beforeEach(() => { h = makeHarness(); });
function seedTerminalSeat(rig: string, logicalId: string, session: string) {
h.db.prepare("INSERT OR IGNORE INTO rigs (id, name) VALUES (?, ?)").run(`rig-${rig}`, rig);
const { id: rigId } = h.db.prepare("SELECT id FROM rigs WHERE name = ?").get(rig) as { id: string };
h.db.prepare("INSERT INTO nodes (id, rig_id, logical_id, runtime) VALUES (?, ?, ?, 'terminal')").run(`node-${logicalId}`, rigId, logicalId);
h.db.prepare("INSERT INTO sessions (id, node_id, session_name) VALUES (?, ?, ?)").run(`sess-${logicalId}`, `node-${logicalId}`, session);
h.db.prepare("INSERT INTO bindings (id, node_id, attachment_type, tmux_session) VALUES (?, ?, 'tmux', ?)").run(`binding-${logicalId}`, `node-${logicalId}`, session);
}
it.each([
["a shell seat", "rig1", "infra.shell", "infra-shell@rig1"],
["the kernel's operator.human (rig tui)", "kernel", "operator.human", "operator-human@kernel"],
])("%s: nothing is typed, the result is an honest failed:, the row stays queued, and a retry types nothing", async (_label, rig, logicalId, session) => {
seedTerminalSeat(rig, logicalId, session);
const item = await h.repo.create({
sourceSession: "dev-a@rig1", destinationSession: session,
summary: "x; touch /tmp/should-not-run", evidenceRef: EVIDENCE, body: "preserve these bytes",
});
await vi.waitFor(() => expect(h.repo.getById(item.qitemId)!.lastNudgeResult).not.toBeNull());
expect(h.sends).toHaveLength(0);
expect(h.repo.getById(item.qitemId)).toMatchObject({
state: "pending", body: "preserve these bytes", lastNudgeResult: expect.stringMatching(/^failed:terminal-seat: /),
});
// The wake ladder retries through maybeNudge.
await h.repo.maybeNudge(item.qitemId, session, true, "dev-a@rig1");
expect(h.sends).toHaveLength(0);
expect(h.repo.getById(item.qitemId)!.lastNudgeResult).toMatch(/^failed:terminal-seat: /);
});
it("a terminal seat known only by its composed name (binding, no session row) gets no typed wake", async () => {
h.db.prepare("INSERT INTO nodes (id, rig_id, logical_id, runtime) VALUES ('node-logs', 'rig-1', 'infra.logs', 'terminal')").run();
h.db.prepare("INSERT INTO bindings (id, node_id, attachment_type, tmux_session) VALUES ('binding-logs', 'node-logs', 'tmux', 'infra-logs@rig1')").run();
const item = await h.repo.create({
sourceSession: "dev-a@rig1", destinationSession: "infra-logs@rig1",
summary: "x; touch /tmp/should-not-run", evidenceRef: EVIDENCE, body: "composed seat",
});
await vi.waitFor(() => expect(h.repo.getById(item.qitemId)!.lastNudgeResult).not.toBeNull());
expect(h.sends).toHaveLength(0);
expect(h.repo.getById(item.qitemId)!.lastNudgeResult).toMatch(/^failed:terminal-seat: /);
});
it("an agent seat still gets its typed wake", async () => {
seedTerminalSeat("rig1", "infra.shell", "infra-shell@rig1");
const item = await h.repo.create({
sourceSession: "infra-shell@rig1", destinationSession: "dev-a@rig1",
summary: "agent work", evidenceRef: EVIDENCE, body: "for the agent",
});
await vi.waitFor(() => expect(h.repo.getById(item.qitemId)!.lastNudgeResult).toBe("verified"));
expect(h.sends).toHaveLength(1);
expect(h.sends[0]!.session).toBe("dev-a@rig1");
expect(h.sends[0]!.text).toContain("From: infra-shell@rig1");
expect(h.sends[0]!.text).toContain(item.qitemId);
});
it("a currently bound agent with older terminal history gets its wake and retry", async () => {
seedTerminalSeat("rig1", "old.shell", "dev-a@rig1");
// Retain the old terminal session row, but its binding has moved elsewhere.
h.db.prepare("UPDATE bindings SET tmux_session='old-shell@rig1' WHERE node_id='node-old.shell'").run();
const item = await h.repo.create({ sourceSession: "dev-b@rig1", destinationSession: "dev-a@rig1",
summary: "current agent work", evidenceRef: EVIDENCE, body: "for the current occupant" });
await vi.waitFor(() => expect(h.repo.getById(item.qitemId)!.lastNudgeResult).not.toBeNull());
expect(h.repo.getById(item.qitemId)!.lastNudgeResult).toBe("verified");
expect(h.sends).toHaveLength(1);
await h.repo.maybeNudge(item.qitemId, "dev-a@rig1", true, "dev-b@rig1");
expect(h.repo.getById(item.qitemId)!.lastNudgeResult).toBe("verified");
expect(h.sends).toHaveLength(2);
expect(h.sends.every(send => send.session === "dev-a@rig1")).toBe(true);
});
});
@@ -54,7 +54,7 @@ describe("#142 transport refuses to type into a bare shell where an agent runtim
// The watchdog's deliver() makes exactly this call (startup.ts parked-owner delivery).
const watchdogSend = (transport: SessionTransport, name: string) =>
transport.send(name, "[OpenRig watchdog scheduler · policy: parked-owner-consumer] You are parked", {
deliveryId: "guard-watchdog-job-1", actorSession: "watchdog@system", auditPointer: "job-1",
deliveryId: "guard-watchdog-job-1", actorSession: "watchdog@system", auditPointer: "job-1", openrigNotice: true,
});
it.each([["claude-code", "zsh"], ["codex", "-bash"]])("%s seat showing %s: refused, nothing typed", async (runtime, shell) => {
@@ -315,13 +315,18 @@ describe("#142 transport refuses to type into a bare shell where an agent runtim
}
});
it("negative: a terminal node's shell is its runtime, so it still receives text", async () => {
it("negative: a terminal node's shell is its runtime, so a person's send and OpenRig's own notice both still type", async () => {
seat("terminal", "ops-human@my-rig");
const { tmux, sendText } = tmuxWithPane(async () => "zsh");
const result = await watchdogSend(new SessionTransport({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux }), "ops-human@my-rig");
const transport = new SessionTransport({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux });
expect(result.ok).toBe(true);
const personal = await transport.send("ops-human@my-rig", "printf 'hello\\n'", { actorSession: "dev-impl@my-rig" });
expect(personal.ok).toBe(true);
expect(sendText).toHaveBeenCalledOnce();
const watchdog = await watchdogSend(transport, "ops-human@my-rig");
expect(watchdog.ok).toBe(true);
expect(sendText).toHaveBeenCalledTimes(2);
});
it.each([["unknown", async () => null], ["unreadable", async () => { throw new Error("tmux failed"); }]])(
@@ -2676,6 +2676,107 @@ describe("SessionTransport", () => {
expect(sendTextSpy).not.toHaveBeenCalled();
} finally { clock.restore(); }
});
// A terminal seat's shell runs every typed line, so it gets the exact text, never the envelope.
function seedTerminalSeat() {
const rig = rigRepo.createRig("term-rig");
const node = rigRepo.addNode(rig.id, "infra.ui", { role: "ui", runtime: "terminal" });
const session = sessionRegistry.registerSession(node.id, "infra-ui@term-rig");
sessionRegistry.updateStatus(session.id, "running");
sessionRegistry.updateBinding(node.id, { tmuxSession: "infra-ui@term-rig" });
}
const HEREDOC = "cat <<'EOF'\nSent: as typed\nEOF";
it("send to a terminal seat types exactText exactly, with no envelope and no delivered segment", async () => {
seedTerminalSeat();
const sent: string[] = [];
const tmux = mockTmux({ getPaneCommand: async () => "zsh", sendText: async (_t, text) => { sent.push(text); return { ok: true }; } });
// The write moment is 30s after the compose stamp: an enveloped send would gain "delivered +30s".
const transport = createTransport(tmux, { now: () => new Date("2026-08-06T17:42:39Z") });
const result = await transport.send("infra-ui@term-rig", H_ENVELOPE, { exactText: HEREDOC, stampISO: "2026-08-06T17:42:09Z" });
expect(result.ok).toBe(true);
expect(result.envelopeOmitted).toBe(true);
expect(sent).toEqual([HEREDOC]);
});
it("send to an agent seat ignores exactText and keeps the envelope", async () => {
seedCanonicalRig();
const sent: string[] = [];
const transport = createTransport(mockTmux({ sendText: async (_t, text) => { sent.push(text); return { ok: true }; } }));
const result = await transport.send("dev-impl@my-rig", H_ENVELOPE, { exactText: "hi" });
expect(result.ok).toBe(true);
expect(result.envelopeOmitted).toBeUndefined();
expect(sent).toEqual([H_ENVELOPE]);
});
it("enveloped fan-out to a terminal seat and an agent seat gives each the right text", async () => {
seedCanonicalRig(); // dev-impl@my-rig, claude-code
seedTerminalSeat(); // infra-ui@term-rig, terminal
const sent = new Map<string, string>();
const tmux = mockTmux({ getPaneCommand: async () => "zsh", sendText: async (target, text) => { sent.set(target, text); return { ok: true }; } });
const transport = createTransport(tmux);
const result = await transport.broadcast(
{ sessions: ["dev-impl@my-rig", "infra-ui@term-rig"] },
"make test",
{ envelopeSender: "orch@my-rig", stampISO: "2026-08-06T17:42:09Z" },
);
expect(result.sent).toBe(2);
expect(sent.get("infra-ui@term-rig")).toBe("make test");
expect(sent.get("dev-impl@my-rig")).toContain("From: orch@my-rig");
expect(sent.get("dev-impl@my-rig")).toContain("make test");
const byName = new Map(result.results.map((r) => [r.sessionName, r]));
expect(byName.get("infra-ui@term-rig")?.envelopeOmitted).toBe(true);
expect(byName.get("dev-impl@my-rig")?.envelopeOmitted).toBeUndefined();
});
it("a person's send (a seat actor) to a terminal seat still gets the exact text", async () => {
seedTerminalSeat();
const sent: string[] = [];
const transport = createTransport(mockTmux({ getPaneCommand: async () => "zsh", sendText: async (_t, text) => { sent.push(text); return { ok: true }; } }));
const result = await transport.send("infra-ui@term-rig", H_ENVELOPE, { exactText: "make test", actorSession: "dev-impl@my-rig" });
expect(result).toMatchObject({ ok: true, envelopeOmitted: true });
expect(sent).toEqual(["make test"]);
});
it("an urgent send to a terminal seat types the exact text without the urgent line, and keeps the mark", async () => {
seedTerminalSeat();
const sent: string[] = [];
const transport = createTransport(mockTmux({ getPaneCommand: async () => "zsh", sendText: async (_t, text) => { sent.push(text); return { ok: true }; } }));
const result = await transport.send("infra-ui@term-rig", H_ENVELOPE, { exactText: "make test", actorSession: "dev-impl@my-rig", urgent: { by: "dev-impl@my-rig" } });
expect(result).toMatchObject({ ok: true, envelopeOmitted: true, urgent: { by: "dev-impl@my-rig" } });
expect(sent).toEqual(["make test"]);
});
it("a terminal seat known only by its composed name (binding, no session row): exact text for a person; a watchdog notice carries only its marker (with no delivery guard here, it types as given)", async () => {
const rig = rigRepo.createRig("term-rig");
const node = rigRepo.addNode(rig.id, "infra.logs", { role: "logs", runtime: "terminal" });
sessionRegistry.updateBinding(node.id, { tmuxSession: "infra-logs@term-rig" });
const sent: string[] = [];
const transport = createTransport(mockTmux({ getPaneCommand: async () => "zsh", sendText: async (_t, text) => { sent.push(text); return { ok: true }; } }));
const personal = await transport.send("infra-logs@term-rig", H_ENVELOPE, { exactText: "tail -f app.log", actorSession: "dev-impl@my-rig" });
expect(personal).toMatchObject({ ok: true, envelopeOmitted: true });
expect(sent).toEqual(["tail -f app.log"]);
const notice = await transport.send("infra-logs@term-rig", "[OpenRig watchdog] a reminder", { actorSession: "watchdog@system", openrigNotice: true });
expect(notice).toMatchObject({ ok: true });
expect(sent).toEqual(["tail -f app.log", "[OpenRig watchdog] a reminder"]);
});
it("a person's send from a seat in a rig named system still gets the exact text", async () => {
seedTerminalSeat();
const sent: string[] = [];
const transport = createTransport(mockTmux({ getPaneCommand: async () => "zsh", sendText: async (_t, text) => { sent.push(text); return { ok: true }; } }));
const result = await transport.send("infra-ui@term-rig", H_ENVELOPE, { exactText: "make test", actorSession: "ops-member@system" });
expect(result).toMatchObject({ ok: true, envelopeOmitted: true });
expect(sent).toEqual(["make test"]);
});
});
// #1084/#1103 edge cases, contributed by z4cc in review (taken verbatim,
@@ -0,0 +1,233 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Command } from "commander";
import { Hono } from "hono";
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { sendCommand, wrapSendBody, type SendDeps } from "../../cli/src/commands/send.js";
import { senderIdentityHeaders } from "../../cli/src/client.js";
import { TmuxAdapter } from "../src/adapters/tmux.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { SessionTransport } from "../src/domain/session-transport.js";
import { isBoundTerminalSeat, isTerminalSeat } from "../src/domain/terminal-seat.js";
import { transportRoutes } from "../src/routes/transport.js";
import { createFullTestDb } from "./helpers/test-app.js";
const BODY = "printf '%s\\n' 'terminal-body-marker' 'From: user-authored' '---' '↩ Reply: user-authored'";
const OUTPUT = "terminal-body-marker\nFrom: user-authored\n---\n↩ Reply: user-authored\n";
// Commander -> actual routes/SQLite/transport/file-backed adapter. The argv seam
// models a paste and Enter; only an owned /bin/sh executes that exact stream.
// This is not a live pane, SSH connection, agent or terminal-emulator claim.
describe("terminal-runtime send framing", () => {
let db: ReturnType<typeof createFullTestDb>;
let scratch: string;
beforeEach(() => {
db = createFullTestDb();
scratch = mkdtempSync(join(tmpdir(), "terminal-framing-"));
vi.stubEnv("OPENRIG_URL", "http://local-fixture:7433");
vi.stubEnv("OPENRIG_SESSION_NAME", "operator@framing");
vi.stubEnv("OPENRIG_HOST_SELECTED", "");
});
afterEach(() => { db.close(); rmSync(scratch, { recursive: true, force: true }); vi.unstubAllEnvs(); });
function fixture(runtime: "terminal" | "claude-code" = "terminal", execute = true) {
const rigRepo = new RigRepository(db);
const registry = new SessionRegistry(db);
const rig = rigRepo.createRig("framing");
const name = "shell@framing";
const node = rigRepo.addNode(rig.id, "shell", { runtime });
const session = registry.registerSession(node.id, name);
registry.updateStatus(session.id, "running");
registry.updateBinding(node.id, { tmuxSession: name });
const pasted: string[] = [];
const commands: string[][] = [];
const executions: Array<{ status: number | null; stdout: string; stderr: string }> = [];
let buffer = "";
let input = "";
const tmux = new TmuxAdapter(async () => { throw new Error("argv executor expected"); }, {
tmpName: () => join(scratch, "input"), bufferName: () => "terminal-framing",
writeFile: async (_file, text) => { buffer = text; }, unlink: async () => {},
}, async argv => {
commands.push(argv);
if (argv[1] === "paste-buffer") { pasted.push(buffer); input += buffer; }
if (argv[1] === "send-keys" && argv.includes("Enter") && runtime === "terminal" && execute) {
const result = spawnSync("/bin/sh", ["-s"], { input: input + "\n", cwd: scratch,
env: { PATH: "/usr/bin:/bin", HOME: scratch }, encoding: "utf8", timeout: 3_000 });
if (result.error) throw result.error;
executions.push({ status: result.status, stdout: result.stdout, stderr: result.stderr });
input = "";
}
return "";
});
tmux.getPanePid = async () => null;
tmux.getPaneCommand = async () => null;
tmux.capturePaneContent = async () => "idle\n❯ ";
const transport = new SessionTransport({ db, rigRepo, sessionRegistry: registry, tmuxAdapter: tmux, sleep: async () => {} });
const app = new Hono();
app.use("*", async (c, next) => { c.set("sessionTransport" as never, transport); await next(); });
app.route("/api/transport", transportRoutes());
const requests: Array<{ path: string; body: Record<string, unknown> }> = [];
const sshArgv: string[][] = [];
const deps = {
lifecycleDeps: { exists: () => false, readFile: () => null, isProcessAlive: () => false,
fetch: async () => ({ ok: true, json: async () => ({ selfHostId: "fixture-host" }) }),
spawn: () => { throw new Error("No lifecycle allowed"); }, kill: () => { throw new Error("No lifecycle allowed"); } },
clientFactory: () => {
const client = { originSelfHostId: undefined as string | undefined,
post: async (path: string, body: Record<string, unknown>) => {
requests.push({ path, body });
const res = await app.request(path, { method: "POST", headers: {
"Content-Type": "application/json", ...senderIdentityHeaders(client.originSelfHostId),
}, body: JSON.stringify(body) });
return { status: res.status, data: await res.json() };
} };
return client;
},
hostRegistryLoader: () => ({ ok: true, registry: { hosts: [
{ id: "remote-http", transport: "http", url: "http://remote-fixture:7433" },
{ id: "remote-ssh", transport: "ssh", target: "fixture.invalid" },
] } }),
crossHostRun: async (_host: unknown, argv: readonly string[]) => {
sshArgv.push([...argv]);
await new Command().addCommand(sendCommand(deps as unknown as SendDeps)).parseAsync(["node", ...argv]);
return { ok: true, exitCode: 0, stdout: "", stderr: "" };
},
};
return { name, pasted, commands, executions, requests, sshArgv, node, session, registry,
rigRepo, rig, transport, deps: deps as unknown as SendDeps };
}
async function run(f: ReturnType<typeof fixture>, args: string[]) {
const previous = process.exitCode;
process.exitCode = undefined;
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const error = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await new Command().addCommand(sendCommand(f.deps)).parseAsync(["node", "rig", "send", ...args, "--json"]);
return process.exitCode ?? 0;
} finally { log.mockRestore(); error.mockRestore(); process.exitCode = previous; }
}
it.each(["local", "fan-out", "http", "ssh"])("%s delivers only the intended terminal body", async route => {
const f = fixture();
const args = route === "fan-out" ? ["--to", f.name, BODY]
: [f.name, BODY, ...(route === "http" ? ["--host", "remote-http"] : route === "ssh" ? ["--host", "remote-ssh"] : [])];
expect(await run(f, args)).toBe(0);
expect(f.executions).toHaveLength(1);
expect(f.executions[0]!.stdout).toBe(OUTPUT); // Body runs even on main; envelope also runs there.
expect(f.executions[0]!.stderr).toBe("");
expect(f.executions[0]!.status).toBe(0);
expect(f.pasted).toEqual([BODY]); // No guessing at user-authored From:/---/reply bytes.
if (route === "ssh") expect(f.sshArgv[0]!.slice(0, 4)).toEqual(["rig", "send", f.name, BODY]);
});
it("keeps deliberate raw terminal input byte-exact", async () => {
const f = fixture();
expect(await run(f, [f.name, BODY, "--raw"])).toBe(0);
expect(f.pasted).toEqual([BODY]);
expect(f.executions).toEqual([{ status: 0, stdout: OUTPUT, stderr: "" }]);
});
it("direct HTTP explicit interaction keeps text when exactText is also supplied", async () => {
const f = fixture("terminal", false);
const app = new Hono();
app.use("*", async (c, next) => { c.set("sessionTransport" as never, f.transport); await next(); });
app.route("/api/transport", transportRoutes());
const response = await app.request("/api/transport/send", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session: f.name, text: "explicit input", exactText: "different exact body",
dangerouslyInteract: true, reason: "private explicit input control" }),
});
expect(response.status).toBe(200);
expect((await response.json()).ok).toBe(true);
expect(f.pasted).toEqual(["explicit input"]);
expect(f.commands.filter(argv => argv[1] === "send-keys" && argv.includes("Enter"))).toHaveLength(0);
});
it("keeps a non-terminal envelope with unreadable process observations", async () => {
const f = fixture("claude-code");
expect(await run(f, [f.name, BODY])).toBe(0);
expect(f.pasted).toHaveLength(1);
expect(f.pasted[0]).toContain("From: operator@framing\nTo: " + f.name);
expect(f.pasted[0]).toContain("\n---\n" + BODY + "\n---\n↩ Reply:");
expect(f.executions).toEqual([]);
});
it.each(["moved binding", "unbound canonical", "ambiguous binding"])(
"does not infer an exact-text target from %s", async shape => {
const f = fixture("terminal", false);
if (shape === "moved binding") f.registry.updateBinding(f.node.id, { tmuxSession: "other@framing" });
if (shape === "unbound canonical") {
db.prepare("DELETE FROM sessions WHERE id = ?").run(f.session.id);
db.prepare("DELETE FROM bindings WHERE node_id = ?").run(f.node.id);
}
if (shape === "ambiguous binding") {
const other = f.rigRepo.addNode(f.rig.id, "other", { runtime: "claude-code" });
f.registry.updateBinding(other.id, { tmuxSession: f.name });
}
// Historical classification alone does not establish the current binding.
expect(isTerminalSeat(db, f.name)).toBe(true);
expect(isBoundTerminalSeat(db, f.name)).toBe(false);
const original = wrapSendBody("operator@framing", f.name, BODY);
const result = await f.transport.send(f.name, original, { exactText: BODY });
expect(result.ok).toBe(true); // No new refusal or raw-mode inference.
expect(result.envelopeOmitted).toBeUndefined();
expect(f.pasted).toEqual([original]);
});
it("a real current binding establishes terminal input even without a session-history row", async () => {
const f = fixture("terminal", false);
db.prepare("DELETE FROM sessions WHERE id = ?").run(f.session.id);
expect(isBoundTerminalSeat(db, f.name)).toBe(true);
const result = await f.transport.send(f.name, "rendered envelope", { exactText: BODY });
expect(result).toMatchObject({ ok: true, envelopeOmitted: true });
expect(f.pasted).toEqual([BODY]);
});
it("keeps an old client's unmarked envelope-looking text verbatim", async () => {
const f = fixture("terminal", false);
const original = wrapSendBody("operator@framing", f.name, BODY);
const result = await f.transport.send(f.name, original);
expect(result.ok).toBe(true);
expect(result.envelopeOmitted).toBeUndefined();
expect(f.pasted).toEqual([original]);
});
it("an OpenRig notice follows the current agent binding rather than old terminal history", async () => {
const f = fixture("claude-code", false);
const old = f.rigRepo.addNode(f.rig.id, "old.shell", { runtime: "terminal" });
db.prepare("INSERT INTO sessions(id,node_id,session_name) VALUES ('zz-old-terminal',?,?)").run(old.id, f.name);
f.registry.updateBinding(old.id, { tmuxSession: "old-shell@framing" });
expect(isTerminalSeat(db, f.name)).toBe(true);
expect(isBoundTerminalSeat(db, f.name)).toBe(false);
const result = await f.transport.send(f.name, "current agent notice", { openrigNotice: true });
expect(result.ok).toBe(true);
expect(f.pasted).toEqual(["current agent notice"]);
});
it.each(["moved binding", "unbound canonical", "ambiguous binding"])(
"an OpenRig notice takes the ordinary path for %s", async shape => {
const f = fixture("terminal", false);
if (shape === "moved binding") f.registry.updateBinding(f.node.id, { tmuxSession: "other@framing" });
if (shape === "unbound canonical") db.prepare("DELETE FROM bindings WHERE node_id = ?").run(f.node.id);
if (shape === "ambiguous binding") {
const other = f.rigRepo.addNode(f.rig.id, "other", { runtime: "claude-code" });
f.registry.updateBinding(other.id, { tmuxSession: f.name });
}
const result = await f.transport.send(f.name, "ordinary notice", { openrigNotice: true });
expect(result.ok).toBe(true);
expect(f.pasted).toEqual(["ordinary notice"]);
});
it("an OpenRig notice to a bound terminal seat types as before: the marker changes nothing by itself", async () => {
const f = fixture("terminal", false);
expect(isBoundTerminalSeat(db, f.name)).toBe(true);
const result = await f.transport.send(f.name, "[OpenRig watchdog] a reminder", { openrigNotice: true });
expect(result.ok).toBe(true);
expect(result.envelopeOmitted).toBeUndefined();
expect(f.pasted).toEqual(["[OpenRig watchdog] a reminder"]);
});
});