fix(session-transport): tighten idle-prompt discriminator per r2 adversarial review

Cycle-2 revision of 85d0844. r2 found the idle-prompt override
regex /^[❯›]\s/ was too broad — it matched any line starting with
the prompt char, including active input like '❯ Working on a task.'
where "Working" should trigger the mid-work guard.

Tightened to two complementary discriminators:
- IDLE_PROMPT_PATTERNS: /^[❯›]\s*$/ (empty prompt only — prompt
  char + optional whitespace + end-of-line)
- IDLE_STATUS_BAR_PATTERNS: Codex model footer (gpt-N... Context)
  and Claude edit-accept bar — these only render at idle prompt,
  never during active tool execution

r2 fixture A (active Claude input '❯ Working on a task.') now
correctly blocks. r2 fixture B (trust-prompt '› 1. Yes, continue')
is documented as a pre-existing gap: no MID_WORK_PATTERNS match
that content, so looksLikeMidWork was never triggered pre- or
post-fix. Trust-prompt detection would require scope expansion
per PM constraint #4; filed as separate concern. Original
stale-scrollback fixes preserved (both Codex and Claude idle
tests still pass via the status-bar discriminator).

Tests: 25/25 session-transport (5 new: 2 stale-scrollback override,
1 genuine mid-work preserved, 1 r2 fixture A active-input blocks,
1 r2 fixture B trust-prompt documented). Full daemon: 1784/1784.
tsc clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.6 committed 2026-04-21 17:52:23 -07:00
1 parent 85d0844303
commit 69479d9a39
2 files changed
+64 -7

No files matched your search

@@ -10,12 +10,19 @@ const MID_WORK_PATTERNS = [
/esc to interrupt/,
];
// Prompt indicators for Claude Code (❯) and Codex (›). When one of these
// appears in the last few non-blank lines, the pane has settled to an idle
// prompt even if stale scrollback above still contains mid-work text.
// Idle-prompt patterns: empty prompt line (no typed text after the char).
// Lines like '❯ Working on a task.' have text after the prompt char and
// are NOT idle — the prompt is active with input that may look mid-work.
const IDLE_PROMPT_PATTERNS = [
/^[❯›]\s/, // prompt char + space (with or without typed text)
/^[❯›]$/, // prompt char alone (empty input line)
/^[❯›]\s*$/, // prompt char + optional whitespace + end-of-line only
];
// Status-bar patterns that ONLY appear when the harness is at its idle
// prompt. These are more reliable than the prompt char alone because they
// are never rendered during active tool execution.
const IDLE_STATUS_BAR_PATTERNS = [
/gpt-\d[\d.]* .+ · Context \[/, // Codex model/context footer
/⏵⏵ accept edits/, // Claude Code edit-accept bar
];
const IDLE_TERMINAL_COMMANDS = new Set(["zsh", "bash", "sh", "fish", "nu", "tmux"]);
@@ -28,12 +35,19 @@ function looksLikeMidWork(paneContent: string): boolean {
// Mid-work pattern matched in the 5-line tail. Check whether the pane has
// since settled to an idle harness prompt — if so, the match is stale
// scrollback, not current working state. Check the last 3 non-blank lines
// (covers prompt line + status bar below it).
// for either an empty prompt (❯/› alone on a line) or a runtime-specific
// idle status bar (Codex model footer / Claude edit-accept bar). These
// indicators are only rendered when the harness is at the idle prompt;
// prompt chars with text after them (like '❯ Working on a task.') are
// NOT treated as idle.
const lastNonBlank = lines
.map((l) => l.trim())
.filter((l) => l.length > 0)
.slice(-3);
if (lastNonBlank.some((l) => IDLE_PROMPT_PATTERNS.some((p) => p.test(l)))) return false;
const hasIdleIndicator =
lastNonBlank.some((l) => IDLE_PROMPT_PATTERNS.some((p) => p.test(l))) ||
lastNonBlank.some((l) => IDLE_STATUS_BAR_PATTERNS.some((p) => p.test(l)));
if (hasIdleIndicator) return false;
return true;
}
@@ -313,6 +313,49 @@ describe("SessionTransport", () => {
expect(sendTextSpy).toHaveBeenCalled();
});
it("send still refuses when prompt char line contains mid-work text (active Claude input)", async () => {
seedCanonicalRig();
const tmux = mockTmux({
capturePaneContent: async () => [
"❯ Working on a task.",
"",
].join("\n"),
});
const transport = createTransport(tmux);
const result = await transport.send("dev-impl@my-rig", "hello");
expect(result.ok).toBe(false);
expect(result.reason).toBe("mid_work");
});
// NOTE: r2 flagged '› 1. Yes, continue' as a bypass concern. This content
// does NOT match any MID_WORK_PATTERNS (no spinner/Working/esc-to-interrupt)
// so looksLikeMidWork was NEVER triggered — pre- or post-fix. This is a
// pre-existing gap (trust-prompt pass-through), not a stale-scrollback
// regression. Adding trust-prompt detection to MID_WORK_PATTERNS would be
// scope expansion per PM constraint #4. Filed as separate named concern in
// the handoff. This test documents the current truthful classification.
it("send proceeds when prompt char appears in a trust-choice line with no mid-work patterns", async () => {
seedCanonicalRig();
const sendTextSpy = vi.fn(async () => ({ ok: true as const }));
const tmux = mockTmux({
capturePaneContent: async () => [
"› 1. Yes, continue",
"",
].join("\n"),
sendText: sendTextSpy,
});
const transport = createTransport(tmux);
const result = await transport.send("dev-impl@my-rig", "hello");
// No MID_WORK_PATTERNS match → guard not triggered → send proceeds.
// Trust-prompt detection is a separate concern (not stale-scrollback scope).
expect(result.ok).toBe(true);
expect(sendTextSpy).toHaveBeenCalled();
});
it("send still refuses when Working footer is present with no idle prompt below it", async () => {
seedCanonicalRig();
const tmux = mockTmux({