mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
fix(session-transport): skip mid-work block when idle prompt follows stale scrollback
looksLikeMidWork() checked the last 5 pane lines for patterns like "Working" and "esc to interrupt" but did not account for the pane having settled to an idle prompt since the working text appeared. Stale scrollback from a completed task would trip the guard and hard-block rig send to idle-at-prompt agents. Add IDLE_PROMPT_PATTERNS that recognise Claude Code (❯) and Codex (›) prompt characters. When mid-work patterns match, a second pass checks the last 3 non-blank lines for prompt indicators. If a prompt is found, the match is classified as stale scrollback and the guard returns false — send proceeds without --force. Genuine mid-work (working footer with no idle prompt below it) is still blocked. The guard is not removed or weakened for real interruption protection. Tests added (3): - Codex pane with "Working" + "esc to interrupt" above idle "›" prompt → send proceeds (was false-positive) - Claude Code pane with "Working…" above idle "❯" prompt → send proceeds (was false-positive) - Working footer present with NO prompt below → send still blocks (regression: real protection preserved) Gates: session-transport.test.ts 23/23; full daemon suite 1782/1782 (128 files); tsc --noEmit clean; npm run build clean. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
1 parent
a4071441c3
commit
85d0844303
2 files changed
+84
-2
No files matched your search
@@ -10,11 +10,32 @@ 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.
|
||||
const IDLE_PROMPT_PATTERNS = [
|
||||
/^[❯›]\s/, // prompt char + space (with or without typed text)
|
||||
/^[❯›]$/, // prompt char alone (empty input line)
|
||||
];
|
||||
|
||||
const IDLE_TERMINAL_COMMANDS = new Set(["zsh", "bash", "sh", "fish", "nu", "tmux"]);
|
||||
|
||||
function looksLikeMidWork(paneContent: string): boolean {
|
||||
const lastLines = paneContent.split("\n").slice(-5).join("\n");
|
||||
return MID_WORK_PATTERNS.some((p) => p.test(lastLines));
|
||||
const lines = paneContent.split("\n");
|
||||
const lastLines = lines.slice(-5).join("\n");
|
||||
if (!MID_WORK_PATTERNS.some((p) => p.test(lastLines))) return false;
|
||||
|
||||
// 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).
|
||||
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;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
|
||||
@@ -271,6 +271,67 @@ describe("SessionTransport", () => {
|
||||
expect(sendTextSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("send does not refuse when Working text is stale scrollback above an idle Codex prompt", async () => {
|
||||
seedCanonicalRig();
|
||||
const sendTextSpy = vi.fn(async () => ({ ok: true as const }));
|
||||
const tmux = mockTmux({
|
||||
capturePaneContent: async () => [
|
||||
"◦ Working (9m 26s • esc to interrupt) · 6 background terminals running",
|
||||
"",
|
||||
"› Use /skills to list available skills",
|
||||
"",
|
||||
" gpt-5.4 xhigh fast · Context [█▉ ] · ~/code/projects/openrig-hub · Fas…",
|
||||
].join("\n"),
|
||||
sendText: sendTextSpy,
|
||||
});
|
||||
const transport = createTransport(tmux);
|
||||
|
||||
const result = await transport.send("dev-impl@my-rig", "hello");
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(sendTextSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("send does not refuse when Working text is stale scrollback above an idle Claude Code prompt", async () => {
|
||||
seedCanonicalRig();
|
||||
const sendTextSpy = vi.fn(async () => ({ ok: true as const }));
|
||||
const tmux = mockTmux({
|
||||
capturePaneContent: async () => [
|
||||
"✢ Working… (5m 9s · ↑ 4.4k tokens)",
|
||||
" ⎿ Tip: Use /btw to ask a quick side question",
|
||||
"",
|
||||
"❯ ",
|
||||
" ⏵⏵ accept edits on (shift+tab to cycle)",
|
||||
].join("\n"),
|
||||
sendText: sendTextSpy,
|
||||
});
|
||||
const transport = createTransport(tmux);
|
||||
|
||||
const result = await transport.send("dev-impl@my-rig", "hello");
|
||||
|
||||
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({
|
||||
capturePaneContent: async () => [
|
||||
"Reading file…",
|
||||
"",
|
||||
"◦ Working (2m 3s • esc to interrupt)",
|
||||
" ⎿ Processing 4 files",
|
||||
"",
|
||||
].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");
|
||||
});
|
||||
|
||||
it("send to terminal session with foreground non-shell command refuses with mid_work", async () => {
|
||||
const rig = rigRepo.createRig("term-rig");
|
||||
const node = rigRepo.addNode(rig.id, "infra.ui", {
|
||||
|
||||
Reference in new issue
Block a user