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
10 changed files with 68 additions and 60 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),
+1 -2
View File
@@ -91,8 +91,7 @@ never picks whichever seat happens to be running. The TUI shows the effective ag
under Settings > Agents & runtime ("Default receiving agent"). It never replaces the default person.
For Slack, `rig slack setup --inbound-destination default-receiver` (the default for a new setup) sends unrouted
messages to the default receiving agent, read when the connector starts. With no receiving agent on the host, they go to
`operator-agent@kernel` for compatibility, as before. A destination already saved in the connector
messages to the default receiving agent, read when the connector starts. A destination already saved in the connector
config, `operator-agent@kernel` included, is kept as chosen; pass `default-receiver` to switch it.
## In the TUI: whose queue, and who acts
+1 -2
View File
@@ -146,8 +146,7 @@ person needs in their project or home folder and name that path.
A declared name is a claim, not a seat's own identity: for a team that acts only on seat identity, the attached
shell above is the route.
- **Who answers OpenRig's questions:** read `rig gateway participants` (its `default:` line) before the handoff.
- A default person is there: say requests and asks that name nobody go to them (a pairing request or an ask can
name someone else), and don't offer again.
- A default person is there: say requests and asks go to them, and don't offer again.
- Several people with no default: ask which should get requests nobody is named for (`rig gateway default <id>`),
or leave it. Never pick one yourself.
- Nobody is registered: offer it in a sentence or two. OpenRig's questions, such as another machine asking to pair,
@@ -449,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(";");
@@ -467,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 };
});
}
@@ -570,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;
@@ -4,7 +4,7 @@ import { join, isAbsolute } from "node:path";
import { getOpenRigHome } from "../../openrig-compat.js";
import { SETTINGS_VALID_KEYS, type SettingsStore, type ResolvedSetting } from "./settings-store.js";
import { connectionsProjection, readConnectionConfiguration } from "../gateway/connections-projection.js";
import { DEFAULT_CONFIG, INHERIT_INBOUND_DESTINATION, LEGACY_INBOUND_DESTINATION } from "../gateway/slack/config.js";
import { DEFAULT_CONFIG, INHERIT_INBOUND_DESTINATION } from "../gateway/slack/config.js";
import { projectionPath } from "../gateway/human-registry.js";
import { loadHostRegistry } from "../hosts/hosts-registry-reader.js";
import { DEFAULT_HEALTH_POLICY, validateHealthPolicy } from "../health-policy.js";
@@ -144,14 +144,9 @@ export function settingsBrowser(store: SettingsStore, gateway: Record<string, un
// Derived, not a setting: the agent that receives answers nothing else reads, from agents.operator_session or the
// kernel operator. Shown with where it came from and its state; changed with rig config set agents.operator_session.
if (receiver) {
// The description names the session and can quote the setting: when either is withheld, so is the description.
const unsafe = [receiver.session, resolved?.["agents.operator_session"]?.value]
.some((v) => typeof v === "string" && v !== "" && safeValue(v, "identity", redact).value === null);
add("agents.default_receiver", "general", receiver.session ?? "", undefined,
receiver.source === "setting" ? resolved?.["agents.operator_session"]?.source ?? "unreported" : "default", "identity",
"Displayed daemon instance", unsafe
? "Withheld: agents.operator_session holds credential-bearing or unsafe text, so the receiving agent isn't described here. Inspect it at its source."
: describeDefaultReceiver(receiver));
"Displayed daemon instance", describeDefaultReceiver(receiver));
}
if (resolved) {
for (const host of store.listFeedHostSubscriptions()) {
@@ -176,10 +171,7 @@ export function settingsBrowser(store: SettingsStore, gateway: Record<string, un
const unchosen = key === "inboundDestination" && (!cfg || cfg[key] === INHERIT_INBOUND_DESTINATION);
add("slack." + key, "slack", cfg ? flatten(cfg[key]) : null, flatten(defs[key]),
cfg ? read.fields.includes(key) ? "file" : "default" : "unavailable", kind, undefined,
unchosen ? receiver?.session
? "Not chosen: when the connector starts, unrouted messages go to the default receiving agent (Agents & runtime)."
: `Not chosen: this host has no receiving agent (Agents & runtime), so when the connector starts, unrouted messages go to ${LEGACY_INBOUND_DESTINATION} for compatibility, as before.`
: undefined);
unchosen ? "Not chosen: when the connector starts, unrouted messages go to the default receiving agent (Agents & runtime)." : undefined);
}
// #192: the channel map as one read-only line, "match: channel" per entry.
add("slack.channelMap", "slack", read.cfg ? c.configuration!.channelMap.map((e) => `${e.match ?? "withheld"}: ${e.channel ?? "withheld"}`).join(", ") : null, "",
@@ -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);
@@ -145,19 +145,4 @@ describe("the settings view", () => {
.toBe("Not chosen: when the connector starts, unrouted messages go to the default receiving agent (Agents & runtime).");
expect(settingsBrowser(store, null, home).entries.some((entry) => entry.key === "agents.default_receiver")).toBe(false);
});
it("withholds the receiving agent's description when the setting holds unsafe text; with no receiver, Slack's line says where unrouted messages go", () => {
const home = mkdtempSync(join(tmpdir(), "default-receiver-browser-"));
closes.push(() => rmSync(home, { recursive: true, force: true }));
const store = new SettingsStore(join(home, "config.json"));
const unsafe = "token=synthetic-example-value";
store.set("agents.operator_session", unsafe);
const view = settingsBrowser(store, null, home, { session: null, source: "none", state: "missing",
note: `agents.operator_session is "${unsafe}", which isn't an agent's session, so it's ignored` });
const row = view.entries.find((entry) => entry.key === "agents.default_receiver")!;
expect(row.application).toBe("Withheld: agents.operator_session holds credential-bearing or unsafe text, so the receiving agent isn't described here. Inspect it at its source.");
expect(JSON.stringify(view)).not.toContain("synthetic-example-value");
expect(view.entries.find((entry) => entry.key === "slack.inboundDestination")?.application)
.toBe(`Not chosen: this host has no receiving agent (Agents & runtime), so when the connector starts, unrouted messages go to ${LEGACY_INBOUND_DESTINATION} for compatibility, as before.`);
});
});
@@ -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");
+1 -2
View File
@@ -146,8 +146,7 @@ person needs in their project or home folder and name that path.
A declared name is a claim, not a seat's own identity: for a team that acts only on seat identity, the attached
shell above is the route.
- **Who answers OpenRig's questions:** read `rig gateway participants` (its `default:` line) before the handoff.
- A default person is there: say requests and asks that name nobody go to them (a pairing request or an ask can
name someone else), and don't offer again.
- A default person is there: say requests and asks go to them, and don't offer again.
- Several people with no default: ask which should get requests nobody is named for (`rig gateway default <id>`),
or leave it. Never pick one yourself.
- Nobody is registered: offer it in a sentence or two. OpenRig's questions, such as another machine asking to pair,