Compare commits

...
Author SHA1 Message Date
dev60-review 4543979bb6 fix(resume): a bare ❯ row under a chooser doesn't retire it
Counting any bare ❯ row after a chooser's options as Claude's input box
made a live chooser with a ❯ row and a mode footer under it read as
resumed, which the restore orchestrator's headerless chooser case refuses.
A missed chooser is the dangerous direction: OpenRig would report a resumed
seat while it waits at the chooser.

Only Claude's own UI retires a chooser now: its framed input box (a rule,
the ❯ row directly under it, another rule), its version header or a reply.
2026-10-25 15:06:03 +00:00
dev60-review 864cf5cdab fix(resume): accept Claude's labelled input-frame rule
On a named session Claude 2.1.309 draws its input frame's top rule with the
session name in it (`─────…── probe-claude@rig ─`), and OpenRig names its
Claude seats. The frame rule only matched a bare run of `─`, so on those
seats a numbered draft still kept an old chooser above it live.

A frame rule may now carry text after a space. The frame still needs a rule,
the ❯ row directly under it and another rule below.
2026-10-25 13:05:50 +00:00
dev60-review 2c1c1e564c fix(resume): a long conversation list and a numbered draft read right
A live conversation list longer than the window pushed its "Choose a
conversation" heading above the last 30 drawn rows, so a seat waiting at
it read as resumed. The heading is read from the whole capture again; the
last option must still be recent, and nothing of Claude's own may follow it.

A composer whose draft starts with "1." looked like a numbered option row,
so an old chooser above it stayed live. Claude's input frame (a rule, the
❯ row directly under it, another rule) now counts as Claude's composer,
while a bare "❯ 3." row in a live list stays an option.
2026-10-25 12:42:50 +00:00
dev60-review 8fb1188414 fix(resume): a chooser counts only while it's the last thing drawn
Ending each window at the last drawn row also reached further back, so an
old chooser (or a reply quoting one) above a later composer made a resumed
seat read as waiting at a chooser. The same misread already happened on
main when such text sat near the bottom of a pane.

A Claude resume chooser now needs its heading inside the window with its
options after it, and nothing of Claude's own drawn after them: no ❯
composer, version header or reply. Codex's model options count only when
no composer or Codex header follows them; its status footer still belongs
to the chooser.
2026-10-25 12:17:51 +00:00
dev60-review 96071b82f1 fix(resume): find Claude's and Codex's choosers when drawn high in a tall pane
A fresh `claude --resume` or Codex model chooser is drawn at the top of the
pane, and tmux keeps the blank rows under it in a capture. Both readers took
their last 30 or 20 physical lines before dropping blank ones, so in a tall
pane they saw only blanks. Claude's resume chooser then read as a resumed
session, and Codex's model chooser as a runtime still starting.

Count each window from the last drawn row, as the Codex TUI check beside
them already does.
2026-10-25 11:55:48 +00:00
2 changed files with 165 additions and 31 deletions

No files matched your search

@@ -339,33 +339,53 @@ function looksLikeClaudeTrustPrompt(paneContent: string): boolean {
// when the operator reaches a usable state.
function looksLikeClaudeResumeSelectionPrompt(paneContent: string): boolean {
// Current Claude chooser (observed 2026-09-04) asks which fidelity to
// resume with. Require the heading, both option rows, and a selection cursor;
// prose containing the labels is intentionally insufficient.
const recentLines = paneContent.split("\n").slice(-30);
const currentChooserHeading = recentLines.some((line) => line.trim() === "How would you like to resume?");
const currentChooserOptions = recentLines.filter((line) =>
/^\s*(?:[❯›]\s+)?(?:Resume from summary|Resume full session as-is)\s*$/.test(line)
);
const currentChooserSelection = currentChooserOptions.some((line) => /^\s*[❯›]\s+/.test(line));
if (
currentChooserHeading
&& currentChooserSelection
&& currentChooserOptions.some((line) => line.includes("Resume from summary"))
&& currentChooserOptions.some((line) => line.includes("Resume full session as-is"))
) return true;
// resume with. Require the heading, both option rows after it, and a selection cursor;
// prose containing the labels is intentionally insufficient. A chooser drawn
// at the top of a tall pane sits above blank rows, so count from the last drawn row.
// A chooser counts only while it is the last thing drawn: a composer, header or reply
// after its options means it is history above a session that resumed.
const drawn = paneContent.trimEnd().split("\n");
const recentLines = drawn.slice(-30);
const heading = recentLines.map((line) => line.trim() === "How would you like to resume?").lastIndexOf(true);
if (heading >= 0) {
const after = recentLines.slice(heading + 1);
const options = after.map((line) => /^\s*(?:[❯›]\s+)?(?:Resume from summary|Resume full session as-is)\s*$/.test(line));
const chosen = (test: (line: string) => boolean) => after.some((line, at) => options[at] && test(line));
if (
chosen((line) => /^\s*[❯›]\s+/.test(line))
&& chosen((line) => line.includes("Resume from summary"))
&& chosen((line) => line.includes("Resume full session as-is"))
&& !claudeDrawnAfter(after.slice(options.lastIndexOf(true) + 1))
) return true;
}
// Stable substring is the explicit "Choose ... conversation" verb plus the
// numbered/arrow option marker that Claude prints. Both must be present so
// we don't false-positive on similar TUI strings.
const hasChooseVerb =
paneContent.includes("Choose a conversation")
|| paneContent.includes("Choose the conversation")
|| paneContent.includes("Select a conversation");
if (!hasChooseVerb) return false;
// numbered/arrow option marker that Claude prints after it. A long list can push the
// heading above the window, so it's read from the whole capture; the last option must be
// in the window, and nothing of Claude's own may be drawn after it.
const verb = drawn.map((line) => /Choose (?:a|the) conversation|Select a conversation/.test(line)).lastIndexOf(true);
if (verb < 0) return false;
const recentStart = Math.max(verb + 1, drawn.length - 30);
const lastOption = drawn.map((line, at) => at >= recentStart && /^\s*(?:[›»]\s*)?\d+\.\s+\S/.test(line)).lastIndexOf(true);
// A draft's own numbered rows can read as the last options, so the input frame counts anywhere after the heading.
return lastOption >= 0 && !claudeDrawnAfter(drawn.slice(lastOption + 1)) && !hasClaudeInputFrame(drawn.slice(verb + 1));
}
// Look for the numbered/arrow option marker in recent lines.
const numberedOption = recentLines.some((line) => /^\s*(?:[›»]\s*)?\d+\.\s+\S/.test(line));
return numberedOption;
/** Claude's own UI drawn after a chooser's options: its framed input box, its version header or a reply. A bare ❯ row
* doesn't count: it can sit under a live chooser, and a missed chooser reads resumed. Hint rows and blank rows under the
* options belong to the chooser. */
function claudeDrawnAfter(rows: string[]): boolean {
return hasClaudeInputFrame(rows) || rows.some((line) => line.includes("Claude Code v") || /^[●⏺]/.test(line));
}
// On a named session Claude labels the frame's top rule (`─────…── probe-claude@rig ─`), so a rule may carry text after a space.
const CLAUDE_INPUT_RULE = /^\s*─{3,}(?:\s.*)?$/;
/** Claude's input frame: a rule, the ❯ row directly under it, and another rule below. A composer holding a draft that
* starts with "1." looks like a numbered option row; the frame is what tells them apart. */
function hasClaudeInputFrame(rows: string[]): boolean {
return rows.some((line, at) => CLAUDE_INPUT_RULE.test(line) && /^\s*❯/.test(rows[at + 1] ?? "")
&& rows.slice(at + 2).some((row) => CLAUDE_INPUT_RULE.test(row)));
}
// Claude's interactive refusals of a request for a missing, expired or revoked login (code.claude.com/docs/en/errors).
@@ -580,11 +600,12 @@ function looksLikeCodexHookReviewPrompt(paneContent: string): boolean {
}
function looksLikeCodexModelSelectionPrompt(paneContent: string): boolean {
const recentLines = paneContent.split("\n").slice(-20);
const numberedModelOptions = recentLines.filter((line) => (
/^\s*(?:[›»]\s*)?\d+\.\s+/.test(line)
&& /\bgpt-[\w.-]+\b/i.test(line)
));
return numberedModelOptions.length >= 2;
// A chooser drawn at the top of a tall pane sits above blank rows, so count from the last drawn row.
const recentLines = paneContent.trimEnd().split("\n").slice(-20);
const options = recentLines.map((line) => /^\s*(?:[›»]\s*)?\d+\.\s+/.test(line) && /\bgpt-[\w.-]+\b/i.test(line));
if (options.filter(Boolean).length < 2) return false;
// The options count only while they are the last thing drawn: a composer or a Codex header after
// them means they are history above a session that resumed. The status footer belongs to the chooser.
return !recentLines.slice(options.lastIndexOf(true) + 1)
.some((line) => line.includes("OpenAI Codex (v") || hasCodexComposer(line));
}
@@ -909,6 +909,107 @@ describe("native resume probe", () => {
).toBe("model_selection_gate");
});
it.each([
["banner", [
"╭───────────────────────────────────────╮",
"│ >_ OpenAI Codex (v0.124.0) │",
"╰───────────────────────────────────────╯",
"",
"› 1. Switch to gpt-5.1-codex-mini Optimized for codex. Cheaper,",
" 2. Switch to gpt-5.4-codex Stronger for complex tasks.",
" 3. Keep current model",
"",
" gpt-5.4 default · ~/code/openrig",
]],
["structural", ["› 1. gpt-5.1-codex-mini", " 2. gpt-5.4-codex", "", " gpt-5.4 default · ~/code/openrig"]],
["» header", ["OpenAI Codex (v0.153.4)", "» 1. gpt-6-astra", " 2. gpt-6-sol", " gpt-6-astra ultra · ~/Documents/openrig"]],
])("keeps the %s Codex model chooser drawn high in a tall pane a gate", (_shape, rows) => {
const paneContent = `${rows.join("\n")}\n${Array<string>(45).fill("").join("\n")}`;
expect(assessNativeResumeProbe({ runtime: "codex", paneCommand: "codex", paneContent }))
.toMatchObject({ status: "inconclusive", code: "model_selection_gate" });
});
// A chooser counts only while it's the last thing drawn. Chooser rows (or text quoting one) above a later composer
// are history, whether the pane reaches its bottom (the capture's final newline is its only blank) or is drawn high.
describe("a chooser above a later composer is history", () => {
const claudeResumed = ["Claude Code v2.1.310", "", "● Picked up where we left off.", "", "─".repeat(40), "❯ ", "─".repeat(40),
" ⏵⏵ accept edits on (shift+tab to cycle)"].join("\n");
const codexResumed = ["╭────────────────────────────╮", "│ >_ OpenAI Codex (v0.164.0) │", "╰────────────────────────────╯", "",
"› Ask Codex to do anything", "", " gpt-6-astra ultra · ~/code/openrig"].join("\n");
const shapes = { bottom: (pane: string) => `${pane}\n`, tall: (pane: string) => `${pane}\n${Array<string>(45).fill("").join("\n")}` };
const cases: Array<[string, "claude-code" | "codex", string]> = [
["an old numbered Claude chooser", "claude-code", "Choose a conversation to resume:\n\n 1. project-foo (modified 2h ago)\n 2. project-bar (modified yesterday)\n\nEnter a number, or press q to cancel."],
["an old › arrow Claude chooser", "claude-code", "Choose the conversation to resume:\n\n› 1. recent project\n 2. older project"],
["an old two-choice Claude chooser", "claude-code", "How would you like to resume?\n\n❯ Resume from summary\n Resume full session as-is"],
["a reply quoting the whole Claude chooser", "claude-code", "● The chooser read:\nHow would you like to resume?\n❯ Resume from summary\n Resume full session as-is"],
["a reply naming the Claude chooser and ending in a list", "claude-code", "● Choose a conversation to resume only if asked. Next:\n 1. run the tests\n 2. push the branch"],
["an old Codex model chooser", "codex", "› 1. gpt-5.1-codex-mini\n 2. gpt-5.4-codex\n\n gpt-5.4 default · ~/code/openrig"],
["a Codex answer listing two models", "codex", "• Two options:\n 1. gpt-6-astra for depth\n 2. gpt-6-sol for speed"],
];
for (const [shape, wrap] of Object.entries(shapes)) {
it.each(cases)(`reads ${shape}: %s, then a resumed session, as resumed`, (_name, runtime, earlier) => {
const paneContent = wrap(`${earlier}\n\n${runtime === "codex" ? codexResumed : claudeResumed}`);
expect(assessNativeResumeProbe({ runtime, paneCommand: runtime === "codex" ? "codex" : "claude", paneContent }))
.toMatchObject({ status: "resumed", code: "active_runtime" });
});
}
it("reads an old heading far above a later numbered list and Claude's composer as resumed", () => {
const conversation = Array.from({ length: 35 }, (_, at) => ` line ${at} of the conversation`).join("\n");
const paneContent = `Choose a conversation to resume:\n${conversation}\n 1. run the tests\n 2. push the branch\n\n${claudeResumed}\n`;
expect(assessNativeResumeProbe({ runtime: "claude-code", paneCommand: "claude", paneContent }))
.toMatchObject({ status: "resumed", code: "active_runtime" });
});
// A composer whose draft starts with "1." looks like a numbered option row; Claude's input frame (a rule above the
// ❯ row and another below) tells them apart. On a named session (OpenRig's seats) Claude 2.1.309 labels the top
// rule; these are its recorded 80-column rule rows (dev50-qa's #1955 native frame `b-question-cancel-observe`).
const plainRule = "─".repeat(80);
const labelledRule = `${"─".repeat(57)} probe-claude@r1955-b ─`;
const choosers = {
numbered: "Choose a conversation to resume:\n\n 1. project-foo (modified 2h ago)\n 2. project-bar (modified yesterday)",
"two-choice": "How would you like to resume?\n\n❯ Resume from summary\n Resume full session as-is",
};
it.each(Object.entries(choosers).flatMap(([shape, earlier]) => [[shape, "plain", plainRule, earlier], [shape, "labelled", labelledRule, earlier]]))(
"reads an old %s chooser above a %s-frame composer holding a numbered draft as resumed", (_shape, _frame, topRule, earlier) => {
const draft = [topRule, "❯ 1. inspect the source", " 2. then fix it", plainRule, " ⏵⏵ accept edits on (shift+tab to cycle)"].join("\n");
expect(assessNativeResumeProbe({ runtime: "claude-code", paneCommand: "claude", paneContent: `${earlier}\n\n${draft}\n` }))
.toMatchObject({ status: "resumed", code: "active_runtime" });
});
});
// A live conversation list can outgrow the window, pushing its heading above the last 30 drawn rows.
describe("a live conversation list longer than the window", () => {
const list = (rows: number) => ["Choose a conversation to resume:", "",
...Array.from({ length: rows }, (_, at) => ` ${at + 1}. project-${at + 1} (modified ${at + 1}h ago)`), "",
"Enter a number, or press q to cancel."].join("\n");
it.each([
["bottom-reaching", `${list(40)}\n`],
["drawn high", `${list(40)}\n${Array<string>(20).fill("").join("\n")}`],
])("still reads as attention_required, %s", (_shape, paneContent) => {
expect(assessNativeResumeProbe({ runtime: "claude-code", paneCommand: "claude", paneContent }))
.toMatchObject({ status: "attention_required", code: "claude_resume_selection_prompt" });
});
// A bare ❯ row and a mode footer under a live chooser (restore-orchestrator's headerless chooser screen) aren't Claude's
// framed input box, so the chooser stands.
it.each([
["conversation list", "Choose a conversation to resume:\n 1. example"],
["two-choice", "How would you like to resume?\n\n❯ Resume from summary\n Resume full session as-is"],
])("keeps a live %s with a bare ❯ row and a mode footer under it a chooser", (_shape, chooser) => {
const paneContent = `${chooser}\n❯ \n ⏵⏵ bypass permissions on (shift+tab to cycle)\n`;
expect(assessNativeResumeProbe({ runtime: "claude-code", paneCommand: "claude", paneContent }))
.toMatchObject({ status: "attention_required", code: "claude_resume_selection_prompt" });
});
it("keeps a ❯ row that isn't inside Claude's input frame an option, not a composer", () => {
const paneContent = "Choose a conversation to resume:\n\n 1. project-foo\n 2. project-bar\n❯ 3. project-baz\n\nEnter a number, or press q to cancel.\n";
expect(assessNativeResumeProbe({ runtime: "claude-code", paneCommand: "claude", paneContent }))
.toMatchObject({ status: "attention_required", code: "claude_resume_selection_prompt" });
});
});
it("classifies Codex as resumed when an old update banner remains in scrollback but the live TUI is present", () => {
expect(
assessNativeResumeProbe({
@@ -1095,6 +1196,18 @@ describe("native resume probe", () => {
expect(result).toMatchObject({ status: "attention_required", code: "claude_resume_selection_prompt" });
});
// A fresh `claude --resume` draws its chooser at the top of the pane; in a tall pane, blank rows fill the rest.
it.each([
["numbered", "Claude Code v2.1.89\n\nChoose a conversation to resume:\n\n 1. project-foo (modified 2h ago)\n 2. project-bar (modified yesterday)\n\nEnter a number, or press q to cancel."],
["› arrow", "Choose the conversation to resume:\n\n› 1. recent project\n 2. older project"],
["two-choice resume-mode", "How would you like to resume?\n\n❯ Resume from summary\n Resume full session as-is"],
])("classifies the %s chooser drawn high in a tall pane as attention_required", (_shape, chooser) => {
const paneContent = `${chooser}\n${Array<string>(45).fill("").join("\n")}`;
expect(assessNativeResumeProbe({ runtime: "claude-code", paneCommand: "claude", paneContent }))
.toMatchObject({ status: "attention_required", code: "claude_resume_selection_prompt" });
});
it("does not classify a prose mention of only one current chooser option", () => {
const result = assessNativeResumeProbe({
runtime: "claude-code",