Compare commits

...
2 changed files with 223 additions and 2 deletions

No files matched your search

@@ -217,8 +217,10 @@ function findClaudeComposer(paneContent: string, options: ClassifyPaneOptions =
let liveStatus: string | null = null;
let headSeen = false;
let statusHead = statusStart;
for (let i = statusStart; i >= 0; i--) {
if (!lines[i]!.startsWith(indent)) break;
statusHead = i;
const line = lines[i]!.slice(indent.length);
const livePatterns = options.timerlessStatusIsLive
? [CLAUDE_LIVE_STATUS_PATTERN, CLAUDE_TIMERLESS_STATUS_PATTERN, ...MID_WORK_PATTERNS]
@@ -232,7 +234,7 @@ function findClaudeComposer(paneContent: string, options: ClassifyPaneOptions =
// or completed status ends this block; do not revive an older work row.
if (!/^\s/.test(line)) { headSeen = true; break; }
}
return { text: prompt.slice(indent.length), bar: lines[bar]!.trim(), framed, hasWarnings: bar < lines.length - 1, supportedWarningFooter, modeFooter, headSeen, liveStatus };
return { text: prompt.slice(indent.length), bar: lines[bar]!.trim(), framed, hasWarnings: bar < lines.length - 1, supportedWarningFooter, modeFooter, headSeen, liveStatus, currentLines: lines.slice(statusHead) };
}
function findPromptDraftBeforeFooter(paneContent: string): string | null {
@@ -364,7 +366,16 @@ export function classifyPaneActivity(paneContent: string, options: ClassifyPaneO
// OPR.0.4.1.10 (FR-1c): a permission/confirmation question is attention even when its selector is
// not in view — checked before the idle short-circuits so a permission block above an idle-looking
// footer never reads as idle (which would let a default send land on it).
const permissionPromptEvidence = findPatternEvidence(promptScanLines, PERMISSION_PROMPT_PATTERNS);
// A complete, empty Claude frame can separate completed output from its current status block.
// Require a recognized completed summary, not merely an unindented row. Keep a current question
// in the scan. A bare prompt, draft, live status, unsupported footer or exhausted status scan
// proves no such boundary.
const permissionLines = claudeComposer?.framed && claudeComposer.headSeen && !claudeComposer.liveStatus &&
CLAUDE_ANCHORED_MODE_FOOTER.test(claudeComposer.bar) &&
IDLE_PROMPT_PATTERNS.some(pattern => pattern.test(claudeComposer.text)) &&
CLAUDE_COMPLETED_STATUS_ROW.test(claudeComposer.currentLines[0]?.trim() ?? "")
? claudeComposer.currentLines.slice(-PROMPT_SCAN_LINES) : promptScanLines;
const permissionPromptEvidence = findPatternEvidence(permissionLines, PERMISSION_PROMPT_PATTERNS);
if (permissionPromptEvidence) {
return {
state: "attention",
@@ -1135,6 +1146,9 @@ const CLAUDE_DURATION = `(?:0\\.0s|${CLAUDE_SIXTY}s|${CLAUDE_NONZERO_SIXTY}m ${C
+ `|${CLAUDE_NONZERO_HOURS}h ${CLAUDE_SIXTY}m ${CLAUDE_SIXTY}s|[1-9][0-9]*d ${CLAUDE_HOURS}h ${CLAUDE_SIXTY}m)`;
const CLAUDE_CLOCK = "(?:(?:[1-9]|1[0-2]):[0-5][0-9] [AP]M|(?:[01][0-9]|2[0-3]):[0-5][0-9]Z?)";
const CLAUDE_COMPLETION_ROW = new RegExp(`^✻ (?:${CLAUDE_COMPLETION_VERBS.join("|")}) for ${CLAUDE_DURATION} · done ${CLAUDE_CLOCK}$`);
// Permission-history boundaries also accept the older short completed summaries in warning-footer
// layouts. Reuse the completion grammar; the input-box idle allow-list above remains exact.
const CLAUDE_COMPLETED_STATUS_ROW = new RegExp(`^✻ (?:${CLAUDE_COMPLETION_VERBS.join("|")}) for ${CLAUDE_DURATION}(?: · done ${CLAUDE_CLOCK})?$`);
/** The idle allow-list. Each entry comes from real Claude Code 2.1.296 and Codex 0.160.0 frames
* (test/fixtures/input-box, including idle seats OpenRig launched: openrig-seat-*), or, where no frame was captured,
@@ -0,0 +1,207 @@
// SYNTHETIC, not a replay: no incident pane capture was retained. Composer shape follows the
// checked-in Claude 2.1.296 idle fixtures and #814 warning-footer tests. The prose is constructed.
import { afterEach, describe, expect, it, vi } from "vitest";
import type Database from "better-sqlite3";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
import { createFullTestDb } from "./helpers/test-app.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
import { EventBus } from "../src/domain/event-bus.js";
import { classifyPaneActivity, probeSessionActivity, SessionTransport } from "../src/domain/session-transport.js";
import { SeatStructuralActivityService } from "../src/domain/seat-structural-activity-service.js";
const SEAT = "dev-impl@quoted-fixture";
const BAR = "─".repeat(70);
const QUESTION_FOOTER = "Enter to select · ↑/↓ to navigate · Esc to cancel";
const MODE = " ⏵⏵ accept edits on (shift+tab to cycle)";
const QUOTE = "● Earlier report: the peer saw a permission prompt (Do you want to proceed?).";
const DONE = "✻ Crunched for 2s";
const NOW = new Date("2026-10-15T11:30:00.000Z");
const frame = (history: string[], head = DONE, prompt = "❯\u00a0", footer = MODE, warnings: string[] = []) =>
[...history, "", head, "", `${"─".repeat(40)} dev-impl@quoted-fixture ─`, prompt, BAR, footer, ...warnings].join("\n");
const historyPane = (withFooter: boolean) => frame(withFooter ? [QUOTE, QUESTION_FOOTER] : [QUOTE]);
const DBS: Database.Database[] = [];
afterEach(() => { for (const db of DBS.splice(0)) db.close(); vi.restoreAllMocks(); });
function fixture(pane: string | null) {
const db = createFullTestDb(); DBS.push(db);
const rigRepo = new RigRepository(db);
const sessionRegistry = new SessionRegistry(db);
const rig = rigRepo.createRig("quoted-fixture");
const node = rigRepo.addNode(rig.id, "dev.impl", { role: "worker", runtime: "claude-code" });
const session = sessionRegistry.registerSession(node.id, SEAT);
sessionRegistry.updateStatus(session.id, "running");
sessionRegistry.updateBinding(node.id, { tmuxSession: SEAT });
const sendText = vi.fn(async () => ({ ok: true as const }));
const sendKeys = vi.fn(async () => ({ ok: true as const }));
const capturePaneContent = vi.fn(async () => pane);
const tmux = {
hasSession: async () => true, probeSession: async () => ({ state: "present" as const }),
sendText, sendKeys, capturePaneContent, listPanes: async () => [],
getPanePid: async () => null, getPaneCommand: async () => null,
isPaneInMode: async () => false, cancelPaneMode: async () => ({ ok: true as const }),
} as unknown as TmuxAdapter;
const eventBus = new EventBus(db);
const hooks = new AgentActivityStore({ db, eventBus });
const transport = new SessionTransport({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux,
agentActivityStore: hooks, eventBus, now: () => NOW, sleep: async () => {}, waitForIdlePollMs: 1 });
return { db, tmux, hooks, transport, sendText, sendKeys, capturePaneContent };
}
describe("quoted permission history regression", () => {
it("keeps the complete later boundary under supported mode and warning layouts", () => {
const warnings = [" ✘ Auto-update failed: no write permission to npm prefix · Run claude doctor",
" tmux focus-events off · add 'set -g focus-events on' to ~/.tmux.conf and re…",
" You've used 96% of your weekly limit · resets 12pm (UTC)"];
for (const footer of [MODE, " -- INSERT -- ⏵⏵ bypass permissions on (shift+tab to cycle)",
" ⏵⏵ auto mode on (shift+tab to cycle)", " ⏸ plan mode on (shift+tab to cycle)", " ? for shortcuts"]) {
for (const trailers of [[], warnings]) {
expect(classifyPaneActivity(frame([QUOTE], DONE, "❯ ", footer, trailers))).toMatchObject({ state: "agent_idle" });
}
}
});
for (const withFooter of [false, true]) {
const label = withFooter ? "with historical question footer" : "without historical question footer";
it(`classifier: ${label}`, () => {
expect(classifyPaneActivity(historyPane(withFooter))).toMatchObject({ state: "agent_idle" });
});
it(`ordinary probe: ${label}`, async () => {
const f = fixture(historyPane(withFooter));
expect(await probeSessionActivity({ sessionName: SEAT, runtime: "claude-code", attachmentType: "tmux", tmuxAdapter: f.tmux, now: NOW }))
.toMatchObject({ state: "idle", evidenceSource: "pane_heuristic" });
});
it(`ordinary send: ${label}`, async () => {
const f = fixture(historyPane(withFooter));
expect(await f.transport.send(SEAT, "ordinary synthetic status message")).toMatchObject({ ok: true });
expect(f.sendText).toHaveBeenCalledTimes(1); expect(f.sendKeys).toHaveBeenCalledTimes(1);
});
it(`structural cache: ${label}`, async () => {
const f = fixture(historyPane(withFooter));
const service = new SeatStructuralActivityService(f.tmux, () => NOW);
expect(await service.pollSeat(SEAT)).toMatchObject({ state: "agent_idle" });
expect(service.getStructuralActivity(SEAT)?.state).toBe("agent_idle");
});
it(`idle-wait consumer: ${label}`, async () => {
const f = fixture(historyPane(withFooter));
expect(await f.transport.waitUntilIdle(SEAT, 100)).toMatchObject({ ok: true, activity: { state: "idle" } });
expect(f.sendText).not.toHaveBeenCalled(); expect(f.sendKeys).not.toHaveBeenCalled();
expect(await f.transport.send(SEAT, "ordinary pane-only wait message", { waitForIdleMs: 100, readinessFromPaneOnly: true }))
.toMatchObject({ ok: true });
expect(f.sendText).toHaveBeenCalledTimes(1); expect(f.sendKeys).toHaveBeenCalledTimes(1);
});
}
});
describe("quoted permission history preservation", () => {
const permission = frame([], "Do you want to proceed?");
const selection = ["Choose an option", "❯ 1. Continue", " 2. Cancel", QUESTION_FOOTER].join("\n");
const tallPrompt = ["Do you want to proceed?", "❯ 1. Yes", " 2. No", " 3. Explain", " Use ↑↓ to choose, enter to confirm", BAR, "❯ ", BAR, MODE].join("\n");
it.each([permission, selection, tallPrompt])("still refuses a current permission or selection prompt", async (pane) => {
expect(classifyPaneActivity(pane).state).toBe("attention");
const f = fixture(pane);
expect(await f.transport.send(SEAT, "ordinary message")).toMatchObject({ ok: false, reason: "target_needs_input" });
expect(f.sendText).not.toHaveBeenCalled(); expect(f.sendKeys).not.toHaveBeenCalled();
});
it("does not use a bare glyph or footer as a permission-history boundary", () => {
for (const pane of [QUOTE + "\n❯ ", QUOTE + "\n❯ \n" + MODE,
frame([QUOTE]).replace(`${"─".repeat(40)} dev-impl@quoted-fixture ─\n`, ""),
frame([QUOTE], DONE, "❯ ", "unrecognized footer"),
[QUOTE, ...Array.from({ length: 20 }, () => " indented unfinished block"), BAR, "❯ ", BAR, MODE].join("\n")]) {
expect(classifyPaneActivity(pane).state).not.toBe("agent_idle");
}
});
it("keeps permission evidence in the current status head or its indented task block", () => {
for (const head of ["Do you want to proceed?", "Do you want to proceed?\n waiting for your answer", "Ready\n Do you want to proceed?"]) {
expect(classifyPaneActivity(frame([], head))).toMatchObject({ state: "attention", reason: "permission_prompt" });
}
});
it("does not erase a draft or live work, including a running hook", async () => {
expect(classifyPaneActivity(frame([QUOTE], DONE, "❯ typed draft")).state).toBe("attention");
expect(classifyPaneActivity(frame([], "✻ Thinking… (15s)"))).toMatchObject({ state: "agent_active" });
expect(classifyPaneActivity(frame([QUOTE], "✻ Thinking… (15s)")).state).not.toBe("agent_idle");
const f = fixture(historyPane(false));
f.hooks.recordHookEvent({ runtime: "claude-code", sessionName: SEAT, hookEvent: "UserPromptSubmit", occurredAt: NOW.toISOString() });
const result = await f.transport.send(SEAT, "ordinary message");
expect(result).toMatchObject({ ok: true }); expect(result.warning).toContain("busy is advisory");
});
it("fresh permission hook remains authoritative over the complete empty composer", async () => {
const f = fixture(historyPane(false));
f.hooks.recordHookEvent({ runtime: "claude-code", sessionName: SEAT, hookEvent: "Notification", subtype: "permission_prompt", occurredAt: NOW.toISOString() });
expect(await f.transport.send(SEAT, "ordinary message")).toMatchObject({ ok: false, reason: "target_needs_input", activity: { evidenceSource: "runtime_hook" } });
expect(f.sendText).not.toHaveBeenCalled(); expect(f.sendKeys).not.toHaveBeenCalled();
});
it("old permission hook and unknown pane retain the old fallback, without treating unknown as idle", async () => {
const f = fixture("unrecognized synthetic pane");
f.hooks.recordHookEvent({ runtime: "claude-code", sessionName: SEAT, hookEvent: "Notification", subtype: "permission_prompt", occurredAt: new Date(NOW.getTime() - 36_000).toISOString() });
expect(await f.transport.send(SEAT, "ordinary message")).toMatchObject({ ok: false, reason: "target_needs_input" });
expect(f.sendText).not.toHaveBeenCalled();
expect(await probeSessionActivity({ sessionName: SEAT, runtime: "claude-code", attachmentType: "tmux", tmuxAdapter: f.tmux, now: NOW })).toMatchObject({ state: "unknown" });
});
it("current permission stays authoritative with supported warnings and every mode footer", () => {
for (const mode of [MODE, " -- INSERT -- ⏵⏵ bypass permissions on (shift+tab to cycle)", " ⏵⏵ auto mode on (shift+tab to cycle)", " ⏸ plan mode on (shift+tab to cycle)", " ? for shortcuts"]) {
expect(classifyPaneActivity(frame([], "Do you want to proceed?", "❯ ", mode))).toMatchObject({ state: "attention", reason: "permission_prompt" });
expect(classifyPaneActivity(frame([], "Do you want to proceed?", "❯ ", mode, [" ✘ Auto-update failed: no write permission to npm prefix · Run claude doctor"])))
.toMatchObject({ state: "attention", reason: "permission_prompt" });
}
});
});
// Synthetic ambiguity and timerless-work controls: none supplies a completed-status boundary.
describe("quoted permission history keeps ambiguous and timerless panes guarded", () => {
const cases = [
[
"ambiguous-instruction",
"Do you want to proceed?\nPress Enter to confirm\n──────────────────────────────────────────────────\n❯ \n──────────────────────────────────────────────────\n ⏵⏵ accept edits on (shift+tab to cycle)",
],
[
"ambiguous-flat-option",
"Do you want to proceed?\n1. Yes\n──────────────────────────────────────────────────\n❯ \n──────────────────────────────────────────────────\n ⏵⏵ accept edits on (shift+tab to cycle)",
],
[
"ambiguous-bordered-detail",
"Do you want to proceed?\n│ Current request\n──────────────────────────────────────────────────\n❯ \n──────────────────────────────────────────────────\n ⏵⏵ accept edits on (shift+tab to cycle)",
],
[
"ambiguous-footer",
"Do you want to proceed?\nEnter to select · ↑/↓ to navigate · Esc to cancel\n──────────────────────────────────────────────────\n❯ \n──────────────────────────────────────────────────\n ⏵⏵ accept edits on (shift+tab to cycle)",
],
[
"selector-historical-footer",
"Do you want to proceed?\n❯ 1. Yes\n 2. No\nEnter to select · ↑/↓ to navigate · Esc to cancel\n──────────────────────────────────────────────────\n❯ \n──────────────────────────────────────────────────\n ⏵⏵ accept edits on (shift+tab to cycle)",
],
[
"live-timerless",
"● Earlier report quoted: Do you want to proceed?\n✻ Thinking…\n──────────────────────────────────────────────────\n❯ \n──────────────────────────────────────────────────\n ⏵⏵ accept edits on (shift+tab to cycle)",
],
] as const;
it.each(cases)("%s: default and strict consumers keep the permission refusal", async (_name, pane) => {
for (const timerlessStatusIsLive of [false, true]) {
expect(classifyPaneActivity(pane, { timerlessStatusIsLive }))
.toMatchObject({ state: "attention", reason: "permission_prompt" });
const f = fixture(pane);
expect(await probeSessionActivity({ sessionName: SEAT, runtime: "claude-code", attachmentType: "tmux",
tmuxAdapter: f.tmux, now: NOW, timerlessStatusIsLive }))
.toMatchObject({ state: "needs_input", reason: "permission_prompt" });
}
const f = fixture(pane);
expect(await f.transport.send(SEAT, "ordinary message"))
.toMatchObject({ ok: false, reason: "target_needs_input" });
expect(await f.transport.send(SEAT, "pane-only message", { waitForIdleMs: 100, readinessFromPaneOnly: true }))
.toMatchObject({ ok: false, reason: "target_needs_input" });
expect(await f.transport.waitUntilIdle(SEAT, 100))
.toMatchObject({ ok: false, reason: "target_needs_input" });
const service = new SeatStructuralActivityService(f.tmux, () => NOW);
expect(await service.pollSeat(SEAT)).toMatchObject({ state: "attention", reason: "permission_prompt" });
expect(service.getStructuralActivity(SEAT)?.state).toBe("attention");
for (const age of [90_000, 360_000]) {
f.hooks.recordHookEvent({ runtime: "claude-code", sessionName: SEAT, hookEvent: "Notification",
subtype: "permission_prompt", occurredAt: new Date(NOW.getTime() - age).toISOString() });
expect(await f.transport.send(SEAT, "ordinary message with an old hook"))
.toMatchObject({ ok: false, reason: "target_needs_input" });
expect(await f.transport.send(SEAT, "pane-only message with an old hook", { waitForIdleMs: 100, readinessFromPaneOnly: true }))
.toMatchObject({ ok: false, reason: "target_needs_input" });
}
expect(f.sendText).not.toHaveBeenCalled(); expect(f.sendKeys).not.toHaveBeenCalled();
});
});