From a0f410e161e60b38e7d7cb6ed1346f89ba3d1aea Mon Sep 17 00:00:00 2001 From: Mike Schwarz <171890339+mvschwarz@users.noreply.github.com> Date: Sat, 7 Nov 2026 05:14:13 -0800 Subject: [PATCH] fix(transport): read the input box for drafts the pane classifier misses (#2653) For an ordinary Claude Code or Codex send, when the transport has the transport.draft_delivery reader and the send carries no input rule of its own (managed compaction, a prompt override), SessionTransport reads the input box once just before the paste, within the input-box read deadline. If the box reads typed and the setting is refuse, the send returns target_needs_input with activity prompt_draft before any input, as a recognized draft does, and the queue's existing retry applies. If the setting is deliver, the result's advisory says the message was pasted into the draft and submitted with it, said once per send. An unreadable box and a transport without the reader keep today's behaviour. rig config help names the refuse-mode retry limit and that the draft's own activity wins under --wait-for-idle. --- packages/cli/src/commands/config.ts | 2 +- .../daemon/src/domain/session-transport.ts | 42 +++++++++- packages/daemon/src/startup.ts | 2 +- .../test/session-transport-input-box.test.ts | 79 +++++++++++++++++++ 4 files changed, 120 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index ce5b46483..ee852febf 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -130,7 +130,7 @@ Keys: usage.pi_openrouter_charge with usage.pi_cost_facts on, Pi seats look up OpenRouter's actual charge for their requests, shown beside the estimate (default off; changes take effect when a Pi seat next launches or resumes; earlier outcomes stay visible) transport.verify_input_box with --verify, a send to a Claude Code or Codex seat (also queue wakes) reads verified only when the seat was proven idle before the paste and its input box showed the exact text before Enter and was empty after it, on the same pane process; a busy or unknown seat, and anything else, reads unconfirmed, not failed. Reporting only (default off) transport.local_user_guard on a Mac, the CLI refuses a local daemon whose reported user, checked with ps, isn't yours; an explicit OPENRIG_URL skips the check. A mitigation for reaching the wrong daemon, not authentication (default off) - transport.draft_delivery deliver (default): a rig send or work notice to a seat whose prompt holds a person's draft goes through: the message is pasted at the current cursor into the draft (normally its end), then Enter is sent to submit the combined input, and the send's result says whether that was confirmed. OpenRig can't read every prompt layout: where it doesn't detect the draft, the result doesn't mention it, and refuse doesn't stop the send. refuse: such a send is refused, and a work notice retried once the prompt is clear, where OpenRig detects the draft. Either way, menus and permission prompts are refused, a per-seat typing guard still holds, managed compaction waits for an empty prompt, and a send with --wait-for-idle still waits + transport.draft_delivery deliver (default): a rig send or work notice to a seat whose prompt holds a person's draft goes through: the message is pasted at the current cursor into the draft (normally its end), then Enter is sent to submit the combined input, and the send's result says whether that was confirmed. OpenRig can't read every prompt layout: where it doesn't detect the draft, the result doesn't mention it, and refuse doesn't stop the send. refuse: such a send is refused, and a work notice retried once the prompt is clear, where OpenRig detects the draft. A draft found only by reading the input box may leave that retry waiting for the seat's next activity change or the stuck sweep, rather than the moment the draft clears. Either way, menus and permission prompts are refused, a per-seat typing guard still holds, managed compaction waits for an empty prompt, and a send with --wait-for-idle still waits decisions.enabled typed decisions (OpenRouter + Jev) for the consumers in decisions.consumers, with OPENROUTER_API_KEY from the rig process that runs them; off makes no provider calls (default off). rig project decisions status shows the state decisions.consumers comma-separated consumers that may call (supported: stream; default none). An unknown name makes no calls diff --git a/packages/daemon/src/domain/session-transport.ts b/packages/daemon/src/domain/session-transport.ts index 984e03f07..ca8bf01a3 100644 --- a/packages/daemon/src/domain/session-transport.ts +++ b/packages/daemon/src/domain/session-transport.ts @@ -1048,6 +1048,11 @@ function safeRecord(sink: CaptureObserverSink, input: ObservationInput): void { try { sink.record(input); } catch { /* observer failure never reaches the caller */ } } +/** What a send under transport.draft_delivery = deliver says when it meets a person's draft. */ +function draftJoinedAdvisory(sessionName: string): string { + return `'${sessionName}' had a draft at its prompt and transport.draft_delivery is deliver: this message was pasted at the current cursor into that draft (normally its end), then Enter was sent to submit the combined input. The delivery result says whether that was confirmed.`; +} + /** transport.draft_delivery as the daemon reads it. deliver is the product default, so only a readable `refuse` opts out: * an unset, unreadable or invalid value follows the default, as other settings fall back to theirs. */ export function draftDeliveryAllows(read: () => unknown): boolean { @@ -2085,8 +2090,9 @@ interface SessionTransportDeps { runtimeAdapters?: Record; /** transport.verify_input_box, read per verified Claude Code or Codex send. Absent means off. */ verifyInputBox?: () => boolean; - /** Whether transport.draft_delivery lets this send through a person's draft, read per send that meets one. The daemon - * wires draftDeliveryAllows (deliver by default); a transport built without it keeps refusing. */ + /** Whether transport.draft_delivery lets this send through a person's draft, read on every ordinary Claude Code or Codex + * send (and on any send that meets a recognised draft). The daemon wires draftDeliveryAllows (deliver by default); a + * transport built without it keeps refusing. */ deliverOverDraft?: () => boolean; } @@ -3280,6 +3286,8 @@ export class SessionTransport { // the positive-picker guard (FR-4 — the footgun separation). The advisory is carried on the // success result via `warning` so the honest telemetry is surfaced. let sendAdvisory: string | undefined; + // Set once this send's result says it joined a person's draft, so the input-box read below doesn't say it twice. + let draftAdvised = false; // Broadcast can carry wait + override: after waiting, it still uses the same deliberate-input boundary. const promptOverride = Boolean(opts?.dangerouslyInteract); if (waitForIdleMs === undefined || promptOverride) { @@ -3341,7 +3349,8 @@ export class SessionTransport { } if (readiness.state === "needs_input") { if (!promptOverride && this.deliversOverDraft(readiness, opts)) { - sendAdvisory = [sendAdvisory, `'${sessionName}' had a draft at its prompt and transport.draft_delivery is deliver: this message was pasted at the current cursor into that draft (normally its end), then Enter was sent to submit the combined input. The delivery result says whether that was confirmed.`].filter(Boolean).join(" "); + sendAdvisory = [sendAdvisory, draftJoinedAdvisory(sessionName)].filter(Boolean).join(" "); + draftAdvised = true; } else if (!promptOverride) { return { ok: false, @@ -3415,6 +3424,27 @@ export class SessionTransport { const copyModeFailure = await leavePaneMode("No text was sent."); if (copyModeFailure) return observe({ ...copyModeFailure, ...(waitMode ? waitEvidence : {}) }); + // transport.draft_delivery for the drafts the pane classifier misses (framed Claude composers read as idle): the + // input-box reader sees them. Under an explicit refuse, a typed box refuses as a recognized draft does, before any + // input; under deliver, the result only says the message joined it. A transport without the setting's reader, a + // send with its own input rule (managed compaction, a deliberate interaction) and an unreadable box are unchanged. + const boxRuntime = runtime === "claude-code" || runtime === "codex" ? runtime : null; + const draftChoice = boxRuntime && !opts?.preserveInput && !promptOverride && !(draftAdvised) ? this.draftDeliveryChoice() : undefined; + if (boxRuntime && draftChoice) { + const box = await this.captureInputBox(readTarget, boxRuntime, { remainingMs: INPUT_BOX_READ_DEADLINE_MS }); + if (box.status === "typed" || box.status === "typed-with-suggestion") { + if (draftChoice === "refuse") { + const activity: AgentActivity = { state: "needs_input", reason: "prompt_draft", evidenceSource: "pane_heuristic", + sampledAt: this.now().toISOString(), evidence: `input box reads ${box.status}`, fallback: true }; + // Wait evidence keeps its waitedMs and attempts; the activity is this draft's, not the idle the wait ended on. + return observe({ ok: false, sessionName, reason: "target_needs_input", sent: false, inputPhase: "not_reached", + ...(waitMode ? waitEvidence : {}), activity, + error: `Refused: '${sessionName}' is at an interactive prompt (prompt_draft): its input box holds a draft and transport.draft_delivery is refuse. No text was sent.` }); + } + sendAdvisory = [sendAdvisory, draftJoinedAdvisory(sessionName)].filter(Boolean).join(" "); + } + } + // 3. Deliver ordinary messages as paste, audited prompt answers as key input. if (observed) observed.sentHash = hashSentText(text); // The caller's cancellation is checked once more at the adapter's last step before the paste (after its buffer @@ -3829,6 +3859,12 @@ export class SessionTransport { return paneCommand.replace(/^-/, ""); } + /** transport.draft_delivery as this transport's reader gives it; undefined without a reader, which keeps today's sends. */ + private draftDeliveryChoice(): "deliver" | "refuse" | undefined { + if (!this.deliverOverDraft) return undefined; + try { return this.deliverOverDraft() ? "deliver" : "refuse"; } catch { return undefined; } + } + /** transport.draft_delivery = deliver (the default): a person's draft at the prompt is the one needs_input an ordinary send or work * notice goes through. Menus, permission prompts and hook-reported requests still refuse, and so does a send that must * not join a draft (preserveInput: managed compaction). A send that waits for idle keeps waiting. */ diff --git a/packages/daemon/src/startup.ts b/packages/daemon/src/startup.ts index e3e44e519..af3027226 100644 --- a/packages/daemon/src/startup.ts +++ b/packages/daemon/src/startup.ts @@ -1583,7 +1583,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise verifyInputBox: () => { try { return new ContextPackSettingsStore().resolveOne("transport.verify_input_box").value === true; } catch { return false; } }, - // transport.draft_delivery, read at each send that meets a person's draft: only a readable refuse opts out. + // transport.draft_delivery, read on every ordinary Claude Code or Codex send: only a readable refuse opts out. deliverOverDraft: () => draftDeliveryAllows(() => readDraftDeliverySetting(() => new ContextPackSettingsStore().resolveOne("transport.draft_delivery").value)), }); diff --git a/packages/daemon/test/session-transport-input-box.test.ts b/packages/daemon/test/session-transport-input-box.test.ts index 49ea033ac..aede8c3b9 100644 --- a/packages/daemon/test/session-transport-input-box.test.ts +++ b/packages/daemon/test/session-transport-input-box.test.ts @@ -997,6 +997,85 @@ describe("SessionTransport with transport.verify_input_box", () => { }); }); +describe("transport.draft_delivery through the input-box reader (framed drafts the pane classifier misses)", () => { + let db: Database.Database; + let rigRepo: RigRepository; + let sessionRegistry: SessionRegistry; + const SESSION = "dev-impl@my-rig"; + const DRAFT = "my unfinished thought"; + // OpenRig's own framed Claude seat with a person's draft in the box (an edit of a real 2.1.296 frame), captured with + // styling, so "not faint" is evidence; the cursor sits after the draft, where a person typing leaves it. + const framed = (text: string) => { + const lines = openRig.staged(text).split("\n"); + const row = lines.findLastIndex((line) => line.startsWith("❯")); + lines[row] = `\x1b[0m${lines[row]}`; + return { screen: lines.join("\n"), row }; + }; + + beforeEach(() => { + db = createFullTestDb(); + rigRepo = new RigRepository(db); + sessionRegistry = new SessionRegistry(db); + const rig = rigRepo.createRig("my-rig"); + const node = rigRepo.addNode(rig.id, "dev.impl", { role: "worker", runtime: "claude-code" }); + const session = sessionRegistry.registerSession(node.id, SESSION); + sessionRegistry.updateStatus(session.id, "running"); + sessionRegistry.updateBinding(node.id, { tmuxSession: SESSION }); + }); + afterEach(() => db.close()); + + async function send(screen: string, cursorX: number, row: number, choice: "deliver" | "refuse" | undefined, opts?: { waitForIdleMs?: number }) { + const { tmux, log } = phasedTmux({ frames: { prePaste: screen, afterPaste: screen, afterEnter: screen } }); + const cursorReads = vi.fn(async () => ({ x: cursorX, y: row, width: 120, height: 40 })); + (tmux as unknown as { getPaneCursorPosition: typeof cursorReads }).getPaneCursorPosition = cursorReads; + const result = await new SessionTransport({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux, sleep: async () => {}, + ...(choice ? { deliverOverDraft: () => choice === "deliver" } : {}) }).send(SESSION, "hello team", opts); + return { result, log, cursorReads }; + } + + it("under an explicit refuse, a typed box the classifier misses is refused before any input", async () => { + const { screen, row } = framed(DRAFT); + const { result, log } = await send(screen, 2 + DRAFT.length, row, "refuse"); + expect(result).toMatchObject({ ok: false, reason: "target_needs_input", sent: false, inputPhase: "not_reached", + activity: { state: "needs_input", reason: "prompt_draft" } }); + expect(result.error).toContain("transport.draft_delivery is refuse"); + expect(log.filter((call) => call === "sendText" || call.startsWith("sendKeys"))).toEqual([]); + }); + + it("with --wait-for-idle under refuse, the refusal reports the draft, not the idle the wait ended on, and keeps the wait's time", async () => { + const { screen, row } = framed(DRAFT); + const { result, log } = await send(screen, 2 + DRAFT.length, row, "refuse", { waitForIdleMs: 1_000 }); + expect(result).toMatchObject({ ok: false, reason: "target_needs_input", sent: false, + activity: { state: "needs_input", reason: "prompt_draft" }, waitedMs: expect.any(Number), attempts: expect.any(Number) }); + expect(log.filter((call) => call === "sendText" || call.startsWith("sendKeys"))).toEqual([]); + }); + + it("under deliver, the same draft is joined and submitted, and the result says so", async () => { + const { screen, row } = framed(DRAFT); + const { result, log } = await send(screen, 2 + DRAFT.length, row, "deliver"); + expect(result.ok).toBe(true); + expect(log.filter((call) => call === "sendText" || call.startsWith("sendKeys"))).toEqual(["sendText", "sendKeys:Enter"]); + expect(result.warning).toContain("transport.draft_delivery is deliver"); + }); + + it("under deliver, an empty box sends as before, with no draft advisory", async () => { + const lines = openRig.idle.split("\n"); + const row = lines.findLastIndex((line) => line.startsWith("❯")); + lines[row] = `\x1b[0m${lines[row]}`; + const { result, log } = await send(lines.join("\n"), 2, row, "deliver"); + expect(result.ok).toBe(true); + expect(log.filter((call) => call === "sendText" || call.startsWith("sendKeys"))).toEqual(["sendText", "sendKeys:Enter"]); + expect(result.warning ?? "").not.toContain("transport.draft_delivery"); + }); + + it("a transport without the setting's reader doesn't read the box, as every other construction keeps today's sends", async () => { + const { screen, row } = framed(DRAFT); + const { result, cursorReads } = await send(screen, 2 + DRAFT.length, row, undefined); + expect(result.ok).toBe(true); + expect(cursorReads).not.toHaveBeenCalled(); + }); +}); + describe("transport.verify_input_box production wiring", () => { interface StageRecord { site: string } class RecordingStageTimer {