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 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 pub(crate) temporary_artifact_dir: PathBuf,
56 pub(crate) temporary_artifact_ttl_seconds: u64,
57 pub(crate) temporary_artifact_max_bytes: u64,
58 pub(crate) temporary_artifact_max_file_bytes: u64,
59}
60
61fn create_playground_dir(thread_id: &str) -> Result<PathBuf> {
64 #[cfg(unix)]
65 let owner = crate::os::unix::effective_uid().to_string();
66 #[cfg(not(unix))]
67 let owner = std::process::id().to_string();
68
69 let dir = std::env::temp_dir().join(format!("winx-playground-{owner}-{thread_id}"));
70 ensure_directory_exists(&dir)?;
71 Ok(dir)
72}
73
74fn command_exists(cmd: &str) -> bool {
76 std::process::Command::new("sh")
77 .args(["-c", &format!("command -v {cmd}")])
78 .output()
79 .is_ok_and(|o| o.status.success())
80}
81
82fn code_writer_state(
83 config: &CodeWriterConfig,
84 workspace_root: &Path,
85) -> (BashCommandMode, FileEditMode, WriteIfEmptyMode) {
86 let mut config = config.clone();
87 config.allowed_globs.normalize();
89 config.allowed_commands.normalize();
90 config.update_relative_globs(&workspace_root.to_string_lossy());
91
92 (
93 BashCommandMode {
94 bash_mode: BashMode::NormalMode,
95 allowed_commands: config.allowed_commands,
96 },
97 FileEditMode { allowed_globs: config.allowed_globs.clone() },
98 WriteIfEmptyMode { allowed_globs: config.allowed_globs },
99 )
100}
101
102fn mode_to_state(
103 mode: Modes,
104 config: Option<&CodeWriterConfig>,
105 workspace_root: &Path,
106) -> Result<(BashCommandMode, FileEditMode, WriteIfEmptyMode)> {
107 match mode {
108 Modes::Wcgw => Ok((
109 BashCommandMode {
110 bash_mode: BashMode::NormalMode,
111 allowed_commands: AllowedCommands::All("all".to_string()),
112 },
113 FileEditMode { allowed_globs: AllowedGlobs::All("all".to_string()) },
114 WriteIfEmptyMode { allowed_globs: AllowedGlobs::All("all".to_string()) },
115 )),
116 Modes::Architect => Ok((
117 BashCommandMode {
118 bash_mode: BashMode::RestrictedMode,
119 allowed_commands: AllowedCommands::List(
120 crate::utils::bash_parser::architect_allowed_commands(),
121 ),
122 },
123 FileEditMode { allowed_globs: AllowedGlobs::List(vec![]) },
124 WriteIfEmptyMode { allowed_globs: AllowedGlobs::List(vec![]) },
125 )),
126 Modes::CodeWriter => {
127 let config = config.ok_or_else(|| {
128 WinxError::ArgumentParseError(
129 "code_writer_config is required when mode_name is code_writer.".to_string(),
130 )
131 })?;
132 Ok(code_writer_state(config, workspace_root))
133 }
134 }
135}
136
137fn active_code_writer_config(state: &BashState) -> Option<CodeWriterConfig> {
138 (state.mode == Modes::CodeWriter).then(|| CodeWriterConfig {
139 allowed_globs: state.file_edit_mode.allowed_globs.clone(),
140 allowed_commands: state.bash_command_mode.allowed_commands.clone(),
141 })
142}
143
144fn active_code_writer_policy(state: &BashState) -> (Option<&'static str>, Vec<String>) {
145 if state.mode != Modes::CodeWriter {
146 return (None, Vec::new());
147 }
148
149 match &state.bash_command_mode.allowed_commands {
150 AllowedCommands::All(value) if value == "all" => (Some("unrestricted"), Vec::new()),
151 AllowedCommands::All(_) => (Some("restricted"), Vec::new()),
152 AllowedCommands::List(commands) => {
153 let bypass = crate::utils::bash_parser::detect_allowlist_bypass(commands);
154 let strength = if bypass.is_empty() { "restricted" } else { "weak" };
155 (Some(strength), bypass)
156 }
157 }
158}
159
160fn warn_code_writer_policy_once(state: &BashState, bypass: &[String]) {
161 if bypass.is_empty() {
162 return;
163 }
164 let AllowedCommands::List(commands) = &state.bash_command_mode.allowed_commands else {
165 return;
166 };
167
168 let cache = WARNED_POLICY_CONFIGURATIONS
169 .get_or_init(|| StdMutex::new(VecDeque::with_capacity(POLICY_WARNING_CACHE_CAPACITY)));
170 let mut cache = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
171 if cache.iter().any(|seen| seen == commands) {
172 return;
173 }
174 if cache.len() == POLICY_WARNING_CACHE_CAPACITY {
175 cache.pop_front();
176 }
177 cache.push_back(commands.clone());
178 drop(cache);
179
180 warn!(
181 commands = ?bypass,
182 "code_writer allowlist includes shell-spawning commands; the command allowlist is \
183 effectively bypassable and does not sandbox the agent"
184 );
185}
186
187fn append_code_writer_policy_warning(response: &mut String, bypass: &[String]) {
188 if bypass.is_empty() {
189 return;
190 }
191 let _ = writeln!(
192 response,
193 "\n⚠️ SECURITY: code_writer allowlist includes shell/eval commands ({}). \
194 They execute arbitrary code from string arguments (e.g. `bash -c …`, \
195 `find -exec …`), so the command allowlist is effectively bypassable and \
196 does NOT sandbox the agent. Drop them if you intended a hard restriction.",
197 bypass.join(", ")
198 );
199}
200
201fn read_initial_files_simple(files: &[String], workspace: &std::path::Path) -> String {
202 let mut output = String::new();
203 for file_path in files {
204 let expanded = expand_user(file_path);
205 let path = if std::path::Path::new(&expanded).is_absolute() {
206 PathBuf::from(&expanded)
207 } else {
208 workspace.join(&expanded)
209 };
210
211 if let Ok(validated) = validate_path_in_workspace(&path, workspace) {
212 if validated.exists() && validated.is_file() {
213 if let Ok(content) = read_file_to_string(&validated, 10_000_000) {
214 let _ = write!(output, "\n{file_path}\n```\n{content}\n```\n");
215 }
216 }
217 }
218 }
219 output
220}
221
222fn prepare_workspace(
223 initialize: &Initialize,
224 thread_id: &str,
225 response: &mut String,
226) -> Result<PathBuf> {
227 let workspace_path_str = expand_user(&initialize.any_workspace_path);
228 if workspace_path_str.is_empty() {
229 let playground = create_playground_dir(thread_id)?;
232 let _ = writeln!(
233 response,
234 "No workspace path provided; created a playground at {}",
235 playground.display()
236 );
237 return Ok(playground);
238 }
239
240 let workspace_path = PathBuf::from(&workspace_path_str);
241 let mut folder_to_start = workspace_path.clone();
242
243 if workspace_path.exists() {
244 if workspace_path.is_file() {
245 folder_to_start = workspace_path.parent().unwrap_or(&workspace_path).to_path_buf();
246 let _ =
247 writeln!(response, "Using parent directory of file: {}", folder_to_start.display());
248 } else if workspace_path.is_dir() {
249 let _ = writeln!(response, "Using workspace directory: {}", folder_to_start.display());
250 }
251 } else if workspace_path.is_absolute() {
252 ensure_directory_exists(&workspace_path).map_err(|e| {
253 WinxError::WorkspacePathError(format!("Failed to create workspace: {e}"))
254 })?;
255 let _ = writeln!(response, "Created workspace directory: {}", workspace_path.display());
256 }
257
258 if folder_to_start.exists() {
262 if let Ok(canonical) = folder_to_start.canonicalize() {
263 folder_to_start = canonical;
264 }
265 }
266
267 Ok(folder_to_start)
268}
269
270fn initialize_thread_id(initialize: &Initialize) -> String {
271 let thread_id = normalize_thread_id(&initialize.thread_id);
272 if thread_id.is_empty() {
273 generate_thread_id()
274 } else {
275 thread_id
276 }
277}
278
279fn validate_thread_id(initialize: &Initialize) -> Result<()> {
280 if initialize.init_type != InitializeType::FirstCall
281 && normalize_thread_id(&initialize.thread_id).is_empty()
282 {
283 return Err(WinxError::ThreadIdMismatch(
284 "Thread id should be provided if type != 'first_call', including when resetting."
285 .to_string(),
286 ));
287 }
288
289 Ok(())
290}
291
292fn load_guidelines(workspace: &Path) -> String {
293 let mut output = String::new();
294 let mut candidates = Vec::new();
295 if let Some(home) = home::home_dir() {
296 candidates.push(home.join(".winx").join("AGENTS.md"));
297 candidates.push(home.join(".winx").join("CLAUDE.md"));
298 candidates.push(home.join(".wcgw").join("AGENTS.md"));
299 candidates.push(home.join(".wcgw").join("CLAUDE.md"));
300 }
301 candidates.push(workspace.join("AGENTS.md"));
302 candidates.push(workspace.join("CLAUDE.md"));
303
304 for path in candidates {
305 if path.is_file() {
306 if let Ok(content) = fs::read_to_string(&path) {
307 let _ = writeln!(output, "\n## {}\n{}", path.display(), content);
308 }
309 }
310 }
311 output
312}
313
314pub async fn handle_tool_call(
315 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
316 initialize: Initialize,
317) -> Result<String> {
318 handle_tool_call_with_runtime(&EmbeddedShellRuntime, bash_state_arc, initialize).await
319}
320
321pub async fn handle_tool_call_with_runtime(
322 runtime: &dyn ShellRuntime,
323 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
324 initialize: Initialize,
325) -> Result<String> {
326 Ok(handle_tool_call_with_runtime_detailed(runtime, bash_state_arc, initialize).await?.text)
327}
328
329#[instrument(level = "info", skip(runtime, bash_state_arc, initialize))]
330#[allow(clippy::too_many_lines)]
331pub(crate) async fn handle_tool_call_with_runtime_detailed(
332 runtime: &dyn ShellRuntime,
333 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
334 initialize: Initialize,
335) -> Result<InitializeOutcome> {
336 let mut response = String::new();
337 let mut context_bytes = 0;
338 let mut guidelines_bytes = 0;
339 let initial_files_count = initialize.initial_files_to_read.len();
340
341 info!("Initialize called for workspace: {}", initialize.any_workspace_path);
342
343 validate_thread_id(&initialize)?;
344 let thread_id = initialize_thread_id(&initialize);
345 let mut bash_state_guard = bash_state_arc.lock().await;
346 if initialize.init_type != InitializeType::FirstCall && bash_state_guard.is_none() {
347 return Err(WinxError::BashStateNotInitialized);
348 }
349 let local_attach = initialize.init_type == InitializeType::FirstCall
350 && bash_state_guard
351 .as_ref()
352 .is_some_and(|state| state.initialized && state.current_thread_id == thread_id);
353 let folder_to_start = if local_attach {
354 bash_state_guard
355 .as_ref()
356 .map_or_else(|| PathBuf::from("/tmp"), |state| state.workspace_root.clone())
357 } else {
358 prepare_workspace(&initialize, &thread_id, &mut response)?
359 };
360 let requested_mode = Modes::from(&initialize.mode_name);
361 let preserve_active_mode =
362 local_attach || initialize.init_type == InitializeType::UserAskedChangeWorkspace;
363 let (mode, bash_command_mode, file_edit_mode, write_if_empty_mode) = if preserve_active_mode {
364 let Some(state) = bash_state_guard.as_ref() else {
365 return Err(WinxError::BashStateNotInitialized);
366 };
367 (
368 state.mode,
369 state.bash_command_mode.clone(),
370 state.file_edit_mode.clone(),
371 state.write_if_empty_mode.clone(),
372 )
373 } else {
374 let (bash_command_mode, file_edit_mode, write_if_empty_mode) = mode_to_state(
375 requested_mode,
376 initialize.code_writer_config.as_ref(),
377 &folder_to_start,
378 )?;
379 (requested_mode, bash_command_mode, file_edit_mode, write_if_empty_mode)
380 };
381
382 let transition;
383 let mut compact_response = false;
384
385 match initialize.init_type {
386 InitializeType::FirstCall => {
387 let configured = if local_attach {
388 let Some(state) = bash_state_guard.as_mut() else {
389 return Err(WinxError::BashStateNotInitialized);
390 };
391 let mut configured =
395 runtime.configure_session(state, ShellSessionTransition::ModeChange).await?;
396 configured.attached_existing = true;
397 configured
398 } else {
399 let mut new_bash_state = BashState::new();
400 new_bash_state.current_thread_id.clone_from(&thread_id);
401 new_bash_state.mode = mode;
402 new_bash_state.bash_command_mode = bash_command_mode;
403 new_bash_state.file_edit_mode = file_edit_mode;
404 new_bash_state.write_if_empty_mode = write_if_empty_mode;
405 new_bash_state.initialized = true;
406
407 let resumed_context = if initialize.task_id_to_resume.is_empty() {
408 None
409 } else {
410 crate::tools::context_save::load_saved_context(&initialize.task_id_to_resume)?
411 };
412
413 if let Some((memory_data, snapshot)) = &resumed_context {
414 if let Some(snapshot) = snapshot {
415 new_bash_state.apply_snapshot(snapshot);
416 new_bash_state.current_thread_id.clone_from(&thread_id);
417 }
418 let _ = writeln!(
419 response,
420 "\n# Resumed task {}\nFollowing is the retrieved task context:\n{}",
421 initialize.task_id_to_resume, memory_data
422 );
423 }
424
425 if resumed_context.as_ref().and_then(|(_, snapshot)| snapshot.as_ref()).is_none() {
429 let resumed_root = resumed_context
430 .as_ref()
431 .and_then(|(memory, _)| {
432 crate::tools::context_save::extract_project_root(memory)
433 })
434 .filter(|root| root.exists());
435 let target = resumed_root.as_deref().unwrap_or(folder_to_start.as_path());
436 if target.exists() {
437 new_bash_state.update_cwd(target)?;
438 new_bash_state.update_workspace_root(target)?;
439 }
440 }
441 let configured = runtime
442 .configure_session(&mut new_bash_state, ShellSessionTransition::FirstCall)
443 .await?;
444 *bash_state_guard = Some(new_bash_state);
445 configured
446 };
447
448 transition = if configured.attached_existing {
449 InitializeTransition::AttachedExisting
450 } else {
451 InitializeTransition::Created
452 };
453 compact_response = configured.attached_existing
454 && initialize.initial_files_to_read.is_empty()
455 && initialize.task_id_to_resume.is_empty();
456
457 if compact_response {
458 response.clear();
459 let state = bash_state_guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
460 let _ = writeln!(
461 response,
462 "Attached to the existing durable Winx session; PTY, cwd, mode, and prior \
463 context were preserved.\ncwd={}\nmode={}",
464 state.cwd.display(),
465 state.mode
466 );
467 if let Some(attach_hint) = configured.attach_hint.as_deref() {
468 let _ = writeln!(response, "Attach terminal: {attach_hint}");
469 }
470 response.push_str(
471 "Context and instructions are unchanged. Continue with the existing thread; \
472 use an explicit mode/workspace change or reset only when intended.\n",
473 );
474 } else {
475 if configured.attached_existing {
476 response.push_str(
477 "\nAttached to the existing durable session for this principal/workspace; \
478 the guardian-owned PTY and cwd were preserved. Use `reset_shell` or an \
479 explicit workspace/mode change when replacement is intended.\n",
480 );
481 }
482
483 let _ = write!(
484 response,
485 "\n# Environment\nSystem: {}\nMachine: {}\nInitialized in directory: {}\n",
486 std::env::consts::OS,
487 std::env::consts::ARCH,
488 bash_state_guard
489 .as_ref()
490 .map_or(folder_to_start.as_path(), |state| state.cwd.as_path())
491 .display()
492 );
493
494 if command_exists("rg") {
495 let _ = writeln!(
496 response,
497 "\n# Available commands\nUse ripgrep `rg` instead of `grep`/`find -name` — \
498 it's much faster and respects .gitignore."
499 );
500 }
501
502 if let Some(attach_hint) = configured.attach_hint.as_deref() {
503 let _ = writeln!(response, "\nAttach terminal: {attach_hint}");
504 }
505
506 let active_config = bash_state_guard.as_ref().and_then(active_code_writer_config);
509 let active_mode = bash_state_guard.as_ref().map_or(mode, |state| state.mode);
510 let _ = writeln!(
511 response,
512 "\n{}",
513 crate::utils::mode_prompts::mode_prompt(active_mode, active_config.as_ref())
514 );
515
516 let active_workspace = bash_state_guard
517 .as_ref()
518 .map_or(folder_to_start.as_path(), |state| state.workspace_root.as_path());
519
520 let guidelines = load_guidelines(active_workspace);
521 guidelines_bytes = guidelines.len();
522 if !guidelines.is_empty() {
523 let _ = writeln!(response, "\n# Agent guidelines\n{guidelines}");
524 }
525
526 if let Ok((repo_context, _)) =
527 crate::utils::repo::get_repo_context(active_workspace)
528 {
529 context_bytes = repo_context.len();
530 let _ = writeln!(response, "\n# Workspace structure\n{repo_context}");
531 }
532
533 if !initialize.initial_files_to_read.is_empty() {
534 let content = read_initial_files_simple(
535 &initialize.initial_files_to_read,
536 active_workspace,
537 );
538 if !content.is_empty() {
539 let _ = writeln!(response, "\n# Requested files\n{content}");
540 }
541 }
542 }
543 }
544 InitializeType::UserAskedModeChange => {
545 transition = InitializeTransition::ModeChanged;
546 if let Some(state) = bash_state_guard.as_mut() {
547 state.mode = mode;
548 state.bash_command_mode = bash_command_mode;
549 state.file_edit_mode = file_edit_mode;
550 state.write_if_empty_mode = write_if_empty_mode;
551 runtime.configure_session(state, ShellSessionTransition::ModeChange).await?;
552 let _ = writeln!(response, "Changed mode to: {mode:?}");
553 let active_config = active_code_writer_config(state);
554 let _ = writeln!(
555 response,
556 "\n{}",
557 crate::utils::mode_prompts::mode_prompt(mode, active_config.as_ref())
558 );
559 } else {
560 return Err(WinxError::BashStateNotInitialized);
561 }
562 }
563 InitializeType::ResetShell => {
564 transition = InitializeTransition::ShellReset;
565 if let Some(state) = bash_state_guard.as_mut() {
566 state.mode = mode;
567 state.bash_command_mode = bash_command_mode;
568 state.file_edit_mode = file_edit_mode;
569 state.write_if_empty_mode = write_if_empty_mode;
570 runtime.configure_session(state, ShellSessionTransition::Reset).await?;
571 response.push_str("Reset shell (new PTY created)\n");
572 } else {
573 return Err(WinxError::BashStateNotInitialized);
574 }
575 }
576 InitializeType::UserAskedChangeWorkspace => {
577 transition = InitializeTransition::WorkspaceChanged;
578 if let Some(state) = bash_state_guard.as_mut() {
579 if folder_to_start.exists() {
580 state.update_cwd(&folder_to_start)?;
581 state.update_workspace_root(&folder_to_start)?;
582 runtime
583 .configure_session(state, ShellSessionTransition::WorkspaceChange)
584 .await?;
585 let _ =
586 writeln!(response, "Changed workspace to: {}", folder_to_start.display());
587 } else {
588 let _ = writeln!(
589 response,
590 "Warning: Workspace path {} does not exist",
591 folder_to_start.display()
592 );
593 }
594 } else {
595 return Err(WinxError::BashStateNotInitialized);
596 }
597 }
598 }
599
600 let state = bash_state_guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
601 let temporary_artifact = if compact_response {
602 crate::utils::agent_temp::session_info(&state.workspace_root, &state.current_thread_id)
603 } else {
604 crate::utils::agent_temp::prepare_session(&state.workspace_root, &state.current_thread_id)
605 };
606 let temporary_artifact_instruction = if compact_response {
607 format!(
608 "Use temporary_artifact_dir={} for session-local derived helpers; BashCommand exports \
609 the same path as WINX_TEMP_DIR.",
610 temporary_artifact.directory.display()
611 )
612 } else {
613 format!(
614 "Use temporary_artifact_dir={} for session-local derived helpers only; keep names \
615 short, preserve source-path/line provenance, and treat helpers as non-canonical. The \
616 directory is created on demand and expired after {} seconds of inactivity. Every \
617 BashCommand PTY exports the same path as WINX_TEMP_DIR; shell-generated helpers must \
618 stay beneath it.",
619 temporary_artifact.directory.display(),
620 temporary_artifact.ttl_seconds,
621 )
622 };
623 let _ = writeln!(
624 response,
625 "\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.",
626 state.workspace_root.display(),
627 );
628
629 let (code_writer_policy_strength, bypass) =
630 bash_state_guard.as_ref().map_or((None, Vec::new()), active_code_writer_policy);
631 if let Some(state) = bash_state_guard.as_ref() {
632 warn_code_writer_policy_once(state, &bypass);
633 }
634 if compact_response {
635 if let Some(strength) = code_writer_policy_strength {
636 let _ = writeln!(
637 response,
638 "code_writer_policy={strength} shell_spawners_present={}",
639 !bypass.is_empty()
640 );
641 }
642 } else {
643 append_code_writer_policy_warning(&mut response, &bypass);
644 crate::utils::orchestration::append_initialize_instructions(&mut response);
645 }
646
647 Ok(InitializeOutcome {
648 text: response,
649 transition,
650 context_bytes,
651 guidelines_bytes,
652 initial_files_count,
653 compact_response,
654 code_writer_policy_strength,
655 shell_spawners_present: !bypass.is_empty(),
656 temporary_artifact_dir: temporary_artifact.directory,
657 temporary_artifact_ttl_seconds: temporary_artifact.ttl_seconds,
658 temporary_artifact_max_bytes: temporary_artifact.max_total_bytes,
659 temporary_artifact_max_file_bytes: temporary_artifact.max_file_bytes,
660 })
661}