1use 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 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 pub(crate) session_status: Option<crate::runtime::ShellSessionStatus>,
79}
80
81fn 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
94fn 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 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 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 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 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 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 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
754async 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}