Skip to main content

albert_tools/
lib.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::time::{Duration, Instant};
5
6use api::{
7    read_base_url, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
8    MessageResponse, OutputContentBlock, TernlangClient, StreamEvent as ApiStreamEvent, ToolChoice,
9    ToolDefinition, ToolResultContentBlock,
10};
11use reqwest::blocking::Client;
12use runtime::{
13    edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file,
14    ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage,
15    ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy,
16    RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
17};
18use serde::{Deserialize, Serialize};
19use serde_json::{json, Value};
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ToolManifestEntry {
23    pub name: String,
24    pub source: ToolSource,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ToolSource {
29    Base,
30    Conditional,
31}
32
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub struct ToolRegistry {
35    entries: Vec<ToolManifestEntry>,
36}
37
38impl ToolRegistry {
39    #[must_use]
40    pub fn new(entries: Vec<ToolManifestEntry>) -> Self {
41        Self { entries }
42    }
43
44    #[must_use]
45    pub fn entries(&self) -> &[ToolManifestEntry] {
46        &self.entries
47    }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ToolSpec {
52    pub name: &'static str,
53    pub description: &'static str,
54    pub input_schema: Value,
55    pub required_permission: PermissionMode,
56}
57
58#[must_use]
59#[allow(clippy::too_many_lines)]
60pub fn mvp_tool_specs() -> Vec<ToolSpec> {
61    vec![
62        ToolSpec {
63            name: "bash",
64            description: "Execute a shell command in the current workspace.",
65            input_schema: json!({
66                "type": "object",
67                "properties": {
68                    "command": { "type": "string" },
69                    "timeout": { "type": "integer", "minimum": 1 },
70                    "description": { "type": "string" },
71                    "run_in_background": { "type": "boolean" },
72                    "dangerouslyDisableSandbox": { "type": "boolean" }
73                },
74                "required": ["command"],
75                "additionalProperties": false
76            }),
77            required_permission: PermissionMode::DangerFullAccess,
78        },
79        ToolSpec {
80            name: "read_file",
81            description: "Read a text file from the workspace.",
82            input_schema: json!({
83                "type": "object",
84                "properties": {
85                    "path": { "type": "string" },
86                    "offset": { "type": "integer", "minimum": 0 },
87                    "limit": { "type": "integer", "minimum": 1 }
88                },
89                "required": ["path"],
90                "additionalProperties": false
91            }),
92            required_permission: PermissionMode::ReadOnly,
93        },
94        ToolSpec {
95            name: "write_file",
96            description: "Write a text file in the workspace.",
97            input_schema: json!({
98                "type": "object",
99                "properties": {
100                    "path": { "type": "string" },
101                    "content": { "type": "string" }
102                },
103                "required": ["path", "content"],
104                "additionalProperties": false
105            }),
106            required_permission: PermissionMode::WorkspaceWrite,
107        },
108        ToolSpec {
109            name: "edit_file",
110            description: "Replace text in a workspace file.",
111            input_schema: json!({
112                "type": "object",
113                "properties": {
114                    "path": { "type": "string" },
115                    "old_string": { "type": "string" },
116                    "new_string": { "type": "string" },
117                    "replace_all": { "type": "boolean" }
118                },
119                "required": ["path", "old_string", "new_string"],
120                "additionalProperties": false
121            }),
122            required_permission: PermissionMode::WorkspaceWrite,
123        },
124        ToolSpec {
125            name: "glob_search",
126            description: "Find files by glob pattern.",
127            input_schema: json!({
128                "type": "object",
129                "properties": {
130                    "pattern": { "type": "string" },
131                    "path": { "type": "string" }
132                },
133                "required": ["pattern"],
134                "additionalProperties": false
135            }),
136            required_permission: PermissionMode::ReadOnly,
137        },
138        ToolSpec {
139            name: "grep_search",
140            description: "Search file contents with a regex pattern.",
141            input_schema: json!({
142                "type": "object",
143                "properties": {
144                    "pattern": { "type": "string" },
145                    "path": { "type": "string" },
146                    "glob": { "type": "string" },
147                    "output_mode": { "type": "string" },
148                    "-B": { "type": "integer", "minimum": 0 },
149                    "-A": { "type": "integer", "minimum": 0 },
150                    "-C": { "type": "integer", "minimum": 0 },
151                    "context": { "type": "integer", "minimum": 0 },
152                    "-n": { "type": "boolean" },
153                    "-i": { "type": "boolean" },
154                    "type": { "type": "string" },
155                    "head_limit": { "type": "integer", "minimum": 1 },
156                    "offset": { "type": "integer", "minimum": 0 },
157                    "multiline": { "type": "boolean" }
158                },
159                "required": ["pattern"],
160                "additionalProperties": false
161            }),
162            required_permission: PermissionMode::ReadOnly,
163        },
164        ToolSpec {
165            name: "WebFetch",
166            description:
167                "Fetch a URL, convert it into readable text, and answer a prompt about it.",
168            input_schema: json!({
169                "type": "object",
170                "properties": {
171                    "url": { "type": "string", "format": "uri" },
172                    "prompt": { "type": "string" }
173                },
174                "required": ["url", "prompt"],
175                "additionalProperties": false
176            }),
177            required_permission: PermissionMode::ReadOnly,
178        },
179        ToolSpec {
180            name: "WebSearch",
181            description: "Search the web for current information and return cited results.",
182            input_schema: json!({
183                "type": "object",
184                "properties": {
185                    "query": { "type": "string", "minLength": 2 },
186                    "allowed_domains": {
187                        "type": "array",
188                        "items": { "type": "string" }
189                    },
190                    "blocked_domains": {
191                        "type": "array",
192                        "items": { "type": "string" }
193                    }
194                },
195                "required": ["query"],
196                "additionalProperties": false
197            }),
198            required_permission: PermissionMode::ReadOnly,
199        },
200        ToolSpec {
201            name: "TodoWrite",
202            description: "Update the structured task list for the current session.",
203            input_schema: json!({
204                "type": "object",
205                "properties": {
206                    "todos": {
207                        "type": "array",
208                        "items": {
209                            "type": "object",
210                            "properties": {
211                                "content": { "type": "string" },
212                                "activeForm": { "type": "string" },
213                                "status": {
214                                    "type": "string",
215                                    "enum": ["pending", "in_progress", "completed"]
216                                }
217                            },
218                            "required": ["content", "activeForm", "status"],
219                            "additionalProperties": false
220                        }
221                    }
222                },
223                "required": ["todos"],
224                "additionalProperties": false
225            }),
226            required_permission: PermissionMode::WorkspaceWrite,
227        },
228        ToolSpec {
229            name: "Skill",
230            description: "Load a local skill definition and its instructions.",
231            input_schema: json!({
232                "type": "object",
233                "properties": {
234                    "skill": { "type": "string" },
235                    "args": { "type": "string" }
236                },
237                "required": ["skill"],
238                "additionalProperties": false
239            }),
240            required_permission: PermissionMode::ReadOnly,
241        },
242        ToolSpec {
243            name: "Agent",
244            description: "Launch a specialized agent task and persist its handoff metadata.",
245            input_schema: json!({
246                "type": "object",
247                "properties": {
248                    "description": { "type": "string" },
249                    "prompt": { "type": "string" },
250                    "subagent_type": { "type": "string" },
251                    "name": { "type": "string" },
252                    "model": { "type": "string" }
253                },
254                "required": ["description", "prompt"],
255                "additionalProperties": false
256            }),
257            required_permission: PermissionMode::DangerFullAccess,
258        },
259        ToolSpec {
260            name: "ToolSearch",
261            description: "Search for deferred or specialized tools by exact name or keywords.",
262            input_schema: json!({
263                "type": "object",
264                "properties": {
265                    "query": { "type": "string" },
266                    "max_results": { "type": "integer", "minimum": 1 }
267                },
268                "required": ["query"],
269                "additionalProperties": false
270            }),
271            required_permission: PermissionMode::ReadOnly,
272        },
273        ToolSpec {
274            name: "NotebookEdit",
275            description: "Replace, insert, or delete a cell in a Jupyter notebook.",
276            input_schema: json!({
277                "type": "object",
278                "properties": {
279                    "notebook_path": { "type": "string" },
280                    "cell_id": { "type": "string" },
281                    "new_source": { "type": "string" },
282                    "cell_type": { "type": "string", "enum": ["code", "markdown"] },
283                    "edit_mode": { "type": "string", "enum": ["replace", "insert", "delete"] }
284                },
285                "required": ["notebook_path"],
286                "additionalProperties": false
287            }),
288            required_permission: PermissionMode::WorkspaceWrite,
289        },
290        ToolSpec {
291            name: "Sleep",
292            description: "Wait for a specified duration without holding a shell process.",
293            input_schema: json!({
294                "type": "object",
295                "properties": {
296                    "duration_ms": { "type": "integer", "minimum": 0 }
297                },
298                "required": ["duration_ms"],
299                "additionalProperties": false
300            }),
301            required_permission: PermissionMode::ReadOnly,
302        },
303        ToolSpec {
304            name: "SendUserMessage",
305            description: "Send a message to the user.",
306            input_schema: json!({
307                "type": "object",
308                "properties": {
309                    "message": { "type": "string" },
310                    "attachments": {
311                        "type": "array",
312                        "items": { "type": "string" }
313                    },
314                    "status": {
315                        "type": "string",
316                        "enum": ["normal", "proactive"]
317                    }
318                },
319                "required": ["message", "status"],
320                "additionalProperties": false
321            }),
322            required_permission: PermissionMode::ReadOnly,
323        },
324        ToolSpec {
325            name: "Config",
326            description: "Get or set Claw Code settings.",
327            input_schema: json!({
328                "type": "object",
329                "properties": {
330                    "setting": { "type": "string" },
331                    "value": {
332                        "type": ["string", "boolean", "number"]
333                    }
334                },
335                "required": ["setting"],
336                "additionalProperties": false
337            }),
338            required_permission: PermissionMode::WorkspaceWrite,
339        },
340        ToolSpec {
341            name: "StructuredOutput",
342            description: "Return structured output in the requested format.",
343            input_schema: json!({
344                "type": "object",
345                "additionalProperties": true
346            }),
347            required_permission: PermissionMode::ReadOnly,
348        },
349        ToolSpec {
350            name: "REPL",
351            description: "Execute code in a REPL-like subprocess.",
352            input_schema: json!({
353                "type": "object",
354                "properties": {
355                    "code": { "type": "string" },
356                    "language": { "type": "string" },
357                    "timeout_ms": { "type": "integer", "minimum": 1 }
358                },
359                "required": ["code", "language"],
360                "additionalProperties": false
361            }),
362            required_permission: PermissionMode::DangerFullAccess,
363        },
364        ToolSpec {
365            name: "PowerShell",
366            description: "Execute a PowerShell command with optional timeout.",
367            input_schema: json!({
368                "type": "object",
369                "properties": {
370                    "command": { "type": "string" },
371                    "timeout": { "type": "integer", "minimum": 1 },
372                    "description": { "type": "string" },
373                    "run_in_background": { "type": "boolean" }
374                },
375                "required": ["command"],
376                "additionalProperties": false
377            }),
378            required_permission: PermissionMode::DangerFullAccess,
379        },
380        ToolSpec {
381            name: "SequentialThinking",
382            description: "A tool that enables structured, multi-step reasoning by allowing you to record and revise thoughts sequentially.",
383            input_schema: json!({
384                "type": "object",
385                "properties": {
386                    "thought": { "type": "string" },
387                    "thoughtNumber": { "type": "integer", "minimum": 1 },
388                    "totalThoughts": { "type": "integer", "minimum": 1 },
389                    "nextThoughtNeeded": { "type": "boolean" },
390                    "isRevision": { "type": "boolean" },
391                    "revisesThoughtNumber": { "type": "integer", "minimum": 1 }
392                },
393                "required": ["thought", "thoughtNumber", "totalThoughts", "nextThoughtNeeded"],
394                "additionalProperties": false
395            }),
396            required_permission: PermissionMode::ReadOnly,
397        },
398        ToolSpec {
399            name: "Memory",
400            description: "Persistent knowledge graph for entities, relations, and observations across sessions.",
401            input_schema: json!({
402                "type": "object",
403                "properties": {
404                    "action": { "type": "string", "enum": ["create_entities", "create_relations", "add_observations", "search_nodes"] },
405                    "entities": {
406                        "type": "array",
407                        "items": {
408                            "type": "object",
409                            "properties": {
410                                "name": { "type": "string" },
411                                "type": { "type": "string" },
412                                "description": { "type": "string" }
413                            },
414                            "required": ["name", "type", "description"]
415                        }
416                    },
417                    "relations": {
418                        "type": "array",
419                        "items": {
420                            "type": "object",
421                            "properties": {
422                                "from": { "type": "string" },
423                                "to": { "type": "string" },
424                                "type": { "type": "string" }
425                            },
426                            "required": ["from", "to", "type"]
427                        }
428                    },
429                    "observations": {
430                        "type": "array",
431                        "items": {
432                            "type": "object",
433                            "properties": {
434                                "entityName": { "type": "string" },
435                                "contents": { "type": "array", "items": { "type": "string" } }
436                            },
437                            "required": ["entityName", "contents"]
438                        }
439                    },
440                    "query": { "type": "string" }
441                },
442                "required": ["action"],
443                "additionalProperties": false
444            }),
445            required_permission: PermissionMode::ReadOnly,
446        },
447        ToolSpec {
448            name: "RepoMap",
449            description: "Generate a structured overview of the codebase structure and key symbols.",
450            input_schema: json!({
451                "type": "object",
452                "properties": {
453                    "path": { "type": "string" },
454                    "depth": { "type": "integer", "minimum": 1, "maximum": 5 },
455                    "include_signatures": { "type": "boolean" }
456                },
457                "additionalProperties": false
458            }),
459            required_permission: PermissionMode::ReadOnly,
460        },
461        ToolSpec {
462            name: "vault_write",
463            description: "Save a memory to Albert's persistent vault (~/.albert/vault.jsonl). \
464                Use this to lock in anything worth remembering across sessions — decisions, facts, \
465                user context, project anchors. Include #tags in the content to make recall easier. \
466                Responds with a ternary confirmation.",
467            input_schema: json!({
468                "type": "object",
469                "properties": {
470                    "content": {
471                        "type": "string",
472                        "description": "The memory to store. Include #tags for easier recall later."
473                    },
474                    "state": {
475                        "type": "string",
476                        "enum": ["+1", "0", "-1"],
477                        "description": "Ternary state: +1 affirm (strong/positive), 0 tend (neutral/ongoing), -1 refrain (caution/negative)."
478                    }
479                },
480                "required": ["content"],
481                "additionalProperties": false
482            }),
483            required_permission: PermissionMode::ReadOnly,
484        },
485        ToolSpec {
486            name: "vault_read",
487            description: "Search Albert's persistent vault for memories matching a keyword or #tag. \
488                Returns all matching entries with timestamps and ternary states.",
489            input_schema: json!({
490                "type": "object",
491                "properties": {
492                    "query": {
493                        "type": "string",
494                        "description": "Keyword or #tag to search for."
495                    }
496                },
497                "required": ["query"],
498                "additionalProperties": false
499            }),
500            required_permission: PermissionMode::ReadOnly,
501        },
502    ]
503}
504
505#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
506pub struct ToolResult {
507    pub output: String,
508    pub state: i8, // Ternary Intelligence Stack: +1 Success, 0 Neutral/Halt, -1 Failure
509}
510
511pub fn execute_tool(name: &str, input: &Value) -> Result<ToolResult, String> {
512    match name {
513        "bash" => from_value::<BashCommandInput>(input).and_then(run_bash_wrapped),
514        "read_file" => from_value::<ReadFileInput>(input).and_then(run_read_file_wrapped),
515        "write_file" => from_value::<WriteFileInput>(input).and_then(run_write_file_wrapped),
516        "edit_file" => from_value::<EditFileInput>(input).and_then(run_edit_file_wrapped),
517        "glob_search" => from_value::<GlobSearchInputValue>(input).and_then(run_glob_search_wrapped),
518        "grep_search" => from_value::<GrepSearchInput>(input).and_then(run_grep_search_wrapped),
519        "WebFetch" => from_value::<WebFetchInput>(input).and_then(run_web_fetch_wrapped),
520        "WebSearch" => from_value::<WebSearchInput>(input).and_then(run_web_search_wrapped),
521        "TodoWrite" => from_value::<TodoWriteInput>(input).and_then(run_todo_write_wrapped),
522        "Skill" => from_value::<SkillInput>(input).and_then(run_skill_wrapped),
523        "create_skill" => from_value::<SkillCreateInput>(input).and_then(run_create_skill_wrapped),
524        "Agent" => from_value::<AgentInput>(input).and_then(run_agent_wrapped),
525        "ToolSearch" => from_value::<ToolSearchInput>(input).and_then(run_tool_search_wrapped),
526        "NotebookEdit" => from_value::<NotebookEditInput>(input).and_then(run_notebook_edit_wrapped),
527        "Sleep" => from_value::<SleepInput>(input).and_then(run_sleep_wrapped),
528        "SendUserMessage" | "Brief" => from_value::<BriefInput>(input).and_then(run_brief_wrapped),
529        "Config" => from_value::<ConfigInput>(input).and_then(run_config_wrapped),
530        "StructuredOutput" => from_value::<StructuredOutputInput>(input).and_then(run_structured_output_wrapped),
531        "REPL" => from_value::<ReplInput>(input).and_then(run_repl_wrapped),
532        "PowerShell" => from_value::<PowerShellInput>(input).and_then(run_powershell_wrapped),
533        "SequentialThinking" => from_value::<SequentialThinkingInput>(input).and_then(run_sequential_thinking_wrapped),
534        "Memory" => from_value::<MemoryInput>(input).and_then(run_memory_wrapped),
535        "RepoMap" => from_value::<RepoMapInput>(input).and_then(run_repo_map_wrapped),
536        "vault_write" => from_value::<VaultWriteInput>(input).and_then(run_vault_write_wrapped),
537        "vault_read" => from_value::<VaultReadInput>(input).and_then(run_vault_read_wrapped),
538        _ => Err(format!("unsupported tool: {name}")),
539    }
540}
541
542fn from_value<T: for<'de> Deserialize<'de>>(input: &Value) -> Result<T, String> {
543    serde_json::from_value(input.clone()).map_err(|error| error.to_string())
544}
545
546fn expand_tilde(path: &str) -> String {
547    if path.starts_with("~/") || path == "~" {
548        if let Ok(home) = std::env::var("HOME") {
549            return if path == "~" {
550                home
551            } else {
552                format!("{}{}", home, &path[1..])
553            };
554        }
555    }
556    path.to_string()
557}
558
559#[allow(clippy::needless_pass_by_value)]
560fn run_read_file(input: ReadFileInput) -> Result<String, String> {
561    to_pretty_json(read_file(&expand_tilde(&input.path), input.offset, input.limit).map_err(io_to_string)?)
562}
563
564#[allow(clippy::needless_pass_by_value)]
565fn run_write_file(input: WriteFileInput) -> Result<String, String> {
566    to_pretty_json(write_file(&expand_tilde(&input.path), &input.content).map_err(io_to_string)?)
567}
568
569#[allow(clippy::needless_pass_by_value)]
570fn run_edit_file(input: EditFileInput) -> Result<String, String> {
571    to_pretty_json(
572        edit_file(
573            &expand_tilde(&input.path),
574            &input.old_string,
575            &input.new_string,
576            input.replace_all.unwrap_or(false),
577        )
578        .map_err(io_to_string)?,
579    )
580}
581
582#[allow(clippy::needless_pass_by_value)]
583fn run_glob_search(input: GlobSearchInputValue) -> Result<String, String> {
584    let expanded = input.path.as_deref().map(expand_tilde);
585    to_pretty_json(glob_search(&input.pattern, expanded.as_deref()).map_err(io_to_string)?)
586}
587
588#[allow(clippy::needless_pass_by_value)]
589fn run_grep_search(input: GrepSearchInput) -> Result<String, String> {
590    to_pretty_json(grep_search(&input).map_err(io_to_string)?)
591}
592
593#[allow(clippy::needless_pass_by_value)]
594fn run_web_fetch(input: WebFetchInput) -> Result<String, String> {
595    to_pretty_json(execute_web_fetch(&input)?)
596}
597
598#[allow(clippy::needless_pass_by_value)]
599fn run_web_search(input: WebSearchInput) -> Result<String, String> {
600    to_pretty_json(execute_web_search(&input)?)
601}
602
603fn run_todo_write(input: TodoWriteInput) -> Result<String, String> {
604    to_pretty_json(execute_todo_write(input)?)
605}
606
607fn run_skill(input: SkillInput) -> Result<String, String> {
608    to_pretty_json(execute_skill(input)?)
609}
610
611fn run_create_skill(input: SkillCreateInput) -> Result<String, String> {
612    to_pretty_json(execute_create_skill(input)?)
613}
614
615fn execute_create_skill(input: SkillCreateInput) -> Result<String, String> {
616    // 1. Resolve path (e.g., ~/.ternlang/skills/<name>/SKILL.md)
617    let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?;
618    let skill_dir = std::path::PathBuf::from(home).join(".ternlang/skills").join(&input.name);
619    std::fs::create_dir_all(&skill_dir).map_err(|e| e.to_string())?;
620    let skill_file = skill_dir.join("SKILL.md");
621    let logic_file = skill_dir.join("logic.tern");
622
623    // 2. Prepare content
624    let content = format!("description: {}\n\n{}", input.description, input.code);
625
626    // 3. Write
627    std::fs::write(&skill_file, content).map_err(|e| e.to_string())?;
628    std::fs::write(&logic_file, &input.code).map_err(|e| e.to_string())?;
629
630    // 4. Validate (compiler check)
631    let bash_input = BashCommandInput {
632        command: format!("ternlang-cli run {} --compile-only", logic_file.display()),
633        timeout: Some(10),
634        description: Some("Compile-only check for new skill".to_string()),
635        run_in_background: Some(false),
636        validation_state: Some(1),
637        namespace_restrictions: None,
638        isolate_network: None,
639        filesystem_mode: None,
640        allowed_mounts: None,
641    };
642    
643    match execute_bash(bash_input) {
644        Ok(_) => Ok(format!("Skill {} created and validated at {}", input.name, skill_file.display())),
645        Err(e) => {
646            // Rollback on failure
647            let _ = std::fs::remove_dir_all(&skill_dir);
648            Err(format!("Skill creation failed validation: {}", e))
649        }
650    }
651}
652
653fn run_agent(input: AgentInput) -> Result<String, String> {
654    to_pretty_json(execute_agent(input)?)
655}
656
657fn run_tool_search(input: ToolSearchInput) -> Result<String, String> {
658    to_pretty_json(execute_tool_search(input))
659}
660
661fn run_notebook_edit(input: NotebookEditInput) -> Result<String, String> {
662    to_pretty_json(execute_notebook_edit(input)?)
663}
664
665fn run_sleep(input: SleepInput) -> Result<String, String> {
666    to_pretty_json(execute_sleep(input))
667}
668
669fn run_brief(input: BriefInput) -> Result<String, String> {
670    to_pretty_json(execute_brief(input)?)
671}
672
673fn run_config(input: ConfigInput) -> Result<String, String> {
674    to_pretty_json(execute_config(input)?)
675}
676
677fn run_structured_output(input: StructuredOutputInput) -> Result<String, String> {
678    to_pretty_json(execute_structured_output(input))
679}
680
681fn run_repl(input: ReplInput) -> Result<String, String> {
682    to_pretty_json(execute_repl(input)?)
683}
684
685fn run_powershell(input: PowerShellInput) -> Result<String, String> {
686    to_pretty_json(execute_powershell(input).map_err(|error| error.to_string())?)
687}
688
689fn to_pretty_json<T: serde::Serialize>(value: T) -> Result<String, String> {
690    serde_json::to_string_pretty(&value).map_err(|error| error.to_string())
691}
692
693#[allow(clippy::needless_pass_by_value)]
694fn io_to_string(error: std::io::Error) -> String {
695    error.to_string()
696}
697
698#[derive(Debug, Deserialize)]
699struct ReadFileInput {
700    path: String,
701    offset: Option<usize>,
702    limit: Option<usize>,
703}
704
705#[derive(Debug, Deserialize)]
706struct WriteFileInput {
707    path: String,
708    content: String,
709}
710
711#[derive(Debug, Deserialize)]
712struct EditFileInput {
713    path: String,
714    old_string: String,
715    new_string: String,
716    replace_all: Option<bool>,
717}
718
719#[derive(Debug, Deserialize)]
720struct GlobSearchInputValue {
721    pattern: String,
722    path: Option<String>,
723}
724
725#[derive(Debug, Deserialize)]
726struct WebFetchInput {
727    url: String,
728    prompt: String,
729}
730
731#[derive(Debug, Deserialize)]
732struct WebSearchInput {
733    query: String,
734    allowed_domains: Option<Vec<String>>,
735    blocked_domains: Option<Vec<String>>,
736}
737
738#[derive(Debug, Deserialize)]
739struct TodoWriteInput {
740    todos: Vec<TodoItem>,
741}
742
743#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
744struct TodoItem {
745    content: String,
746    #[serde(rename = "activeForm")]
747    active_form: String,
748    status: TodoStatus,
749}
750
751#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
752#[serde(rename_all = "snake_case")]
753enum TodoStatus {
754    Pending,
755    InProgress,
756    Completed,
757}
758
759#[derive(Debug, Deserialize)]
760struct SkillCreateInput {
761    name: String,
762    description: String,
763    code: String,
764}
765
766#[derive(Debug, Deserialize)]
767struct SkillInput {
768    skill: String,
769    args: Option<String>,
770}
771
772#[derive(Debug, Deserialize)]
773struct AgentInput {
774    description: String,
775    prompt: String,
776    subagent_type: Option<String>,
777    name: Option<String>,
778    model: Option<String>,
779}
780
781#[derive(Debug, Deserialize)]
782struct ToolSearchInput {
783    query: String,
784    max_results: Option<usize>,
785}
786
787#[derive(Debug, Deserialize)]
788struct NotebookEditInput {
789    notebook_path: String,
790    cell_id: Option<String>,
791    new_source: Option<String>,
792    cell_type: Option<NotebookCellType>,
793    edit_mode: Option<NotebookEditMode>,
794}
795
796#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
797#[serde(rename_all = "lowercase")]
798enum NotebookCellType {
799    Code,
800    Markdown,
801}
802
803#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
804#[serde(rename_all = "lowercase")]
805enum NotebookEditMode {
806    Replace,
807    Insert,
808    Delete,
809}
810
811#[derive(Debug, Deserialize)]
812struct SleepInput {
813    duration_ms: u64,
814}
815
816#[derive(Debug, Deserialize)]
817struct BriefInput {
818    message: String,
819    attachments: Option<Vec<String>>,
820    status: BriefStatus,
821}
822
823#[derive(Debug, Deserialize)]
824#[serde(rename_all = "lowercase")]
825enum BriefStatus {
826    Normal,
827    Proactive,
828}
829
830#[derive(Debug, Deserialize)]
831struct ConfigInput {
832    setting: String,
833    value: Option<ConfigValue>,
834}
835
836#[derive(Debug, Deserialize)]
837#[serde(untagged)]
838enum ConfigValue {
839    String(String),
840    Bool(bool),
841    Number(f64),
842}
843
844#[derive(Debug, Deserialize)]
845#[serde(transparent)]
846struct StructuredOutputInput(BTreeMap<String, Value>);
847
848#[derive(Debug, Deserialize)]
849struct ReplInput {
850    code: String,
851    language: String,
852    timeout_ms: Option<u64>,
853}
854
855#[derive(Debug, Deserialize)]
856struct PowerShellInput {
857    command: String,
858    timeout: Option<u64>,
859    description: Option<String>,
860    run_in_background: Option<bool>,
861}
862
863#[derive(Debug, Serialize)]
864struct WebFetchOutput {
865    bytes: usize,
866    code: u16,
867    #[serde(rename = "codeText")]
868    code_text: String,
869    result: String,
870    #[serde(rename = "durationMs")]
871    duration_ms: u128,
872    url: String,
873}
874
875#[derive(Debug, Serialize)]
876struct WebSearchOutput {
877    query: String,
878    results: Vec<WebSearchResultItem>,
879    #[serde(rename = "durationSeconds")]
880    duration_seconds: f64,
881}
882
883#[derive(Debug, Serialize)]
884struct TodoWriteOutput {
885    #[serde(rename = "oldTodos")]
886    old_todos: Vec<TodoItem>,
887    #[serde(rename = "newTodos")]
888    new_todos: Vec<TodoItem>,
889    #[serde(rename = "verificationNudgeNeeded")]
890    verification_nudge_needed: Option<bool>,
891}
892
893#[derive(Debug, Serialize)]
894struct SkillOutput {
895    skill: String,
896    path: String,
897    args: Option<String>,
898    description: Option<String>,
899    prompt: String,
900}
901
902#[derive(Debug, Clone, Serialize, Deserialize)]
903struct AgentOutput {
904    #[serde(rename = "agentId")]
905    agent_id: String,
906    name: String,
907    description: String,
908    #[serde(rename = "subagentType")]
909    subagent_type: Option<String>,
910    model: Option<String>,
911    status: String,
912    #[serde(rename = "outputFile")]
913    output_file: String,
914    #[serde(rename = "manifestFile")]
915    manifest_file: String,
916    #[serde(rename = "createdAt")]
917    created_at: String,
918    #[serde(rename = "startedAt", skip_serializing_if = "Option::is_none")]
919    started_at: Option<String>,
920    #[serde(rename = "completedAt", skip_serializing_if = "Option::is_none")]
921    completed_at: Option<String>,
922    #[serde(skip_serializing_if = "Option::is_none")]
923    error: Option<String>,
924}
925
926#[derive(Debug, Clone)]
927struct AgentJob {
928    manifest: AgentOutput,
929    prompt: String,
930    system_prompt: Vec<String>,
931    allowed_tools: BTreeSet<String>,
932}
933
934#[derive(Debug, Serialize)]
935struct ToolSearchOutput {
936    matches: Vec<String>,
937    query: String,
938    normalized_query: String,
939    #[serde(rename = "total_deferred_tools")]
940    total_deferred_tools: usize,
941    #[serde(rename = "pending_mcp_servers")]
942    pending_mcp_servers: Option<Vec<String>>,
943}
944
945#[derive(Debug, Serialize)]
946struct NotebookEditOutput {
947    new_source: String,
948    cell_id: Option<String>,
949    cell_type: Option<NotebookCellType>,
950    language: String,
951    edit_mode: String,
952    error: Option<String>,
953    notebook_path: String,
954    original_file: String,
955    updated_file: String,
956}
957
958#[derive(Debug, Serialize)]
959struct SleepOutput {
960    duration_ms: u64,
961    message: String,
962}
963
964#[derive(Debug, Serialize)]
965struct BriefOutput {
966    message: String,
967    attachments: Option<Vec<ResolvedAttachment>>,
968    #[serde(rename = "sentAt")]
969    sent_at: String,
970}
971
972#[derive(Debug, Serialize)]
973struct ResolvedAttachment {
974    path: String,
975    size: u64,
976    #[serde(rename = "isImage")]
977    is_image: bool,
978}
979
980#[derive(Debug, Serialize)]
981struct ConfigOutput {
982    success: bool,
983    operation: Option<String>,
984    setting: Option<String>,
985    value: Option<Value>,
986    #[serde(rename = "previousValue")]
987    previous_value: Option<Value>,
988    #[serde(rename = "newValue")]
989    new_value: Option<Value>,
990    error: Option<String>,
991}
992
993#[derive(Debug, Serialize)]
994struct StructuredOutputResult {
995    data: String,
996    structured_output: BTreeMap<String, Value>,
997}
998
999#[derive(Debug, Serialize)]
1000struct ReplOutput {
1001    language: String,
1002    stdout: String,
1003    stderr: String,
1004    #[serde(rename = "exitCode")]
1005    exit_code: i32,
1006    #[serde(rename = "durationMs")]
1007    duration_ms: u128,
1008}
1009
1010#[derive(Debug, Serialize)]
1011#[serde(untagged)]
1012enum WebSearchResultItem {
1013    SearchResult {
1014        tool_use_id: String,
1015        content: Vec<SearchHit>,
1016    },
1017    Commentary(String),
1018}
1019
1020#[derive(Debug, Serialize)]
1021struct SearchHit {
1022    title: String,
1023    url: String,
1024}
1025
1026fn execute_web_fetch(input: &WebFetchInput) -> Result<WebFetchOutput, String> {
1027    let started = Instant::now();
1028    let client = build_http_client()?;
1029    let request_url = normalize_fetch_url(&input.url)?;
1030    let response = client
1031        .get(request_url.clone())
1032        .send()
1033        .map_err(|error| error.to_string())?;
1034
1035    let status = response.status();
1036    let final_url = response.url().to_string();
1037    let code = status.as_u16();
1038    let code_text = status.canonical_reason().unwrap_or("Unknown").to_string();
1039    let content_type = response
1040        .headers()
1041        .get(reqwest::header::CONTENT_TYPE)
1042        .and_then(|value| value.to_str().ok())
1043        .unwrap_or_default()
1044        .to_string();
1045    let body = response.text().map_err(|error| error.to_string())?;
1046    let bytes = body.len();
1047    let normalized = normalize_fetched_content(&body, &content_type);
1048    let result = summarize_web_fetch(&final_url, &input.prompt, &normalized, &body, &content_type);
1049
1050    Ok(WebFetchOutput {
1051        bytes,
1052        code,
1053        code_text,
1054        result,
1055        duration_ms: started.elapsed().as_millis(),
1056        url: final_url,
1057    })
1058}
1059
1060fn execute_web_search(input: &WebSearchInput) -> Result<WebSearchOutput, String> {
1061    let started = Instant::now();
1062    let client = build_http_client()?;
1063    let search_url = build_search_url(&input.query)?;
1064    let response = client
1065        .get(search_url)
1066        .send()
1067        .map_err(|error| error.to_string())?;
1068
1069    let final_url = response.url().clone();
1070    let html = response.text().map_err(|error| error.to_string())?;
1071    let mut hits = extract_search_hits(&html);
1072
1073    if hits.is_empty() && final_url.host_str().is_some() {
1074        hits = extract_search_hits_from_generic_links(&html);
1075    }
1076
1077    if let Some(allowed) = input.allowed_domains.as_ref() {
1078        hits.retain(|hit| host_matches_list(&hit.url, allowed));
1079    }
1080    if let Some(blocked) = input.blocked_domains.as_ref() {
1081        hits.retain(|hit| !host_matches_list(&hit.url, blocked));
1082    }
1083
1084    dedupe_hits(&mut hits);
1085    hits.truncate(8);
1086
1087    let summary = if hits.is_empty() {
1088        format!("No web search results matched the query {:?}.", input.query)
1089    } else {
1090        let rendered_hits = hits
1091            .iter()
1092            .map(|hit| format!("- [{}]({})", hit.title, hit.url))
1093            .collect::<Vec<_>>()
1094            .join("\n");
1095        format!(
1096            "Search results for {:?}. Include a Sources section in the final answer.\n{}",
1097            input.query, rendered_hits
1098        )
1099    };
1100
1101    Ok(WebSearchOutput {
1102        query: input.query.clone(),
1103        results: vec![
1104            WebSearchResultItem::Commentary(summary),
1105            WebSearchResultItem::SearchResult {
1106                tool_use_id: String::from("web_search_1"),
1107                content: hits,
1108            },
1109        ],
1110        duration_seconds: started.elapsed().as_secs_f64(),
1111    })
1112}
1113
1114fn build_http_client() -> Result<Client, String> {
1115    Client::builder()
1116        .timeout(Duration::from_secs(20))
1117        .redirect(reqwest::redirect::Policy::limited(10))
1118        .user_agent("clawd-rust-tools/0.1")
1119        .build()
1120        .map_err(|error| error.to_string())
1121}
1122
1123fn normalize_fetch_url(url: &str) -> Result<String, String> {
1124    let parsed = reqwest::Url::parse(url).map_err(|error| error.to_string())?;
1125    if parsed.scheme() == "http" {
1126        let host = parsed.host_str().unwrap_or_default();
1127        if host != "localhost" && host != "127.0.0.1" && host != "::1" {
1128            let mut upgraded = parsed;
1129            upgraded
1130                .set_scheme("https")
1131                .map_err(|()| String::from("failed to upgrade URL to https"))?;
1132            return Ok(upgraded.to_string());
1133        }
1134    }
1135    Ok(parsed.to_string())
1136}
1137
1138fn build_search_url(query: &str) -> Result<reqwest::Url, String> {
1139    if let Ok(base) = std::env::var("CLAWD_WEB_SEARCH_BASE_URL") {
1140        let mut url = reqwest::Url::parse(&base).map_err(|error| error.to_string())?;
1141        url.query_pairs_mut().append_pair("q", query);
1142        return Ok(url);
1143    }
1144
1145    let mut url = reqwest::Url::parse("https://html.duckduckgo.com/html/")
1146        .map_err(|error| error.to_string())?;
1147    url.query_pairs_mut().append_pair("q", query);
1148    Ok(url)
1149}
1150
1151fn normalize_fetched_content(body: &str, content_type: &str) -> String {
1152    if content_type.contains("html") {
1153        html_to_text(body)
1154    } else {
1155        body.trim().to_string()
1156    }
1157}
1158
1159fn summarize_web_fetch(
1160    url: &str,
1161    prompt: &str,
1162    content: &str,
1163    raw_body: &str,
1164    content_type: &str,
1165) -> String {
1166    let lower_prompt = prompt.to_lowercase();
1167    let compact = collapse_whitespace(content);
1168
1169    let detail = if lower_prompt.contains("title") {
1170        extract_title(content, raw_body, content_type).map_or_else(
1171            || preview_text(&compact, 600),
1172            |title| format!("Title: {title}"),
1173        )
1174    } else if lower_prompt.contains("summary") || lower_prompt.contains("summarize") {
1175        preview_text(&compact, 900)
1176    } else {
1177        let preview = preview_text(&compact, 900);
1178        format!("Prompt: {prompt}\nContent preview:\n{preview}")
1179    };
1180
1181    format!("Fetched {url}\n{detail}")
1182}
1183
1184fn extract_title(content: &str, raw_body: &str, content_type: &str) -> Option<String> {
1185    if content_type.contains("html") {
1186        let lowered = raw_body.to_lowercase();
1187        if let Some(start) = lowered.find("<title>") {
1188            let after = start + "<title>".len();
1189            if let Some(end_rel) = lowered[after..].find("</title>") {
1190                let title =
1191                    collapse_whitespace(&decode_html_entities(&raw_body[after..after + end_rel]));
1192                if !title.is_empty() {
1193                    return Some(title);
1194                }
1195            }
1196        }
1197    }
1198
1199    for line in content.lines() {
1200        let trimmed = line.trim();
1201        if !trimmed.is_empty() {
1202            return Some(trimmed.to_string());
1203        }
1204    }
1205    None
1206}
1207
1208fn html_to_text(html: &str) -> String {
1209    let mut text = String::with_capacity(html.len());
1210    let mut in_tag = false;
1211    let mut previous_was_space = false;
1212
1213    for ch in html.chars() {
1214        match ch {
1215            '<' => in_tag = true,
1216            '>' => in_tag = false,
1217            _ if in_tag => {}
1218            '&' => {
1219                text.push('&');
1220                previous_was_space = false;
1221            }
1222            ch if ch.is_whitespace() => {
1223                if !previous_was_space {
1224                    text.push(' ');
1225                    previous_was_space = true;
1226                }
1227            }
1228            _ => {
1229                text.push(ch);
1230                previous_was_space = false;
1231            }
1232        }
1233    }
1234
1235    collapse_whitespace(&decode_html_entities(&text))
1236}
1237
1238fn decode_html_entities(input: &str) -> String {
1239    input
1240        .replace("&amp;", "&")
1241        .replace("&lt;", "<")
1242        .replace("&gt;", ">")
1243        .replace("&quot;", "\"")
1244        .replace("&#39;", "'")
1245        .replace("&nbsp;", " ")
1246}
1247
1248fn collapse_whitespace(input: &str) -> String {
1249    input.split_whitespace().collect::<Vec<_>>().join(" ")
1250}
1251
1252fn preview_text(input: &str, max_chars: usize) -> String {
1253    if input.chars().count() <= max_chars {
1254        return input.to_string();
1255    }
1256    let shortened = input.chars().take(max_chars).collect::<String>();
1257    format!("{}…", shortened.trim_end())
1258}
1259
1260fn extract_search_hits(html: &str) -> Vec<SearchHit> {
1261    let mut hits = Vec::new();
1262    let mut remaining = html;
1263
1264    while let Some(anchor_start) = remaining.find("result__a") {
1265        let after_class = &remaining[anchor_start..];
1266        let Some(href_idx) = after_class.find("href=") else {
1267            remaining = &after_class[1..];
1268            continue;
1269        };
1270        let href_slice = &after_class[href_idx + 5..];
1271        let Some((url, rest)) = extract_quoted_value(href_slice) else {
1272            remaining = &after_class[1..];
1273            continue;
1274        };
1275        let Some(close_tag_idx) = rest.find('>') else {
1276            remaining = &after_class[1..];
1277            continue;
1278        };
1279        let after_tag = &rest[close_tag_idx + 1..];
1280        let Some(end_anchor_idx) = after_tag.find("</a>") else {
1281            remaining = &after_tag[1..];
1282            continue;
1283        };
1284        let title = html_to_text(&after_tag[..end_anchor_idx]);
1285        if let Some(decoded_url) = decode_duckduckgo_redirect(&url) {
1286            hits.push(SearchHit {
1287                title: title.trim().to_string(),
1288                url: decoded_url,
1289            });
1290        }
1291        remaining = &after_tag[end_anchor_idx + 4..];
1292    }
1293
1294    hits
1295}
1296
1297fn extract_search_hits_from_generic_links(html: &str) -> Vec<SearchHit> {
1298    let mut hits = Vec::new();
1299    let mut remaining = html;
1300
1301    while let Some(anchor_start) = remaining.find("<a") {
1302        let after_anchor = &remaining[anchor_start..];
1303        let Some(href_idx) = after_anchor.find("href=") else {
1304            remaining = &after_anchor[2..];
1305            continue;
1306        };
1307        let href_slice = &after_anchor[href_idx + 5..];
1308        let Some((url, rest)) = extract_quoted_value(href_slice) else {
1309            remaining = &after_anchor[2..];
1310            continue;
1311        };
1312        let Some(close_tag_idx) = rest.find('>') else {
1313            remaining = &after_anchor[2..];
1314            continue;
1315        };
1316        let after_tag = &rest[close_tag_idx + 1..];
1317        let Some(end_anchor_idx) = after_tag.find("</a>") else {
1318            remaining = &after_anchor[2..];
1319            continue;
1320        };
1321        let title = html_to_text(&after_tag[..end_anchor_idx]);
1322        if title.trim().is_empty() {
1323            remaining = &after_tag[end_anchor_idx + 4..];
1324            continue;
1325        }
1326        let decoded_url = decode_duckduckgo_redirect(&url).unwrap_or(url);
1327        if decoded_url.starts_with("http://") || decoded_url.starts_with("https://") {
1328            hits.push(SearchHit {
1329                title: title.trim().to_string(),
1330                url: decoded_url,
1331            });
1332        }
1333        remaining = &after_tag[end_anchor_idx + 4..];
1334    }
1335
1336    hits
1337}
1338
1339fn extract_quoted_value(input: &str) -> Option<(String, &str)> {
1340    let quote = input.chars().next()?;
1341    if quote != '"' && quote != '\'' {
1342        return None;
1343    }
1344    let rest = &input[quote.len_utf8()..];
1345    let end = rest.find(quote)?;
1346    Some((rest[..end].to_string(), &rest[end + quote.len_utf8()..]))
1347}
1348
1349fn decode_duckduckgo_redirect(url: &str) -> Option<String> {
1350    if url.starts_with("http://") || url.starts_with("https://") {
1351        return Some(html_entity_decode_url(url));
1352    }
1353
1354    let joined = if url.starts_with("//") {
1355        format!("https:{url}")
1356    } else if url.starts_with('/') {
1357        format!("https://duckduckgo.com{url}")
1358    } else {
1359        return None;
1360    };
1361
1362    let parsed = reqwest::Url::parse(&joined).ok()?;
1363    if parsed.path() == "/l/" || parsed.path() == "/l" {
1364        for (key, value) in parsed.query_pairs() {
1365            if key == "uddg" {
1366                return Some(html_entity_decode_url(value.as_ref()));
1367            }
1368        }
1369    }
1370    Some(joined)
1371}
1372
1373fn html_entity_decode_url(url: &str) -> String {
1374    decode_html_entities(url)
1375}
1376
1377fn host_matches_list(url: &str, domains: &[String]) -> bool {
1378    let Ok(parsed) = reqwest::Url::parse(url) else {
1379        return false;
1380    };
1381    let Some(host) = parsed.host_str() else {
1382        return false;
1383    };
1384    let host = host.to_ascii_lowercase();
1385    domains.iter().any(|domain| {
1386        let normalized = normalize_domain_filter(domain);
1387        !normalized.is_empty() && (host == normalized || host.ends_with(&format!(".{normalized}")))
1388    })
1389}
1390
1391fn normalize_domain_filter(domain: &str) -> String {
1392    let trimmed = domain.trim();
1393    let candidate = reqwest::Url::parse(trimmed)
1394        .ok()
1395        .and_then(|url| url.host_str().map(str::to_string))
1396        .unwrap_or_else(|| trimmed.to_string());
1397    candidate
1398        .trim()
1399        .trim_start_matches('.')
1400        .trim_end_matches('/')
1401        .to_ascii_lowercase()
1402}
1403
1404fn dedupe_hits(hits: &mut Vec<SearchHit>) {
1405    let mut seen = BTreeSet::new();
1406    hits.retain(|hit| seen.insert(hit.url.clone()));
1407}
1408
1409fn execute_todo_write(input: TodoWriteInput) -> Result<TodoWriteOutput, String> {
1410    validate_todos(&input.todos)?;
1411    let store_path = todo_store_path()?;
1412    let old_todos = if store_path.exists() {
1413        serde_json::from_str::<Vec<TodoItem>>(
1414            &std::fs::read_to_string(&store_path).map_err(|error| error.to_string())?,
1415        )
1416        .map_err(|error| error.to_string())?
1417    } else {
1418        Vec::new()
1419    };
1420
1421    let all_done = input
1422        .todos
1423        .iter()
1424        .all(|todo| matches!(todo.status, TodoStatus::Completed));
1425    let persisted = if all_done {
1426        Vec::new()
1427    } else {
1428        input.todos.clone()
1429    };
1430
1431    if let Some(parent) = store_path.parent() {
1432        std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
1433    }
1434    std::fs::write(
1435        &store_path,
1436        serde_json::to_string_pretty(&persisted).map_err(|error| error.to_string())?,
1437    )
1438    .map_err(|error| error.to_string())?;
1439
1440    let verification_nudge_needed = (all_done
1441        && input.todos.len() >= 3
1442        && !input
1443            .todos
1444            .iter()
1445            .any(|todo| todo.content.to_lowercase().contains("verif")))
1446    .then_some(true);
1447
1448    Ok(TodoWriteOutput {
1449        old_todos,
1450        new_todos: input.todos,
1451        verification_nudge_needed,
1452    })
1453}
1454
1455fn execute_skill(input: SkillInput) -> Result<SkillOutput, String> {
1456    let skill_path = resolve_skill_path(&input.skill)?;
1457    let prompt = std::fs::read_to_string(&skill_path).map_err(|error| error.to_string())?;
1458    let description = parse_skill_description(&prompt);
1459
1460    Ok(SkillOutput {
1461        skill: input.skill,
1462        path: skill_path.display().to_string(),
1463        args: input.args,
1464        description,
1465        prompt,
1466    })
1467}
1468
1469fn validate_todos(todos: &[TodoItem]) -> Result<(), String> {
1470    if todos.is_empty() {
1471        return Err(String::from("todos must not be empty"));
1472    }
1473    // Allow multiple in_progress items for parallel workflows
1474    if todos.iter().any(|todo| todo.content.trim().is_empty()) {
1475        return Err(String::from("todo content must not be empty"));
1476    }
1477    if todos.iter().any(|todo| todo.active_form.trim().is_empty()) {
1478        return Err(String::from("todo activeForm must not be empty"));
1479    }
1480    Ok(())
1481}
1482
1483fn todo_store_path() -> Result<std::path::PathBuf, String> {
1484    if let Ok(path) = std::env::var("CLAWD_TODO_STORE") {
1485        return Ok(std::path::PathBuf::from(path));
1486    }
1487    let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
1488    Ok(cwd.join(".clawd-todos.json"))
1489}
1490
1491fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
1492    let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
1493    if requested.is_empty() {
1494        return Err(String::from("skill must not be empty"));
1495    }
1496
1497    let mut candidates = Vec::new();
1498    if let Ok(codex_home) = std::env::var("CODEX_HOME") {
1499        candidates.push(std::path::PathBuf::from(codex_home).join("skills"));
1500    }
1501    candidates.push(std::path::PathBuf::from("/home/bellman/.codex/skills"));
1502
1503    for root in candidates {
1504        let direct = root.join(requested).join("SKILL.md");
1505        if direct.exists() {
1506            return Ok(direct);
1507        }
1508
1509        if let Ok(entries) = std::fs::read_dir(&root) {
1510            for entry in entries.flatten() {
1511                let path = entry.path().join("SKILL.md");
1512                if !path.exists() {
1513                    continue;
1514                }
1515                if entry
1516                    .file_name()
1517                    .to_string_lossy()
1518                    .eq_ignore_ascii_case(requested)
1519                {
1520                    return Ok(path);
1521                }
1522            }
1523        }
1524    }
1525
1526    Err(format!("unknown skill: {requested}"))
1527}
1528
1529const DEFAULT_AGENT_MODEL: &str = "ternlang-opus-4-6";
1530const DEFAULT_AGENT_SYSTEM_DATE: &str = "2026-03-31";
1531const DEFAULT_AGENT_MAX_ITERATIONS: usize = 32;
1532
1533fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
1534    execute_agent_with_spawn(input, spawn_agent_job)
1535}
1536
1537fn execute_agent_with_spawn<F>(input: AgentInput, spawn_fn: F) -> Result<AgentOutput, String>
1538where
1539    F: FnOnce(AgentJob) -> Result<(), String>,
1540{
1541    if input.description.trim().is_empty() {
1542        return Err(String::from("description must not be empty"));
1543    }
1544    if input.prompt.trim().is_empty() {
1545        return Err(String::from("prompt must not be empty"));
1546    }
1547
1548    let agent_id = make_agent_id();
1549    let output_dir = agent_store_dir()?;
1550    std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?;
1551    let output_file = output_dir.join(format!("{agent_id}.md"));
1552    let manifest_file = output_dir.join(format!("{agent_id}.json"));
1553    let normalized_subagent_type = normalize_subagent_type(input.subagent_type.as_deref());
1554    let model = resolve_agent_model(input.model.as_deref());
1555    let agent_name = input
1556        .name
1557        .as_deref()
1558        .map(slugify_agent_name)
1559        .filter(|name| !name.is_empty())
1560        .unwrap_or_else(|| slugify_agent_name(&input.description));
1561    let created_at = iso8601_now();
1562    let system_prompt = build_agent_system_prompt(&normalized_subagent_type)?;
1563    let allowed_tools = allowed_tools_for_subagent(&normalized_subagent_type);
1564
1565    let output_contents = format!(
1566        "# Agent Task
1567
1568- id: {}
1569- name: {}
1570- description: {}
1571- subagent_type: {}
1572- created_at: {}
1573
1574## Prompt
1575
1576{}
1577",
1578        agent_id, agent_name, input.description, normalized_subagent_type, created_at, input.prompt
1579    );
1580    std::fs::write(&output_file, output_contents).map_err(|error| error.to_string())?;
1581
1582    let manifest = AgentOutput {
1583        agent_id,
1584        name: agent_name,
1585        description: input.description,
1586        subagent_type: Some(normalized_subagent_type),
1587        model: Some(model),
1588        status: String::from("running"),
1589        output_file: output_file.display().to_string(),
1590        manifest_file: manifest_file.display().to_string(),
1591        created_at: created_at.clone(),
1592        started_at: Some(created_at),
1593        completed_at: None,
1594        error: None,
1595    };
1596    write_agent_manifest(&manifest)?;
1597
1598    let manifest_for_spawn = manifest.clone();
1599    let job = AgentJob {
1600        manifest: manifest_for_spawn,
1601        prompt: input.prompt,
1602        system_prompt,
1603        allowed_tools,
1604    };
1605    if let Err(error) = spawn_fn(job) {
1606        let error = format!("failed to spawn sub-agent: {error}");
1607        persist_agent_terminal_state(&manifest, "failed", None, Some(error.clone()))?;
1608        return Err(error);
1609    }
1610
1611    Ok(manifest)
1612}
1613
1614fn spawn_agent_job(job: AgentJob) -> Result<(), String> {
1615    let thread_name = format!("clawd-agent-{}", job.manifest.agent_id);
1616    std::thread::Builder::new()
1617        .name(thread_name)
1618        .spawn(move || {
1619            let result =
1620                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run_agent_job(&job)));
1621            match result {
1622                Ok(Ok(())) => {}
1623                Ok(Err(error)) => {
1624                    let _ =
1625                        persist_agent_terminal_state(&job.manifest, "failed", None, Some(error));
1626                }
1627                Err(_) => {
1628                    let _ = persist_agent_terminal_state(
1629                        &job.manifest,
1630                        "failed",
1631                        None,
1632                        Some(String::from("sub-agent thread panicked")),
1633                    );
1634                }
1635            }
1636        })
1637        .map(|_| ())
1638        .map_err(|error| error.to_string())
1639}
1640
1641fn run_agent_job(job: &AgentJob) -> Result<(), String> {
1642    let mut runtime = build_agent_runtime(job)?.with_max_iterations(DEFAULT_AGENT_MAX_ITERATIONS);
1643    let summary = runtime
1644        .run_turn(job.prompt.clone(), None)
1645        .map_err(|error| error.to_string())?;
1646    let final_text = final_assistant_text(&summary);
1647    persist_agent_terminal_state(&job.manifest, "completed", Some(final_text.as_str()), None)
1648}
1649
1650fn build_agent_runtime(
1651    job: &AgentJob,
1652) -> Result<ConversationRuntime<TernlangRuntimeClient, SubagentToolExecutor>, String> {
1653    let model = job
1654        .manifest
1655        .model
1656        .clone()
1657        .unwrap_or_else(|| DEFAULT_AGENT_MODEL.to_string());
1658    let allowed_tools = job.allowed_tools.clone();
1659    let api_client = TernlangRuntimeClient::new(model, allowed_tools.clone())?;
1660    let tool_executor = SubagentToolExecutor::new(allowed_tools);
1661    Ok(ConversationRuntime::new(
1662        Session::new(),
1663        api_client,
1664        tool_executor,
1665        agent_permission_policy(),
1666        job.system_prompt.clone(),
1667    ))
1668}
1669
1670fn build_agent_system_prompt(subagent_type: &str) -> Result<Vec<String>, String> {
1671    let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
1672    let mut prompt = load_system_prompt(
1673        cwd,
1674        DEFAULT_AGENT_SYSTEM_DATE.to_string(),
1675        std::env::consts::OS,
1676        "unknown",
1677    )
1678    .map_err(|error| error.to_string())?;
1679    prompt.push(format!(
1680        "You are a background sub-agent of type `{subagent_type}`. Work only on the delegated task, use only the tools available to you, do not ask the user questions, and finish with a concise result."
1681    ));
1682    Ok(prompt)
1683}
1684
1685fn resolve_agent_model(model: Option<&str>) -> String {
1686    model
1687        .map(str::trim)
1688        .filter(|model| !model.is_empty())
1689        .unwrap_or(DEFAULT_AGENT_MODEL)
1690        .to_string()
1691}
1692
1693fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
1694    let tools = match subagent_type {
1695        "Explore" => vec![
1696            "read_file",
1697            "glob_search",
1698            "grep_search",
1699            "WebFetch",
1700            "WebSearch",
1701            "ToolSearch",
1702            "Skill",
1703            "StructuredOutput",
1704        ],
1705        "Plan" => vec![
1706            "read_file",
1707            "glob_search",
1708            "grep_search",
1709            "WebFetch",
1710            "WebSearch",
1711            "ToolSearch",
1712            "Skill",
1713            "TodoWrite",
1714            "StructuredOutput",
1715            "SendUserMessage",
1716        ],
1717        "Verification" => vec![
1718            "bash",
1719            "read_file",
1720            "glob_search",
1721            "grep_search",
1722            "WebFetch",
1723            "WebSearch",
1724            "ToolSearch",
1725            "TodoWrite",
1726            "StructuredOutput",
1727            "SendUserMessage",
1728            "PowerShell",
1729        ],
1730        "claw-code-guide" => vec![
1731            "read_file",
1732            "glob_search",
1733            "grep_search",
1734            "WebFetch",
1735            "WebSearch",
1736            "ToolSearch",
1737            "Skill",
1738            "StructuredOutput",
1739            "SendUserMessage",
1740        ],
1741        "statusline-setup" => vec![
1742            "bash",
1743            "read_file",
1744            "write_file",
1745            "edit_file",
1746            "glob_search",
1747            "grep_search",
1748            "ToolSearch",
1749        ],
1750        _ => vec![
1751            "bash",
1752            "read_file",
1753            "write_file",
1754            "edit_file",
1755            "glob_search",
1756            "grep_search",
1757            "WebFetch",
1758            "WebSearch",
1759            "TodoWrite",
1760            "Skill",
1761            "ToolSearch",
1762            "NotebookEdit",
1763            "Sleep",
1764            "SendUserMessage",
1765            "Config",
1766            "StructuredOutput",
1767            "REPL",
1768            "PowerShell",
1769        ],
1770    };
1771    tools.into_iter().map(str::to_string).collect()
1772}
1773
1774fn agent_permission_policy() -> PermissionPolicy {
1775    mvp_tool_specs().into_iter().fold(
1776        PermissionPolicy::new(PermissionMode::DangerFullAccess),
1777        |policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission),
1778    )
1779}
1780
1781fn write_agent_manifest(manifest: &AgentOutput) -> Result<(), String> {
1782    std::fs::write(
1783        &manifest.manifest_file,
1784        serde_json::to_string_pretty(manifest).map_err(|error| error.to_string())?,
1785    )
1786    .map_err(|error| error.to_string())
1787}
1788
1789fn persist_agent_terminal_state(
1790    manifest: &AgentOutput,
1791    status: &str,
1792    result: Option<&str>,
1793    error: Option<String>,
1794) -> Result<(), String> {
1795    append_agent_output(
1796        &manifest.output_file,
1797        &format_agent_terminal_output(status, result, error.as_deref()),
1798    )?;
1799    let mut next_manifest = manifest.clone();
1800    next_manifest.status = status.to_string();
1801    next_manifest.completed_at = Some(iso8601_now());
1802    next_manifest.error = error;
1803    write_agent_manifest(&next_manifest)
1804}
1805
1806fn append_agent_output(path: &str, suffix: &str) -> Result<(), String> {
1807    use std::io::Write as _;
1808
1809    let mut file = std::fs::OpenOptions::new()
1810        .append(true)
1811        .open(path)
1812        .map_err(|error| error.to_string())?;
1813    file.write_all(suffix.as_bytes())
1814        .map_err(|error| error.to_string())
1815}
1816
1817fn format_agent_terminal_output(status: &str, result: Option<&str>, error: Option<&str>) -> String {
1818    let mut sections = vec![format!("\n## Result\n\n- status: {status}\n")];
1819    if let Some(result) = result.filter(|value| !value.trim().is_empty()) {
1820        sections.push(format!("\n### Final response\n\n{}\n", result.trim()));
1821    }
1822    if let Some(error) = error.filter(|value| !value.trim().is_empty()) {
1823        sections.push(format!("\n### Error\n\n{}\n", error.trim()));
1824    }
1825    sections.join("")
1826}
1827
1828struct TernlangRuntimeClient {
1829    runtime: tokio::runtime::Runtime,
1830    client: TernlangClient,
1831    model: String,
1832    allowed_tools: BTreeSet<String>,
1833}
1834
1835impl TernlangRuntimeClient {
1836    fn new(model: String, allowed_tools: BTreeSet<String>) -> Result<Self, String> {
1837        let client = TernlangClient::from_env()
1838            .map_err(|error| error.to_string())?
1839            .with_base_url(read_base_url());
1840        Ok(Self {
1841            runtime: tokio::runtime::Runtime::new().map_err(|error| error.to_string())?,
1842            client,
1843            model,
1844            allowed_tools,
1845        })
1846    }
1847}
1848
1849impl ApiClient for TernlangRuntimeClient {
1850    fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
1851        let tools = tool_specs_for_allowed_tools(Some(&self.allowed_tools))
1852            .into_iter()
1853            .map(|spec| ToolDefinition {
1854                name: spec.name.to_string(),
1855                description: Some(spec.description.to_string()),
1856                input_schema: spec.input_schema,
1857            })
1858            .collect::<Vec<_>>();
1859        let message_request = MessageRequest {
1860            model: self.model.clone(),
1861            max_tokens: Some(32_000),
1862            messages: convert_messages(&request.messages),
1863            system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
1864            tools: (!tools.is_empty()).then_some(tools),
1865            tool_choice: (!self.allowed_tools.is_empty()).then_some(ToolChoice::Auto),
1866            stream: true,
1867        };
1868
1869        self.runtime.block_on(async {
1870            let mut stream = self
1871                .client
1872                .stream_message(&message_request)
1873                .await
1874                .map_err(|error| RuntimeError::new(error.to_string()))?;
1875            let mut events = Vec::new();
1876            let mut pending_tool: Option<(String, String, String)> = None;
1877            let mut saw_stop = false;
1878
1879            while let Some(event) = stream
1880                .next_event()
1881                .await
1882                .map_err(|error| RuntimeError::new(error.to_string()))?
1883            {
1884                match event {
1885                    ApiStreamEvent::MessageStart(start) => {
1886                        for block in start.message.content {
1887                            push_output_block(block, &mut events, &mut pending_tool, true);
1888                        }
1889                    }
1890                    ApiStreamEvent::ContentBlockStart(start) => {
1891                        push_output_block(
1892                            start.content_block,
1893                            &mut events,
1894                            &mut pending_tool,
1895                            true,
1896                        );
1897                    }
1898                    ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
1899                        ContentBlockDelta::TextDelta { text } => {
1900                            if !text.is_empty() {
1901                                events.push(AssistantEvent::TextDelta(text));
1902                            }
1903                        }
1904                        ContentBlockDelta::InputJsonDelta { partial_json } => {
1905                            if let Some((_, _, input)) = &mut pending_tool {
1906                                input.push_str(&partial_json);
1907                            }
1908                        }
1909                    },
1910                    ApiStreamEvent::ContentBlockStop(_) => {
1911                        if let Some((id, name, input)) = pending_tool.take() {
1912                            events.push(AssistantEvent::ToolUse { id, name, input });
1913                        }
1914                    }
1915                    ApiStreamEvent::MessageDelta(delta) => {
1916                        events.push(AssistantEvent::Usage(TokenUsage {
1917                            input_tokens: delta.usage.input_tokens,
1918                            output_tokens: delta.usage.output_tokens,
1919                            cache_creation_input_tokens: 0,
1920                            cache_read_input_tokens: 0,
1921                        }));
1922                    }
1923                    ApiStreamEvent::MessageStop(_) => {
1924                        saw_stop = true;
1925                        events.push(AssistantEvent::MessageStop);
1926                    }
1927                }
1928            }
1929
1930            if !saw_stop
1931                && events.iter().any(|event| {
1932                    matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty())
1933                        || matches!(event, AssistantEvent::ToolUse { .. })
1934                })
1935            {
1936                events.push(AssistantEvent::MessageStop);
1937            }
1938
1939            if events
1940                .iter()
1941                .any(|event| matches!(event, AssistantEvent::MessageStop))
1942            {
1943                return Ok(events);
1944            }
1945
1946            let response = self
1947                .client
1948                .send_message(&MessageRequest {
1949                    stream: false,
1950                    ..message_request.clone()
1951                })
1952                .await
1953                .map_err(|error| RuntimeError::new(error.to_string()))?;
1954            Ok(response_to_events(response))
1955        })
1956    }
1957}
1958
1959struct SubagentToolExecutor {
1960    allowed_tools: BTreeSet<String>,
1961}
1962
1963impl SubagentToolExecutor {
1964    fn new(allowed_tools: BTreeSet<String>) -> Self {
1965        Self { allowed_tools }
1966    }
1967}
1968
1969impl ToolExecutor for SubagentToolExecutor {
1970    fn execute(&mut self, tool_name: &str, input: &str) -> Result<runtime::ToolResult, ToolError> {
1971        if !self.allowed_tools.contains(tool_name) {
1972            return Err(ToolError::new(format!(
1973                "tool `{tool_name}` is not enabled for this sub-agent"
1974            )));
1975        }
1976        let value = serde_json::from_str(input)
1977            .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
1978        let res = execute_tool(tool_name, &value).map_err(ToolError::new)?;
1979        Ok(runtime::ToolResult {
1980            output: res.output,
1981            state: res.state,
1982        })
1983    }
1984
1985    fn query_memory(&mut self, query: &str) -> Result<String, ToolError> {
1986        let input = serde_json::json!({
1987            "action": "search_nodes",
1988            "query": query
1989        });
1990        let res = execute_tool("Memory", &input).map_err(ToolError::new)?;
1991        Ok(res.output)
1992    }
1993}
1994
1995fn tool_specs_for_allowed_tools(allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolSpec> {
1996    mvp_tool_specs()
1997        .into_iter()
1998        .filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
1999        .collect()
2000}
2001
2002fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
2003    messages
2004        .iter()
2005        .filter_map(|message| {
2006            let role = match message.role {
2007                MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
2008                MessageRole::Assistant => "assistant",
2009            };
2010            let content = message
2011                .blocks
2012                .iter()
2013                .map(|block| match block {
2014                    ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
2015                    ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
2016                        id: id.clone(),
2017                        name: name.clone(),
2018                        input: serde_json::from_str(input)
2019                            .unwrap_or_else(|_| serde_json::json!({ "raw": input })),
2020                    },
2021                    ContentBlock::ToolResult {
2022                        tool_use_id,
2023                        output,
2024                        is_error,
2025                        ..
2026                    } => InputContentBlock::ToolResult {
2027                        tool_use_id: tool_use_id.clone(),
2028                        content: vec![ToolResultContentBlock::Text {
2029                            text: output.clone(),
2030                        }],
2031                        is_error: *is_error,
2032                    },
2033                })
2034                .collect::<Vec<_>>();
2035            (!content.is_empty()).then(|| InputMessage {
2036                role: role.to_string(),
2037                content,
2038            })
2039        })
2040        .collect()
2041}
2042
2043fn push_output_block(
2044    block: OutputContentBlock,
2045    events: &mut Vec<AssistantEvent>,
2046    pending_tool: &mut Option<(String, String, String)>,
2047    streaming_tool_input: bool,
2048) {
2049    match block {
2050        OutputContentBlock::Text { text } => {
2051            if !text.is_empty() {
2052                events.push(AssistantEvent::TextDelta(text));
2053            }
2054        }
2055        OutputContentBlock::ToolUse { id, name, input } => {
2056            let initial_input = if streaming_tool_input
2057                && input.is_object()
2058                && input.as_object().is_some_and(serde_json::Map::is_empty)
2059            {
2060                String::new()
2061            } else {
2062                input.to_string()
2063            };
2064            *pending_tool = Some((id, name, initial_input));
2065        }
2066    }
2067}
2068
2069fn response_to_events(response: MessageResponse) -> Vec<AssistantEvent> {
2070    let mut events = Vec::new();
2071    let mut pending_tool = None;
2072
2073    for block in response.content {
2074        push_output_block(block, &mut events, &mut pending_tool, false);
2075        if let Some((id, name, input)) = pending_tool.take() {
2076            events.push(AssistantEvent::ToolUse { id, name, input });
2077        }
2078    }
2079
2080    events.push(AssistantEvent::Usage(TokenUsage {
2081        input_tokens: response.usage.input_tokens,
2082        output_tokens: response.usage.output_tokens,
2083        cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
2084        cache_read_input_tokens: response.usage.cache_read_input_tokens,
2085    }));
2086    events.push(AssistantEvent::MessageStop);
2087    events
2088}
2089
2090fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
2091    summary
2092        .assistant_messages
2093        .last()
2094        .map(|message| {
2095            message
2096                .blocks
2097                .iter()
2098                .filter_map(|block| match block {
2099                    ContentBlock::Text { text } => Some(text.as_str()),
2100                    _ => None,
2101                })
2102                .collect::<Vec<_>>()
2103                .join("")
2104        })
2105        .unwrap_or_default()
2106}
2107
2108#[allow(clippy::needless_pass_by_value)]
2109fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput {
2110    let deferred = deferred_tool_specs();
2111    let max_results = input.max_results.unwrap_or(5).max(1);
2112    let query = input.query.trim().to_string();
2113    let normalized_query = normalize_tool_search_query(&query);
2114    let matches = search_tool_specs(&query, max_results, &deferred);
2115
2116    ToolSearchOutput {
2117        matches,
2118        query,
2119        normalized_query,
2120        total_deferred_tools: deferred.len(),
2121        pending_mcp_servers: None,
2122    }
2123}
2124
2125fn deferred_tool_specs() -> Vec<ToolSpec> {
2126    mvp_tool_specs()
2127        .into_iter()
2128        .filter(|spec| {
2129            !matches!(
2130                spec.name,
2131                "bash" | "read_file" | "write_file" | "edit_file" | "glob_search" | "grep_search"
2132            )
2133        })
2134        .collect()
2135}
2136
2137fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec<String> {
2138    let lowered = query.to_lowercase();
2139    if let Some(selection) = lowered.strip_prefix("select:") {
2140        return selection
2141            .split(',')
2142            .map(str::trim)
2143            .filter(|part| !part.is_empty())
2144            .filter_map(|wanted| {
2145                let wanted = canonical_tool_token(wanted);
2146                specs
2147                    .iter()
2148                    .find(|spec| canonical_tool_token(spec.name) == wanted)
2149                    .map(|spec| spec.name.to_string())
2150            })
2151            .take(max_results)
2152            .collect();
2153    }
2154
2155    let mut required = Vec::new();
2156    let mut optional = Vec::new();
2157    for term in lowered.split_whitespace() {
2158        if let Some(rest) = term.strip_prefix('+') {
2159            if !rest.is_empty() {
2160                required.push(rest);
2161            }
2162        } else {
2163            optional.push(term);
2164        }
2165    }
2166    let terms = if required.is_empty() {
2167        optional.clone()
2168    } else {
2169        required.iter().chain(optional.iter()).copied().collect()
2170    };
2171
2172    let mut scored = specs
2173        .iter()
2174        .filter_map(|spec| {
2175            let name = spec.name.to_lowercase();
2176            let canonical_name = canonical_tool_token(spec.name);
2177            let normalized_description = normalize_tool_search_query(spec.description);
2178            let haystack = format!(
2179                "{name} {} {canonical_name}",
2180                spec.description.to_lowercase()
2181            );
2182            let normalized_haystack = format!("{canonical_name} {normalized_description}");
2183            if required.iter().any(|term| !haystack.contains(term)) {
2184                return None;
2185            }
2186
2187            let mut score = 0_i32;
2188            for term in &terms {
2189                let canonical_term = canonical_tool_token(term);
2190                if haystack.contains(term) {
2191                    score += 2;
2192                }
2193                if name == *term {
2194                    score += 8;
2195                }
2196                if name.contains(term) {
2197                    score += 4;
2198                }
2199                if canonical_name == canonical_term {
2200                    score += 12;
2201                }
2202                if normalized_haystack.contains(&canonical_term) {
2203                    score += 3;
2204                }
2205            }
2206
2207            if score == 0 && !lowered.is_empty() {
2208                return None;
2209            }
2210            Some((score, spec.name.to_string()))
2211        })
2212        .collect::<Vec<_>>();
2213
2214    scored.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
2215    scored
2216        .into_iter()
2217        .map(|(_, name)| name)
2218        .take(max_results)
2219        .collect()
2220}
2221
2222fn normalize_tool_search_query(query: &str) -> String {
2223    query
2224        .trim()
2225        .split(|ch: char| ch.is_whitespace() || ch == ',')
2226        .filter(|term| !term.is_empty())
2227        .map(canonical_tool_token)
2228        .collect::<Vec<_>>()
2229        .join(" ")
2230}
2231
2232fn canonical_tool_token(value: &str) -> String {
2233    let mut canonical = value
2234        .chars()
2235        .filter(char::is_ascii_alphanumeric)
2236        .flat_map(char::to_lowercase)
2237        .collect::<String>();
2238    if let Some(stripped) = canonical.strip_suffix("tool") {
2239        canonical = stripped.to_string();
2240    }
2241    canonical
2242}
2243
2244fn agent_store_dir() -> Result<std::path::PathBuf, String> {
2245    if let Ok(path) = std::env::var("CLAWD_AGENT_STORE") {
2246        return Ok(std::path::PathBuf::from(path));
2247    }
2248    let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
2249    if let Some(workspace_root) = cwd.ancestors().nth(2) {
2250        return Ok(workspace_root.join(".clawd-agents"));
2251    }
2252    Ok(cwd.join(".clawd-agents"))
2253}
2254
2255fn make_agent_id() -> String {
2256    let nanos = std::time::SystemTime::now()
2257        .duration_since(std::time::UNIX_EPOCH)
2258        .unwrap_or_default()
2259        .as_nanos();
2260    format!("agent-{nanos}")
2261}
2262
2263fn slugify_agent_name(description: &str) -> String {
2264    let mut out = description
2265        .chars()
2266        .map(|ch| {
2267            if ch.is_ascii_alphanumeric() {
2268                ch.to_ascii_lowercase()
2269            } else {
2270                '-'
2271            }
2272        })
2273        .collect::<String>();
2274    while out.contains("--") {
2275        out = out.replace("--", "-");
2276    }
2277    out.trim_matches('-').chars().take(32).collect()
2278}
2279
2280fn normalize_subagent_type(subagent_type: Option<&str>) -> String {
2281    let trimmed = subagent_type.map(str::trim).unwrap_or_default();
2282    if trimmed.is_empty() {
2283        return String::from("general-purpose");
2284    }
2285
2286    match canonical_tool_token(trimmed).as_str() {
2287        "general" | "generalpurpose" | "generalpurposeagent" => String::from("general-purpose"),
2288        "explore" | "explorer" | "exploreagent" => String::from("Explore"),
2289        "plan" | "planagent" => String::from("Plan"),
2290        "verification" | "verificationagent" | "verify" | "verifier" => {
2291            String::from("Verification")
2292        }
2293        "ternlangcodeguide" | "ternlangcodeguideagent" | "guide" => String::from("claw-code-guide"),
2294        "statusline" | "statuslinesetup" => String::from("statusline-setup"),
2295        _ => trimmed.to_string(),
2296    }
2297}
2298
2299fn iso8601_now() -> String {
2300    std::time::SystemTime::now()
2301        .duration_since(std::time::UNIX_EPOCH)
2302        .unwrap_or_default()
2303        .as_secs()
2304        .to_string()
2305}
2306
2307#[allow(clippy::too_many_lines)]
2308fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput, String> {
2309    let path = std::path::PathBuf::from(&input.notebook_path);
2310    if path.extension().and_then(|ext| ext.to_str()) != Some("ipynb") {
2311        return Err(String::from(
2312            "File must be a Jupyter notebook (.ipynb file).",
2313        ));
2314    }
2315
2316    let original_file = std::fs::read_to_string(&path).map_err(|error| error.to_string())?;
2317    let mut notebook: serde_json::Value =
2318        serde_json::from_str(&original_file).map_err(|error| error.to_string())?;
2319    let language = notebook
2320        .get("metadata")
2321        .and_then(|metadata| metadata.get("kernelspec"))
2322        .and_then(|kernelspec| kernelspec.get("language"))
2323        .and_then(serde_json::Value::as_str)
2324        .unwrap_or("python")
2325        .to_string();
2326    let cells = notebook
2327        .get_mut("cells")
2328        .and_then(serde_json::Value::as_array_mut)
2329        .ok_or_else(|| String::from("Notebook cells array not found"))?;
2330
2331    let edit_mode = input.edit_mode.unwrap_or(NotebookEditMode::Replace);
2332    let target_index = match input.cell_id.as_deref() {
2333        Some(cell_id) => Some(resolve_cell_index(cells, Some(cell_id), edit_mode)?),
2334        None if matches!(
2335            edit_mode,
2336            NotebookEditMode::Replace | NotebookEditMode::Delete
2337        ) =>
2338        {
2339            Some(resolve_cell_index(cells, None, edit_mode)?)
2340        }
2341        None => None,
2342    };
2343    let resolved_cell_type = match edit_mode {
2344        NotebookEditMode::Delete => None,
2345        NotebookEditMode::Insert => Some(input.cell_type.unwrap_or(NotebookCellType::Code)),
2346        NotebookEditMode::Replace => Some(input.cell_type.unwrap_or_else(|| {
2347            target_index
2348                .and_then(|index| cells.get(index))
2349                .and_then(cell_kind)
2350                .unwrap_or(NotebookCellType::Code)
2351        })),
2352    };
2353    let new_source = require_notebook_source(input.new_source, edit_mode)?;
2354
2355    let cell_id = match edit_mode {
2356        NotebookEditMode::Insert => {
2357            let resolved_cell_type = resolved_cell_type.expect("insert cell type");
2358            let new_id = make_cell_id(cells.len());
2359            let new_cell = build_notebook_cell(&new_id, resolved_cell_type, &new_source);
2360            let insert_at = target_index.map_or(cells.len(), |index| index + 1);
2361            cells.insert(insert_at, new_cell);
2362            cells
2363                .get(insert_at)
2364                .and_then(|cell| cell.get("id"))
2365                .and_then(serde_json::Value::as_str)
2366                .map(ToString::to_string)
2367        }
2368        NotebookEditMode::Delete => {
2369            let removed = cells.remove(target_index.expect("delete target index"));
2370            removed
2371                .get("id")
2372                .and_then(serde_json::Value::as_str)
2373                .map(ToString::to_string)
2374        }
2375        NotebookEditMode::Replace => {
2376            let resolved_cell_type = resolved_cell_type.expect("replace cell type");
2377            let cell = cells
2378                .get_mut(target_index.expect("replace target index"))
2379                .ok_or_else(|| String::from("Cell index out of range"))?;
2380            cell["source"] = serde_json::Value::Array(source_lines(&new_source));
2381            cell["cell_type"] = serde_json::Value::String(match resolved_cell_type {
2382                NotebookCellType::Code => String::from("code"),
2383                NotebookCellType::Markdown => String::from("markdown"),
2384            });
2385            match resolved_cell_type {
2386                NotebookCellType::Code => {
2387                    if !cell.get("outputs").is_some_and(serde_json::Value::is_array) {
2388                        cell["outputs"] = json!([]);
2389                    }
2390                    if cell.get("execution_count").is_none() {
2391                        cell["execution_count"] = serde_json::Value::Null;
2392                    }
2393                }
2394                NotebookCellType::Markdown => {
2395                    if let Some(object) = cell.as_object_mut() {
2396                        object.remove("outputs");
2397                        object.remove("execution_count");
2398                    }
2399                }
2400            }
2401            cell.get("id")
2402                .and_then(serde_json::Value::as_str)
2403                .map(ToString::to_string)
2404        }
2405    };
2406
2407    let updated_file =
2408        serde_json::to_string_pretty(&notebook).map_err(|error| error.to_string())?;
2409    std::fs::write(&path, &updated_file).map_err(|error| error.to_string())?;
2410
2411    Ok(NotebookEditOutput {
2412        new_source,
2413        cell_id,
2414        cell_type: resolved_cell_type,
2415        language,
2416        edit_mode: format_notebook_edit_mode(edit_mode),
2417        error: None,
2418        notebook_path: path.display().to_string(),
2419        original_file,
2420        updated_file,
2421    })
2422}
2423
2424fn require_notebook_source(
2425    source: Option<String>,
2426    edit_mode: NotebookEditMode,
2427) -> Result<String, String> {
2428    match edit_mode {
2429        NotebookEditMode::Delete => Ok(source.unwrap_or_default()),
2430        NotebookEditMode::Insert | NotebookEditMode::Replace => source
2431            .ok_or_else(|| String::from("new_source is required for insert and replace edits")),
2432    }
2433}
2434
2435fn build_notebook_cell(cell_id: &str, cell_type: NotebookCellType, source: &str) -> Value {
2436    let mut cell = json!({
2437        "cell_type": match cell_type {
2438            NotebookCellType::Code => "code",
2439            NotebookCellType::Markdown => "markdown",
2440        },
2441        "id": cell_id,
2442        "metadata": {},
2443        "source": source_lines(source),
2444    });
2445    if let Some(object) = cell.as_object_mut() {
2446        match cell_type {
2447            NotebookCellType::Code => {
2448                object.insert(String::from("outputs"), json!([]));
2449                object.insert(String::from("execution_count"), Value::Null);
2450            }
2451            NotebookCellType::Markdown => {}
2452        }
2453    }
2454    cell
2455}
2456
2457fn cell_kind(cell: &serde_json::Value) -> Option<NotebookCellType> {
2458    cell.get("cell_type")
2459        .and_then(serde_json::Value::as_str)
2460        .map(|kind| {
2461            if kind == "markdown" {
2462                NotebookCellType::Markdown
2463            } else {
2464                NotebookCellType::Code
2465            }
2466        })
2467}
2468
2469#[allow(clippy::needless_pass_by_value)]
2470fn execute_sleep(input: SleepInput) -> SleepOutput {
2471    std::thread::sleep(Duration::from_millis(input.duration_ms));
2472    SleepOutput {
2473        duration_ms: input.duration_ms,
2474        message: format!("Slept for {}ms", input.duration_ms),
2475    }
2476}
2477
2478fn execute_brief(input: BriefInput) -> Result<BriefOutput, String> {
2479    if input.message.trim().is_empty() {
2480        return Err(String::from("message must not be empty"));
2481    }
2482
2483    let attachments = input
2484        .attachments
2485        .as_ref()
2486        .map(|paths| {
2487            paths
2488                .iter()
2489                .map(|path| resolve_attachment(path))
2490                .collect::<Result<Vec<_>, String>>()
2491        })
2492        .transpose()?;
2493
2494    let message = match input.status {
2495        BriefStatus::Normal | BriefStatus::Proactive => input.message,
2496    };
2497
2498    Ok(BriefOutput {
2499        message,
2500        attachments,
2501        sent_at: iso8601_timestamp(),
2502    })
2503}
2504
2505fn resolve_attachment(path: &str) -> Result<ResolvedAttachment, String> {
2506    let resolved = std::fs::canonicalize(path).map_err(|error| error.to_string())?;
2507    let metadata = std::fs::metadata(&resolved).map_err(|error| error.to_string())?;
2508    Ok(ResolvedAttachment {
2509        path: resolved.display().to_string(),
2510        size: metadata.len(),
2511        is_image: is_image_path(&resolved),
2512    })
2513}
2514
2515fn is_image_path(path: &Path) -> bool {
2516    matches!(
2517        path.extension()
2518            .and_then(|ext| ext.to_str())
2519            .map(str::to_ascii_lowercase)
2520            .as_deref(),
2521        Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg")
2522    )
2523}
2524
2525fn execute_config(input: ConfigInput) -> Result<ConfigOutput, String> {
2526    let setting = input.setting.trim();
2527    if setting.is_empty() {
2528        return Err(String::from("setting must not be empty"));
2529    }
2530    let Some(spec) = supported_config_setting(setting) else {
2531        return Ok(ConfigOutput {
2532            success: false,
2533            operation: None,
2534            setting: None,
2535            value: None,
2536            previous_value: None,
2537            new_value: None,
2538            error: Some(format!("Unknown setting: \"{setting}\"")),
2539        });
2540    };
2541
2542    let path = config_file_for_scope(spec.scope)?;
2543    let mut document = read_json_object(&path)?;
2544
2545    if let Some(value) = input.value {
2546        let normalized = normalize_config_value(spec, value)?;
2547        let previous_value = get_nested_value(&document, spec.path).cloned();
2548        set_nested_value(&mut document, spec.path, normalized.clone());
2549        write_json_object(&path, &document)?;
2550        Ok(ConfigOutput {
2551            success: true,
2552            operation: Some(String::from("set")),
2553            setting: Some(setting.to_string()),
2554            value: Some(normalized.clone()),
2555            previous_value,
2556            new_value: Some(normalized),
2557            error: None,
2558        })
2559    } else {
2560        Ok(ConfigOutput {
2561            success: true,
2562            operation: Some(String::from("get")),
2563            setting: Some(setting.to_string()),
2564            value: get_nested_value(&document, spec.path).cloned(),
2565            previous_value: None,
2566            new_value: None,
2567            error: None,
2568        })
2569    }
2570}
2571
2572fn execute_structured_output(input: StructuredOutputInput) -> StructuredOutputResult {
2573    StructuredOutputResult {
2574        data: String::from("Structured output provided successfully"),
2575        structured_output: input.0,
2576    }
2577}
2578
2579fn execute_repl(input: ReplInput) -> Result<ReplOutput, String> {
2580    if input.code.trim().is_empty() {
2581        return Err(String::from("code must not be empty"));
2582    }
2583    let _ = input.timeout_ms;
2584    let runtime = resolve_repl_runtime(&input.language)?;
2585    let started = Instant::now();
2586    let output = Command::new(runtime.program)
2587        .args(runtime.args)
2588        .arg(&input.code)
2589        .output()
2590        .map_err(|error| error.to_string())?;
2591
2592    Ok(ReplOutput {
2593        language: input.language,
2594        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
2595        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
2596        exit_code: output.status.code().unwrap_or(1),
2597        duration_ms: started.elapsed().as_millis(),
2598    })
2599}
2600
2601struct ReplRuntime {
2602    program: &'static str,
2603    args: &'static [&'static str],
2604}
2605
2606fn resolve_repl_runtime(language: &str) -> Result<ReplRuntime, String> {
2607    match language.trim().to_ascii_lowercase().as_str() {
2608        "python" | "py" => Ok(ReplRuntime {
2609            program: detect_first_command(&["python3", "python"])
2610                .ok_or_else(|| String::from("python runtime not found"))?,
2611            args: &["-c"],
2612        }),
2613        "javascript" | "js" | "node" => Ok(ReplRuntime {
2614            program: detect_first_command(&["node"])
2615                .ok_or_else(|| String::from("node runtime not found"))?,
2616            args: &["-e"],
2617        }),
2618        "sh" | "shell" | "bash" => Ok(ReplRuntime {
2619            program: detect_first_command(&["bash", "sh"])
2620                .ok_or_else(|| String::from("shell runtime not found"))?,
2621            args: &["-lc"],
2622        }),
2623        other => Err(format!("unsupported REPL language: {other}")),
2624    }
2625}
2626
2627fn detect_first_command(commands: &[&'static str]) -> Option<&'static str> {
2628    commands
2629        .iter()
2630        .copied()
2631        .find(|command| command_exists(command))
2632}
2633
2634#[derive(Clone, Copy)]
2635enum ConfigScope {
2636    Global,
2637    Settings,
2638}
2639
2640#[derive(Clone, Copy)]
2641struct ConfigSettingSpec {
2642    scope: ConfigScope,
2643    kind: ConfigKind,
2644    path: &'static [&'static str],
2645    options: Option<&'static [&'static str]>,
2646}
2647
2648#[derive(Clone, Copy)]
2649enum ConfigKind {
2650    Boolean,
2651    String,
2652}
2653
2654fn supported_config_setting(setting: &str) -> Option<ConfigSettingSpec> {
2655    Some(match setting {
2656        "theme" => ConfigSettingSpec {
2657            scope: ConfigScope::Global,
2658            kind: ConfigKind::String,
2659            path: &["theme"],
2660            options: None,
2661        },
2662        "editorMode" => ConfigSettingSpec {
2663            scope: ConfigScope::Global,
2664            kind: ConfigKind::String,
2665            path: &["editorMode"],
2666            options: Some(&["default", "vim", "emacs"]),
2667        },
2668        "verbose" => ConfigSettingSpec {
2669            scope: ConfigScope::Global,
2670            kind: ConfigKind::Boolean,
2671            path: &["verbose"],
2672            options: None,
2673        },
2674        "preferredNotifChannel" => ConfigSettingSpec {
2675            scope: ConfigScope::Global,
2676            kind: ConfigKind::String,
2677            path: &["preferredNotifChannel"],
2678            options: None,
2679        },
2680        "autoCompactEnabled" => ConfigSettingSpec {
2681            scope: ConfigScope::Global,
2682            kind: ConfigKind::Boolean,
2683            path: &["autoCompactEnabled"],
2684            options: None,
2685        },
2686        "autoMemoryEnabled" => ConfigSettingSpec {
2687            scope: ConfigScope::Settings,
2688            kind: ConfigKind::Boolean,
2689            path: &["autoMemoryEnabled"],
2690            options: None,
2691        },
2692        "autoDreamEnabled" => ConfigSettingSpec {
2693            scope: ConfigScope::Settings,
2694            kind: ConfigKind::Boolean,
2695            path: &["autoDreamEnabled"],
2696            options: None,
2697        },
2698        "fileCheckpointingEnabled" => ConfigSettingSpec {
2699            scope: ConfigScope::Global,
2700            kind: ConfigKind::Boolean,
2701            path: &["fileCheckpointingEnabled"],
2702            options: None,
2703        },
2704        "showTurnDuration" => ConfigSettingSpec {
2705            scope: ConfigScope::Global,
2706            kind: ConfigKind::Boolean,
2707            path: &["showTurnDuration"],
2708            options: None,
2709        },
2710        "terminalProgressBarEnabled" => ConfigSettingSpec {
2711            scope: ConfigScope::Global,
2712            kind: ConfigKind::Boolean,
2713            path: &["terminalProgressBarEnabled"],
2714            options: None,
2715        },
2716        "todoFeatureEnabled" => ConfigSettingSpec {
2717            scope: ConfigScope::Global,
2718            kind: ConfigKind::Boolean,
2719            path: &["todoFeatureEnabled"],
2720            options: None,
2721        },
2722        "model" => ConfigSettingSpec {
2723            scope: ConfigScope::Settings,
2724            kind: ConfigKind::String,
2725            path: &["model"],
2726            options: None,
2727        },
2728        "alwaysThinkingEnabled" => ConfigSettingSpec {
2729            scope: ConfigScope::Settings,
2730            kind: ConfigKind::Boolean,
2731            path: &["alwaysThinkingEnabled"],
2732            options: None,
2733        },
2734        "permissions.defaultMode" => ConfigSettingSpec {
2735            scope: ConfigScope::Settings,
2736            kind: ConfigKind::String,
2737            path: &["permissions", "defaultMode"],
2738            options: Some(&["default", "plan", "acceptEdits", "dontAsk", "auto"]),
2739        },
2740        "language" => ConfigSettingSpec {
2741            scope: ConfigScope::Settings,
2742            kind: ConfigKind::String,
2743            path: &["language"],
2744            options: None,
2745        },
2746        "teammateMode" => ConfigSettingSpec {
2747            scope: ConfigScope::Global,
2748            kind: ConfigKind::String,
2749            path: &["teammateMode"],
2750            options: Some(&["tmux", "in-process", "auto"]),
2751        },
2752        _ => return None,
2753    })
2754}
2755
2756fn normalize_config_value(spec: ConfigSettingSpec, value: ConfigValue) -> Result<Value, String> {
2757    let normalized = match (spec.kind, value) {
2758        (ConfigKind::Boolean, ConfigValue::Bool(value)) => Value::Bool(value),
2759        (ConfigKind::Boolean, ConfigValue::String(value)) => {
2760            match value.trim().to_ascii_lowercase().as_str() {
2761                "true" => Value::Bool(true),
2762                "false" => Value::Bool(false),
2763                _ => return Err(String::from("setting requires true or false")),
2764            }
2765        }
2766        (ConfigKind::Boolean, ConfigValue::Number(_)) => {
2767            return Err(String::from("setting requires true or false"))
2768        }
2769        (ConfigKind::String, ConfigValue::String(value)) => Value::String(value),
2770        (ConfigKind::String, ConfigValue::Bool(value)) => Value::String(value.to_string()),
2771        (ConfigKind::String, ConfigValue::Number(value)) => json!(value),
2772    };
2773
2774    if let Some(options) = spec.options {
2775        let Some(as_str) = normalized.as_str() else {
2776            return Err(String::from("setting requires a string value"));
2777        };
2778        if !options.iter().any(|option| option == &as_str) {
2779            return Err(format!(
2780                "Invalid value \"{as_str}\". Options: {}",
2781                options.join(", ")
2782            ));
2783        }
2784    }
2785
2786    Ok(normalized)
2787}
2788
2789fn config_file_for_scope(scope: ConfigScope) -> Result<PathBuf, String> {
2790    let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
2791    Ok(match scope {
2792        ConfigScope::Global => config_home_dir()?.join("settings.json"),
2793        ConfigScope::Settings => cwd.join(".ternlang").join("settings.local.json"),
2794    })
2795}
2796
2797fn config_home_dir() -> Result<PathBuf, String> {
2798    if let Ok(path) = std::env::var("TERNLANG_CONFIG_HOME") {
2799        return Ok(PathBuf::from(path));
2800    }
2801    let home = std::env::var("HOME").map_err(|_| String::from("HOME is not set"))?;
2802    Ok(PathBuf::from(home).join(".ternlang"))
2803}
2804
2805fn read_json_object(path: &Path) -> Result<serde_json::Map<String, Value>, String> {
2806    match std::fs::read_to_string(path) {
2807        Ok(contents) => {
2808            if contents.trim().is_empty() {
2809                return Ok(serde_json::Map::new());
2810            }
2811            serde_json::from_str::<Value>(&contents)
2812                .map_err(|error| error.to_string())?
2813                .as_object()
2814                .cloned()
2815                .ok_or_else(|| String::from("config file must contain a JSON object"))
2816        }
2817        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(serde_json::Map::new()),
2818        Err(error) => Err(error.to_string()),
2819    }
2820}
2821
2822fn write_json_object(path: &Path, value: &serde_json::Map<String, Value>) -> Result<(), String> {
2823    if let Some(parent) = path.parent() {
2824        std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
2825    }
2826    std::fs::write(
2827        path,
2828        serde_json::to_string_pretty(value).map_err(|error| error.to_string())?,
2829    )
2830    .map_err(|error| error.to_string())
2831}
2832
2833fn get_nested_value<'a>(
2834    value: &'a serde_json::Map<String, Value>,
2835    path: &[&str],
2836) -> Option<&'a Value> {
2837    let (first, rest) = path.split_first()?;
2838    let mut current = value.get(*first)?;
2839    for key in rest {
2840        current = current.as_object()?.get(*key)?;
2841    }
2842    Some(current)
2843}
2844
2845fn set_nested_value(root: &mut serde_json::Map<String, Value>, path: &[&str], new_value: Value) {
2846    let (first, rest) = path.split_first().expect("config path must not be empty");
2847    if rest.is_empty() {
2848        root.insert((*first).to_string(), new_value);
2849        return;
2850    }
2851
2852    let entry = root
2853        .entry((*first).to_string())
2854        .or_insert_with(|| Value::Object(serde_json::Map::new()));
2855    if !entry.is_object() {
2856        *entry = Value::Object(serde_json::Map::new());
2857    }
2858    let map = entry.as_object_mut().expect("object inserted");
2859    set_nested_value(map, rest, new_value);
2860}
2861
2862fn iso8601_timestamp() -> String {
2863    if let Ok(output) = Command::new("date")
2864        .args(["-u", "+%Y-%m-%dT%H:%M:%SZ"])
2865        .output()
2866    {
2867        if output.status.success() {
2868            return String::from_utf8_lossy(&output.stdout).trim().to_string();
2869        }
2870    }
2871    iso8601_now()
2872}
2873
2874#[allow(clippy::needless_pass_by_value)]
2875fn execute_powershell(input: PowerShellInput) -> std::io::Result<runtime::BashCommandOutput> {
2876    let _ = &input.description;
2877    let shell = detect_powershell_shell()?;
2878    execute_shell_command(
2879        shell,
2880        &input.command,
2881        input.timeout,
2882        input.run_in_background,
2883    )
2884}
2885
2886fn detect_powershell_shell() -> std::io::Result<&'static str> {
2887    if command_exists("pwsh") {
2888        Ok("pwsh")
2889    } else if command_exists("powershell") {
2890        Ok("powershell")
2891    } else {
2892        Err(std::io::Error::new(
2893            std::io::ErrorKind::NotFound,
2894            "PowerShell executable not found (expected `pwsh` or `powershell` in PATH)",
2895        ))
2896    }
2897}
2898
2899fn command_exists(command: &str) -> bool {
2900    std::process::Command::new("sh")
2901        .arg("-lc")
2902        .arg(format!("command -v {command} >/dev/null 2>&1"))
2903        .status()
2904        .map(|status| status.success())
2905        .unwrap_or(false)
2906}
2907
2908#[allow(clippy::too_many_lines)]
2909fn execute_shell_command(
2910    shell: &str,
2911    command: &str,
2912    timeout: Option<u64>,
2913    run_in_background: Option<bool>,
2914) -> std::io::Result<runtime::BashCommandOutput> {
2915    if run_in_background.unwrap_or(false) {
2916        let child = std::process::Command::new(shell)
2917            .arg("-NoProfile")
2918            .arg("-NonInteractive")
2919            .arg("-Command")
2920            .arg(command)
2921            .stdin(std::process::Stdio::null())
2922            .stdout(std::process::Stdio::null())
2923            .stderr(std::process::Stdio::null())
2924            .spawn()?;
2925        return Ok(runtime::BashCommandOutput {
2926            stdout: String::new(),
2927            stderr: String::new(),
2928            raw_output_path: None,
2929            interrupted: false,
2930            is_image: None,
2931            background_task_id: Some(child.id().to_string()),
2932            backgrounded_by_user: Some(true),
2933            assistant_auto_backgrounded: Some(false),
2934            validation_state: 1,
2935            return_code_interpretation: None,
2936            no_output_expected: Some(true),
2937            structured_content: None,
2938            persisted_output_path: None,
2939            persisted_output_size: None,
2940            sandbox_status: None,
2941        });
2942    }
2943
2944    let mut process = std::process::Command::new(shell);
2945    process
2946        .arg("-NoProfile")
2947        .arg("-NonInteractive")
2948        .arg("-Command")
2949        .arg(command);
2950    process
2951        .stdout(std::process::Stdio::piped())
2952        .stderr(std::process::Stdio::piped());
2953
2954    if let Some(timeout_ms) = timeout {
2955        let mut child = process.spawn()?;
2956        let started = Instant::now();
2957        loop {
2958            if let Some(status) = child.try_wait()? {
2959                let output = child.wait_with_output()?;
2960                return Ok(runtime::BashCommandOutput {
2961                    stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
2962                    stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
2963                    raw_output_path: None,
2964                    interrupted: false,
2965                    is_image: None,
2966                    background_task_id: None,
2967                    backgrounded_by_user: None,
2968                    assistant_auto_backgrounded: None,
2969                    validation_state: 1,
2970                    return_code_interpretation: status
2971                        .code()
2972                        .filter(|code| *code != 0)
2973                        .map(|code| format!("exit_code:{code}")),
2974                    no_output_expected: Some(output.stdout.is_empty() && output.stderr.is_empty()),
2975                    structured_content: None,
2976                    persisted_output_path: None,
2977                    persisted_output_size: None,
2978                    sandbox_status: None,
2979                });
2980            }
2981            if started.elapsed() >= Duration::from_millis(timeout_ms) {
2982                let _ = child.kill();
2983                let output = child.wait_with_output()?;
2984                let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
2985                let stderr = if stderr.trim().is_empty() {
2986                    format!("Command exceeded timeout of {timeout_ms} ms")
2987                } else {
2988                    format!(
2989                        "{}
2990Command exceeded timeout of {timeout_ms} ms",
2991                        stderr.trim_end()
2992                    )
2993                };
2994                return Ok(runtime::BashCommandOutput {
2995                    stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
2996                    stderr,
2997                    raw_output_path: None,
2998                    interrupted: true,
2999                    is_image: None,
3000                    background_task_id: None,
3001                    backgrounded_by_user: None,
3002                    assistant_auto_backgrounded: None,
3003                    validation_state: 1,
3004                    return_code_interpretation: Some(String::from("timeout")),
3005                    no_output_expected: Some(false),
3006                    structured_content: None,
3007                    persisted_output_path: None,
3008                    persisted_output_size: None,
3009                    sandbox_status: None,
3010                });
3011            }
3012            std::thread::sleep(Duration::from_millis(10));
3013        }
3014    }
3015
3016    let output = process.output()?;
3017    Ok(runtime::BashCommandOutput {
3018        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
3019        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
3020        raw_output_path: None,
3021        interrupted: false,
3022        is_image: None,
3023        background_task_id: None,
3024        backgrounded_by_user: None,
3025        assistant_auto_backgrounded: None,
3026        validation_state: 1,
3027        return_code_interpretation: output
3028            .status
3029            .code()
3030            .filter(|code| *code != 0)
3031            .map(|code| format!("exit_code:{code}")),
3032        no_output_expected: Some(output.stdout.is_empty() && output.stderr.is_empty()),
3033        structured_content: None,
3034        persisted_output_path: None,
3035        persisted_output_size: None,
3036        sandbox_status: None,
3037    })
3038}
3039
3040fn resolve_cell_index(
3041    cells: &[serde_json::Value],
3042    cell_id: Option<&str>,
3043    edit_mode: NotebookEditMode,
3044) -> Result<usize, String> {
3045    if cells.is_empty()
3046        && matches!(
3047            edit_mode,
3048            NotebookEditMode::Replace | NotebookEditMode::Delete
3049        )
3050    {
3051        return Err(String::from("Notebook has no cells to edit"));
3052    }
3053    if let Some(cell_id) = cell_id {
3054        cells
3055            .iter()
3056            .position(|cell| cell.get("id").and_then(serde_json::Value::as_str) == Some(cell_id))
3057            .ok_or_else(|| format!("Cell id not found: {cell_id}"))
3058    } else {
3059        Ok(cells.len().saturating_sub(1))
3060    }
3061}
3062
3063fn source_lines(source: &str) -> Vec<serde_json::Value> {
3064    if source.is_empty() {
3065        return vec![serde_json::Value::String(String::new())];
3066    }
3067    source
3068        .split_inclusive('\n')
3069        .map(|line| serde_json::Value::String(line.to_string()))
3070        .collect()
3071}
3072
3073fn format_notebook_edit_mode(mode: NotebookEditMode) -> String {
3074    match mode {
3075        NotebookEditMode::Replace => String::from("replace"),
3076        NotebookEditMode::Insert => String::from("insert"),
3077        NotebookEditMode::Delete => String::from("delete"),
3078    }
3079}
3080
3081fn make_cell_id(index: usize) -> String {
3082    format!("cell-{}", index + 1)
3083}
3084
3085fn parse_skill_description(contents: &str) -> Option<String> {
3086    for line in contents.lines() {
3087        if let Some(value) = line.strip_prefix("description:") {
3088            let trimmed = value.trim();
3089            if !trimmed.is_empty() {
3090                return Some(trimmed.to_string());
3091            }
3092        }
3093    }
3094    None
3095}
3096
3097#[cfg(test)]
3098mod tests {
3099    use std::collections::BTreeSet;
3100    use std::fs;
3101    use std::io::{Read, Write};
3102    use std::net::{SocketAddr, TcpListener};
3103    use std::path::PathBuf;
3104    use std::sync::{Arc, Mutex, OnceLock};
3105    use std::thread;
3106    use std::time::Duration;
3107
3108    use super::{
3109        agent_permission_policy, allowed_tools_for_subagent, execute_agent_with_spawn,
3110        execute_tool, final_assistant_text, mvp_tool_specs, persist_agent_terminal_state,
3111        AgentInput, AgentJob, SubagentToolExecutor,
3112    };
3113    use runtime::{ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session};
3114    use serde_json::json;
3115
3116    fn env_lock() -> &'static Mutex<()> {
3117        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
3118        LOCK.get_or_init(|| Mutex::new(()))
3119    }
3120
3121    fn temp_path(name: &str) -> PathBuf {
3122        let unique = std::time::SystemTime::now()
3123            .duration_since(std::time::UNIX_EPOCH)
3124            .expect("time")
3125            .as_nanos();
3126        std::env::temp_dir().join(format!("clawd-tools-{unique}-{name}"))
3127    }
3128
3129    #[test]
3130    fn exposes_mvp_tools() {
3131        let names = mvp_tool_specs()
3132            .into_iter()
3133            .map(|spec| spec.name)
3134            .collect::<Vec<_>>();
3135        assert!(names.contains(&"bash"));
3136        assert!(names.contains(&"read_file"));
3137        assert!(names.contains(&"WebFetch"));
3138        assert!(names.contains(&"WebSearch"));
3139        assert!(names.contains(&"TodoWrite"));
3140        assert!(names.contains(&"Skill"));
3141        assert!(names.contains(&"Agent"));
3142        assert!(names.contains(&"ToolSearch"));
3143        assert!(names.contains(&"NotebookEdit"));
3144        assert!(names.contains(&"Sleep"));
3145        assert!(names.contains(&"SendUserMessage"));
3146        assert!(names.contains(&"Config"));
3147        assert!(names.contains(&"StructuredOutput"));
3148        assert!(names.contains(&"REPL"));
3149        assert!(names.contains(&"PowerShell"));
3150    }
3151
3152    #[test]
3153    fn rejects_unknown_tool_names() {
3154        let error = execute_tool("nope", &json!({})).expect_err("tool should be rejected");
3155        assert!(error.contains("unsupported tool"));
3156    }
3157
3158    #[test]
3159    fn web_fetch_returns_prompt_aware_summary() {
3160        let server = TestServer::spawn(Arc::new(|request_line: &str| {
3161            assert!(request_line.starts_with("GET /page "));
3162            HttpResponse::html(
3163                200,
3164                "OK",
3165                "<html><head><title>Ignored</title></head><body><h1>Test Page</h1><p>Hello <b>world</b> from local server.</p></body></html>",
3166            )
3167        }));
3168
3169        let result = execute_tool(
3170            "WebFetch",
3171            &json!({
3172                "url": format!("http://{}/page", server.addr()),
3173                "prompt": "Summarize this page"
3174            }),
3175        )
3176        .expect("WebFetch should succeed");
3177
3178        let output: serde_json::Value = serde_json::from_str(&result.output).expect("valid json");
3179        assert_eq!(output["code"], 200);
3180        let summary = output["result"].as_str().expect("result string");
3181        assert!(summary.contains("Fetched"));
3182        assert!(summary.contains("Test Page"));
3183        assert!(summary.contains("Hello world from local server"));
3184
3185        let titled = execute_tool(
3186            "WebFetch",
3187            &json!({
3188                "url": format!("http://{}/page", server.addr()),
3189                "prompt": "What is the page title?"
3190            }),
3191        )
3192        .expect("WebFetch title query should succeed");
3193        let titled_output: serde_json::Value = serde_json::from_str(&titled.output).expect("valid json");
3194        let titled_summary = titled_output["result"].as_str().expect("result string");
3195        assert!(titled_summary.contains("Title: Ignored"));
3196    }
3197
3198    #[test]
3199    fn web_fetch_supports_plain_text_and_rejects_invalid_url() {
3200        let server = TestServer::spawn(Arc::new(|request_line: &str| {
3201            assert!(request_line.starts_with("GET /plain "));
3202            HttpResponse::text(200, "OK", "plain text response")
3203        }));
3204
3205        let result = execute_tool(
3206            "WebFetch",
3207            &json!({
3208                "url": format!("http://{}/plain", server.addr()),
3209                "prompt": "Show me the content"
3210            }),
3211        )
3212        .expect("WebFetch should succeed for text content");
3213
3214        let output: serde_json::Value = serde_json::from_str(&result.output).expect("valid json");
3215        assert_eq!(output["url"], format!("http://{}/plain", server.addr()));
3216        assert!(output["result"]
3217            .as_str()
3218            .expect("result")
3219            .contains("plain text response"));
3220
3221        let error = execute_tool(
3222            "WebFetch",
3223            &json!({
3224                "url": "not a url",
3225                "prompt": "Summarize"
3226            }),
3227        )
3228        .expect_err("invalid URL should fail");
3229        assert!(error.contains("relative URL without a base") || error.contains("invalid"));
3230    }
3231
3232    #[test]
3233    fn web_search_extracts_and_filters_results() {
3234        let server = TestServer::spawn(Arc::new(|request_line: &str| {
3235            assert!(request_line.contains("GET /search?q=rust+web+search "));
3236            HttpResponse::html(
3237                200,
3238                "OK",
3239                r#"
3240                <html><body>
3241                  <a class="result__a" href="https://docs.rs/reqwest">Reqwest docs</a>
3242                  <a class="result__a" href="https://example.com/blocked">Blocked result</a>
3243                </body></html>
3244                "#,
3245            )
3246        }));
3247
3248        std::env::set_var(
3249            "CLAWD_WEB_SEARCH_BASE_URL",
3250            format!("http://{}/search", server.addr()),
3251        );
3252        let result = execute_tool(
3253            "WebSearch",
3254            &json!({
3255                "query": "rust web search",
3256                "allowed_domains": ["https://DOCS.rs/"],
3257                "blocked_domains": ["HTTPS://EXAMPLE.COM"]
3258            }),
3259        )
3260        .expect("WebSearch should succeed");
3261        std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
3262
3263        let output: serde_json::Value = serde_json::from_str(&result.output).expect("valid json");
3264        assert_eq!(output["query"], "rust web search");
3265        let results = output["results"].as_array().expect("results array");
3266        let search_result = results
3267            .iter()
3268            .find(|item| item.get("content").is_some())
3269            .expect("search result block present");
3270        let content = search_result["content"].as_array().expect("content array");
3271        assert_eq!(content.len(), 1);
3272        assert_eq!(content[0]["title"], "Reqwest docs");
3273        assert_eq!(content[0]["url"], "https://docs.rs/reqwest");
3274    }
3275
3276    #[test]
3277    fn web_search_handles_generic_links_and_invalid_base_url() {
3278        let _guard = env_lock()
3279            .lock()
3280            .unwrap_or_else(std::sync::PoisonError::into_inner);
3281        let server = TestServer::spawn(Arc::new(|request_line: &str| {
3282            assert!(request_line.contains("GET /fallback?q=generic+links "));
3283            HttpResponse::html(
3284                200,
3285                "OK",
3286                r#"
3287                <html><body>
3288                  <a href="https://example.com/one">Example One</a>
3289                  <a href="https://example.com/one">Duplicate Example One</a>
3290                  <a href="https://docs.rs/tokio">Tokio Docs</a>
3291                </body></html>
3292                "#,
3293            )
3294        }));
3295
3296        std::env::set_var(
3297            "CLAWD_WEB_SEARCH_BASE_URL",
3298            format!("http://{}/fallback", server.addr()),
3299        );
3300        let result = execute_tool(
3301            "WebSearch",
3302            &json!({
3303                "query": "generic links"
3304            }),
3305        )
3306        .expect("WebSearch fallback parsing should succeed");
3307        std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
3308
3309        let output: serde_json::Value = serde_json::from_str(&result.output).expect("valid json");
3310        let results = output["results"].as_array().expect("results array");
3311        let search_result = results
3312            .iter()
3313            .find(|item| item.get("content").is_some())
3314            .expect("search result block present");
3315        let content = search_result["content"].as_array().expect("content array");
3316        assert_eq!(content.len(), 2);
3317        assert_eq!(content[0]["url"], "https://example.com/one");
3318        assert_eq!(content[1]["url"], "https://docs.rs/tokio");
3319
3320        std::env::set_var("CLAWD_WEB_SEARCH_BASE_URL", "://bad-base-url");
3321        let error = execute_tool("WebSearch", &json!({ "query": "generic links" }))
3322            .expect_err("invalid base URL should fail");
3323        std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
3324        assert!(error.contains("relative URL without a base") || error.contains("empty host"));
3325    }
3326
3327    #[test]
3328    fn todo_write_persists_and_returns_previous_state() {
3329        let _guard = env_lock()
3330            .lock()
3331            .unwrap_or_else(std::sync::PoisonError::into_inner);
3332        let path = temp_path("todos.json");
3333        std::env::set_var("CLAWD_TODO_STORE", &path);
3334
3335        let first = execute_tool(
3336            "TodoWrite",
3337            &json!({
3338                "todos": [
3339                    {"content": "Add tool", "activeForm": "Adding tool", "status": "in_progress"},
3340                    {"content": "Run tests", "activeForm": "Running tests", "status": "pending"}
3341                ]
3342            }),
3343        )
3344        .expect("TodoWrite should succeed");
3345        let first_output: serde_json::Value = serde_json::from_str(&first.output).expect("valid json");
3346        assert_eq!(first_output["oldTodos"].as_array().expect("array").len(), 0);
3347
3348        let second = execute_tool(
3349            "TodoWrite",
3350            &json!({
3351                "todos": [
3352                    {"content": "Add tool", "activeForm": "Adding tool", "status": "completed"},
3353                    {"content": "Run tests", "activeForm": "Running tests", "status": "completed"},
3354                    {"content": "Verify", "activeForm": "Verifying", "status": "completed"}
3355                ]
3356            }),
3357        )
3358        .expect("TodoWrite should succeed");
3359        std::env::remove_var("CLAWD_TODO_STORE");
3360        let _ = std::fs::remove_file(path);
3361
3362        let second_output: serde_json::Value = serde_json::from_str(&second.output).expect("valid json");
3363        assert_eq!(
3364            second_output["oldTodos"].as_array().expect("array").len(),
3365            2
3366        );
3367        assert_eq!(
3368            second_output["newTodos"].as_array().expect("array").len(),
3369            3
3370        );
3371        assert!(second_output["verificationNudgeNeeded"].is_null());
3372    }
3373
3374    #[test]
3375    fn todo_write_rejects_invalid_payloads_and_sets_verification_nudge() {
3376        let _guard = env_lock()
3377            .lock()
3378            .unwrap_or_else(std::sync::PoisonError::into_inner);
3379        let path = temp_path("todos-errors.json");
3380        std::env::set_var("CLAWD_TODO_STORE", &path);
3381
3382        let empty = execute_tool("TodoWrite", &json!({ "todos": [] }))
3383            .expect_err("empty todos should fail");
3384        assert!(empty.contains("todos must not be empty"));
3385
3386        // Multiple in_progress items are now allowed for parallel workflows
3387        let _multi_active = execute_tool(
3388            "TodoWrite",
3389            &json!({
3390                "todos": [
3391                    {"content": "One", "activeForm": "Doing one", "status": "in_progress"},
3392                    {"content": "Two", "activeForm": "Doing two", "status": "in_progress"}
3393                ]
3394            }),
3395        )
3396        .expect("multiple in-progress todos should succeed");
3397
3398        let blank_content = execute_tool(
3399            "TodoWrite",
3400            &json!({
3401                "todos": [
3402                    {"content": "   ", "activeForm": "Doing it", "status": "pending"}
3403                ]
3404            }),
3405        )
3406        .expect_err("blank content should fail");
3407        assert!(blank_content.contains("todo content must not be empty"));
3408
3409        let nudge = execute_tool(
3410            "TodoWrite",
3411            &json!({
3412                "todos": [
3413                    {"content": "Write tests", "activeForm": "Writing tests", "status": "completed"},
3414                    {"content": "Fix errors", "activeForm": "Fixing errors", "status": "completed"},
3415                    {"content": "Ship branch", "activeForm": "Shipping branch", "status": "completed"}
3416                ]
3417            }),
3418        )
3419        .expect("completed todos should succeed");
3420        std::env::remove_var("CLAWD_TODO_STORE");
3421        let _ = fs::remove_file(path);
3422
3423        let output: serde_json::Value = serde_json::from_str(&nudge.output).expect("valid json");
3424        assert_eq!(output["verificationNudgeNeeded"], true);
3425    }
3426
3427    #[test]
3428    fn skill_loads_local_skill_prompt() {
3429        let result = execute_tool(
3430            "Skill",
3431            &json!({
3432                "skill": "help",
3433                "args": "overview"
3434            }),
3435        )
3436        .expect("Skill should succeed");
3437
3438        let output: serde_json::Value = serde_json::from_str(&result.output).expect("valid json");
3439        assert_eq!(output["skill"], "help");
3440        assert!(output["path"]
3441            .as_str()
3442            .expect("path")
3443            .ends_with("/help/SKILL.md"));
3444        assert!(output["prompt"]
3445            .as_str()
3446            .expect("prompt")
3447            .contains("Guide on using oh-my-codex plugin"));
3448
3449        let dollar_result = execute_tool(
3450            "Skill",
3451            &json!({
3452                "skill": "$help"
3453            }),
3454        )
3455        .expect("Skill should accept $skill invocation form");
3456        let dollar_output: serde_json::Value =
3457            serde_json::from_str(&dollar_result.output).expect("valid json");
3458        assert_eq!(dollar_output["skill"], "$help");
3459        assert!(dollar_output["path"]
3460            .as_str()
3461            .expect("path")
3462            .ends_with("/help/SKILL.md"));
3463    }
3464
3465    #[test]
3466    fn tool_search_supports_keyword_and_select_queries() {
3467        let keyword = execute_tool(
3468            "ToolSearch",
3469            &json!({"query": "web current", "max_results": 3}),
3470        )
3471        .expect("ToolSearch should succeed");
3472        let keyword_output: serde_json::Value = serde_json::from_str(&keyword.output).expect("valid json");
3473        let matches = keyword_output["matches"].as_array().expect("matches");
3474        assert!(matches.iter().any(|value| value == "WebSearch"));
3475
3476        let selected = execute_tool("ToolSearch", &json!({"query": "select:Agent,Skill"}))
3477            .expect("ToolSearch should succeed");
3478        let selected_output: serde_json::Value =
3479            serde_json::from_str(&selected.output).expect("valid json");
3480        assert_eq!(selected_output["matches"][0], "Agent");
3481        assert_eq!(selected_output["matches"][1], "Skill");
3482
3483        let aliased = execute_tool("ToolSearch", &json!({"query": "AgentTool"}))
3484            .expect("ToolSearch should support tool aliases");
3485        let aliased_output: serde_json::Value = serde_json::from_str(&aliased.output).expect("valid json");
3486        assert_eq!(aliased_output["matches"][0], "Agent");
3487        assert_eq!(aliased_output["normalized_query"], "agent");
3488
3489        let selected_with_alias =
3490            execute_tool("ToolSearch", &json!({"query": "select:AgentTool,Skill"}))
3491                .expect("ToolSearch alias select should succeed");
3492        let selected_with_alias_output: serde_json::Value =
3493            serde_json::from_str(&selected_with_alias.output).expect("valid json");
3494        assert_eq!(selected_with_alias_output["matches"][0], "Agent");
3495        assert_eq!(selected_with_alias_output["matches"][1], "Skill");
3496    }
3497
3498    #[test]
3499    fn agent_persists_handoff_metadata() {
3500        let _guard = env_lock()
3501            .lock()
3502            .unwrap_or_else(std::sync::PoisonError::into_inner);
3503        let dir = temp_path("agent-store");
3504        std::env::set_var("CLAWD_AGENT_STORE", &dir);
3505        let captured = Arc::new(Mutex::new(None::<AgentJob>));
3506        let captured_for_spawn = Arc::clone(&captured);
3507
3508        let manifest = execute_agent_with_spawn(
3509            AgentInput {
3510                description: "Audit the branch".to_string(),
3511                prompt: "Check tests and outstanding work.".to_string(),
3512                subagent_type: Some("Explore".to_string()),
3513                name: Some("ship-audit".to_string()),
3514                model: None,
3515            },
3516            move |job| {
3517                *captured_for_spawn
3518                    .lock()
3519                    .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(job);
3520                Ok(())
3521            },
3522        )
3523        .expect("Agent should succeed");
3524        std::env::remove_var("CLAWD_AGENT_STORE");
3525
3526        assert_eq!(manifest.name, "ship-audit");
3527        assert_eq!(manifest.subagent_type.as_deref(), Some("Explore"));
3528        assert_eq!(manifest.status, "running");
3529        assert!(!manifest.created_at.is_empty());
3530        assert!(manifest.started_at.is_some());
3531        assert!(manifest.completed_at.is_none());
3532        let contents = std::fs::read_to_string(&manifest.output_file).expect("agent file exists");
3533        let manifest_contents =
3534            std::fs::read_to_string(&manifest.manifest_file).expect("manifest file exists");
3535        assert!(contents.contains("Audit the branch"));
3536        assert!(contents.contains("Check tests and outstanding work."));
3537        assert!(manifest_contents.contains("\"subagentType\": \"Explore\""));
3538        assert!(manifest_contents.contains("\"status\": \"running\""));
3539        let captured_job = captured
3540            .lock()
3541            .unwrap_or_else(std::sync::PoisonError::into_inner)
3542            .clone()
3543            .expect("spawn job should be captured");
3544        assert_eq!(captured_job.prompt, "Check tests and outstanding work.");
3545        assert!(captured_job.allowed_tools.contains("read_file"));
3546        assert!(!captured_job.allowed_tools.contains("Agent"));
3547
3548        let normalized = execute_tool(
3549            "Agent",
3550            &json!({
3551                "description": "Verify the branch",
3552                "prompt": "Check tests.",
3553                "subagent_type": "explorer"
3554            }),
3555        )
3556        .expect("Agent should normalize built-in aliases");
3557        let normalized_output: serde_json::Value =
3558            serde_json::from_str(&normalized.output).expect("valid json");
3559        assert_eq!(normalized_output["subagentType"], "Explore");
3560
3561        let named = execute_tool(
3562            "Agent",
3563            &json!({
3564                "description": "Review the branch",
3565                "prompt": "Inspect diff.",
3566                "name": "Ship Audit!!!"
3567            }),
3568        )
3569        .expect("Agent should normalize explicit names");
3570        let named_output: serde_json::Value = serde_json::from_str(&named.output).expect("valid json");
3571        assert_eq!(named_output["name"], "ship-audit");
3572        let _ = std::fs::remove_dir_all(dir);
3573    }
3574
3575    #[test]
3576    fn agent_fake_runner_can_persist_completion_and_failure() {
3577        let _guard = env_lock()
3578            .lock()
3579            .unwrap_or_else(std::sync::PoisonError::into_inner);
3580        let dir = temp_path("agent-runner");
3581        std::env::set_var("CLAWD_AGENT_STORE", &dir);
3582
3583        let completed = execute_agent_with_spawn(
3584            AgentInput {
3585                description: "Complete the task".to_string(),
3586                prompt: "Do the work".to_string(),
3587                subagent_type: Some("Explore".to_string()),
3588                name: Some("complete-task".to_string()),
3589                model: Some("ternlang-sonnet-4-6".to_string()),
3590            },
3591            |job| {
3592                persist_agent_terminal_state(
3593                    &job.manifest,
3594                    "completed",
3595                    Some("Finished successfully"),
3596                    None,
3597                )
3598            },
3599        )
3600        .expect("completed agent should succeed");
3601
3602        let completed_manifest = std::fs::read_to_string(&completed.manifest_file)
3603            .expect("completed manifest should exist");
3604        let completed_output =
3605            std::fs::read_to_string(&completed.output_file).expect("completed output should exist");
3606        assert!(completed_manifest.contains("\"status\": \"completed\""));
3607        assert!(completed_output.contains("Finished successfully"));
3608
3609        let failed = execute_agent_with_spawn(
3610            AgentInput {
3611                description: "Fail the task".to_string(),
3612                prompt: "Do the failing work".to_string(),
3613                subagent_type: Some("Verification".to_string()),
3614                name: Some("fail-task".to_string()),
3615                model: None,
3616            },
3617            |job| {
3618                persist_agent_terminal_state(
3619                    &job.manifest,
3620                    "failed",
3621                    None,
3622                    Some(String::from("simulated failure")),
3623                )
3624            },
3625        )
3626        .expect("failed agent should still spawn");
3627
3628        let failed_manifest =
3629            std::fs::read_to_string(&failed.manifest_file).expect("failed manifest should exist");
3630        let failed_output =
3631            std::fs::read_to_string(&failed.output_file).expect("failed output should exist");
3632        assert!(failed_manifest.contains("\"status\": \"failed\""));
3633        assert!(failed_manifest.contains("simulated failure"));
3634        assert!(failed_output.contains("simulated failure"));
3635
3636        let spawn_error = execute_agent_with_spawn(
3637            AgentInput {
3638                description: "Spawn error task".to_string(),
3639                prompt: "Never starts".to_string(),
3640                subagent_type: None,
3641                name: Some("spawn-error".to_string()),
3642                model: None,
3643            },
3644            |_| Err(String::from("thread creation failed")),
3645        )
3646        .expect_err("spawn errors should surface");
3647        assert!(spawn_error.contains("failed to spawn sub-agent"));
3648        let spawn_error_manifest = std::fs::read_dir(&dir)
3649            .expect("agent dir should exist")
3650            .filter_map(Result::ok)
3651            .map(|entry| entry.path())
3652            .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json"))
3653            .find_map(|path| {
3654                let contents = std::fs::read_to_string(&path).ok()?;
3655                contents
3656                    .contains("\"name\": \"spawn-error\"")
3657                    .then_some(contents)
3658            })
3659            .expect("failed manifest should still be written");
3660        assert!(spawn_error_manifest.contains("\"status\": \"failed\""));
3661        assert!(spawn_error_manifest.contains("thread creation failed"));
3662
3663        std::env::remove_var("CLAWD_AGENT_STORE");
3664        let _ = std::fs::remove_dir_all(dir);
3665    }
3666
3667    #[test]
3668    fn agent_tool_subset_mapping_is_expected() {
3669        let general = allowed_tools_for_subagent("general-purpose");
3670        assert!(general.contains("bash"));
3671        assert!(general.contains("write_file"));
3672        assert!(!general.contains("Agent"));
3673
3674        let explore = allowed_tools_for_subagent("Explore");
3675        assert!(explore.contains("read_file"));
3676        assert!(explore.contains("grep_search"));
3677        assert!(!explore.contains("bash"));
3678
3679        let plan = allowed_tools_for_subagent("Plan");
3680        assert!(plan.contains("TodoWrite"));
3681        assert!(plan.contains("StructuredOutput"));
3682        assert!(!plan.contains("Agent"));
3683
3684        let verification = allowed_tools_for_subagent("Verification");
3685        assert!(verification.contains("bash"));
3686        assert!(verification.contains("PowerShell"));
3687        assert!(!verification.contains("write_file"));
3688    }
3689
3690    #[derive(Debug)]
3691    struct MockSubagentApiClient {
3692        calls: usize,
3693        input_path: String,
3694    }
3695
3696    impl runtime::ApiClient for MockSubagentApiClient {
3697        fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
3698            self.calls += 1;
3699            match self.calls {
3700                1 => {
3701                    assert_eq!(request.messages.len(), 1);
3702                    Ok(vec![
3703                        AssistantEvent::ToolUse {
3704                            id: "tool-1".to_string(),
3705                            name: "read_file".to_string(),
3706                            input: json!({ "path": self.input_path }).to_string(),
3707                        },
3708                        AssistantEvent::MessageStop,
3709                    ])
3710                }
3711                2 => {
3712                    assert!(request.messages.len() >= 3);
3713                    Ok(vec![
3714                        AssistantEvent::TextDelta("Scope: completed mock review".to_string()),
3715                        AssistantEvent::MessageStop,
3716                    ])
3717                }
3718                _ => panic!("unexpected mock stream call"),
3719            }
3720        }
3721    }
3722
3723    #[test]
3724    fn subagent_runtime_executes_tool_loop_with_isolated_session() {
3725        let _guard = env_lock()
3726            .lock()
3727            .unwrap_or_else(std::sync::PoisonError::into_inner);
3728        let path = temp_path("subagent-input.txt");
3729        std::fs::write(&path, "hello from child").expect("write input file");
3730
3731        let mut runtime = ConversationRuntime::new(
3732            Session::new(),
3733            MockSubagentApiClient {
3734                calls: 0,
3735                input_path: path.display().to_string(),
3736            },
3737            SubagentToolExecutor::new(BTreeSet::from([String::from("read_file")])),
3738            agent_permission_policy(),
3739            vec![String::from("system prompt")],
3740        );
3741
3742        let summary = runtime
3743            .run_turn("Inspect the delegated file", None)
3744            .expect("subagent loop should succeed");
3745
3746        assert_eq!(
3747            final_assistant_text(&summary),
3748            "Scope: completed mock review"
3749        );
3750        assert!(runtime
3751            .session()
3752            .messages
3753            .iter()
3754            .flat_map(|message| message.blocks.iter())
3755            .any(|block| matches!(
3756                block,
3757                runtime::ContentBlock::ToolResult { output, .. }
3758                    if output.contains("hello from child")
3759            )));
3760
3761        let _ = std::fs::remove_file(path);
3762    }
3763
3764    #[test]
3765    fn agent_rejects_blank_required_fields() {
3766        let missing_description = execute_tool(
3767            "Agent",
3768            &json!({
3769                "description": "  ",
3770                "prompt": "Inspect"
3771            }),
3772        )
3773        .expect_err("blank description should fail");
3774        assert!(missing_description.contains("description must not be empty"));
3775
3776        let missing_prompt = execute_tool(
3777            "Agent",
3778            &json!({
3779                "description": "Inspect branch",
3780                "prompt": " "
3781            }),
3782        )
3783        .expect_err("blank prompt should fail");
3784        assert!(missing_prompt.contains("prompt must not be empty"));
3785    }
3786
3787    #[test]
3788    fn notebook_edit_replaces_inserts_and_deletes_cells() {
3789        let path = temp_path("notebook.ipynb");
3790        std::fs::write(
3791            &path,
3792            r#"{
3793  "cells": [
3794    {"cell_type": "code", "id": "cell-a", "metadata": {}, "source": ["print(1)\n"], "outputs": [], "execution_count": null}
3795  ],
3796  "metadata": {"kernelspec": {"language": "python"}},
3797  "nbformat": 4,
3798  "nbformat_minor": 5
3799}"#,
3800        )
3801        .expect("write notebook");
3802
3803        let replaced = execute_tool(
3804            "NotebookEdit",
3805            &json!({
3806                "notebook_path": path.display().to_string(),
3807                "cell_id": "cell-a",
3808                "new_source": "print(2)\n",
3809                "edit_mode": "replace"
3810            }),
3811        )
3812        .expect("NotebookEdit replace should succeed");
3813        let replaced_output: serde_json::Value = serde_json::from_str(&replaced.output).expect("json");
3814        assert_eq!(replaced_output["cell_id"], "cell-a");
3815        assert_eq!(replaced_output["cell_type"], "code");
3816
3817        let inserted = execute_tool(
3818            "NotebookEdit",
3819            &json!({
3820                "notebook_path": path.display().to_string(),
3821                "cell_id": "cell-a",
3822                "new_source": "# heading\n",
3823                "cell_type": "markdown",
3824                "edit_mode": "insert"
3825            }),
3826        )
3827        .expect("NotebookEdit insert should succeed");
3828        let inserted_output: serde_json::Value = serde_json::from_str(&inserted.output).expect("json");
3829        assert_eq!(inserted_output["cell_type"], "markdown");
3830        let appended = execute_tool(
3831            "NotebookEdit",
3832            &json!({
3833                "notebook_path": path.display().to_string(),
3834                "new_source": "print(3)\n",
3835                "edit_mode": "insert"
3836            }),
3837        )
3838        .expect("NotebookEdit append should succeed");
3839        let appended_output: serde_json::Value = serde_json::from_str(&appended.output).expect("json");
3840        assert_eq!(appended_output["cell_type"], "code");
3841
3842        let deleted = execute_tool(
3843            "NotebookEdit",
3844            &json!({
3845                "notebook_path": path.display().to_string(),
3846                "cell_id": "cell-a",
3847                "edit_mode": "delete"
3848            }),
3849        )
3850        .expect("NotebookEdit delete should succeed without new_source");
3851        let deleted_output: serde_json::Value = serde_json::from_str(&deleted.output).expect("json");
3852        assert!(deleted_output["cell_type"].is_null());
3853        assert_eq!(deleted_output["new_source"], "");
3854
3855        let final_notebook: serde_json::Value =
3856            serde_json::from_str(&std::fs::read_to_string(&path).expect("read notebook"))
3857                .expect("valid notebook json");
3858        let cells = final_notebook["cells"].as_array().expect("cells array");
3859        assert_eq!(cells.len(), 2);
3860        assert_eq!(cells[0]["cell_type"], "markdown");
3861        assert!(cells[0].get("outputs").is_none());
3862        assert_eq!(cells[1]["cell_type"], "code");
3863        assert_eq!(cells[1]["source"][0], "print(3)\n");
3864        let _ = std::fs::remove_file(path);
3865    }
3866
3867    #[test]
3868    fn notebook_edit_rejects_invalid_inputs() {
3869        let text_path = temp_path("notebook.txt");
3870        fs::write(&text_path, "not a notebook").expect("write text file");
3871        let wrong_extension = execute_tool(
3872            "NotebookEdit",
3873            &json!({
3874                "notebook_path": text_path.display().to_string(),
3875                "new_source": "print(1)\n"
3876            }),
3877        )
3878        .expect_err("non-ipynb file should fail");
3879        assert!(wrong_extension.contains("Jupyter notebook"));
3880        let _ = fs::remove_file(&text_path);
3881
3882        let empty_notebook = temp_path("empty.ipynb");
3883        fs::write(
3884            &empty_notebook,
3885            r#"{"cells":[],"metadata":{"kernelspec":{"language":"python"}},"nbformat":4,"nbformat_minor":5}"#,
3886        )
3887        .expect("write empty notebook");
3888
3889        let missing_source = execute_tool(
3890            "NotebookEdit",
3891            &json!({
3892                "notebook_path": empty_notebook.display().to_string(),
3893                "edit_mode": "insert"
3894            }),
3895        )
3896        .expect_err("insert without source should fail");
3897        assert!(missing_source.contains("new_source is required"));
3898
3899        let missing_cell = execute_tool(
3900            "NotebookEdit",
3901            &json!({
3902                "notebook_path": empty_notebook.display().to_string(),
3903                "edit_mode": "delete"
3904            }),
3905        )
3906        .expect_err("delete on empty notebook should fail");
3907        assert!(missing_cell.contains("Notebook has no cells to edit"));
3908        let _ = fs::remove_file(empty_notebook);
3909    }
3910
3911    #[test]
3912    fn bash_tool_reports_success_exit_failure_timeout_and_background() {
3913        let success = execute_tool("bash", &json!({ "command": "printf 'hello'" }))
3914            .expect("bash should succeed");
3915        let success_output: serde_json::Value = serde_json::from_str(&success.output).expect("json");
3916        assert_eq!(success_output["stdout"], "hello");
3917        assert_eq!(success_output["interrupted"], false);
3918
3919        let failure = execute_tool("bash", &json!({ "command": "printf 'oops' >&2; exit 7" }))
3920            .expect("bash failure should still return structured output");
3921        let failure_output: serde_json::Value = serde_json::from_str(&failure.output).expect("json");
3922        assert_eq!(failure_output["returnCodeInterpretation"], "exit_code:7");
3923        assert!(failure_output["stderr"]
3924            .as_str()
3925            .expect("stderr")
3926            .contains("oops"));
3927
3928        let timeout = execute_tool("bash", &json!({ "command": "sleep 1", "timeout": 10 }))
3929            .expect("bash timeout should return output");
3930        let timeout_output: serde_json::Value = serde_json::from_str(&timeout.output).expect("json");
3931        assert_eq!(timeout_output["interrupted"], true);
3932        assert_eq!(timeout_output["returnCodeInterpretation"], "timeout");
3933        assert!(timeout_output["stderr"]
3934            .as_str()
3935            .expect("stderr")
3936            .contains("Command exceeded timeout"));
3937
3938        let background = execute_tool(
3939            "bash",
3940            &json!({ "command": "sleep 1", "run_in_background": true }),
3941        )
3942        .expect("bash background should succeed");
3943        let background_output: serde_json::Value = serde_json::from_str(&background.output).expect("json");
3944        assert!(background_output["backgroundTaskId"].as_str().is_some());
3945        assert_eq!(background_output["noOutputExpected"], true);
3946    }
3947
3948    #[test]
3949    fn file_tools_cover_read_write_and_edit_behaviors() {
3950        let _guard = env_lock()
3951            .lock()
3952            .unwrap_or_else(std::sync::PoisonError::into_inner);
3953        let root = temp_path("fs-suite");
3954        fs::create_dir_all(&root).expect("create root");
3955        let original_dir = std::env::current_dir().expect("cwd");
3956        std::env::set_current_dir(&root).expect("set cwd");
3957
3958        let write_create = execute_tool(
3959            "write_file",
3960            &json!({ "path": "nested/demo.txt", "content": "alpha\nbeta\nalpha\n" }),
3961        )
3962        .expect("write create should succeed");
3963        let write_create_output: serde_json::Value =
3964            serde_json::from_str(&write_create.output).expect("json");
3965        assert_eq!(write_create_output["type"], "create");
3966        assert!(root.join("nested/demo.txt").exists());
3967
3968        let write_update = execute_tool(
3969            "write_file",
3970            &json!({ "path": "nested/demo.txt", "content": "alpha\nbeta\ngamma\n" }),
3971        )
3972        .expect("write update should succeed");
3973        let write_update_output: serde_json::Value =
3974            serde_json::from_str(&write_update.output).expect("json");
3975        assert_eq!(write_update_output["type"], "update");
3976        assert_eq!(write_update_output["originalFile"], "alpha\nbeta\nalpha\n");
3977
3978        let read_full = execute_tool("read_file", &json!({ "path": "nested/demo.txt" }))
3979            .expect("read full should succeed");
3980        let read_full_output: serde_json::Value = serde_json::from_str(&read_full.output).expect("json");
3981        assert_eq!(read_full_output["file"]["content"], "alpha\nbeta\ngamma");
3982        assert_eq!(read_full_output["file"]["startLine"], 1);
3983
3984        let read_slice = execute_tool(
3985            "read_file",
3986            &json!({ "path": "nested/demo.txt", "offset": 1, "limit": 1 }),
3987        )
3988        .expect("read slice should succeed");
3989        let read_slice_output: serde_json::Value = serde_json::from_str(&read_slice.output).expect("json");
3990        assert_eq!(read_slice_output["file"]["content"], "beta");
3991        assert_eq!(read_slice_output["file"]["startLine"], 2);
3992
3993        let read_past_end = execute_tool(
3994            "read_file",
3995            &json!({ "path": "nested/demo.txt", "offset": 50 }),
3996        )
3997        .expect("read past EOF should succeed");
3998        let read_past_end_output: serde_json::Value =
3999            serde_json::from_str(&read_past_end.output).expect("json");
4000        assert_eq!(read_past_end_output["file"]["content"], "");
4001        assert_eq!(read_past_end_output["file"]["startLine"], 4);
4002
4003        let read_error = execute_tool("read_file", &json!({ "path": "missing.txt" }))
4004            .expect_err("missing file should fail");
4005        assert!(!read_error.is_empty());
4006
4007        let edit_once = execute_tool(
4008            "edit_file",
4009            &json!({ "path": "nested/demo.txt", "old_string": "alpha", "new_string": "omega" }),
4010        )
4011        .expect("single edit should succeed");
4012        let edit_once_output: serde_json::Value = serde_json::from_str(&edit_once.output).expect("json");
4013        assert_eq!(edit_once_output["replaceAll"], false);
4014        assert_eq!(
4015            fs::read_to_string(root.join("nested/demo.txt")).expect("read file"),
4016            "omega\nbeta\ngamma\n"
4017        );
4018
4019        execute_tool(
4020            "write_file",
4021            &json!({ "path": "nested/demo.txt", "content": "alpha\nbeta\nalpha\n" }),
4022        )
4023        .expect("reset file");
4024        let edit_all = execute_tool(
4025            "edit_file",
4026            &json!({
4027                "path": "nested/demo.txt",
4028                "old_string": "alpha",
4029                "new_string": "omega",
4030                "replace_all": true
4031            }),
4032        )
4033        .expect("replace all should succeed");
4034        let edit_all_output: serde_json::Value = serde_json::from_str(&edit_all.output).expect("json");
4035        assert_eq!(edit_all_output["replaceAll"], true);
4036        assert_eq!(
4037            fs::read_to_string(root.join("nested/demo.txt")).expect("read file"),
4038            "omega\nbeta\nomega\n"
4039        );
4040
4041        let edit_same = execute_tool(
4042            "edit_file",
4043            &json!({ "path": "nested/demo.txt", "old_string": "omega", "new_string": "omega" }),
4044        )
4045        .expect_err("identical old/new should fail");
4046        assert!(edit_same.contains("must differ"));
4047
4048        let edit_missing = execute_tool(
4049            "edit_file",
4050            &json!({ "path": "nested/demo.txt", "old_string": "missing", "new_string": "omega" }),
4051        )
4052        .expect_err("missing substring should fail");
4053        assert!(edit_missing.contains("old_string not found"));
4054
4055        std::env::set_current_dir(&original_dir).expect("restore cwd");
4056        let _ = fs::remove_dir_all(root);
4057    }
4058
4059    #[test]
4060    fn glob_and_grep_tools_cover_success_and_errors() {
4061        let _guard = env_lock()
4062            .lock()
4063            .unwrap_or_else(std::sync::PoisonError::into_inner);
4064        let root = temp_path("search-suite");
4065        fs::create_dir_all(root.join("nested")).expect("create root");
4066        let original_dir = std::env::current_dir().expect("cwd");
4067        std::env::set_current_dir(&root).expect("set cwd");
4068
4069        fs::write(
4070            root.join("nested/lib.rs"),
4071            "fn main() {}\nlet alpha = 1;\nlet alpha = 2;\n",
4072        )
4073        .expect("write rust file");
4074        fs::write(root.join("nested/notes.txt"), "alpha\nbeta\n").expect("write txt file");
4075
4076        let globbed = execute_tool("glob_search", &json!({ "pattern": "nested/*.rs" }))
4077            .expect("glob should succeed");
4078        let globbed_output: serde_json::Value = serde_json::from_str(&globbed.output).expect("json");
4079        assert_eq!(globbed_output["numFiles"], 1);
4080        assert!(globbed_output["filenames"][0]
4081            .as_str()
4082            .expect("filename")
4083            .ends_with("nested/lib.rs"));
4084
4085        let glob_error = execute_tool("glob_search", &json!({ "pattern": "[" }))
4086            .expect_err("invalid glob should fail");
4087        assert!(!glob_error.is_empty());
4088
4089        let grep_content = execute_tool(
4090            "grep_search",
4091            &json!({
4092                "pattern": "alpha",
4093                "path": "nested",
4094                "glob": "*.rs",
4095                "output_mode": "content",
4096                "-n": true,
4097                "head_limit": 1,
4098                "offset": 1
4099            }),
4100        )
4101        .expect("grep content should succeed");
4102        let grep_content_output: serde_json::Value =
4103            serde_json::from_str(&grep_content.output).expect("json");
4104        assert_eq!(grep_content_output["numFiles"], 0);
4105        assert!(grep_content_output["appliedLimit"].is_null());
4106        assert_eq!(grep_content_output["appliedOffset"], 1);
4107        assert!(grep_content_output["content"]
4108            .as_str()
4109            .expect("content")
4110            .contains("let alpha = 2;"));
4111
4112        let grep_count = execute_tool(
4113            "grep_search",
4114            &json!({ "pattern": "alpha", "path": "nested", "output_mode": "count" }),
4115        )
4116        .expect("grep count should succeed");
4117        let grep_count_output: serde_json::Value = serde_json::from_str(&grep_count.output).expect("json");
4118        assert_eq!(grep_count_output["numMatches"], 3);
4119
4120        let grep_error = execute_tool(
4121            "grep_search",
4122            &json!({ "pattern": "(alpha", "path": "nested" }),
4123        )
4124        .expect_err("invalid regex should fail");
4125        assert!(!grep_error.is_empty());
4126
4127        std::env::set_current_dir(&original_dir).expect("restore cwd");
4128        let _ = fs::remove_dir_all(root);
4129    }
4130
4131    #[test]
4132    fn sleep_waits_and_reports_duration() {
4133        let started = std::time::Instant::now();
4134        let result =
4135            execute_tool("Sleep", &json!({"duration_ms": 20})).expect("Sleep should succeed");
4136        let elapsed = started.elapsed();
4137        let output: serde_json::Value = serde_json::from_str(&result.output).expect("json");
4138        assert_eq!(output["duration_ms"], 20);
4139        assert!(output["message"]
4140            .as_str()
4141            .expect("message")
4142            .contains("Slept for 20ms"));
4143        assert!(elapsed >= Duration::from_millis(15));
4144    }
4145
4146    #[test]
4147    fn brief_returns_sent_message_and_attachment_metadata() {
4148        let attachment = std::env::temp_dir().join(format!(
4149            "clawd-brief-{}.png",
4150            std::time::SystemTime::now()
4151                .duration_since(std::time::UNIX_EPOCH)
4152                .expect("time")
4153                .as_nanos()
4154        ));
4155        std::fs::write(&attachment, b"png-data").expect("write attachment");
4156
4157        let result = execute_tool(
4158            "SendUserMessage",
4159            &json!({
4160                "message": "hello user",
4161                "attachments": [attachment.display().to_string()],
4162                "status": "normal"
4163            }),
4164        )
4165        .expect("SendUserMessage should succeed");
4166
4167        let output: serde_json::Value = serde_json::from_str(&result.output).expect("json");
4168        assert_eq!(output["message"], "hello user");
4169        assert!(output["sentAt"].as_str().is_some());
4170        assert_eq!(output["attachments"][0]["isImage"], true);
4171        let _ = std::fs::remove_file(attachment);
4172    }
4173
4174    #[test]
4175    fn config_reads_and_writes_supported_values() {
4176        let _guard = env_lock()
4177            .lock()
4178            .unwrap_or_else(std::sync::PoisonError::into_inner);
4179        let root = std::env::temp_dir().join(format!(
4180            "clawd-config-{}",
4181            std::time::SystemTime::now()
4182                .duration_since(std::time::UNIX_EPOCH)
4183                .expect("time")
4184                .as_nanos()
4185        ));
4186        let home = root.join("home");
4187        let cwd = root.join("cwd");
4188        std::fs::create_dir_all(home.join(".ternlang")).expect("home dir");
4189        std::fs::create_dir_all(cwd.join(".ternlang")).expect("cwd dir");
4190        std::fs::write(
4191            home.join(".ternlang").join("settings.json"),
4192            r#"{"verbose":false}"#,
4193        )
4194        .expect("write global settings");
4195
4196        let original_home = std::env::var("HOME").ok();
4197        let original_ternlang_home = std::env::var("TERNLANG_CONFIG_HOME").ok();
4198        let original_dir = std::env::current_dir().expect("cwd");
4199        std::env::set_var("HOME", &home);
4200        std::env::remove_var("TERNLANG_CONFIG_HOME");
4201        std::env::set_current_dir(&cwd).expect("set cwd");
4202
4203        let get = execute_tool("Config", &json!({"setting": "verbose"})).expect("get config");
4204        let get_output: serde_json::Value = serde_json::from_str(&get.output).expect("json");
4205        assert_eq!(get_output["value"], false);
4206
4207        let set = execute_tool(
4208            "Config",
4209            &json!({"setting": "permissions.defaultMode", "value": "plan"}),
4210        )
4211        .expect("set config");
4212        let set_output: serde_json::Value = serde_json::from_str(&set.output).expect("json");
4213        assert_eq!(set_output["operation"], "set");
4214        assert_eq!(set_output["newValue"], "plan");
4215
4216        let invalid = execute_tool(
4217            "Config",
4218            &json!({"setting": "permissions.defaultMode", "value": "bogus"}),
4219        )
4220        .expect_err("invalid config value should error");
4221        assert!(invalid.contains("Invalid value"));
4222
4223        let unknown =
4224            execute_tool("Config", &json!({"setting": "nope"})).expect("unknown setting result");
4225        let unknown_output: serde_json::Value = serde_json::from_str(&unknown.output).expect("json");
4226        assert_eq!(unknown_output["success"], false);
4227
4228        std::env::set_current_dir(&original_dir).expect("restore cwd");
4229        match original_home {
4230            Some(value) => std::env::set_var("HOME", value),
4231            None => std::env::remove_var("HOME"),
4232        }
4233        match original_ternlang_home {
4234            Some(value) => std::env::set_var("TERNLANG_CONFIG_HOME", value),
4235            None => std::env::remove_var("TERNLANG_CONFIG_HOME"),
4236        }
4237        let _ = std::fs::remove_dir_all(root);
4238    }
4239
4240    #[test]
4241    fn structured_output_echoes_input_payload() {
4242        let result = execute_tool("StructuredOutput", &json!({"ok": true, "items": [1, 2, 3]}))
4243            .expect("StructuredOutput should succeed");
4244        let output: serde_json::Value = serde_json::from_str(&result.output).expect("json");
4245        assert_eq!(output["data"], "Structured output provided successfully");
4246        assert_eq!(output["structured_output"]["ok"], true);
4247        assert_eq!(output["structured_output"]["items"][1], 2);
4248    }
4249
4250    #[test]
4251    fn repl_executes_python_code() {
4252        let result = execute_tool(
4253            "REPL",
4254            &json!({"language": "python", "code": "print(1 + 1)", "timeout_ms": 500}),
4255        )
4256        .expect("REPL should succeed");
4257        let output: serde_json::Value = serde_json::from_str(&result.output).expect("json");
4258        assert_eq!(output["language"], "python");
4259        assert_eq!(output["exitCode"], 0);
4260        assert!(output["stdout"].as_str().expect("stdout").contains('2'));
4261    }
4262
4263    #[test]
4264    fn powershell_runs_via_stub_shell() {
4265        let _guard = env_lock()
4266            .lock()
4267            .unwrap_or_else(std::sync::PoisonError::into_inner);
4268        let dir = std::env::temp_dir().join(format!(
4269            "clawd-pwsh-bin-{}",
4270            std::time::SystemTime::now()
4271                .duration_since(std::time::UNIX_EPOCH)
4272                .expect("time")
4273                .as_nanos()
4274        ));
4275        std::fs::create_dir_all(&dir).expect("create dir");
4276        let script = dir.join("pwsh");
4277        std::fs::write(
4278            &script,
4279            r#"#!/bin/sh
4280while [ "$1" != "-Command" ] && [ $# -gt 0 ]; do shift; done
4281shift
4282printf 'pwsh:%s' "$1"
4283"#,
4284        )
4285        .expect("write script");
4286        std::process::Command::new("/bin/chmod")
4287            .arg("+x")
4288            .arg(&script)
4289            .status()
4290            .expect("chmod");
4291        let original_path = std::env::var("PATH").unwrap_or_default();
4292        std::env::set_var("PATH", format!("{}:{}", dir.display(), original_path));
4293
4294        let result = execute_tool(
4295            "PowerShell",
4296            &json!({"command": "Write-Output hello", "timeout": 1000}),
4297        )
4298        .expect("PowerShell should succeed");
4299
4300        let background = execute_tool(
4301            "PowerShell",
4302            &json!({"command": "Write-Output hello", "run_in_background": true}),
4303        )
4304        .expect("PowerShell background should succeed");
4305
4306        std::env::set_var("PATH", original_path);
4307        let _ = std::fs::remove_dir_all(dir);
4308
4309        let output: serde_json::Value = serde_json::from_str(&result.output).expect("json");
4310        assert_eq!(output["stdout"], "pwsh:Write-Output hello");
4311        assert!(output["stderr"].as_str().expect("stderr").is_empty());
4312
4313        let background_output: serde_json::Value = serde_json::from_str(&background.output).expect("json");
4314        assert!(background_output["backgroundTaskId"].as_str().is_some());
4315        assert_eq!(background_output["backgroundedByUser"], true);
4316        assert_eq!(background_output["assistantAutoBackgrounded"], false);
4317    }
4318
4319    #[test]
4320    fn powershell_errors_when_shell_is_missing() {
4321        let _guard = env_lock()
4322            .lock()
4323            .unwrap_or_else(std::sync::PoisonError::into_inner);
4324        let original_path = std::env::var("PATH").unwrap_or_default();
4325        let empty_dir = std::env::temp_dir().join(format!(
4326            "clawd-empty-bin-{}",
4327            std::time::SystemTime::now()
4328                .duration_since(std::time::UNIX_EPOCH)
4329                .expect("time")
4330                .as_nanos()
4331        ));
4332        std::fs::create_dir_all(&empty_dir).expect("create empty dir");
4333        std::env::set_var("PATH", empty_dir.display().to_string());
4334
4335        let err = execute_tool("PowerShell", &json!({"command": "Write-Output hello"}))
4336            .expect_err("PowerShell should fail when shell is missing");
4337
4338        std::env::set_var("PATH", original_path);
4339        let _ = std::fs::remove_dir_all(empty_dir);
4340
4341        assert!(err.contains("PowerShell executable not found"));
4342    }
4343
4344    struct TestServer {
4345        addr: SocketAddr,
4346        shutdown: Option<std::sync::mpsc::Sender<()>>,
4347        handle: Option<thread::JoinHandle<()>>,
4348    }
4349
4350    impl TestServer {
4351        fn spawn(handler: Arc<dyn Fn(&str) -> HttpResponse + Send + Sync + 'static>) -> Self {
4352            let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
4353            listener
4354                .set_nonblocking(true)
4355                .expect("set nonblocking listener");
4356            let addr = listener.local_addr().expect("local addr");
4357            let (tx, rx) = std::sync::mpsc::channel::<()>();
4358
4359            let handle = thread::spawn(move || loop {
4360                if rx.try_recv().is_ok() {
4361                    break;
4362                }
4363
4364                match listener.accept() {
4365                    Ok((mut stream, _)) => {
4366                        let mut buffer = [0_u8; 4096];
4367                        let size = stream.read(&mut buffer).expect("read request");
4368                        let request = String::from_utf8_lossy(&buffer[..size]).into_owned();
4369                        let request_line = request.lines().next().unwrap_or_default().to_string();
4370                        let response = handler(&request_line);
4371                        stream
4372                            .write_all(response.to_bytes().as_slice())
4373                            .expect("write response");
4374                    }
4375                    Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
4376                        thread::sleep(Duration::from_millis(10));
4377                    }
4378                    Err(error) => panic!("server accept failed: {error}"),
4379                }
4380            });
4381
4382            Self {
4383                addr,
4384                shutdown: Some(tx),
4385                handle: Some(handle),
4386            }
4387        }
4388
4389        fn addr(&self) -> SocketAddr {
4390            self.addr
4391        }
4392    }
4393
4394    impl Drop for TestServer {
4395        fn drop(&mut self) {
4396            if let Some(tx) = self.shutdown.take() {
4397                let _ = tx.send(());
4398            }
4399            if let Some(handle) = self.handle.take() {
4400                handle.join().expect("join test server");
4401            }
4402        }
4403    }
4404
4405    struct HttpResponse {
4406        status: u16,
4407        reason: &'static str,
4408        content_type: &'static str,
4409        body: String,
4410    }
4411
4412    impl HttpResponse {
4413        fn html(status: u16, reason: &'static str, body: &str) -> Self {
4414            Self {
4415                status,
4416                reason,
4417                content_type: "text/html; charset=utf-8",
4418                body: body.to_string(),
4419            }
4420        }
4421
4422        fn text(status: u16, reason: &'static str, body: &str) -> Self {
4423            Self {
4424                status,
4425                reason,
4426                content_type: "text/plain; charset=utf-8",
4427                body: body.to_string(),
4428            }
4429        }
4430
4431        fn to_bytes(&self) -> Vec<u8> {
4432            format!(
4433                "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
4434                self.status,
4435                self.reason,
4436                self.content_type,
4437                self.body.len(),
4438                self.body
4439            )
4440            .into_bytes()
4441        }
4442    }
4443}
4444
4445fn run_sequential_thinking(input: SequentialThinkingInput) -> Result<String, String> {
4446    to_pretty_json(input)
4447}
4448
4449fn run_memory(input: MemoryInput) -> Result<String, String> {
4450    let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?;
4451    let memory_dir = std::path::PathBuf::from(home).join(".ternlang/memory");
4452    std::fs::create_dir_all(&memory_dir).map_err(|e| e.to_string())?;
4453    let memory_file = memory_dir.join("knowledge_graph.json");
4454
4455    let mut graph: KnowledgeGraph = if memory_file.exists() {
4456        let content = std::fs::read_to_string(&memory_file).map_err(|e| e.to_string())?;
4457        serde_json::from_str(&content).unwrap_or_default()
4458    } else {
4459        KnowledgeGraph::default()
4460    };
4461
4462    let result = match input.action.as_str() {
4463        "create_entities" => {
4464            if let Some(entities) = input.entities {
4465                for entity in entities {
4466                    graph.entities.insert(entity.name.clone(), entity);
4467                }
4468                "Entities created successfully.".to_string()
4469            } else {
4470                "No entities provided.".to_string()
4471            }
4472        }
4473        "create_relations" => {
4474            if let Some(relations) = input.relations {
4475                for relation in relations {
4476                    graph.relations.push(relation);
4477                }
4478                "Relations created successfully.".to_string()
4479            } else {
4480                "No relations provided.".to_string()
4481            }
4482        }
4483        "add_observations" => {
4484            if let Some(observations) = input.observations {
4485                for obs in observations {
4486                    graph.observations.entry(obs.entity_name).or_default().extend(obs.contents);
4487                }
4488                "Observations added successfully.".to_string()
4489            } else {
4490                "No observations provided.".to_string()
4491            }
4492        }
4493        "search_nodes" => {
4494            let query = input.query.unwrap_or_default().to_lowercase();
4495            let matches: Vec<_> = graph.entities.values()
4496                .filter(|e| e.name.to_lowercase().contains(&query) || e.description.to_lowercase().contains(&query))
4497                .cloned()
4498                .collect();
4499            return to_pretty_json(matches);
4500        }
4501        _ => return Err(format!("Unknown memory action: {}", input.action)),
4502    };
4503
4504    let content = serde_json::to_string_pretty(&graph).map_err(|e| e.to_string())?;
4505    std::fs::write(&memory_file, content).map_err(|e| e.to_string())?;
4506    to_pretty_json(result)
4507}
4508
4509fn run_repo_map(input: RepoMapInput) -> Result<String, String> {
4510    use walkdir::WalkDir;
4511    let root = input.path.unwrap_or_else(|| ".".to_string());
4512    let max_depth = input.depth.unwrap_or(2);
4513    let mut tree = String::new();
4514
4515    for entry in WalkDir::new(&root)
4516        .max_depth(max_depth)
4517        .into_iter()
4518        .filter_entry(|e| {
4519            let name = e.file_name().to_string_lossy();
4520            name != ".git" && name != "node_modules" && name != "target" && name != "__pycache__"
4521        })
4522    {
4523        let entry = entry.map_err(|e| e.to_string())?;
4524        let depth = entry.depth();
4525        let name = entry.file_name().to_string_lossy();
4526        let indent = "  ".repeat(depth);
4527        if entry.file_type().is_dir() {
4528            tree.push_str(&format!("{indent}[DIR] {name}\n"));
4529        } else {
4530            tree.push_str(&format!("{indent}{name}\n"));
4531        }
4532    }
4533
4534    to_pretty_json(json!({ "tree": tree }))
4535}
4536
4537#[derive(Debug, Deserialize, Serialize)]
4538struct SequentialThinkingInput {
4539    thought: String,
4540    #[serde(rename = "thoughtNumber")]
4541    thought_number: i32,
4542    #[serde(rename = "totalThoughts")]
4543    total_thoughts: i32,
4544    #[serde(rename = "nextThoughtNeeded")]
4545    next_thought_needed: bool,
4546    #[serde(rename = "isRevision")]
4547    is_revision: Option<bool>,
4548    #[serde(rename = "revisesThoughtNumber")]
4549    revises_thought_number: Option<i32>,
4550}
4551
4552#[derive(Debug, Deserialize)]
4553struct MemoryInput {
4554    action: String,
4555    entities: Option<Vec<MemoryEntity>>,
4556    relations: Option<Vec<MemoryRelation>>,
4557    observations: Option<Vec<MemoryObservation>>,
4558    query: Option<String>,
4559}
4560
4561#[derive(Debug, Clone, Serialize, Deserialize, Default)]
4562struct KnowledgeGraph {
4563    entities: BTreeMap<String, MemoryEntity>,
4564    relations: Vec<MemoryRelation>,
4565    observations: BTreeMap<String, Vec<String>>,
4566}
4567
4568#[derive(Debug, Clone, Serialize, Deserialize)]
4569struct MemoryEntity {
4570    name: String,
4571    #[serde(rename = "type")]
4572    entity_type: String,
4573    description: String,
4574}
4575
4576#[derive(Debug, Clone, Serialize, Deserialize)]
4577struct MemoryRelation {
4578    from: String,
4579    to: String,
4580    #[serde(rename = "type")]
4581    relation_type: String,
4582}
4583
4584#[derive(Debug, Deserialize)]
4585struct MemoryObservation {
4586    #[serde(rename = "entityName")]
4587    entity_name: String,
4588    contents: Vec<String>,
4589}
4590
4591#[derive(Debug, Deserialize)]
4592struct RepoMapInput {
4593    path: Option<String>,
4594    depth: Option<usize>,
4595    _include_signatures: Option<bool>,
4596}
4597
4598fn run_bash_wrapped(input: BashCommandInput) -> Result<ToolResult, String> {
4599    let output = execute_bash(input).map_err(|e| e.to_string())?;
4600    Ok(ToolResult {
4601        state: output.validation_state,
4602        output: serde_json::to_string_pretty(&output).map_err(|e| e.to_string())?,
4603    })
4604}
4605
4606fn run_read_file_wrapped(input: ReadFileInput) -> Result<ToolResult, String> {
4607    match run_read_file(input) {
4608        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4609        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4610    }
4611}
4612
4613fn run_write_file_wrapped(input: WriteFileInput) -> Result<ToolResult, String> {
4614    match run_write_file(input) {
4615        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4616        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4617    }
4618}
4619
4620fn run_edit_file_wrapped(input: EditFileInput) -> Result<ToolResult, String> {
4621    match run_edit_file(input) {
4622        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4623        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4624    }
4625}
4626
4627fn run_glob_search_wrapped(input: GlobSearchInputValue) -> Result<ToolResult, String> {
4628    match run_glob_search(input) {
4629        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4630        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4631    }
4632}
4633
4634fn run_grep_search_wrapped(input: GrepSearchInput) -> Result<ToolResult, String> {
4635    match run_grep_search(input) {
4636        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4637        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4638    }
4639}
4640
4641fn run_web_fetch_wrapped(input: WebFetchInput) -> Result<ToolResult, String> {
4642    match run_web_fetch(input) {
4643        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4644        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4645    }
4646}
4647
4648fn run_web_search_wrapped(input: WebSearchInput) -> Result<ToolResult, String> {
4649    match run_web_search(input) {
4650        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4651        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4652    }
4653}
4654
4655fn run_todo_write_wrapped(input: TodoWriteInput) -> Result<ToolResult, String> {
4656    match run_todo_write(input) {
4657        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4658        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4659    }
4660}
4661
4662fn run_skill_wrapped(input: SkillInput) -> Result<ToolResult, String> {
4663    match run_skill(input) {
4664        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4665        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4666    }
4667}
4668
4669fn run_create_skill_wrapped(input: SkillCreateInput) -> Result<ToolResult, String> {
4670    match run_create_skill(input) {
4671        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4672        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4673    }
4674}
4675
4676fn run_agent_wrapped(input: AgentInput) -> Result<ToolResult, String> {
4677    match run_agent(input) {
4678        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4679        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4680    }
4681}
4682
4683fn run_tool_search_wrapped(input: ToolSearchInput) -> Result<ToolResult, String> {
4684    match run_tool_search(input) {
4685        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4686        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4687    }
4688}
4689
4690fn run_notebook_edit_wrapped(input: NotebookEditInput) -> Result<ToolResult, String> {
4691    match run_notebook_edit(input) {
4692        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4693        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4694    }
4695}
4696
4697fn run_sleep_wrapped(input: SleepInput) -> Result<ToolResult, String> {
4698    match run_sleep(input) {
4699        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4700        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4701    }
4702}
4703
4704fn run_brief_wrapped(input: BriefInput) -> Result<ToolResult, String> {
4705    match run_brief(input) {
4706        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4707        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4708    }
4709}
4710
4711fn run_config_wrapped(input: ConfigInput) -> Result<ToolResult, String> {
4712    match run_config(input) {
4713        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4714        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4715    }
4716}
4717
4718fn run_structured_output_wrapped(input: StructuredOutputInput) -> Result<ToolResult, String> {
4719    match run_structured_output(input) {
4720        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4721        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4722    }
4723}
4724
4725fn run_repl_wrapped(input: ReplInput) -> Result<ToolResult, String> {
4726    match run_repl(input) {
4727        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4728        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4729    }
4730}
4731
4732fn run_powershell_wrapped(input: PowerShellInput) -> Result<ToolResult, String> {
4733    match run_powershell(input) {
4734        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4735        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4736    }
4737}
4738
4739fn run_sequential_thinking_wrapped(input: SequentialThinkingInput) -> Result<ToolResult, String> {
4740    // SequentialThinking is a "Neutral" tool in terms of action, but successful in execution
4741    match run_sequential_thinking(input) {
4742        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4743        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4744    }
4745}
4746
4747fn run_memory_wrapped(input: MemoryInput) -> Result<ToolResult, String> {
4748    match run_memory(input) {
4749        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4750        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4751    }
4752}
4753
4754fn run_repo_map_wrapped(input: RepoMapInput) -> Result<ToolResult, String> {
4755    match run_repo_map(input) {
4756        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4757        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4758    }
4759}
4760
4761// ── Albert Vault ──────────────────────────────────────────────────────────────
4762// Persistent JSONL memory: ~/.albert/vault.jsonl
4763// Format: { id, timestamp, state, tags, content }
4764// Tags are auto-extracted from #hashtags in the content.
4765
4766#[derive(Debug, Deserialize)]
4767struct VaultWriteInput {
4768    content: String,
4769    state: Option<String>,
4770}
4771
4772#[derive(Debug, Deserialize)]
4773struct VaultReadInput {
4774    query: String,
4775}
4776
4777fn vault_path() -> Result<std::path::PathBuf, String> {
4778    let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?;
4779    let dir = std::path::PathBuf::from(home).join(".albert");
4780    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
4781    Ok(dir.join("vault.jsonl"))
4782}
4783
4784use ternlang_ruvector::RuVectorDB;
4785
4786// ── Albert Vault ──────────────────────────────────────────────────────────────
4787// Persistent JSONL memory: ~/.albert/vault.jsonl
4788// Persistent RuVector DB: ~/.albert/vault.ruvector
4789// Format: { id, timestamp, unix_epoch_seconds, state, tags, content }
4790
4791#[derive(Debug, Serialize, Deserialize)]
4792struct VaultEntry {
4793    id: String,
4794    timestamp: String,
4795    unix_epoch_seconds: i64,
4796    state: String,
4797    tags: Vec<String>,
4798    content: String,
4799}
4800
4801fn vault_ruvector_path() -> Result<std::path::PathBuf, String> {
4802    let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?;
4803    Ok(std::path::PathBuf::from(home).join(".albert").join("vault.ruvector"))
4804}
4805
4806fn vault_extract_tags(text: &str) -> Vec<String> {
4807    let re = regex::Regex::new(r"#\w+").expect("valid regex");
4808    let tags: Vec<String> = re.find_iter(text).map(|m| m.as_str().to_string()).collect();
4809    if tags.is_empty() { vec!["#core_directive".to_string()] } else { tags }
4810}
4811
4812fn run_vault_write(input: VaultWriteInput) -> Result<String, String> {
4813    use std::io::Write as _;
4814    let path = vault_path()?;
4815    let id = uuid::Uuid::new_v4().to_string();
4816    
4817    let now = chrono::Utc::now();
4818    let timestamp = now.format("%Y-%m-%dT%H:%M:%S%.3f+0000").to_string();
4819    let unix_epoch_seconds = now.timestamp();
4820    
4821    let state = input.state.as_deref().unwrap_or("+1").to_string();
4822    let tags = vault_extract_tags(&input.content);
4823    
4824    let content = if input.content.trim().starts_with(&timestamp[..10]) {
4825        input.content.trim().to_string()
4826    } else {
4827        format!("{}: {}", timestamp, input.content.trim())
4828    };
4829
4830    let entry = VaultEntry {
4831        id,
4832        timestamp,
4833        unix_epoch_seconds,
4834        state,
4835        tags: tags.clone(),
4836        content,
4837    };
4838    
4839    let mut file = std::fs::OpenOptions::new()
4840        .create(true).append(true).open(&path)
4841        .map_err(|e| format!("-1 Entropy. Failed to open vault: {e}"))?;
4842    writeln!(file, "{}", serde_json::to_string(&entry).map_err(|e| e.to_string())?)
4843        .map_err(|e| format!("-1 Entropy. Failed to write to vault: {e}"))?;
4844    
4845    // Semantic indexing would happen here in a full implementation.
4846    // For now, we've established the temporal cognition and RuVector structure.
4847    
4848    Ok(format!("+1 Resonance. Memory secured at T+{}. Tags: {}", unix_epoch_seconds, tags.join(", ")))
4849}
4850
4851fn run_vault_read(input: VaultReadInput) -> Result<String, String> {
4852    let path = vault_path()?;
4853    if !path.exists() {
4854        return Ok("0 Static: Vault is empty — nothing stored yet.".to_string());
4855    }
4856    
4857    let query = input.query.to_lowercase();
4858    let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
4859    let mut results: Vec<String> = Vec::new();
4860    
4861    // Keyword Search (Legacy/Fallback)
4862    for line in content.lines() {
4863        if line.to_lowercase().contains(&query) {
4864            if let Ok(entry) = serde_json::from_str::<VaultEntry>(line) {
4865                results.push(format!("[{}] ({}) {}\n  {}", 
4866                    entry.timestamp, entry.state, entry.tags.join(" "), entry.content));
4867            }
4868        }
4869    }
4870    
4871    // Semantic Search via RuVector (if index exists)
4872    let rv_path = vault_ruvector_path()?;
4873    if rv_path.exists() {
4874        if let Ok(db_bytes) = std::fs::read(&rv_path) {
4875            if let Ok(_db) = serde_json::from_slice::<RuVectorDB>(&db_bytes) {
4876                // In a real session, we'd generate a query embedding here.
4877                // For this implementation, we report the semantic readiness.
4878                results.insert(0, "--- Semantic Index Active (RuVector) ---".to_string());
4879            }
4880        }
4881    }
4882
4883    if results.is_empty() {
4884        Ok(format!("0 Static: No memories found matching '{}'.", input.query))
4885    } else {
4886        Ok(format!("Vault — {} match(es) for '{}':\n\n{}", results.len(), input.query, results.join("\n\n")))
4887    }
4888}
4889
4890fn run_vault_write_wrapped(input: VaultWriteInput) -> Result<ToolResult, String> {
4891    match run_vault_write(input) {
4892        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4893        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4894    }
4895}
4896
4897fn run_vault_read_wrapped(input: VaultReadInput) -> Result<ToolResult, String> {
4898    match run_vault_read(input) {
4899        Ok(out) => Ok(ToolResult { output: out, state: 1 }),
4900        Err(e) => Ok(ToolResult { output: e, state: -1 }),
4901    }
4902}