1use std::fmt::Write as FmtWrite;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use tokio::sync::Mutex;
8use tracing::{debug, info, instrument, warn};
9
10use crate::errors::{Result, WinxError};
11use crate::state::bash_state::{generate_thread_id, BashState};
12use crate::types::{
13 normalize_thread_id, AllowedCommands, AllowedGlobs, BashCommandMode, BashMode,
14 CodeWriterConfig, FileEditMode, Initialize, InitializeType, ModeName, Modes, WriteIfEmptyMode,
15};
16use crate::utils::mmap::read_file_to_string;
17use crate::utils::path::{ensure_directory_exists, expand_user, validate_path_in_workspace};
18
19#[inline]
20fn convert_mode_name(mode_name: &ModeName) -> Modes {
21 match mode_name {
22 ModeName::Wcgw => Modes::Wcgw,
23 ModeName::Architect => Modes::Architect,
24 ModeName::CodeWriter => Modes::CodeWriter,
25 }
26}
27
28fn create_playground_dir() -> Result<PathBuf> {
31 let stamp = std::time::SystemTime::now()
32 .duration_since(std::time::UNIX_EPOCH)
33 .map_or(0, |d| d.as_nanos());
34 let dir =
35 std::env::temp_dir().join(format!("winx-playground-{}-{:x}", std::process::id(), stamp));
36 ensure_directory_exists(&dir)?;
37 Ok(dir)
38}
39
40fn command_exists(cmd: &str) -> bool {
42 std::process::Command::new("sh")
43 .args(["-c", &format!("command -v {cmd}")])
44 .output()
45 .is_ok_and(|o| o.status.success())
46}
47
48fn code_writer_state(
49 config: &CodeWriterConfig,
50 workspace_root: &Path,
51) -> (BashCommandMode, FileEditMode, WriteIfEmptyMode) {
52 let mut config = config.clone();
53 config.allowed_globs.normalize();
55 config.allowed_commands.normalize();
56 config.update_relative_globs(&workspace_root.to_string_lossy());
57
58 if let AllowedCommands::List(cmds) = &config.allowed_commands {
59 let bypass = crate::utils::bash_parser::detect_allowlist_bypass(cmds);
60 if !bypass.is_empty() {
61 warn!(
62 commands = ?bypass,
63 "code_writer allowlist includes shell-spawning commands; the command \
64 allowlist is effectively bypassable and does not sandbox the agent"
65 );
66 }
67 }
68
69 (
70 BashCommandMode {
71 bash_mode: BashMode::NormalMode,
72 allowed_commands: config.allowed_commands,
73 },
74 FileEditMode { allowed_globs: config.allowed_globs.clone() },
75 WriteIfEmptyMode { allowed_globs: config.allowed_globs },
76 )
77}
78
79fn mode_to_state(
80 mode: Modes,
81 config: Option<&CodeWriterConfig>,
82 workspace_root: &Path,
83) -> Result<(BashCommandMode, FileEditMode, WriteIfEmptyMode)> {
84 match mode {
85 Modes::Wcgw => Ok((
86 BashCommandMode {
87 bash_mode: BashMode::NormalMode,
88 allowed_commands: AllowedCommands::All("all".to_string()),
89 },
90 FileEditMode { allowed_globs: AllowedGlobs::All("all".to_string()) },
91 WriteIfEmptyMode { allowed_globs: AllowedGlobs::All("all".to_string()) },
92 )),
93 Modes::Architect => Ok((
94 BashCommandMode {
95 bash_mode: BashMode::RestrictedMode,
96 allowed_commands: AllowedCommands::All("all".to_string()),
97 },
98 FileEditMode { allowed_globs: AllowedGlobs::List(vec![]) },
99 WriteIfEmptyMode { allowed_globs: AllowedGlobs::List(vec![]) },
100 )),
101 Modes::CodeWriter => {
102 let config = config.ok_or_else(|| {
103 WinxError::ArgumentParseError(
104 "code_writer_config is required when mode_name is code_writer.".to_string(),
105 )
106 })?;
107 Ok(code_writer_state(config, workspace_root))
108 }
109 }
110}
111
112fn read_initial_files_simple(files: &[String], workspace: &std::path::Path) -> String {
113 let mut output = String::new();
114 for file_path in files {
115 let expanded = expand_user(file_path);
116 let path = if std::path::Path::new(&expanded).is_absolute() {
117 PathBuf::from(&expanded)
118 } else {
119 workspace.join(&expanded)
120 };
121
122 if let Ok(validated) = validate_path_in_workspace(&path, workspace) {
123 if validated.exists() && validated.is_file() {
124 if let Ok(content) = read_file_to_string(&validated, 10_000_000) {
125 let _ = write!(output, "\n{file_path}\n```\n{content}\n```\n");
126 }
127 }
128 }
129 }
130 output
131}
132
133fn prepare_workspace(initialize: &Initialize, response: &mut String) -> Result<PathBuf> {
134 let workspace_path_str = expand_user(&initialize.any_workspace_path);
135 if workspace_path_str.is_empty() {
136 let playground = create_playground_dir()?;
139 let _ = writeln!(
140 response,
141 "No workspace path provided; created a playground at {}",
142 playground.display()
143 );
144 return Ok(playground);
145 }
146
147 let workspace_path = PathBuf::from(&workspace_path_str);
148 let mut folder_to_start = workspace_path.clone();
149
150 if workspace_path.exists() {
151 if workspace_path.is_file() {
152 folder_to_start = workspace_path.parent().unwrap_or(&workspace_path).to_path_buf();
153 let _ =
154 writeln!(response, "Using parent directory of file: {}", folder_to_start.display());
155 } else if workspace_path.is_dir() {
156 let _ = writeln!(response, "Using workspace directory: {}", folder_to_start.display());
157 }
158 } else if workspace_path.is_absolute() {
159 ensure_directory_exists(&workspace_path).map_err(|e| {
160 WinxError::WorkspacePathError(format!("Failed to create workspace: {e}"))
161 })?;
162 let _ = writeln!(response, "Created workspace directory: {}", workspace_path.display());
163 }
164
165 if folder_to_start.exists() {
169 if let Ok(canonical) = folder_to_start.canonicalize() {
170 folder_to_start = canonical;
171 }
172 }
173
174 Ok(folder_to_start)
175}
176
177fn initialize_thread_id(initialize: &Initialize) -> String {
178 let thread_id = normalize_thread_id(&initialize.thread_id);
179 if thread_id.is_empty() {
180 generate_thread_id()
181 } else {
182 thread_id
183 }
184}
185
186fn validate_thread_id(initialize: &Initialize) -> Result<()> {
187 if initialize.init_type != InitializeType::FirstCall
188 && normalize_thread_id(&initialize.thread_id).is_empty()
189 {
190 return Err(WinxError::ThreadIdMismatch(
191 "Thread id should be provided if type != 'first_call', including when resetting."
192 .to_string(),
193 ));
194 }
195
196 Ok(())
197}
198
199fn load_guidelines(workspace: &Path) -> String {
200 let mut output = String::new();
201 let mut candidates = Vec::new();
202 if let Some(home) = home::home_dir() {
203 candidates.push(home.join(".winx").join("AGENTS.md"));
204 candidates.push(home.join(".winx").join("CLAUDE.md"));
205 candidates.push(home.join(".wcgw").join("AGENTS.md"));
206 candidates.push(home.join(".wcgw").join("CLAUDE.md"));
207 }
208 candidates.push(workspace.join("AGENTS.md"));
209 candidates.push(workspace.join("CLAUDE.md"));
210
211 for path in candidates {
212 if path.is_file() {
213 if let Ok(content) = fs::read_to_string(&path) {
214 let _ = writeln!(output, "\n## {}\n{}", path.display(), content);
215 }
216 }
217 }
218 output
219}
220
221#[instrument(level = "info", skip(bash_state_arc, initialize))]
222#[allow(clippy::too_many_lines)]
223pub async fn handle_tool_call(
224 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
225 initialize: Initialize,
226) -> Result<String> {
227 let mut response = String::new();
228
229 info!("Initialize called for workspace: {}", initialize.any_workspace_path);
230
231 validate_thread_id(&initialize)?;
232 let folder_to_start = prepare_workspace(&initialize, &mut response)?;
233 let thread_id = initialize_thread_id(&initialize);
234
235 let mut bash_state_guard = bash_state_arc.lock().await;
236 let mode = convert_mode_name(&initialize.mode_name);
237 let (bash_command_mode, file_edit_mode, write_if_empty_mode) =
238 mode_to_state(mode, initialize.code_writer_config.as_ref(), &folder_to_start)?;
239
240 match initialize.init_type {
241 InitializeType::FirstCall => {
242 let mut new_bash_state = BashState::new();
243 new_bash_state.current_thread_id.clone_from(&thread_id);
244 new_bash_state.mode = mode;
245 new_bash_state.bash_command_mode = bash_command_mode;
246 new_bash_state.file_edit_mode = file_edit_mode;
247 new_bash_state.write_if_empty_mode = write_if_empty_mode;
248 new_bash_state.initialized = true;
249
250 let resumed_context = if initialize.task_id_to_resume.is_empty() {
251 None
252 } else {
253 crate::tools::context_save::load_saved_context(&initialize.task_id_to_resume)?
254 };
255
256 if let Some((memory_data, snapshot)) = &resumed_context {
257 if let Some(snapshot) = snapshot {
258 new_bash_state.apply_snapshot(snapshot);
259 new_bash_state.current_thread_id.clone_from(&thread_id);
260 }
261 let _ = writeln!(
262 response,
263 "\n# Resumed task {}\nFollowing is the retrieved task context:\n{}",
264 initialize.task_id_to_resume, memory_data
265 );
266 }
267
268 if resumed_context.as_ref().and_then(|(_, snapshot)| snapshot.as_ref()).is_none() {
272 let resumed_root = resumed_context
273 .as_ref()
274 .and_then(|(memory, _)| {
275 crate::tools::context_save::extract_project_root(memory)
276 })
277 .filter(|root| root.exists());
278 let target = resumed_root.as_deref().unwrap_or(folder_to_start.as_path());
279 if target.exists() {
280 new_bash_state.update_cwd(target)?;
281 new_bash_state.update_workspace_root(target)?;
282 }
283 }
284 if new_bash_state.cwd.exists() {
285 new_bash_state.init_pty_shell().await?;
286 }
287
288 let attach_hint = {
289 let pty_guard = new_bash_state.pty_shell.lock().await;
290 pty_guard.as_ref().and_then(|shell| shell.attach_hint.clone())
291 };
292
293 *bash_state_guard = Some(new_bash_state);
294
295 let _ = write!(
296 response,
297 "\n# Environment\nSystem: {}\nMachine: {}\nInitialized in directory: {}\n",
298 std::env::consts::OS,
299 std::env::consts::ARCH,
300 bash_state_guard
301 .as_ref()
302 .map_or(folder_to_start.as_path(), |state| state.cwd.as_path())
303 .display()
304 );
305
306 if command_exists("rg") {
307 let _ = writeln!(
308 response,
309 "\n# Available commands\nUse ripgrep `rg` instead of `grep`/`find -name` — \
310 it's much faster and respects .gitignore."
311 );
312 }
313
314 let _ = writeln!(response, "\nUse thread_id={thread_id} for all winx tool calls.");
315 if let Some(attach_hint) = attach_hint {
316 let _ = writeln!(response, "\nAttach terminal: {attach_hint}");
317 }
318
319 let _ = writeln!(
323 response,
324 "\n{}",
325 crate::utils::mode_prompts::mode_prompt(
326 mode,
327 initialize.code_writer_config.as_ref()
328 )
329 );
330
331 if let Some(cfg) = initialize.code_writer_config.as_ref() {
336 if let AllowedCommands::List(cmds) = &cfg.allowed_commands {
337 let bypass = crate::utils::bash_parser::detect_allowlist_bypass(cmds);
338 if !bypass.is_empty() {
339 let _ = writeln!(
340 response,
341 "\n⚠️ SECURITY: code_writer allowlist includes shell/eval commands ({}). \
342 They execute arbitrary code from string arguments (e.g. `bash -c …`, \
343 `find -exec …`), so the command allowlist is effectively bypassable and \
344 does NOT sandbox the agent. Drop them if you intended a hard restriction.",
345 bypass.join(", ")
346 );
347 }
348 }
349 }
350
351 let active_workspace = bash_state_guard
352 .as_ref()
353 .map_or(folder_to_start.as_path(), |state| state.workspace_root.as_path());
354
355 let guidelines = load_guidelines(active_workspace);
356 if !guidelines.is_empty() {
357 let _ = writeln!(response, "\n# Agent guidelines\n{guidelines}");
358 }
359
360 if let Ok((repo_context, _)) = crate::utils::repo::get_repo_context(active_workspace) {
361 let _ = writeln!(response, "\n# Workspace structure\n{repo_context}");
362 }
363
364 if !initialize.initial_files_to_read.is_empty() {
365 let content =
366 read_initial_files_simple(&initialize.initial_files_to_read, active_workspace);
367 if !content.is_empty() {
368 let _ = writeln!(response, "\n# Requested files\n{content}");
369 }
370 }
371 }
372 InitializeType::UserAskedModeChange => {
373 if let Some(state) = bash_state_guard.as_mut() {
374 state.mode = mode;
375 state.bash_command_mode = bash_command_mode;
376 state.file_edit_mode = file_edit_mode;
377 state.write_if_empty_mode = write_if_empty_mode;
378 let _ = writeln!(response, "Changed mode to: {mode:?}");
379 let _ = writeln!(
380 response,
381 "\n{}",
382 crate::utils::mode_prompts::mode_prompt(
383 mode,
384 initialize.code_writer_config.as_ref()
385 )
386 );
387 } else {
388 return Err(WinxError::BashStateNotInitialized);
389 }
390 }
391 InitializeType::ResetShell => {
392 if let Some(state) = bash_state_guard.as_mut() {
393 state.mode = mode;
394 state.bash_command_mode = bash_command_mode;
395 state.file_edit_mode = file_edit_mode;
396 state.write_if_empty_mode = write_if_empty_mode;
397 state.init_pty_shell().await?;
398 response.push_str("Reset shell (new PTY created)\n");
399 } else {
400 return Err(WinxError::BashStateNotInitialized);
401 }
402 }
403 InitializeType::UserAskedChangeWorkspace => {
404 if let Some(state) = bash_state_guard.as_mut() {
405 if folder_to_start.exists() {
406 state.update_cwd(&folder_to_start)?;
407 state.update_workspace_root(&folder_to_start)?;
408 let _ =
409 writeln!(response, "Changed workspace to: {}", folder_to_start.display());
410 } else {
411 let _ = writeln!(
412 response,
413 "Warning: Workspace path {} does not exist",
414 folder_to_start.display()
415 );
416 }
417 } else {
418 return Err(WinxError::BashStateNotInitialized);
419 }
420 }
421 }
422
423 append_server_instructions(&mut response);
424
425 Ok(response)
426}
427
428fn append_server_instructions(response: &mut String) {
431 response.push_str(
432 "\nAs soon as you encounter \"The user has chosen to disallow the tool call.\", \
433 immediately stop doing everything and ask the user for the reason.\n",
434 );
435 if let Ok(extra) = std::env::var("WINX_SERVER_INSTRUCTIONS") {
436 let extra = extra.trim();
437 if !extra.is_empty() {
438 let _ = write!(response, "\n# Additional instructions\n{extra}\n");
439 }
440 }
441}