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 (
59 BashCommandMode {
60 bash_mode: BashMode::NormalMode,
61 allowed_commands: config.allowed_commands,
62 },
63 FileEditMode { allowed_globs: config.allowed_globs.clone() },
64 WriteIfEmptyMode { allowed_globs: config.allowed_globs },
65 )
66}
67
68fn mode_to_state(
69 mode: Modes,
70 config: Option<&CodeWriterConfig>,
71 workspace_root: &Path,
72) -> Result<(BashCommandMode, FileEditMode, WriteIfEmptyMode)> {
73 match mode {
74 Modes::Wcgw => Ok((
75 BashCommandMode {
76 bash_mode: BashMode::NormalMode,
77 allowed_commands: AllowedCommands::All("all".to_string()),
78 },
79 FileEditMode { allowed_globs: AllowedGlobs::All("all".to_string()) },
80 WriteIfEmptyMode { allowed_globs: AllowedGlobs::All("all".to_string()) },
81 )),
82 Modes::Architect => Ok((
83 BashCommandMode {
84 bash_mode: BashMode::RestrictedMode,
85 allowed_commands: AllowedCommands::All("all".to_string()),
86 },
87 FileEditMode { allowed_globs: AllowedGlobs::List(vec![]) },
88 WriteIfEmptyMode { allowed_globs: AllowedGlobs::List(vec![]) },
89 )),
90 Modes::CodeWriter => {
91 let config = config.ok_or_else(|| {
92 WinxError::ArgumentParseError(
93 "code_writer_config is required when mode_name is code_writer.".to_string(),
94 )
95 })?;
96 Ok(code_writer_state(config, workspace_root))
97 }
98 }
99}
100
101fn read_initial_files_simple(files: &[String], workspace: &std::path::Path) -> String {
102 let mut output = String::new();
103 for file_path in files {
104 let expanded = expand_user(file_path);
105 let path = if std::path::Path::new(&expanded).is_absolute() {
106 PathBuf::from(&expanded)
107 } else {
108 workspace.join(&expanded)
109 };
110
111 if let Ok(validated) = validate_path_in_workspace(&path, workspace) {
112 if validated.exists() && validated.is_file() {
113 if let Ok(content) = read_file_to_string(&validated, 10_000_000) {
114 let _ = write!(output, "\n{file_path}\n```\n{content}\n```\n");
115 }
116 }
117 }
118 }
119 output
120}
121
122fn prepare_workspace(initialize: &Initialize, response: &mut String) -> Result<PathBuf> {
123 let workspace_path_str = expand_user(&initialize.any_workspace_path);
124 if workspace_path_str.is_empty() {
125 let playground = create_playground_dir()?;
128 let _ = writeln!(
129 response,
130 "No workspace path provided; created a playground at {}",
131 playground.display()
132 );
133 return Ok(playground);
134 }
135
136 let workspace_path = PathBuf::from(&workspace_path_str);
137 let mut folder_to_start = workspace_path.clone();
138
139 if workspace_path.exists() {
140 if workspace_path.is_file() {
141 folder_to_start = workspace_path.parent().unwrap_or(&workspace_path).to_path_buf();
142 let _ =
143 writeln!(response, "Using parent directory of file: {}", folder_to_start.display());
144 } else if workspace_path.is_dir() {
145 let _ = writeln!(response, "Using workspace directory: {}", folder_to_start.display());
146 }
147 } else if workspace_path.is_absolute() {
148 ensure_directory_exists(&workspace_path).map_err(|e| {
149 WinxError::WorkspacePathError(format!("Failed to create workspace: {e}"))
150 })?;
151 let _ = writeln!(response, "Created workspace directory: {}", workspace_path.display());
152 }
153
154 if folder_to_start.exists() {
158 if let Ok(canonical) = folder_to_start.canonicalize() {
159 folder_to_start = canonical;
160 }
161 }
162
163 Ok(folder_to_start)
164}
165
166fn initialize_thread_id(initialize: &Initialize) -> String {
167 let thread_id = normalize_thread_id(&initialize.thread_id);
168 if thread_id.is_empty() {
169 generate_thread_id()
170 } else {
171 thread_id
172 }
173}
174
175fn validate_thread_id(initialize: &Initialize) -> Result<()> {
176 if initialize.init_type != InitializeType::FirstCall
177 && normalize_thread_id(&initialize.thread_id).is_empty()
178 {
179 return Err(WinxError::ThreadIdMismatch(
180 "Thread id should be provided if type != 'first_call', including when resetting."
181 .to_string(),
182 ));
183 }
184
185 Ok(())
186}
187
188fn load_guidelines(workspace: &Path) -> String {
189 let mut output = String::new();
190 let mut candidates = Vec::new();
191 if let Some(home) = home::home_dir() {
192 candidates.push(home.join(".winx").join("AGENTS.md"));
193 candidates.push(home.join(".winx").join("CLAUDE.md"));
194 candidates.push(home.join(".wcgw").join("AGENTS.md"));
195 candidates.push(home.join(".wcgw").join("CLAUDE.md"));
196 }
197 candidates.push(workspace.join("AGENTS.md"));
198 candidates.push(workspace.join("CLAUDE.md"));
199
200 for path in candidates {
201 if path.is_file() {
202 if let Ok(content) = fs::read_to_string(&path) {
203 let _ = writeln!(output, "\n## {}\n{}", path.display(), content);
204 }
205 }
206 }
207 output
208}
209
210#[instrument(level = "info", skip(bash_state_arc, initialize))]
211#[allow(clippy::too_many_lines)]
212pub async fn handle_tool_call(
213 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
214 initialize: Initialize,
215) -> Result<String> {
216 let mut response = String::new();
217
218 info!("Initialize called for workspace: {}", initialize.any_workspace_path);
219
220 validate_thread_id(&initialize)?;
221 let folder_to_start = prepare_workspace(&initialize, &mut response)?;
222 let thread_id = initialize_thread_id(&initialize);
223
224 let mut bash_state_guard = bash_state_arc.lock().await;
225 let mode = convert_mode_name(&initialize.mode_name);
226 let (bash_command_mode, file_edit_mode, write_if_empty_mode) =
227 mode_to_state(mode, initialize.code_writer_config.as_ref(), &folder_to_start)?;
228
229 match initialize.init_type {
230 InitializeType::FirstCall => {
231 let mut new_bash_state = BashState::new();
232 new_bash_state.current_thread_id.clone_from(&thread_id);
233 new_bash_state.mode = mode;
234 new_bash_state.bash_command_mode = bash_command_mode;
235 new_bash_state.file_edit_mode = file_edit_mode;
236 new_bash_state.write_if_empty_mode = write_if_empty_mode;
237 new_bash_state.initialized = true;
238
239 let resumed_context = if initialize.task_id_to_resume.is_empty() {
240 None
241 } else {
242 crate::tools::context_save::load_saved_context(&initialize.task_id_to_resume)?
243 };
244
245 if let Some((memory_data, snapshot)) = &resumed_context {
246 if let Some(snapshot) = snapshot {
247 new_bash_state.apply_snapshot(snapshot);
248 new_bash_state.current_thread_id.clone_from(&thread_id);
249 }
250 let _ = writeln!(
251 response,
252 "\n# Resumed task {}\nFollowing is the retrieved task context:\n{}",
253 initialize.task_id_to_resume, memory_data
254 );
255 }
256
257 if resumed_context.as_ref().and_then(|(_, snapshot)| snapshot.as_ref()).is_none() {
261 let resumed_root = resumed_context
262 .as_ref()
263 .and_then(|(memory, _)| {
264 crate::tools::context_save::extract_project_root(memory)
265 })
266 .filter(|root| root.exists());
267 let target = resumed_root.as_deref().unwrap_or(folder_to_start.as_path());
268 if target.exists() {
269 new_bash_state.update_cwd(target)?;
270 new_bash_state.update_workspace_root(target)?;
271 }
272 }
273 if new_bash_state.cwd.exists() {
274 new_bash_state.init_pty_shell().await?;
275 }
276
277 let attach_hint = {
278 let pty_guard = new_bash_state.pty_shell.lock().await;
279 pty_guard.as_ref().and_then(|shell| shell.attach_hint.clone())
280 };
281
282 *bash_state_guard = Some(new_bash_state);
283
284 let _ = write!(
285 response,
286 "\n# Environment\nSystem: {}\nMachine: {}\nInitialized in directory: {}\n",
287 std::env::consts::OS,
288 std::env::consts::ARCH,
289 bash_state_guard
290 .as_ref()
291 .map_or(folder_to_start.as_path(), |state| state.cwd.as_path())
292 .display()
293 );
294
295 if command_exists("rg") {
296 let _ = writeln!(
297 response,
298 "\n# Available commands\nUse ripgrep `rg` instead of `grep`/`find -name` — \
299 it's much faster and respects .gitignore."
300 );
301 }
302
303 let _ = writeln!(response, "\nUse thread_id={thread_id} for all winx tool calls.");
304 if let Some(attach_hint) = attach_hint {
305 let _ = writeln!(response, "\nAttach terminal: {attach_hint}");
306 }
307
308 let _ = writeln!(
312 response,
313 "\n{}",
314 crate::utils::mode_prompts::mode_prompt(
315 mode,
316 initialize.code_writer_config.as_ref()
317 )
318 );
319
320 let active_workspace = bash_state_guard
321 .as_ref()
322 .map_or(folder_to_start.as_path(), |state| state.workspace_root.as_path());
323
324 let guidelines = load_guidelines(active_workspace);
325 if !guidelines.is_empty() {
326 let _ = writeln!(response, "\n# Agent guidelines\n{guidelines}");
327 }
328
329 if let Ok((repo_context, _)) = crate::utils::repo::get_repo_context(active_workspace) {
330 let _ = writeln!(response, "\n# Workspace structure\n{repo_context}");
331 }
332
333 if !initialize.initial_files_to_read.is_empty() {
334 let content =
335 read_initial_files_simple(&initialize.initial_files_to_read, active_workspace);
336 if !content.is_empty() {
337 let _ = writeln!(response, "\n# Requested files\n{content}");
338 }
339 }
340 }
341 InitializeType::UserAskedModeChange => {
342 if let Some(state) = bash_state_guard.as_mut() {
343 state.mode = mode;
344 state.bash_command_mode = bash_command_mode;
345 state.file_edit_mode = file_edit_mode;
346 state.write_if_empty_mode = write_if_empty_mode;
347 let _ = writeln!(response, "Changed mode to: {mode:?}");
348 let _ = writeln!(
349 response,
350 "\n{}",
351 crate::utils::mode_prompts::mode_prompt(
352 mode,
353 initialize.code_writer_config.as_ref()
354 )
355 );
356 } else {
357 return Err(WinxError::BashStateNotInitialized);
358 }
359 }
360 InitializeType::ResetShell => {
361 if let Some(state) = bash_state_guard.as_mut() {
362 state.mode = mode;
363 state.bash_command_mode = bash_command_mode;
364 state.file_edit_mode = file_edit_mode;
365 state.write_if_empty_mode = write_if_empty_mode;
366 state.init_pty_shell().await?;
367 response.push_str("Reset shell (new PTY created)\n");
368 } else {
369 return Err(WinxError::BashStateNotInitialized);
370 }
371 }
372 InitializeType::UserAskedChangeWorkspace => {
373 if let Some(state) = bash_state_guard.as_mut() {
374 if folder_to_start.exists() {
375 state.update_cwd(&folder_to_start)?;
376 state.update_workspace_root(&folder_to_start)?;
377 let _ =
378 writeln!(response, "Changed workspace to: {}", folder_to_start.display());
379 } else {
380 let _ = writeln!(
381 response,
382 "Warning: Workspace path {} does not exist",
383 folder_to_start.display()
384 );
385 }
386 } else {
387 return Err(WinxError::BashStateNotInitialized);
388 }
389 }
390 }
391
392 append_server_instructions(&mut response);
393
394 Ok(response)
395}
396
397fn append_server_instructions(response: &mut String) {
400 response.push_str(
401 "\nAs soon as you encounter \"The user has chosen to disallow the tool call.\", \
402 immediately stop doing everything and ask the user for the reason.\n",
403 );
404 if let Ok(extra) = std::env::var("WINX_SERVER_INSTRUCTIONS") {
405 let extra = extra.trim();
406 if !extra.is_empty() {
407 let _ = write!(response, "\n# Additional instructions\n{extra}\n");
408 }
409 }
410}