fix(transport): quoted approval text stops vetoing a send to a working Claude seat (#1955)

## What a user gets

A send or queue wake to a Claude seat that is plainly working is no longer refused as "at an approval prompt" just because the seat quoted approval-prompt text in its own output, such as a selector like `› 1. Yes, proceed (y)` or "Do you want to proceed?".

Real approval dialogues stay protected: the permission dialogue, AskUserQuestion, and the MCP server menu.

## The defect

`classifyPaneActivity` looks for prompt signatures in the last 12 non-blank lines: numbered selectors, plus phrases such as "Do you want to proceed?". Quoted text matches as well as a real dialogue does.
- **The ordinary pane probe:** a quoted selector reads as `selection_prompt`. This is pre-existing and was observed on a working seat.
- **#1864's fresh running-hook path:** this path narrows selectors to the current dialogue, but still scans the permission phrases across the whole window, so a quoted phrase reads as `permission_prompt`. This was found by reading the source and hasn't been seen on a real seat.

Either way the send is refused with `target_needs_input`, and delivery stalls until someone notices.

## The fix

One condition in the existing detector, `session-transport.ts` +9/-2.
- **When it applies:** for a Claude read, when Claude's own timer row (for example `✶ Churning… (12s · …)`, including the `running … hook` form) runs directly above its complete, empty input box. The box must be framed, with a recognised mode bar.
- **What it does:** the turn is running and no dialogue is open, so the prompt scan ignores the text above that box.
- **Only the timer row counts.** A selector-shaped "mid-work" line doesn't, so a prompt whose selector sits just above the box isn't mistaken for work.
- **What stays protected:**
  - A dialogue that replaces the input box never meets the condition, because no empty input box is at the bottom. The native AskUserQuestion capture (`claude-question-picker-2.1.289.json`) shows that layout.
  - **Correction (provenance):** an earlier version of this body cited the 2.1.300 permission fixture as showing this. It doesn't establish screen geometry: `claude-permission-2.1.300.md` says those are ConPTY menu excerpts with separators removed, not full-screen captures. No retained full-screen native capture shows where a Claude permission dialogue sits, or one beside a running hook timer.
  - **So this is an inference [I]:** "a real permission dialogue never shows beside Claude's running timer row above an empty input box". That includes parallel tool calls, where one tool's hook timer runs while another asks permission. It's for Quality's native check. The latest PermissionRequest hook still overrides first (`classifySendReadiness`).
  - A prompt drawn above the input box (`send-prompt-guard`'s FORWARD-FIX layout) has no timer row, so it's still refused.
- **Unchanged:** Codex, other runtimes, and reads without a runtime (the display surfaces).

## Limits, deliberately conservative

These still read as attention, as today:
- **an idle Claude seat** (no timer row) with quoted prompt text above its input box. Nothing on screen tells that apart from a prompt drawn above the box;
- **a draft in the input box;**
- **a mode bar outside the recognised list,** such as the default "⏸ manual mode on";
- **an unframed input box.**

## Tests

`packages/daemon/test/claude-quoted-prompt-veto.test.ts`, +120.

**The classifier:**
- quoted text reads as working with a timer row, in both modes;
- the native permission (Bash, Edit, WebFetch), AskUserQuestion and MCP dialogues, and a prompt above the input box with a listed phrase, all read as attention under every option set;
- with no runtime, a Codex runtime, no timer row, an unrecognised or non-mode bar, or a draft, the result is identical to before.

**The real send path,** `SessionTransport.send`:
- delivered for the working seat with quoted text, through both the ordinary probe and the fresh running-hook path;
- refused at a real permission dialogue and at the MCP menu, through both paths.

**Results on Build:**
- **Before the fix,** the new tests against the base source fail 3 of 12, on exactly the working-seat cases. The ordinary probe refuses on the quoted `› 1. Yes, proceed (y)` (`selection_prompt`), and the fresh-hook path on the quoted "Do you want to proceed?" (`permission_prompt`). The protection cases pass on both sides.
- **With the fix,** 1,723 tests pass across 40 files: every test file that refers to the classifier, the send probe or `target_needs_input`. That includes `send-prompt-guard` (FORWARD-FIX 1), `claude-question-picker` and `session-transport`. The daemon builds, and its typecheck exits 0.

## Cost

Source +9/-2, tests +120. No new refusal, matcher, error code or setting.
This commit is contained in:
Mike Schwarz authored and GitHub committed 2026-10-24 18:49:00 -07:00
1 parent f8b3d03cd1
commit db863940c8
2 files changed
+129 -2

No files matched your search

@@ -514,9 +514,17 @@ export function classifyPaneActivity(paneContent: string, options: ClassifyPaneO
}
const recentLines = lastNonBlank.slice(-8);
const claudeComposer = findClaudeComposer(paneContent, options);
// Claude's own timer row above its complete, empty input box means the turn is running, not waiting at a dialogue:
// a real dialogue replaces the input box, and a prompt drawn above the box shows no timer row. So for a Claude read,
// prompt-looking text above that box is quoted output or history. Only the timer row counts, never a selector-shaped
// "mid-work" line.
const historyAboveInput = options.runtime === "claude-code" && claudeComposer?.framed === true &&
CLAUDE_ANCHORED_MODE_FOOTER.test(claudeComposer.bar) && IDLE_PROMPT_PATTERNS.some(pattern => pattern.test(claudeComposer.text)) &&
!!claudeComposer.liveStatus && CLAUDE_LIVE_STATUS_PATTERN.test(claudeComposer.liveStatus);
// Wider window for prompt SIGNATURES so a tall footer can't push a real prompt out of view (see
// PROMPT_SCAN_LINES). The generic activity checks below keep the tighter 8-line window.
const promptScanLines = lastNonBlank.slice(-PROMPT_SCAN_LINES);
const promptScanLines = historyAboveInput ? [] : lastNonBlank.slice(-PROMPT_SCAN_LINES);
const trailingNonBlank = lastNonBlank.slice(-3);
const lastLine = lastNonBlank.at(-1) ?? "";
const idlePromptLine = trailingNonBlank.find((line) =>
@@ -525,7 +533,6 @@ export function classifyPaneActivity(paneContent: string, options: ClassifyPaneO
const idleStatusBarLine = IDLE_STATUS_BAR_PATTERNS.some((pattern) => pattern.test(lastLine))
? lastLine
: null;
const claudeComposer = findClaudeComposer(paneContent, options);
const oldQuestionEnd = promptScanLines.lastIndexOf(CLAUDE_QUESTION_FOOTER);
const permissionPromptEvidence = findPatternEvidence(promptScanLines, PERMISSION_PROMPT_PATTERNS);
// A complete later empty composer with a recognized Claude bar makes the preceding question history.
@@ -0,0 +1,120 @@
import { readFileSync } from "node:fs";
import { describe, it, expect, vi } from "vitest";
import { SessionTransport, classifyPaneActivity } from "../src/domain/session-transport.js";
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
import { EventBus } from "../src/domain/event-bus.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { createFullTestDb } from "./helpers/test-app.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
// A working Claude seat quotes approval-prompt text in its own output: a send or queue wake to it must not read that
// text as the dialogue it waits on. Claude's own timer row above its empty input box shows the turn running, so no
// dialogue is open. A real dialogue replaces the input box (the native captures here), and a prompt drawn above the
// input box with no timer row (send-prompt-guard's FORWARD-FIX layout) stays protected. Quoted lines are constructed.
const permission = JSON.parse(readFileSync(new URL("./fixtures/claude-permission-2.1.300.json", import.meta.url), "utf8")) as Record<string, string>;
const question = JSON.parse(readFileSync(new URL("./fixtures/claude-question-picker-2.1.289.json", import.meta.url), "utf8")) as Record<string, string>;
const border = "────────────────────────────────────────────";
const bar = "⏵⏵ accept edits on (shift+tab to cycle) · ← for agents";
const quoted = ["● I answered review-r2's prompt. It read:", " Would you like to run the following command?",
" › 1. Yes, proceed (y)", " 2. No, and tell Codex what to do differently (esc)", "● Claude's own prompt read: Do you want to proceed?"];
const input = (status: string | null, prompt = "❯ ", footer = bar) => [...(status ? [status] : []), border, prompt, border, footer];
const working = [...quoted, ...input("✶ Churning… (12s · ↓ 300 tokens)")].join("\n");
const workingInHook = [...quoted, ...input("✶ Churning… (running PostToolUse hook · 3m 12s · ↓ 9 tokens)")].join("\n");
const idle = [...quoted, ...input(null)].join("\n");
// send-prompt-guard's FORWARD-FIX layout: a real prompt above the input box, with no timer row. Here with a listed phrase.
const promptAboveInput = ["Do you want to make this edit to session-transport.ts?", "", "❯ 1. Yes",
" 2. Yes, allow all edits in domain/ this session", " 3. No, and tell Claude what to do differently", "",
" Use ↑↓ to choose, enter to confirm", "", ...input(null, "❯ ", " ⏵⏵ accept edits on (shift+tab to cycle)")].join("\n");
const mcpMenu = ["2 new MCP servers found in this project", "Select any you wish to enable.",
"MCP servers may execute code or access system resources. All tool calls", "require approval. Learn more in the MCP documentation.",
"❯ [✔] dummy1", " [✔] dummy2", " Enable selected", "Space to select · Esc to reject all"].join("\n");
// Real dialogues, with the same quoted history above them.
const dialogs: Record<string, string> = {
permissionBash: `${quoted.join("\n")}\n${permission.bash}`,
permissionEdit: `${quoted.join("\n")}\n${permission.edit}`,
permissionWebFetch: `${quoted.join("\n")}\n${permission.webFetch}`,
askUserQuestion: `${quoted.join("\n")}\n${question.picker100}`,
mcpMenu: `${quoted.join("\n")}\n${mcpMenu}`,
};
describe("quoted approval text above Claude's current input box", () => {
it("is history to a Claude read while Claude's timer row shows the turn running", () => {
for (const pane of [working, workingInHook]) {
for (const options of [{ runtime: "claude-code" }, { runtime: "claude-code", currentClaudeDialogueOnly: true }]) {
expect(classifyPaneActivity(pane, options), JSON.stringify(options)).toMatchObject({ state: "agent_active", reason: "mid_work_pattern" });
}
}
});
it.each(Object.entries({ ...dialogs, promptAboveInput }))("a real dialogue still reads as attention: %s", (_name, pane) => {
for (const options of [{ runtime: "claude-code" }, { runtime: "claude-code", currentClaudeDialogueOnly: true }, {}]) {
expect(classifyPaneActivity(pane, options).state, JSON.stringify(options)).toBe("attention");
}
});
it("changes nothing without the Claude runtime, its timer row, a recognized mode bar, or an empty input box", () => {
for (const pane of [working, workingInHook]) {
expect(classifyPaneActivity(pane).state).toBe("attention");
expect(classifyPaneActivity(pane, { runtime: "codex" }).state).toBe("attention");
}
// No timer row: an idle seat quoting prompt text is not told apart from a prompt above the input box, so it stays refused.
for (const options of [{ runtime: "claude-code" }, { runtime: "claude-code", currentClaudeDialogueOnly: true }]) {
expect(classifyPaneActivity(idle, options).state).toBe("attention");
}
const unrecognizedBar = [...quoted, ...input("✶ Churning… (12s · ↓ 300 tokens)", "❯ ", "⏸ manual mode on · ← for agents")].join("\n");
const notAModeBar = [...quoted, ...input("✶ Churning… (12s · ↓ 300 tokens)", "❯ ", "Esc to cancel · Tab to amend")].join("\n");
const draft = [...quoted, ...input("✶ Churning… (12s · ↓ 300 tokens)", "❯ unfinished message")].join("\n");
for (const pane of [unrecognizedBar, notAModeBar, draft]) {
expect(classifyPaneActivity(pane, { runtime: "claude-code" })).toEqual(classifyPaneActivity(pane));
}
});
});
describe("a send to a working Claude seat with quoted approval text", () => {
async function send(pane: string, hookEvent: "UserPromptSubmit" | null) {
const db = createFullTestDb();
try {
const repo = new RigRepository(db), registry = new SessionRegistry(db), rig = repo.createRig("quoted-test");
const node = repo.addNode(rig.id, "worker.a", { runtime: "claude-code", role: "worker" });
const name = "worker-a@quoted-test";
const session = registry.registerSession(node.id, name);
registry.updateStatus(session.id, "running");
registry.updateBinding(node.id, { tmuxSession: name });
const generation = registry.currentOccupantTenure(node.id)!.generationUuid;
const now = new Date("2026-10-25T01:00:00Z");
const store = new AgentActivityStore({ db, eventBus: new EventBus(db), now: () => now,
resolveOccupantGeneration: id => registry.currentOccupantTenure(id)?.generationUuid ?? null,
isRegisteredOccupantGeneration: (id, g) => Boolean(db.prepare("SELECT 1 FROM occupant_tenures WHERE node_id=? AND generation_uuid=?").get(id, g)),
});
// A fresh running hook (1 s old) takes the #1864 path; none takes the ordinary pane probe.
if (hookEvent) expect(store.recordHookEvent({ runtime: "claude-code", sessionName: name, hookEvent, generation,
occurredAt: new Date(now.getTime() - 1_000).toISOString() }).ok).toBe(true);
const paste = vi.fn(async () => ({ ok: true as const })), enter = vi.fn(async () => ({ ok: true as const }));
const adapter = { hasSession: async () => true, probeSession: async () => ({ state: "present" as const }),
getPaneCommand: async () => "claude", getPanePid: async () => null, listPanes: async () => [],
hasSessionEnv: async () => false, capturePaneContent: vi.fn(async () => pane), sendText: paste, sendKeys: enter,
} as unknown as TmuxAdapter;
const transport = new SessionTransport({ db, rigRepo: repo, sessionRegistry: registry, tmuxAdapter: adapter,
agentActivityStore: store, now: () => now, sleep: async () => undefined, activityEndpointFile: () => null });
const result = await transport.send(name, "Ordinary synthetic peer message");
return { result, pasted: paste.mock.calls.length };
} finally { db.close(); }
}
it.each([["the ordinary pane probe", null], ["the fresh running-hook path (#1864)", "UserPromptSubmit"]] as const)(
"is delivered through %s", async (_path, hook) => {
const { result, pasted } = await send(working, hook);
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(pasted).toBe(1);
});
it.each([["the ordinary pane probe", null], ["the fresh running-hook path (#1864)", "UserPromptSubmit"]] as const)(
"is still refused at a real permission dialogue through %s", async (_path, hook) => {
for (const pane of [dialogs.permissionBash!, dialogs.mcpMenu!]) {
const { result, pasted } = await send(pane, hook);
expect(result).toMatchObject({ ok: false, reason: "target_needs_input" });
expect(pasted).toBe(0);
}
});
});