diff --git a/bridge/prompt-binding.test.ts b/bridge/prompt-binding.test.ts new file mode 100644 index 0000000..68e8d39 --- /dev/null +++ b/bridge/prompt-binding.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, test } from "bun:test"; + +import { + DEFAULT_PROMPT_TAIL_LINES, + normalizePromptRegion, + verifyExpectedPrompt, +} from "./prompt-binding.ts"; + +describe("normalizePromptRegion", () => { + test("strips SGR sequences and normalizes CRLF and CR line endings", () => { + expect(normalizePromptRegion("\x1b[31mApprove?\x1b[0m\r\n\x1b[1m1. Yes\x1b[0m\r2. No")).toEqual([ + "Approve?", + "1. Yes", + "2. No", + ]); + }); + + test("ignores trailing terminal padding", () => { + const compact = normalizePromptRegion("Approve this command?\n1. Yes\n2. No"); + const redrawn = normalizePromptRegion("Approve this command? \n1. Yes \n2. No "); + expect(redrawn).toEqual(compact); + }); + + // Whitespace inside diffs and commands is semantic. Redraw tolerance must not erase it. + test("preserves leading indentation", () => { + expect(normalizePromptRegion(" diff line")).toEqual([" diff line"]); + }); + + test("preserves internal alignment", () => { + expect(normalizePromptRegion("key: aligned")).toEqual(["key: aligned"]); + }); + + test("does not equate regions that differ only in indentation", () => { + expect(normalizePromptRegion("Apply edit?\n return value;")).not.toEqual( + normalizePromptRegion("Apply edit?\n return value;"), + ); + }); + + test("ignores blank-line layout changes", () => { + const compact = normalizePromptRegion("Approve?\n1. Yes\n2. No"); + const redrawn = normalizePromptRegion("\nApprove?\n\n\n1. Yes\n \t \n2. No\n"); + expect(redrawn).toEqual(compact); + }); + + test("preserves wording changes", () => { + expect(normalizePromptRegion("Approve this command?\n1. Yes")).not.toEqual( + normalizePromptRegion("Approve this file?\n1. Yes"), + ); + }); +}); + +describe("verifyExpectedPrompt", () => { + test("accepts an exact contiguous match", () => { + expect(verifyExpectedPrompt("older output\nApprove?\n1. Yes\n2. No", "Approve?\n1. Yes\n2. No")).toEqual({ + ok: true, + }); + }); + + test("accepts a match after harmless terminal repadding", () => { + expect( + verifyExpectedPrompt( + "older output\nApprove this? \n1. Yes \n\n2. No ", + "Approve this?\n1. Yes\n2. No", + ), + ).toEqual({ ok: true }); + }); + + test("returns not_found after the prompt was answered and replaced", () => { + expect(verifyExpectedPrompt("Running tests\nAll done", "Approve?\n1. Yes\n2. No")).toEqual({ + ok: false, + reason: "not_found", + }); + }); + + test("returns not_in_tail when the region exists only high in scrollback", () => { + const fresh = [ + "Approve?", + "1. Yes", + "2. No", + ...Array.from({ length: DEFAULT_PROMPT_TAIL_LINES + 1 }, (_, i) => `line ${i}`), + ].join("\n"); + expect(verifyExpectedPrompt(fresh, "Approve?\n1. Yes\n2. No")).toEqual({ + ok: false, + reason: "not_in_tail", + }); + }); + + test("refuses a stale region when a full replacement prompt is rendered below it", () => { + const expected = ["Apply this edit?", " old value", " new value", "1. Yes", "2. No"].join("\n"); + const replacement = [ + "Apply this different edit?", + " function replacement() {", + " const first = true;", + " const second = false;", + " const third = null;", + " return { first, second, third };", + " }", + "", + "This change affects:", + " bridge/server.ts", + " bridge/server.test.ts", + "", + "Choose an action:", + "1. Apply", + "2. Apply and remember", + "3. Refuse", + "4. Explain", + "5. Open details", + "6. Cancel", + "7. Retry", + "8. Show diff", + "Selection:", + ].join("\n"); + + expect(verifyExpectedPrompt(`${expected}\n${replacement}`, expected)).toEqual({ + ok: false, + reason: "not_in_tail", + }); + }); + + test("returns empty when expected normalizes to no lines", () => { + expect(verifyExpectedPrompt("Approve?\n1. Yes", " \r\n\t\n")).toEqual({ + ok: false, + reason: "empty", + }); + }); + + test("uses the last occurrence when a region appears more than once", () => { + const gap = Array.from({ length: DEFAULT_PROMPT_TAIL_LINES + 5 }, (_, i) => `output ${i}`); + const fresh = ["Approve?", "1. Yes", ...gap, "Approve?", "1. Yes"].join("\n"); + expect(verifyExpectedPrompt(fresh, "Approve?\n1. Yes")).toEqual({ ok: true }); + }); + + test("requires the expected lines to be contiguous", () => { + expect( + verifyExpectedPrompt( + "Approve?\nunrelated output\n1. Yes\nmore output\n2. No", + "Approve?\n1. Yes\n2. No", + ), + ).toEqual({ ok: false, reason: "not_found" }); + }); +}); + +// ── The client/bridge contract ─────────────────────────────────────────────── +// +// The load-bearing risk in this feature is not a wrong comparison, it is a SILENT DIVERGENCE. The +// client derives the region it sends from pane text it has already parsed ANSI away from; the bridge +// verifies that region against the RAW `pane.read`. Nothing in the type system couples the two. If +// either side's normalisation drifts, every legitimate approval starts failing with "prompt changed" +// and the honest-looking symptom is a feature users switch off. +// +// So both sides are pinned to the same committed expectation. `prompt-binding-regions.json` holds, +// for each real pane fixture, the exact region string its detector produces. THIS test proves the +// bridge still finds those regions in the raw fixtures. Its sibling in +// web/src/lib/harness/prompt-binding-contract.test.ts proves the client detectors still produce them +// byte-for-byte. Neither side can drift without one of the two going red. +// +// Regenerating the JSON to make a failure "go away" defeats the point: if the web test is the one +// that fails, the detector changed and the regions may legitimately need regenerating; if THIS test +// fails, the bridge's normalisation stopped matching text the client will really send. + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const FIXTURE_DIR = join(import.meta.dir, "..", "web", "src", "fixtures", "panes"); +const REGIONS = JSON.parse( + readFileSync(join(import.meta.dir, "..", "web", "src", "fixtures", "prompt-binding-regions.json"), "utf8"), +) as { fixture: string; detector: string; region: string }[]; + +describe("client/bridge binding contract", () => { + test("the committed expectations cover every dialog detector", () => { + expect(REGIONS.length).toBeGreaterThan(0); + expect([...new Set(REGIONS.map((r) => r.detector))].sort()).toEqual([ + "multi-select", + "preview-select", + "prompt-select", + "wizard", + ]); + }); + + for (const { fixture, detector, region } of REGIONS) { + test(`${detector}: the bridge finds ${fixture}'s region in the raw pane read`, () => { + const raw = readFileSync(join(FIXTURE_DIR, fixture), "utf8"); + expect(verifyExpectedPrompt(raw, region)).toEqual({ ok: true }); + }); + } + + test("a region from a different dialog is never accepted", () => { + let compared = 0; + for (const a of REGIONS) { + const raw = readFileSync(join(FIXTURE_DIR, a.fixture), "utf8"); + for (const b of REGIONS) { + if (a.fixture === b.fixture) continue; + compared++; + expect(verifyExpectedPrompt(raw, b.region).ok).toBe(false); + } + } + expect(compared).toBeGreaterThan(0); + }); +}); diff --git a/bridge/prompt-binding.ts b/bridge/prompt-binding.ts new file mode 100644 index 0000000..890d584 --- /dev/null +++ b/bridge/prompt-binding.ts @@ -0,0 +1,51 @@ +/** + * Normalize a rendered prompt region for comparison across terminal redraws. + * + * A terminal redraw can append trailing padding or change blank-line layout without changing the + * question, so trailing whitespace and blank lines are ignored. Leading indentation and internal + * alignment must survive because they can be semantic content in a displayed diff or command. + */ +const SGR_SEQUENCE = /(?:\x1b\[|\x9b)[0-?]*[ -/]*m/g; + +export function normalizePromptRegion(text: string): string[] { + return text + .replace(SGR_SEQUENCE, "") + .replace(/\r\n?/g, "\n") + .split("\n") + .map((line) => line.replace(/\s+$/, "")) + .filter((line) => line.length > 0); +} + +// Across all 20 committed fixture regions, at most one normalized line follows a match. Six lines +// leave generous headroom for a status or spinner update while ensuring a replacement prompt, whose +// regions span 20 to 32 normalized lines, pushes a stale match outside the accepted tail. +export const DEFAULT_PROMPT_TAIL_LINES = 6; + +export type PromptBindingResult = + | { ok: true } + | { ok: false; reason: "empty" | "not_found" | "not_in_tail" }; + +export function verifyExpectedPrompt( + freshText: string, + expected: string, + tailLines = DEFAULT_PROMPT_TAIL_LINES, +): PromptBindingResult { + const freshLines = normalizePromptRegion(freshText); + const expectedLines = normalizePromptRegion(expected); + if (expectedLines.length === 0) return { ok: false, reason: "empty" }; + + let lastMatch = -1; + candidate: for (let start = 0; start <= freshLines.length - expectedLines.length; start++) { + for (let offset = 0; offset < expectedLines.length; offset++) { + if (freshLines[start + offset] !== expectedLines[offset]) continue candidate; + } + lastMatch = start; + } + if (lastMatch === -1) return { ok: false, reason: "not_found" }; + + const boundedTailLines = Math.max(0, Math.floor(tailLines)); + const tailStart = Math.max(0, freshLines.length - boundedTailLines); + const matchEnd = lastMatch + expectedLines.length - 1; + if (matchEnd < tailStart) return { ok: false, reason: "not_in_tail" }; + return { ok: true }; +} diff --git a/bridge/server.test.ts b/bridge/server.test.ts index 5f36871..fbf1e06 100644 --- a/bridge/server.test.ts +++ b/bridge/server.test.ts @@ -8,16 +8,19 @@ import { guard, historyParams, isHostAllowed, + keysPane, normalizeTabLabel, paneReadResponse, + replyPane, resolveStaticPath, sendReplySteps, startupWarnings, withBuildHeader, type ReplySender, } from "./server.ts"; +import { AuditLog } from "./audit.ts"; import type { Config } from "./config.ts"; -import type { PaneRead } from "./herdr-client.ts"; +import type { HerdrClient, PaneRead } from "./herdr-client.ts"; // checkAccess is the API security gate (same-origin/CSRF + optional Tailscale identity). A // regression here silently opens remote shell access, so it gets the most direct coverage. @@ -307,6 +310,249 @@ describe("sendReplySteps — two-step send & partial-failure clarity", () => { }); }); +describe("pane write prompt binding", () => { + type ReadArgs = Parameters; + + class FakePaneClient { + text = "Approve this command?\n1. Yes\n2. No"; + readonly reads: ReadArgs[] = []; + readonly texts: Array<[string, string]> = []; + readonly keys: Array<[string, string[]]> = []; + + readPane( + paneId: string, + source: ReadArgs[1], + lines: number, + format: ReadArgs[3], + ): Promise { + this.reads.push([paneId, source, lines, format]); + return Promise.resolve({ + pane_id: paneId, + text: this.text, + truncated: false, + revision: 1, + }); + } + + sendPaneText(paneId: string, text: string): Promise { + this.texts.push([paneId, text]); + return Promise.resolve(); + } + + sendPaneKeys(paneId: string, keys: string[]): Promise { + this.keys.push([paneId, keys]); + return Promise.resolve(); + } + } + + function request(body: unknown): Request { + return new Request("http://localhost/api/pane/w1%3Ap1/action", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + } + + function auditEntries(): { audit: AuditLog; entries: Array> } { + const entries: Array> = []; + return { + audit: new AuditLog((line) => { + entries.push(JSON.parse(line)); + }), + entries, + }; + } + + test("keys without expected_prompt writes without an extra pane read", async () => { + const client = new FakePaneClient(); + const { audit } = auditEntries(); + const res = await keysPane( + client as unknown as HerdrClient, + cfg(), + "w1:p1", + request({ keys: ["1"] }), + audit, + null, + "default", + ); + expect(res.status).toBe(200); + expect(client.reads).toEqual([]); + expect(client.keys).toEqual([["w1:p1", ["1"]]]); + }); + + test("reply without expected_prompt writes without an extra pane read", async () => { + const client = new FakePaneClient(); + const { audit } = auditEntries(); + const res = await replyPane( + client as unknown as HerdrClient, + cfg(), + "w1:p1", + request({ text: "hello", submit: false }), + audit, + null, + "default", + ); + expect(res.status).toBe(200); + expect(client.reads).toEqual([]); + expect(client.texts).toEqual([["w1:p1", "hello"]]); + }); + + test("matching expected_prompt reads the GET window then sends keys", async () => { + const client = new FakePaneClient(); + const { audit, entries } = auditEntries(); + const res = await keysPane( + client as unknown as HerdrClient, + cfg({ readLines: 321 }), + "w1:p1", + request({ keys: ["1"], expected_prompt: "Approve this command?\n1. Yes\n2. No" }), + audit, + "phone", + "default", + ); + expect(res.status).toBe(200); + expect(client.reads).toEqual([["w1:p1", "recent", 321, "ansi"]]); + expect(client.keys).toEqual([["w1:p1", ["1"]]]); + expect(entries[0]?.detail).toMatchObject({ + promptBinding: { checked: true, passed: true }, + }); + }); + + test("binding read depth grows beyond a small configured window to contain the expectation", async () => { + const client = new FakePaneClient(); + const expected = Array.from({ length: 32 }, (_, index) => `prompt line ${index + 1}`).join("\n"); + client.text = expected; + const { audit } = auditEntries(); + const res = await keysPane( + client as unknown as HerdrClient, + cfg({ readLines: 20 }), + "w1:p1", + request({ keys: ["1"], expected_prompt: expected }), + audit, + null, + "default", + ); + + expect(res.status).toBe(200); + expect(client.reads).toHaveLength(1); + expect(client.reads[0]?.[0]).toBe("w1:p1"); + expect(client.reads[0]?.[1]).toBe("recent"); + expect(client.reads[0]?.[2]).toBeGreaterThan(32); + expect(client.reads[0]?.[3]).toBe("ansi"); + expect(client.keys).toEqual([["w1:p1", ["1"]]]); + }); + + test("matching expected_prompt reads the GET window then sends reply text", async () => { + const client = new FakePaneClient(); + const { audit } = auditEntries(); + const res = await replyPane( + client as unknown as HerdrClient, + cfg({ readLines: 321 }), + "w1:p1", + request({ + text: "hello", + submit: false, + expected_prompt: "Approve this command?\n1. Yes\n2. No", + }), + audit, + null, + "default", + ); + expect(res.status).toBe(200); + expect(client.reads).toEqual([["w1:p1", "recent", 321, "ansi"]]); + expect(client.texts).toEqual([["w1:p1", "hello"]]); + }); + + test("stale expected_prompt returns prompt_changed and sends no keys", async () => { + const client = new FakePaneClient(); + client.text = "Command finished"; + const { audit, entries } = auditEntries(); + const res = await keysPane( + client as unknown as HerdrClient, + cfg(), + "w1:p1", + request({ keys: ["1"], expected_prompt: "Approve this command?\n1. Yes\n2. No" }), + audit, + null, + "default", + ); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + ok: false, + error: "prompt changed", + code: "prompt_changed", + }); + expect(client.keys).toEqual([]); + expect(client.texts).toEqual([]); + expect(entries[0]?.detail).toMatchObject({ + promptBinding: { checked: true, passed: false, reason: "not_found" }, + }); + }); + + test("stale expected_prompt returns prompt_changed and sends no reply text or keys", async () => { + const client = new FakePaneClient(); + client.text = "Command finished"; + const { audit } = auditEntries(); + const res = await replyPane( + client as unknown as HerdrClient, + cfg(), + "w1:p1", + request({ + text: "hello", + submit: true, + expected_prompt: "Approve this command?\n1. Yes\n2. No", + }), + audit, + null, + "default", + ); + expect(res.status).toBe(409); + expect(await res.json()).toMatchObject({ ok: false, code: "prompt_changed" }); + expect(client.keys).toEqual([]); + expect(client.texts).toEqual([]); + }); + + test("rejects oversized and non-string expected_prompt before a keys write", async () => { + for (const expected_prompt of ["x".repeat(8193), 42]) { + const client = new FakePaneClient(); + const { audit } = auditEntries(); + const res = await keysPane( + client as unknown as HerdrClient, + cfg(), + "w1:p1", + request({ keys: ["1"], expected_prompt }), + audit, + null, + "default", + ); + expect(res.status).toBe(400); + expect(await res.text()).toBe("bad expected_prompt"); + expect(client.reads).toEqual([]); + expect(client.keys).toEqual([]); + } + }); + + test("rejects oversized and non-string expected_prompt before a reply write", async () => { + for (const expected_prompt of ["x".repeat(8193), null]) { + const client = new FakePaneClient(); + const { audit } = auditEntries(); + const res = await replyPane( + client as unknown as HerdrClient, + cfg(), + "w1:p1", + request({ text: "hello", expected_prompt }), + audit, + null, + "default", + ); + expect(res.status).toBe(400); + expect(await res.text()).toBe("bad expected_prompt"); + expect(client.reads).toEqual([]); + expect(client.texts).toEqual([]); + expect(client.keys).toEqual([]); + } + }); +}); + describe("paneReadResponse — pane read → REST body", () => { test("passes text, truncated, and the monotonic revision through", () => { const read: PaneRead = { pane_id: "w1:p1", text: "hello", truncated: true, revision: 42 }; diff --git a/bridge/server.ts b/bridge/server.ts index 11a6b99..1be778b 100644 --- a/bridge/server.ts +++ b/bridge/server.ts @@ -6,6 +6,11 @@ import type { Config } from "./config.ts"; import type { HerdrClient, PaneRead } from "./herdr-client.ts"; import { computeEtag, gzipJsonResponse, notModified } from "./http-cache.ts"; import type { NotifyPrefs, NotifyPrefsStore } from "./notify-prefs.ts"; +import { + DEFAULT_PROMPT_TAIL_LINES, + verifyExpectedPrompt, + type PromptBindingResult, +} from "./prompt-binding.ts"; import type { Push, PushSubscription } from "./push.ts"; import { herdTagFor, type SessionRegistry } from "./sessions.ts"; import type { Snooze } from "./snooze.ts"; @@ -36,6 +41,8 @@ const MAX_UPLOAD_OVERHEAD = 64 * 1024; // 64 KB const MAX_REQUEST_BODY_BYTES = 12 * 1024 * 1024; // 12 MB // Upper bound on the pane-read `lines` param — don't trust the client (or Herdr) to cap it. const MAX_READ_LINES = 10_000; +const MAX_EXPECTED_PROMPT_CHARS = 8192; +const PROMPT_BINDING_BLANK_LINE_HEADROOM = 6; const IMAGE_EXT: Record = { "image/png": "png", "image/jpeg": "jpg", @@ -206,7 +213,7 @@ export function startServer(opts: { if (action === "history" && req.method === "GET") return paneHistory(cfg, transcripts, rt.engine, paneId, url, req); if (action === "reply" && req.method === "POST") return replyPane(herdr, cfg, paneId, req, audit, device, session); - if (action === "keys" && req.method === "POST") return keysPane(herdr, paneId, req, audit, device, session); + if (action === "keys" && req.method === "POST") return keysPane(herdr, cfg, paneId, req, audit, device, session); if (action === "upload" && req.method === "POST") return uploadPane(cfg, paneId, req, audit, device, session); if (action === "close" && req.method === "POST") return closePane(herdr, paneId, req, audit, device, session); if (action === "rename" && req.method === "POST") return renamePane(herdr, paneId, req, audit, device, session); @@ -512,7 +519,7 @@ export async function sendReplySteps( } } -async function replyPane( +export async function replyPane( herdr: HerdrClient, cfg: Config, paneId: string, @@ -521,15 +528,36 @@ async function replyPane( device: string | null, session: string, ): Promise { - let body: { text?: string; submit?: boolean }; + let body: { text?: string; submit?: boolean; expected_prompt?: unknown }; try { body = (await req.json()) as typeof body; } catch { return text("bad body", 400); } + const expected = expectedPrompt(body); + if (!expected.ok) return text("bad expected_prompt", 400); const txt = body.text ?? ""; const submit = body.submit ?? true; const ae = req.headers.get("accept-encoding"); + const binding = expected.present + ? await checkPromptBinding(herdr, cfg, paneId, expected.value) + : null; + if (binding && !binding.ok) { + audit.record({ + action: "reply", + paneId, + session, + device, + detail: { + text: txt, + submit, + submitted: false, + textDelivered: false, + promptBinding: binding.audit, + }, + }); + return promptBindingFailure(binding, ae); + } const outcome = await sendReplySteps(herdr, paneId, txt, submit, cfg.submitKeys); // Audit the attempt regardless of outcome — text may have landed even when the submit failed. audit.record({ @@ -537,7 +565,13 @@ async function replyPane( paneId, session, device, - detail: { text: txt, submit, submitted: outcome.ok, textDelivered: outcome.textDelivered }, + detail: { + text: txt, + submit, + submitted: outcome.ok, + textDelivered: outcome.textDelivered, + ...(binding ? { promptBinding: binding.audit } : {}), + }, }); if (outcome.ok) return json({ ok: true } satisfies ActionResponse, ae); return json( @@ -546,32 +580,166 @@ async function replyPane( ); } -async function keysPane( +export async function keysPane( herdr: HerdrClient, + cfg: Config, paneId: string, req: Request, audit: AuditLog, device: string | null, session: string, ): Promise { - let body: { keys?: unknown }; + let body: { keys?: unknown; expected_prompt?: unknown }; try { body = (await req.json()) as typeof body; } catch { return text("bad body", 400); } + const expected = expectedPrompt(body); + if (!expected.ok) return text("bad expected_prompt", 400); const keys = Array.isArray(body.keys) ? body.keys.filter((k): k is string => typeof k === "string") : []; if (keys.length === 0) return text("no keys", 400); const ae = req.headers.get("accept-encoding"); + const binding = expected.present + ? await checkPromptBinding(herdr, cfg, paneId, expected.value) + : null; + if (binding && !binding.ok) { + audit.record({ + action: "keys", + paneId, + session, + device, + detail: { keys, promptBinding: binding.audit }, + }); + return promptBindingFailure(binding, ae); + } try { await herdr.sendPaneKeys(paneId, keys); - audit.record({ action: "keys", paneId, session, device, detail: { keys } }); + audit.record({ + action: "keys", + paneId, + session, + device, + detail: { keys, ...(binding ? { promptBinding: binding.audit } : {}) }, + }); return json({ ok: true } satisfies ActionResponse, ae); } catch (err) { + if (binding) { + audit.record({ + action: "keys", + paneId, + session, + device, + detail: { keys, sent: false, promptBinding: binding.audit }, + }); + } return json({ ok: false, error: (err as Error).message } satisfies ActionResponse, ae); } } +type ExpectedPrompt = + | { ok: true; present: false } + | { ok: true; present: true; value: string } + | { ok: false }; + +function expectedPrompt(body: object): ExpectedPrompt { + if (!Object.prototype.hasOwnProperty.call(body, "expected_prompt")) { + return { ok: true, present: false }; + } + const value = (body as { expected_prompt?: unknown }).expected_prompt; + if (typeof value !== "string" || value.length > MAX_EXPECTED_PROMPT_CHARS) { + return { ok: false }; + } + return { ok: true, present: true, value }; +} + +type PromptBindingCheck = + | { + ok: true; + audit: { checked: true; passed: true; expected: string }; + } + | { + ok: false; + error: string; + status: 409 | 502; + code?: "prompt_changed"; + audit: { + checked: true; + passed: false; + expected: string; + reason: Extract["reason"] | "read_failed"; + }; + }; + +// There is deliberately no expected_blocked flag. agent_status is not carried by pane.read, only by +// session.snapshot, so checking it would cost a second RPC before the write and widen the very +// window this feature exists to shrink. The region check already subsumes it: if the exact prompt +// text is still on screen, that prompt is still what the pane is showing. +async function checkPromptBinding( + herdr: HerdrClient, + cfg: Config, + paneId: string, + expected: string, +): Promise { + let fresh: PaneRead; + try { + const expectedRawLines = expected.split(/\r\n?|\n/).length; + const bindingReadLines = Math.min( + MAX_READ_LINES, + Math.max( + cfg.readLines, + expectedRawLines + DEFAULT_PROMPT_TAIL_LINES + PROMPT_BINDING_BLANK_LINE_HEADROOM, + ), + ); + // Keep this coupled to readPane(): use its recent source and ANSI format so the bridge verifies + // the same kind of pane data the GET handler serves. The line count deliberately does not follow + // cfg.readLines alone because a small legal setting may not contain the expected region; include + // room for the accepted tail and for blank separator lines that normalization drops. + fresh = await herdr.readPane(paneId, "recent", bindingReadLines, "ansi"); + } catch (err) { + return { + ok: false, + error: `herdr read failed: ${(err as Error).message}`, + status: 502, + audit: { checked: true, passed: false, expected, reason: "read_failed" }, + }; + } + + const result = verifyExpectedPrompt(fresh.text, expected); + if (!result.ok) { + return { + ok: false, + error: "prompt changed", + status: 409, + code: "prompt_changed", + audit: { checked: true, passed: false, expected, reason: result.reason }, + }; + } + + // This is a mitigation, not a guarantee. The re-read and the send_keys are two separate herdr + // RPCs, so a TOCTOU window remains by construction; it shrinks from seconds (poll interval + push + // latency + human reaction time) to the few milliseconds between two local RPCs. It removes the + // human-latency portion of the window, which is where essentially all of the real risk lives. + // Closing the window completely would need a conditional-input primitive in herdr (send_keys with + // a precondition rejected atomically server-side), which does not exist today. + return { ok: true, audit: { checked: true, passed: true, expected } }; +} + +function promptBindingFailure( + result: Extract, + acceptEncoding: string | null, +): Response { + return json( + { + ok: false, + error: result.error, + ...(result.code ? { code: result.code } : {}), + } satisfies ActionResponse, + acceptEncoding, + result.status, + ); +} + // Close a pane ("kill the agent"). Structural op — strictly less powerful than the text/keys // injection the bridge already allows, so it stays within the existing remote-shell threat model. async function closePane( @@ -976,8 +1144,10 @@ function secure(res: Response): Response { return res; } -function json(data: unknown, acceptEncoding: string | null): Response { - return secure(gzipJsonResponse(data, acceptEncoding)); +function json(data: unknown, acceptEncoding: string | null, status = 200): Response { + const response = gzipJsonResponse(data, acceptEncoding); + if (status === 200) return secure(response); + return secure(new Response(response.body, { status, headers: response.headers })); } /** diff --git a/bridge/types.ts b/bridge/types.ts index 535becc..74e139e 100644 --- a/bridge/types.ts +++ b/bridge/types.ts @@ -192,7 +192,12 @@ export type PaneHistoryResponse = */ export type ActionResponse = | { ok: true } - | { ok: false; error: string; textDelivered?: boolean }; + | { + ok: false; + error: string; + textDelivered?: boolean; + code?: "prompt_changed"; + }; /** POST /api/pane/:id/upload — image saved to a host file; `path` is the absolute path to ref. */ export type UploadResponse = { ok: true; path: string } | { ok: false; error: string }; diff --git a/web/src/components/prompt-select-block.test.tsx b/web/src/components/prompt-select-block.test.tsx index bb05eec..837988b 100644 --- a/web/src/components/prompt-select-block.test.tsx +++ b/web/src/components/prompt-select-block.test.tsx @@ -99,7 +99,12 @@ describe("submitPromptOption — race guard + per-family keystroke recipe", () = option: model.options[0]!, }); expect(res).toEqual({ status: "sent" }); - expect(mockSendKeys).toHaveBeenCalledWith("w1:p1", ["1", "Enter"], undefined); + expect(mockSendKeys).toHaveBeenCalledWith( + "w1:p1", + ["1", "Enter"], + undefined, + model.signature, + ); }); it("permission family: sends the digit ALONE (a trailing Enter would leak)", async () => { @@ -118,7 +123,7 @@ describe("submitPromptOption — race guard + per-family keystroke recipe", () = option: model.options[0]!, }); expect(res).toEqual({ status: "sent" }); - expect(mockSendKeys).toHaveBeenCalledWith("w1:p1", ["1"], undefined); + expect(mockSendKeys).toHaveBeenCalledWith("w1:p1", ["1"], undefined, model.signature); }); it("rejects (no send) when the fresh revision differs", async () => { @@ -178,7 +183,7 @@ describe("submitPromptOption — race guard + per-family keystroke recipe", () = option: model.options[1]!, }); expect(res).toEqual({ status: "sent" }); - expect(mockSendKeys).toHaveBeenCalledWith("w1:p1", ["2"], undefined); + expect(mockSendKeys).toHaveBeenCalledWith("w1:p1", ["2"], undefined, model.signature); }); it("rejects a 304 with MATCHING (stub) revisions when the cached text no longer shows the menu", async () => { @@ -244,6 +249,29 @@ describe("submitPromptOption — race guard + per-family keystroke recipe", () = }); expect(res).toEqual({ status: "error", error: "agent busy" }); }); + + it("maps a bridge prompt_changed conflict to changed", async () => { + const model = fixtureModel("claude--select-menu.txt"); + mockFetchPane.mockResolvedValue({ + paneId: "w1:p1", + text: fixtureText("claude--select-menu.txt"), + truncated: false, + revision: 5, + }); + mockSendKeys.mockResolvedValue({ + ok: false, + error: "prompt changed", + code: "prompt_changed", + }); + const res = await submitPromptOption({ + paneId: "w1:p1", + requestedLines: 600, + detectedRevision: 5, + prompt: model, + option: model.options[0]!, + }); + expect(res).toEqual({ status: "changed" }); + }); }); // A miniature of AgentChat's handler + status surface, so the wired tap is exercised through the @@ -289,7 +317,12 @@ describe("PromptSelectBlock — wired tap (component → handler → api)", () = await user.click(screen.getByRole("button", { name: /Red/ })); await waitFor(() => expect(screen.getByTestId("status")).toHaveTextContent("Sent")); - expect(mockSendKeys).toHaveBeenCalledWith("w1:p1", ["1", "Enter"], undefined); + expect(mockSendKeys).toHaveBeenCalledWith( + "w1:p1", + ["1", "Enter"], + undefined, + model.signature, + ); }); it("a stale tap surfaces a 'menu changed' notice and sends nothing", async () => { @@ -356,6 +389,6 @@ describe("submitPromptOption — same-shaped successor prompt (H1)", () => { option: promptA.options[0]!, }); expect(res).toEqual({ status: "sent" }); - expect(mockSendKeys).toHaveBeenCalledWith("w1:p1", ["1"], undefined); + expect(mockSendKeys).toHaveBeenCalledWith("w1:p1", ["1"], undefined, promptA.signature); }); }); diff --git a/web/src/components/wizard-block.test.tsx b/web/src/components/wizard-block.test.tsx index 69831c0..c9fc73d 100644 --- a/web/src/components/wizard-block.test.tsx +++ b/web/src/components/wizard-block.test.tsx @@ -184,7 +184,7 @@ describe("submitWizardKeys — race guard (one keystroke per tap)", () => { keys: ["2"], }); expect(res).toEqual({ status: "sent" }); - expect(mockSendKeys).toHaveBeenCalledWith("w1:p1", ["2"], undefined); + expect(mockSendKeys).toHaveBeenCalledWith("w1:p1", ["2"], undefined, model.signature); }); it("rejects (no send) when the wizard advanced to another step underfoot", async () => { @@ -263,6 +263,29 @@ describe("submitWizardKeys — race guard (one keystroke per tap)", () => { }); expect(res).toEqual({ status: "error", error: "agent busy" }); }); + + it("maps a bridge prompt_changed conflict to changed", async () => { + const model = fixtureModel("claude--wizard-q1.txt"); + mockFetchPane.mockResolvedValue({ + paneId: "w1:p1", + text: fixtureText("claude--wizard-q1.txt"), + truncated: false, + revision: 5, + }); + mockSendKeys.mockResolvedValue({ + ok: false, + error: "prompt changed", + code: "prompt_changed", + }); + const res = await submitWizardKeys({ + paneId: "w1:p1", + requestedLines: 600, + detectedRevision: 5, + wizard: model, + keys: ["3"], + }); + expect(res).toEqual({ status: "changed" }); + }); }); // A miniature of AgentChat's handler + status surface, so the wired tap is exercised through the @@ -308,7 +331,7 @@ describe("WizardBlock — wired tap (component → handler → api)", () => { await user.click(screen.getByRole("button", { name: /Tests/ })); await waitFor(() => expect(screen.getByTestId("status")).toHaveTextContent("Sent")); - expect(mockSendKeys).toHaveBeenCalledWith("w1:p1", ["3"], undefined); + expect(mockSendKeys).toHaveBeenCalledWith("w1:p1", ["3"], undefined, model.signature); }); it("a stale tap surfaces a 'wizard changed' notice and sends nothing", async () => { diff --git a/web/src/fixtures/prompt-binding-regions.json b/web/src/fixtures/prompt-binding-regions.json new file mode 100644 index 0000000..eb824f1 --- /dev/null +++ b/web/src/fixtures/prompt-binding-regions.json @@ -0,0 +1,102 @@ +[ + { + "fixture": "claude--permission-bash.txt", + "detector": "prompt-select", + "region": "│ /…/scratchpad/fixture-sandbox │ │\n╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯\n\n ▎ Fable 5 is back.\n ▎ Until July 7, you can use up to 50% of your plan's weekly usage limit on Fable 5. If you hit your limit, you can continue on Fable 5 with usage credits. Fable 5 draws down usage faster than Opus 4.8. Learn more\n ▎ (https://support.claude.com/en/articles/15424964-claude-fable-5-promotional-access)\n\n❯ Use the AskUserQuestion tool to ask me one question: which color theme should the dashboard use? Offer exactly three options: Red, Green, Blue. \n\n● User answered Claude's questions:\n ⎿  · Which color theme should the dashboard use? → Green\n\n● You chose the Green theme — a calm, natural look with green as the primary accent color. Let me know what you'd like to do next with the dashboard.\n\n✻ Sautéed for 9s\n\n❯ Create a file named hello.txt in this directory containing exactly the single word: hello \n\n● Write(hello.txt)\n ⎿  Wrote 1 line to hello.txt\n 1 hello\n\n● Created hello.txt containing the single word hello.\n\n✻ Sautéed for 5s\n\n❯ Now run exactly this bash command: mkfifo fixture-fifo \n\n Running 1 shell command…\n ⎿ $ mkfifo fixture-fifo\n\n─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Bash command\n\n mkfifo fixture-fifo\n Create a named pipe (FIFO)\n\n This command requires approval\n\n Do you want to proceed?\n ❯ 1. Yes\n 2. Yes, and don’t ask again for: mkfifo fixture-fifo *\n 3. No\n\n Esc to cancel · Tab to amend · ctrl+e to explain" + }, + { + "fixture": "claude--permission-edit.txt", + "detector": "prompt-select", + "region": "\n╭─── Claude Code v2.1.201 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮\n│ │ Tips for getting started │\n│ Welcome back Altan! │ Ask Claude to create a new app or clone a repository │\n│ │ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── │\n│ ▐▛███▜▌ │ What's new │\n│ ▝▜█████▛▘ │ Claude Sonnet 5 sessions no longer use the mid-conversation system role for harness reminders │\n│ ▘▘ ▝▝ │ Changed `AskUserQuestion` dialogs to no longer auto-continue by default; opt into an idle timeout via `/config` │\n│ Opus 4.8 with xhigh effort · Claude Max · │ Changed the \"default\" permission mode to \"Manual\" across the CLI, `--help`, VS Code, and JetBrains; `--permission-mode manual` and `\"defaultMode\": \"manual\"… │\n│ sandboxdev@example.com's Organization │ /release-notes for more │\n│ /…/scratchpad/fixture-sandbox │ │\n╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯\n\n ▎ Fable 5 is back.\n ▎ Until July 7, you can use up to 50% of your plan's weekly usage limit on Fable 5. If you hit your limit, you can continue on Fable 5 with usage credits. Fable 5 draws down usage faster than Opus 4.8. Learn more\n ▎ (https://support.claude.com/en/articles/15424964-claude-fable-5-promotional-access)\n\n❯ Use the AskUserQuestion tool to ask me one question: which color theme should the dashboard use? Offer exactly three options: Red, Green, Blue. \n\n● User answered Claude's questions:\n ⎿  · Which color theme should the dashboard use? → Green\n\n● You chose the Green theme — a calm, natural look with green as the primary accent color. Let me know what you'd like to do next with the dashboard.\n\n✻ Sautéed for 9s\n\n❯ Create a file named hello.txt in this directory containing exactly the single word: hello \n\n● Write(hello.txt)\n\n─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Create file\n hello.txt\n╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌\n 1 hello\n╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌\n Do you want to create hello.txt?\n ❯ 1. Yes\n 2. Yes, allow all edits during this session (shift+tab)\n 3. No\n\n Esc to cancel · Tab to amend" + }, + { + "fixture": "claude--plan-approval--numbered-body.txt", + "detector": "prompt-select", + "region": "▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔\n\n ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Ready to code?\n\n Here is Claude's plan:\n ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌\n Plan: Add README.md to biscuit\n\n Context\n\n /var/home/altan/playground/collie-demo-herd/biscuit is a completely empty directory (created 2026-07-09; no files, no dotfiles, no git). It sits in the collie-demo-herd\n playground alongside four dog-named siblings (clover, comet, juniper, thistle), which suggests each subdirectory is a separate demo/sandbox. Since there is no code to\n document, the README will be a short orientation/placeholder doc that gives the directory an identity until real content lands.\n\n Change\n\n Create one file: /var/home/altan/playground/collie-demo-herd/biscuit/README.md with:\n\n 1. Title — # biscuit\n 2. One-line description — a demo/sandbox directory in the collie-demo-herd playground.\n 3. Status — empty placeholder; no code yet.\n 4. Context — sibling demos in the herd (clover, comet, juniper, thistle) for orientation.\n 5. TODO stub — a short section to fill in purpose / setup / usage once the demo exists.\n\n Keep it under ~20 lines. No other files created or modified.\n\n Verification\n\n - ls the directory to confirm README.md is the sole file.\n - Render-check the markdown (read it back / glance at formatting) — headings and list syntax valid.\n ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌\n\n\n\n\n\n ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Claude has written up a plan and is ready to execute. Would you like to proceed?\n\n ❯ 1. Yes, and use auto mode\n 2. Yes, manually approve edits\n 3. No, refine with Ultraplan on Claude Code on the web\n 4. Tell Claude what to change\n shift+tab to approve with this feedback\n\n ctrl+g to edit in nano · ~/.claude/plans/make-a-short-plan-bubbly-neumann.md" + }, + { + "fixture": "claude--plan-approval.txt", + "detector": "prompt-select", + "region": " Change\n\n Create haiku.txt in the working directory (fixture-sandbox/) with a three-line dog haiku, for example:\n\n Loyal wagging tail\n Bounding through the morning grass\n Joy on four soft paws\n\n Syllable check: 5 / 7 / 5.\n\n Files\n\n - haiku.txt (new)\n\n Verification\n\n - Confirm the file exists and contains exactly three lines.\n - Read it back to confirm the 5–7–5 structure and dog theme.\n ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌\n\n\n\n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Claude has written up a plan and is ready to execute. Would you like to proceed?\n\n ❯ 1. Yes, and use auto mode\n 2. Yes, manually approve edits\n 3. No, refine with Ultraplan on Claude Code on the web\n 4. Tell Claude what to change\n shift+tab to approve with this feedback\n\n ctrl+g to edit in nano · ~/.claude/plans/velvet-toasting-turtle.md" + }, + { + "fixture": "claude--select-menu.txt", + "detector": "prompt-select", + "region": "\n╭─── Claude Code v2.1.201 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮\n│ │ Tips for getting started │\n│ Welcome back Altan! │ Ask Claude to create a new app or clone a repository │\n│ │ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── │\n│ ▐▛███▜▌ │ What's new │\n│ ▝▜█████▛▘ │ Claude Sonnet 5 sessions no longer use the mid-conversation system role for harness reminders │\n│ ▘▘ ▝▝ │ Changed `AskUserQuestion` dialogs to no longer auto-continue by default; opt into an idle timeout via `/config` │\n│ Opus 4.8 with xhigh effort · Claude Max · │ Changed the \"default\" permission mode to \"Manual\" across the CLI, `--help`, VS Code, and JetBrains; `--permission-mode manual` and `\"defaultMode\": \"manual\"… │\n│ sandboxdev@example.com's Organization │ /release-notes for more │\n│ /…/scratchpad/fixture-sandbox │ │\n╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯\n\n ▎ Fable 5 is back.\n ▎ Until July 7, you can use up to 50% of your plan's weekly usage limit on Fable 5. If you hit your limit, you can continue on Fable 5 with usage credits. Fable 5 draws down usage faster than Opus 4.8. Learn more\n ▎ (https://support.claude.com/en/articles/15424964-claude-fable-5-promotional-access)\n\n❯ Use the AskUserQuestion tool to ask me one question: which color theme should the dashboard use? Offer exactly three options: Red, Green, Blue. \n─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n ☐ Color Theme \n\nWhich color theme should the dashboard use?\n\n❯ 1. Red\n A warm, high-energy theme with red as the primary accent color.\n 2. Green\n A calm, natural theme with green as the primary accent color.\n 3. Blue\n A cool, professional theme with blue as the primary accent color.\n 4. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n 5. Chat about this\n\nEnter to select · ↑/↓ to navigate · Esc to cancel" + }, + { + "fixture": "claude--select-multi.txt", + "detector": "wizard", + "region": "← ☒ Focus area ☐ Scope ☐ Workflow ✔ Submit →\n\nHow should I approach the work?\n\n❯ 1. Plan first\n I draft a plan and get your approval before writing any code.\n 2. Just build it\n I go straight to implementation and show you the diff when done.\n 3. Build + verify\n I implement, then build and drive the app end-to-end to confirm it works before handing back.\n 4. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n 5. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel" + }, + { + "fixture": "claude--select-multiselect-checked.txt", + "detector": "multi-select", + "region": "← ☒ Toppings ✔ Submit →\n\nWhich pizza toppings do you want?\n\n❯ 1. [ ] Cheese\n Classic melted cheese topping.\n 2. [✔] Mushrooms\n Sliced mushrooms.\n 3. [✔] Olives\n Sliced olives.\n 4. [ ] Peppers\n Sliced peppers.\n 5. [ ] Type something\n Submit\n─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n 6. Chat about this" + }, + { + "fixture": "claude--select-multiselect-review.txt", + "detector": "multi-select", + "region": "← ☐ Toppings ✔ Submit →\n\nReview your answers\n \n⚠ You have not answered all questions\n \nReady to submit your answers?\n \n❯ 1. Submit answers\n 2. Cancel" + }, + { + "fixture": "claude--select-multiselect-single.txt", + "detector": "multi-select", + "region": "← ☐ Toppings ✔ Submit →\n\nWhich pizza toppings do you want?\n\n❯ 1. [ ] Cheese\n Classic melted cheese topping.\n 2. [ ] Mushrooms\n Sliced mushrooms.\n 3. [ ] Olives\n Sliced olives.\n 4. [ ] Peppers\n Sliced peppers.\n 5. [ ] Type something\n Submit\n─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n 6. Chat about this" + }, + { + "fixture": "claude--select-preview-note-attached.txt", + "detector": "preview-select", + "region": "❯ Now use the AskUserQuestion tool to ask me exactly ONE question: \"Which widget design should we use?\" with 3 options \n (Boxy, Rounded, Minimal), each having a short description AND a preview field containing a small ASCII mockup (plain \n text). Not multiSelect. Do nothing else afterward. \n\n● User answered Claude's questions:\n ⎿  · Which widget design should we use? → (notes only)\n \n● You didn't select any of the three options, but left a note: \"prefer thicker borders.\" Stopping here as requested — let me\n know if you'd like me to mock up a thick-bordered variant.\n\n✻ Cooked for 13s\n\n❯ Ask me the exact same AskUserQuestion again (same question, options, descriptions, previews). Do nothing else afterward. \n\n─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n ☐ Design \n\nWhich widget design should we use?\n\n❯ 1. Boxy ┌──────────────────────────────────────────┐\n 2. Rounded │ +----------------------+ │\n 3. Minimal │ | WIDGET [X] | │ \n │ +----------------------+ │\n │ | Status: OK | │\n │ | Value: 42 | │\n │ +----------------------+ │\n │ | [ OK ] [ CANCEL ] | │\n │ +----------------------+ │\n └──────────────────────────────────────────┘\n\n Notes: prefer subtle shadows\n\n─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Chat about this\n\nEnter to select · ↑/↓ to navigate · n to add notes · Esc to cancel" + }, + { + "fixture": "claude--select-preview-note-input.txt", + "detector": "preview-select", + "region": "❯ Use the AskUserQuestion tool to ask me exactly ONE question: \"Which color should the widget be?\" with options Red, Blue, \n Green (each with a short description). Do nothing else afterward. \n\n● User answered Claude's questions:\n ⎿  · Which color should the widget be? → Red\n\n● You picked Red. Noted — no further action taken, as requested.\n \n✻ Cogitated for 9s\n \n❯ Now use the AskUserQuestion tool to ask me exactly ONE question: \"Which widget design should we use?\" with 3 options \n (Boxy, Rounded, Minimal), each having a short description AND a preview field containing a small ASCII mockup (plain \n text). Not multiSelect. Do nothing else afterward. \n\n─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n ☐ Design \n\nWhich widget design should we use?\n\n❯ 1. Boxy ┌──────────────────────────────────────────┐\n 2. Rounded │ +----------------------+ │\n 3. Minimal │ | WIDGET [X] | │\n │ +----------------------+ │\n │ | Status: OK | │\n │ | Value: 42 | │\n │ +----------------------+ │\n │ | [ OK ] [ CANCEL ] | │\n │ +----------------------+ │\n └──────────────────────────────────────────┘\n\n Notes: Add notes on this design… \n\n─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Chat about this\n\nEnter to select · ↑/↓ to navigate · n to add notes · ctrl+g to edit in nano · Esc to cancel" + }, + { + "fixture": "claude--select-preview.txt", + "detector": "preview-select", + "region": "❯ Use the AskUserQuestion tool to ask me exactly ONE question: \"Which color should the widget be?\" with options Red, Blue, \n Green (each with a short description). Do nothing else afterward. \n\n● User answered Claude's questions:\n ⎿  · Which color should the widget be? → Red\n\n● You picked Red. Noted — no further action taken, as requested.\n \n✻ Cogitated for 9s\n \n❯ Now use the AskUserQuestion tool to ask me exactly ONE question: \"Which widget design should we use?\" with 3 options \n (Boxy, Rounded, Minimal), each having a short description AND a preview field containing a small ASCII mockup (plain \n text). Not multiSelect. Do nothing else afterward. \n\n─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n ☐ Design \n\nWhich widget design should we use?\n\n❯ 1. Boxy ┌──────────────────────────────────────────┐\n 2. Rounded │ +----------------------+ │\n 3. Minimal │ | WIDGET [X] | │\n │ +----------------------+ │\n │ | Status: OK | │\n │ | Value: 42 | │\n │ +----------------------+ │\n │ | [ OK ] [ CANCEL ] | │\n │ +----------------------+ │\n └──────────────────────────────────────────┘\n\n Notes: press n to add notes \n\n─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Chat about this\n\nEnter to select · ↑/↓ to navigate · n to add notes · Esc to cancel" + }, + { + "fixture": "claude--trust-prompt.txt", + "detector": "prompt-select", + "region": "\n 󱍢 Welcome to Bluefin \n \n 󱋩 ghcr.io/ublue-os/bluefin-dx:stable \n \n  Command │ Description \n ───────────────────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────── \n ujust --choose │ Show available commands \n ujust toggle-user-motd │ Toggle this banner on/off \n ujust bluefin-cli │ Enable terminal bling \n brew help │ Manage command line packages \n \n Help keep Bluefin alive and healthy, consider donating https://docs.projectbluefin.io/donations \n \n • 󰊤 Issues https://issues.projectbluefin.io \n • 󰊤 Ask Bluefin https://ask.projectbluefin.io \n • 󰈙 Documentation https://docs.projectbluefin.io \n\n\naltan in 🌐 bluefin in bad3b8de-6e92-4541-b9cc-4af710c91d88/scratchpad/fixture-sandbox \n❯ claude\n\n─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Accessing workspace:\n\n /tmp/claude-1000/-var-home-altan-playground-collie/bad3b8de-6e92-4541-b9cc-4af710c91d88/scratchpad/fixture-sandbox\n\n Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open source project, or work from your team). If not, take a moment to review what's in this folder first.\n\n Claude Code'll be able to read, edit, and execute files here.\n\n Security guide\n\n ❯ 1. Yes, I trust this folder\n 2. No, exit\n\n Enter to confirm · Esc to cancel" + }, + { + "fixture": "claude--wizard-preview-note-attached.txt", + "detector": "preview-select", + "region": " ⎿  · Which widget design should we use? → (notes only)\n\n● You didn't select any of the three options, but left a note: \"prefer thicker borders.\" Stopping here as requested — let me know if you'd like me to mock up a thick-bordered variant.\n\n✻ Cooked for 13s\n\n❯ Ask me the exact same AskUserQuestion again (same question, options, descriptions, previews). Do nothing else afterward. \n\n● User answered Claude's questions:\n ⎿  · Which widget design should we use? → Minimal\n\n● You selected Minimal this time, with a note: \"prefer subtle shadows.\" Stopping here as requested.\n\n✻ Churned for 11s\n\n❯ Now use AskUserQuestion ONCE with TWO questions in the same tool call: Q1 \"Which card layout should we use?\" with options Grid, List (short descriptions, and each option MUST have a preview field with a small ASCII \n mockup). Q2 \"Should we add dark mode?\" with options Yes, No (short descriptions, NO previews). Not multiSelect. Do nothing else afterward. \n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n← ☐ Card layout ☐ Dark mode ✔ Submit →\n\nWhich card layout should we use?\n\n❯ 1. Grid ┌──────────────────────────────────────────┐\n 2. List │ +--------+ +--------+ │\n │ | Card 1 | | Card 2 | │\n │ | .... | | .... | │\n │ +--------+ +--------+ │\n │ +--------+ +--------+ │\n │ | Card 3 | | Card 4 | │\n │ | .... | | .... | │\n │ +--------+ +--------+ │\n └──────────────────────────────────────────┘\n\n Notes: keep cards compact\n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Chat about this\n\nEnter to select · ↑/↓ to navigate · n to add notes · Tab to switch questions · Esc to cancel" + }, + { + "fixture": "claude--wizard-preview-q1.txt", + "detector": "preview-select", + "region": " ⎿  · Which widget design should we use? → (notes only)\n\n● You didn't select any of the three options, but left a note: \"prefer thicker borders.\" Stopping here as requested — let me know if you'd like me to mock up a thick-bordered variant.\n\n✻ Cooked for 13s\n\n❯ Ask me the exact same AskUserQuestion again (same question, options, descriptions, previews). Do nothing else afterward. \n\n● User answered Claude's questions:\n ⎿  · Which widget design should we use? → Minimal\n\n● You selected Minimal this time, with a note: \"prefer subtle shadows.\" Stopping here as requested.\n\n✻ Churned for 11s\n\n❯ Now use AskUserQuestion ONCE with TWO questions in the same tool call: Q1 \"Which card layout should we use?\" with options Grid, List (short descriptions, and each option MUST have a preview field with a small ASCII \n mockup). Q2 \"Should we add dark mode?\" with options Yes, No (short descriptions, NO previews). Not multiSelect. Do nothing else afterward. \n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n← ☐ Card layout ☐ Dark mode ✔ Submit →\n\nWhich card layout should we use?\n\n❯ 1. Grid ┌──────────────────────────────────────────┐\n 2. List │ +--------+ +--------+ │\n │ | Card 1 | | Card 2 | │\n │ | .... | | .... | │\n │ +--------+ +--------+ │\n │ +--------+ +--------+ │\n │ | Card 3 | | Card 4 | │\n │ | .... | | .... | │\n │ +--------+ +--------+ │\n └──────────────────────────────────────────┘\n\n Notes: press n to add notes\n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Chat about this\n\nEnter to select · ↑/↓ to navigate · n to add notes · Tab to switch questions · Esc to cancel" + }, + { + "fixture": "claude--wizard-q1-revisit.txt", + "detector": "wizard", + "region": "← ☒ Focus area ☐ Scope ☐ Workflow ✔ Submit →\n \nWhich focus area should we work on?\n\n❯ 1. Parser\n Work on parsing logic and input handling.\n 2. UI ✔\n Work on the user interface and presentation layer.\n 3. Tests\n Work on test coverage and verification.\n 4. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n 5. Chat about this\n \nEnter to select · Tab/Arrow keys to navigate · Esc to cancel" + }, + { + "fixture": "claude--wizard-q1.txt", + "detector": "wizard", + "region": "← ☐ Focus area ☐ Scope ☐ Workflow ✔ Submit →\n\nWhich focus area should we work on?\n\n❯ 1. Parser\n Work on parsing logic and input handling.\n 2. UI\n Work on the user interface and presentation layer.\n 3. Tests\n Work on test coverage and verification.\n 4. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n 5. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel" + }, + { + "fixture": "claude--wizard-q2.txt", + "detector": "wizard", + "region": "← ☒ Focus area ☐ Scope ☐ Workflow ✔ Submit →\n\nWhat scope should this work have?\n\n❯ 1. Small\n A focused, minimal change touching little surface area.\n 2. Medium\n A moderate change spanning a few files or components.\n 3. Large\n A substantial change across many files or subsystems.\n 4. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n 5. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel" + }, + { + "fixture": "claude--wizard-submit-unanswered.txt", + "detector": "wizard", + "region": "← ☐ Focus area ☐ Scope ☐ Workflow ✔ Submit →\n\nReview your answers\n\n⚠ You have not answered all questions\n \nReady to submit your answers?\n \n❯ 1. Submit answers\n 2. Cancel" + }, + { + "fixture": "claude--wizard-submit.txt", + "detector": "wizard", + "region": "← ☒ Focus area ☒ Scope ☒ Workflow ✔ Submit →\n\nReview your answers\n\n ● Which focus area should we work on?\n → UI\n ● What scope should this work have?\n → Medium\n ● How should we approach the work?\n → Plan first\n \nReady to submit your answers?\n\n❯ 1. Submit answers\n 2. Cancel" + } +] diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index 8802dee..4b75404 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -8,6 +8,7 @@ import { createTab, fetchPane, fetchSnapshot, + sendKeys, sendReply, uploadImage, withTimeout, @@ -33,6 +34,62 @@ describe("api client", () => { await expect(sendReply("w1:p1", "hi")).rejects.toThrow(/herdr down/); }); + it("adds expected_prompt to reply and keys bodies only when supplied", async () => { + const bodies: unknown[] = []; + server.use( + http.post(/\/api\/pane\/[^/]+\/(reply|keys)$/, async ({ request }) => { + bodies.push(await request.json()); + return HttpResponse.json({ ok: true }); + }), + ); + + await sendReply("w1:p1", "hi", true, undefined, "Approve?\n1. Yes"); + await sendKeys("w1:p1", ["1"], undefined, "Approve?\n1. Yes"); + await sendKeys("w1:p1", ["Left"]); + + expect(bodies).toEqual([ + { text: "hi", submit: true, expected_prompt: "Approve?\n1. Yes" }, + { keys: ["1"], expected_prompt: "Approve?\n1. Yes" }, + { keys: ["Left"] }, + ]); + }); + + it("returns the structured prompt_changed result instead of throwing on 409", async () => { + server.use( + http.post(/\/api\/pane\/[^/]+\/keys$/, () => + HttpResponse.json( + { ok: false, error: "prompt changed", code: "prompt_changed" }, + { status: 409 }, + ), + ), + ); + await expect(sendKeys("w1:p1", ["1"], undefined, "Approve?")).resolves.toEqual({ + ok: false, + error: "prompt changed", + code: "prompt_changed", + }); + }); + + // The bridge runs the binding check on BOTH endpoints that accept `expected_prompt`, so reply + // must recover a 409 exactly like keys. They are easy to let drift apart: the recovery used to be + // blanket handling inside the transport, and moving it to the call sites is precisely the moment + // one of them gets forgotten and starts throwing where the other returns a value. + it("returns the structured prompt_changed result instead of throwing on 409 for reply too", async () => { + server.use( + http.post(/\/api\/pane\/[^/]+\/reply$/, () => + HttpResponse.json( + { ok: false, error: "prompt changed", code: "prompt_changed" }, + { status: 409 }, + ), + ), + ); + await expect(sendReply("w1:p1", "hi", true, undefined, "Approve?")).resolves.toEqual({ + ok: false, + error: "prompt changed", + code: "prompt_changed", + }); + }); + it("uploadImage posts multipart and returns the saved path", async () => { server.use( http.post(/\/api\/pane\/[^/]+\/upload$/, () => HttpResponse.json({ ok: true, path: "/tmp/x.png" })), diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 0316f48..2084435 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -85,6 +85,35 @@ async function errorDetail(res: Response): Promise { } } +/** + * The recover handler for the two endpoints that accept `expected_prompt`: reply and keys. A + * rejected binding is their normal answer, not a transport failure. The bridge refuses the write + * because the prompt moved, and the caller renders "the dialog changed". Both must share it, or one + * of them starts throwing where the other returns a value. + */ +const recoverPromptChanged = (status: number, detail: string): ActionResponse | null => + status === 409 ? promptChangedResponse(detail) : null; + +function promptChangedResponse(detail: string): ActionResponse | null { + try { + const body = JSON.parse(detail) as { + ok?: unknown; + code?: unknown; + error?: unknown; + }; + if ( + body.ok === false && + body.code === "prompt_changed" && + typeof body.error === "string" + ) { + return { ok: false, error: body.error, code: "prompt_changed" }; + } + } catch { + // A non-JSON error body follows the existing ApiError path below. + } + return null; +} + // Capture the bridge's build id off any response that carries it. Every poll (snapshot/pane) — and // config + mutations — funnels through the two fetch sites below, so the store stays current for // free, powering the no-service-worker self-updater (lib/self-update.ts). Absent header (older @@ -93,7 +122,14 @@ function captureBuild(res: Response): void { observeServerBuild(res.headers.get(SERVER_BUILD_HEADER)); } -async function doReq(path: string, init?: RequestInit): Promise { +/** + * Lets ONE caller claim a non-ok response instead of having it thrown. The transport stays generic: + * it knows a caller may recognise a refusal and turn it into a normal value, but nothing about which + * status or which body shape. Return null to fall through to the usual {@link ApiError}. + */ +type Recover = (status: number, detail: string) => T | null; + +async function doReq(path: string, init?: RequestInit, recover?: Recover): Promise { // GET reads get the short leash; anything mutating gets the longer mutation budget. const method = init?.method?.toUpperCase() ?? "GET"; const timeoutMs = method === "GET" ? GET_TIMEOUT_MS : MUTATION_TIMEOUT_MS; @@ -104,7 +140,10 @@ async function doReq(path: string, init?: RequestInit): Promise { }); captureBuild(res); if (!res.ok) { - throw new ApiError(`${path} → ${res.status} ${await errorDetail(res)}`, res.status); + const detail = await errorDetail(res); + const recovered = recover?.(res.status, detail); + if (recovered !== null && recovered !== undefined) return recovered; + throw new ApiError(`${path} → ${res.status} ${detail}`, res.status); } if (res.status === 204) return undefined as T; return (await res.json()) as T; @@ -113,8 +152,8 @@ async function doReq(path: string, init?: RequestInit): Promise { // Every mutating request (non-GET) feeds the app-wide busy signal so the top progress bar shows // while it's in flight; GET reads (snapshot/config polling) don't, or the bar would never rest. // trackBusy increments synchronously, so a caller sees `isBusy()` true the instant it fires. -function req(path: string, init?: RequestInit): Promise { - const op = doReq(path, init); +function req(path: string, init?: RequestInit, recover?: Recover): Promise { + const op = doReq(path, init, recover); const method = init?.method?.toUpperCase() ?? "GET"; return method === "GET" ? op : trackBusy(op); } @@ -225,18 +264,39 @@ export function sendReply( text: string, submit = true, session?: string, + expectedPrompt?: string, ): Promise { - return req(withSession(`/api/pane/${encodeURIComponent(paneId)}/reply`, session), { - method: "POST", - body: JSON.stringify({ text, submit }), - }); + return req( + withSession(`/api/pane/${encodeURIComponent(paneId)}/reply`, session), + { + method: "POST", + body: JSON.stringify({ + text, + submit, + ...(expectedPrompt !== undefined ? { expected_prompt: expectedPrompt } : {}), + }), + }, + recoverPromptChanged, + ); } -export function sendKeys(paneId: string, keys: string[], session?: string): Promise { - return req(withSession(`/api/pane/${encodeURIComponent(paneId)}/keys`, session), { - method: "POST", - body: JSON.stringify({ keys }), - }); +export function sendKeys( + paneId: string, + keys: string[], + session?: string, + expectedPrompt?: string, +): Promise { + return req( + withSession(`/api/pane/${encodeURIComponent(paneId)}/keys`, session), + { + method: "POST", + body: JSON.stringify({ + keys, + ...(expectedPrompt !== undefined ? { expected_prompt: expectedPrompt } : {}), + }), + }, + recoverPromptChanged, + ); } /** Close a pane ("kill the agent"). */ diff --git a/web/src/lib/harness/claude/multi-select.test.ts b/web/src/lib/harness/claude/multi-select.test.ts index 6c7e3b5..c549570 100644 --- a/web/src/lib/harness/claude/multi-select.test.ts +++ b/web/src/lib/harness/claude/multi-select.test.ts @@ -90,6 +90,22 @@ describe("detectMultiSelect — review phase", () => { }); }); +describe("detectMultiSelect region signature", () => { + it("is literal contiguous fixture text in both phases", () => { + for (const name of [ + "claude--select-multiselect-single.txt", + "claude--select-multiselect-checked.txt", + "claude--select-multiselect-review.txt", + ]) { + const lines = fixtureLines(name); + const screenText = lines.map(lineText).join("\n"); + const model = detectMultiSelect(lines); + expect(model).not.toBeNull(); + expect(screenText.includes(model!.regionSignature)).toBe(true); + } + }); +}); + describe("detectMultiSelectRegion — render boundary", () => { it("starts the checkbox region at the single-question stepper (raw scrollback stays above)", () => { const lines = fixtureLines("claude--select-multiselect-single.txt"); diff --git a/web/src/lib/harness/claude/multi-select.ts b/web/src/lib/harness/claude/multi-select.ts index 9a3eea6..5068c23 100644 --- a/web/src/lib/harness/claude/multi-select.ts +++ b/web/src/lib/harness/claude/multi-select.ts @@ -64,11 +64,25 @@ export type MultiSelectModel = escape: MultiSelectEscape | null; pointer: MultiPointer; signature: string; + /** + * Literal contiguous text over the same stepper-to-last-menu-row span as `signature`. It ends + * before the footer because pointer moves change that footer during the macro. The bridge must + * find this text in its fresh pane.read, while `signature` remains the pointer- and + * checkbox-independent identity used by client comparisons. + */ + regionSignature: string; } | { phase: "review"; incomplete: boolean; signature: string; + /** + * Literal contiguous text over the same stepper-to-tail span as `signature`. The checkbox + * phase uses the same rule and stops at its last menu row rather than its mutable footer. The + * bridge must find this text in its fresh pane.read, while `signature` remains the pointer- and + * checkbox-independent identity used by client comparisons. + */ + regionSignature: string; }; /** Detection result for buildBlocks: the model plus the region's first line — the multi-select @@ -277,6 +291,9 @@ function detectCheckboxPhase( // pointer down the dialog, a footer-inclusive signature would mutate mid-macro and read as // drift, aborting the submit. The footer is chrome anyway — classifyFooter gates detection. signature: coreSignature(texts, stepperIdx, menu[menu.length - 1]!.index), + regionSignature: texts + .slice(stepperIdx, menu[menu.length - 1]!.index + 1) + .join("\n"), }, startLine: stepperIdx, }; @@ -324,7 +341,12 @@ function detectReviewPhase( } return { - model: { phase: "review", incomplete, signature: coreSignature(texts, stepperIdx, fi) }, + model: { + phase: "review", + incomplete, + signature: coreSignature(texts, stepperIdx, fi), + regionSignature: texts.slice(stepperIdx, fi + 1).join("\n"), + }, startLine: stepperIdx, }; } diff --git a/web/src/lib/harness/claude/preview-select.test.ts b/web/src/lib/harness/claude/preview-select.test.ts index 9ee5a21..e852470 100644 --- a/web/src/lib/harness/claude/preview-select.test.ts +++ b/web/src/lib/harness/claude/preview-select.test.ts @@ -73,6 +73,22 @@ describe("detectPreviewSelect — the preview-variant fixtures", () => { expect(model).not.toBeNull(); expect(model!.note).toEqual({ state: "attached", text: "keep cards compact" }); }); + + it("keeps a literal contiguous region signature for every preview fixture", () => { + for (const name of [ + "claude--select-preview.txt", + "claude--select-preview-note-input.txt", + "claude--select-preview-note-attached.txt", + "claude--wizard-preview-q1.txt", + "claude--wizard-preview-note-attached.txt", + ]) { + const lines = fixtureLines(name); + const screenText = lines.map(lineText).join("\n"); + const model = detectPreviewSelect(lines); + expect(model).not.toBeNull(); + expect(screenText.includes(model!.regionSignature)).toBe(true); + } + }); }); describe("detectPreviewSelect — core signature (pointer/note-independent identity)", () => { diff --git a/web/src/lib/harness/claude/preview-select.ts b/web/src/lib/harness/claude/preview-select.ts index 51a4437..302f8dc 100644 --- a/web/src/lib/harness/claude/preview-select.ts +++ b/web/src/lib/harness/claude/preview-select.ts @@ -56,6 +56,14 @@ export interface PreviewSelectModel { /** The wizard stepper chips when this question is a step of a multi-question dialog; null for a * single-question dialog. Navigation uses the same Left/Right keys as the standard wizard. */ steps: WizardStepChip[] | null; + /** + * The literal, contiguous dialog region from the bounded lookback through the footer. The bridge + * must find this text in its fresh pane.read before writing. Unlike `coreSignature`, this includes + * the pointer and note state, so it changes during the choreography and binds only the first write + * after the client guard. `coreSignature` remains the pointer- and note-independent identity used + * by client comparisons across the full choreography. + */ + regionSignature: string; /** * A pointer- and note-INDEPENDENT byte-signature of the dialog's identity, computed at detection * time: the head lines above the options (the stepper/chip header + question + any subject/preamble @@ -219,6 +227,9 @@ export function detectPreviewSelectRegion(lines: StyledLine[]): PreviewSelectReg preview, note, steps, + regionSignature: texts + .slice(Math.max(0, firstOpt - SIGNATURE_LOOKBACK), fi + 1) + .join("\n"), coreSignature: computeCoreSignature(texts, firstOpt, lastOpt, noteCol), }, startLine, diff --git a/web/src/lib/harness/guard.ts b/web/src/lib/harness/guard.ts index 22d16fc..3a16e38 100644 --- a/web/src/lib/harness/guard.ts +++ b/web/src/lib/harness/guard.ts @@ -20,6 +20,16 @@ export type ActionResult = | { status: "changed" } | { status: "error"; error: string }; +/** + * What {@link entryGuard} returns. Discriminated on `ok`, the same shape the bridge side uses for + * `PromptBindingResult`, `ExpectedPrompt` and `PromptBindingCheck`, so both ends of this feature + * read alike. The passing case carries its payload instead of borrowing a type that reads as a + * failure, and no call site has to reason about truthiness to tell the two apart. + */ +export type GuardOutcome = + | { ok: true; region: string } + | { ok: false; result: ActionResult }; + /** Test seam for the verification polls' pacing. */ export type Sleep = (ms: number) => Promise; export const defaultSleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -31,6 +41,7 @@ export const POLL_DELAY_MS = 350; /** Derive the on-screen dialog model from a fresh pane's styled lines (null = no dialog there). */ type Detect = (lines: StyledLine[]) => M | null; +type RegionOf = (model: M) => string; /** One fresh read + re-derivation. Returns the model (null = no dialog on screen). */ export async function readModel( @@ -45,9 +56,15 @@ export async function readModel( /** * The shared entry guard: a FRESH pane read, the UNCONDITIONAL revision check, and a full model - * re-derivation compared (via `equals`) against `tapped` — what the user actually tapped. Returns a - * terminal `ActionResult` when the guard fails ("changed") or the read errors, or `null` when the - * guard passes and the caller may proceed to send. + * re-derivation compared (via `equals`) against `tapped` — what the user actually tapped. + * + * Returns a {@link GuardOutcome}: `{ ok: false, result }` when the guard refused (`"changed"`) or + * the read failed, and `{ ok: true, region }` when it passed, carrying the verified region (via + * `regionOf`) that the caller binds to its write. + * + * The region the caller gets back is the one derived from THIS fresh read, so it describes the pane + * as of a moment ago, not as of the render the user tapped. That is deliberate: the client guard has + * already established the two are the same dialog, and the bridge needs the current text to find it. */ export async function entryGuard( args: { @@ -61,25 +78,29 @@ export async function entryGuard( tapped: M, detect: Detect, equals: (a: M, b: M) => boolean, -): Promise { + regionOf: RegionOf, +): Promise { let fresh; try { fresh = await readModel(args.paneId, args.requestedLines, args.session, detect); } catch (e) { - return { status: "error", error: e instanceof Error ? e.message : String(e) }; + const error = e instanceof Error ? e.message : String(e); + return { ok: false, result: { status: "error", error } }; } // Revision check is UNCONDITIONAL: a 304 only means "unchanged since the last poll", and polls // keep advancing the ETag cache under a frozen mirror — it does NOT vouch for the snapshot the // user actually tapped on. The cached 304 body carries its revision, so this covers both paths. - if (fresh.revision !== args.detectedRevision) return { status: "changed" }; + if (fresh.revision !== args.detectedRevision) return { ok: false, result: { status: "changed" } }; // EMPIRICAL (Herdr 0.7.x, live-verified 2026-07-05): pane.read's `revision` is a stub upstream — // it is always 0, even for actively-changing panes. The gate above is therefore defense-in-depth // for future Herdr versions, NOT load-bearing. So the model re-derivation below runs on EVERY // path, including 304: the fresh (= latest cached) text is exactly what a tap on a possibly // frozen mirror must be compared against. One parse per tap — taps are rare, correctness isn't. - if (!fresh.model || !equals(fresh.model, tapped)) return { status: "changed" }; - return null; + if (!fresh.model || !equals(fresh.model, tapped)) { + return { ok: false, result: { status: "changed" } }; + } + return { ok: true, region: regionOf(fresh.model) }; } /** diff --git a/web/src/lib/harness/prompt-binding-contract.test.ts b/web/src/lib/harness/prompt-binding-contract.test.ts new file mode 100644 index 0000000..a234d23 --- /dev/null +++ b/web/src/lib/harness/prompt-binding-contract.test.ts @@ -0,0 +1,82 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { parseAnsi } from "../ansi"; +import { splitLines, type StyledLine } from "../blocks"; +import { detectMultiSelect } from "./claude/multi-select"; +import { detectPreviewSelect } from "./claude/preview-select"; +import { detectPromptSelect } from "./claude/prompt-select"; +import { detectWizard } from "./claude/wizard"; + +// The client half of the prompt-binding contract. See the sibling test in +// bridge/prompt-binding.test.ts for the full reasoning; in short: +// +// The client sends the bridge a region string derived from ANSI it has already parsed away, and the +// bridge looks for that region in the RAW pane.read. Nothing in the type system couples the two +// normalisations. If either drifts, every legitimate approval starts coming back "prompt changed", +// a failure that looks like the feature working and ends with users turning it off. +// +// prompt-binding-regions.json pins the exact region each detector produces for each real fixture. +// THIS test proves the detectors still produce them byte-for-byte; the bridge test proves the bridge +// still finds them in the raw text. Divergence turns one of the two red. +// +// If a detector legitimately changes what it captures, regenerate the JSON and expect the bridge +// test to confirm the new regions are still findable. Regenerating to silence the BRIDGE test would +// be backwards: that failure means the bridge stopped matching what the client really sends. + +// Anchored on this file's own directory (NOT `new URL(..., import.meta.url)`, which Vite rewrites). +const FIXTURES_DIR = join(import.meta.dirname, "..", "..", "fixtures"); +const PANES_DIR = join(FIXTURES_DIR, "panes"); + +const fixtureLines = (name: string): StyledLine[] => + splitLines(parseAnsi(readFileSync(join(PANES_DIR, name), "utf8"))); + +const REGIONS = JSON.parse( + readFileSync(join(FIXTURES_DIR, "prompt-binding-regions.json"), "utf8"), +) as { fixture: string; detector: string; region: string }[]; + +/** The region each detector hands to the bridge, or null when it does not recognise the pane. The + * order mirrors the precedence the action layer uses, so a pane is attributed to one detector. */ +function detectRegion(lines: StyledLine[]): { detector: string; region: string } | null { + const prompt = detectPromptSelect(lines); + if (prompt) return { detector: "prompt-select", region: prompt.signature }; + const wizard = detectWizard(lines); + if (wizard) return { detector: "wizard", region: wizard.signature }; + const preview = detectPreviewSelect(lines); + if (preview) return { detector: "preview-select", region: preview.regionSignature }; + const multi = detectMultiSelect(lines); + if (multi) return { detector: "multi-select", region: multi.regionSignature }; + return null; +} + +describe("client/bridge binding contract", () => { + it("covers every dialog detector", () => { + expect([...new Set(REGIONS.map((r) => r.detector))].sort()).toEqual([ + "multi-select", + "preview-select", + "prompt-select", + "wizard", + ]); + }); + + for (const { fixture, detector, region } of REGIONS) { + it(`${detector}: ${fixture} still derives byte-identically`, () => { + const found = detectRegion(fixtureLines(fixture)); + expect(found).not.toBeNull(); + expect(found!.detector).toBe(detector); + expect(found!.region).toBe(region); + }); + } + + // Completeness gate: a newly added dialog fixture must not slip past the contract unnoticed. A new + // capture that some detector recognises but that carries no committed region would otherwise ship + // an unpinned write path, which is exactly what this pair of tests exists to prevent. + it("pins every fixture that any detector recognises", () => { + const pinned = new Set(REGIONS.map((r) => r.fixture)); + const unpinned = readdirSync(PANES_DIR) + .filter((n) => n.endsWith(".txt")) + .filter((n) => !pinned.has(n) && detectRegion(fixtureLines(n)) !== null); + expect(unpinned).toEqual([]); + }); +}); diff --git a/web/src/lib/multi-select-action.test.ts b/web/src/lib/multi-select-action.test.ts index ee5a3b0..dd7ab8f 100644 --- a/web/src/lib/multi-select-action.test.ts +++ b/web/src/lib/multi-select-action.test.ts @@ -132,7 +132,9 @@ describe("toggle / escape — one guarded keystroke", () => { mockFetchPane.mockResolvedValue(paneWith(checkboxBuffer({ pointer: "opt1" }))); const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "toggle", n: 3 } }); expect(res).toEqual({ status: "sent" }); - expect(mockSendKeys.mock.calls).toEqual([["w1:p1", ["3"], undefined]]); + expect(mockSendKeys.mock.calls).toEqual([ + ["w1:p1", ["3"], undefined, m.regionSignature], + ]); }); it("escape sends the 'Chat about this' digit", async () => { @@ -140,7 +142,28 @@ describe("toggle / escape — one guarded keystroke", () => { mockFetchPane.mockResolvedValue(paneWith(checkboxBuffer({}))); const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "escape" } }); expect(res).toEqual({ status: "sent" }); - expect(mockSendKeys.mock.calls).toEqual([["w1:p1", ["6"], undefined]]); + expect(mockSendKeys.mock.calls).toEqual([ + ["w1:p1", ["6"], undefined, m.regionSignature], + ]); + }); + + it("returns changed when the bound key write reports prompt_changed", async () => { + const m = model(checkboxBuffer({ pointer: "opt1" })); + mockFetchPane.mockResolvedValueOnce(paneWith(checkboxBuffer({ pointer: "opt1" }))); + mockSendKeys.mockResolvedValueOnce({ + ok: false, + error: "Prompt changed before keys were sent", + code: "prompt_changed", + }); + const res = await submitMultiSelectIntent({ + ...base, + multi: m, + intent: { kind: "toggle", n: 3 }, + }); + expect(res).toEqual({ status: "changed" }); + expect(mockSendKeys.mock.calls).toEqual([ + ["w1:p1", ["3"], undefined, m.regionSignature], + ]); }); it("toggle rejects an out-of-range digit against the model, sending nothing (no stray keystroke)", async () => { @@ -183,7 +206,10 @@ describe("review — confirm / cancel", () => { expect(await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "cancel" } })).toEqual({ status: "sent", }); - expect(keysSent()).toEqual([["1"], ["2"]]); + expect(mockSendKeys.mock.calls).toEqual([ + ["w1:p1", ["1"], undefined, m.regionSignature], + ["w1:p1", ["2"], undefined, m.regionSignature], + ]); }); }); @@ -198,7 +224,29 @@ describe("submit macro — walk the pointer down onto Submit, then Enter", () => ); const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); expect(res).toEqual({ status: "sent" }); - expect(keysSent()).toEqual([["Down"], ["Down"], ["Enter"]]); + expect(mockSendKeys.mock.calls).toEqual([ + ["w1:p1", ["Down"], undefined, m.regionSignature], + ["w1:p1", ["Down"], undefined], + ["w1:p1", ["Enter"], undefined], + ]); + }); + + it("returns changed when the first bound macro write reports prompt_changed", async () => { + const m = model(checkboxBuffer({ pointer: "opt1" })); + script( + checkboxBuffer({ pointer: "opt1" }), // entry guard + checkboxBuffer({ pointer: "opt1" }), // first pointer read leads to Down + ); + mockSendKeys.mockResolvedValueOnce({ + ok: false, + error: "Prompt changed before keys were sent", + code: "prompt_changed", + }); + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); + expect(res).toEqual({ status: "changed" }); + expect(mockSendKeys.mock.calls).toEqual([ + ["w1:p1", ["Down"], undefined, m.regionSignature], + ]); }); it("re-sends Down when a key is swallowed (the re-read still shows an option row)", async () => { diff --git a/web/src/lib/multi-select-action.ts b/web/src/lib/multi-select-action.ts index 16621db..a0fa013 100644 Binary files a/web/src/lib/multi-select-action.ts and b/web/src/lib/multi-select-action.ts differ diff --git a/web/src/lib/preview-action.test.ts b/web/src/lib/preview-action.test.ts index a1ff1de..e9f9bb7 100644 --- a/web/src/lib/preview-action.test.ts +++ b/web/src/lib/preview-action.test.ts @@ -133,17 +133,34 @@ describe("submitPreviewOption — digit → verify pointer → Enter", () => { const res = await submitPreviewOption({ ...base, preview: m, option: m.options[1]! }); expect(res).toEqual({ status: "sent" }); expect(mockSendKeys.mock.calls).toEqual([ - ["w1:p1", ["2"], undefined], + ["w1:p1", ["2"], undefined, m.regionSignature], ["w1:p1", ["Enter"], undefined], ]); }); + it("returns changed when the bound digit write reports prompt_changed", async () => { + const m = model({ pointer: 1 }); + mockFetchPane.mockResolvedValueOnce(paneWith(buffer({ pointer: 1 }))); + mockSendKeys.mockResolvedValueOnce({ + ok: false, + error: "Prompt changed before keys were sent", + code: "prompt_changed", + }); + const res = await submitPreviewOption({ ...base, preview: m, option: m.options[1]! }); + expect(res).toEqual({ status: "changed" }); + expect(mockSendKeys.mock.calls).toEqual([ + ["w1:p1", ["2"], undefined, m.regionSignature], + ]); + }); + it("never sends Enter when the pointer does not converge (digit was the only side effect)", async () => { const m = model({ pointer: 1 }); mockFetchPane.mockResolvedValue(paneWith(buffer({ pointer: 1 }))); // pointer never moves const res = await submitPreviewOption({ ...base, preview: m, option: m.options[2]! }); expect(res).toEqual({ status: "changed" }); - expect(mockSendKeys.mock.calls).toEqual([["w1:p1", ["3"], undefined]]); + expect(mockSendKeys.mock.calls).toEqual([ + ["w1:p1", ["3"], undefined, m.regionSignature], + ]); }); it("rejects at the entry guard when the dialog changed underfoot (no keys at all)", async () => { @@ -169,7 +186,9 @@ describe("submitPreviewOption — digit → verify pointer → Enter", () => { .mockResolvedValue(paneWith(buffer({ question: "Another dialog entirely?" }))); const res = await submitPreviewOption({ ...base, preview: m, option: m.options[1]! }); expect(res).toEqual({ status: "changed" }); - expect(mockSendKeys.mock.calls).toEqual([["w1:p1", ["2"], undefined]]); // digit only, no Enter + expect(mockSendKeys.mock.calls).toEqual([ + ["w1:p1", ["2"], undefined, m.regionSignature], + ]); // digit only, no Enter }); it("rejects a same-labels successor whose core signature differs mid-flight — NO Enter", async () => { @@ -184,7 +203,9 @@ describe("submitPreviewOption — digit → verify pointer → Enter", () => { .mockResolvedValue(paneWith(buffer({ pointer: 2, subject: "Editing bar.ts" }))); // successor: pointer on tapped row, DIFFERENT subject const res = await submitPreviewOption({ ...base, preview: m, option: m.options[1]! }); expect(res).toEqual({ status: "changed" }); - expect(mockSendKeys.mock.calls).toEqual([["w1:p1", ["2"], undefined]]); // digit moved the pointer; Enter never fired + expect(mockSendKeys.mock.calls).toEqual([ + ["w1:p1", ["2"], undefined, m.regionSignature], + ]); // digit moved the pointer; Enter never fired }); }); @@ -209,12 +230,28 @@ describe("submitPreviewNote — n → verify focus → clear → type → Escape const res = await submitPreviewNote({ ...base, preview: m, text: "focus on mobile" }); expect(res).toEqual({ status: "sent" }); expect(mockSendKeys.mock.calls).toEqual([ - ["w1:p1", ["n"], undefined], + ["w1:p1", ["n"], undefined, m.regionSignature], ["w1:p1", ["Escape"], undefined], ]); expect(mockSendReply.mock.calls).toEqual([["w1:p1", "focus on mobile", false, undefined]]); }); + it("returns changed when the bound note-open write reports prompt_changed", async () => { + const m = model({}); + mockFetchPane.mockResolvedValueOnce(paneWith(buffer({}))); + mockSendKeys.mockResolvedValueOnce({ + ok: false, + error: "Prompt changed before keys were sent", + code: "prompt_changed", + }); + const res = await submitPreviewNote({ ...base, preview: m, text: "focus on mobile" }); + expect(res).toEqual({ status: "changed" }); + expect(mockSendKeys.mock.calls).toEqual([ + ["w1:p1", ["n"], undefined, m.regionSignature], + ]); + expect(mockSendReply).not.toHaveBeenCalled(); + }); + it("replaces an existing note with the deterministic clear (ctrl+k + Backspace sweep)", async () => { const m = model({ note: "old note" }); script( @@ -226,13 +263,19 @@ describe("submitPreviewNote — n → verify focus → clear → type → Escape ); const res = await submitPreviewNote({ ...base, preview: m, text: "new note" }); expect(res).toEqual({ status: "sent" }); - expect(mockSendKeys.mock.calls[0]).toEqual(["w1:p1", ["n"], undefined]); + expect(mockSendKeys.mock.calls[0]).toEqual([ + "w1:p1", + ["n"], + undefined, + m.regionSignature, + ]); const clear = mockSendKeys.mock.calls[1]![1]; expect(clear[0]).toBe("ctrl+k"); expect(clear.length).toBe(1 + NOTE_MAX_LENGTH + 20); expect(clear.slice(1).every((k: string) => k === "Backspace")).toBe(true); + expect(mockSendKeys.mock.calls[1]).toHaveLength(3); expect(mockSendKeys.mock.calls[2]).toEqual(["w1:p1", ["Escape"], undefined]); - expect(mockSendReply).toHaveBeenCalledWith("w1:p1", "new note", false, undefined); + expect(mockSendReply.mock.calls).toEqual([["w1:p1", "new note", false, undefined]]); }); it("removes a note with empty text: clear + Escape, nothing typed", async () => { @@ -351,7 +394,9 @@ describe("submitPreviewNote — n → verify focus → clear → type → Escape mockFetchPane.mockResolvedValue(paneWith(buffer({}))); // editing state never appears const res = await submitPreviewNote({ ...base, preview: m, text: "hello" }); expect(res).toEqual({ status: "error", error: "Note input didn't open — check the pane" }); - expect(mockSendKeys.mock.calls).toEqual([["w1:p1", ["n"], undefined]]); + expect(mockSendKeys.mock.calls).toEqual([ + ["w1:p1", ["n"], undefined, m.regionSignature], + ]); expect(mockSendReply).not.toHaveBeenCalled(); }); }); @@ -362,7 +407,27 @@ describe("submitPreviewKeys — guarded single keystroke (wizard step navigation mockFetchPane.mockResolvedValue(paneWith(buffer({}))); const res = await submitPreviewKeys({ ...base, preview: m, keys: ["Right"] }); expect(res).toEqual({ status: "sent" }); - expect(mockSendKeys).toHaveBeenCalledWith("w1:p1", ["Right"], undefined); + expect(mockSendKeys).toHaveBeenCalledWith( + "w1:p1", + ["Right"], + undefined, + m.regionSignature, + ); + }); + + it("returns changed when the bound navigation write reports prompt_changed", async () => { + const m = model({}); + mockFetchPane.mockResolvedValueOnce(paneWith(buffer({}))); + mockSendKeys.mockResolvedValueOnce({ + ok: false, + error: "Prompt changed before keys were sent", + code: "prompt_changed", + }); + const res = await submitPreviewKeys({ ...base, preview: m, keys: ["Right"] }); + expect(res).toEqual({ status: "changed" }); + expect(mockSendKeys.mock.calls).toEqual([ + ["w1:p1", ["Right"], undefined, m.regionSignature], + ]); }); it("rejects when the dialog is gone", async () => { diff --git a/web/src/lib/preview-action.ts b/web/src/lib/preview-action.ts index c159d26..035ade0 100644 --- a/web/src/lib/preview-action.ts +++ b/web/src/lib/preview-action.ts @@ -106,11 +106,24 @@ interface GuardArgs { export async function submitPreviewOption( args: GuardArgs & { option: PreviewOption }, ): Promise { - const guarded = await entryGuard(args, args.preview, detectPreviewSelect, previewsEqual); - if (guarded) return guarded; + const guarded = await entryGuard( + args, + args.preview, + detectPreviewSelect, + previewsEqual, + (model) => model.regionSignature, + ); + if (!guarded.ok) return guarded.result; try { - const digit = await sendKeys(args.paneId, [String(args.option.n)], args.session); + // Bind only this first write. It changes the dialog, so later steps must not reuse this region. + const digit = await sendKeys( + args.paneId, + [String(args.option.n)], + args.session, + guarded.region, + ); + if (!digit.ok && digit.code === "prompt_changed") return { status: "changed" }; if (!digit.ok) return { status: "error", error: digit.error }; } catch (e) { return { status: "error", error: e instanceof Error ? e.message : String(e) }; @@ -152,14 +165,22 @@ export async function submitPreviewNote( args: GuardArgs & { text: string }, ): Promise { if (args.preview.note.state === "editing") return { status: "changed" }; - const guarded = await entryGuard(args, args.preview, detectPreviewSelect, previewsEqual); - if (guarded) return guarded; + const guarded = await entryGuard( + args, + args.preview, + detectPreviewSelect, + previewsEqual, + (model) => model.regionSignature, + ); + if (!guarded.ok) return guarded.result; const text = sanitizeTypedText(args.text, NOTE_MAX_LENGTH); const editing = (m: PreviewSelectModel) => coreEqual(m, args.preview) && m.note.state === "editing"; try { - const open = await sendKeys(args.paneId, ["n"], args.session); + // Bind only this first write. It changes the dialog, so later steps must not reuse this region. + const open = await sendKeys(args.paneId, ["n"], args.session, guarded.region); + if (!open.ok && open.code === "prompt_changed") return { status: "changed" }; if (!open.ok) return { status: "error", error: open.error }; } catch (e) { return { status: "error", error: e instanceof Error ? e.message : String(e) }; @@ -242,10 +263,18 @@ export async function submitPreviewNote( export async function submitPreviewKeys( args: GuardArgs & { keys: string[] }, ): Promise { - const guarded = await entryGuard(args, args.preview, detectPreviewSelect, previewsEqual); - if (guarded) return guarded; + const guarded = await entryGuard( + args, + args.preview, + detectPreviewSelect, + previewsEqual, + (model) => model.regionSignature, + ); + if (!guarded.ok) return guarded.result; try { - const res = await sendKeys(args.paneId, args.keys, args.session); + // Bind only this first write. It changes the dialog, so later steps must not reuse this region. + const res = await sendKeys(args.paneId, args.keys, args.session, guarded.region); + if (!res.ok && res.code === "prompt_changed") return { status: "changed" }; if (!res.ok) return { status: "error", error: res.error }; return { status: "sent" }; } catch (e) { diff --git a/web/src/lib/prompt-action.ts b/web/src/lib/prompt-action.ts index b332bc3..3ea056f 100644 --- a/web/src/lib/prompt-action.ts +++ b/web/src/lib/prompt-action.ts @@ -63,11 +63,18 @@ export async function submitPromptOption(args: { }): Promise { const { paneId, prompt, option, session } = args; - const guarded = await entryGuard(args, prompt, detectPromptSelect, promptsEqual); - if (guarded) return guarded; + const guarded = await entryGuard( + args, + prompt, + detectPromptSelect, + promptsEqual, + (model) => model.signature, + ); + if (!guarded.ok) return guarded.result; try { - const res = await sendKeys(paneId, option.keys, session); + const res = await sendKeys(paneId, option.keys, session, guarded.region); + if (!res.ok && res.code === "prompt_changed") return { status: "changed" }; if (!res.ok) return { status: "error", error: res.error }; return { status: "sent" }; } catch (e) { diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index bcf3cb7..95a6959 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -209,7 +209,12 @@ export type PaneHistoryResponse = export type ActionResponse = | { ok: true } - | { ok: false; error: string; textDelivered?: boolean }; + | { + ok: false; + error: string; + textDelivered?: boolean; + code?: "prompt_changed"; + }; export type UploadResponse = { ok: true; path: string } | { ok: false; error: string }; diff --git a/web/src/lib/wizard-action.ts b/web/src/lib/wizard-action.ts index be3c527..27af4f7 100644 --- a/web/src/lib/wizard-action.ts +++ b/web/src/lib/wizard-action.ts @@ -82,11 +82,18 @@ export async function submitWizardKeys(args: { }): Promise { const { paneId, wizard, keys, session } = args; - const guarded = await entryGuard(args, wizard, detectWizard, wizardsEqual); - if (guarded) return guarded; + const guarded = await entryGuard( + args, + wizard, + detectWizard, + wizardsEqual, + (model) => model.signature, + ); + if (!guarded.ok) return guarded.result; try { - const res = await sendKeys(paneId, keys, session); + const res = await sendKeys(paneId, keys, session, guarded.region); + if (!res.ok && res.code === "prompt_changed") return { status: "changed" }; if (!res.ok) return { status: "error", error: res.error }; return { status: "sent" }; } catch (e) {