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