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 std::time::Instant;
9use tokio::sync::Mutex;
10use tracing::{info, instrument, warn};
11
12use crate::errors::{Result, WinxError};
13use crate::runtime::{EmbeddedShellRuntime, ShellRuntime, ShellSessionTransition};
14use crate::state::bash_state::{generate_thread_id, BashState};
15use crate::types::{
16    normalize_thread_id, AllowedCommands, AllowedGlobs, BashCommandMode, BashMode,
17    CodeWriterConfig, FileEditMode, Initialize, InitializeType, Modes, WriteIfEmptyMode,
18};
19use crate::utils::mmap::read_file_to_string;
20use crate::utils::path::{ensure_directory_exists, expand_user, validate_path_in_workspace};
21
22const POLICY_WARNING_CACHE_CAPACITY: usize = 128;
23static WARNED_POLICY_CONFIGURATIONS: OnceLock<StdMutex<VecDeque<Vec<String>>>> = OnceLock::new();
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub(crate) enum InitializeTransition {
27    Created,
28    AttachedExisting,
29    ModeChanged,
30    ShellReset,
31    ResetSkippedHealthy,
32    WorkspaceChanged,
33    /// Read-only `status` probe; nothing changed.
34    Status,
35}
36
37impl InitializeTransition {
38    pub(crate) const fn as_str(self) -> &'static str {
39        match self {
40            Self::Created => "created",
41            Self::AttachedExisting => "attached_existing",
42            Self::ModeChanged => "mode_changed",
43            Self::ShellReset => "shell_reset",
44            Self::ResetSkippedHealthy => "reset_skipped_healthy",
45            Self::WorkspaceChanged => "workspace_changed",
46            Self::Status => "status",
47        }
48    }
49
50    pub(crate) const fn reused(self) -> bool {
51        matches!(self, Self::AttachedExisting | Self::ResetSkippedHealthy)
52    }
53}
54
55#[derive(Debug)]
56pub(crate) struct InitializeOutcome {
57    pub(crate) text: String,
58    pub(crate) transition: InitializeTransition,
59    pub(crate) recovered_missing_session: bool,
60    pub(crate) context_bytes: usize,
61    pub(crate) guidelines_bytes: usize,
62    pub(crate) initial_files_count: usize,
63    pub(crate) compact_response: bool,
64    pub(crate) code_writer_policy_strength: Option<&'static str>,
65    pub(crate) shell_spawners_present: bool,
66    pub(crate) shell_reset_retry_after_seconds: Option<u64>,
67    pub(crate) temporary_artifact_dir: PathBuf,
68    pub(crate) temporary_artifact_ttl_seconds: u64,
69    pub(crate) temporary_artifact_max_bytes: u64,
70    pub(crate) temporary_artifact_max_session_bytes: u64,
71    pub(crate) temporary_artifact_max_file_bytes: u64,
72    pub(crate) temporary_artifact_max_files: usize,
73    pub(crate) temporary_artifact_stale_pruned_files: usize,
74    pub(crate) temporary_artifact_stale_pruned_bytes: u64,
75    pub(crate) temporary_code_map_max_calls: usize,
76    pub(crate) temporary_code_map_max_unique_files: usize,
77    /// Present only for `type = "status"` probes.
78    pub(crate) session_status: Option<crate::runtime::ShellSessionStatus>,
79}
80
81/// Create a unique scratch workspace under the system temp dir, used when the
82/// caller initializes without a workspace path.
83fn create_playground_dir(thread_id: &str) -> Result<PathBuf> {
84    #[cfg(unix)]
85    let owner = crate::os::unix::effective_uid().to_string();
86    #[cfg(not(unix))]
87    let owner = std::process::id().to_string();
88
89    let dir = std::env::temp_dir().join(format!("winx-playground-{owner}-{thread_id}"));
90    ensure_directory_exists(&dir)?;
91    Ok(dir)
92}
93
94/// Whether `cmd` is on PATH (best-effort, used only for advisory hints).
95fn command_exists(cmd: &str) -> bool {
96    crate::utils::executable::is_available(cmd)
97}
98
99fn code_writer_state(
100    config: &CodeWriterConfig,
101    workspace_root: &Path,
102) -> (BashCommandMode, FileEditMode, WriteIfEmptyMode) {
103    let mut config = config.clone();
104    // Forgive the common `["all"]` mistake before turning relative globs absolute.
105    config.allowed_globs.normalize();
106    config.allowed_commands.normalize();
107    config.update_relative_globs(&workspace_root.to_string_lossy());
108
109    (
110        BashCommandMode {
111            bash_mode: BashMode::NormalMode,
112            allowed_commands: config.allowed_commands,
113        },
114        FileEditMode { allowed_globs: config.allowed_globs.clone() },
115        WriteIfEmptyMode { allowed_globs: config.allowed_globs },
116    )
117}
118
119fn mode_to_state(
120    mode: Modes,
121    config: Option<&CodeWriterConfig>,
122    workspace_root: &Path,
123) -> Result<(BashCommandMode, FileEditMode, WriteIfEmptyMode)> {
124    match mode {
125        Modes::Wcgw => Ok((
126            BashCommandMode {
127                bash_mode: BashMode::NormalMode,
128                allowed_commands: AllowedCommands::All("all".to_string()),
129            },
130            FileEditMode { allowed_globs: AllowedGlobs::All("all".to_string()) },
131            WriteIfEmptyMode { allowed_globs: AllowedGlobs::All("all".to_string()) },
132        )),
133        Modes::Architect => Ok((
134            BashCommandMode {
135                bash_mode: BashMode::RestrictedMode,
136                allowed_commands: AllowedCommands::List(
137                    crate::utils::bash_parser::architect_allowed_commands(),
138                ),
139            },
140            FileEditMode { allowed_globs: AllowedGlobs::List(vec![]) },
141            WriteIfEmptyMode { allowed_globs: AllowedGlobs::List(vec![]) },
142        )),
143        Modes::CodeWriter => {
144            let config = config.ok_or_else(|| {
145                WinxError::ArgumentParseError(
146                    "code_writer_config is required when mode_name is code_writer.".to_string(),
147                )
148            })?;
149            Ok(code_writer_state(config, workspace_root))
150        }
151    }
152}
153
154fn active_code_writer_config(state: &BashState) -> Option<CodeWriterConfig> {
155    (state.mode == Modes::CodeWriter).then(|| CodeWriterConfig {
156        allowed_globs: state.file_edit_mode.allowed_globs.clone(),
157        allowed_commands: state.bash_command_mode.allowed_commands.clone(),
158    })
159}
160
161fn active_code_writer_policy(state: &BashState) -> (Option<&'static str>, Vec<String>) {
162    if state.mode != Modes::CodeWriter {
163        return (None, Vec::new());
164    }
165
166    match &state.bash_command_mode.allowed_commands {
167        AllowedCommands::All(value) if value == "all" => (Some("unrestricted"), Vec::new()),
168        AllowedCommands::All(_) => (Some("restricted"), Vec::new()),
169        AllowedCommands::List(commands) => {
170            let bypass = crate::utils::bash_parser::detect_allowlist_bypass(commands);
171            let strength = if bypass.is_empty() { "restricted" } else { "weak" };
172            (Some(strength), bypass)
173        }
174    }
175}
176
177fn warn_code_writer_policy_once(state: &BashState, bypass: &[String]) {
178    if bypass.is_empty() {
179        return;
180    }
181    let AllowedCommands::List(commands) = &state.bash_command_mode.allowed_commands else {
182        return;
183    };
184
185    let cache = WARNED_POLICY_CONFIGURATIONS
186        .get_or_init(|| StdMutex::new(VecDeque::with_capacity(POLICY_WARNING_CACHE_CAPACITY)));
187    let mut cache = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
188    if cache.iter().any(|seen| seen == commands) {
189        return;
190    }
191    if cache.len() == POLICY_WARNING_CACHE_CAPACITY {
192        cache.pop_front();
193    }
194    cache.push_back(commands.clone());
195    drop(cache);
196
197    warn!(
198        commands = ?bypass,
199        "code_writer allowlist includes shell-spawning commands; the command allowlist is \
200         effectively bypassable and does not sandbox the agent"
201    );
202}
203
204fn append_code_writer_policy_warning(response: &mut String, bypass: &[String]) {
205    if bypass.is_empty() {
206        return;
207    }
208    let _ = writeln!(
209        response,
210        "\n⚠️  SECURITY: code_writer allowlist includes shell/eval commands ({}). \
211         They execute arbitrary code from string arguments (e.g. `bash -c …`, \
212         `find -exec …`), so the command allowlist is effectively bypassable and \
213         does NOT sandbox the agent. Drop them if you intended a hard restriction.",
214        bypass.join(", ")
215    );
216}
217
218fn read_initial_files_simple(files: &[String], workspace: &std::path::Path) -> String {
219    let mut output = String::new();
220    for file_path in files {
221        let expanded = expand_user(file_path);
222        let path = if std::path::Path::new(&expanded).is_absolute() {
223            PathBuf::from(&expanded)
224        } else {
225            workspace.join(&expanded)
226        };
227
228        if let Ok(validated) = validate_path_in_workspace(&path, workspace) {
229            if validated.exists() && validated.is_file() {
230                if let Ok(content) = read_file_to_string(&validated, 10_000_000) {
231                    let _ = write!(output, "\n{file_path}\n```\n{content}\n```\n");
232                }
233            }
234        }
235    }
236    output
237}
238
239fn prepare_workspace(
240    initialize: &Initialize,
241    thread_id: &str,
242    response: &mut String,
243) -> Result<PathBuf> {
244    let workspace_path_str = expand_user(&initialize.any_workspace_path);
245    if workspace_path_str.is_empty() {
246        // wcgw parity: no path given → spin up a scratch playground instead of
247        // forcing the agent to always supply a workspace.
248        let playground = create_playground_dir(thread_id)?;
249        let _ = writeln!(
250            response,
251            "No workspace path provided; created a playground at {}",
252            playground.display()
253        );
254        return Ok(playground);
255    }
256
257    let workspace_path = PathBuf::from(&workspace_path_str);
258    let mut folder_to_start = workspace_path.clone();
259
260    if workspace_path.exists() {
261        if workspace_path.is_file() {
262            folder_to_start = workspace_path.parent().unwrap_or(&workspace_path).to_path_buf();
263            let _ =
264                writeln!(response, "Using parent directory of file: {}", folder_to_start.display());
265        } else if workspace_path.is_dir() {
266            let _ = writeln!(response, "Using workspace directory: {}", folder_to_start.display());
267        }
268    } else if workspace_path.is_absolute() {
269        ensure_directory_exists(&workspace_path).map_err(|e| {
270            WinxError::WorkspacePathError(format!("Failed to create workspace: {e}"))
271        })?;
272        let _ = writeln!(response, "Created workspace directory: {}", workspace_path.display());
273    }
274
275    // Canonicalize so downstream comparisons (workspace checks, glob prefixes) match
276    // paths that were canonicalized via fs::canonicalize — important on macOS where
277    // /var, /tmp etc. are symlinks to /private/var, /private/tmp.
278    if folder_to_start.exists() {
279        if let Ok(canonical) = folder_to_start.canonicalize().map(crate::utils::path::simplified) {
280            folder_to_start = canonical;
281        }
282    }
283
284    Ok(folder_to_start)
285}
286
287fn initialize_thread_id(initialize: &Initialize) -> String {
288    let thread_id = normalize_thread_id(&initialize.thread_id);
289    if thread_id.is_empty() {
290        generate_thread_id()
291    } else {
292        thread_id
293    }
294}
295
296fn validate_thread_id(initialize: &Initialize) -> Result<()> {
297    if initialize.init_type != InitializeType::FirstCall
298        && normalize_thread_id(&initialize.thread_id).is_empty()
299    {
300        return Err(WinxError::ThreadIdMismatch(
301            "Thread id should be provided if type != 'first_call', including when resetting."
302                .to_string(),
303        ));
304    }
305
306    Ok(())
307}
308
309fn load_guidelines(workspace: &Path) -> String {
310    let mut output = String::new();
311    let mut candidates = Vec::new();
312    if let Some(home) = home::home_dir() {
313        candidates.push(home.join(".winx").join("AGENTS.md"));
314        candidates.push(home.join(".winx").join("CLAUDE.md"));
315        candidates.push(home.join(".wcgw").join("AGENTS.md"));
316        candidates.push(home.join(".wcgw").join("CLAUDE.md"));
317    }
318    candidates.push(workspace.join("AGENTS.md"));
319    candidates.push(workspace.join("CLAUDE.md"));
320
321    for path in candidates {
322        if path.is_file() {
323            if let Ok(content) = fs::read_to_string(&path) {
324                let _ = writeln!(output, "\n## {}\n{}", path.display(), content);
325            }
326        }
327    }
328    output
329}
330
331pub async fn handle_tool_call(
332    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
333    initialize: Initialize,
334) -> Result<String> {
335    handle_tool_call_with_runtime(&EmbeddedShellRuntime, bash_state_arc, initialize).await
336}
337
338pub async fn handle_tool_call_with_runtime(
339    runtime: &dyn ShellRuntime,
340    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
341    initialize: Initialize,
342) -> Result<String> {
343    Ok(handle_tool_call_with_runtime_detailed(runtime, bash_state_arc, initialize).await?.text)
344}
345
346#[instrument(level = "info", skip(runtime, bash_state_arc, initialize))]
347#[allow(clippy::too_many_lines)]
348pub(crate) async fn handle_tool_call_with_runtime_detailed(
349    runtime: &dyn ShellRuntime,
350    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
351    mut initialize: Initialize,
352) -> Result<InitializeOutcome> {
353    let mut response = String::new();
354    let mut context_bytes = 0;
355    let mut guidelines_bytes = 0;
356    let initial_files_count = initialize.initial_files_to_read.len();
357
358    info!("Initialize called for workspace: {}", initialize.any_workspace_path);
359
360    validate_thread_id(&initialize)?;
361    let thread_id = initialize_thread_id(&initialize);
362    if initialize.init_type == InitializeType::Status {
363        return describe_session_status(runtime, bash_state_arc, &thread_id).await;
364    }
365    let mut bash_state_guard = bash_state_arc.lock().await;
366    let recovered_request = if bash_state_guard.is_none() {
367        match initialize.init_type {
368            InitializeType::UserAskedModeChange => Some("user_asked_mode_change"),
369            InitializeType::ResetShell => Some("reset_shell"),
370            InitializeType::FirstCall
371            | InitializeType::UserAskedChangeWorkspace
372            | InitializeType::Status => None,
373        }
374    } else {
375        None
376    };
377    if let Some(requested_transition) = recovered_request {
378        info!(requested_transition, "Initialize recovered a missing live session as first_call");
379        initialize.init_type = InitializeType::FirstCall;
380    }
381    if initialize.init_type != InitializeType::FirstCall && bash_state_guard.is_none() {
382        return Err(WinxError::BashStateNotInitialized);
383    }
384    let local_attach = initialize.init_type == InitializeType::FirstCall
385        && bash_state_guard
386            .as_ref()
387            .is_some_and(|state| state.initialized && state.current_thread_id == thread_id);
388    let folder_to_start = if local_attach {
389        bash_state_guard
390            .as_ref()
391            .map_or_else(|| PathBuf::from("/tmp"), |state| state.workspace_root.clone())
392    } else {
393        prepare_workspace(&initialize, &thread_id, &mut response)?
394    };
395    let requested_mode = Modes::from(&initialize.mode_name);
396    let preserve_active_mode =
397        local_attach || initialize.init_type == InitializeType::UserAskedChangeWorkspace;
398    let (mode, bash_command_mode, file_edit_mode, write_if_empty_mode) = if preserve_active_mode {
399        let Some(state) = bash_state_guard.as_ref() else {
400            return Err(WinxError::BashStateNotInitialized);
401        };
402        (
403            state.mode,
404            state.bash_command_mode.clone(),
405            state.file_edit_mode.clone(),
406            state.write_if_empty_mode.clone(),
407        )
408    } else {
409        let (bash_command_mode, file_edit_mode, write_if_empty_mode) = mode_to_state(
410            requested_mode,
411            initialize.code_writer_config.as_ref(),
412            &folder_to_start,
413        )?;
414        (requested_mode, bash_command_mode, file_edit_mode, write_if_empty_mode)
415    };
416
417    let transition;
418    let mut compact_response = false;
419    let mut shell_reset_retry_after_seconds = None;
420
421    match initialize.init_type {
422        InitializeType::Status => {
423            return Err(WinxError::ArgumentParseError(
424                "status probes are answered before any session transition".to_string(),
425            ));
426        }
427        InitializeType::FirstCall => {
428            let configured = if local_attach {
429                let Some(state) = bash_state_guard.as_mut() else {
430                    return Err(WinxError::BashStateNotInitialized);
431                };
432                // Refresh guardian activity without resetting the PTY or waiting
433                // behind an unrelated long-running shell action.
434                let mut configured = runtime.attach_session(state).await?;
435                configured.attached_existing = true;
436                configured
437            } else {
438                let mut new_bash_state = BashState::new();
439                new_bash_state.current_thread_id.clone_from(&thread_id);
440                new_bash_state.mode = mode;
441                new_bash_state.bash_command_mode = bash_command_mode;
442                new_bash_state.file_edit_mode = file_edit_mode;
443                new_bash_state.write_if_empty_mode = write_if_empty_mode;
444                new_bash_state.initialized = true;
445
446                let resumed_context = if initialize.task_id_to_resume.trim().is_empty() {
447                    None
448                } else {
449                    crate::tools::context_save::load_saved_context(&initialize.task_id_to_resume)?
450                };
451
452                if let Some((memory_data, snapshot)) = &resumed_context {
453                    if let Some(snapshot) = snapshot {
454                        new_bash_state.apply_snapshot(snapshot);
455                        new_bash_state.current_thread_id.clone_from(&thread_id);
456                    }
457                    let _ = writeln!(
458                        response,
459                        "\n# Resumed task {}\nFollowing is the retrieved task context:\n{}",
460                        initialize.task_id_to_resume, memory_data
461                    );
462                }
463
464                // A bash snapshot already carries cwd/workspace. Without one, prefer
465                // the project root recorded in the resumed memory (so the agent lands
466                // back in the right repo), then fall back to the provided folder.
467                if resumed_context.as_ref().and_then(|(_, snapshot)| snapshot.as_ref()).is_none() {
468                    let resumed_root = resumed_context
469                        .as_ref()
470                        .and_then(|(memory, _)| {
471                            crate::tools::context_save::extract_project_root(memory)
472                        })
473                        .filter(|root| root.exists());
474                    let target = resumed_root.as_deref().unwrap_or(folder_to_start.as_path());
475                    if target.exists() {
476                        new_bash_state.update_cwd(target)?;
477                        new_bash_state.update_workspace_root(target)?;
478                    }
479                }
480                let configured = runtime
481                    .configure_session(&mut new_bash_state, ShellSessionTransition::FirstCall)
482                    .await?;
483                *bash_state_guard = Some(new_bash_state);
484                configured
485            };
486
487            transition = if configured.attached_existing {
488                InitializeTransition::AttachedExisting
489            } else {
490                InitializeTransition::Created
491            };
492            compact_response = configured.attached_existing
493                && initialize.initial_files_to_read.is_empty()
494                && initialize.task_id_to_resume.is_empty();
495
496            if compact_response {
497                response.clear();
498                let state = bash_state_guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
499                let _ = writeln!(
500                    response,
501                    "Attached to the existing durable Winx session; PTY, cwd, mode, and prior \
502                     context were preserved.\ncwd={}\nmode={}",
503                    state.cwd.display(),
504                    state.mode
505                );
506                if let Some(attach_hint) = configured.attach_hint.as_deref() {
507                    let _ = writeln!(response, "Attach terminal: {attach_hint}");
508                }
509                response.push_str(
510                    "Context and instructions are unchanged. Continue with the existing thread; \
511                     use an explicit mode/workspace change or reset only when intended.\n",
512                );
513            } else {
514                if configured.attached_existing {
515                    response.push_str(
516                        "\nAttached to the existing durable session for this principal/workspace; \
517                         the guardian-owned PTY and cwd were preserved. Use `reset_shell` or an \
518                         explicit workspace/mode change when replacement is intended.\n",
519                    );
520                }
521
522                let _ = write!(
523                    response,
524                    "\n# Environment\nSystem: {}\nMachine: {}\nInitialized in directory: {}\n",
525                    std::env::consts::OS,
526                    std::env::consts::ARCH,
527                    bash_state_guard
528                        .as_ref()
529                        .map_or(folder_to_start.as_path(), |state| state.cwd.as_path())
530                        .display()
531                );
532
533                if command_exists("rg") {
534                    let _ = writeln!(
535                        response,
536                        "\n# Available commands\nUse ripgrep `rg` instead of `grep`/`find -name` — \
537                         it's much faster and respects .gitignore."
538                    );
539                }
540
541                if let Some(attach_hint) = configured.attach_hint.as_deref() {
542                    let _ = writeln!(response, "\nAttach terminal: {attach_hint}");
543                }
544
545                // Explain the actual active policy. A daemon reattach can restore a
546                // different mode than the model redundantly supplied.
547                let active_config = bash_state_guard.as_ref().and_then(active_code_writer_config);
548                let active_mode = bash_state_guard.as_ref().map_or(mode, |state| state.mode);
549                let _ = writeln!(
550                    response,
551                    "\n{}",
552                    crate::utils::mode_prompts::mode_prompt(active_mode, active_config.as_ref())
553                );
554
555                let active_workspace = bash_state_guard
556                    .as_ref()
557                    .map_or(folder_to_start.as_path(), |state| state.workspace_root.as_path());
558
559                let guidelines = load_guidelines(active_workspace);
560                guidelines_bytes = guidelines.len();
561                if !guidelines.is_empty() {
562                    let _ = writeln!(response, "\n# Agent guidelines\n{guidelines}");
563                }
564
565                if let Ok((repo_context, _)) =
566                    crate::utils::repo::get_repo_context(active_workspace)
567                {
568                    context_bytes = repo_context.len();
569                    let _ = writeln!(response, "\n# Workspace structure\n{repo_context}");
570                }
571
572                if !initialize.initial_files_to_read.is_empty() {
573                    let content = read_initial_files_simple(
574                        &initialize.initial_files_to_read,
575                        active_workspace,
576                    );
577                    if !content.is_empty() {
578                        let _ = writeln!(response, "\n# Requested files\n{content}");
579                    }
580                }
581            }
582            if let Some(requested_transition) = recovered_request {
583                let _ = writeln!(
584                    response,
585                    "\nRecovered missing live session: `{requested_transition}` arrived after \
586                     the adapter no longer had session state. Winx safely performed `first_call` \
587                     with the same thread and intended workspace; continue with the returned \
588                     thread_id/workspace_root pair."
589                );
590            }
591        }
592        InitializeType::UserAskedModeChange => {
593            transition = InitializeTransition::ModeChanged;
594            if let Some(state) = bash_state_guard.as_mut() {
595                state.mode = mode;
596                state.bash_command_mode = bash_command_mode;
597                state.file_edit_mode = file_edit_mode;
598                state.write_if_empty_mode = write_if_empty_mode;
599                runtime.configure_session(state, ShellSessionTransition::ModeChange).await?;
600                let _ = writeln!(response, "Changed mode to: {mode:?}");
601                let active_config = active_code_writer_config(state);
602                let _ = writeln!(
603                    response,
604                    "\n{}",
605                    crate::utils::mode_prompts::mode_prompt(mode, active_config.as_ref())
606                );
607            } else {
608                return Err(WinxError::BashStateNotInitialized);
609            }
610        }
611        InitializeType::ResetShell => {
612            if let Some(state) = bash_state_guard.as_mut() {
613                if let Some(retry_after) = state.shell_reset_retry_after(Instant::now()) {
614                    transition = InitializeTransition::ResetSkippedHealthy;
615                    compact_response = true;
616                    let retry_after_seconds = retry_after.as_secs().max(1);
617                    shell_reset_retry_after_seconds = Some(retry_after_seconds);
618                    let _ = writeln!(
619                        response,
620                        "Skipped redundant reset_shell: the PTY was replaced recently and no \
621                         shell-runtime failure has occurred since. The existing PTY, cwd, mode, \
622                         and context were preserved. Continue with BashCommand; retry reset_shell \
623                         only after a shell-runtime failure or in {retry_after_seconds} seconds."
624                    );
625                } else {
626                    state.mode = mode;
627                    state.bash_command_mode = bash_command_mode;
628                    state.file_edit_mode = file_edit_mode;
629                    state.write_if_empty_mode = write_if_empty_mode;
630                    if let Err(error) =
631                        runtime.configure_session(state, ShellSessionTransition::Reset).await
632                    {
633                        state.record_shell_runtime_failure();
634                        return Err(error);
635                    }
636                    state.record_shell_reset(Instant::now());
637                    transition = InitializeTransition::ShellReset;
638                    response.push_str("Reset shell (new PTY created)\n");
639                }
640            } else {
641                return Err(WinxError::BashStateNotInitialized);
642            }
643        }
644        InitializeType::UserAskedChangeWorkspace => {
645            transition = InitializeTransition::WorkspaceChanged;
646            if let Some(state) = bash_state_guard.as_mut() {
647                if folder_to_start.exists() {
648                    state.update_cwd(&folder_to_start)?;
649                    state.update_workspace_root(&folder_to_start)?;
650                    runtime
651                        .configure_session(state, ShellSessionTransition::WorkspaceChange)
652                        .await?;
653                    let _ =
654                        writeln!(response, "Changed workspace to: {}", folder_to_start.display());
655                } else {
656                    let _ = writeln!(
657                        response,
658                        "Warning: Workspace path {} does not exist",
659                        folder_to_start.display()
660                    );
661                }
662            } else {
663                return Err(WinxError::BashStateNotInitialized);
664            }
665        }
666    }
667
668    let state = bash_state_guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
669    let temporary_artifact =
670        crate::utils::agent_temp::prepare_session(&state.workspace_root, &state.current_thread_id);
671    let temporary_artifact_instruction = if compact_response {
672        format!(
673            "Use temporary_artifact_dir={} for a small stable set of session-local derived \
674             helpers; BashCommand exports the same path as WINX_TEMP_DIR. Reuse helpers instead \
675             of creating CodeMap carriers; helper maps accept one existing file and are limited \
676             to {} unique files / {} calls. Near its quota Winx reclaims only helpers inactive for \
677             the advertised TTL; if it remains over budget, explicitly remove obsolete helpers \
678             before running ordinary commands.",
679            temporary_artifact.directory.display(),
680            crate::utils::agent_temp::MAX_DERIVED_CODE_MAP_UNIQUE_FILES,
681            crate::utils::agent_temp::MAX_DERIVED_CODE_MAP_CALLS,
682        )
683    } else {
684        format!(
685            "Use temporary_artifact_dir={} for session-local derived helpers only; keep names \
686             short, reuse one stable file per purpose, preserve source-path/line provenance, and \
687             treat helpers as non-canonical. Never create a carrier solely for CodeMap. The \
688             directory is created on demand, limited to {} files / {} bytes for this session, and \
689             expired after {} seconds of inactivity. Every BashCommand PTY exports the same path \
690             as WINX_TEMP_DIR; shell-generated helpers must stay beneath it. Winx audits actual \
691             usage after Bash. At a high-water mark it reclaims only helpers inactive for the full \
692             TTL, never fresh artifacts; if the hard limit remains exceeded, explicitly inspect and \
693             remove obsolete helpers before ordinary commands can continue. \
694             Helper CodeMap accepts one existing file and is limited to {} unique files / {} calls \
695             in this live session.",
696            temporary_artifact.directory.display(),
697            temporary_artifact.max_session_files,
698            temporary_artifact.max_session_bytes,
699            temporary_artifact.ttl_seconds,
700            crate::utils::agent_temp::MAX_DERIVED_CODE_MAP_UNIQUE_FILES,
701            crate::utils::agent_temp::MAX_DERIVED_CODE_MAP_CALLS,
702        )
703    };
704    let _ = writeln!(
705        response,
706        "\nUse thread_id={thread_id} for all winx tool calls.\nUse workspace_root={} for all winx tool calls.\n{temporary_artifact_instruction}\nBefore every reuse, confirm workspace_root still matches the user's current project. Keep this exact pair together. workspace_root identifies this project session; it does not restrict target paths allowed by policy.",
707        state.workspace_root.display(),
708    );
709
710    let (code_writer_policy_strength, bypass) =
711        bash_state_guard.as_ref().map_or((None, Vec::new()), active_code_writer_policy);
712    if let Some(state) = bash_state_guard.as_ref() {
713        warn_code_writer_policy_once(state, &bypass);
714    }
715    if compact_response {
716        if let Some(strength) = code_writer_policy_strength {
717            let _ = writeln!(
718                response,
719                "code_writer_policy={strength} shell_spawners_present={}",
720                !bypass.is_empty()
721            );
722        }
723    } else {
724        append_code_writer_policy_warning(&mut response, &bypass);
725        crate::utils::orchestration::append_initialize_instructions(&mut response);
726    }
727
728    Ok(InitializeOutcome {
729        text: response,
730        transition,
731        recovered_missing_session: recovered_request.is_some(),
732        context_bytes,
733        guidelines_bytes,
734        initial_files_count,
735        compact_response,
736        code_writer_policy_strength,
737        shell_spawners_present: !bypass.is_empty(),
738        shell_reset_retry_after_seconds,
739        temporary_artifact_dir: temporary_artifact.directory,
740        temporary_artifact_ttl_seconds: temporary_artifact.ttl_seconds,
741        temporary_artifact_max_bytes: temporary_artifact.max_total_bytes,
742        temporary_artifact_max_session_bytes: temporary_artifact.max_session_bytes,
743        temporary_artifact_max_file_bytes: temporary_artifact.max_file_bytes,
744        temporary_artifact_max_files: temporary_artifact.max_session_files,
745        temporary_artifact_stale_pruned_files: temporary_artifact.stale_pruned_files,
746        temporary_artifact_stale_pruned_bytes: temporary_artifact.stale_pruned_bytes,
747        temporary_code_map_max_calls: crate::utils::agent_temp::MAX_DERIVED_CODE_MAP_CALLS,
748        temporary_code_map_max_unique_files:
749            crate::utils::agent_temp::MAX_DERIVED_CODE_MAP_UNIQUE_FILES,
750        session_status: None,
751    })
752}
753
754/// `Initialize(type = "status")`: describe the live session for `thread_id`
755/// without creating, resetting or reconfiguring anything.
756async fn describe_session_status(
757    runtime: &dyn ShellRuntime,
758    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
759    thread_id: &str,
760) -> Result<InitializeOutcome> {
761    let (workspace_root, cwd, mode, bound_thread_id) = {
762        let guard = bash_state_arc.lock().await;
763        let state = guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
764        if !state.initialized {
765            return Err(WinxError::BashStateNotInitialized);
766        }
767        if state.current_thread_id != thread_id {
768            return Err(WinxError::ThreadIdMismatch(format!(
769                "thread_id {thread_id} is not the session bound here ({})",
770                state.current_thread_id
771            )));
772        }
773        (
774            state.workspace_root.clone(),
775            state.cwd.clone(),
776            state.mode,
777            state.current_thread_id.clone(),
778        )
779    };
780    let status = runtime.describe_session(bash_state_arc).await?;
781    let temporary_artifact =
782        crate::utils::agent_temp::session_info(&workspace_root, &bound_thread_id);
783    let mut response = String::new();
784    let _ = writeln!(response, "# Session status");
785    let _ = writeln!(response, "thread_id={bound_thread_id}");
786    let _ = writeln!(response, "workspace_root={}", workspace_root.display());
787    let _ = writeln!(response, "cwd={}", cwd.display());
788    let _ = writeln!(response, "mode={mode}");
789    let _ = writeln!(response, "runtime={}", status.runtime);
790    if let Some(pid) = status.guardian_pid {
791        let _ = writeln!(
792            response,
793            "guardian_pid={pid} guardian_epoch={} guardian_build={}",
794            status.guardian_epoch.as_deref().unwrap_or("?"),
795            status.guardian_build.as_deref().unwrap_or("?")
796        );
797    }
798    let _ = writeln!(response, "command_running={}", status.command_running);
799    if !status.background_command_ids.is_empty() {
800        let _ = writeln!(response, "background_command_ids={:?}", status.background_command_ids);
801    }
802    let _ = writeln!(
803        response,
804        "next: {}",
805        if status.command_running {
806            "poll BashCommand status_check for the running command"
807        } else {
808            "keep using this thread_id/workspace_root pair; nothing to re-initialize"
809        }
810    );
811    Ok(InitializeOutcome {
812        text: response,
813        transition: InitializeTransition::Status,
814        recovered_missing_session: false,
815        context_bytes: 0,
816        guidelines_bytes: 0,
817        initial_files_count: 0,
818        compact_response: true,
819        code_writer_policy_strength: None,
820        shell_spawners_present: false,
821        shell_reset_retry_after_seconds: None,
822        temporary_artifact_dir: temporary_artifact.directory,
823        temporary_artifact_ttl_seconds: temporary_artifact.ttl_seconds,
824        temporary_artifact_max_bytes: temporary_artifact.max_total_bytes,
825        temporary_artifact_max_session_bytes: temporary_artifact.max_session_bytes,
826        temporary_artifact_max_file_bytes: temporary_artifact.max_file_bytes,
827        temporary_artifact_max_files: temporary_artifact.max_session_files,
828        temporary_artifact_stale_pruned_files: temporary_artifact.stale_pruned_files,
829        temporary_artifact_stale_pruned_bytes: temporary_artifact.stale_pruned_bytes,
830        temporary_code_map_max_calls: crate::utils::agent_temp::MAX_DERIVED_CODE_MAP_CALLS,
831        temporary_code_map_max_unique_files:
832            crate::utils::agent_temp::MAX_DERIVED_CODE_MAP_UNIQUE_FILES,
833        session_status: Some(status),
834    })
835}