Skip to main content

winx_code_agent/tools/
initialize.rs

1//! Implementation of the Initialize tool.
2
3use 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
28/// Create a unique scratch workspace under the system temp dir, used when the
29/// caller initializes without a workspace path.
30fn 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
40/// Whether `cmd` is on PATH (best-effort, used only for advisory hints).
41fn 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    // Forgive the common `["all"]` mistake before turning relative globs absolute.
54    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        // wcgw parity: no path given → spin up a scratch playground instead of
126        // forcing the agent to always supply a workspace.
127        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    // Canonicalize so downstream comparisons (workspace checks, glob prefixes) match
155    // paths that were canonicalized via fs::canonicalize — important on macOS where
156    // /var, /tmp etc. are symlinks to /private/var, /private/tmp.
157    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            // A bash snapshot already carries cwd/workspace. Without one, prefer
258            // the project root recorded in the resumed memory (so the agent lands
259            // back in the right repo), then fall back to the provided folder.
260            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            // Inject the behavioral prompt for the active mode so the agent knows
309            // how to behave (read-only / allowed globs / etc.) before its first
310            // action, instead of discovering the rules by hitting enforcement errors.
311            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
397/// Append the standard "disallow" note plus any operator-provided instructions
398/// from `WINX_SERVER_INSTRUCTIONS`, mirroring wcgw's Initialize output.
399fn 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}