Compare commits

...
Author SHA1 Message Date
Mike Schwarz c92b4f3c93 fix(delivery): ignore Claude's drawn cursor blank (#2933)
For #2925. On an idle Claude seat with the typing guard set to draft-aware, every automatic delivery was held as draft_input_busy while rig capture read the input box as empty. The guard's strict reader counted Claude's own drawn cursor, a single inverse-video blank at the start of the composer, as typed text; the capture annotation already trimmed it. The strict reader now recognises that one drawn blank at the start of a one-row Claude composer and reads the box as empty. Real draft text, ordinary spaces, a moved cursor and multi-row input keep the existing protection, and the explicit draft-aware mode's handling of unreadable input is unchanged. No new refusal or timeout. Reproduced in an isolated 68x31 tmux 3.6 pane with the reported frame: unchanged main held the send, and with the change the message submits once while a real draft stays untouched. It doesn't explain the reported delay of about fifteen minutes before the first hold.
2026-11-10 19:05:22 -08:00
Mike Schwarz 85b4ccf27a docs: clarify when seats receive new guidance (#2930)
Three corrections to the agent startup guide's section on how a running seat gets new guidance. A send can be retained rather than delivered, so the guide no longer promises next-turn delivery or relative order. A managed refocus after compaction waits for an eligible prompt, which may not be the immediate next one. And rig walk reads each named file and sends its contents, unlike a pointer to a file that the seat still has to read. Finding a file, a hook picking it up and the agent reading it stay separate facts. Docs only.
2026-11-10 18:37:30 -08:00
Mike Schwarz eb73d75edd refactor(daemon): resolve a seat's kind at one boundary for send and capture (#2932)
Five places in session-transport.ts (send, its wait path, capture, the activity probe and target resolution) each compared a seat's attachment type with external_cli on their own. A new seat-kinds.ts resolves the kind once: tmux (also for a legacy row with no value), external_cli, or unknown with the stored value, so an unknown value is named rather than silently read as tmux, while keeping today's tmux path. The five branches now ask it. No result, error text or refusal changes. It's the first step of one seat boundary for delivery evidence, and it builds on rlch's SeatKind facade and attachment key proposed in #1429, with credit.
2026-11-10 18:16:14 -08:00
Mike Schwarz 1f30867775 docs: say how queueCounts treats a row tagged with several slices (#2929)
For #2902's queueCounts. The slice-list section of the daemon HTTP clients reference now says how a row tagged with several slices or missions is counted. Its tags count only when they all name the same physical slice or mission (a slice's folder name and its frontmatter id, for example). A row that names two distinct slices, missions or projects counts nowhere in queueCounts, while the older qitemCount can include it under several tagged slices, subject to the existing project filter and the newest 500 rows per slice. Docs only.
2026-11-10 18:13:32 -08:00
Mike Schwarz e89031d211 test(refocus): skip mode-000 denial cases as root (#2928)
Follow-up to #2916. Two refocus-record test cases rely on chmod 000 causing EACCES, which root doesn't get. They now skip when process.getuid?.() === 0, with the reason in their names, following the existing root skip in refocus-notes-local.test.ts. The other eight cases still run, and non-root runs keep both. Test only.
2026-11-10 17:56:14 -08:00
10 changed files with 146 additions and 37 deletions

No files matched your search

+12 -10
View File
@@ -428,22 +428,24 @@ running seat only through the action in the last column.
### New guidance for a running seat
These reach a running seat without a restart, in the order they reach the agent:
There are three ways for a seat to pick up new guidance:
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.
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`.
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. 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.
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.
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 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.
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.
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),
+6 -2
View File
@@ -218,5 +218,9 @@ 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. 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. 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.
+25
View File
@@ -0,0 +1,25 @@
// 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";
}
+26 -14
View File
@@ -51,6 +51,7 @@ 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
@@ -448,14 +449,18 @@ function closesFramedPromptAbove(lines: string[], open: number, indent: number,
const codexFooter = (line: string): boolean => isComposerFooter(line)
|| /(?:\b\d+% context left\b|\bContext \[)/.test(line);
/** Preserve faint styling so autocomplete ghosts can be separated from authored
* text. Color channel values must never be interpreted as style codes. */
/** Preserve the styling used for autocomplete ghosts and the drawn cursor.
* Color channel values must never be interpreted as style codes. */
function styledTextLines(screen: string) {
let faint = false;
let faint = false, inverse = false;
return screen.replace(/\r\n/g, "\n").split("\n").map(raw => {
let text = "", offset = 0;
const faintCells: boolean[] = [];
const append = (value: string) => { text += value; faintCells.push(...Array<boolean>(value.length).fill(faint)); };
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));
};
for (const match of raw.matchAll(/\x1b\[([0-9;:]*)m/g)) {
append(raw.slice(offset, match.index));
const codes = match[1]!.split(";");
@@ -466,15 +471,18 @@ 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 = false; break; }
if (!skip || i + skip !== codes.length - 1) { faint = inverse = false; break; }
i += skip;
} else if (code === "" || code === "0" || code === "22") faint = false;
} else if (code === "" || code === "0") faint = inverse = false;
else if (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 };
return { text, faintCells, inverseCells };
});
}
@@ -569,7 +577,11 @@ 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;
const empty = atStart && rows.length === 1 && rows[0] === "";
// 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 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;
@@ -969,7 +981,7 @@ export async function probeSessionActivity(input: {
};
}
const readTarget = binding?.pane ?? sessionName;
if (attachmentType === "external_cli") {
if (seatInboundTransport(resolveSeatKind(attachmentType)) === "none") {
return {
state: "unknown",
reason: "unsupported_attachment",
@@ -2569,7 +2581,7 @@ export class SessionTransport {
const binding = this.sessionRegistry.getBindingForNode(node.id);
const latestSession = latestByNode.get(node.id);
if (binding?.attachmentType === "external_cli" && binding.externalSessionName) {
if (resolveSeatKind(binding?.attachmentType).kind === "external_cli" && binding?.externalSessionName) {
targets.push({
sessionName: binding.externalSessionName,
rigName,
@@ -2900,7 +2912,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 (meta.attachmentType === "external_cli") {
if (seatInboundTransport(resolveSeatKind(meta.attachmentType)) === "none") {
return { ok: false, failure: {
ok: false,
sessionName,
@@ -3094,7 +3106,7 @@ export class SessionTransport {
const waitMode = waitForIdleMs !== undefined;
let waitEvidence: Pick<SendResult, "activity" | "waitedMs" | "attempts"> = {};
if (sessionMeta.attachmentType === "external_cli") {
if (seatInboundTransport(resolveSeatKind(sessionMeta.attachmentType)) === "none") {
return {
ok: false,
sessionName,
@@ -4360,7 +4372,7 @@ export class SessionTransport {
readable?: { width: number; height: number }; readableBudget?: ReadableBudget }): Promise<CaptureResult> {
sessionName = physicalSessionName(this.db, sessionName);
const sessionMeta = this.getSessionMeta(sessionName);
if (sessionMeta.attachmentType === "external_cli") {
if (seatInboundTransport(resolveSeatKind(sessionMeta.attachmentType)) === "none") {
return {
ok: false,
sessionName,
@@ -162,6 +162,15 @@ 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 { inspectComposerInput } from "../src/domain/session-transport.js";
import { annotateInputBox, 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("submits a multiline paste under a combined update footer through real tmux", async () => {
it.each(["update-banner", "cursor-block"])("submits a multiline paste with %s and keeps a real draft through tmux", async variant => {
const scratch = mkdtempSync(join(tmpdir(), "draft-banner-"));
const socket = join(scratch, "s");
const env = { ...process.env }; delete env.TMUX; delete env.TMUX_TMPDIR;
@@ -143,8 +143,9 @@ 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, "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();
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();
await vi.waitFor(() => expect(state().input).toBe(""), { timeout: 5000 });
migrate(db, ALL_MIGRATIONS);
db.exec(`INSERT INTO rigs(id,name) VALUES ('rig','test');
@@ -157,7 +158,14 @@ 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");
await vi.waitFor(async () => expect(inspectComposerInput(await adapter.captureComposerSnapshot(pane), "claude-code").state).toBe("empty"));
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"));
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 });
@@ -165,7 +173,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 });
.toMatchObject({ outcome: "retained", sent: false, reason: "draft_input_busy" });
expect(state()).toEqual({ input: "person's draft", submissions: [text] });
} finally {
await transport?.guardedDelivery?.stop();
+7 -3
View File
@@ -5,19 +5,23 @@ 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 (runtime === "claude") rows.push("────────────────────────────────────────");
if (cursorBlock) while (rows.length < 25) rows.push("");
if (runtime === "claude") rows.push(rule);
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" : "");
: input || (runtime === "codex" ? "\x1b[2mAsk Codex to do anything\x1b[22m" : cursorBlock ? "\x1b[7m \x1b[0m" : "");
const body = displayed.split("\n");
rows.push(`${runtime === "claude" ? "❯\u00a0" : "› "}${body[0]}`, ...body.slice(1).map(line => ` ${line}`));
rows.push(...(runtime === "claude" ? ["────────────────────────────────────────", "? for shortcuts"] : ["", "fixture · 90% context left"]));
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"]));
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");
+2 -2
View File
@@ -70,7 +70,7 @@ describe("seat status reads what OpenRig's own refocus hook recorded", () => {
});
});
it("an earlier record that can't be read makes the history unknown, never 'no record' or an older time", () => {
it.skipIf(process.getuid?.() === 0)("an earlier record that can't be read makes the history unknown, never 'no record' or an older time (root bypasses mode 000)", () => {
withHome((home) => {
// Alone: an unreadable earlier record, and the seat now has another conversation (or no resume token).
prompt(home, "earlier");
@@ -90,7 +90,7 @@ describe("seat status reads what OpenRig's own refocus hook recorded", () => {
});
});
it("a state folder that can't be read reads unknown, not 'no record'", () => {
it.skipIf(process.getuid?.() === 0)("a state folder that can't be read reads unknown, not 'no record' (root bypasses mode 000)", () => {
withHome((home) => {
prompt(home, conversation);
const dir = join(home, "refocus");
+24
View File
@@ -0,0 +1,24 @@
// 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,6 +2741,27 @@ 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();