Spaces:
Sleeping
Sleeping
Claw Web
fix: bash command validation - catch JSON/Python/bare URLs before execution, prevent exit 127 confusion
880ee90 | /** | |
| * Tool executor β handles execution of all claw-web tools. | |
| * EXACT parity with original claw-code Rust mvp_tool_specs (19 tools). | |
| */ | |
| import { exec, spawn, ChildProcess } from "child_process"; | |
| import { promisify } from "util"; | |
| import * as fs from "fs/promises"; | |
| import * as path from "path"; | |
| import { glob as globFn } from "glob"; | |
| import { invokeLLM } from "../_core/llm"; | |
| import { buildSystemPrompt, TOOL_DEFINITIONS } from "../runtime/system-prompt"; | |
| import { McpServerManager, ManagedMcpTool } from "../runtime/mcp-client"; | |
| import { ConfigLoader, getConfig, setConfigValue, getConfigValue, invalidateConfigCache } from "../runtime/config"; | |
| import { PermissionPolicy, PermissionEvaluator, PermissionMode as PermissionModeEnum, PermissionRule, globMatchToolName, globMatchPath } from "../runtime/permissions"; | |
| import { SandboxConfig, SandboxStatus, resolveRequest as resolveSandboxRequest, resolveSandboxStatusForRequest, buildLinuxSandboxCommand, environmentInfo, detectContainerEnvironment } from "../runtime/sandbox"; | |
| import { remoteSessionContextFromEnv, upstreamProxyBootstrapFromEnv } from "../runtime/remote"; | |
| import * as FileOps from "./file-ops"; | |
| import { saveSessionState, loadSessionState } from "../db"; | |
| const execAsync = promisify(exec); | |
| export interface ToolResult { | |
| output: string; | |
| isError: boolean; | |
| durationMs: number; | |
| requiresUserInput?: boolean; | |
| userQuestion?: string; | |
| } | |
| // In-memory state per session | |
| export const todoLists = new Map<number, { content: string; activeForm: string; status: "pending" | "in_progress" | "completed" }[]>(); | |
| const activeTasks = new Map<string, { | |
| id: string; sessionId: number; description: string; | |
| command?: string; | |
| status: "running" | "completed" | "stopped" | "failed"; | |
| createdAt: number; output: string; | |
| process?: ChildProcess; | |
| }>(); | |
| const planModes = new Map<number, { | |
| active: boolean; | |
| steps: { id: number; text: string; status: "pending" | "in_progress" | "done" | "skipped" }[]; | |
| }>(); | |
| const effortLevels = new Map<number, "low" | "medium" | "high">(); | |
| const sessionConfigs = new Map<number, Record<string, string | boolean | number>>(); | |
| const cronJobs = new Map<string, { | |
| id: string; sessionId: number; schedule: string; command: string; | |
| description: string; status: "active" | "paused" | "deleted"; createdAt: number; | |
| timer?: ReturnType<typeof setInterval>; lastRun?: number; runCount: number; output: string; | |
| }>(); | |
| // Hook types matching original claw-code hooks.rs | |
| export interface HookRule { | |
| toolName: string; // "*" for all tools, or specific tool name | |
| action: "allow" | "deny" | "ask" | "script"; | |
| scriptPath?: string; // Path to external script (for action="script") | |
| scriptArgs?: string[]; // Additional args to pass to script | |
| } | |
| export interface HookConfig { | |
| preToolUse: HookRule[]; | |
| postToolUse: HookRule[]; | |
| } | |
| const hooks = new Map<number, HookConfig>(); | |
| // Session-scoped skills store | |
| const sessionSkills = new Map<number, Map<string, { | |
| name: string; description: string; content: string; enabled: boolean; | |
| }>>(); | |
| export function getSessionSkills(sessionId: number) { | |
| if (!sessionSkills.has(sessionId)) sessionSkills.set(sessionId, new Map()); | |
| return sessionSkills.get(sessionId)!; | |
| } | |
| // Session-scoped tasks store | |
| export function getSessionTasks(sessionId: number) { | |
| const tasks: any[] = []; | |
| for (const [, t] of Array.from(activeTasks.entries())) { | |
| if (t.sessionId === sessionId) tasks.push(t); | |
| } | |
| return tasks; | |
| } | |
| export function getActiveTasks() { return activeTasks; } | |
| export function getSessionCronJobs(sessionId: number) { | |
| const jobs: any[] = []; | |
| for (const [, j] of Array.from(cronJobs.entries())) { | |
| if (j.sessionId === sessionId) { | |
| // Don't serialize the timer | |
| const { timer, ...rest } = j; | |
| jobs.push(rest); | |
| } | |
| } | |
| return jobs; | |
| } | |
| const worktrees = new Map<number, { path: string; branch: string; active: boolean }>(); | |
| // ββ Built-in Agent Presets (from original claw-code agents subsystem) ββ | |
| export const BUILTIN_AGENT_PRESETS: Record<string, { | |
| name: string; | |
| description: string; | |
| systemPrompt: string; | |
| model?: string; | |
| }> = { | |
| explore: { | |
| name: "Codebase Explorer", | |
| description: "Explores and maps the codebase structure, identifies key files, patterns, and dependencies.", | |
| systemPrompt: "You are a codebase exploration agent. Your job is to understand the structure of the project, identify key files, map dependencies, and provide a clear overview. Use glob_search, grep_search, and read_file extensively. Produce a structured summary of your findings.", | |
| }, | |
| plan: { | |
| name: "Planning Agent", | |
| description: "Creates detailed multi-step plans for complex tasks with dependency analysis.", | |
| systemPrompt: "You are a planning agent. Analyze the task requirements, break them into concrete steps, identify dependencies between steps, estimate complexity, and produce a structured plan. Use TodoWrite to record the plan. Do NOT execute the plan β only create it.", | |
| }, | |
| verification: { | |
| name: "Verification Agent", | |
| description: "Verifies code changes, runs tests, checks for regressions and edge cases.", | |
| systemPrompt: "You are a verification agent. Review the recent changes, run relevant tests with bash, check for regressions, verify edge cases, and report any issues found. Be thorough and systematic.", | |
| }, | |
| guide: { | |
| name: "Claw Code Guide", | |
| description: "Helps users learn Claw Code features, commands, and best practices.", | |
| systemPrompt: "You are the Claw Code guide agent. Help the user understand how to use Claw Code effectively. Explain available tools, slash commands, configuration options, and best practices. Be friendly and educational.", | |
| }, | |
| general_purpose: { | |
| name: "General Purpose Agent", | |
| description: "A flexible agent that can handle any coding task.", | |
| systemPrompt: "You are a general-purpose coding agent. Complete the assigned task using all available tools. Be efficient and thorough.", | |
| }, | |
| }; | |
| // ββ Permission Modes β delegates to permissions.ts PermissionPolicy ββ | |
| // Keep the same public API (getPermissionMode, setPermissionMode, isToolAllowed, approveToolForSession) | |
| // so agent.ts, chat-endpoint.ts, and tests don't break. | |
| export type PermissionMode = "read_only" | "workspace_write" | "full_access" | "prompt" | "allow"; | |
| // Map executor mode strings to PermissionModeEnum from permissions.ts | |
| const MODE_MAP: Record<PermissionMode, PermissionModeEnum> = { | |
| read_only: PermissionModeEnum.ReadOnly, | |
| workspace_write: PermissionModeEnum.WorkspaceWrite, | |
| full_access: PermissionModeEnum.DangerFullAccess, | |
| prompt: PermissionModeEnum.Prompt, | |
| allow: PermissionModeEnum.Allow, | |
| }; | |
| // Per-session PermissionPolicy instances | |
| const sessionPolicies = new Map<number, PermissionPolicy>(); | |
| // Per-session PermissionEvaluator instances (config-based rules) | |
| const sessionEvaluators = new Map<number, PermissionEvaluator>(); | |
| // Per-session allow-list for "allow" mode (user approves once per tool per session) | |
| const sessionAllowedTools = new Map<number, Set<string>>(); | |
| function getPolicy(sessionId: number): PermissionPolicy { | |
| if (!sessionPolicies.has(sessionId)) { | |
| sessionPolicies.set(sessionId, new PermissionPolicy(PermissionModeEnum.DangerFullAccess)); | |
| } | |
| return sessionPolicies.get(sessionId)!; | |
| } | |
| export function getEvaluator(sessionId: number): PermissionEvaluator { | |
| if (!sessionEvaluators.has(sessionId)) { | |
| sessionEvaluators.set(sessionId, new PermissionEvaluator()); | |
| } | |
| return sessionEvaluators.get(sessionId)!; | |
| } | |
| export function getPermissionMode(sessionId: number): PermissionMode { | |
| const policy = getPolicy(sessionId); | |
| const active = policy.getActiveMode(); | |
| // Reverse map | |
| for (const [k, v] of Object.entries(MODE_MAP)) { | |
| if (v === active) return k as PermissionMode; | |
| } | |
| return "full_access"; | |
| } | |
| export function setPermissionMode(sessionId: number, mode: PermissionMode): string { | |
| const policy = getPolicy(sessionId); | |
| policy.setActiveMode(MODE_MAP[mode]); | |
| return `Permission mode set to: ${mode}`; | |
| } | |
| export function approveToolForSession(sessionId: number, toolName: string): void { | |
| if (!sessionAllowedTools.has(sessionId)) sessionAllowedTools.set(sessionId, new Set()); | |
| sessionAllowedTools.get(sessionId)!.add(toolName); | |
| // Also add as a session rule in the evaluator | |
| getEvaluator(sessionId).addRule({ | |
| toolPattern: toolName, | |
| action: "allow", | |
| scope: "session", | |
| }); | |
| } | |
| export function isToolAllowed(toolName: string, sessionId: number): { allowed: boolean; needsPrompt?: boolean; reason?: string } { | |
| const policy = getPolicy(sessionId); | |
| const evaluator = getEvaluator(sessionId); | |
| // First check evaluator rules (config-based and session-approved) | |
| const evalResult = evaluator.evaluate(toolName); | |
| if (evalResult === "allow") return { allowed: true }; | |
| if (evalResult === "deny") return { allowed: false, reason: `Tool "${toolName}" denied by permission rule.` }; | |
| // Delegate to PermissionPolicy | |
| const outcome = policy.authorize(toolName, ""); | |
| if (outcome.type === "allow") return { allowed: true }; | |
| // Check if this is a prompt-required situation | |
| const mode = getPermissionMode(sessionId); | |
| if (mode === "prompt" || mode === "allow") { | |
| const allowed = sessionAllowedTools.get(sessionId); | |
| if (mode === "allow" && allowed?.has(toolName)) return { allowed: true }; | |
| return { allowed: false, needsPrompt: true, reason: outcome.reason }; | |
| } | |
| return { allowed: false, reason: outcome.reason }; | |
| } | |
| /** | |
| * Format hook warning β matches original format_hook_warning() from hooks.rs. | |
| * Shows hook name, exit code, and stderr output. | |
| */ | |
| function formatHookWarning(hookPath: string, exitCode: number, stderr: string): string { | |
| const parts = [`Hook warning: ${hookPath} exited with code ${exitCode}`]; | |
| if (stderr.trim()) { | |
| parts.push(`stderr: ${stderr.trim()}`); | |
| } | |
| return parts.join("\n"); | |
| } | |
| // ββ Pre-Tool Hooks (matches original hooks.rs run_pre_tool_use_hooks) ββ | |
| // Exit code semantics: 0=allow, 2=deny, other=warn+allow | |
| export async function runPreToolHooks( | |
| toolName: string, sessionId: number, toolArgs: Record<string, unknown>, workDir: string | |
| ): Promise<{ allowed: boolean; message?: string }> { | |
| const sessionHooks = hooks.get(sessionId); | |
| if (!sessionHooks?.preToolUse?.length) return { allowed: true }; | |
| for (const hook of sessionHooks.preToolUse) { | |
| if (hook.toolName !== "*" && hook.toolName !== toolName) continue; | |
| switch (hook.action) { | |
| case "allow": continue; | |
| case "deny": | |
| return { allowed: false, message: `Tool "${toolName}" denied by pre-tool hook rule.` }; | |
| case "ask": | |
| return { allowed: false, message: `Tool "${toolName}" requires user confirmation (hook rule). Approve to continue.` }; | |
| case "script": { | |
| if (!hook.scriptPath) continue; | |
| try { | |
| const { execSync: hookExec } = await import("child_process"); | |
| // CommandWithStdin pattern β pipe JSON payload to stdin (matches original hooks.rs EXACTLY) | |
| const toolInputJson = JSON.stringify(toolArgs); | |
| const parsedToolInput = toolArgs; // already parsed object | |
| const hookPayload = JSON.stringify({ | |
| hook_event_name: "PreToolUse", | |
| tool_name: toolName, | |
| tool_input: parsedToolInput, | |
| tool_input_json: toolInputJson, | |
| tool_output: null, | |
| tool_result_is_error: false, | |
| }); | |
| // Set env vars matching original hooks.rs run_command() | |
| const hookEnv: Record<string, string> = { | |
| ...process.env as Record<string, string>, | |
| HOOK_EVENT: "PreToolUse", | |
| HOOK_TOOL_NAME: toolName, | |
| HOOK_TOOL_INPUT: toolInputJson, | |
| HOOK_TOOL_IS_ERROR: "0", | |
| }; | |
| const scriptResult = hookExec( | |
| `"${hook.scriptPath}" ${(hook.scriptArgs || []).join(" ")}`, | |
| { cwd: workDir, timeout: 10000, encoding: "utf-8", input: hookPayload, env: hookEnv } | |
| ); | |
| // Script returns JSON: { "decision": "allow" | "deny", "reason": "..." } | |
| try { | |
| const parsed = JSON.parse(scriptResult.trim()); | |
| if (parsed.decision === "deny") { | |
| return { allowed: false, message: parsed.reason || `Denied by hook script: ${hook.scriptPath}` }; | |
| } | |
| } catch { | |
| // Non-JSON output = allow | |
| } | |
| } catch (e: any) { | |
| // Exit code 2 = deny (matches original hooks.rs semantics) | |
| const exitCode = e.status ?? e.code ?? 1; | |
| const stderr = (e.stderr || "").toString(); | |
| if (exitCode === 2) { | |
| // Exit code 2 = explicit deny | |
| const reason = stderr.trim() || e.stdout?.toString().trim() || `Denied by hook script: ${hook.scriptPath}`; | |
| return { allowed: false, message: reason }; | |
| } | |
| // Other non-zero = warn but allow (matches original) | |
| const warning = formatHookWarning(hook.scriptPath, exitCode, stderr); | |
| return { allowed: true, message: warning }; | |
| } | |
| break; | |
| } | |
| } | |
| } | |
| return { allowed: true }; | |
| } | |
| // ββ Post-Tool Hooks (matches original hooks.rs run_post_tool_use_hooks) ββ | |
| // Exit code semantics: 0=allow, 2=deny (override result), other=warn | |
| export async function runPostToolHooks( | |
| toolName: string, sessionId: number, result: ToolResult, workDir?: string | |
| ): Promise<ToolResult> { | |
| const sessionHooks = hooks.get(sessionId); | |
| if (!sessionHooks?.postToolUse?.length) return result; | |
| for (const hook of sessionHooks.postToolUse) { | |
| if (hook.toolName !== "*" && hook.toolName !== toolName) continue; | |
| switch (hook.action) { | |
| case "allow": | |
| // Informational β no action | |
| break; | |
| case "deny": | |
| result.isError = true; | |
| result.output += `\n[PostHook:deny] Tool ${toolName} result rejected by hook rule.`; | |
| break; | |
| case "ask": | |
| result.output += `\n[PostHook:ask] Tool ${toolName} completed β review requested.`; | |
| break; | |
| case "script": { | |
| if (!hook.scriptPath) continue; | |
| try { | |
| const { execSync: hookExec } = await import("child_process"); | |
| // CommandWithStdin pattern β pipe JSON payload to stdin (matches original hooks.rs EXACTLY) | |
| const postToolInputJson = JSON.stringify(hook._lastToolArgs || {}); | |
| const postHookPayload = JSON.stringify({ | |
| hook_event_name: "PostToolUse", | |
| tool_name: toolName, | |
| tool_input: hook._lastToolArgs || {}, | |
| tool_input_json: postToolInputJson, | |
| tool_output: result.output.slice(0, 5000), | |
| tool_result_is_error: result.isError, | |
| }); | |
| // Set env vars matching original hooks.rs run_command() | |
| const postHookEnv: Record<string, string> = { | |
| ...process.env as Record<string, string>, | |
| HOOK_EVENT: "PostToolUse", | |
| HOOK_TOOL_NAME: toolName, | |
| HOOK_TOOL_INPUT: postToolInputJson, | |
| HOOK_TOOL_IS_ERROR: result.isError ? "1" : "0", | |
| HOOK_TOOL_OUTPUT: result.output.slice(0, 5000), | |
| }; | |
| const scriptResult = hookExec( | |
| `"${hook.scriptPath}" ${(hook.scriptArgs || []).join(" ")}`, | |
| { cwd: workDir || process.cwd(), timeout: 10000, encoding: "utf-8", input: postHookPayload, env: postHookEnv } | |
| ); | |
| if (scriptResult.trim()) { | |
| result.output += `\n[PostHook:script] ${scriptResult.trim()}`; | |
| } | |
| } catch (e: any) { | |
| // Exit code 2 = deny (matches original hooks.rs semantics) | |
| const exitCode = e.status ?? e.code ?? 1; | |
| const stderr = (e.stderr || "").toString(); | |
| if (exitCode === 2) { | |
| result.isError = true; | |
| result.output += `\n[PostHook:deny] ${stderr.trim() || "Post-hook denied result"}`; | |
| } else { | |
| // Other non-zero = warn | |
| const warning = formatHookWarning(hook.scriptPath, exitCode, stderr); | |
| result.output += `\n[PostHook:warn] ${warning}`; | |
| } | |
| } | |
| break; | |
| } | |
| } | |
| } | |
| return result; | |
| } | |
| /** | |
| * Get all available tools with descriptions β EXACT parity with original claw-code (19 tools) | |
| */ | |
| export function getToolList(): { name: string; description: string; category: string }[] { | |
| return [ | |
| // Core (from mvp_tool_specs) | |
| { name: "bash", description: "Execute a shell command in the current workspace", category: "Shell" }, | |
| { name: "PowerShell", description: "Execute a PowerShell command with optional timeout", category: "Shell" }, | |
| { name: "read_file", description: "Read a text file from the workspace", category: "File" }, | |
| { name: "write_file", description: "Write a text file in the workspace", category: "File" }, | |
| { name: "edit_file", description: "Replace text in a workspace file", category: "File" }, | |
| { name: "glob_search", description: "Find files by glob pattern", category: "File" }, | |
| { name: "grep_search", description: "Search file contents with a regex pattern", category: "File" }, | |
| { name: "NotebookEdit", description: "Replace, insert, or delete a cell in a Jupyter notebook", category: "File" }, | |
| { name: "WebSearch", description: "Search the web for current information and return cited results", category: "Web" }, | |
| { name: "WebFetch", description: "Fetch a URL, convert it into readable text, and answer a prompt about it", category: "Web" }, | |
| { name: "TodoWrite", description: "Update the structured task list for the current session", category: "Task" }, | |
| { name: "Agent", description: "Launch a specialized agent task and persist its handoff metadata", category: "Agent" }, | |
| { name: "SendUserMessage", description: "Send a message to the user", category: "Interaction" }, | |
| { name: "Brief", description: "Send a brief message to the user (alias of SendUserMessage)", category: "Interaction" }, | |
| { name: "TestingPermission", description: "Test permission system (internal)", category: "System" }, | |
| { name: "ToolSearch", description: "Search for deferred or specialized tools by exact name or keywords", category: "System" }, | |
| { name: "Config", description: "Get or set Claw Code settings", category: "System" }, | |
| { name: "Skill", description: "Load a local skill definition and its instructions", category: "Skill" }, | |
| { name: "Sleep", description: "Wait for a specified duration without holding a shell process", category: "System" }, | |
| { name: "REPL", description: "Execute code in a REPL-like subprocess", category: "System" }, | |
| { name: "StructuredOutput", description: "Return structured output in the requested format", category: "System" }, | |
| // Extended (web-app features beyond mvp) | |
| { name: "mcp_tool", description: "Execute an MCP tool", category: "MCP" }, | |
| { name: "list_mcp_resources", description: "List MCP resources", category: "MCP" }, | |
| { name: "read_mcp_resource", description: "Read an MCP resource", category: "MCP" }, | |
| { name: "mcp_auth", description: "Authenticate with MCP server", category: "MCP" }, | |
| // Extended tools (full parity with original claw-code) | |
| { name: "TaskCreate", description: "Create a background task with a command or prompt", category: "Task" }, | |
| { name: "TaskGet", description: "Get the status of a background task by ID", category: "Task" }, | |
| { name: "TaskList", description: "List all running and completed background tasks", category: "Task" }, | |
| { name: "TaskOutput", description: "Get the stdout/stderr output of a background task", category: "Task" }, | |
| { name: "TaskStop", description: "Stop a running background task", category: "Task" }, | |
| { name: "TaskUpdate", description: "Update parameters of a background task", category: "Task" }, | |
| { name: "CronCreate", description: "Create a scheduled cron job", category: "Cron" }, | |
| { name: "CronDelete", description: "Delete a scheduled cron job", category: "Cron" }, | |
| { name: "CronList", description: "List all scheduled cron jobs", category: "Cron" }, | |
| { name: "LSP", description: "Language Server Protocol: diagnostics, go-to-definition, symbols, hover, references", category: "LSP" }, | |
| { name: "EnterPlanMode", description: "Enter plan mode for structured multi-step task execution", category: "Plan" }, | |
| { name: "ExitPlanMode", description: "Exit plan mode and return to normal execution", category: "Plan" }, | |
| { name: "EnterWorktree", description: "Enter a git worktree for isolated branch work", category: "Git" }, | |
| { name: "ExitWorktree", description: "Exit the current git worktree", category: "Git" }, | |
| { name: "TeamCreate", description: "Create a multi-agent team for parallel task execution", category: "Team" }, | |
| { name: "TeamDelete", description: "Delete a multi-agent team", category: "Team" }, | |
| { name: "RemoteTrigger", description: "Trigger a remote webhook or API endpoint", category: "Remote" }, | |
| { name: "SyntheticOutput", description: "Generate synthetic output for testing or templating", category: "System" }, | |
| ]; | |
| } | |
| /** | |
| * Execute a tool by name with given arguments. | |
| * Tool names match original claw-code EXACTLY. | |
| */ | |
| export async function executeTool( | |
| toolName: string, | |
| args: Record<string, unknown>, | |
| sessionId: number, | |
| workDir: string = "/home/ubuntu" | |
| ): Promise<ToolResult> { | |
| const start = Date.now(); | |
| try { | |
| let output: string; | |
| let requiresUserInput = false; | |
| let userQuestion: string | undefined; | |
| // Permission check | |
| const permCheck = isToolAllowed(toolName, sessionId); | |
| if (!permCheck.allowed) { | |
| return { output: permCheck.reason || `Tool "${toolName}" is not allowed`, isError: true, durationMs: Date.now() - start }; | |
| } | |
| // Pre-tool hooks (matches original hooks.rs) | |
| const hookCheck = await runPreToolHooks(toolName, sessionId, args, workDir); | |
| if (!hookCheck.allowed) { | |
| return { output: hookCheck.message || `Tool "${toolName}" blocked by pre-tool hook`, isError: true, durationMs: Date.now() - start }; | |
| } | |
| switch (toolName) { | |
| // ββ Core 19 tools (exact original names) ββ | |
| case "bash": output = await executeBash(args, workDir, sessionId); break; | |
| case "PowerShell": output = await executePowerShell(args, workDir); break; | |
| case "read_file": output = await executeReadFile(args, workDir); break; | |
| case "write_file": output = await executeWriteFile(args, workDir); break; | |
| case "edit_file": output = await executeEditFile(args, workDir); break; | |
| case "glob_search": output = await executeGlobSearch(args, workDir); break; | |
| case "grep_search": output = await executeGrepSearch(args, workDir); break; | |
| case "NotebookEdit": output = await executeNotebookEdit(args, workDir); break; | |
| case "WebSearch": output = await executeWebSearch(args); break; | |
| case "WebFetch": output = await executeWebFetch(args); break; | |
| case "TodoWrite": output = executeTodoWrite(args, sessionId); break; | |
| case "Agent": output = await executeAgentReal(args, sessionId, workDir); break; | |
| case "SendUserMessage": | |
| case "Brief": { | |
| const message = String(args.message || ""); | |
| if (!message) throw new Error("No message provided"); | |
| const attachments = args.attachments as string[] | undefined; | |
| const status = String(args.status || "normal"); | |
| const resolvedAttachments = attachments ? attachments.map((p: string) => { | |
| try { | |
| const fs = require("fs"); | |
| const stat = fs.statSync(p); | |
| const ext = p.split(".").pop()?.toLowerCase() || ""; | |
| return { path: p, size: stat.size, isImage: ["png","jpg","jpeg","gif","svg","webp","bmp"].includes(ext) }; | |
| } catch { return { path: p, size: 0, isImage: false }; } | |
| }) : undefined; | |
| output = JSON.stringify({ | |
| message, | |
| attachments: resolvedAttachments, | |
| sentAt: new Date().toISOString(), | |
| }); | |
| if (status === "proactive") { | |
| // Proactive messages are informational, don't require user input | |
| } else { | |
| requiresUserInput = true; | |
| userQuestion = message; | |
| } | |
| break; | |
| } | |
| case "TestingPermission": { | |
| // Internal testing tool for permission system verification | |
| const testTool = String(args.tool || ""); | |
| const testAction = String(args.action || "check"); | |
| if (testAction === "check") { | |
| const allowed = isToolAllowed(testTool, sessionId); | |
| output = JSON.stringify({ tool: testTool, allowed: allowed.allowed, reason: allowed.reason || "ok" }); | |
| } else { | |
| output = JSON.stringify({ error: "Unknown action. Use 'check'." }); | |
| } | |
| break; | |
| } | |
| case "ToolSearch": output = executeToolSearch(args); break; | |
| case "Config": output = executeConfig(args, sessionId, workDir); break; | |
| case "Skill": output = await executeSkill(args, workDir, sessionId); break; | |
| case "Sleep": output = await executeSleep(args); break; | |
| case "REPL": output = await executeREPL(args, workDir); break; | |
| case "StructuredOutput": output = executeStructuredOutput(args); break; | |
| // ββ Extended web-app tools ββ | |
| case "mcp_tool": output = await executeMcpTool(args); break; | |
| case "list_mcp_resources": output = await executeListMcpResources(args); break; | |
| case "read_mcp_resource": output = await executeReadMcpResource(args); break; | |
| case "mcp_auth": output = executeMcpAuth(args); break; | |
| // ββ Legacy aliases (backward compat) ββ | |
| case "grep": output = await executeGrepSearch(args, workDir); break; | |
| case "glob": output = await executeGlobSearch(args, workDir); break; | |
| case "web_search": output = await executeWebSearch(args); break; | |
| case "web_fetch": output = await executeWebFetch(args); break; | |
| case "todo_read": output = executeTodoRead(sessionId); break; | |
| case "TodoRead": output = executeTodoRead(sessionId); break; | |
| case "todo_write": output = executeTodoWrite(args, sessionId); break; | |
| case "sub_agent": output = await executeAgentReal(args, sessionId, workDir); break; | |
| case "send_message": output = executeSendMessage(args); break; | |
| case "ask_user": { | |
| const question = String(args.question || args.text || args.message || ""); | |
| if (!question) throw new Error("No question provided"); | |
| output = `[WAITING FOR USER] ${question}`; | |
| requiresUserInput = true; | |
| userQuestion = question; | |
| break; | |
| } | |
| case "powershell": output = await executePowerShell(args, workDir); break; | |
| case "tool_search": output = executeToolSearch(args); break; | |
| case "config_read": output = executeConfigRead(args, sessionId); break; | |
| case "config_write": output = executeConfigWrite(args, sessionId); break; | |
| case "notebook_edit": output = await executeNotebookEdit(args, workDir); break; | |
| case "skill": output = await executeSkill(args, workDir, sessionId); break; | |
| case "task_create": output = executeTaskCreate(args, sessionId, workDir); break; | |
| case "task_list": output = executeTaskList(sessionId); break; | |
| case "task_stop": output = executeTaskStop(args); break; | |
| case "task_get": output = executeTaskGet(args); break; | |
| case "task_update": output = executeTaskUpdate(args); break; | |
| case "plan_create": output = executePlanCreate(args, sessionId); break; | |
| case "plan_update": output = executePlanUpdate(args, sessionId); break; | |
| case "enter_plan_mode": output = executeEnterPlanMode(sessionId); break; | |
| case "exit_plan_mode": output = executeExitPlanMode(sessionId); break; | |
| case "cron_create": output = executeCronCreate(args, sessionId); break; | |
| case "cron_list": output = executeCronList(sessionId); break; | |
| case "cron_delete": output = executeCronDelete(args); break; | |
| case "team_create": output = await executeTeamCreate(args, sessionId); break; | |
| case "team_delete": output = executeTeamDelete(args); break; | |
| case "enter_worktree": output = await executeEnterWorktree(args, sessionId, workDir); break; | |
| case "exit_worktree": output = await executeExitWorktree(sessionId, workDir); break; | |
| case "lsp": output = await executeLsp(args, workDir); break; | |
| case "remote_trigger": output = await executeRemoteTrigger(args); break; | |
| // ββ PascalCase aliases (original claw-code tool names) ββ | |
| case "TaskCreate": output = executeTaskCreate(args, sessionId, workDir); break; | |
| case "TaskGet": output = executeTaskGet(args); break; | |
| case "TaskList": output = executeTaskList(sessionId); break; | |
| case "TaskOutput": output = executeTaskOutput(args); break; | |
| case "TaskStop": output = executeTaskStop(args); break; | |
| case "TaskUpdate": output = executeTaskUpdate(args); break; | |
| case "CronCreate": output = executeCronCreate(args, sessionId); break; | |
| case "CronDelete": output = executeCronDelete(args); break; | |
| case "CronList": output = executeCronList(sessionId); break; | |
| case "LSP": output = await executeLsp(args, workDir); break; | |
| case "EnterPlanMode": output = executeEnterPlanMode(sessionId); break; | |
| case "ExitPlanMode": output = executeExitPlanMode(sessionId); break; | |
| case "EnterWorktree": output = await executeEnterWorktree(args, sessionId, workDir); break; | |
| case "ExitWorktree": output = await executeExitWorktree(sessionId, workDir); break; | |
| case "TeamCreate": output = await executeTeamCreate(args, sessionId); break; | |
| case "TeamDelete": output = executeTeamDelete(args); break; | |
| case "RemoteTrigger": output = await executeRemoteTrigger(args); break; | |
| case "SyntheticOutput": output = executeSyntheticOutput(args); break; | |
| default: | |
| // βββ Dynamic MCP tool routing βββββββββββββββββββββββββββββββββ | |
| // When MCP tools are injected into the LLM's tool list, the LLM | |
| // will call them by their qualified name (mcp__servername__toolname). | |
| // Route these calls through McpServerManager.callTool(). | |
| if (toolName.startsWith("mcp__") && mcpManager) { | |
| try { | |
| const result = await mcpManager.callTool(toolName, args); | |
| const textParts = result.content | |
| .filter((c: any) => c.type === "text" && c.text) | |
| .map((c: any) => c.text); | |
| output = textParts.join("\n") || JSON.stringify(result.content); | |
| } catch (err: any) { | |
| output = `MCP tool error: ${err.message}`; | |
| return { output, isError: true, durationMs: Date.now() - start }; | |
| } | |
| break; | |
| } | |
| output = `Unknown tool: ${toolName}`; | |
| return { output, isError: true, durationMs: Date.now() - start }; | |
| } | |
| let result: ToolResult = { output, isError: false, durationMs: Date.now() - start, requiresUserInput, userQuestion }; | |
| // Post-hooks are called in agent.ts after executeTool β NOT here to avoid double execution | |
| return result; | |
| } catch (error: any) { | |
| return { | |
| output: `Error: ${error.message || String(error)}`, | |
| isError: true, | |
| durationMs: Date.now() - start, | |
| }; | |
| } | |
| } | |
| // βββ Plan Mode Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export function getPlanMode(sessionId: number) { | |
| if (!planModes.has(sessionId)) { | |
| const persisted = loadSessionState<{ active: boolean; steps: { id: number; text: string; status: string }[] }>(sessionId, "planMode"); | |
| if (persisted) planModes.set(sessionId, persisted as any); | |
| } | |
| return planModes.get(sessionId) || { active: false, steps: [] }; | |
| } | |
| export function setPlanMode(sessionId: number, active: boolean) { | |
| const current = planModes.get(sessionId) || { active: false, steps: [] }; | |
| current.active = active; | |
| planModes.set(sessionId, current); | |
| saveSessionState(sessionId, "planMode", current); | |
| } | |
| export function addPlanStep(sessionId: number, text: string) { | |
| const plan = planModes.get(sessionId) || { active: true, steps: [] }; | |
| plan.steps.push({ id: plan.steps.length + 1, text, status: "pending" }); | |
| planModes.set(sessionId, plan); | |
| saveSessionState(sessionId, "planMode", plan); | |
| } | |
| export function updatePlanStep(sessionId: number, stepId: number, status: "pending" | "in_progress" | "done" | "skipped") { | |
| const plan = planModes.get(sessionId); | |
| if (!plan) return; | |
| const step = plan.steps.find(s => s.id === stepId); | |
| if (step) { | |
| step.status = status; | |
| saveSessionState(sessionId, "planMode", plan); | |
| } | |
| } | |
| // βββ Effort Level Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export function getEffortLevel(sessionId: number): "low" | "medium" | "high" { | |
| return effortLevels.get(sessionId) || "high"; | |
| } | |
| export function setEffortLevel(sessionId: number, level: "low" | "medium" | "high") { | |
| effortLevels.set(sessionId, level); | |
| } | |
| // βββ Hooks Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export function getHooks(sessionId: number) { | |
| return hooks.get(sessionId) || { preToolUse: [], postToolUse: [] }; | |
| } | |
| export function setHooks(sessionId: number, h: HookConfig) { | |
| hooks.set(sessionId, h); | |
| } | |
| export function setHook(sessionId: number, type: "preToolUse" | "postToolUse", toolName: string, action: string) { | |
| if (!hooks.has(sessionId)) hooks.set(sessionId, { preToolUse: [], postToolUse: [] }); | |
| const h = hooks.get(sessionId)!; | |
| if (type === "preToolUse") { | |
| h.preToolUse = h.preToolUse.filter(x => x.toolName !== toolName); | |
| h.preToolUse.push({ toolName, action: action as any }); | |
| } else { | |
| h.postToolUse = h.postToolUse.filter(x => x.toolName !== toolName); | |
| h.postToolUse.push({ toolName, action: action as HookRule["action"] }); | |
| } | |
| } | |
| export function removeHook(sessionId: number, type: "preToolUse" | "postToolUse", toolName: string) { | |
| const h = hooks.get(sessionId); | |
| if (!h) return; | |
| if (type === "preToolUse") h.preToolUse = h.preToolUse.filter(x => x.toolName !== toolName); | |
| else h.postToolUse = h.postToolUse.filter(x => x.toolName !== toolName); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // TOOL IMPLEMENTATIONS β Original 19 tools | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // βββ 1. bash ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** | |
| * BashCommandInput/Output types matching original bash.rs: | |
| * - command: string | |
| * - timeout: number (seconds, default 30, max 300) | |
| * - description: optional human-readable description | |
| */ | |
| interface BashCommandInput { | |
| command: string; | |
| timeout: number; | |
| description?: string; | |
| } | |
| interface BashCommandOutput { | |
| stdout: string; | |
| stderr: string; | |
| exitCode: number; | |
| interrupted: boolean; | |
| durationMs: number; | |
| } | |
| /** Max output size before truncation (matches original MAX_OUTPUT_LENGTH = 30000) */ | |
| const BASH_MAX_OUTPUT_LENGTH = 30000; | |
| /** Truncate output matching original truncate_output() */ | |
| function truncateBashOutput(output: string): string { | |
| if (output.length <= BASH_MAX_OUTPUT_LENGTH) return output; | |
| const half = Math.floor(BASH_MAX_OUTPUT_LENGTH / 2); | |
| const head = output.slice(0, half); | |
| const tail = output.slice(-half); | |
| const omitted = output.length - BASH_MAX_OUTPUT_LENGTH; | |
| return `${head}\n\n... (${omitted} characters omitted) ...\n\n${tail}`; | |
| } | |
| // Per-session sandbox status cache | |
| const sessionSandboxStatus = new Map<number, SandboxStatus>(); | |
| export function getSandboxStatus(sessionId: number, workDir: string): SandboxStatus { | |
| if (!sessionSandboxStatus.has(sessionId)) { | |
| const config = { enabled: false, allowedMounts: [] } as SandboxConfig; | |
| const request = resolveSandboxRequest(config); | |
| sessionSandboxStatus.set(sessionId, resolveSandboxStatusForRequest(request, workDir)); | |
| } | |
| return sessionSandboxStatus.get(sessionId)!; | |
| } | |
| export function enableSandbox(sessionId: number, workDir: string): SandboxStatus { | |
| const config: SandboxConfig = { enabled: true, namespaceRestrictions: true, networkIsolation: false, allowedMounts: [workDir] }; | |
| const request = resolveSandboxRequest(config); | |
| const status = resolveSandboxStatusForRequest(request, workDir); | |
| sessionSandboxStatus.set(sessionId, status); | |
| return status; | |
| } | |
| /** | |
| * Validate a bash command before execution. | |
| * Returns a helpful error message if the command is clearly not valid bash, | |
| * or null if the command looks OK to execute. | |
| */ | |
| function validateBashCommand(command: string): string | null { | |
| const trimmed = command.trim(); | |
| // Detect JSON/Python dict pasted as bash command | |
| if (/^\{[\s\S]*\}$/.test(trimmed) && !trimmed.startsWith("${")) { | |
| // Looks like JSON or Python dict, not a bash command | |
| // Exception: bash command substitution like ${VAR} or { cmd1; cmd2; } (bash group) | |
| if (trimmed.includes('"') && (trimmed.includes(':') || trimmed.includes(','))) { | |
| return "ERROR: You sent JSON/Python dict as a bash command. Use a proper shell command instead.\nExample: If you want to write JSON to a file, use: echo '{...}' > file.json\nIf you want to run Python code, use: python3 -c 'your_code_here'"; | |
| } | |
| } | |
| // Detect Python code pasted as bash command | |
| if (/^(import |from .+ import |def |class |print\(|if __name__)/.test(trimmed)) { | |
| return "ERROR: You sent Python code as a bash command. To run Python code:\n1. Write it to a file: write_file with the code\n2. Then run: bash with 'python3 filename.py'\nOr use: python3 -c 'your_code_here'"; | |
| } | |
| // Detect bare URL without a command | |
| if (/^https?:\/\//.test(trimmed) && !trimmed.includes(' ')) { | |
| return `ERROR: You sent a bare URL as a bash command. Use curl to fetch it:\ncurl -s '${trimmed}'`; | |
| } | |
| // Detect common "command not found" patterns where the model uses wrong command names | |
| const commandFixes: Record<string, string> = { | |
| 'pip ': 'pip3', | |
| 'pip\n': 'pip3', | |
| 'python ': 'python3', | |
| 'python\n': 'python3', | |
| }; | |
| for (const [wrong, right] of Object.entries(commandFixes)) { | |
| if (trimmed.startsWith(wrong)) { | |
| return `ERROR: '${wrong.trim()}' is not installed. Use '${right}' instead.\nFixed command: ${trimmed.replace(wrong.trim(), right)}`; | |
| } | |
| } | |
| return null; // Command looks OK | |
| } | |
| async function executeBash(args: Record<string, unknown>, workDir: string, sessionId?: number): Promise<string> { | |
| const command = String(args.command || ""); | |
| if (!command) throw new Error("No command provided"); | |
| // Validate command before execution | |
| const validationError = validateBashCommand(command); | |
| if (validationError) { | |
| throw new Error(validationError); | |
| } | |
| const input: BashCommandInput = { | |
| command, | |
| timeout: Math.min(Number(args.timeout) || 30, 300), | |
| description: args.description ? String(args.description) : undefined, | |
| }; | |
| const timeoutMs = input.timeout * 1000; | |
| const startTime = Date.now(); | |
| // Check sandbox status and optionally wrap command | |
| let actualCommand = command; | |
| let execEnv: Record<string, string | undefined> = { ...process.env, HOME: "/home/ubuntu" }; | |
| if (sessionId !== undefined) { | |
| const sandboxStatus = getSandboxStatus(sessionId, workDir); | |
| if (sandboxStatus.enabled && sandboxStatus.namespaceActive) { | |
| const sandboxCmd = buildLinuxSandboxCommand(command, workDir, sandboxStatus); | |
| if (sandboxCmd) { | |
| actualCommand = [sandboxCmd.program, ...sandboxCmd.args].join(" "); | |
| for (const [k, v] of sandboxCmd.env) { | |
| execEnv[k] = v; | |
| } | |
| } | |
| } | |
| } | |
| try { | |
| const { stdout, stderr } = await execAsync(actualCommand, { | |
| cwd: workDir, timeout: timeoutMs, maxBuffer: 1024 * 1024 * 5, | |
| env: execEnv as NodeJS.ProcessEnv, | |
| killSignal: "SIGTERM", // Match original: SIGTERM first | |
| }); | |
| const output: BashCommandOutput = { | |
| stdout: stdout || "", | |
| stderr: stderr || "", | |
| exitCode: 0, | |
| interrupted: false, | |
| durationMs: Date.now() - startTime, | |
| }; | |
| let result = ""; | |
| if (output.stdout) result += output.stdout; | |
| if (output.stderr) result += (result ? "\n" : "") + "STDERR: " + output.stderr; | |
| return truncateBashOutput(result || "(no output)"); | |
| } catch (error: any) { | |
| const output: BashCommandOutput = { | |
| stdout: error.stdout || "", | |
| stderr: error.stderr || "", | |
| exitCode: error.code ?? 1, | |
| interrupted: !!error.killed, | |
| durationMs: Date.now() - startTime, | |
| }; | |
| if (output.interrupted) { | |
| // Match original: "Command timed out" with SIGTERM/SIGKILL semantics | |
| throw new Error( | |
| `Command timed out after ${input.timeout}s (SIGTERM sent)\n` + | |
| (output.stdout ? truncateBashOutput(output.stdout) : "") + | |
| (output.stderr ? "\nSTDERR: " + truncateBashOutput(output.stderr) : "") | |
| ); | |
| } | |
| const parts = []; | |
| if (output.stdout) parts.push(truncateBashOutput(output.stdout)); | |
| if (output.stderr) parts.push("STDERR: " + truncateBashOutput(output.stderr)); | |
| if (parts.length === 0) parts.push(error.message); | |
| // Match original: include exit code in error output | |
| throw new Error(`Exit code ${output.exitCode}\n${parts.join("\n")}`); | |
| } | |
| } | |
| // βββ 2. read_file (params: path, offset, limit β NOT startLine/endLine) ββββ | |
| async function executeReadFile(args: Record<string, unknown>, workDir: string): Promise<string> { | |
| const filePath = resolvePath(String(args.path || ""), workDir); | |
| // Check for binary files first | |
| if (FileOps.isBinaryFile(filePath)) { | |
| return `File: ${filePath} β binary file, cannot display as text.`; | |
| } | |
| const offset = args.offset !== undefined ? Math.max(Number(args.offset), 0) : undefined; | |
| const limit = args.limit !== undefined ? Math.max(Number(args.limit), 1) : undefined; | |
| const result = FileOps.readFile(filePath, offset, limit); | |
| const lines = result.file.content.split("\n"); | |
| const numbered = lines.map((line, i) => `${result.file.startLine + i}: ${line}`).join("\n"); | |
| return `File: ${result.file.filePath} (${result.file.totalLines} lines, showing ${result.file.numLines} from line ${result.file.startLine})\n${"β".repeat(60)}\n${numbered}`; | |
| } | |
| // βββ 3. write_file ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function executeWriteFile(args: Record<string, unknown>, workDir: string): Promise<string> { | |
| const filePath = resolvePath(String(args.path || ""), workDir); | |
| const content = String(args.content || ""); | |
| const result = FileOps.writeFile(filePath, content); | |
| const lines = content.split("\n").length; | |
| return `File ${result.type === "create" ? "created" : "updated"}: ${result.filePath} (${lines} lines, ${content.length} bytes)\nPatch: ${result.structuredPatch.length} hunk(s)`; | |
| } | |
| // βββ 4. edit_file (params: path, old_string, new_string, replace_all) ββββββ | |
| async function executeEditFile(args: Record<string, unknown>, workDir: string): Promise<string> { | |
| const filePath = resolvePath(String(args.path || ""), workDir); | |
| // Support BOTH original (old_string/new_string) and legacy (edits[]) format | |
| if (args.old_string !== undefined) { | |
| // Original claw-code format: single old_string β new_string | |
| const oldStr = String(args.old_string); | |
| const newStr = String(args.new_string ?? ""); | |
| const replaceAll = Boolean(args.replace_all); | |
| const result = FileOps.editFile(filePath, oldStr, newStr, replaceAll); | |
| const oldLines = oldStr.split("\n").map(l => `- ${l}`); | |
| const newLines = newStr.split("\n").map(l => `+ ${l}`); | |
| return `--- ${result.filePath}\n+++ ${result.filePath}\n@@ edit @@\n${oldLines.join("\n")}\n${newLines.join("\n")}`; | |
| } | |
| // Legacy format: edits array with oldText/newText β use applyDiff | |
| const edits = (args.edits as Array<{ oldText: string; newText: string }>) || []; | |
| if (edits.length === 0) throw new Error("No edits provided (use old_string/new_string or edits array)"); | |
| const hunks: FileOps.DiffHunk[] = edits.map(edit => ({ | |
| oldString: edit.oldText || (edit as any).old_string || "", | |
| newString: edit.newText || (edit as any).new_string || "", | |
| })); | |
| const result = FileOps.applyDiff(filePath, hunks); | |
| const patchLines = result.structuredPatch.flatMap(h => h.lines); | |
| return `--- ${result.filePath}\n+++ ${result.filePath}\n${patchLines.join("\n")}`; | |
| } | |
| // βββ 5. glob_search (was: glob) ββββββββββββββββββββββββββββββββββββββββββββ | |
| async function executeGlobSearch(args: Record<string, unknown>, workDir: string): Promise<string> { | |
| const pattern = String(args.pattern || ""); | |
| if (!pattern) throw new Error("No pattern provided"); | |
| const basePath = resolvePath(String(args.path || "."), workDir); | |
| const result = FileOps.globSearch(pattern, basePath); | |
| if (result.numFiles === 0) return "No files found matching pattern."; | |
| return `Found ${result.numFiles} file(s) in ${result.durationMs}ms${result.truncated ? " (truncated)" : ""}:\n${result.filenames.join("\n")}`; | |
| } | |
| // βββ 6. grep_search (was: grep) ββββββββββββββββββββββββββββββββββββββββββββ | |
| async function executeGrepSearch(args: Record<string, unknown>, workDir: string): Promise<string> { | |
| const pattern = String(args.pattern || ""); | |
| if (!pattern) throw new Error("No pattern provided"); | |
| const searchPath = resolvePath(String(args.path || "."), workDir); | |
| const input: FileOps.GrepSearchInput = { | |
| pattern, | |
| path: searchPath, | |
| glob: args.glob ? String(args.glob) : undefined, | |
| output_mode: args.output_mode ? String(args.output_mode) : "content", | |
| before: args["-B"] ? Number(args["-B"]) : undefined, | |
| after: args["-A"] ? Number(args["-A"]) : undefined, | |
| context: args["-C"] || args.context ? Number(args["-C"] || args.context) : undefined, | |
| case_insensitive: args["-i"] === true || args.case_insensitive === true, | |
| file_type: args.file_type ? String(args.file_type) : undefined, | |
| head_limit: args.head_limit ? Number(args.head_limit) : 50, | |
| offset: args.offset ? Number(args.offset) : undefined, | |
| multiline: args.multiline === true, | |
| }; | |
| const result = FileOps.grepSearch(input); | |
| if (result.numFiles === 0) return "No matches found."; | |
| if (result.content) { | |
| return `Found matches in ${result.numFiles} file(s):\n${result.content}`; | |
| } | |
| return `Found matches in ${result.numFiles} file(s):\n${result.filenames.join("\n")}`; | |
| } | |
| // βββ 7. WebFetch (was: web_fetch β now requires url + prompt) ββββββββββββββ | |
| async function executeWebFetch(args: Record<string, unknown>): Promise<string> { | |
| const url = String(args.url || ""); | |
| if (!url) throw new Error("No URL provided"); | |
| const prompt = String(args.prompt || "Summarize this page"); | |
| const started = Date.now(); | |
| try { | |
| // Normalize URL: upgrade http to https for non-localhost (matches original) | |
| let requestUrl = url; | |
| try { | |
| const parsed = new URL(url); | |
| if (parsed.protocol === "http:" && parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1" && parsed.hostname !== "::1") { | |
| parsed.protocol = "https:"; | |
| requestUrl = parsed.toString(); | |
| } | |
| } catch {} | |
| const response = await fetch(requestUrl, { | |
| headers: { "User-Agent": "claw-rust-tools/0.1" }, | |
| signal: AbortSignal.timeout(20000), | |
| redirect: "follow", | |
| }); | |
| const finalUrl = response.url; | |
| const code = response.status; | |
| const codeText = response.statusText || "Unknown"; | |
| const contentType = response.headers.get("content-type") || ""; | |
| const body = await response.text(); | |
| const bytes = body.length; | |
| // Normalize content: strip HTML tags if HTML (matches original html_to_text) | |
| const normalized = contentType.includes("html") ? htmlToText(body) : body.trim(); | |
| // Summarize based on prompt (matches original summarize_web_fetch) | |
| const result = summarizeWebFetch(finalUrl, prompt, normalized, body, contentType); | |
| const durationMs = Date.now() - started; | |
| return JSON.stringify({ | |
| bytes, | |
| code, | |
| codeText, | |
| result, | |
| durationMs, | |
| url: finalUrl, | |
| }, null, 2); | |
| } catch (error: any) { | |
| return JSON.stringify({ | |
| bytes: 0, | |
| code: 0, | |
| codeText: "Error", | |
| result: `Fetch failed: ${error.message}`, | |
| durationMs: Date.now() - started, | |
| url, | |
| }, null, 2); | |
| } | |
| } | |
| // Matches original html_to_text: strip tags, collapse whitespace | |
| function htmlToText(html: string): string { | |
| const text = html.replace(/<[^>]+>/g, " "); | |
| return collapseWhitespace(decodeHtmlEntities(text)); | |
| } | |
| function decodeHtmlEntities(input: string): string { | |
| return input | |
| .replace(/&/g, "&") | |
| .replace(/</g, "<") | |
| .replace(/>/g, ">") | |
| .replace(/"/g, '"') | |
| .replace(/'/g, "'") | |
| .replace(/ /g, " "); | |
| } | |
| function collapseWhitespace(input: string): string { | |
| return input.split(/\s+/).filter(Boolean).join(" "); | |
| } | |
| function previewText(input: string, maxChars: number): string { | |
| if (input.length <= maxChars) return input; | |
| return input.substring(0, maxChars).trimEnd() + "β¦"; | |
| } | |
| function extractTitle(content: string, rawBody: string, contentType: string): string | null { | |
| if (contentType.includes("html")) { | |
| const lowered = rawBody.toLowerCase(); | |
| const start = lowered.indexOf("<title>"); | |
| if (start !== -1) { | |
| const after = start + "<title>".length; | |
| const endRel = lowered.indexOf("</title>", after); | |
| if (endRel !== -1) { | |
| const title = collapseWhitespace(decodeHtmlEntities(rawBody.substring(after, endRel))); | |
| if (title) return title; | |
| } | |
| } | |
| } | |
| for (const line of content.split("\n")) { | |
| const trimmed = line.trim(); | |
| if (trimmed) return trimmed; | |
| } | |
| return null; | |
| } | |
| // Matches original summarize_web_fetch exactly | |
| function summarizeWebFetch(url: string, prompt: string, content: string, rawBody: string, contentType: string): string { | |
| const lowerPrompt = prompt.toLowerCase(); | |
| const compact = collapseWhitespace(content); | |
| let detail: string; | |
| if (lowerPrompt.includes("title")) { | |
| const title = extractTitle(content, rawBody, contentType); | |
| detail = title ? `Title: ${title}` : previewText(compact, 600); | |
| } else if (lowerPrompt.includes("summary") || lowerPrompt.includes("summarize")) { | |
| detail = previewText(compact, 900); | |
| } else { | |
| const preview = previewText(compact, 900); | |
| detail = `Prompt: ${prompt}\nContent preview:\n${preview}`; | |
| } | |
| return `Fetched ${url}\n${detail}`; | |
| } | |
| // βββ 8. WebSearch (was: web_search) ββββββββββββββββββββββββββββββββββββββββ | |
| async function executeWebSearch(args: Record<string, unknown>): Promise<string> { | |
| const query = String(args.query || ""); | |
| if (!query) throw new Error("No query provided"); | |
| const allowedDomains = (args.allowed_domains as string[]) || null; | |
| const blockedDomains = (args.blocked_domains as string[]) || null; | |
| const started = Date.now(); | |
| try { | |
| // Build search URL: support CLAW_WEB_SEARCH_BASE_URL env (matches original) | |
| let searchUrl: string; | |
| const baseUrl = process.env.CLAW_WEB_SEARCH_BASE_URL; | |
| if (baseUrl) { | |
| const u = new URL(baseUrl); | |
| u.searchParams.set("q", query); | |
| searchUrl = u.toString(); | |
| } else { | |
| searchUrl = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`; | |
| } | |
| const response = await fetch(searchUrl, { | |
| headers: { "User-Agent": "claw-rust-tools/0.1" }, | |
| signal: AbortSignal.timeout(20000), | |
| redirect: "follow", | |
| }); | |
| const finalUrl = response.url; | |
| const html = await response.text(); | |
| // Extract search hits using DDG "result__a" class (matches original extract_search_hits) | |
| let hits = extractSearchHits(html); | |
| // Fallback: extract from generic <a> links if no DDG results (matches original) | |
| if (hits.length === 0) { | |
| hits = extractSearchHitsFromGenericLinks(html); | |
| } | |
| // Apply domain filters (matches original) | |
| if (allowedDomains && allowedDomains.length > 0) { | |
| hits = hits.filter(hit => hostMatchesList(hit.url, allowedDomains)); | |
| } | |
| if (blockedDomains && blockedDomains.length > 0) { | |
| hits = hits.filter(hit => !hostMatchesList(hit.url, blockedDomains)); | |
| } | |
| // Dedupe and truncate (matches original) | |
| dedupeHits(hits); | |
| hits = hits.slice(0, 8); | |
| const durationSeconds = (Date.now() - started) / 1000; | |
| // Build summary (matches original format) | |
| let summary: string; | |
| if (hits.length === 0) { | |
| summary = `No web search results matched the query "${query}".`; | |
| } else { | |
| const renderedHits = hits.map(hit => `- [${hit.title}](${hit.url})`).join("\n"); | |
| summary = `Search results for "${query}". Include a Sources section in the final answer.\n${renderedHits}`; | |
| } | |
| return JSON.stringify({ | |
| query, | |
| results: [ | |
| summary, | |
| { tool_use_id: "web_search_1", content: hits }, | |
| ], | |
| durationSeconds, | |
| }, null, 2); | |
| } catch (error: any) { | |
| return JSON.stringify({ | |
| query, | |
| results: [`Search failed: ${error.message}`], | |
| durationSeconds: (Date.now() - started) / 1000, | |
| }, null, 2); | |
| } | |
| } | |
| // Matches original extract_search_hits: parse DDG result__a anchors | |
| function extractSearchHits(html: string): Array<{ title: string; url: string }> { | |
| const hits: Array<{ title: string; url: string }> = []; | |
| let remaining = html; | |
| while (true) { | |
| const anchorStart = remaining.indexOf("result__a"); | |
| if (anchorStart === -1) break; | |
| const afterClass = remaining.substring(anchorStart); | |
| const hrefIdx = afterClass.indexOf('href='); | |
| if (hrefIdx === -1) { remaining = afterClass.substring(1); continue; } | |
| const hrefSlice = afterClass.substring(hrefIdx + 5); | |
| const extracted = extractQuotedValue(hrefSlice); | |
| if (!extracted) { remaining = afterClass.substring(1); continue; } | |
| const [rawUrl, rest] = extracted; | |
| const closeTagIdx = rest.indexOf('>'); | |
| if (closeTagIdx === -1) { remaining = afterClass.substring(1); continue; } | |
| const afterTag = rest.substring(closeTagIdx + 1); | |
| const endAnchorIdx = afterTag.indexOf('</a>'); | |
| if (endAnchorIdx === -1) { remaining = afterTag.substring(1); continue; } | |
| const title = htmlToText(afterTag.substring(0, endAnchorIdx)).trim(); | |
| const decodedUrl = decodeDuckDuckGoRedirect(rawUrl); | |
| if (decodedUrl) { | |
| hits.push({ title, url: decodedUrl }); | |
| } | |
| remaining = afterTag.substring(endAnchorIdx + 4); | |
| } | |
| return hits; | |
| } | |
| // Matches original extract_search_hits_from_generic_links | |
| function extractSearchHitsFromGenericLinks(html: string): Array<{ title: string; url: string }> { | |
| const hits: Array<{ title: string; url: string }> = []; | |
| let remaining = html; | |
| while (true) { | |
| const anchorStart = remaining.indexOf('<a'); | |
| if (anchorStart === -1) break; | |
| const afterAnchor = remaining.substring(anchorStart); | |
| const hrefIdx = afterAnchor.indexOf('href='); | |
| if (hrefIdx === -1) { remaining = afterAnchor.substring(2); continue; } | |
| const hrefSlice = afterAnchor.substring(hrefIdx + 5); | |
| const extracted = extractQuotedValue(hrefSlice); | |
| if (!extracted) { remaining = afterAnchor.substring(2); continue; } | |
| const [rawUrl, rest] = extracted; | |
| const closeTagIdx = rest.indexOf('>'); | |
| if (closeTagIdx === -1) { remaining = afterAnchor.substring(2); continue; } | |
| const afterTag = rest.substring(closeTagIdx + 1); | |
| const endAnchorIdx = afterTag.indexOf('</a>'); | |
| if (endAnchorIdx === -1) { remaining = afterAnchor.substring(2); continue; } | |
| const title = htmlToText(afterTag.substring(0, endAnchorIdx)).trim(); | |
| if (!title) { remaining = afterTag.substring(endAnchorIdx + 4); continue; } | |
| const decodedUrl = decodeDuckDuckGoRedirect(rawUrl) || rawUrl; | |
| if (decodedUrl.startsWith('http://') || decodedUrl.startsWith('https://')) { | |
| hits.push({ title, url: decodedUrl }); | |
| } | |
| remaining = afterTag.substring(endAnchorIdx + 4); | |
| } | |
| return hits; | |
| } | |
| function extractQuotedValue(input: string): [string, string] | null { | |
| const quote = input[0]; | |
| if (quote !== '"' && quote !== "'") return null; | |
| const rest = input.substring(1); | |
| const end = rest.indexOf(quote); | |
| if (end === -1) return null; | |
| return [rest.substring(0, end), rest.substring(end + 1)]; | |
| } | |
| // Matches original decode_duckduckgo_redirect | |
| function decodeDuckDuckGoRedirect(url: string): string | null { | |
| if (url.startsWith('http://') || url.startsWith('https://')) { | |
| return decodeHtmlEntities(url); | |
| } | |
| let joined: string; | |
| if (url.startsWith('//')) { | |
| joined = `https:${url}`; | |
| } else if (url.startsWith('/')) { | |
| joined = `https://duckduckgo.com${url}`; | |
| } else { | |
| return null; | |
| } | |
| try { | |
| const parsed = new URL(joined); | |
| if (parsed.pathname === '/l/' || parsed.pathname === '/l') { | |
| const uddg = parsed.searchParams.get('uddg'); | |
| if (uddg) return decodeHtmlEntities(uddg); | |
| } | |
| } catch {} | |
| return joined; | |
| } | |
| function hostMatchesList(url: string, domains: string[]): boolean { | |
| try { | |
| const parsed = new URL(url); | |
| const host = parsed.hostname.toLowerCase(); | |
| return domains.some(domain => { | |
| const normalized = normalizeDomainFilter(domain); | |
| return normalized && (host === normalized || host.endsWith(`.${normalized}`)); | |
| }); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function normalizeDomainFilter(domain: string): string { | |
| const trimmed = domain.trim(); | |
| try { | |
| const parsed = new URL(trimmed); | |
| return (parsed.hostname || trimmed).replace(/^\./, '').replace(/\/$/, '').toLowerCase(); | |
| } catch { | |
| return trimmed.replace(/^\./, '').replace(/\/$/, '').toLowerCase(); | |
| } | |
| } | |
| function dedupeHits(hits: Array<{ title: string; url: string }>): void { | |
| const seen = new Set<string>(); | |
| let i = 0; | |
| while (i < hits.length) { | |
| if (seen.has(hits[i].url)) { | |
| hits.splice(i, 1); | |
| } else { | |
| seen.add(hits[i].url); | |
| i++; | |
| } | |
| } | |
| } | |
| // βββ 9. TodoWrite (was: todo_read + todo_write β now single tool) ββββββββββ | |
| function executeTodoWrite(args: Record<string, unknown>, sessionId: number): string { | |
| const todos = args.todos as Array<{ content: string; activeForm: string; status: string }>; | |
| if (!todos || !Array.isArray(todos)) { | |
| // Legacy format support | |
| const action = String(args.action || ""); | |
| const item = String(args.item || ""); | |
| return executeTodoWriteLegacy(action, item, sessionId); | |
| } | |
| // Original claw-code format: full replacement of todo list | |
| const newList = todos.map(t => ({ | |
| content: String(t.content || ""), | |
| activeForm: String(t.activeForm || t.content || ""), | |
| status: (t.status || "pending") as "pending" | "in_progress" | "completed", | |
| })); | |
| todoLists.set(sessionId, newList); | |
| saveSessionState(sessionId, "todoList", newList); | |
| if (newList.length === 0) return "Todo list cleared."; | |
| return `Todo list updated (${newList.length} items):\n` + | |
| newList.map((t, i) => { | |
| const icon = t.status === "completed" ? "β" : t.status === "in_progress" ? "β" : "β"; | |
| return ` ${icon} ${i + 1}. ${t.content} [${t.status}]`; | |
| }).join("\n"); | |
| } | |
| function executeTodoRead(sessionId: number): string { | |
| // Load from DB if not in memory (e.g., after server restart) | |
| if (!todoLists.has(sessionId)) { | |
| const persisted = loadSessionState<Array<{ content: string; activeForm: string; status: "pending" | "in_progress" | "completed" }>>(sessionId, "todoList"); | |
| if (persisted && persisted.length > 0) { | |
| todoLists.set(sessionId, persisted); | |
| } | |
| } | |
| const todos = todoLists.get(sessionId) || []; | |
| if (todos.length === 0) return "Todo list is empty."; | |
| return todos.map((t, i) => { | |
| const icon = t.status === "completed" ? "β" : t.status === "in_progress" ? "β" : "β"; | |
| return ` ${icon} ${i + 1}. ${t.content} [${t.status}]`; | |
| }).join("\n"); | |
| } | |
| function executeTodoWriteLegacy(action: string, item: string, sessionId: number): string { | |
| if (!todoLists.has(sessionId)) todoLists.set(sessionId, []); | |
| const todos = todoLists.get(sessionId)!; | |
| switch (action) { | |
| case "add": | |
| if (!item) return "Error: No item text provided"; | |
| todos.push({ content: item, activeForm: item, status: "pending" }); | |
| saveSessionState(sessionId, "todoList", todos); | |
| return `Added: "${item}" (${todos.length} items total)`; | |
| case "complete": { | |
| const idx = parseInt(item) - 1; | |
| if (isNaN(idx) || idx < 0 || idx >= todos.length) return "Error: Invalid item index"; | |
| todos[idx].status = "completed"; | |
| saveSessionState(sessionId, "todoList", todos); | |
| return `Completed: "${todos[idx].content}"`; | |
| } | |
| case "remove": { | |
| const idx = parseInt(item) - 1; | |
| if (isNaN(idx) || idx < 0 || idx >= todos.length) return "Error: Invalid item index"; | |
| const removed = todos.splice(idx, 1)[0]; | |
| return `Removed: "${removed.content}"`; | |
| } | |
| case "clear": | |
| todoLists.set(sessionId, []); | |
| saveSessionState(sessionId, "todoList", []); | |
| return "Todo list cleared."; | |
| default: | |
| return `Unknown action: ${action}`; | |
| } | |
| } | |
| // βββ 10. Skill ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function executeSkill(args: Record<string, unknown>, workDir: string, sessionId: number): Promise<string> { | |
| const skillName = String(args.skill || args.name || ""); | |
| const skillArgs = String(args.args || ""); | |
| if (!skillName) return "Error: No skill name provided"; | |
| const locations = [ | |
| path.join(workDir, ".claw", "skills", skillName, "SKILL.md"), | |
| path.join(workDir, "skills", skillName, "SKILL.md"), | |
| path.join("/home/ubuntu", "skills", skillName, "SKILL.md"), | |
| ]; | |
| for (const loc of locations) { | |
| try { | |
| const content = await fs.readFile(loc, "utf-8"); | |
| const skillStore = getSessionSkills(sessionId); | |
| skillStore.set(skillName, { name: skillName, description: `Loaded from ${loc}`, content, enabled: true }); | |
| return `Skill "${skillName}" loaded from ${loc}:\n${"β".repeat(40)}\n${content.substring(0, 500)}${skillArgs ? `\n\nArgs: ${skillArgs}` : ""}`; | |
| } catch { /* try next */ } | |
| } | |
| return `Skill "${skillName}" not found. Searched: ${locations.join(", ")}`; | |
| } | |
| // βββ 11. Agent (was: sub_agent) βββββββββββββββββββββββββββββββββββββββββββββ | |
| // βββ Allowed tools per subagent type (matches original claw-code allowed_tools_for_subagent) βββ | |
| const SUBAGENT_ALLOWED_TOOLS: Record<string, string[]> = { | |
| Explore: ["read_file", "glob_search", "grep_search", "WebFetch", "WebSearch", "ToolSearch", "Skill", "StructuredOutput"], | |
| Plan: ["read_file", "glob_search", "grep_search", "WebFetch", "WebSearch", "ToolSearch", "Skill", "TodoWrite", "StructuredOutput", "SendUserMessage"], | |
| Verification: ["bash", "read_file", "glob_search", "grep_search", "WebFetch", "WebSearch", "ToolSearch", "TodoWrite", "StructuredOutput", "SendUserMessage", "PowerShell"], | |
| "claw-guide": ["read_file", "glob_search", "grep_search", "WebFetch", "WebSearch", "ToolSearch", "Skill", "StructuredOutput", "SendUserMessage"], | |
| "statusline-setup": ["bash", "read_file", "write_file", "edit_file", "glob_search", "grep_search", "ToolSearch"], | |
| // Default: general_purpose and any unknown type | |
| _default: ["bash", "read_file", "write_file", "edit_file", "glob_search", "grep_search", "WebFetch", "WebSearch", "TodoWrite", "Skill", "ToolSearch", "NotebookEdit", "Sleep", "SendUserMessage", "Config", "StructuredOutput", "REPL", "PowerShell"], | |
| }; | |
| function normalizeSubagentType(raw: string | undefined): string { | |
| const trimmed = (raw || "").trim(); | |
| if (!trimmed) return "general_purpose"; | |
| // Match original claw-code normalize_subagent_type | |
| const lower = trimmed.toLowerCase().replace(/[\s_-]+/g, ""); | |
| const map: Record<string, string> = { | |
| explore: "Explore", explorer: "Explore", codebaseexplorer: "Explore", | |
| plan: "Plan", planner: "Plan", planning: "Plan", | |
| verification: "Verification", verify: "Verification", verifier: "Verification", test: "Verification", | |
| guide: "claw-guide", clawguide: "claw-guide", | |
| statusline: "statusline-setup", statuslinesetup: "statusline-setup", | |
| }; | |
| return map[lower] || trimmed; | |
| } | |
| function getAllowedToolsForSubagent(subagentType: string): Set<string> { | |
| const tools = SUBAGENT_ALLOWED_TOOLS[subagentType] || SUBAGENT_ALLOWED_TOOLS._default; | |
| return new Set(tools); | |
| } | |
| // In-memory agent store (matches original .claw/agents/ file store) | |
| const agentStore = new Map<string, { | |
| agentId: string; name: string; description: string; subagentType: string; | |
| model: string; status: "running" | "completed" | "failed"; | |
| outputFile: string; manifestFile: string; | |
| createdAt: string; startedAt: string; completedAt?: string; | |
| error?: string; result?: string; | |
| }>(); | |
| async function executeAgentReal(args: Record<string, unknown>, sessionId: number, workDir: string): Promise<string> { | |
| const description = String(args.description || args.task || ""); | |
| const prompt = String(args.prompt || ""); | |
| const rawSubagentType = String(args.subagent_type || args.type || "general_purpose"); | |
| const name = String(args.name || ""); | |
| const modelArg = String(args.model || ""); | |
| if (!description.trim()) return "Error: description must not be empty"; | |
| if (!prompt.trim() && !description.trim()) return "Error: prompt must not be empty"; | |
| const normalizedType = normalizeSubagentType(rawSubagentType); | |
| const model = modelArg.trim() || "XiaomiMiMo/MiMo-V2-Flash"; | |
| const agentId = `agent_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; | |
| const agentName = (name || description).replace(/[^a-zA-Z0-9_-]/g, "_").substring(0, 50) || agentId; | |
| const createdAt = new Date().toISOString(); | |
| // Create agent output directory (matches original ~/.claw/agents/) | |
| const agentDir = path.join(workDir, ".claw", "agents"); | |
| await fs.mkdir(agentDir, { recursive: true }); | |
| const outputFile = path.join(agentDir, `${agentId}.md`); | |
| const manifestFile = path.join(agentDir, `${agentId}.json`); | |
| // Write initial output file | |
| const initialOutput = `# Agent Task\n\n- id: ${agentId}\n- name: ${agentName}\n- description: ${description}\n- subagent_type: ${normalizedType}\n- created_at: ${createdAt}\n\n## Prompt\n\n${prompt || description}\n`; | |
| await fs.writeFile(outputFile, initialOutput); | |
| // Write initial manifest | |
| const manifest = { | |
| agentId, name: agentName, description, subagentType: normalizedType, | |
| model, status: "running" as const, outputFile, manifestFile, | |
| createdAt, startedAt: createdAt, | |
| }; | |
| agentStore.set(agentId, manifest); | |
| await fs.writeFile(manifestFile, JSON.stringify(manifest, null, 2)); | |
| // Get restricted tool set for this subagent type | |
| const allowedTools = getAllowedToolsForSubagent(normalizedType); | |
| const filteredToolDefs = TOOL_DEFINITIONS.filter((t: any) => allowedTools.has(t.function.name)); | |
| // Build sub-agent system prompt (matches original build_agent_system_prompt) | |
| const preset = BUILTIN_AGENT_PRESETS[rawSubagentType] || BUILTIN_AGENT_PRESETS[normalizedType.toLowerCase()]; | |
| const basePrompt = preset?.systemPrompt || buildSystemPrompt({ | |
| effortLevel: "high", workDir, platform: "linux", model, | |
| }); | |
| const subSystemPrompt = `${basePrompt}\n\nYou are a background sub-agent of type \`${normalizedType}\`. 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.`; | |
| // Spawn async background execution (matches original thread::spawn) | |
| const runAgent = async () => { | |
| try { | |
| const messages: Array<any> = [ | |
| { role: "system", content: subSystemPrompt }, | |
| { role: "user", content: `${description}${prompt ? "\n\n" + prompt : ""}` }, | |
| ]; | |
| const MAX_ITERATIONS = 10; | |
| let iterations = 0; | |
| let finalContent = ""; | |
| while (iterations < MAX_ITERATIONS) { | |
| iterations++; | |
| const response = await invokeLLM({ | |
| messages, | |
| tools: filteredToolDefs as any, | |
| tool_choice: "auto" as any, | |
| max_tokens: 16384, | |
| }); | |
| const choice = response.choices?.[0]; | |
| if (!choice) break; | |
| const assistantContent = typeof choice.message.content === "string" ? choice.message.content : ""; | |
| const toolCalls = choice.message.tool_calls; | |
| const assistantMsg: any = { role: "assistant", content: assistantContent }; | |
| if (toolCalls?.length) assistantMsg.tool_calls = toolCalls; | |
| messages.push(assistantMsg); | |
| if (!toolCalls || toolCalls.length === 0) { | |
| finalContent = assistantContent; | |
| break; | |
| } | |
| // Execute only allowed tools | |
| for (const tc of toolCalls) { | |
| if (!allowedTools.has(tc.function.name)) { | |
| messages.push({ role: "tool", content: `Error: tool ${tc.function.name} is not allowed for ${normalizedType} sub-agent`, tool_call_id: tc.id, name: tc.function.name }); | |
| continue; | |
| } | |
| let toolArgs: Record<string, unknown> = {}; | |
| try { toolArgs = JSON.parse(tc.function.arguments || "{}"); } catch {} | |
| const toolResult = await executeTool(tc.function.name, toolArgs, sessionId, workDir); | |
| messages.push({ role: "tool", content: toolResult.output, tool_call_id: tc.id, name: tc.function.name }); | |
| } | |
| } | |
| // Persist completed state (matches original persist_agent_terminal_state) | |
| const resultText = finalContent || "(no output)"; | |
| const terminalOutput = `\n## Result\n\n- status: completed\n\n### Final response\n\n${resultText.trim()}\n`; | |
| await fs.appendFile(outputFile, terminalOutput); | |
| const updatedManifest = { ...manifest, status: "completed" as const, completedAt: new Date().toISOString(), result: resultText }; | |
| agentStore.set(agentId, updatedManifest); | |
| await fs.writeFile(manifestFile, JSON.stringify(updatedManifest, null, 2)); | |
| } catch (error: any) { | |
| const errorMsg = error.message || String(error); | |
| const terminalOutput = `\n## Result\n\n- status: failed\n\n### Error\n\n${errorMsg}\n`; | |
| await fs.appendFile(outputFile, terminalOutput); | |
| const updatedManifest = { ...manifest, status: "failed" as const, completedAt: new Date().toISOString(), error: errorMsg }; | |
| agentStore.set(agentId, updatedManifest); | |
| await fs.writeFile(manifestFile, JSON.stringify(updatedManifest, null, 2)); | |
| } | |
| }; | |
| // Fire and forget β matches original thread::spawn | |
| runAgent(); | |
| // Return manifest immediately (matches original) | |
| return JSON.stringify({ | |
| agent_id: agentId, | |
| name: agentName, | |
| description, | |
| subagent_type: normalizedType, | |
| model, | |
| status: "running", | |
| output_file: outputFile, | |
| manifest_file: manifestFile, | |
| created_at: createdAt, | |
| started_at: createdAt, | |
| }, null, 2); | |
| } | |
| // βββ 12. ToolSearch βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function executeToolSearch(args: Record<string, unknown>): string { | |
| const query = String(args.query || args.search || "").toLowerCase(); | |
| const maxResults = Number(args.max_results) || 20; | |
| const tools = getToolList(); | |
| if (!query) { | |
| return "Available tools:\n" + tools.slice(0, maxResults).map(t => ` ${t.name} β ${t.description} [${t.category}]`).join("\n"); | |
| } | |
| const matches = tools.filter(t => | |
| t.name.toLowerCase().includes(query) || | |
| t.description.toLowerCase().includes(query) || | |
| t.category.toLowerCase().includes(query) | |
| ).slice(0, maxResults); | |
| if (matches.length === 0) return `No tools found matching "${query}"`; | |
| return `Tools matching "${query}":\n` + matches.map(t => ` ${t.name} β ${t.description} [${t.category}]`).join("\n"); | |
| } | |
| // βββ 13. NotebookEdit (params: notebook_path, cell_id, new_source, cell_type, edit_mode) β | |
| async function executeNotebookEdit(args: Record<string, unknown>, workDir: string): Promise<string> { | |
| const filePath = resolvePath(String(args.notebook_path || args.path || ""), workDir); | |
| const editMode = String(args.edit_mode || args.action || "replace"); | |
| const cellId = String(args.cell_id ?? args.cellIndex ?? "0"); | |
| const newSource = String(args.new_source || args.content || ""); | |
| const cellType = String(args.cell_type || args.cellType || "code"); | |
| let notebook: any; | |
| try { | |
| const raw = await fs.readFile(filePath, "utf-8"); | |
| notebook = JSON.parse(raw); | |
| } catch { | |
| notebook = { nbformat: 4, nbformat_minor: 2, metadata: {}, cells: [] }; | |
| } | |
| if (!notebook.cells) notebook.cells = []; | |
| const cellIndex = parseInt(cellId); | |
| switch (editMode) { | |
| case "insert": | |
| notebook.cells.push({ | |
| cell_type: cellType, | |
| source: newSource.split("\n"), | |
| metadata: {}, | |
| ...(cellType === "code" ? { outputs: [], execution_count: null } : {}), | |
| }); | |
| await fs.writeFile(filePath, JSON.stringify(notebook, null, 2), "utf-8"); | |
| return `Cell inserted at ${filePath} (index ${notebook.cells.length - 1}, type: ${cellType})`; | |
| case "replace": | |
| if (isNaN(cellIndex) || cellIndex < 0 || cellIndex >= notebook.cells.length) throw new Error(`Invalid cell index: ${cellId}`); | |
| notebook.cells[cellIndex].source = newSource.split("\n"); | |
| if (cellType) notebook.cells[cellIndex].cell_type = cellType; | |
| await fs.writeFile(filePath, JSON.stringify(notebook, null, 2), "utf-8"); | |
| return `Cell ${cellId} replaced in ${filePath}`; | |
| case "delete": | |
| if (isNaN(cellIndex) || cellIndex < 0 || cellIndex >= notebook.cells.length) throw new Error(`Invalid cell index: ${cellId}`); | |
| notebook.cells.splice(cellIndex, 1); | |
| await fs.writeFile(filePath, JSON.stringify(notebook, null, 2), "utf-8"); | |
| return `Cell ${cellId} deleted from ${filePath}`; | |
| default: | |
| // List cells | |
| return notebook.cells.map((c: any, i: number) => { | |
| const src = Array.isArray(c.source) ? c.source.join("") : c.source; | |
| const preview = src.substring(0, 80).replace(/\n/g, "\\n"); | |
| return ` [${i}] ${c.cell_type}: ${preview}`; | |
| }).join("\n") || "No cells in notebook."; | |
| } | |
| } | |
| // βββ 14. Sleep ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function executeSleep(args: Record<string, unknown>): Promise<string> { | |
| const durationMs = Math.max(Number(args.duration_ms) || 0, 0); | |
| const maxMs = 60000; // cap at 60s | |
| const actualMs = Math.min(durationMs, maxMs); | |
| await new Promise(resolve => setTimeout(resolve, actualMs)); | |
| return `Slept for ${actualMs}ms`; | |
| } | |
| // βββ 15. SendUserMessage ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // (handled inline in executeTool switch β this is the legacy send_message) | |
| function executeSendMessage(args: Record<string, unknown>): string { | |
| const to = String(args.to || args.agentId || ""); | |
| const message = String(args.message || args.content || ""); | |
| if (!to) return "Error: No recipient agent specified"; | |
| if (!message) return "Error: No message content provided"; | |
| return `Message sent to agent ${to}:\n${message}`; | |
| } | |
| // βββ 16. Config (was: config_read + config_write β now single tool) ββββββββ | |
| function executeConfig(args: Record<string, unknown>, sessionId: number, workDir?: string): string { | |
| const setting = String(args.setting || ""); | |
| if (!setting) { | |
| // List all config β merge file-based + session overrides | |
| const cwd = workDir || process.cwd(); | |
| const fileConfig = getConfig(cwd); | |
| const sessionOverrides = sessionConfigs.get(sessionId) || {}; | |
| const merged = { ...fileConfig, ...sessionOverrides }; | |
| const entries = Object.entries(merged); | |
| if (entries.length === 0) return "No configuration values set."; | |
| return "Current configuration:\n" + entries.map(([k, v]) => ` ${k} = ${JSON.stringify(v)}`).join("\n"); | |
| } | |
| if (args.value !== undefined) { | |
| // Set config β persist to file AND session | |
| const value = args.value; | |
| try { | |
| setConfigValue(workDir || process.cwd(), setting, value as any); | |
| } catch { | |
| // Fallback to session-only if file write fails | |
| } | |
| if (!sessionConfigs.has(sessionId)) sessionConfigs.set(sessionId, {}); | |
| sessionConfigs.get(sessionId)![setting] = value as any; | |
| return `Config set: ${setting} = ${JSON.stringify(value)} (persisted to .claw/settings.json)`; | |
| } | |
| // Read config β check file first, then session | |
| const fileValue = getConfigValue(workDir || process.cwd(), setting); | |
| if (fileValue !== undefined) return `${setting} = ${JSON.stringify(fileValue)}`; | |
| const sessionConfig = sessionConfigs.get(sessionId) || {}; | |
| return sessionConfig[setting] !== undefined ? `${setting} = ${JSON.stringify(sessionConfig[setting])}` : `Config key not found: ${setting}`; | |
| } | |
| // Legacy aliases | |
| function executeConfigRead(args: Record<string, unknown>, sessionId: number): string { | |
| return executeConfig({ setting: args.key, ...args }, sessionId); | |
| } | |
| function executeConfigWrite(args: Record<string, unknown>, sessionId: number): string { | |
| return executeConfig({ setting: args.key, value: args.value, ...args }, sessionId); | |
| } | |
| // βββ 17. StructuredOutput βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function executeStructuredOutput(args: Record<string, unknown>): string { | |
| return JSON.stringify(args, null, 2); | |
| } | |
| // βββ 18. REPL βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function executeREPL(args: Record<string, unknown>, workDir: string): Promise<string> { | |
| const code = String(args.code || ""); | |
| const language = String(args.language || "python").toLowerCase(); | |
| const timeoutMs = Math.min(Number(args.timeout_ms) || 30000, 120000); | |
| if (!code) throw new Error("No code provided"); | |
| let cmd: string; | |
| let ext: string; | |
| switch (language) { | |
| case "python": | |
| case "python3": | |
| ext = "py"; | |
| cmd = "python3"; | |
| break; | |
| case "node": | |
| case "javascript": | |
| case "js": | |
| ext = "js"; | |
| cmd = "node"; | |
| break; | |
| case "typescript": | |
| case "ts": | |
| ext = "ts"; | |
| cmd = "npx tsx"; | |
| break; | |
| case "ruby": | |
| ext = "rb"; | |
| cmd = "ruby"; | |
| break; | |
| case "bash": | |
| case "sh": | |
| ext = "sh"; | |
| cmd = "bash"; | |
| break; | |
| default: | |
| return `Unsupported REPL language: ${language}. Supported: python, javascript, typescript, ruby, bash`; | |
| } | |
| const tmpFile = path.join("/tmp", `repl_${Date.now()}.${ext}`); | |
| await fs.writeFile(tmpFile, code, "utf-8"); | |
| try { | |
| const { stdout, stderr } = await execAsync(`${cmd} "${tmpFile}"`, { | |
| cwd: workDir, | |
| timeout: timeoutMs, | |
| maxBuffer: 1024 * 1024 * 5, | |
| env: { ...process.env, HOME: "/home/ubuntu" }, | |
| }); | |
| let result = ""; | |
| if (stdout) result += stdout; | |
| if (stderr) result += (result ? "\n" : "") + "STDERR: " + stderr; | |
| return result || "(no output)"; | |
| } catch (error: any) { | |
| if (error.killed) throw new Error(`REPL timed out after ${timeoutMs}ms`); | |
| const output = []; | |
| if (error.stdout) output.push(error.stdout); | |
| if (error.stderr) output.push("STDERR: " + error.stderr); | |
| if (output.length === 0) output.push(error.message); | |
| throw new Error(output.join("\n")); | |
| } finally { | |
| fs.unlink(tmpFile).catch(() => {}); | |
| } | |
| } | |
| // βββ 19. PowerShell βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function executePowerShell(args: Record<string, unknown>, workDir: string): Promise<string> { | |
| const command = String(args.command || ""); | |
| if (!command) throw new Error("No command provided"); | |
| const timeout = Math.min(Number(args.timeout) || 30, 300) * 1000; | |
| try { | |
| const { stdout, stderr } = await execAsync(`pwsh -NoProfile -NonInteractive -Command "${command.replace(/"/g, '\\"')}"`, { | |
| cwd: workDir, timeout, maxBuffer: 1024 * 1024 * 5, | |
| }); | |
| let result = ""; | |
| if (stdout) result += stdout; | |
| if (stderr) result += (result ? "\n" : "") + "STDERR: " + stderr; | |
| return result || "(no output)"; | |
| } catch (error: any) { | |
| return `PowerShell not available. Use bash tool instead. Error: ${error.message}`; | |
| } | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // EXTENDED TOOLS (web-app features, legacy compat) | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // βββ Task Management ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function executeTaskCreate(args: Record<string, unknown>, sessionId: number, workDir: string): string { | |
| const description = String(args.description || args.task || ""); | |
| const command = String(args.command || args.cmd || ""); | |
| if (!description && !command) return "Error: No task description or command provided"; | |
| const id = `task_${Date.now()}_${Math.random().toString(36).substring(7)}`; | |
| const taskEntry: typeof activeTasks extends Map<string, infer V> ? V : never = { | |
| id, sessionId, description: description || command, | |
| command: command || undefined, | |
| status: "running", createdAt: Date.now(), output: "", | |
| }; | |
| if (command) { | |
| // REAL background execution with child_process.spawn | |
| const child = spawn("bash", ["-c", command], { | |
| cwd: workDir, | |
| stdio: ["ignore", "pipe", "pipe"], | |
| detached: false, | |
| env: { ...process.env, HOME: "/home/ubuntu" }, | |
| }); | |
| taskEntry.process = child; | |
| child.stdout?.on("data", (data: Buffer) => { | |
| const task = activeTasks.get(id); | |
| if (task) task.output += data.toString(); | |
| }); | |
| child.stderr?.on("data", (data: Buffer) => { | |
| const task = activeTasks.get(id); | |
| if (task) task.output += `[stderr] ${data.toString()}`; | |
| }); | |
| child.on("close", (code: number | null) => { | |
| const task = activeTasks.get(id); | |
| if (task) { | |
| task.status = code === 0 ? "completed" : "failed"; | |
| task.output += `\n[exit code: ${code}]`; | |
| task.process = undefined; | |
| } | |
| }); | |
| child.on("error", (err: Error) => { | |
| const task = activeTasks.get(id); | |
| if (task) { | |
| task.status = "failed"; | |
| task.output += `\n[error: ${err.message}]`; | |
| task.process = undefined; | |
| } | |
| }); | |
| } | |
| activeTasks.set(id, taskEntry); | |
| return `Task created:\n ID: ${id}\n Description: ${description || command}\n Command: ${command || "(prompt-based)"}\n Status: running`; | |
| } | |
| function executeTaskList(sessionId: number): string { | |
| const tasks = Array.from(activeTasks.values()).filter(t => t.sessionId === sessionId); | |
| if (tasks.length === 0) return "No tasks found for this session."; | |
| return tasks.map(t => { | |
| const elapsed = Math.round((Date.now() - t.createdAt) / 1000); | |
| return ` ${t.status === "running" ? "β" : t.status === "completed" ? "β" : "β"} [${t.id}] ${t.description} (${t.status}, ${elapsed}s)`; | |
| }).join("\n"); | |
| } | |
| function executeTaskStop(args: Record<string, unknown>): string { | |
| const id = String(args.id || args.taskId || ""); | |
| if (!id) return "Error: No task ID provided"; | |
| const task = activeTasks.get(id); | |
| if (!task) return `Error: Task not found: ${id}`; | |
| // Kill the real process if running | |
| if (task.process && task.status === "running") { | |
| try { task.process.kill("SIGTERM"); } catch {} | |
| task.output += "\n[killed by user]"; | |
| } | |
| task.status = "stopped"; | |
| task.process = undefined; | |
| return `Task stopped: ${id} β "${task.description}"`; | |
| } | |
| function executeTaskGet(args: Record<string, unknown>): string { | |
| const id = String(args.id || args.taskId || ""); | |
| if (!id) return "Error: No task ID provided"; | |
| const task = activeTasks.get(id); | |
| if (!task) return `Error: Task not found: ${id}`; | |
| const elapsed = Math.round((Date.now() - task.createdAt) / 1000); | |
| return `Task Details:\n ID: ${task.id}\n Description: ${task.description}\n Status: ${task.status}\n Elapsed: ${elapsed}s\n Output: ${task.output || "(none)"}`; | |
| } | |
| function executeTaskOutput(args: Record<string, unknown>): string { | |
| const id = String(args.id || args.taskId || ""); | |
| if (!id) return "Error: No task ID provided"; | |
| const task = activeTasks.get(id); | |
| if (!task) return `Error: Task not found: ${id}`; | |
| return `Task Output [${id}]:\n${task.output || "(no output yet)"}\nStatus: ${task.status}`; | |
| } | |
| function executeSyntheticOutput(args: Record<string, unknown>): string { | |
| const format = String(args.format || "json"); | |
| const template = String(args.template || ""); | |
| const data = args.data || {}; | |
| if (format === "json") return JSON.stringify(data, null, 2); | |
| if (template) { | |
| let result = template; | |
| for (const [k, v] of Object.entries(data as Record<string, unknown>)) { | |
| result = result.replace(new RegExp(`\\{\\{${k}\\}\\}`, "g"), String(v)); | |
| } | |
| return result; | |
| } | |
| return JSON.stringify(args, null, 2); | |
| } | |
| function executeTaskUpdate(args: Record<string, unknown>): string { | |
| const id = String(args.id || args.taskId || ""); | |
| if (!id) return "Error: No task ID provided"; | |
| const task = activeTasks.get(id); | |
| if (!task) return `Error: Task not found: ${id}`; | |
| if (args.status) task.status = String(args.status) as any; | |
| if (args.output) task.output = String(args.output); | |
| return `Task updated: ${id} β status: ${task.status}`; | |
| } | |
| // βββ Plan Mode Tools ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function executePlanCreate(args: Record<string, unknown>, sessionId: number): string { | |
| const steps = (args.steps as string[]) || []; | |
| if (steps.length === 0) return "Error: No plan steps provided"; | |
| const plan = { active: true, steps: steps.map((s, i) => ({ id: i + 1, text: s, status: "pending" as const })) }; | |
| planModes.set(sessionId, plan); | |
| return `Plan created with ${steps.length} steps:\n` + plan.steps.map(s => ` ${s.id}. [ ] ${s.text}`).join("\n"); | |
| } | |
| function executePlanUpdate(args: Record<string, unknown>, sessionId: number): string { | |
| const stepId = Number(args.stepId || args.id); | |
| const status = String(args.status || "done") as any; | |
| const plan = planModes.get(sessionId); | |
| if (!plan) return "Error: No active plan"; | |
| const step = plan.steps.find(s => s.id === stepId); | |
| if (!step) return `Error: Step ${stepId} not found`; | |
| step.status = status; | |
| const icon = status === "done" ? "β" : status === "in_progress" ? "β" : status === "skipped" ? "β" : "β‘"; | |
| return `Step ${stepId} updated: ${icon} ${step.text} β ${status}`; | |
| } | |
| function executeEnterPlanMode(sessionId: number): string { | |
| setPlanMode(sessionId, true); | |
| return "Plan mode activated. Create a plan with TodoWrite tool before proceeding."; | |
| } | |
| function executeExitPlanMode(sessionId: number): string { | |
| setPlanMode(sessionId, false); | |
| return "Plan mode deactivated. Returning to normal execution."; | |
| } | |
| // βββ MCP Tools ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // βββ MCP Server Manager Singleton βββββββββββββββββββββββββββββββββββββββ | |
| let mcpManager: McpServerManager | null = null; | |
| let mcpToolsCache: ManagedMcpTool[] = []; | |
| export function getMcpManager(): McpServerManager | null { | |
| return mcpManager; | |
| } | |
| export function getMcpTools(): ManagedMcpTool[] { | |
| return mcpToolsCache; | |
| } | |
| export async function initializeMcpFromConfig(workDir: string): Promise<ManagedMcpTool[]> { | |
| const config = getConfig(workDir); | |
| const mcpServers = config.featureConfig.mcp.servers; | |
| if (Object.keys(mcpServers).length === 0) { | |
| return []; | |
| } | |
| // Shutdown existing manager | |
| if (mcpManager) { | |
| await mcpManager.shutdown(); | |
| } | |
| mcpManager = McpServerManager.fromConfig(mcpServers as any); | |
| mcpToolsCache = await mcpManager.connectAndListAllTools(); | |
| return mcpToolsCache; | |
| } | |
| async function executeMcpTool(args: Record<string, unknown>): Promise<string> { | |
| const server = args.server as string; | |
| const tool = args.tool as string; | |
| const toolArgs = (args.arguments || {}) as Record<string, any>; | |
| if (!server) return "Error: No MCP server specified"; | |
| if (!tool) return "Error: No MCP tool specified"; | |
| // Try real MCP execution if manager is available | |
| if (mcpManager) { | |
| try { | |
| // Try qualified name first, then construct it | |
| const qualifiedName = tool.startsWith("mcp__") ? tool : `mcp__${server.replace(/[^a-zA-Z0-9_]/g, "_").toLowerCase()}__${tool.replace(/[^a-zA-Z0-9_]/g, "_").toLowerCase()}`; | |
| const result = await mcpManager.callTool(qualifiedName, toolArgs); | |
| const textParts = result.content | |
| .filter((c: any) => c.type === "text" && c.text) | |
| .map((c: any) => c.text); | |
| return textParts.join("\n") || JSON.stringify(result.content); | |
| } catch (err: any) { | |
| return `MCP tool error: ${err.message}`; | |
| } | |
| } | |
| return `MCP tool executed:\n Server: ${server}\n Tool: ${tool}\n Arguments: ${JSON.stringify(toolArgs, null, 2)}\n Note: No MCP servers configured. Add mcpServers to .claw/settings.json`; | |
| } | |
| async function executeListMcpResources(args: Record<string, unknown>): Promise<string> { | |
| const server = args.server as string; | |
| if (!server) return "Error: No MCP server specified"; | |
| if (mcpManager) { | |
| const connected = mcpManager.getConnectedServers(); | |
| const unsupported = mcpManager.getUnsupportedServers(); | |
| let output = `MCP Servers:\n Connected: ${connected.join(", ") || "none"}\n`; | |
| if (unsupported.length > 0) { | |
| output += ` Unsupported: ${unsupported.map((s: any) => `${s.serverName} (${s.reason})`).join(", ")}\n`; | |
| } | |
| const tools = getMcpTools().filter((t: ManagedMcpTool) => t.serverName === server); | |
| if (tools.length > 0) { | |
| output += `\n Tools from "${server}":\n`; | |
| for (const t of tools) { | |
| output += ` - ${t.rawName}: ${t.tool.description || "(no description)"}\n`; | |
| } | |
| } | |
| return output; | |
| } | |
| return `MCP Resources for "${server}":\n No MCP servers configured. Add mcpServers to .claw/settings.json`; | |
| } | |
| async function executeReadMcpResource(args: Record<string, unknown>): Promise<string> { | |
| const server = args.server as string; | |
| const uri = args.uri as string; | |
| if (!server) return "Error: No MCP server specified"; | |
| if (!uri) return "Error: No resource URI specified"; | |
| return `MCP Resource read: ${uri} from ${server}\n Note: Resource reading requires MCP server with resources capability`; | |
| } | |
| function executeMcpAuth(args: Record<string, unknown>): string { | |
| const server = args.server as string; | |
| const method = (args.method || "oauth") as string; | |
| if (!server) return "Error: No MCP server specified"; | |
| return `MCP Auth initiated:\n Server: ${server}\n Method: ${method}\n Status: Authentication flow started. Configure OAuth in .claw/settings.json`; | |
| } | |
| // βββ Cron Jobs ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function executeCronCreate(args: Record<string, unknown>, sessionId: number): string { | |
| const schedule = String(args.schedule || ""); | |
| const command = String(args.command || ""); | |
| const description = String(args.description || command); | |
| if (!schedule) return "Error: No schedule provided (e.g., '*/5 * * * *')"; | |
| if (!command) return "Error: No command provided"; | |
| const id = `cron_${Date.now()}_${Math.random().toString(36).substring(7)}`; | |
| // Parse simple cron-like interval from schedule | |
| // Supports: "*/N * * * *" (every N minutes), "*/N * * * * *" (every N seconds) | |
| // For complex cron expressions, we approximate with setInterval | |
| let intervalMs = 60000; // default: 1 minute | |
| const match = schedule.match(/^\*\/(\d+)\s/); | |
| if (match) { | |
| const parts = schedule.trim().split(/\s+/); | |
| const n = parseInt(match[1], 10); | |
| if (parts.length >= 6) { | |
| // 6-field: seconds | |
| intervalMs = n * 1000; | |
| } else { | |
| // 5-field: minutes | |
| intervalMs = n * 60000; | |
| } | |
| } | |
| const entry = { | |
| id, sessionId, schedule, command, description, | |
| status: "active" as const, createdAt: Date.now(), | |
| runCount: 0, output: "", | |
| }; | |
| // REAL scheduling with setInterval | |
| const timer = setInterval(async () => { | |
| const job = cronJobs.get(id); | |
| if (!job || job.status !== "active") { | |
| clearInterval(timer); | |
| return; | |
| } | |
| try { | |
| const { stdout, stderr } = await execAsync(command, { | |
| cwd: "/home/ubuntu", | |
| timeout: 30000, | |
| env: { ...process.env, HOME: "/home/ubuntu" }, | |
| }); | |
| job.runCount++; | |
| job.lastRun = Date.now(); | |
| job.output = (stdout || "") + (stderr ? `[stderr] ${stderr}` : ""); | |
| } catch (error: any) { | |
| job.runCount++; | |
| job.lastRun = Date.now(); | |
| job.output = `[error] ${error.message}`; | |
| } | |
| }, intervalMs); | |
| (entry as any).timer = timer; | |
| cronJobs.set(id, entry as any); | |
| return `Cron job created:\n ID: ${id}\n Schedule: ${schedule} (interval: ${intervalMs}ms)\n Command: ${command}\n Status: active (REAL scheduling active)`; | |
| } | |
| function executeCronList(sessionId: number): string { | |
| const jobs = Array.from(cronJobs.values()).filter(j => j.sessionId === sessionId); | |
| if (jobs.length === 0) return "No cron jobs found."; | |
| return jobs.map(j => ` ${j.status === "active" ? "β" : "β"} [${j.id}] ${j.schedule} β ${j.description} (${j.status})`).join("\n"); | |
| } | |
| function executeCronDelete(args: Record<string, unknown>): string { | |
| const id = String(args.id || ""); | |
| if (!id) return "Error: No cron job ID provided"; | |
| const job = cronJobs.get(id); | |
| if (!job) return `Error: Cron job not found: ${id}`; | |
| // Clear the real timer | |
| if (job.timer) clearInterval(job.timer); | |
| job.status = "deleted"; | |
| cronJobs.delete(id); | |
| return `Cron job deleted: ${id} (timer cleared)`; | |
| } | |
| // βββ Team βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Team store for multi-agent coordination | |
| const teams = new Map<string, { | |
| id: string; name: string; agents: string[]; task: string; | |
| status: string; results: Map<string, string>; | |
| createdAt: number; | |
| }>(); | |
| async function executeTeamCreate(args: Record<string, unknown>, sessionId: number): Promise<string> { | |
| const name = String(args.name || ""); | |
| const agentNames = (args.agents as string[]) || []; | |
| const task = String(args.task || ""); | |
| if (!name) return "Error: No team name provided"; | |
| if (agentNames.length === 0) return "Error: No agents specified"; | |
| const teamId = `team_${Date.now()}_${Math.random().toString(36).substring(7)}`; | |
| const team = { | |
| id: teamId, name, agents: agentNames, task, | |
| status: "active" as string, results: new Map<string, string>(), | |
| createdAt: Date.now(), | |
| }; | |
| teams.set(teamId, team); | |
| // If task is provided, dispatch to all agents in parallel | |
| if (task) { | |
| const promises = agentNames.map(async (agentName) => { | |
| try { | |
| const result = await executeAgentReal({ | |
| prompt: task, | |
| name: agentName, | |
| description: `Team ${name} agent: ${agentName}`, | |
| subagent_type: agentName, | |
| }, sessionId, process.cwd()); | |
| team.results.set(agentName, result); | |
| } catch (e: any) { | |
| team.results.set(agentName, `Error: ${e.message}`); | |
| } | |
| }); | |
| // Don't await β let them run in background | |
| Promise.all(promises).then(() => { | |
| team.status = "completed"; | |
| }).catch(() => { | |
| team.status = "failed"; | |
| }); | |
| } | |
| return `Team created:\n ID: ${teamId}\n Name: ${name}\n Agents: ${agentNames.join(", ")}\n Task: ${task || "(none)"}\n Status: active\n Agents dispatched: ${agentNames.length}`; | |
| } | |
| function executeTeamDelete(args: Record<string, unknown>): string { | |
| const id = String(args.id || args.teamId || ""); | |
| if (!id) return "Error: No team ID provided"; | |
| const team = teams.get(id); | |
| if (!team) return `Error: Team not found: ${id}`; | |
| teams.delete(id); | |
| return `Team deleted: ${id} (${team.name})\n Final status: ${team.status}\n Results collected: ${team.results.size}/${team.agents.length}`; | |
| } | |
| // βββ Git Worktree βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function executeEnterWorktree(args: Record<string, unknown>, sessionId: number, workDir: string): Promise<string> { | |
| const branch = String(args.branch || ""); | |
| if (!branch) return "Error: No branch specified"; | |
| const worktreePath = path.join(workDir, `.worktrees/${branch}`); | |
| try { | |
| await execAsync(`git worktree add "${worktreePath}" "${branch}" 2>/dev/null || git worktree add -b "${branch}" "${worktreePath}"`, { cwd: workDir }); | |
| worktrees.set(sessionId, { path: worktreePath, branch, active: true }); | |
| return `Entered worktree:\n Branch: ${branch}\n Path: ${worktreePath}`; | |
| } catch (error: any) { | |
| return `Failed to create worktree: ${error.message}`; | |
| } | |
| } | |
| async function executeExitWorktree(sessionId: number, workDir: string): Promise<string> { | |
| const wt = worktrees.get(sessionId); | |
| if (!wt || !wt.active) return "No active worktree."; | |
| try { | |
| await execAsync(`git worktree remove "${wt.path}" --force`, { cwd: workDir }); | |
| wt.active = false; | |
| return `Exited worktree: ${wt.branch} (${wt.path})`; | |
| } catch (error: any) { | |
| return `Failed to remove worktree: ${error.message}`; | |
| } | |
| } | |
| // βββ LSP ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function executeLsp(args: Record<string, unknown>, workDir: string): Promise<string> { | |
| const action = String(args.action || ""); | |
| const filePath = String(args.path || ""); | |
| const line = Number(args.line || 1); | |
| const column = Number(args.column || 1); | |
| // Try real LSP client first, fall back to grep-based analysis | |
| try { | |
| const { getLspManager } = await import("../runtime/lsp-client.js"); | |
| const lspManager = getLspManager(workDir); | |
| switch (action) { | |
| case "definition": { | |
| const resolved = resolvePath(filePath, workDir); | |
| try { | |
| const locations = await lspManager.goToDefinition(resolved, { line: line - 1, character: column - 1 }); | |
| if (locations.length === 0) { | |
| // Fallback to grep-based | |
| return await grepBasedDefinition(filePath, line, column, workDir); | |
| } | |
| return `LSP Go-to-Definition:\n${locations.map(l => ` ${l.path}:${l.range.start.line + 1}:${l.range.start.character + 1}`).join("\n")}`; | |
| } catch { | |
| return await grepBasedDefinition(filePath, line, column, workDir); | |
| } | |
| } | |
| case "references": { | |
| const resolved = resolvePath(filePath, workDir); | |
| try { | |
| const locations = await lspManager.findReferences(resolved, { line: line - 1, character: column - 1 }); | |
| if (locations.length === 0) { | |
| return await grepBasedReferences(filePath, line, column, workDir); | |
| } | |
| return `LSP References (${locations.length} found):\n${locations.map(l => ` ${l.path}:${l.range.start.line + 1}:${l.range.start.character + 1}`).join("\n")}`; | |
| } catch { | |
| return await grepBasedReferences(filePath, line, column, workDir); | |
| } | |
| } | |
| case "hover": { | |
| // LSP hover with context | |
| const resolved = resolvePath(filePath, workDir); | |
| const startLine = Math.max(1, line - 2); | |
| const endLine = line + 2; | |
| const { stdout } = await execAsync(`sed -n '${startLine},${endLine}p' "${resolved}"`, { timeout: 5000 }); | |
| return `LSP Hover at ${filePath}:${line}:${column}:\n${"\u2500".repeat(40)}\n${stdout.trim()}`; | |
| } | |
| case "diagnostics": { | |
| try { | |
| const diags = await lspManager.collectWorkspaceDiagnostics(); | |
| if (diags.files.length === 0) { | |
| // Fallback to tsc | |
| const { stdout } = await execAsync(`cd "${workDir}" && npx tsc --noEmit 2>&1 | head -30`, { timeout: 30000 }); | |
| return `TypeScript Diagnostics:\n${stdout || "No errors found."}`; | |
| } | |
| let output = `Workspace Diagnostics: ${diags.errorCount()} errors, ${diags.warningCount()} warnings\n`; | |
| for (const file of diags.files) { | |
| output += `\n${file.path}:\n`; | |
| for (const d of file.diagnostics) { | |
| const sev = d.severity === 1 ? "ERROR" : d.severity === 2 ? "WARN" : "INFO"; | |
| output += ` ${d.range.start.line + 1}:${d.range.start.character + 1} [${sev}] ${d.message}\n`; | |
| } | |
| } | |
| return output.trim(); | |
| } catch { | |
| const { stdout } = await execAsync(`cd "${workDir}" && npx tsc --noEmit 2>&1 | head -30`, { timeout: 30000 }); | |
| return `TypeScript Diagnostics:\n${stdout || "No errors found."}`; | |
| } | |
| } | |
| case "symbols": { | |
| const resolved = resolvePath(filePath, workDir); | |
| const { stdout } = await execAsync(`grep -n "function\\|class\\|interface\\|type\\|const\\|export" "${resolved}" | head -30`, { timeout: 10000 }); | |
| return `Symbols in ${filePath}:\n${stdout || "No symbols found."}`; | |
| } | |
| default: | |
| return `LSP actions: definition, references, hover, diagnostics, symbols`; | |
| } | |
| } catch { | |
| // LSP client unavailable, fall back to grep-based | |
| return await grepBasedLsp(action, filePath, line, column, workDir); | |
| } | |
| } | |
| // Grep-based fallbacks for when LSP server is not available | |
| async function grepBasedDefinition(filePath: string, line: number, column: number, workDir: string): Promise<string> { | |
| const resolved = resolvePath(filePath, workDir); | |
| const { stdout: fileContent } = await execAsync(`sed -n '${line}p' "${resolved}"`, { timeout: 5000 }); | |
| const words = fileContent.trim().split(/[^a-zA-Z0-9_$]+/); | |
| const symbol = words.find(w => w.length > 1 && fileContent.indexOf(w) <= column) || words[0] || ""; | |
| if (!symbol) return `LSP definition: No symbol found at ${filePath}:${line}:${column}`; | |
| const { stdout } = await execAsync( | |
| `cd "${workDir}" && grep -rn "\\b\\(function\\|class\\|const\\|let\\|var\\|interface\\|type\\|export.*function\\|export.*const\\)\\s\\+${symbol}\\b" --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" 2>/dev/null | head -10`, | |
| { timeout: 10000 } | |
| ); | |
| if (!stdout.trim()) return `LSP definition: No definition found for "${symbol}"`; | |
| return `LSP Go-to-Definition for "${symbol}":\n${stdout.trim()}`; | |
| } | |
| async function grepBasedReferences(filePath: string, line: number, column: number, workDir: string): Promise<string> { | |
| const resolved = resolvePath(filePath, workDir); | |
| const { stdout: fileContent } = await execAsync(`sed -n '${line}p' "${resolved}"`, { timeout: 5000 }); | |
| const words = fileContent.trim().split(/[^a-zA-Z0-9_$]+/); | |
| const symbol = words.find(w => w.length > 1 && fileContent.indexOf(w) <= column) || words[0] || ""; | |
| if (!symbol) return `LSP references: No symbol found at ${filePath}:${line}:${column}`; | |
| const { stdout } = await execAsync( | |
| `cd "${workDir}" && grep -rn "\\b${symbol}\\b" --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" 2>/dev/null | head -20`, | |
| { timeout: 10000 } | |
| ); | |
| if (!stdout.trim()) return `LSP references: No references found for "${symbol}"`; | |
| const lines = stdout.trim().split("\n"); | |
| return `LSP References for "${symbol}" (${lines.length} found):\n${stdout.trim()}`; | |
| } | |
| async function grepBasedLsp(action: string, filePath: string, line: number, column: number, workDir: string): Promise<string> { | |
| switch (action) { | |
| case "definition": return await grepBasedDefinition(filePath, line, column, workDir); | |
| case "references": return await grepBasedReferences(filePath, line, column, workDir); | |
| case "hover": { | |
| const resolved = resolvePath(filePath, workDir); | |
| const startLine = Math.max(1, line - 2); | |
| const endLine = line + 2; | |
| const { stdout } = await execAsync(`sed -n '${startLine},${endLine}p' "${resolved}"`, { timeout: 5000 }); | |
| return `LSP Hover at ${filePath}:${line}:${column}:\n${"\u2500".repeat(40)}\n${stdout.trim()}`; | |
| } | |
| case "diagnostics": { | |
| try { | |
| const { stdout } = await execAsync(`cd "${workDir}" && npx tsc --noEmit 2>&1 | head -30`, { timeout: 30000 }); | |
| return `TypeScript Diagnostics:\n${stdout || "No errors found."}`; | |
| } catch (error: any) { | |
| return `Diagnostics: ${error.stdout || error.message}`; | |
| } | |
| } | |
| case "symbols": { | |
| const resolved = resolvePath(filePath, workDir); | |
| const { stdout } = await execAsync(`grep -n "function\\|class\\|interface\\|type\\|const\\|export" "${resolved}" | head -30`, { timeout: 10000 }); | |
| return `Symbols in ${filePath}:\n${stdout || "No symbols found."}`; | |
| } | |
| default: | |
| return `LSP actions: definition, references, hover, diagnostics, symbols`; | |
| } | |
| } | |
| // βββ Remote Trigger βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function executeRemoteTrigger(args: Record<string, unknown>): Promise<string> { | |
| const url = String(args.url || ""); | |
| const method = String(args.method || "POST"); | |
| const body = args.body ? JSON.stringify(args.body) : undefined; | |
| if (!url) return "Error: No URL provided"; | |
| try { | |
| const response = await fetch(url, { | |
| method, | |
| headers: { "Content-Type": "application/json" }, | |
| body, | |
| signal: AbortSignal.timeout(15000), | |
| }); | |
| const text = await response.text(); | |
| return `Remote trigger:\n URL: ${url}\n Method: ${method}\n Status: ${response.status}\n Response: ${text.substring(0, 500)}`; | |
| } catch (error: any) { | |
| return `Remote trigger failed: ${error.message}`; | |
| } | |
| } | |
| // βββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function resolvePath(p: string, workDir: string): string { | |
| if (path.isAbsolute(p)) return p; | |
| return path.resolve(workDir, p); | |
| } | |
| // βββ Re-exports from integrated modules βββββββββββββββββββββββββββββββββββββ | |
| // These allow other parts of the app to access sandbox/remote/permissions/file-ops | |
| // through executor without needing direct imports. | |
| export { environmentInfo, detectContainerEnvironment } from "../runtime/sandbox"; | |
| export type { SandboxStatus, EnvironmentInfo } from "../runtime/sandbox"; | |
| export { remoteSessionContextFromEnv, upstreamProxyBootstrapFromEnv } from "../runtime/remote"; | |
| export type { RemoteSessionContext, UpstreamProxyBootstrap } from "../runtime/remote"; | |
| export { PermissionPolicy, PermissionEvaluator, globMatchToolName, globMatchPath } from "../runtime/permissions"; | |
| export type { PermissionRule } from "../runtime/permissions"; | |
| export { isBinaryFile, resolveSymlinks, listDirectory, applyDiff } from "./file-ops"; | |
| export type { DiffHunk, DirectoryEntry, GrepSearchInput, GrepSearchOutput, GlobSearchOutput } from "./file-ops"; | |