mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c14ca76c4 | ||
|
|
682bede406 | ||
|
|
db45c6bcb8 |
No files matched your search
@@ -899,7 +899,7 @@ export class TmuxAdapter {
|
||||
const identity = this.freshManaged.get(target)?.nodeId ?? target;
|
||||
const checkDraft = guard?.needsInputCheck(identity);
|
||||
const policy = checkDraft || options?.beforePaste ? {
|
||||
beforePaste: async () => { if (checkDraft) await guard!.beforeInput(identity); await options?.beforePaste?.(); },
|
||||
beforePaste: async () => { if (checkDraft) await guard!.beforeInput(identity, false, text); await options?.beforePaste?.(); },
|
||||
afterPaste: async () => { if (checkDraft) await guard!.notePasted(identity, text); },
|
||||
} : undefined;
|
||||
return this.sendTextUnchecked(pane, text, () => { beforeWrite(); beforeInput?.(); }, options?.bracketed, policy);
|
||||
|
||||
@@ -137,6 +137,8 @@ export interface ComposerInput {
|
||||
state: "empty" | "text" | "unknown";
|
||||
rows: string[];
|
||||
columns: number;
|
||||
/** Conservative visible capacity of the recognized Codex fullscreen composer. */
|
||||
pasteViewport?: { columns: number; rows: number };
|
||||
cursor?: ComposerSnapshot["cursor"];
|
||||
unknownReason?: string;
|
||||
suggestionRows?: string[];
|
||||
@@ -177,6 +179,28 @@ export function composerContainsOwnedText(input: ComposerInput, expected: string
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Upper-bound a complete paste in the recognized Codex viewport before writing.
|
||||
* FirstFit may move a whole word to the next row; every adjacent pair of
|
||||
* non-final rows then consumes at least one row's width. Reserve another row
|
||||
* for the insertion cursor. Non-ASCII code points are charged two cells, so
|
||||
* combining marks/emoji cannot make this bound optimistic. Other readers keep
|
||||
* their existing policy; this is not ownership or permission to press Enter.
|
||||
*/
|
||||
export function composerCanObservePaste(input: ComposerInput, text: string): boolean {
|
||||
const viewport = input.pasteViewport;
|
||||
if (!viewport) return true;
|
||||
if (viewport.columns < 1 || viewport.rows < 1) return false;
|
||||
let rows = 0;
|
||||
for (const line of text.replace(/\r\n/g, "\n").split("\n")) {
|
||||
// Tabs/control sequences have no bounded printable layout here.
|
||||
if (/[\x00-\x1f\x7f-\x9f]/.test(line)) return false;
|
||||
const cells = [...line].reduce((sum, ch) => sum + (ch >= " " && ch <= "~" ? 1 : 2), 0);
|
||||
rows += cells < viewport.columns ? 1 : Math.ceil(2 * cells / viewport.columns) + 1;
|
||||
if (rows > viewport.rows) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Recognize this lease's paste immediately after pasting or before its Enter
|
||||
* in the original composer. A later lease cannot acquire ownership from a label.
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@ import { hasOtherSeatHistory, isStoppedSeatPredecessor, resolveCurrentSessionAdd
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type Database from "better-sqlite3";
|
||||
import { composerContainsOwnedText, ownCollapsedPaste, type ComposerInput, type ComposerOwnership } from "./composer-prompts.js";
|
||||
import { composerCanObservePaste, composerContainsOwnedText, ownCollapsedPaste, type ComposerInput, type ComposerOwnership } from "./composer-prompts.js";
|
||||
|
||||
/** A binding is captured before waiting. Never rebind an old operation to a new occupant. */
|
||||
export interface GuardTarget {
|
||||
@@ -414,7 +414,7 @@ export class SeatDeliveryGuard {
|
||||
}
|
||||
|
||||
/** Called at the paste/key boundary after file and buffer preparation. */
|
||||
async beforeInput(name: string, submit = false): Promise<void> {
|
||||
async beforeInput(name: string, submit = false, pasteText?: string): Promise<void> {
|
||||
if (!this.needsInputCheck(name)) return;
|
||||
const target = this.target(name);
|
||||
const lease = this.scope.getStore()!.get(target.nodeId)!;
|
||||
@@ -433,6 +433,9 @@ export class SeatDeliveryGuard {
|
||||
} else if (input.state !== "empty") {
|
||||
throw new DeliveryGuardError("draft_input_busy", "The seat has an unsubmitted input. Automatic delivery is held.");
|
||||
} else {
|
||||
if (pasteText !== undefined && !composerCanObservePaste(input, pasteText)) {
|
||||
throw new DeliveryGuardError("draft_input_unknown", "Held before paste: the complete message cannot stay observable in this Codex viewport. Open a larger terminal or shorten the message, then retry this request.");
|
||||
}
|
||||
lease.inputFrame = input.frame;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,6 +467,13 @@ export function inspectComposerInput(snapshot: ComposerSnapshot | null, runtime:
|
||||
input.collapsedPaste = visible.join("\n");
|
||||
input.pasteMatchesExpected = true;
|
||||
}
|
||||
if (prompt.glyph !== "❯" && codexFooterSeparator(lines) === end) {
|
||||
// Codex 0.164 fullscreen: composer <= 2/3 of the pane (at least eight
|
||||
// rows, capped by the pane), less padding/status/hint rows. Leave the
|
||||
// right padding and scroll marker out of the printable width.
|
||||
input.pasteViewport = { columns: width - prefix - 2,
|
||||
rows: Math.min(height, Math.max(8, Math.floor(height * 2 / 3))) - 4 };
|
||||
}
|
||||
candidates.push(input);
|
||||
}
|
||||
return candidates.length === 1 ? candidates[0]! : unknown(candidates.length ? "ambiguous_composer" : "unrecognized_composer", snapshot.cursor);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import nativePaste from "./fixtures/claude-paste-footer.json";
|
||||
import { wrapPaneEnvelope } from "../src/lib/pane-envelope.js";
|
||||
import type { ComposerSnapshot } from "../src/domain/composer-prompts.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { OutboxHandler } from "../src/domain/outbox-handler.js";
|
||||
@@ -102,6 +103,113 @@ function fixture(saved?: Buffer, opts: { runtime?: string; idleHook?: boolean }
|
||||
}
|
||||
|
||||
|
||||
// Retained D1 geometry: empty Codex 0.164 at 65x9; after paste the editor
|
||||
// shows only the envelope's reply footer and an upward scroll indicator.
|
||||
function codexViewport(body: string, width: number, height: number): ComposerSnapshot {
|
||||
// rust-v0.164.0 uses this character-count label, not Claude's line-count label.
|
||||
if ([...body].length > 1000) return codexViewport(`[Pasted Content ${[...body].length} chars]`, width, height);
|
||||
if (!body) return { screen: ["• DONE", "", " Worked for 4s • 20:24", "", "",
|
||||
"› \x1b[2mAsk Codex to do anything\x1b[0m", "", " GPT-6.1-Sol low · ~/project · Recover OpenRig identity context", " ? for shortcuts", ""].join("\n"),
|
||||
cursor: { x: 2, y: 5, width, height }, inMode: false };
|
||||
if (height === 9 && body.includes("From:")) return {
|
||||
screen: ["", "", "› --- ↑",
|
||||
" ↩ Reply if needed: rig queue create --destination host-",
|
||||
' fixture-driver@external --summary "<one line>" --body "..."',
|
||||
" --verify", "", " GPT-6.1-Sol low · ~/project · Recover OpenRig identity context", ""].join("\n"),
|
||||
cursor: { x: 10, y: 5, width, height }, inMode: false };
|
||||
const rows = body.split("\n");
|
||||
return { screen: ["Ready", "", `› ${rows[0]}`, ...rows.slice(1).map(row => ` ${row}`), "",
|
||||
" GPT-6.1-Sol low · ~/project", " ? for shortcuts", ""].join("\n"),
|
||||
cursor: { x: 2 + stringWidth(rows.at(-1)!), y: 1 + rows.length, width, height }, inMode: false };
|
||||
}
|
||||
|
||||
const d1Envelope = wrapPaneEnvelope("host-fixture-driver@external", "worker@test",
|
||||
"Write exactly draft-aware to /home/t1s075/project/delivery-draft-aware.txt with one shell tool; reply DONE briefly and wait.");
|
||||
|
||||
async function codexDelivery(mode = "draft-aware", size = { width: 65, height: 9 }) {
|
||||
const f = fixture(undefined, { runtime: "codex" });
|
||||
await f.guard.set("a", { mode }, "human@test", "D1 protected delivery");
|
||||
vi.mocked(f.tmux.captureComposerSnapshot).mockImplementation(async () => codexViewport(f.bodies.a ?? "", size.width, size.height));
|
||||
return { ...f, size, options: { verify: false, ...(mode === "openrig-urgent" ? { urgent: { by: "human@test" } } : {}) } };
|
||||
}
|
||||
|
||||
describe("Codex pre-paste observability through the real delivery path", () => {
|
||||
it.each(["draft-aware", "openrig-urgent"])("retains the full envelope before writing into the native 65x9 frame (%s)", async mode => {
|
||||
const f = await codexDelivery(mode);
|
||||
expect(await f.send("D1", d1Envelope, f.options)).toMatchObject({ ok: true, outcome: "retained", sent: false,
|
||||
inputPhase: "not_reached", reason: "draft_input_unknown" });
|
||||
expect(f.writes).toEqual([]);
|
||||
expect(f.submissions).toEqual([]);
|
||||
expect(f.bodies.a).toBe("");
|
||||
});
|
||||
|
||||
it.each(["draft-aware", "openrig-urgent"])("submits a fitting full envelope once (%s)", async mode => {
|
||||
const f = await codexDelivery(mode, { width: 160, height: 45 });
|
||||
expect(await f.send("fits", d1Envelope, f.options)).toMatchObject({ ok: true, sent: true });
|
||||
expect(f.submissions).toEqual([d1Envelope]);
|
||||
expect(f.writes).toHaveLength(2);
|
||||
expect(f.bodies.a).toBe("");
|
||||
});
|
||||
|
||||
it("delivers nine native-fitting 61-column lines plus their envelope at 65x45", async () => {
|
||||
const f = await codexDelivery("draft-aware", { width: 65, height: 45 });
|
||||
const text = wrapPaneEnvelope("sender@test", "worker@test", Array(9).fill("x".repeat(61)).join("\n"));
|
||||
expect(await f.send("native-fit", text, f.options)).toMatchObject({ ok: true, sent: true });
|
||||
expect(f.submissions).toEqual([text]);
|
||||
});
|
||||
|
||||
it("retains a raw-fitting 1200-code-point envelope before Codex collapses it into an unowned label", async () => {
|
||||
const f = await codexDelivery("draft-aware", { width: 160, height: 45 });
|
||||
const empty = wrapPaneEnvelope("sender@test", "worker@test", "");
|
||||
const text = wrapPaneEnvelope("sender@test", "worker@test", "x".repeat(1200 - [...empty].length));
|
||||
expect([...text]).toHaveLength(1200);
|
||||
const result = await f.send("native-long", text, f.options);
|
||||
expect(result).toMatchObject({ outcome: "retained", sent: false, inputPhase: "not_reached" });
|
||||
expect(result.error).toMatch(/shorter message.*guard.*off/);
|
||||
expect(f.writes).toEqual([]);
|
||||
expect(f.bodies.a).toBe("");
|
||||
});
|
||||
|
||||
it.each(["\t", "\x1b"])("gives control-specific recovery at every size (%j)", async control => {
|
||||
const f = await codexDelivery("draft-aware", { width: 160, height: 45 });
|
||||
for (const text of [`a${control}b`, `${"x".repeat(1200)}${control}`]) {
|
||||
const result = await f.send("control", text, f.options);
|
||||
expect(result).toMatchObject({ outcome: "retained", sent: false, inputPhase: "not_reached" });
|
||||
expect(result.error).toMatch(/without them/);
|
||||
expect(result.error).not.toMatch(/larger terminal/);
|
||||
}
|
||||
expect(f.writes).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves default-off delivery unchanged in the small viewport", async () => {
|
||||
const f = await codexDelivery("off");
|
||||
expect(await f.send("off", d1Envelope, f.options)).toMatchObject({ ok: true, sent: true });
|
||||
expect(f.submissions).toEqual([d1Envelope]);
|
||||
});
|
||||
|
||||
it("rechecks the actual viewport after buffer preparation, before any paste", async () => {
|
||||
const f = await codexDelivery("draft-aware", { width: 160, height: 45 });
|
||||
f.hooks.load = () => { f.size.width = 65; f.size.height = 9; };
|
||||
expect(await f.send("resized", d1Envelope, f.options)).toMatchObject({ outcome: "retained", sent: false, inputPhase: "not_reached" });
|
||||
expect(f.writes).toEqual([]);
|
||||
});
|
||||
|
||||
it("retains a wrapped line which cannot stay visible without writing it", async () => {
|
||||
const f = await codexDelivery();
|
||||
expect(await f.send("wraps", "one-long-word-".repeat(40), f.options)).toMatchObject({ outcome: "retained", sent: false, inputPhase: "not_reached" });
|
||||
expect(f.writes).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a concurrent real draft and the human-input epoch check", async () => {
|
||||
const f = await codexDelivery("draft-aware", { width: 160, height: 45 });
|
||||
f.hooks.load = () => f.guard.humanInput("a", async () => { f.bodies.a = "my real draft"; });
|
||||
expect(await f.send("human", d1Envelope, f.options)).toMatchObject({ outcome: "retained", sent: false,
|
||||
inputPhase: "not_reached", reason: "draft_input_changed" });
|
||||
expect(f.writes).toEqual([]);
|
||||
expect(f.bodies.a).toBe("my real draft");
|
||||
});
|
||||
});
|
||||
|
||||
describe("current draft-aware delivery through the real transport", () => {
|
||||
it.each(["ordinary request ", "1. Review the change"])("attempts each ordinary authored request, even with a reused caller ID: %j", async text => {
|
||||
const f = fixture(); await f.enable();
|
||||
|
||||
Reference in new issue
Block a user