Skip to main content

winx_code_agent/tools/
initialize.rs

1//! Implementation of the Initialize tool.
2
3use std::collections::VecDeque;
4use std::fmt::Write as FmtWrite;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::sync::{Arc, Mutex as StdMutex, OnceLock};
8use tokio::sync::Mutex;
9use tracing::{info, instrument, warn};
10
11use crate::errors::{Result, WinxError};
12use crate::runtime::{EmbeddedShellRuntime, ShellRuntime, ShellSessionTransition};
13use crate::state::bash_state::{generate_thread_id, BashState};
14use crate::types::{
15    normalize_thread_id, AllowedCommands, AllowedGlobs, BashCommandMode, BashMode,
16    CodeWriterConfig, FileEditMode, Initialize, InitializeType, Modes, WriteIfEmptyMode,
17};
18use crate::utils::mmap::read_file_to_string;
19use crate::utils::path::{ensure_directory_exists, expand_user, validate_path_in_workspace};
20
21const POLICY_WARNING_CACHE_CAPACITY: usize = 128;
22static WARNED_POLICY_CONFIGURATIONS: OnceLock<StdMutex<VecDeque<Vec<String>>>> = OnceLock::new();
23
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub(crate) enum InitializeTransition {
26    Created,
27    AttachedExisting,
28    ModeChanged,
29    ShellReset,
30    WorkspaceChanged,
31}
32
33impl InitializeTransition {
34    pub(crate) const fn as_str(self) -> &'static str {
35        match self {
36            Self::Created => "created",
37            Self::AttachedExisting => "attached_existing",
38            Self::ModeChanged => "mode_changed",
39            Self::ShellReset => "shell_reset",
40            Self::WorkspaceChanged => "workspace_changed",
41        }
42    }
43}
44
45#[derive(Debug)]
46pub(crate) struct InitializeOutcome {
47    pub(crate) text: String,
48    pub(crate) transition: InitializeTransition,
49    pub(crate) context_bytes: usize,
50    pub(crate) guidelines_bytes: usize,
51    pub(crate) initial_files_count: usize,
52    pub(crate) compact_response: bool,
53    pub(crate) code_writer_policy_strength: Option<&'static str>,
54    pub(crate) shell_spawners_present: bool,
55}
56
57/// Create a unique scratch workspace under the system temp dir, used when the
58/// caller initializes without a workspace path.
59fn create_playground_dir(thread_id: &str) -> Result<PathBuf> {
60    #[cfg(unix)]
61    let owner = crate::os::unix::effective_uid().to_string();
62    #[cfg(not(unix))]
63    let owner = std::process::id().to_string();
64
65    let dir = std::env::temp_dir().join(format!("winx-playground-{owner}-{thread_id}"));
66    ensure_directory_exists(&dir)?;
67    Ok(dir)
68}
69
70/// Whether `cmd` is on PATH (best-effort, used only for advisory hints).
71fn command_exists(cmd: &str) -> bool {
72    std::process::Command::new("sh")
73        .args(["-c", &format!("command -v {cmd}")])
74        .output()
75        .is_ok_and(|o| o.status.success())
76}
77
78fn code_writer_state(
79    config: &CodeWriterConfig,
80    workspace_root: &Path,
81) -> (BashCommandMode, FileEditMode, WriteIfEmptyMode) {
82    let mut config = config.clone();
83    // Forgive the common `["all"]` mistake before turning relative globs absolute.
84    config.allowed_globs.normalize();
85    config.allowed_commands.normalize();
86    config.update_relative_globs(&workspace_root.to_string_lossy());
87
88    (
89        BashCommandMode {
90            bash_mode: BashMode::NormalMode,
91            allowed_commands: config.allowed_commands,
92        },
93        FileEditMode { allowed_globs: config.allowed_globs.clone() },
94        WriteIfEmptyMode { allowed_globs: config.allowed_globs },
95    )
96}
97
98fn mode_to_state(
99    mode: Modes,
100    config: Option<&CodeWriterConfig>,
101    workspace_root: &Path,
102) -> Result<(BashCommandMode, FileEditMode, WriteIfEmptyMode)> {
103    match mode {
104        Modes::Wcgw => Ok((
105            BashCommandMode {
106                bash_mode: BashMode::NormalMode,
107                allowed_commands: AllowedCommands::All("all".to_string()),
108            },
109            FileEditMode { allowed_globs: AllowedGlobs::All("all".to_string()) },
110            WriteIfEmptyMode { allowed_globs: AllowedGlobs::All("all".to_string()) },
111        )),
112        Modes::Architect => Ok((
113            BashCommandMode {
114                bash_mode: BashMode::RestrictedMode,
115                allowed_commands: AllowedCommands::List(
116                    crate::utils::bash_parser::architect_allowed_commands(),
117                ),
118            },
119            FileEditMode { allowed_globs: AllowedGlobs::List(vec![]) },
120            WriteIfEmptyMode { allowed_globs: AllowedGlobs::List(vec![]) },
121        )),
122        Modes::CodeWriter => {
123            let config = config.ok_or_else(|| {
124                WinxError::ArgumentParseError(
125                    "code_writer_config is required when mode_name is code_writer.".to_string(),
126                )
127            })?;
128            Ok(code_writer_state(config, workspace_root))
129        }
130    }
131}
132
133fn active_code_writer_config(state: &BashState) -> Option<CodeWriterConfig> {
134    (state.mode == Modes::CodeWriter).then(|| CodeWriterConfig {
135        allowed_globs: state.file_edit_mode.allowed_globs.clone(),
136        allowed_commands: state.bash_command_mode.allowed_commands.clone(),
137    })
138}
139
140fn active_code_writer_policy(state: &BashState) -> (Option<&'static str>, Vec<String>) {
141    if state.mode != Modes::CodeWriter {
142        return (None, Vec::new());
143    }
144
145    match &state.bash_command_mode.allowed_commands {
146        AllowedCommands::All(value) if value == "all" => (Some("unrestricted"), Vec::new()),
147        AllowedCommands::All(_) => (Some("restricted"), Vec::new()),
148        AllowedCommands::List(commands) => {
149            let bypass = crate::utils::bash_parser::detect_allowlist_bypass(commands);
150            let strength = if bypass.is_empty() { "restricted" } else { "weak" };
151            (Some(strength), bypass)
152        }
153    }
154}
155
156fn warn_code_writer_policy_once(state: &BashState, bypass: &[String]) {
157    if bypass.is_empty() {
158        return;
159    }
160    let AllowedCommands::List(commands) = &state.bash_command_mode.allowed_commands else {
161        return;
162    };
163
164    let cache = WARNED_POLICY_CONFIGURATIONS
165        .get_or_init(|| StdMutex::new(VecDeque::with_capacity(POLICY_WARNING_CACHE_CAPACITY)));
166    let mut cache = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
167    if cache.iter().any(|seen| seen == commands) {
168        return;
169    }
170    if cache.len() == POLICY_WARNING_CACHE_CAPACITY {
171        cache.pop_front();
172    }
173    cache.push_back(commands.clone());
174    drop(cache);
175
176    warn!(
177        commands = ?bypass,
178        "code_writer allowlist includes shell-spawning commands; the command allowlist is \
179         effectively bypassable and does not sandbox the agent"
180    );
181}
182
183fn append_code_writer_policy_warning(response: &mut String, bypass: &[String]) {
184    if bypass.is_empty() {
185        return;
186    }
187    let _ = writeln!(
188        response,
189        "\n⚠️  SECURITY: code_writer allowlist includes shell/eval commands ({}). \
190         They execute arbitrary code from string arguments (e.g. `bash -c …`, \
191         `find -exec …`), so the command allowlist is effectively bypassable and \
192         does NOT sandbox the agent. Drop them if you intended a hard restriction.",
193        bypass.join(", ")
194    );
195}
196
197fn read_initial_files_simple(files: &[String], workspace: &std::path::Path) -> String {
198    let mut output = String::new();
199    for file_path in files {
200        let expanded = expand_user(file_path);
201        let path = if std::path::Path::new(&expanded).is_absolute() {
202            PathBuf::from(&expanded)
203        } else {
204            workspace.join(&expanded)
205        };
206
207        if let Ok(validated) = validate_path_in_workspace(&path, workspace) {
208            if validated.exists() && validated.is_file() {
209                if let Ok(content) = read_file_to_string(&validated, 10_000_000) {
210                    let _ = write!(output, "\n{file_path}\n```\n{content}\n```\n");
211                }
212            }
213        }
214    }
215    output
216}
217
218fn prepare_workspace(
219    initialize: &Initialize,
220    thread_id: &str,
221    response: &mut String,
222) -> Result<PathBuf> {
223    let workspace_path_str = expand_user(&initialize.any_workspace_path);
224    if workspace_path_str.is_empty() {
225        // wcgw parity: no path given → spin up a scratch playground instead of
226        // forcing the agent to always supply a workspace.
227        let playground = create_playground_dir(thread_id)?;
228        let _ = writeln!(
229            response,
230            "No workspace path provided; created a playground at {}",
231            playground.display()
232        );
233        return Ok(playground);
234    }
235
236    let workspace_path = PathBuf::from(&workspace_path_str);
237    let mut folder_to_start = workspace_path.clone();
238
239    if workspace_path.exists() {
240        if workspace_path.is_file() {
241            folder_to_start = workspace_path.parent().unwrap_or(&workspace_path).to_path_buf();
242            let _ =
243                writeln!(response, "Using parent directory of file: {}", folder_to_start.display());
244        } else if workspace_path.is_dir() {
245            let _ = writeln!(response, "Using workspace directory: {}", folder_to_start.display());
246        }
247    } else if workspace_path.is_absolute() {
248        ensure_directory_exists(&workspace_path).map_err(|e| {
249            WinxError::WorkspacePathError(format!("Failed to create workspace: {e}"))
250        })?;
251        let _ = writeln!(response, "Created workspace directory: {}", workspace_path.display());
252    }
253
254    // Canonicalize so downstream comparisons (workspace checks, glob prefixes) match
255    // paths that were canonicalized via fs::canonicalize — important on macOS where
256    // /var, /tmp etc. are symlinks to /private/var, /private/tmp.
257    if folder_to_start.exists() {
258        if let Ok(canonical) = folder_to_start.canonicalize() {
259            folder_to_start = canonical;
260        }
261    }
262
263    Ok(folder_to_start)
264}
265
266fn initialize_thread_id(initialize: &Initialize) -> String {
267    let thread_id = normalize_thread_id(&initialize.thread_id);
268    if thread_id.is_empty() {
269        generate_thread_id()
270    } else {
271        thread_id
272    }
273}
274
275fn validate_thread_id(initialize: &Initialize) -> Result<()> {
276    if initialize.init_type != InitializeType::FirstCall
277        && normalize_thread_id(&initialize.thread_id).is_empty()
278    {
279        return Err(WinxError::ThreadIdMismatch(
280            "Thread id should be provided if type != 'first_call', including when resetting."
281                .to_string(),
282        ));
283    }
284
285    Ok(())
286}
287
288fn load_guidelines(workspace: &Path) -> String {
289    let mut output = String::new();
290    let mut candidates = Vec::new();
291    if let Some(home) = home::home_dir() {
292        candidates.push(home.join(".winx").join("AGENTS.md"));
293        candidates.push(home.join(".winx").join("CLAUDE.md"));
294        candidates.push(home.join(".wcgw").join("AGENTS.md"));
295        candidates.push(home.join(".wcgw").join("CLAUDE.md"));
296    }
297    candidates.push(workspace.join("AGENTS.md"));
298    candidates.push(workspace.join("CLAUDE.md"));
299
300    for path in candidates {
301        if path.is_file() {
302            if let Ok(content) = fs::read_to_string(&path) {
303                let _ = writeln!(output, "\n## {}\n{}", path.display(), content);
304            }
305        }
306    }
307    output
308}
309
310pub async fn handle_tool_call(
311    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
312    initialize: Initialize,
313) -> Result<String> {
314    handle_tool_call_with_runtime(&EmbeddedShellRuntime, bash_state_arc, initialize).await
315}
316
317pub async fn handle_tool_call_with_runtime(
318    runtime: &dyn ShellRuntime,
319    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
320    initialize: Initialize,
321) -> Result<String> {
322    Ok(handle_tool_call_with_runtime_detailed(runtime, bash_state_arc, initialize).await?.text)
323}
324
325#[instrument(level = "info", skip(runtime, bash_state_arc, initialize))]
326#[allow(clippy::too_many_lines)]
327pub(crate) async fn handle_tool_call_with_runtime_detailed(
328    runtime: &dyn ShellRuntime,
329    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
330    initialize: Initialize,
331) -> Result<InitializeOutcome> {
332    let mut response = String::new();
333    let mut context_bytes = 0;
334    let mut guidelines_bytes = 0;
335    let initial_files_count = initialize.initial_files_to_read.len();
336
337    info!("Initialize called for workspace: {}", initialize.any_workspace_path);
338
339    validate_thread_id(&initialize)?;
340    let thread_id = initialize_thread_id(&initialize);
341    let mut bash_state_guard = bash_state_arc.lock().await;
342    if initialize.init_type != InitializeType::FirstCall && bash_state_guard.is_none() {
343        return Err(WinxError::BashStateNotInitialized);
344    }
345    let local_attach = initialize.init_type == InitializeType::FirstCall
346        && bash_state_guard
347            .as_ref()
348            .is_some_and(|state| state.initialized && state.current_thread_id == thread_id);
349    let folder_to_start = if local_attach {
350        bash_state_guard
351            .as_ref()
352            .map_or_else(|| PathBuf::from("/tmp"), |state| state.workspace_root.clone())
353    } else {
354        prepare_workspace(&initialize, &thread_id, &mut response)?
355    };
356    let requested_mode = Modes::from(&initialize.mode_name);
357    let preserve_active_mode =
358        local_attach || initialize.init_type == InitializeType::UserAskedChangeWorkspace;
359    let (mode, bash_command_mode, file_edit_mode, write_if_empty_mode) = if preserve_active_mode {
360        let Some(state) = bash_state_guard.as_ref() else {
361            return Err(WinxError::BashStateNotInitialized);
362        };
363        (
364            state.mode,
365            state.bash_command_mode.clone(),
366            state.file_edit_mode.clone(),
367            state.write_if_empty_mode.clone(),
368        )
369    } else {
370        let (bash_command_mode, file_edit_mode, write_if_empty_mode) = mode_to_state(
371            requested_mode,
372            initialize.code_writer_config.as_ref(),
373            &folder_to_start,
374        )?;
375        (requested_mode, bash_command_mode, file_edit_mode, write_if_empty_mode)
376    };
377
378    let transition;
379    let mut compact_response = false;
380
381    match initialize.init_type {
382        InitializeType::FirstCall => {
383            let configured = if local_attach {
384                let Some(state) = bash_state_guard.as_mut() else {
385                    return Err(WinxError::BashStateNotInitialized);
386                };
387                // Refresh guardian activity without resetting the PTY. Reusing the
388                // current snapshot as a mode transition is also compatible with
389                // protocol-1.2 guardians that predate attach-or-create.
390                let mut configured =
391                    runtime.configure_session(state, ShellSessionTransition::ModeChange).await?;
392                configured.attached_existing = true;
393                configured
394            } else {
395                let mut new_bash_state = BashState::new();
396                new_bash_state.current_thread_id.clone_from(&thread_id);
397                new_bash_state.mode = mode;
398                new_bash_state.bash_command_mode = bash_command_mode;
399                new_bash_state.file_edit_mode = file_edit_mode;
400                new_bash_state.write_if_empty_mode = write_if_empty_mode;
401                new_bash_state.initialized = true;
402
403                let resumed_context = if initialize.task_id_to_resume.is_empty() {
404                    None
405                } else {
406                    crate::tools::context_save::load_saved_context(&initialize.task_id_to_resume)?
407                };
408
409                if let Some((memory_data, snapshot)) = &resumed_context {
410                    if let Some(snapshot) = snapshot {
411                        new_bash_state.apply_snapshot(snapshot);
412                        new_bash_state.current_thread_id.clone_from(&thread_id);
413                    }
414                    let _ = writeln!(
415                        response,
416                        "\n# Resumed task {}\nFollowing is the retrieved task context:\n{}",
417                        initialize.task_id_to_resume, memory_data
418                    );
419                }
420
421                // A bash snapshot already carries cwd/workspace. Without one, prefer
422                // the project root recorded in the resumed memory (so the agent lands
423                // back in the right repo), then fall back to the provided folder.
424                if resumed_context.as_ref().and_then(|(_, snapshot)| snapshot.as_ref()).is_none() {
425                    let resumed_root = resumed_context
426                        .as_ref()
427                        .and_then(|(memory, _)| {
428                            crate::tools::context_save::extract_project_root(memory)
429                        })
430                        .filter(|root| root.exists());
431                    let target = resumed_root.as_deref().unwrap_or(folder_to_start.as_path());
432                    if target.exists() {
433                        new_bash_state.update_cwd(target)?;
434                        new_bash_state.update_workspace_root(target)?;
435                    }
436                }
437                let configured = runtime
438                    .configure_session(&mut new_bash_state, ShellSessionTransition::FirstCall)
439                    .await?;
440                *bash_state_guard = Some(new_bash_state);
441                configured
442            };
443
444            transition = if configured.attached_existing {
445                InitializeTransition::AttachedExisting
446            } else {
447                InitializeTransition::Created
448            };
449            compact_response = configured.attached_existing
450                && initialize.initial_files_to_read.is_empty()
451                && initialize.task_id_to_resume.is_empty();
452
453            if compact_response {
454                response.clear();
455                let state = bash_state_guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
456                let _ = writeln!(
457                    response,
458                    "Attached to the existing durable Winx session; PTY, cwd, mode, and prior \
459                     context were preserved.\nUse thread_id={thread_id} for all winx tool calls.\n\
460                     cwd={}\nmode={}",
461                    state.cwd.display(),
462                    state.mode
463                );
464                if let Some(attach_hint) = configured.attach_hint.as_deref() {
465                    let _ = writeln!(response, "Attach terminal: {attach_hint}");
466                }
467                response.push_str(
468                    "Context and instructions are unchanged. Continue with the existing thread; \
469                     use an explicit mode/workspace change or reset only when intended.\n",
470                );
471            } else {
472                if configured.attached_existing {
473                    response.push_str(
474                        "\nAttached to the existing durable session for this principal/workspace; \
475                         the guardian-owned PTY and cwd were preserved. Use `reset_shell` or an \
476                         explicit workspace/mode change when replacement is intended.\n",
477                    );
478                }
479
480                let _ = write!(
481                    response,
482                    "\n# Environment\nSystem: {}\nMachine: {}\nInitialized in directory: {}\n",
483                    std::env::consts::OS,
484                    std::env::consts::ARCH,
485                    bash_state_guard
486                        .as_ref()
487                        .map_or(folder_to_start.as_path(), |state| state.cwd.as_path())
488                        .display()
489                );
490
491                if command_exists("rg") {
492                    let _ = writeln!(
493                        response,
494                        "\n# Available commands\nUse ripgrep `rg` instead of `grep`/`find -name` — \
495                         it's much faster and respects .gitignore."
496                    );
497                }
498
499                let _ = writeln!(response, "\nUse thread_id={thread_id} for all winx tool calls.");
500                if let Some(attach_hint) = configured.attach_hint.as_deref() {
501                    let _ = writeln!(response, "\nAttach terminal: {attach_hint}");
502                }
503
504                // Explain the actual active policy. A daemon reattach can restore a
505                // different mode than the model redundantly supplied.
506                let active_config = bash_state_guard.as_ref().and_then(active_code_writer_config);
507                let active_mode = bash_state_guard.as_ref().map_or(mode, |state| state.mode);
508                let _ = writeln!(
509                    response,
510                    "\n{}",
511                    crate::utils::mode_prompts::mode_prompt(active_mode, active_config.as_ref())
512                );
513
514                let active_workspace = bash_state_guard
515                    .as_ref()
516                    .map_or(folder_to_start.as_path(), |state| state.workspace_root.as_path());
517
518                let guidelines = load_guidelines(active_workspace);
519                guidelines_bytes = guidelines.len();
520                if !guidelines.is_empty() {
521                    let _ = writeln!(response, "\n# Agent guidelines\n{guidelines}");
522                }
523
524                if let Ok((repo_context, _)) =
525                    crate::utils::repo::get_repo_context(active_workspace)
526                {
527                    context_bytes = repo_context.len();
528                    let _ = writeln!(response, "\n# Workspace structure\n{repo_context}");
529                }
530
531                if !initialize.initial_files_to_read.is_empty() {
532                    let content = read_initial_files_simple(
533                        &initialize.initial_files_to_read,
534                        active_workspace,
535                    );
536                    if !content.is_empty() {
537                        let _ = writeln!(response, "\n# Requested files\n{content}");
538                    }
539                }
540            }
541        }
542        InitializeType::UserAskedModeChange => {
543            transition = InitializeTransition::ModeChanged;
544            if let Some(state) = bash_state_guard.as_mut() {
545                state.mode = mode;
546                state.bash_command_mode = bash_command_mode;
547                state.file_edit_mode = file_edit_mode;
548                state.write_if_empty_mode = write_if_empty_mode;
549                runtime.configure_session(state, ShellSessionTransition::ModeChange).await?;
550                let _ = writeln!(response, "Changed mode to: {mode:?}");
551                let active_config = active_code_writer_config(state);
552                let _ = writeln!(
553                    response,
554                    "\n{}",
555                    crate::utils::mode_prompts::mode_prompt(mode, active_config.as_ref())
556                );
557            } else {
558                return Err(WinxError::BashStateNotInitialized);
559            }
560        }
561        InitializeType::ResetShell => {
562            transition = InitializeTransition::ShellReset;
563            if let Some(state) = bash_state_guard.as_mut() {
564                state.mode = mode;
565                state.bash_command_mode = bash_command_mode;
566                state.file_edit_mode = file_edit_mode;
567                state.write_if_empty_mode = write_if_empty_mode;
568                runtime.configure_session(state, ShellSessionTransition::Reset).await?;
569                response.push_str("Reset shell (new PTY created)\n");
570            } else {
571                return Err(WinxError::BashStateNotInitialized);
572            }
573        }
574        InitializeType::UserAskedChangeWorkspace => {
575            transition = InitializeTransition::WorkspaceChanged;
576            if let Some(state) = bash_state_guard.as_mut() {
577                if folder_to_start.exists() {
578                    state.update_cwd(&folder_to_start)?;
579                    state.update_workspace_root(&folder_to_start)?;
580                    runtime
581                        .configure_session(state, ShellSessionTransition::WorkspaceChange)
582                        .await?;
583                    let _ =
584                        writeln!(response, "Changed workspace to: {}", folder_to_start.display());
585                } else {
586                    let _ = writeln!(
587                        response,
588                        "Warning: Workspace path {} does not exist",
589                        folder_to_start.display()
590                    );
591                }
592            } else {
593                return Err(WinxError::BashStateNotInitialized);
594            }
595        }
596    }
597
598    let (code_writer_policy_strength, bypass) =
599        bash_state_guard.as_ref().map_or((None, Vec::new()), active_code_writer_policy);
600    if let Some(state) = bash_state_guard.as_ref() {
601        warn_code_writer_policy_once(state, &bypass);
602    }
603    if compact_response {
604        if let Some(strength) = code_writer_policy_strength {
605            let _ = writeln!(
606                response,
607                "code_writer_policy={strength} shell_spawners_present={}",
608                !bypass.is_empty()
609            );
610        }
611    } else {
612        append_code_writer_policy_warning(&mut response, &bypass);
613        crate::utils::orchestration::append_initialize_instructions(&mut response);
614    }
615
616    Ok(InitializeOutcome {
617        text: response,
618        transition,
619        context_bytes,
620        guidelines_bytes,
621        initial_files_count,
622        compact_response,
623        code_writer_policy_strength,
624        shell_spawners_present: !bypass.is_empty(),
625    })
626}