Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 44 additions & 22 deletions distribution/agent-detection/claude.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
id = "claude"
version = "2026.08.31.1"
version = "2026.09.04.1"
min_engine_version = 2
updated_at = "2026-08-31T00:00:00Z"
updated_at = "2026-09-04T00:00:00Z"
aliases = ["claude-code"]

[[rules]]
Expand Down Expand Up @@ -43,7 +43,7 @@ visible_working = true
regex = ['(?m)^[\x{002A}\x{00B7}\x{2722}\x{2736}\x{273B}\x{273D}][ \t]+\S[^\n]*?(?:\n[ \t]+[^\n]*?){0,3}·(?:[ \t]+|\n[ \t]*)[1-9]\d*(?:[ \t]+|\n[ \t]*)MCP(?:[ \t]+|\n[ \t]*)tasks?(?:[ \t]+|\n[ \t]*)still(?:[ \t]+|\n[ \t]*)running[ \t]*$']
not = [
{ contains = ["do you want to proceed?"] },
{ contains = ["esc to cancel"] },
{ regex = ['(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel'] },
{ contains = ["waiting for permission"] },
{ contains = ["do you want to allow this connection?"] },
{ contains = ["tab to amend"] },
Expand Down Expand Up @@ -82,15 +82,21 @@ state = "blocked"
priority = 980
region = "after_last_horizontal_rule"
visible_blocker = true
contains = ["esc to cancel"]
# Claude word-wraps its own hint and footer lines, so any phrase sharing one of
# those lines can be split by a real newline in a narrow pane (issue #2868).
# Rules that gate on that evidence match across a single wrap with
# `(?:[ \t]+|[ \t]*\n[ \t]*)`: spaces/tabs, or one newline with optional
# indentation. The separator stays bounded on purpose. It must not match a
# blank line between the words, and it must not match no whitespace at all.
regex = ['(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel']
any = [
{ contains = ["enter to confirm"] },
{ contains = ["enter to select"], any = [
{ contains = ["tab/arrow keys to navigate"] },
{ contains = ["arrow keys to navigate"] },
{ contains = ["arrows to navigate"] },
{ contains = ["↑/↓ to navigate"] },
{ contains = ["↑↓ to navigate"] },
{ regex = ['(?i)enter(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)confirm'] },
{ regex = ['(?i)enter(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)select'], any = [
{ regex = ['(?i)tab/arrow(?:[ \t]+|[ \t]*\n[ \t]*)keys(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)navigate'] },
{ regex = ['(?i)arrow(?:[ \t]+|[ \t]*\n[ \t]*)keys(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)navigate'] },
{ regex = ['(?i)arrows(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)navigate'] },
{ regex = ['(?i)↑/↓(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)navigate'] },
{ regex = ['(?i)↑↓(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)navigate'] },
] },
]

Expand All @@ -100,7 +106,10 @@ state = "blocked"
priority = 980
region = "whole_recent"
visible_blocker = true
contains = ["run a dynamic workflow?", "esc to cancel"]
regex = [
'(?i)run(?:[ \t]+|[ \t]*\n[ \t]*)a(?:[ \t]+|[ \t]*\n[ \t]*)dynamic(?:[ \t]+|[ \t]*\n[ \t]*)workflow\?',
'(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel',
]

[[rules]]
id = "mcp_elicitation_prompt"
Expand All @@ -111,9 +120,13 @@ visible_blocker = true
# MCP elicitation dialogs (elicitation/create) show Accept/Decline controls
# with an "Esc to cancel" footer but no Enter hint, so live_blocked_form
# cannot see them (issue #3283). Gate on the invariant header line, the
# Accept/Decline control line, and the cancel footer.
contains = ["esc to cancel"]
line_regex = ['(?i)^\s*MCP server ["\x{201C}].+["\x{201D}] requests your input\s*$']
# Accept/Decline control line, and the cancel footer. The header keeps its
# line anchors so prose cannot impersonate it, but the quoted server name and
# the trailing words may wrap in a narrow pane (#2868).
regex = [
'(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel',
'(?im)^[ \t]*MCP(?:[ \t]+|[ \t]*\n[ \t]*)server(?:[ \t]+|[ \t]*\n[ \t]*)["\x{201C}][^\n]*?(?:\n[ \t]*[^\n]*?)?["\x{201D}](?:[ \t]+|[ \t]*\n[ \t]*)requests(?:[ \t]+|[ \t]*\n[ \t]*)your(?:[ \t]+|[ \t]*\n[ \t]*)input[ \t]*$',
]
all = [
{ any = [
{ line_regex = ['^\s*\x{276F}?\s*Accept\b'] },
Expand All @@ -128,12 +141,14 @@ priority = 950
region = "prompt_box_body"
visible_idle = true
line_regex = ['^\s*❯']
# A wrapped dialog footer must still suppress the idle claim, otherwise a
# narrow pane reports idle when no blocked rule managed to match (#2868).
not = [
{ contains = ["enter to select"] },
{ contains = ["esc to cancel"] },
{ contains = ["tab/arrow keys"] },
{ contains = ["arrow keys to navigate"] },
{ contains = ["↑/↓ to navigate"] },
{ regex = ['(?i)enter(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)select'] },
{ regex = ['(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel'] },
{ regex = ['(?i)tab/arrow(?:[ \t]+|[ \t]*\n[ \t]*)keys'] },
{ regex = ['(?i)arrow(?:[ \t]+|[ \t]*\n[ \t]*)keys(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)navigate'] },
{ regex = ['(?i)↑/↓(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)navigate'] },
]

[[rules]]
Expand All @@ -142,7 +157,11 @@ state = "unknown"
priority = 900
region = "whole_recent"
skip_state_update = true
contains = ["select model", "enter to set as default", "esc to cancel"]
regex = [
'(?i)select(?:[ \t]+|[ \t]*\n[ \t]*)model',
'(?i)enter(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)set(?:[ \t]+|[ \t]*\n[ \t]*)as(?:[ \t]+|[ \t]*\n[ \t]*)default',
'(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel',
]
not = [
{ contains = ["do you want to proceed?"] },
{ contains = ["enter to select"] },
Expand Down Expand Up @@ -172,7 +191,10 @@ state = "blocked"
priority = 840
region = "after_last_horizontal_rule"
visible_blocker = true
contains = ["do you want to proceed?", "esc to cancel"]
# "Do you want to proceed?" renders on its own line and only wraps below ~24
# columns, so it stays a contiguous needle; the shared footer does not.
contains = ["do you want to proceed?"]
regex = ['(?i)esc(?:[ \t]+|[ \t]*\n[ \t]*)to(?:[ \t]+|[ \t]*\n[ \t]*)cancel']
all = [
{ any = [
{ line_regex = ['(?i)^\s*❯?\s*1\.\s*yes\b'] },
Expand Down
257 changes: 257 additions & 0 deletions src/detect/manifest/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,263 @@ fn claude_blocker_screen_outranks_stale_osc_progress() {
assert!(result.visible_blocker);
}

#[test]
fn claude_wrapped_selection_dialog_is_blocked() {
// #2868: Claude word-wraps its own footer, so the reporter's 48-column pane
// splits `Esc to cancel` across two rendered rows and the contiguous needle
// stopped matching. Both captures come from the issue thread; only the pane
// width differs between them.
let wide = "──────────────────────────────────────────────────\n 4. Chat about this\n\nEnter to select · ↑/↓ to navigate · Esc to cancel\n";
let narrow = "────────────────────────────────────────────────\n 4. Chat about this\n\nEnter to select · ↑/↓ to navigate · Esc to\ncancel\n";

for (label, screen) in [("50 columns", wide), ("48 columns", narrow)] {
let result = osc_explain(Agent::Claude, screen, "", "");
assert_eq!(result.state, AgentState::Blocked, "{label}");
assert_eq!(
result.matched_rule.as_ref().map(|rule| rule.id.as_str()),
Some("live_blocked_form"),
"{label}"
);
assert!(result.visible_blocker, "{label}");
}
}

#[test]
fn claude_wrapped_selection_dialog_is_blocked_on_a_full_screen() {
// The same dialog as a whole screen, reported on Windows two releases later.
// Only the wrap positions differ; every other row is identical.
let wide = concat!(
"───────────────────────────────────────────────────────────────────────────────\n",
" ☐ Indentation\n",
"\n",
"Do you prefer tabs or spaces for indentation?\n",
"\n",
"❯ 1. Spaces\n",
" Indent with spaces (the common default in most style guides).\n",
" 2. Tabs\n",
" Indent with tab characters.\n",
" 3. Type something.\n",
"───────────────────────────────────────────────────────────────────────────────\n",
" 4. Chat about this\n",
"\n",
"Enter to select · ↑/↓ to navigate · Esc to cancel\n",
);
let narrow = concat!(
"──────────────────────────────────────────\n",
" ☐ Indentation\n",
"\n",
"Do you prefer tabs or spaces for\n",
"indentation?\n",
"\n",
"❯ 1. Spaces\n",
" Indent with spaces (the common\n",
" default in most style guides).\n",
" 2. Tabs\n",
" Indent with tab characters.\n",
" 3. Type something.\n",
"──────────────────────────────────────────\n",
" 4. Chat about this\n",
"\n",
"Enter to select · ↑/↓ to navigate · Esc to\n",
"cancel\n",
);

for (label, screen) in [("wide", wide), ("narrow", narrow)] {
let result = osc_explain(Agent::Claude, screen, "", "");
assert_eq!(result.state, AgentState::Blocked, "{label}");
assert_eq!(
result.matched_rule.as_ref().map(|rule| rule.id.as_str()),
Some("live_blocked_form"),
"{label}"
);
}
}

#[test]
fn claude_wrapped_mcp_elicitation_dialog_is_blocked() {
// The elicitation header and its footer share the same wrap exposure, and
// the header needed line anchors, so it keeps them across the wrap.
let wide = concat!(
"MCP server \u{201C}notes\u{201D} requests your input\n",
"\n",
"\u{276F} Accept\n",
" Decline\n",
"\n",
"Esc to cancel\n",
);
let narrow = concat!(
"MCP server \u{201C}notes\u{201D} requests your\n",
"input\n",
"\n",
"\u{276F} Accept\n",
" Decline\n",
"\n",
"Esc to\n",
"cancel\n",
);

for (label, screen) in [("wide", wide), ("narrow", narrow)] {
let result = osc_explain(Agent::Claude, screen, "", "");
assert_eq!(result.state, AgentState::Blocked, "{label}");
assert_eq!(
result.matched_rule.as_ref().map(|rule| rule.id.as_str()),
Some("mcp_elicitation_prompt"),
"{label}"
);
}
}

#[test]
fn claude_mcp_elicitation_header_spans_one_wrap_only() {
// The quoted server name gets the same bounded tolerance as the footer: one
// continuation row, never a blank row and never a second wrap.
let rejected = [
(
"blank row inside the quoted name",
"MCP server \u{201C}notes\n\nserver\u{201D} requests your input\n\n\u{276F} Accept\n Decline\n\nEsc to cancel\n",
),
(
"name wrapped across three rows",
"MCP server \u{201C}a\nb\nc\u{201D} requests your input\n\n\u{276F} Accept\n Decline\n\nEsc to cancel\n",
),
];

for (label, screen) in rejected {
let result = osc_explain(Agent::Claude, screen, "", "");
assert_ne!(
result.matched_rule.as_ref().map(|rule| rule.id.as_str()),
Some("mcp_elicitation_prompt"),
"{label}"
);
}
}

#[test]
fn claude_wrapped_permission_prompt_keeps_its_own_rule() {
// A narrow permission prompt already reported blocked, but only through the
// priority 300 legacy fallback. The owning rule must keep the match.
// The wide screen is the shape this rule was written against. The narrow one
// is that same screen with the footer phrase wrapped: no capture pins where
// this particular footer breaks, so it only asserts the rule survives a wrap.
let wide = "────────────────────────\nDo you want to proceed?\n\n\u{276F} 1. Yes\n 2. No\n\nEsc to cancel\n";
let narrow = "────────────────────────\nDo you want to proceed?\n\n\u{276F} 1. Yes\n 2. No\n\nEsc to\ncancel\n";

for (label, screen) in [("wide", wide), ("narrow", narrow)] {
let result = osc_explain(Agent::Claude, screen, "", "");
assert_eq!(result.state, AgentState::Blocked, "{label}");
assert_eq!(
result.matched_rule.as_ref().map(|rule| rule.id.as_str()),
Some("generic_permission_prompt"),
"{label}"
);
}
}

#[test]
fn claude_wrapped_dynamic_workflow_prompt_is_blocked() {
let narrow = "Run a dynamic\nworkflow?\n\n\u{276F} 1. Yes\n 2. No\n\nEnter to confirm · Esc to\ncancel\n";
let result = osc_explain(Agent::Claude, narrow, "", "");
assert_eq!(result.state, AgentState::Blocked);
assert!(result.visible_blocker);
}

#[test]
fn claude_wrapped_model_picker_still_skips_the_state_update() {
// The picker is not a blocker; a wrapped footer must not cost it its skip.
let narrow = "Select model\n\n\u{276F} 1. Default\n 2. Opus\n\nEnter to set as\ndefault · Esc to\ncancel\n";
let result = osc_explain(Agent::Claude, narrow, "", "");
assert_eq!(
result.matched_rule.as_ref().map(|rule| rule.id.as_str()),
Some("model_picker_menu")
);
assert!(result.skip_state_update);
}

#[test]
fn claude_wrap_tolerant_blocker_leaves_neighbouring_screens_alone() {
// Negative controls for #2868: loosening the whitespace between the footer
// words must not reach streaming, overlay, or transcript screens.
// `live_turn_working` (priority 970) owns Claude's streaming screens. The
// widened blocker at 980 must not steal them. Both lines below are real
// Claude renderings: the spinner summary and the mode line.
let spinner = osc_explain(
Agent::Claude,
"✽ Cooking… (6s · ↓ 174 tokens · thinking)\n",
"",
"",
);
assert_eq!(spinner.state, AgentState::Working);
assert_eq!(
spinner.matched_rule.as_ref().map(|rule| rule.id.as_str()),
Some("live_turn_working")
);

let mode_line = osc_explain(
Agent::Claude,
"⏵⏵ accept edits on · esc to interrupt\n",
"",
"",
);
assert_eq!(mode_line.state, AgentState::Working);
assert_eq!(
mode_line.matched_rule.as_ref().map(|rule| rule.id.as_str()),
Some("live_turn_working")
);

let overlay = osc_explain(Agent::Claude, " /btw\n a note\n\nesc to close\n", "", "");
assert_eq!(overlay.state, AgentState::Working);
assert_eq!(
overlay.matched_rule.as_ref().map(|rule| rule.id.as_str()),
Some("btw_overlay_working")
);

let transcript = osc_explain(
Agent::Claude,
"Showing detailed transcript · Ctrl+O to toggle\n",
"",
"",
);
assert_eq!(
transcript
.matched_rule
.as_ref()
.map(|rule| rule.id.as_str()),
Some("transcript_viewer")
);
assert!(transcript.skip_state_update);
}

#[test]
fn claude_blocker_footer_separator_spans_one_wrap_only() {
// The footer separator is deliberately bounded. A soft wrap inserts exactly
// one newline, so a blank line between the words is not a wrap, a
// non-breaking space is not a wrap point at all, and no whitespace is not a
// footer. All three must stay unmatched.
let rejected = [
(
"blank line between `esc to` and `cancel`",
"────────────────────────\nEnter to confirm\n\nsomething esc to\n\ncancel\n",
),
(
"non-breaking spaces instead of a wrap",
"────────────────────────\n 4. Chat about this\n\nEnter to select · ↑/↓ to navigate · Esc\u{a0}to\u{a0}cancel\n",
),
(
"no whitespace at all",
"────────────────────────\n 4. Chat about this\n\nEntertoselect · ↑/↓tonavigate · Esctocancel\n",
),
];

for (label, screen) in rejected {
let result = osc_explain(Agent::Claude, screen, "", "");
assert_ne!(
result.matched_rule.as_ref().map(|rule| rule.id.as_str()),
Some("live_blocked_form"),
"{label}"
);
}
}

#[test]
fn claude_osc_progress_4_0_is_idle() {
let result = osc_explain(Agent::Claude, "", "", "4;0;");
Expand Down
Loading
Loading