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    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        // wcgw parity: no path given → spin up a scratch playground instead of
137        // forcing the agent to always supply a workspace.
138        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    // Canonicalize so downstream comparisons (workspace checks, glob prefixes) match
166    // paths that were canonicalized via fs::canonicalize — important on macOS where
167    // /var, /tmp etc. are symlinks to /private/var, /private/tmp.
168    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            // A bash snapshot already carries cwd/workspace. Without one, prefer
269            // the project root recorded in the resumed memory (so the agent lands
270            // back in the right repo), then fall back to the provided folder.
271            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            // Inject the behavioral prompt for the active mode so the agent knows
320            // how to behave (read-only / allowed globs / etc.) before its first
321            // action, instead of discovering the rules by hitting enforcement errors.
322            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            // Transparency: a code_writer command allowlist that includes a
332            // shell/eval spawner is bypassable (`bash -c '...'`, `find -exec`),
333            // so surface it in the response the model reads — here the allowlist
334            // is a convenience filter, not a sandbox.
335            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
428/// Append the standard "disallow" note plus any operator-provided instructions
429/// from `WINX_SERVER_INSTRUCTIONS`, mirroring wcgw's Initialize output.
430fn 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}