Compare commits

..
5 changed files with 91 additions and 3 deletions

No files matched your search

+1 -1
View File
@@ -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,30 @@ export function composerContainsOwnedText(input: ComposerInput, expected: string
return false;
}
/** Conservative uncertainty before a protected Codex paste, never Enter authority.
* Codex 0.164 collapses >1000 code points to [Pasted Content N chars], which
* the current ownership reader cannot own. Other wrapped text gets a row
* upper bound; a line fitting the native width needs only one row.
*/
export function composerPasteUncertainty(input: ComposerInput, text: string): "controls" | "long" | "viewport" | null {
const viewport = input.pasteViewport;
if (!viewport) return null;
const normalized = text.replace(/\r\n/g, "\n");
const lines = normalized.split("\n");
if (lines.some(line => /[\x00-\x1f\x7f-\x9f]/.test(line))) return "controls";
// rust-v0.164.0 chat_composer/paste_input.rs:184-192 (Unicode scalar count).
if ([...normalized].length > 1000) return "long";
if (viewport.columns < 1 || viewport.rows < 1) return "viewport";
let rows = 0;
for (const line of lines) {
// Two cells per non-ASCII code point conservatively covers wide glyphs.
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 "viewport";
}
return null;
}
/** 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 { composerPasteUncertainty, 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,15 @@ 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 {
const uncertainty = pasteText === undefined ? null : composerPasteUncertainty(input, pasteText);
if (uncertainty) {
const message = uncertainty === "controls"
? "OpenRig couldn't confirm the layout of tabs or control characters. Send the message without them; nothing was pasted."
: uncertainty === "long"
? "OpenRig couldn't confirm ownership of Codex's collapsed long paste. Send a shorter message, or set this seat's typing guard to off for delivery without draft protection; nothing was pasted."
: "OpenRig couldn't confirm the complete message would stay visible in this Codex viewport. Open a larger terminal or shorten the message, then retry this request; nothing was pasted.";
throw new DeliveryGuardError("draft_input_unknown", message);
}
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
// one-column right inset out of the native printable width.
input.pasteViewport = { columns: width - prefix - 1,
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);
@@ -106,6 +106,8 @@ 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 };
@@ -149,6 +151,50 @@ describe("Codex pre-paste observability through the real delivery path", () => {
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("owns the complete envelope after a native 62-column hard wrap at 65x45", async () => {
const f = await codexDelivery("draft-aware", { width: 65, height: 45 });
const body = "x".repeat(64);
const text = wrapPaneEnvelope("sender@test", "worker@test", body);
expect(text.split("\n").filter(row => row.length > 62)).toEqual([body]);
// Codex's two-column prompt and one-column right inset leave 62 cells.
// This ASCII word wraps without dropping or replacing any authored byte.
vi.mocked(f.tmux.captureComposerSnapshot).mockImplementation(async () =>
codexViewport((f.bodies.a ?? "").replace(body, `${body.slice(0, 62)}\n${body.slice(62)}`), 65, 45));
expect(await f.send("native-wrap", text, f.options)).toMatchObject({ ok: true, sent: true });
expect(f.submissions).toEqual([text]);
expect(f.bodies.a).toBe("");
});
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 });