diff --git a/README.md b/README.md
index 126dbc8..81e7406 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ Code without a keyboard. Send a message from your phone, and **txtcode** dispatc
1. **Text your AI** from WhatsApp, Telegram, Discord, Slack, Teams, or Signal
2. **It writes code** using Claude Code, Cursor, Codex, Gemini CLI, or other adapters
-3. **You stay in control** with mode switching, tool calling, and session logs
+3. **You stay in control** with mode switching and session logs
No port forwarding. No VPN. Just message and code.
@@ -66,10 +66,6 @@ Claude Code, Cursor CLI, OpenAI Codex, Gemini CLI, Kiro CLI, OpenCode, and Ollam
-### 9 Built-in Tools
-
-Terminal, process manager, git, file search, HTTP client, environment variables, network diagnostics, cron jobs, and system info all callable by the LLM.
-
### Session Logging
Per-session logs accessible from the TUI. Follow live, view by index, auto-pruned after 7 days.
@@ -159,7 +155,7 @@ txtcode supports **9 LLM providers** for chat mode. Configure one or more during
| **HuggingFace** | _Discovered at runtime_ | Inference Providers API |
| **OpenRouter** | _Discovered at runtime_ | Unified API for 100+ models |
-All providers support tool calling and the LLM can invoke any built-in tool.
+All providers are used in chat mode for general conversation and coding questions.
---
@@ -179,37 +175,19 @@ Use `/code` mode to route messages directly to a coding adapter with full coding
---
-## 🛠️ Built-in Tools
-
-The primary LLM in chat mode has access to **9 built-in tools** that it can call autonomously:
-
-| Tool | Capabilities |
-| :----------- | :---------------------------------------------------------------------------------------- |
-| **Terminal** | Execute shell commands with timeout and output capture |
-| **Process** | Manage background processes: list, poll, stream logs, kill, send input |
-| **Git** | Full git operations (blocks force-push and credential config for safety) |
-| **Search** | File and content search across the project |
-| **HTTP** | Make HTTP requests (GET, POST, PUT, DELETE, PATCH, HEAD). Blocks cloud metadata endpoints |
-| **Env** | Get, set, list, and delete environment variables. Masks sensitive values |
-| **Network** | Ping, DNS lookup, reachability checks, port scanning |
-| **Cron** | Create, list, and manage cron jobs |
-| **Sysinfo** | CPU, memory, disk, uptime, OS details |
-
----
-
## 💬 Chat Commands
Send these commands in any messaging app while connected:
-| Command | Description |
-| :----------- | :----------------------------------------------------------------------------- |
-| `/chat` | Switch to **Chat mode** to send messages to primary LLM with tools _(default)_ |
-| `/code` | Switch to **Code mode** to send messages to coding adapter (full CLI control) |
-| `/switch` | Switch primary LLM provider or coding adapter on the fly |
-| `/cli-model` | Change the model used by the current coding adapter |
-| `/cancel` | Cancel the currently running command |
-| `/status` | Show adapter connection and current configuration |
-| `/help` | Show available commands |
+| Command | Description |
+| :----------- | :---------------------------------------------------------------------------- |
+| `/chat` | Switch to **Chat mode** to send messages to primary LLM _(default)_ |
+| `/code` | Switch to **Code mode** to send messages to coding adapter (full CLI control) |
+| `/switch` | Switch primary LLM provider or coding adapter on the fly |
+| `/cli-model` | Change the model used by the current coding adapter |
+| `/cancel` | Cancel the currently running command |
+| `/status` | Show adapter connection and current configuration |
+| `/help` | Show available commands |
---
diff --git a/src/core/router.ts b/src/core/router.ts
index b28b29d..8f2ac99 100644
--- a/src/core/router.ts
+++ b/src/core/router.ts
@@ -16,16 +16,6 @@ import { processWithOpenRouter } from "../providers/openrouter";
import { processWithXAI } from "../providers/xai";
import { logger } from "../shared/logger";
import { IDEAdapter, ModelInfo } from "../shared/types";
-import { CronTool } from "../tools/cron";
-import { EnvTool } from "../tools/env";
-import { GitTool } from "../tools/git";
-import { HttpTool } from "../tools/http";
-import { NetworkTool } from "../tools/network";
-import { ProcessTool } from "../tools/process";
-import { ToolRegistry } from "../tools/registry";
-import { SearchTool } from "../tools/search";
-import { SysinfoTool } from "../tools/sysinfo";
-import { TerminalTool } from "../tools/terminal";
import { ContextManager } from "./context-manager";
export const AVAILABLE_ADAPTERS = [
@@ -43,7 +33,6 @@ export class Router {
private provider: string;
private apiKey: string;
private model: string;
- private toolRegistry: ToolRegistry;
private contextManager: ContextManager;
private pendingHandoff: string | null = null;
private currentAbortController: AbortController | null = null;
@@ -53,17 +42,6 @@ export class Router {
this.apiKey = process.env.AI_API_KEY || "";
this.model = process.env.AI_MODEL || "";
- this.toolRegistry = new ToolRegistry();
- this.toolRegistry.register(new TerminalTool());
- this.toolRegistry.register(new ProcessTool());
- this.toolRegistry.register(new GitTool());
- this.toolRegistry.register(new SearchTool());
- this.toolRegistry.register(new HttpTool());
- this.toolRegistry.register(new EnvTool());
- this.toolRegistry.register(new NetworkTool());
- this.toolRegistry.register(new CronTool());
- this.toolRegistry.register(new SysinfoTool());
-
this.contextManager = new ContextManager();
const ideType = process.env.IDE_TYPE || "";
@@ -151,28 +129,23 @@ export class Router {
private async _routeToProvider(instruction: string): Promise {
switch (this.provider) {
case "anthropic":
- return await processWithAnthropic(instruction, this.apiKey, this.model, this.toolRegistry);
+ return await processWithAnthropic(instruction, this.apiKey, this.model);
case "openai":
- return await processWithOpenAI(instruction, this.apiKey, this.model, this.toolRegistry);
+ return await processWithOpenAI(instruction, this.apiKey, this.model);
case "gemini":
- return await processWithGemini(instruction, this.apiKey, this.model, this.toolRegistry);
+ return await processWithGemini(instruction, this.apiKey, this.model);
case "openrouter":
- return await processWithOpenRouter(instruction, this.apiKey, this.model, this.toolRegistry);
+ return await processWithOpenRouter(instruction, this.apiKey, this.model);
case "moonshot":
- return await processWithMoonshot(instruction, this.apiKey, this.model, this.toolRegistry);
+ return await processWithMoonshot(instruction, this.apiKey, this.model);
case "minimax":
- return await processWithMiniMax(instruction, this.apiKey, this.model, this.toolRegistry);
+ return await processWithMiniMax(instruction, this.apiKey, this.model);
case "huggingface":
- return await processWithHuggingFace(
- instruction,
- this.apiKey,
- this.model,
- this.toolRegistry,
- );
+ return await processWithHuggingFace(instruction, this.apiKey, this.model);
case "mistral":
- return await processWithMistral(instruction, this.apiKey, this.model, this.toolRegistry);
+ return await processWithMistral(instruction, this.apiKey, this.model);
case "xai":
- return await processWithXAI(instruction, this.apiKey, this.model, this.toolRegistry);
+ return await processWithXAI(instruction, this.apiKey, this.model);
default:
return `[ERROR] Unsupported AI provider: ${this.provider}. Run: txtcode config`;
}
diff --git a/src/data/primary_llm_system_prompt.txt b/src/data/primary_llm_system_prompt.txt
deleted file mode 100644
index c95740b..0000000
--- a/src/data/primary_llm_system_prompt.txt
+++ /dev/null
@@ -1,40 +0,0 @@
-You are TxtCode AI — a helpful, knowledgeable coding assistant accessible via messaging platforms (WhatsApp, Telegram, Discord).
-
-ROLE:
-- You are the user's primary AI assistant for general conversation, coding questions, debugging help, and technical discussions
-- You have full access to the user's machine via a rich set of tools — terminal, git, file search, HTTP, env vars, networking, scheduling, and system monitoring
-- Use tools when the user asks you to execute something, check system state, or perform actions — NOT just to introduce yourself
-
-TOOLS AVAILABLE (9 total):
-1. exec — Run any shell command in any directory. Use workdir param for absolute paths. PowerShell on Windows, bash on macOS/Linux.
-2. process — Manage backgrounded shell commands: list, poll, kill, send stdin, clear finished sessions.
-3. git — Git operations: status, diff, log, branch, commit, stash, checkout, add, reset, push, pull, merge, rebase, tag, blame, show. Destructive ops (force push, hard reset) blocked unless force=true.
-4. search — Find files and code: grep (search content by regex), glob (find files by name pattern), find (by extension/size/date). Auto-skips node_modules/.git/dist.
-5. http — Make HTTP requests: GET/POST/PUT/DELETE/PATCH/HEAD. Test APIs, check endpoints, hit webhooks. Supports headers, body, timeout.
-6. env — Read environment variables and .env files. Sensitive values (keys, tokens, passwords) are automatically masked. Use unmask=true to reveal.
-7. network — Network diagnostics: ping, DNS lookup, reachability check, list listening ports or check if a specific port is in use.
-8. cron — Manage scheduled tasks: list/add/remove cron jobs (Unix) or scheduled tasks (Windows). Destructive ops require confirm=true.
-9. sysinfo — System info: overview, CPU, memory, disk usage, uptime, top processes by CPU/memory.
-
-TOOL USAGE GUIDELINES:
-- Use the git tool (not exec) for git operations — it has safety guardrails and structured output
-- Use the search tool (not exec + grep/find) for searching files — it's cross-platform and skips junk directories
-- Use the http tool (not exec + curl) for API testing — it gives structured response data
-- Use the env tool for environment variables — it masks secrets automatically
-- Use the network tool for connectivity checks — it works identically on all platforms
-- Use the sysinfo tool for system monitoring — it uses Node.js built-ins for reliable cross-platform data
-- Fall back to exec only for commands not covered by dedicated tools
-- Use the process tool to manage long-running commands started via exec
-
-BEHAVIOR:
-- Respond naturally and conversationally to greetings and general questions
-- Be concise — remember you're communicating via a messaging platform, not a full IDE
-- When asked to run commands or check something, use your available tools immediately
-- Provide clear, well-formatted responses (use markdown where helpful)
-- If the user needs deep coding work (editing files, writing code across multiple files), suggest switching to /code mode
-
-CONTEXT:
-- The user is communicating from a mobile or desktop messaging app
-- Keep responses short and scannable — avoid long walls of text
-- Use bullet points and code blocks for clarity
-- The user can switch to /code mode for direct coding adapter access
diff --git a/src/providers/anthropic.ts b/src/providers/anthropic.ts
index 690fe71..0dfd1b8 100644
--- a/src/providers/anthropic.ts
+++ b/src/providers/anthropic.ts
@@ -1,33 +1,13 @@
-import fs from "fs";
-import path from "path";
import Anthropic from "@anthropic-ai/sdk";
-import type {
- ContentBlock,
- MessageParam,
- TextBlock,
- ToolResultBlockParam,
- ToolUnion,
- ToolUseBlock,
-} from "@anthropic-ai/sdk/resources/messages/messages";
import { logger } from "../shared/logger";
-import { ToolRegistry } from "../tools/registry";
-const MAX_ITERATIONS = 10;
-
-function loadSystemPrompt(): string {
- try {
- const promptPath = path.join(__dirname, "..", "data", "primary_llm_system_prompt.txt");
- return fs.readFileSync(promptPath, "utf-8");
- } catch {
- return "You are a helpful coding assistant.";
- }
-}
+const SYSTEM_PROMPT =
+ "You are TxtCode AI — a helpful, knowledgeable coding assistant accessible via messaging. Be concise, use markdown for clarity, and suggest /code mode for deep coding work.";
export async function processWithAnthropic(
instruction: string,
apiKey: string,
model: string,
- toolRegistry?: ToolRegistry,
): Promise {
const startTime = Date.now();
logger.debug(`[Anthropic] Request → model=${model}, prompt=${instruction.length} chars`);
@@ -35,63 +15,24 @@ export async function processWithAnthropic(
try {
const anthropic = new Anthropic({ apiKey });
- const tools = toolRegistry
- ? (toolRegistry.getDefinitionsForProvider("anthropic") as unknown as ToolUnion[])
- : undefined;
-
- const messages: MessageParam[] = [{ role: "user", content: instruction }];
-
- for (let i = 0; i < MAX_ITERATIONS; i++) {
- const iterStart = Date.now();
- const response = await anthropic.messages.create({
- model,
- max_tokens: 4096,
- system: loadSystemPrompt(),
- messages,
- ...(tools ? { tools } : {}),
- });
-
- logger.debug(
- `[Anthropic] Response ← iteration=${i + 1}, stop=${response.stop_reason}, ` +
- `tokens=${response.usage.input_tokens}in/${response.usage.output_tokens}out, ` +
- `time=${Date.now() - iterStart}ms`,
- );
-
- const textParts = response.content
- .filter((block: ContentBlock): block is TextBlock => block.type === "text")
- .map((block: TextBlock) => block.text);
-
- const toolCalls = response.content.filter(
- (block: ContentBlock): block is ToolUseBlock => block.type === "tool_use",
- );
-
- if (toolCalls.length === 0 || !toolRegistry) {
- logger.debug(`[Anthropic] Done in ${Date.now() - startTime}ms (${i + 1} iteration(s))`);
- return textParts.join("\n") || "No response from Claude";
- }
-
- logger.debug(`[Anthropic] Tool calls: ${toolCalls.map((t) => t.name).join(", ")}`);
-
- messages.push({ role: "assistant", content: response.content });
-
- const toolResults: ToolResultBlockParam[] = [];
- for (const toolUse of toolCalls) {
- const result = await toolRegistry.execute(
- toolUse.name,
- toolUse.input as Record,
- );
- toolResults.push({
- type: "tool_result",
- tool_use_id: toolUse.id,
- content: result.output,
- });
- }
-
- messages.push({ role: "user", content: toolResults });
- }
+ const response = await anthropic.messages.create({
+ model,
+ max_tokens: 4096,
+ system: SYSTEM_PROMPT,
+ messages: [{ role: "user", content: instruction }],
+ });
+
+ const text = response.content
+ .filter((block): block is Anthropic.TextBlock => block.type === "text")
+ .map((block) => block.text)
+ .join("\n");
+
+ logger.debug(
+ `[Anthropic] Done in ${Date.now() - startTime}ms, ` +
+ `tokens=${response.usage.input_tokens}in/${response.usage.output_tokens}out`,
+ );
- logger.warn(`[Anthropic] Reached max ${MAX_ITERATIONS} iterations`);
- return "Reached maximum tool iterations.";
+ return text || "No response from Claude";
} catch (error: unknown) {
logger.error(`[Anthropic] API error after ${Date.now() - startTime}ms`, error);
throw new Error(
diff --git a/src/providers/gemini.ts b/src/providers/gemini.ts
index 7465e41..0e0191f 100644
--- a/src/providers/gemini.ts
+++ b/src/providers/gemini.ts
@@ -1,29 +1,13 @@
-import fs from "fs";
-import path from "path";
-import {
- type FunctionResponsePart,
- type Tool as GeminiTool,
- GoogleGenerativeAI,
-} from "@google/generative-ai";
+import { GoogleGenerativeAI } from "@google/generative-ai";
import { logger } from "../shared/logger";
-import { ToolRegistry } from "../tools/registry";
-const MAX_ITERATIONS = 10;
-
-function loadSystemPrompt(): string {
- try {
- const promptPath = path.join(__dirname, "..", "data", "primary_llm_system_prompt.txt");
- return fs.readFileSync(promptPath, "utf-8");
- } catch {
- return "You are a helpful coding assistant.";
- }
-}
+const SYSTEM_PROMPT =
+ "You are TxtCode AI — a helpful, knowledgeable coding assistant accessible via messaging. Be concise, use markdown for clarity, and suggest /code mode for deep coding work.";
export async function processWithGemini(
instruction: string,
apiKey: string,
model: string,
- toolRegistry?: ToolRegistry,
): Promise {
const startTime = Date.now();
logger.debug(`[Gemini] Request → model=${model}, prompt=${instruction.length} chars`);
@@ -31,57 +15,17 @@ export async function processWithGemini(
try {
const genAI = new GoogleGenerativeAI(apiKey);
- const tools = toolRegistry
- ? (toolRegistry.getDefinitionsForProvider("gemini") as unknown as GeminiTool[])
- : undefined;
-
const genModel = genAI.getGenerativeModel({
model,
- systemInstruction: loadSystemPrompt(),
- ...(tools ? { tools } : {}),
+ systemInstruction: SYSTEM_PROMPT,
});
- const chat = genModel.startChat();
- let iterStart = Date.now();
- let result = await chat.sendMessage(instruction);
-
- for (let i = 0; i < MAX_ITERATIONS; i++) {
- const response = result.response;
- const calls = response.functionCalls();
-
- logger.debug(
- `[Gemini] Response ← iteration=${i + 1}, ` +
- `toolCalls=${calls?.length ?? 0}, ` +
- `time=${Date.now() - iterStart}ms`,
- );
-
- if (!calls || calls.length === 0 || !toolRegistry) {
- logger.debug(`[Gemini] Done in ${Date.now() - startTime}ms (${i + 1} iteration(s))`);
- return response.text();
- }
-
- logger.debug(`[Gemini] Tool calls: ${calls.map((c) => c.name).join(", ")}`);
-
- const toolResults: FunctionResponsePart[] = [];
- for (const call of calls) {
- const execResult = await toolRegistry.execute(
- call.name,
- (call.args || {}) as Record,
- );
- toolResults.push({
- functionResponse: {
- name: call.name,
- response: { output: execResult.output, isError: execResult.isError },
- },
- });
- }
+ const result = await genModel.generateContent(instruction);
+ const text = result.response.text();
- iterStart = Date.now();
- result = await chat.sendMessage(toolResults);
- }
+ logger.debug(`[Gemini] Done in ${Date.now() - startTime}ms`);
- logger.warn(`[Gemini] Reached max ${MAX_ITERATIONS} iterations`);
- return "Reached maximum tool iterations.";
+ return text || "No response from Gemini";
} catch (error: unknown) {
logger.error(`[Gemini] API error after ${Date.now() - startTime}ms`, error);
throw new Error(
diff --git a/src/providers/huggingface.ts b/src/providers/huggingface.ts
index 43066a2..2d9a071 100644
--- a/src/providers/huggingface.ts
+++ b/src/providers/huggingface.ts
@@ -1,95 +1,42 @@
-import fs from "fs";
-import path from "path";
import OpenAI from "openai";
-import type {
- ChatCompletionMessageParam,
- ChatCompletionTool,
-} from "openai/resources/chat/completions/completions";
import { logger } from "../shared/logger";
-import { ToolRegistry } from "../tools/registry";
-const MAX_ITERATIONS = 10;
const HUGGINGFACE_BASE_URL = "https://router.huggingface.co/v1";
-function loadSystemPrompt(): string {
- try {
- const promptPath = path.join(__dirname, "..", "data", "primary_llm_system_prompt.txt");
- return fs.readFileSync(promptPath, "utf-8");
- } catch {
- return "You are a helpful coding assistant.";
- }
-}
+const SYSTEM_PROMPT =
+ "You are TxtCode AI — a helpful, knowledgeable coding assistant accessible via messaging. Be concise, use markdown for clarity, and suggest /code mode for deep coding work.";
export async function processWithHuggingFace(
instruction: string,
apiKey: string,
model: string,
- toolRegistry?: ToolRegistry,
): Promise {
const startTime = Date.now();
logger.debug(`[HuggingFace] Request → model=${model}, prompt=${instruction.length} chars`);
try {
- // HuggingFace Inference Providers use OpenAI-compatible API
const client = new OpenAI({
apiKey,
baseURL: HUGGINGFACE_BASE_URL,
});
- const tools = toolRegistry
- ? (toolRegistry.getDefinitionsForProvider("openai") as unknown as ChatCompletionTool[])
- : undefined;
-
- const messages: ChatCompletionMessageParam[] = [
- { role: "system", content: loadSystemPrompt() },
- { role: "user", content: instruction },
- ];
-
- for (let i = 0; i < MAX_ITERATIONS; i++) {
- const iterStart = Date.now();
- const completion = await client.chat.completions.create({
- model,
- messages,
- max_tokens: 4096,
- ...(tools ? { tools } : {}),
- });
-
- const choice = completion.choices[0];
- const assistantMsg = choice.message;
-
- logger.debug(
- `[HuggingFace] Response ← iteration=${i + 1}, finish=${choice.finish_reason}, ` +
- `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out, ` +
- `time=${Date.now() - iterStart}ms`,
- );
-
- if (!assistantMsg.tool_calls || assistantMsg.tool_calls.length === 0 || !toolRegistry) {
- logger.debug(`[HuggingFace] Done in ${Date.now() - startTime}ms (${i + 1} iteration(s))`);
- return assistantMsg.content || "No response from HuggingFace";
- }
-
- logger.debug(
- `[HuggingFace] Tool calls: ${assistantMsg.tool_calls.map((t) => ("function" in t ? t.function.name : t.type)).join(", ")}`,
- );
+ const completion = await client.chat.completions.create({
+ model,
+ max_tokens: 4096,
+ messages: [
+ { role: "system", content: SYSTEM_PROMPT },
+ { role: "user", content: instruction },
+ ],
+ });
- messages.push(assistantMsg);
+ const choice = completion.choices[0];
- for (const toolCall of assistantMsg.tool_calls) {
- if (toolCall.type !== "function") {
- continue;
- }
- const args = JSON.parse(toolCall.function.arguments);
- const result = await toolRegistry.execute(toolCall.function.name, args);
- messages.push({
- role: "tool",
- tool_call_id: toolCall.id,
- content: result.output,
- });
- }
- }
+ logger.debug(
+ `[HuggingFace] Done in ${Date.now() - startTime}ms, ` +
+ `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out`,
+ );
- logger.warn(`[HuggingFace] Reached max ${MAX_ITERATIONS} iterations`);
- return "Reached maximum tool iterations.";
+ return choice.message.content || "No response from HuggingFace";
} catch (error: unknown) {
logger.error(`[HuggingFace] API error after ${Date.now() - startTime}ms`, error);
throw new Error(
diff --git a/src/providers/minimax.ts b/src/providers/minimax.ts
index 83933cd..92445fb 100644
--- a/src/providers/minimax.ts
+++ b/src/providers/minimax.ts
@@ -1,102 +1,43 @@
-import fs from "fs";
-import path from "path";
import Anthropic from "@anthropic-ai/sdk";
-import type {
- ContentBlock,
- MessageParam,
- TextBlock,
- ToolResultBlockParam,
- ToolUnion,
- ToolUseBlock,
-} from "@anthropic-ai/sdk/resources/messages/messages";
import { logger } from "../shared/logger";
-import { ToolRegistry } from "../tools/registry";
-const MAX_ITERATIONS = 10;
const MINIMAX_BASE_URL = "https://api.minimax.chat/v1";
-function loadSystemPrompt(): string {
- try {
- const promptPath = path.join(__dirname, "..", "data", "primary_llm_system_prompt.txt");
- return fs.readFileSync(promptPath, "utf-8");
- } catch {
- return "You are a helpful coding assistant.";
- }
-}
+const SYSTEM_PROMPT =
+ "You are TxtCode AI — a helpful, knowledgeable coding assistant accessible via messaging. Be concise, use markdown for clarity, and suggest /code mode for deep coding work.";
export async function processWithMiniMax(
instruction: string,
apiKey: string,
model: string,
- toolRegistry?: ToolRegistry,
): Promise {
const startTime = Date.now();
logger.debug(`[MiniMax] Request → model=${model}, prompt=${instruction.length} chars`);
try {
- // MiniMax uses Anthropic-compatible API
const client = new Anthropic({
apiKey,
baseURL: MINIMAX_BASE_URL,
});
- const tools = toolRegistry
- ? (toolRegistry.getDefinitionsForProvider("anthropic") as unknown as ToolUnion[])
- : undefined;
-
- const messages: MessageParam[] = [{ role: "user", content: instruction }];
-
- for (let i = 0; i < MAX_ITERATIONS; i++) {
- const iterStart = Date.now();
- const response = await client.messages.create({
- model,
- max_tokens: 4096,
- system: loadSystemPrompt(),
- messages,
- ...(tools ? { tools } : {}),
- });
-
- logger.debug(
- `[MiniMax] Response ← iteration=${i + 1}, stop=${response.stop_reason}, ` +
- `tokens=${response.usage.input_tokens}in/${response.usage.output_tokens}out, ` +
- `time=${Date.now() - iterStart}ms`,
- );
-
- const textParts = response.content
- .filter((block: ContentBlock): block is TextBlock => block.type === "text")
- .map((block: TextBlock) => block.text);
-
- const toolCalls = response.content.filter(
- (block: ContentBlock): block is ToolUseBlock => block.type === "tool_use",
- );
-
- if (toolCalls.length === 0 || !toolRegistry) {
- logger.debug(`[MiniMax] Done in ${Date.now() - startTime}ms (${i + 1} iteration(s))`);
- return textParts.join("\n") || "No response from MiniMax";
- }
-
- logger.debug(`[MiniMax] Tool calls: ${toolCalls.map((t) => t.name).join(", ")}`);
-
- messages.push({ role: "assistant", content: response.content });
+ const response = await client.messages.create({
+ model,
+ max_tokens: 4096,
+ system: SYSTEM_PROMPT,
+ messages: [{ role: "user", content: instruction }],
+ });
- const toolResults: ToolResultBlockParam[] = [];
- for (const toolUse of toolCalls) {
- const result = await toolRegistry.execute(
- toolUse.name,
- toolUse.input as Record,
- );
- toolResults.push({
- type: "tool_result",
- tool_use_id: toolUse.id,
- content: result.output,
- });
- }
+ const text = response.content
+ .filter((block): block is Anthropic.TextBlock => block.type === "text")
+ .map((block) => block.text)
+ .join("\n");
- messages.push({ role: "user", content: toolResults });
- }
+ logger.debug(
+ `[MiniMax] Done in ${Date.now() - startTime}ms, ` +
+ `tokens=${response.usage.input_tokens}in/${response.usage.output_tokens}out`,
+ );
- logger.warn(`[MiniMax] Reached max ${MAX_ITERATIONS} iterations`);
- return "Reached maximum tool iterations.";
+ return text || "No response from MiniMax";
} catch (error: unknown) {
logger.error(`[MiniMax] API error after ${Date.now() - startTime}ms`, error);
throw new Error(
diff --git a/src/providers/mistral.ts b/src/providers/mistral.ts
index 7be41a1..c53f5ba 100644
--- a/src/providers/mistral.ts
+++ b/src/providers/mistral.ts
@@ -1,29 +1,13 @@
-import fs from "fs";
-import path from "path";
import OpenAI from "openai";
-import type {
- ChatCompletionMessageParam,
- ChatCompletionTool,
-} from "openai/resources/chat/completions/completions";
import { logger } from "../shared/logger";
-import { ToolRegistry } from "../tools/registry";
-const MAX_ITERATIONS = 10;
-
-function loadSystemPrompt(): string {
- try {
- const promptPath = path.join(__dirname, "..", "data", "primary_llm_system_prompt.txt");
- return fs.readFileSync(promptPath, "utf-8");
- } catch {
- return "You are a helpful coding assistant.";
- }
-}
+const SYSTEM_PROMPT =
+ "You are TxtCode AI — a helpful, knowledgeable coding assistant accessible via messaging. Be concise, use markdown for clarity, and suggest /code mode for deep coding work.";
export async function processWithMistral(
instruction: string,
apiKey: string,
model: string,
- toolRegistry?: ToolRegistry,
): Promise {
const startTime = Date.now();
logger.debug(`[Mistral] Request → model=${model}, prompt=${instruction.length} chars`);
@@ -34,60 +18,23 @@ export async function processWithMistral(
baseURL: "https://api.mistral.ai/v1",
});
- const tools = toolRegistry
- ? (toolRegistry.getDefinitionsForProvider("openai") as unknown as ChatCompletionTool[])
- : undefined;
-
- const messages: ChatCompletionMessageParam[] = [
- { role: "system", content: loadSystemPrompt() },
- { role: "user", content: instruction },
- ];
-
- for (let i = 0; i < MAX_ITERATIONS; i++) {
- const iterStart = Date.now();
- const completion = await client.chat.completions.create({
- model,
- messages,
- max_tokens: 4096,
- ...(tools ? { tools } : {}),
- });
-
- const choice = completion.choices[0];
- const assistantMsg = choice.message;
-
- logger.debug(
- `[Mistral] Response ← iteration=${i + 1}, finish=${choice.finish_reason}, ` +
- `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out, ` +
- `time=${Date.now() - iterStart}ms`,
- );
-
- if (!assistantMsg.tool_calls || assistantMsg.tool_calls.length === 0 || !toolRegistry) {
- logger.debug(`[Mistral] Done in ${Date.now() - startTime}ms (${i + 1} iteration(s))`);
- return assistantMsg.content || "No response from Mistral AI";
- }
-
- logger.debug(
- `[Mistral] Tool calls: ${assistantMsg.tool_calls.map((t) => ("function" in t ? t.function.name : t.type)).join(", ")}`,
- );
+ const completion = await client.chat.completions.create({
+ model,
+ max_tokens: 4096,
+ messages: [
+ { role: "system", content: SYSTEM_PROMPT },
+ { role: "user", content: instruction },
+ ],
+ });
- messages.push(assistantMsg);
+ const choice = completion.choices[0];
- for (const toolCall of assistantMsg.tool_calls) {
- if (toolCall.type !== "function") {
- continue;
- }
- const args = JSON.parse(toolCall.function.arguments);
- const result = await toolRegistry.execute(toolCall.function.name, args);
- messages.push({
- role: "tool",
- tool_call_id: toolCall.id,
- content: result.output,
- });
- }
- }
+ logger.debug(
+ `[Mistral] Done in ${Date.now() - startTime}ms, ` +
+ `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out`,
+ );
- logger.warn(`[Mistral] Reached max ${MAX_ITERATIONS} iterations`);
- return "Reached maximum tool iterations.";
+ return choice.message.content || "No response from Mistral AI";
} catch (error: unknown) {
logger.error(`[Mistral] API error after ${Date.now() - startTime}ms`, error);
throw new Error(
diff --git a/src/providers/moonshot.ts b/src/providers/moonshot.ts
index 29b77fd..b014ff5 100644
--- a/src/providers/moonshot.ts
+++ b/src/providers/moonshot.ts
@@ -1,29 +1,13 @@
-import fs from "fs";
-import path from "path";
import OpenAI from "openai";
-import type {
- ChatCompletionMessageParam,
- ChatCompletionTool,
-} from "openai/resources/chat/completions/completions";
import { logger } from "../shared/logger";
-import { ToolRegistry } from "../tools/registry";
-const MAX_ITERATIONS = 10;
-
-function loadSystemPrompt(): string {
- try {
- const promptPath = path.join(__dirname, "..", "data", "primary_llm_system_prompt.txt");
- return fs.readFileSync(promptPath, "utf-8");
- } catch {
- return "You are a helpful coding assistant.";
- }
-}
+const SYSTEM_PROMPT =
+ "You are TxtCode AI — a helpful, knowledgeable coding assistant accessible via messaging. Be concise, use markdown for clarity, and suggest /code mode for deep coding work.";
export async function processWithMoonshot(
instruction: string,
apiKey: string,
model: string,
- toolRegistry?: ToolRegistry,
): Promise {
const startTime = Date.now();
logger.debug(`[Moonshot] Request → model=${model}, prompt=${instruction.length} chars`);
@@ -34,60 +18,23 @@ export async function processWithMoonshot(
baseURL: "https://api.moonshot.cn/v1",
});
- const tools = toolRegistry
- ? (toolRegistry.getDefinitionsForProvider("openai") as unknown as ChatCompletionTool[])
- : undefined;
-
- const messages: ChatCompletionMessageParam[] = [
- { role: "system", content: loadSystemPrompt() },
- { role: "user", content: instruction },
- ];
-
- for (let i = 0; i < MAX_ITERATIONS; i++) {
- const iterStart = Date.now();
- const completion = await client.chat.completions.create({
- model,
- messages,
- max_tokens: 4096,
- ...(tools ? { tools } : {}),
- });
-
- const choice = completion.choices[0];
- const assistantMsg = choice.message;
-
- logger.debug(
- `[Moonshot] Response ← iteration=${i + 1}, finish=${choice.finish_reason}, ` +
- `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out, ` +
- `time=${Date.now() - iterStart}ms`,
- );
-
- if (!assistantMsg.tool_calls || assistantMsg.tool_calls.length === 0 || !toolRegistry) {
- logger.debug(`[Moonshot] Done in ${Date.now() - startTime}ms (${i + 1} iteration(s))`);
- return assistantMsg.content || "No response from Moonshot AI";
- }
-
- logger.debug(
- `[Moonshot] Tool calls: ${assistantMsg.tool_calls.map((t) => ("function" in t ? t.function.name : t.type)).join(", ")}`,
- );
+ const completion = await client.chat.completions.create({
+ model,
+ max_tokens: 4096,
+ messages: [
+ { role: "system", content: SYSTEM_PROMPT },
+ { role: "user", content: instruction },
+ ],
+ });
- messages.push(assistantMsg);
+ const choice = completion.choices[0];
- for (const toolCall of assistantMsg.tool_calls) {
- if (toolCall.type !== "function") {
- continue;
- }
- const args = JSON.parse(toolCall.function.arguments);
- const result = await toolRegistry.execute(toolCall.function.name, args);
- messages.push({
- role: "tool",
- tool_call_id: toolCall.id,
- content: result.output,
- });
- }
- }
+ logger.debug(
+ `[Moonshot] Done in ${Date.now() - startTime}ms, ` +
+ `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out`,
+ );
- logger.warn(`[Moonshot] Reached max ${MAX_ITERATIONS} iterations`);
- return "Reached maximum tool iterations.";
+ return choice.message.content || "No response from Moonshot AI";
} catch (error: unknown) {
logger.error(`[Moonshot] API error after ${Date.now() - startTime}ms`, error);
throw new Error(
diff --git a/src/providers/openai.ts b/src/providers/openai.ts
index a377e79..86cf85a 100644
--- a/src/providers/openai.ts
+++ b/src/providers/openai.ts
@@ -1,29 +1,13 @@
-import fs from "fs";
-import path from "path";
import OpenAI from "openai";
-import type {
- ChatCompletionMessageParam,
- ChatCompletionTool,
-} from "openai/resources/chat/completions/completions";
import { logger } from "../shared/logger";
-import { ToolRegistry } from "../tools/registry";
-const MAX_ITERATIONS = 10;
-
-function loadSystemPrompt(): string {
- try {
- const promptPath = path.join(__dirname, "..", "data", "primary_llm_system_prompt.txt");
- return fs.readFileSync(promptPath, "utf-8");
- } catch {
- return "You are a helpful coding assistant.";
- }
-}
+const SYSTEM_PROMPT =
+ "You are TxtCode AI — a helpful, knowledgeable coding assistant accessible via messaging. Be concise, use markdown for clarity, and suggest /code mode for deep coding work.";
export async function processWithOpenAI(
instruction: string,
apiKey: string,
model: string,
- toolRegistry?: ToolRegistry,
): Promise {
const startTime = Date.now();
logger.debug(`[OpenAI] Request → model=${model}, prompt=${instruction.length} chars`);
@@ -31,60 +15,23 @@ export async function processWithOpenAI(
try {
const openai = new OpenAI({ apiKey });
- const tools = toolRegistry
- ? (toolRegistry.getDefinitionsForProvider("openai") as unknown as ChatCompletionTool[])
- : undefined;
-
- const messages: ChatCompletionMessageParam[] = [
- { role: "system", content: loadSystemPrompt() },
- { role: "user", content: instruction },
- ];
-
- for (let i = 0; i < MAX_ITERATIONS; i++) {
- const iterStart = Date.now();
- const completion = await openai.chat.completions.create({
- model,
- messages,
- max_tokens: 4096,
- ...(tools ? { tools } : {}),
- });
+ const completion = await openai.chat.completions.create({
+ model,
+ max_tokens: 4096,
+ messages: [
+ { role: "system", content: SYSTEM_PROMPT },
+ { role: "user", content: instruction },
+ ],
+ });
- const choice = completion.choices[0];
- const assistantMsg = choice.message;
+ const choice = completion.choices[0];
- logger.debug(
- `[OpenAI] Response ← iteration=${i + 1}, finish=${choice.finish_reason}, ` +
- `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out, ` +
- `time=${Date.now() - iterStart}ms`,
- );
-
- if (!assistantMsg.tool_calls || assistantMsg.tool_calls.length === 0 || !toolRegistry) {
- logger.debug(`[OpenAI] Done in ${Date.now() - startTime}ms (${i + 1} iteration(s))`);
- return assistantMsg.content || "No response from GPT";
- }
-
- logger.debug(
- `[OpenAI] Tool calls: ${assistantMsg.tool_calls.map((t) => ("function" in t ? t.function.name : t.type)).join(", ")}`,
- );
-
- messages.push(assistantMsg);
-
- for (const toolCall of assistantMsg.tool_calls) {
- if (toolCall.type !== "function") {
- continue;
- }
- const args = JSON.parse(toolCall.function.arguments);
- const result = await toolRegistry.execute(toolCall.function.name, args);
- messages.push({
- role: "tool",
- tool_call_id: toolCall.id,
- content: result.output,
- });
- }
- }
+ logger.debug(
+ `[OpenAI] Done in ${Date.now() - startTime}ms, ` +
+ `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out`,
+ );
- logger.warn(`[OpenAI] Reached max ${MAX_ITERATIONS} iterations`);
- return "Reached maximum tool iterations.";
+ return choice.message.content || "No response from GPT";
} catch (error: unknown) {
logger.error(`[OpenAI] API error after ${Date.now() - startTime}ms`, error);
throw new Error(
diff --git a/src/providers/openrouter.ts b/src/providers/openrouter.ts
index ec84ea0..9fa99ee 100644
--- a/src/providers/openrouter.ts
+++ b/src/providers/openrouter.ts
@@ -1,29 +1,13 @@
-import fs from "fs";
-import path from "path";
import OpenAI from "openai";
-import type {
- ChatCompletionMessageParam,
- ChatCompletionTool,
-} from "openai/resources/chat/completions/completions";
import { logger } from "../shared/logger";
-import { ToolRegistry } from "../tools/registry";
-const MAX_ITERATIONS = 10;
-
-function loadSystemPrompt(): string {
- try {
- const promptPath = path.join(__dirname, "..", "data", "primary_llm_system_prompt.txt");
- return fs.readFileSync(promptPath, "utf-8");
- } catch {
- return "You are a helpful coding assistant.";
- }
-}
+const SYSTEM_PROMPT =
+ "You are TxtCode AI — a helpful, knowledgeable coding assistant accessible via messaging. Be concise, use markdown for clarity, and suggest /code mode for deep coding work.";
export async function processWithOpenRouter(
instruction: string,
apiKey: string,
model: string,
- toolRegistry?: ToolRegistry,
): Promise {
const startTime = Date.now();
logger.debug(`[OpenRouter] Request → model=${model}, prompt=${instruction.length} chars`);
@@ -38,60 +22,23 @@ export async function processWithOpenRouter(
},
});
- const tools = toolRegistry
- ? (toolRegistry.getDefinitionsForProvider("openai") as unknown as ChatCompletionTool[])
- : undefined;
-
- const messages: ChatCompletionMessageParam[] = [
- { role: "system", content: loadSystemPrompt() },
- { role: "user", content: instruction },
- ];
-
- for (let i = 0; i < MAX_ITERATIONS; i++) {
- const iterStart = Date.now();
- const completion = await client.chat.completions.create({
- model,
- messages,
- max_tokens: 4096,
- ...(tools ? { tools } : {}),
- });
-
- const choice = completion.choices[0];
- const assistantMsg = choice.message;
-
- logger.debug(
- `[OpenRouter] Response ← iteration=${i + 1}, finish=${choice.finish_reason}, ` +
- `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out, ` +
- `time=${Date.now() - iterStart}ms`,
- );
-
- if (!assistantMsg.tool_calls || assistantMsg.tool_calls.length === 0 || !toolRegistry) {
- logger.debug(`[OpenRouter] Done in ${Date.now() - startTime}ms (${i + 1} iteration(s))`);
- return assistantMsg.content || "No response from OpenRouter";
- }
-
- logger.debug(
- `[OpenRouter] Tool calls: ${assistantMsg.tool_calls.map((t) => ("function" in t ? t.function.name : t.type)).join(", ")}`,
- );
+ const completion = await client.chat.completions.create({
+ model,
+ max_tokens: 4096,
+ messages: [
+ { role: "system", content: SYSTEM_PROMPT },
+ { role: "user", content: instruction },
+ ],
+ });
- messages.push(assistantMsg);
+ const choice = completion.choices[0];
- for (const toolCall of assistantMsg.tool_calls) {
- if (toolCall.type !== "function") {
- continue;
- }
- const args = JSON.parse(toolCall.function.arguments);
- const result = await toolRegistry.execute(toolCall.function.name, args);
- messages.push({
- role: "tool",
- tool_call_id: toolCall.id,
- content: result.output,
- });
- }
- }
+ logger.debug(
+ `[OpenRouter] Done in ${Date.now() - startTime}ms, ` +
+ `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out`,
+ );
- logger.warn(`[OpenRouter] Reached max ${MAX_ITERATIONS} iterations`);
- return "Reached maximum tool iterations.";
+ return choice.message.content || "No response from OpenRouter";
} catch (error: unknown) {
logger.error(`[OpenRouter] API error after ${Date.now() - startTime}ms`, error);
throw new Error(
diff --git a/src/providers/xai.ts b/src/providers/xai.ts
index fd8707d..c4f4c77 100644
--- a/src/providers/xai.ts
+++ b/src/providers/xai.ts
@@ -1,29 +1,13 @@
-import fs from "fs";
-import path from "path";
import OpenAI from "openai";
-import type {
- ChatCompletionMessageParam,
- ChatCompletionTool,
-} from "openai/resources/chat/completions/completions";
import { logger } from "../shared/logger";
-import { ToolRegistry } from "../tools/registry";
-const MAX_ITERATIONS = 10;
-
-function loadSystemPrompt(): string {
- try {
- const promptPath = path.join(__dirname, "..", "data", "primary_llm_system_prompt.txt");
- return fs.readFileSync(promptPath, "utf-8");
- } catch {
- return "You are a helpful coding assistant.";
- }
-}
+const SYSTEM_PROMPT =
+ "You are TxtCode AI — a helpful, knowledgeable coding assistant accessible via messaging. Be concise, use markdown for clarity, and suggest /code mode for deep coding work.";
export async function processWithXAI(
instruction: string,
apiKey: string,
model: string,
- toolRegistry?: ToolRegistry,
): Promise {
const startTime = Date.now();
logger.debug(`[xAI] Request → model=${model}, prompt=${instruction.length} chars`);
@@ -34,60 +18,23 @@ export async function processWithXAI(
baseURL: "https://api.x.ai/v1",
});
- const tools = toolRegistry
- ? (toolRegistry.getDefinitionsForProvider("openai") as unknown as ChatCompletionTool[])
- : undefined;
-
- const messages: ChatCompletionMessageParam[] = [
- { role: "system", content: loadSystemPrompt() },
- { role: "user", content: instruction },
- ];
-
- for (let i = 0; i < MAX_ITERATIONS; i++) {
- const iterStart = Date.now();
- const completion = await client.chat.completions.create({
- model,
- messages,
- max_tokens: 4096,
- ...(tools ? { tools } : {}),
- });
-
- const choice = completion.choices[0];
- const assistantMsg = choice.message;
-
- logger.debug(
- `[xAI] Response ← iteration=${i + 1}, finish=${choice.finish_reason}, ` +
- `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out, ` +
- `time=${Date.now() - iterStart}ms`,
- );
-
- if (!assistantMsg.tool_calls || assistantMsg.tool_calls.length === 0 || !toolRegistry) {
- logger.debug(`[xAI] Done in ${Date.now() - startTime}ms (${i + 1} iteration(s))`);
- return assistantMsg.content || "No response from xAI";
- }
-
- logger.debug(
- `[xAI] Tool calls: ${assistantMsg.tool_calls.map((t) => ("function" in t ? t.function.name : t.type)).join(", ")}`,
- );
+ const completion = await client.chat.completions.create({
+ model,
+ max_tokens: 4096,
+ messages: [
+ { role: "system", content: SYSTEM_PROMPT },
+ { role: "user", content: instruction },
+ ],
+ });
- messages.push(assistantMsg);
+ const choice = completion.choices[0];
- for (const toolCall of assistantMsg.tool_calls) {
- if (toolCall.type !== "function") {
- continue;
- }
- const args = JSON.parse(toolCall.function.arguments);
- const result = await toolRegistry.execute(toolCall.function.name, args);
- messages.push({
- role: "tool",
- tool_call_id: toolCall.id,
- content: result.output,
- });
- }
- }
+ logger.debug(
+ `[xAI] Done in ${Date.now() - startTime}ms, ` +
+ `tokens=${completion.usage?.prompt_tokens ?? "?"}in/${completion.usage?.completion_tokens ?? "?"}out`,
+ );
- logger.warn(`[xAI] Reached max ${MAX_ITERATIONS} iterations`);
- return "Reached maximum tool iterations.";
+ return choice.message.content || "No response from xAI";
} catch (error: unknown) {
logger.error(`[xAI] API error after ${Date.now() - startTime}ms`, error);
throw new Error(`xAI API error: ${error instanceof Error ? error.message : "Unknown error"}`, {
diff --git a/src/tools/cron.ts b/src/tools/cron.ts
deleted file mode 100644
index e13cd74..0000000
--- a/src/tools/cron.ts
+++ /dev/null
@@ -1,372 +0,0 @@
-import { execFile, spawn } from "child_process";
-import { Tool, ToolDefinition, ToolResult } from "./types";
-
-const CMD_TIMEOUT = 15_000;
-
-function runCommand(
- cmd: string,
- args: string[],
- timeout: number = CMD_TIMEOUT,
-): Promise<{ stdout: string; stderr: string; code: number | null }> {
- return new Promise((resolve) => {
- execFile(cmd, args, { timeout, maxBuffer: 512 * 1024 }, (err, stdout, stderr) => {
- resolve({
- stdout: stdout?.toString() ?? "",
- stderr: stderr?.toString() ?? "",
- code: err ? ((err as { status?: number }).status ?? 1) : 0,
- });
- });
- });
-}
-
-function writeCrontab(
- content: string,
- timeout: number = CMD_TIMEOUT,
-): Promise<{ stderr: string; code: number | null }> {
- return new Promise((resolve) => {
- const proc = spawn("crontab", ["-"], { stdio: ["pipe", "ignore", "pipe"] });
- let stderr = "";
- const timer = setTimeout(() => {
- proc.kill();
- resolve({ stderr: "crontab write timed out", code: 1 });
- }, timeout);
-
- proc.stderr.on("data", (data: Buffer) => {
- stderr += data.toString();
- });
-
- proc.on("close", (code) => {
- clearTimeout(timer);
- resolve({ stderr, code });
- });
-
- proc.on("error", (err) => {
- clearTimeout(timer);
- resolve({ stderr: err.message, code: 1 });
- });
-
- proc.stdin.write(content);
- proc.stdin.end();
- });
-}
-
-export class CronTool implements Tool {
- name = "cron";
- description =
- "Manage scheduled tasks and cron jobs. Actions: list (show all scheduled tasks), " +
- "get (details of a specific task), add (create a scheduled task), remove (delete a task). " +
- "Uses crontab on Linux/macOS and schtasks on Windows.";
-
- getDefinition(): ToolDefinition {
- return {
- name: this.name,
- description: this.description,
- parameters: {
- type: "object",
- properties: {
- action: {
- type: "string",
- description: "Action to perform.",
- enum: ["list", "get", "add", "remove"],
- },
- name: {
- type: "string",
- description:
- "Task name (required for get, add, remove). On Unix, used as a comment identifier in crontab.",
- },
- schedule: {
- type: "string",
- description:
- "Schedule expression. Unix: cron expression (e.g. '0 2 * * *'). Windows: schtasks schedule (e.g. '/sc daily /st 02:00').",
- },
- command: {
- type: "string",
- description: "Command to execute (required for add).",
- },
- confirm: {
- type: "boolean",
- description: "Confirm destructive action (required true for add/remove).",
- },
- },
- required: ["action"],
- },
- };
- }
-
- async execute(args: Record, signal?: AbortSignal): Promise {
- if (signal?.aborted) {
- return { toolCallId: "", output: "Cron operation aborted", isError: true };
- }
-
- const action = args.action as string;
- const isWindows = process.platform === "win32";
-
- switch (action) {
- case "list":
- return isWindows ? this.listWindows() : this.listUnix();
- case "get":
- return isWindows ? this.getWindows(args.name as string) : this.getUnix(args.name as string);
- case "add":
- return this.addTask(args, isWindows);
- case "remove":
- return this.removeTask(args, isWindows);
- default:
- return {
- toolCallId: "",
- output: `Unknown action: ${action}. Use: list, get, add, remove.`,
- isError: true,
- };
- }
- }
-
- private async listUnix(): Promise {
- const result = await runCommand("crontab", ["-l"]);
-
- if (result.code !== 0) {
- if (result.stderr.includes("no crontab")) {
- return {
- toolCallId: "",
- output: "No crontab configured for current user.",
- isError: false,
- };
- }
- return {
- toolCallId: "",
- output: `Failed to list crontab: ${result.stderr.trim()}`,
- isError: true,
- };
- }
-
- const lines = result.stdout.trim().split("\n");
- const jobs: string[] = [];
-
- for (const line of lines) {
- const trimmed = line.trim();
- if (!trimmed) {
- continue;
- }
- if (trimmed.startsWith("#")) {
- jobs.push(trimmed);
- continue;
- }
- jobs.push(trimmed);
- }
-
- if (jobs.length === 0) {
- return { toolCallId: "", output: "Crontab is empty.", isError: false };
- }
-
- return {
- toolCallId: "",
- output: `Crontab entries:\n\n${jobs.join("\n")}`,
- isError: false,
- metadata: { count: jobs.length },
- };
- }
-
- private async listWindows(): Promise {
- const result = await runCommand("schtasks.exe", ["/query", "/fo", "TABLE", "/nh"]);
-
- if (result.code !== 0) {
- return {
- toolCallId: "",
- output: `Failed to list scheduled tasks: ${result.stderr.trim()}`,
- isError: true,
- };
- }
-
- const output = result.stdout.trim();
- if (!output) {
- return { toolCallId: "", output: "No scheduled tasks found.", isError: false };
- }
-
- return { toolCallId: "", output: `Scheduled tasks:\n\n${output}`, isError: false };
- }
-
- private async getUnix(name: string | undefined): Promise {
- if (!name) {
- return { toolCallId: "", output: "Error: name is required for get.", isError: true };
- }
-
- const result = await runCommand("crontab", ["-l"]);
- if (result.code !== 0) {
- return {
- toolCallId: "",
- output: `Failed to read crontab: ${result.stderr.trim()}`,
- isError: true,
- };
- }
-
- const lines = result.stdout.split("\n");
- const matching: string[] = [];
-
- for (let i = 0; i < lines.length; i++) {
- if (lines[i].includes(name)) {
- matching.push(lines[i].trim());
- }
- }
-
- if (matching.length === 0) {
- return { toolCallId: "", output: `No crontab entry matching "${name}".`, isError: false };
- }
-
- return {
- toolCallId: "",
- output: `Entries matching "${name}":\n\n${matching.join("\n")}`,
- isError: false,
- };
- }
-
- private async getWindows(name: string | undefined): Promise {
- if (!name) {
- return { toolCallId: "", output: "Error: name is required for get.", isError: true };
- }
-
- const result = await runCommand("schtasks.exe", ["/query", "/tn", name, "/v", "/fo", "LIST"]);
-
- if (result.code !== 0) {
- return {
- toolCallId: "",
- output: `Task "${name}" not found: ${result.stderr.trim()}`,
- isError: true,
- };
- }
-
- return { toolCallId: "", output: result.stdout.trim(), isError: false };
- }
-
- private async addTask(args: Record, isWindows: boolean): Promise {
- const name = (args.name as string)?.trim();
- const schedule = (args.schedule as string)?.trim();
- const command = (args.command as string)?.trim();
- const confirm = args.confirm === true;
-
- if (!name) {
- return { toolCallId: "", output: "Error: name is required for add.", isError: true };
- }
- if (!schedule) {
- return { toolCallId: "", output: "Error: schedule is required for add.", isError: true };
- }
- if (!command) {
- return { toolCallId: "", output: "Error: command is required for add.", isError: true };
- }
- if (!confirm) {
- return {
- toolCallId: "",
- output: `This will add a scheduled task:\n Name: ${name}\n Schedule: ${schedule}\n Command: ${command}\n\nSet confirm=true to proceed.`,
- isError: false,
- };
- }
-
- if (isWindows) {
- const ALLOWED_SCHTASKS_FLAGS = new Set([
- "/sc",
- "/st",
- "/sd",
- "/ed",
- "/mo",
- "/d",
- "/m",
- "/ri",
- ]);
- const scheduleParts = schedule.split(/\s+/);
- for (const part of scheduleParts) {
- if (part.startsWith("/") && !ALLOWED_SCHTASKS_FLAGS.has(part.toLowerCase())) {
- return {
- toolCallId: "",
- output: `Blocked: schedule flag "${part}" is not allowed. Allowed flags: ${[...ALLOWED_SCHTASKS_FLAGS].join(", ")}`,
- isError: true,
- };
- }
- }
- const schtasksArgs = ["/create", "/tn", name, "/tr", command];
- schtasksArgs.push(...scheduleParts);
- schtasksArgs.push("/f");
-
- const result = await runCommand("schtasks.exe", schtasksArgs);
- if (result.code !== 0) {
- return {
- toolCallId: "",
- output: `Failed to create task: ${(result.stdout + result.stderr).trim()}`,
- isError: true,
- };
- }
- return { toolCallId: "", output: `Task "${name}" created successfully.`, isError: false };
- }
-
- // Unix: append to crontab
- const cronLine = `${schedule} ${command} # ${name}`;
- const existing = await runCommand("crontab", ["-l"]);
- const currentCrontab = existing.code === 0 ? existing.stdout : "";
- const newCrontab = currentCrontab.trimEnd() + "\n" + cronLine + "\n";
-
- const result = await writeCrontab(newCrontab);
- if (result.code !== 0) {
- return {
- toolCallId: "",
- output: `Failed to add cron job: ${result.stderr.trim()}`,
- isError: true,
- };
- }
-
- return { toolCallId: "", output: `Cron job "${name}" added:\n ${cronLine}`, isError: false };
- }
-
- private async removeTask(args: Record, isWindows: boolean): Promise {
- const name = (args.name as string)?.trim();
- const confirm = args.confirm === true;
-
- if (!name) {
- return { toolCallId: "", output: "Error: name is required for remove.", isError: true };
- }
- if (!confirm) {
- return {
- toolCallId: "",
- output: `This will remove the scheduled task "${name}". Set confirm=true to proceed.`,
- isError: false,
- };
- }
-
- if (isWindows) {
- const result = await runCommand("schtasks.exe", ["/delete", "/tn", name, "/f"]);
- if (result.code !== 0) {
- return {
- toolCallId: "",
- output: `Failed to remove task: ${(result.stdout + result.stderr).trim()}`,
- isError: true,
- };
- }
- return { toolCallId: "", output: `Task "${name}" removed.`, isError: false };
- }
-
- // Unix: remove matching lines from crontab
- const existing = await runCommand("crontab", ["-l"]);
- if (existing.code !== 0) {
- return {
- toolCallId: "",
- output: `No crontab to modify: ${existing.stderr.trim()}`,
- isError: true,
- };
- }
-
- const lines = existing.stdout.split("\n");
- const commentTag = `# ${name}`;
- const filtered = lines.filter((line) => !line.endsWith(commentTag));
-
- if (filtered.length === lines.length) {
- return { toolCallId: "", output: `No crontab entry matching "${name}".`, isError: false };
- }
-
- const newCrontab = filtered.join("\n") + "\n";
- const result = await writeCrontab(newCrontab);
- if (result.code !== 0) {
- return {
- toolCallId: "",
- output: `Failed to update crontab: ${result.stderr.trim()}`,
- isError: true,
- };
- }
-
- return { toolCallId: "", output: `Cron job "${name}" removed.`, isError: false };
- }
-}
diff --git a/src/tools/env.ts b/src/tools/env.ts
deleted file mode 100644
index edc9ab5..0000000
--- a/src/tools/env.ts
+++ /dev/null
@@ -1,267 +0,0 @@
-import fs from "fs";
-import path from "path";
-import { Tool, ToolDefinition, ToolResult } from "./types";
-
-const SECRET_PATTERNS = [
- /secret/i,
- /key/i,
- /token/i,
- /password/i,
- /passwd/i,
- /credential/i,
- /auth/i,
- /private/i,
- /api_key/i,
- /apikey/i,
- /access/i,
- /jwt/i,
- /encrypt/i,
- /signing/i,
- /certificate/i,
-];
-
-function isSensitive(name: string): boolean {
- return SECRET_PATTERNS.some((p) => p.test(name));
-}
-
-function maskValue(name: string, value: string, unmask: boolean): string {
- if (unmask) {
- return value;
- }
- if (isSensitive(name)) {
- if (value.length <= 4) {
- return "****";
- }
- return value.substring(0, 2) + "****" + value.substring(value.length - 2);
- }
- return value;
-}
-
-export class EnvTool implements Tool {
- name = "env";
- description =
- "Read environment variables and .env files. Actions: list (all vars), get (single var), " +
- "dotenv (read .env file), check (verify multiple vars exist). " +
- "Sensitive values (keys, tokens, passwords) are masked by default — set unmask=true to reveal.";
-
- private defaultCwd: string;
-
- constructor(opts?: { cwd?: string }) {
- this.defaultCwd = opts?.cwd || process.env.PROJECT_PATH || process.cwd();
- }
-
- getDefinition(): ToolDefinition {
- return {
- name: this.name,
- description: this.description,
- parameters: {
- type: "object",
- properties: {
- action: {
- type: "string",
- description: "Action to perform.",
- enum: ["list", "get", "dotenv", "check"],
- },
- name: {
- type: "string",
- description:
- "Variable name (for action=get) or comma-separated names (for action=check).",
- },
- file: {
- type: "string",
- description: "Path to .env file (for action=dotenv). Defaults to .env in project root.",
- },
- filter: {
- type: "string",
- description:
- "Filter env vars by prefix or substring (for action=list). e.g. 'NODE', 'DATABASE'.",
- },
- unmask: {
- type: "boolean",
- description: "Show actual values of sensitive variables (default false).",
- },
- },
- required: ["action"],
- },
- };
- }
-
- async execute(args: Record, signal?: AbortSignal): Promise {
- if (signal?.aborted) {
- return { toolCallId: "", output: "Env operation aborted", isError: true };
- }
-
- const action = args.action as string;
- const unmask = args.unmask === true;
-
- switch (action) {
- case "list":
- return this.actionList(args.filter as string | undefined, unmask);
- case "get":
- return this.actionGet(args.name as string | undefined, unmask);
- case "dotenv":
- return this.actionDotenv(args.file as string | undefined, unmask);
- case "check":
- return this.actionCheck(args.name as string | undefined);
- default:
- return {
- toolCallId: "",
- output: `Unknown action: ${action}. Use: list, get, dotenv, check.`,
- isError: true,
- };
- }
- }
-
- private actionList(filter: string | undefined, unmask: boolean): ToolResult {
- const env = process.env;
- let entries = Object.entries(env).filter(([, v]) => v !== undefined) as [string, string][];
-
- if (filter) {
- const upper = filter.toUpperCase();
- entries = entries.filter(([k]) => k.toUpperCase().includes(upper));
- }
-
- entries.sort(([a], [b]) => a.localeCompare(b));
-
- if (entries.length === 0) {
- return {
- toolCallId: "",
- output: filter ? `No env vars matching "${filter}".` : "No environment variables found.",
- isError: false,
- };
- }
-
- const lines = entries.map(([k, v]) => `${k}=${maskValue(k, v, unmask)}`);
-
- // Cap at 200 entries to protect context
- const capped = lines.length > 200;
- const output = (capped ? lines.slice(0, 200) : lines).join("\n");
-
- return {
- toolCallId: "",
- output: `${entries.length} variable(s)${filter ? ` matching "${filter}"` : ""}:\n\n${output}${capped ? "\n\n(showing first 200)" : ""}`,
- isError: false,
- metadata: { count: entries.length },
- };
- }
-
- private actionGet(name: string | undefined, unmask: boolean): ToolResult {
- if (!name) {
- return { toolCallId: "", output: "Error: name is required for action=get.", isError: true };
- }
-
- const value = process.env[name];
- if (value === undefined) {
- return {
- toolCallId: "",
- output: `${name} is not set.`,
- isError: false,
- metadata: { exists: false },
- };
- }
-
- return {
- toolCallId: "",
- output: `${name}=${maskValue(name, value, unmask)}`,
- isError: false,
- metadata: { exists: true },
- };
- }
-
- private actionDotenv(file: string | undefined, unmask: boolean): ToolResult {
- const envFile = file || path.join(this.defaultCwd, ".env");
-
- if (!fs.existsSync(envFile)) {
- // Try common alternatives
- const alternatives = [".env.local", ".env.development", ".env.example"];
- const found: string[] = [];
- for (const alt of alternatives) {
- const altPath = path.join(this.defaultCwd, alt);
- if (fs.existsSync(altPath)) {
- found.push(alt);
- }
- }
-
- let msg = `File not found: ${envFile}`;
- if (found.length > 0) {
- msg += `\n\nAvailable .env files: ${found.join(", ")}`;
- }
- return { toolCallId: "", output: msg, isError: true };
- }
-
- let content: string;
- try {
- content = fs.readFileSync(envFile, "utf-8");
- } catch (err) {
- return {
- toolCallId: "",
- output: `Cannot read ${envFile}: ${err instanceof Error ? err.message : "permission denied"}`,
- isError: true,
- };
- }
-
- const lines = content.split("\n");
- const result: string[] = [];
-
- for (const line of lines) {
- const trimmed = line.trim();
- if (!trimmed || trimmed.startsWith("#")) {
- result.push(line);
- continue;
- }
-
- const eqIndex = trimmed.indexOf("=");
- if (eqIndex === -1) {
- result.push(line);
- continue;
- }
-
- const key = trimmed.substring(0, eqIndex).trim();
- const val = trimmed
- .substring(eqIndex + 1)
- .trim()
- .replace(/^["']|["']$/g, "");
- result.push(`${key}=${maskValue(key, val, unmask)}`);
- }
-
- return {
- toolCallId: "",
- output: `${path.basename(envFile)}:\n\n${result.join("\n")}`,
- isError: false,
- };
- }
-
- private actionCheck(names: string | undefined): ToolResult {
- if (!names) {
- return {
- toolCallId: "",
- output: "Error: name is required for action=check (comma-separated).",
- isError: true,
- };
- }
-
- const varNames = names
- .split(",")
- .map((n) => n.trim())
- .filter(Boolean);
- const results: string[] = [];
- let allSet = true;
-
- for (const name of varNames) {
- const value = process.env[name];
- if (value !== undefined && value !== "") {
- results.push(` ${name}: set`);
- } else {
- results.push(` ${name}: NOT SET`);
- allSet = false;
- }
- }
-
- return {
- toolCallId: "",
- output: `${allSet ? "All variables are set" : "Some variables are missing"}:\n\n${results.join("\n")}`,
- isError: false,
- metadata: { allSet },
- };
- }
-}
diff --git a/src/tools/git.ts b/src/tools/git.ts
deleted file mode 100644
index 672941e..0000000
--- a/src/tools/git.ts
+++ /dev/null
@@ -1,219 +0,0 @@
-import { execFile } from "child_process";
-import { killProcessTree } from "../shared/process-kill";
-import { Tool, ToolDefinition, ToolResult } from "./types";
-
-const BLOCKED_PATTERNS = [
- /push\s+.*--force/i,
- /push\s+.*-f\b/i,
- /config\s+.*credential/i,
- /config\s+.*user\.(name|email)/i,
-];
-
-const MAX_OUTPUT = 50_000;
-
-function runGit(
- args: string[],
- cwd: string,
- signal?: AbortSignal,
-): Promise<{ stdout: string; stderr: string; code: number | null }> {
- return new Promise((resolve) => {
- const proc = execFile(
- "git",
- args,
- { cwd, maxBuffer: 1024 * 1024, timeout: 30_000 },
- (err, stdout, stderr) => {
- resolve({
- stdout: stdout?.toString() ?? "",
- stderr: stderr?.toString() ?? "",
- code: err ? ((err as { status?: number }).status ?? 1) : 0,
- });
- },
- );
-
- if (signal) {
- const handler = () => {
- killProcessTree(proc);
- };
- signal.addEventListener("abort", handler, { once: true });
- proc.on("exit", () => signal.removeEventListener("abort", handler));
- }
- });
-}
-
-function truncate(text: string): string {
- if (text.length > MAX_OUTPUT) {
- return text.slice(0, MAX_OUTPUT) + "\n\n(output truncated)";
- }
- return text;
-}
-
-export class GitTool implements Tool {
- name = "git";
- description =
- "Run git operations on the local repository. Actions: status, diff, log, branch, commit, stash, checkout, add, reset, remote, show, blame. " +
- "Destructive operations (push --force, reset --hard) are blocked unless force=true.";
-
- private defaultCwd: string;
-
- constructor(opts?: { cwd?: string }) {
- this.defaultCwd = opts?.cwd || process.env.PROJECT_PATH || process.cwd();
- }
-
- getDefinition(): ToolDefinition {
- return {
- name: this.name,
- description: this.description,
- parameters: {
- type: "object",
- properties: {
- action: {
- type: "string",
- description: "Git action to perform.",
- enum: [
- "status",
- "diff",
- "log",
- "branch",
- "commit",
- "stash",
- "checkout",
- "add",
- "reset",
- "remote",
- "show",
- "blame",
- "tag",
- "pull",
- "push",
- "fetch",
- "merge",
- "rebase",
- "cherry-pick",
- ],
- },
- args: {
- type: "string",
- description:
- "Additional arguments (e.g. branch name, file path, --oneline, -n 10). Passed directly to git.",
- },
- message: {
- type: "string",
- description: "Commit message (used with action=commit).",
- },
- workdir: {
- type: "string",
- description: "Working directory. Defaults to project root.",
- },
- force: {
- type: "boolean",
- description:
- "Allow destructive operations like push --force or reset --hard. Default false.",
- },
- },
- required: ["action"],
- },
- };
- }
-
- async execute(args: Record, signal?: AbortSignal): Promise {
- if (signal?.aborted) {
- return { toolCallId: "", output: "Git operation aborted", isError: true };
- }
-
- const action = args.action as string;
- const extraArgs = (args.args as string)?.trim() || "";
- const message = (args.message as string)?.trim() || "";
- const workdir = (args.workdir as string)?.trim() || this.defaultCwd;
- const force = args.force === true;
-
- const fullCommand = `${action} ${extraArgs}`.trim();
- if (!force) {
- for (const pattern of BLOCKED_PATTERNS) {
- if (pattern.test(fullCommand)) {
- return {
- toolCallId: "",
- output: `Blocked: "${fullCommand}" is a destructive operation. Set force=true to proceed.`,
- isError: true,
- };
- }
- }
-
- if (action === "reset" && extraArgs.includes("--hard")) {
- return {
- toolCallId: "",
- output: `Blocked: "reset --hard" will discard changes. Set force=true to proceed.`,
- isError: true,
- };
- }
- }
-
- let gitArgs: string[];
-
- switch (action) {
- case "status":
- gitArgs = ["status", "--short", "--branch"];
- if (extraArgs) {
- gitArgs.push(...extraArgs.split(/\s+/));
- }
- break;
-
- case "diff":
- gitArgs = ["diff"];
- if (extraArgs) {
- gitArgs.push(...extraArgs.split(/\s+/));
- } else {
- gitArgs.push("--stat");
- }
- break;
-
- case "log":
- gitArgs = ["log", "--oneline"];
- if (extraArgs) {
- gitArgs.push(...extraArgs.split(/\s+/));
- } else {
- gitArgs.push("-20");
- }
- break;
-
- case "commit":
- if (!message) {
- return {
- toolCallId: "",
- output: "Error: commit requires a message parameter.",
- isError: true,
- };
- }
- gitArgs = ["commit", "-m", message];
- if (extraArgs) {
- gitArgs.push(...extraArgs.split(/\s+/));
- }
- break;
-
- case "branch":
- gitArgs = ["branch"];
- if (extraArgs) {
- gitArgs.push(...extraArgs.split(/\s+/));
- } else {
- gitArgs.push("-a");
- }
- break;
-
- default:
- gitArgs = [action];
- if (extraArgs) {
- gitArgs.push(...extraArgs.split(/\s+/));
- }
- break;
- }
-
- const result = await runGit(gitArgs, workdir, signal);
- const output = (result.stdout + (result.stderr ? "\n" + result.stderr : "")).trim();
-
- return {
- toolCallId: "",
- output: truncate(output || "(no output)"),
- isError: result.code !== 0 && result.code !== null,
- metadata: { exitCode: result.code, action },
- };
- }
-}
diff --git a/src/tools/http.ts b/src/tools/http.ts
deleted file mode 100644
index 94d1bec..0000000
--- a/src/tools/http.ts
+++ /dev/null
@@ -1,204 +0,0 @@
-import { Tool, ToolDefinition, ToolResult } from "./types";
-
-const DEFAULT_TIMEOUT_MS = 30_000;
-const MAX_BODY_SIZE = 50_000;
-
-const BLOCKED_HOSTS = new Set(["169.254.169.254", "metadata.google.internal", "metadata.internal"]);
-
-function isBlockedHost(hostname: string): boolean {
- if (BLOCKED_HOSTS.has(hostname)) {
- return true;
- }
-
- const lower = hostname.toLowerCase();
-
- // Block localhost variants
- if (
- lower === "localhost" ||
- lower === "127.0.0.1" ||
- lower === "[::1]" ||
- lower === "0.0.0.0" ||
- lower === "[::ffff:127.0.0.1]"
- ) {
- return true;
- }
-
- // Block link-local / metadata IP ranges
- if (lower.startsWith("169.254.") || lower.includes("169.254.169.254")) {
- return true;
- }
-
- // Block IPv6-mapped metadata
- if (lower.includes("::ffff:169.254.") || lower.includes("::ffff:a9fe")) {
- return true;
- }
-
- // Block cloud metadata hostnames via subdomain
- if (lower.endsWith(".internal") || lower.includes("metadata")) {
- return true;
- }
-
- return false;
-}
-
-export class HttpTool implements Tool {
- name = "http";
- description =
- "Make HTTP requests to test APIs, check endpoints, or fetch data. " +
- "Supports GET, POST, PUT, DELETE, PATCH, HEAD methods. " +
- "Response body is truncated to 50KB. Cloud metadata endpoints are blocked for security.";
-
- getDefinition(): ToolDefinition {
- return {
- name: this.name,
- description: this.description,
- parameters: {
- type: "object",
- properties: {
- url: {
- type: "string",
- description: "The URL to request (e.g. http://localhost:3000/api/users).",
- },
- method: {
- type: "string",
- description: "HTTP method.",
- enum: ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
- },
- headers: {
- type: "object",
- description:
- 'Request headers as key-value pairs (e.g. {"Authorization": "Bearer ...", "Content-Type": "application/json"}).',
- },
- body: {
- type: "string",
- description:
- "Request body (for POST/PUT/PATCH). Send as string — use JSON.stringify for JSON payloads.",
- },
- timeout: {
- type: "number",
- description: `Timeout in milliseconds (default ${DEFAULT_TIMEOUT_MS}).`,
- },
- },
- required: ["url"],
- },
- };
- }
-
- async execute(args: Record, signal?: AbortSignal): Promise {
- if (signal?.aborted) {
- return { toolCallId: "", output: "HTTP request aborted", isError: true };
- }
-
- const url = (args.url as string)?.trim();
- if (!url) {
- return { toolCallId: "", output: "Error: url is required.", isError: true };
- }
-
- let parsedUrl: URL;
- try {
- parsedUrl = new URL(url);
- } catch {
- return { toolCallId: "", output: `Error: invalid URL: ${url}`, isError: true };
- }
-
- if (isBlockedHost(parsedUrl.hostname)) {
- return {
- toolCallId: "",
- output: `Blocked: requests to ${parsedUrl.hostname} are not allowed (cloud metadata security).`,
- isError: true,
- };
- }
-
- const method = ((args.method as string) || "GET").toUpperCase();
- const headers = (args.headers as Record) || {};
- const body = args.body as string | undefined;
- const timeoutMs = typeof args.timeout === "number" ? args.timeout : DEFAULT_TIMEOUT_MS;
-
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
-
- if (signal) {
- signal.addEventListener("abort", () => controller.abort(), { once: true });
- }
-
- try {
- const startTime = Date.now();
-
- const fetchOpts: RequestInit = {
- method,
- headers,
- signal: controller.signal,
- redirect: "manual",
- };
-
- if (body && !["GET", "HEAD", "OPTIONS"].includes(method)) {
- fetchOpts.body = body;
- }
-
- const response = await fetch(url, fetchOpts);
- const elapsed = Date.now() - startTime;
-
- const responseHeaders: Record = {};
- const importantHeaders = [
- "content-type",
- "content-length",
- "server",
- "x-request-id",
- "location",
- "set-cookie",
- "cache-control",
- ];
- for (const key of importantHeaders) {
- const val = response.headers.get(key);
- if (val) {
- responseHeaders[key] = val;
- }
- }
-
- let responseBody = "";
- if (method !== "HEAD") {
- try {
- const text = await response.text();
- responseBody =
- text.length > MAX_BODY_SIZE
- ? text.substring(0, MAX_BODY_SIZE) + "\n\n(body truncated)"
- : text;
- } catch {
- responseBody = "(could not read response body)";
- }
- }
-
- const headerLines = Object.entries(responseHeaders)
- .map(([k, v]) => ` ${k}: ${v}`)
- .join("\n");
-
- const output = [
- `${response.status} ${response.statusText} (${elapsed}ms)`,
- headerLines ? `\nHeaders:\n${headerLines}` : "",
- responseBody ? `\nBody:\n${responseBody}` : "",
- ]
- .filter(Boolean)
- .join("\n");
-
- return {
- toolCallId: "",
- output,
- isError: response.status >= 400,
- metadata: {
- status: response.status,
- statusText: response.statusText,
- elapsed,
- method,
- },
- };
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- if (message.includes("abort")) {
- return { toolCallId: "", output: `Request timed out after ${timeoutMs}ms.`, isError: true };
- }
- return { toolCallId: "", output: `HTTP request failed: ${message}`, isError: true };
- } finally {
- clearTimeout(timeoutId);
- }
- }
-}
diff --git a/src/tools/network.ts b/src/tools/network.ts
deleted file mode 100644
index a1c9553..0000000
--- a/src/tools/network.ts
+++ /dev/null
@@ -1,281 +0,0 @@
-import { execFile } from "child_process";
-import dns from "dns";
-import { Tool, ToolDefinition, ToolResult } from "./types";
-
-const PING_TIMEOUT = 10_000;
-const REACHABLE_TIMEOUT = 10_000;
-
-function runCommand(
- cmd: string,
- args: string[],
- timeout: number = PING_TIMEOUT,
-): Promise<{ stdout: string; stderr: string; code: number | null }> {
- return new Promise((resolve) => {
- execFile(cmd, args, { timeout, maxBuffer: 512 * 1024 }, (err, stdout, stderr) => {
- resolve({
- stdout: stdout?.toString() ?? "",
- stderr: stderr?.toString() ?? "",
- code: err ? ((err as { code?: number }).code ?? 1) : 0,
- });
- });
- });
-}
-
-export class NetworkTool implements Tool {
- name = "network";
- description =
- "Network diagnostics. Actions: ping (ICMP ping a host), dns (DNS lookup), " +
- "reachable (check if URL/host responds), ports (list listening ports or check a specific port). " +
- "Works on Windows, macOS, and Linux.";
-
- getDefinition(): ToolDefinition {
- return {
- name: this.name,
- description: this.description,
- parameters: {
- type: "object",
- properties: {
- action: {
- type: "string",
- description: "Network action to perform.",
- enum: ["ping", "dns", "reachable", "ports"],
- },
- target: {
- type: "string",
- description: "Hostname, IP, or URL (for ping, dns, reachable).",
- },
- port: {
- type: "number",
- description: "Specific port to check (for action=ports).",
- },
- count: {
- type: "number",
- description: "Number of ping packets (default 4).",
- },
- },
- required: ["action"],
- },
- };
- }
-
- async execute(args: Record, signal?: AbortSignal): Promise {
- if (signal?.aborted) {
- return { toolCallId: "", output: "Network operation aborted", isError: true };
- }
-
- const action = args.action as string;
- const target = (args.target as string)?.trim() || "";
- const port = args.port as number | undefined;
- const count = typeof args.count === "number" ? Math.min(args.count, 20) : 4;
-
- switch (action) {
- case "ping":
- return this.actionPing(target, count);
- case "dns":
- return this.actionDns(target);
- case "reachable":
- return this.actionReachable(target);
- case "ports":
- return this.actionPorts(port);
- default:
- return {
- toolCallId: "",
- output: `Unknown action: ${action}. Use: ping, dns, reachable, ports.`,
- isError: true,
- };
- }
- }
-
- private async actionPing(target: string, count: number): Promise {
- if (!target) {
- return { toolCallId: "", output: "Error: target is required for ping.", isError: true };
- }
-
- const isWindows = process.platform === "win32";
- const isMac = process.platform === "darwin";
- const timeoutVal = isMac ? "5000" : "5";
- const pingArgs = isWindows
- ? ["-n", String(count), target]
- : ["-c", String(count), "-W", timeoutVal, target];
-
- const result = await runCommand("ping", pingArgs, PING_TIMEOUT + count * 2000);
- const output = (result.stdout + result.stderr).trim();
-
- return {
- toolCallId: "",
- output: output || `Ping to ${target} completed (exit code ${result.code}).`,
- isError: result.code !== 0,
- metadata: { target, exitCode: result.code },
- };
- }
-
- private async actionDns(target: string): Promise {
- if (!target) {
- return { toolCallId: "", output: "Error: target is required for dns.", isError: true };
- }
-
- // Strip protocol if URL was passed
- const hostname = target.replace(/^https?:\/\//, "").replace(/[:/].*$/, "");
-
- try {
- const resolver = new dns.promises.Resolver();
- resolver.setServers(["8.8.8.8", "1.1.1.1"]);
-
- const results: string[] = [`DNS lookup for ${hostname}:\n`];
-
- try {
- const a = await resolver.resolve4(hostname);
- results.push(`A records: ${a.join(", ")}`);
- } catch {}
-
- try {
- const aaaa = await resolver.resolve6(hostname);
- results.push(`AAAA records: ${aaaa.join(", ")}`);
- } catch {}
-
- try {
- const mx = await resolver.resolveMx(hostname);
- const mxStr = mx.map((r) => `${r.exchange} (priority ${r.priority})`).join(", ");
- results.push(`MX records: ${mxStr}`);
- } catch {}
-
- try {
- const cname = await resolver.resolveCname(hostname);
- results.push(`CNAME: ${cname.join(", ")}`);
- } catch {}
-
- try {
- const ns = await resolver.resolveNs(hostname);
- results.push(`NS records: ${ns.join(", ")}`);
- } catch {}
-
- try {
- const txt = await resolver.resolveTxt(hostname);
- const txtStr = txt.map((t) => t.join("")).slice(0, 5);
- if (txtStr.length > 0) {
- results.push(`TXT records: ${txtStr.join("; ")}`);
- }
- } catch {}
-
- return {
- toolCallId: "",
- output: results.length > 1 ? results.join("\n") : `No DNS records found for ${hostname}.`,
- isError: results.length <= 1,
- };
- } catch (err) {
- return {
- toolCallId: "",
- output: `DNS lookup failed for ${hostname}: ${err instanceof Error ? err.message : String(err)}`,
- isError: true,
- };
- }
- }
-
- private async actionReachable(target: string): Promise {
- if (!target) {
- return { toolCallId: "", output: "Error: target is required for reachable.", isError: true };
- }
-
- let url = target;
- if (!url.startsWith("http://") && !url.startsWith("https://")) {
- url = `https://${url}`;
- }
-
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), REACHABLE_TIMEOUT);
-
- try {
- const start = Date.now();
- const response = await fetch(url, {
- method: "HEAD",
- signal: controller.signal,
- redirect: "follow",
- });
- const elapsed = Date.now() - start;
-
- return {
- toolCallId: "",
- output: `${target} is reachable.\n Status: ${response.status} ${response.statusText}\n Response time: ${elapsed}ms`,
- isError: false,
- metadata: { reachable: true, status: response.status, elapsed },
- };
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- if (message.includes("abort")) {
- return {
- toolCallId: "",
- output: `${target} is not reachable (timed out after ${REACHABLE_TIMEOUT}ms).`,
- isError: false,
- metadata: { reachable: false },
- };
- }
- return {
- toolCallId: "",
- output: `${target} is not reachable: ${message}`,
- isError: false,
- metadata: { reachable: false },
- };
- } finally {
- clearTimeout(timeoutId);
- }
- }
-
- private async actionPorts(port?: number): Promise {
- const isWindows = process.platform === "win32";
- const isMac = process.platform === "darwin";
-
- let result: { stdout: string; stderr: string; code: number | null };
-
- if (isWindows) {
- if (port) {
- result = await runCommand("powershell.exe", [
- "-NoProfile",
- "-NonInteractive",
- "-Command",
- `Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | ` +
- `Select-Object LocalAddress, LocalPort, OwningProcess | Format-Table -AutoSize`,
- ]);
- } else {
- result = await runCommand("powershell.exe", [
- "-NoProfile",
- "-NonInteractive",
- "-Command",
- `Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | ` +
- `Select-Object LocalAddress, LocalPort, OwningProcess | Sort-Object LocalPort | Format-Table -AutoSize`,
- ]);
- }
- } else if (isMac) {
- if (port) {
- result = await runCommand("lsof", ["-iTCP:" + port, "-sTCP:LISTEN", "-P", "-n"]);
- } else {
- result = await runCommand("lsof", ["-iTCP", "-sTCP:LISTEN", "-P", "-n"]);
- }
- } else {
- // Linux
- if (port) {
- result = await runCommand("ss", ["-tlnp", "sport", "=", String(port)]);
- } else {
- result = await runCommand("ss", ["-tlnp"]);
- }
- }
-
- const output = (result.stdout + result.stderr).trim();
-
- if (!output || result.code !== 0) {
- if (port) {
- return { toolCallId: "", output: `No process listening on port ${port}.`, isError: false };
- }
- return {
- toolCallId: "",
- output: output || "Could not retrieve listening ports.",
- isError: result.code !== 0,
- };
- }
-
- return {
- toolCallId: "",
- output: port ? `Port ${port}:\n\n${output}` : `Listening ports:\n\n${output}`,
- isError: false,
- };
- }
-}
diff --git a/src/tools/process-registry.ts b/src/tools/process-registry.ts
deleted file mode 100644
index 43100e5..0000000
--- a/src/tools/process-registry.ts
+++ /dev/null
@@ -1,234 +0,0 @@
-import { ChildProcessWithoutNullStreams } from "child_process";
-import crypto from "crypto";
-
-const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
-const DEFAULT_MAX_OUTPUT_CHARS = 200_000;
-const DEFAULT_PENDING_MAX_OUTPUT_CHARS = 30_000;
-const TAIL_CHARS = 2000;
-
-export type ProcessStatus = "running" | "completed" | "failed" | "killed";
-
-export interface ProcessSession {
- id: string;
- command: string;
- child?: ChildProcessWithoutNullStreams;
- pid?: number;
- startedAt: number;
- cwd?: string;
- maxOutputChars: number;
- pendingMaxOutputChars: number;
- totalOutputChars: number;
- pendingStdout: string[];
- pendingStderr: string[];
- pendingStdoutChars: number;
- pendingStderrChars: number;
- aggregated: string;
- tail: string;
- exitCode?: number | null;
- exitSignal?: string | null;
- exited: boolean;
- truncated: boolean;
- backgrounded: boolean;
-}
-
-export interface FinishedSession {
- id: string;
- command: string;
- startedAt: number;
- endedAt: number;
- cwd?: string;
- status: ProcessStatus;
- exitCode?: number | null;
- exitSignal?: string | null;
- aggregated: string;
- tail: string;
- truncated: boolean;
- totalOutputChars: number;
-}
-
-const runningSessions = new Map();
-const finishedSessions = new Map();
-let sweeper: ReturnType | null = null;
-
-export function createSessionId(): string {
- return crypto.randomUUID().slice(0, 8);
-}
-
-export function createSession(opts: {
- command: string;
- cwd?: string;
- maxOutputChars?: number;
- pendingMaxOutputChars?: number;
-}): ProcessSession {
- const id = createSessionId();
- const session: ProcessSession = {
- id,
- command: opts.command,
- startedAt: Date.now(),
- cwd: opts.cwd,
- maxOutputChars: opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS,
- pendingMaxOutputChars: opts.pendingMaxOutputChars ?? DEFAULT_PENDING_MAX_OUTPUT_CHARS,
- totalOutputChars: 0,
- pendingStdout: [],
- pendingStderr: [],
- pendingStdoutChars: 0,
- pendingStderrChars: 0,
- aggregated: "",
- tail: "",
- exited: false,
- truncated: false,
- backgrounded: false,
- };
- runningSessions.set(id, session);
- startSweeper();
- return session;
-}
-
-export function getSession(id: string): ProcessSession | undefined {
- return runningSessions.get(id);
-}
-
-export function getFinishedSession(id: string): FinishedSession | undefined {
- return finishedSessions.get(id);
-}
-
-export function deleteSession(id: string): void {
- runningSessions.delete(id);
- finishedSessions.delete(id);
-}
-
-export function appendOutput(
- session: ProcessSession,
- stream: "stdout" | "stderr",
- chunk: string,
-): void {
- const buffer = stream === "stdout" ? session.pendingStdout : session.pendingStderr;
- const bufferCharsKey = stream === "stdout" ? "pendingStdoutChars" : "pendingStderrChars";
- const pendingCap = Math.min(session.pendingMaxOutputChars, session.maxOutputChars);
-
- buffer.push(chunk);
- let pendingChars = session[bufferCharsKey] + chunk.length;
-
- if (pendingChars > pendingCap) {
- session.truncated = true;
- pendingChars = capBuffer(buffer, pendingChars, pendingCap);
- }
- session[bufferCharsKey] = pendingChars;
- session.totalOutputChars += chunk.length;
-
- const newAggregated = session.aggregated + chunk;
- if (newAggregated.length > session.maxOutputChars) {
- session.truncated = true;
- session.aggregated = newAggregated.slice(newAggregated.length - session.maxOutputChars);
- } else {
- session.aggregated = newAggregated;
- }
- session.tail = tail(session.aggregated, TAIL_CHARS);
-}
-
-export function drainSession(session: ProcessSession): { stdout: string; stderr: string } {
- const stdout = session.pendingStdout.join("");
- const stderr = session.pendingStderr.join("");
- session.pendingStdout = [];
- session.pendingStderr = [];
- session.pendingStdoutChars = 0;
- session.pendingStderrChars = 0;
- return { stdout, stderr };
-}
-
-export function markExited(
- session: ProcessSession,
- exitCode: number | null,
- exitSignal: string | null,
- status: ProcessStatus,
-): void {
- session.exited = true;
- session.exitCode = exitCode;
- session.exitSignal = exitSignal;
- session.tail = tail(session.aggregated, TAIL_CHARS);
-
- cleanupChild(session);
- runningSessions.delete(session.id);
-
- if (session.backgrounded) {
- finishedSessions.set(session.id, {
- id: session.id,
- command: session.command,
- startedAt: session.startedAt,
- endedAt: Date.now(),
- cwd: session.cwd,
- status,
- exitCode: session.exitCode,
- exitSignal: session.exitSignal,
- aggregated: session.aggregated,
- tail: session.tail,
- truncated: session.truncated,
- totalOutputChars: session.totalOutputChars,
- });
- }
-}
-
-export function markBackgrounded(session: ProcessSession): void {
- session.backgrounded = true;
-}
-
-export function listRunningSessions(): ProcessSession[] {
- return Array.from(runningSessions.values()).filter((s) => s.backgrounded);
-}
-
-export function listFinishedSessions(): FinishedSession[] {
- return Array.from(finishedSessions.values());
-}
-
-export function clearFinished(): void {
- finishedSessions.clear();
-}
-
-export function tail(text: string, max = TAIL_CHARS): string {
- if (text.length <= max) {
- return text;
- }
- return text.slice(text.length - max);
-}
-
-function capBuffer(buffer: string[], pendingChars: number, cap: number): number {
- while (buffer.length > 0 && pendingChars - buffer[0].length >= cap) {
- pendingChars -= buffer[0].length;
- buffer.shift();
- }
- if (buffer.length > 0 && pendingChars > cap) {
- const overflow = pendingChars - cap;
- buffer[0] = buffer[0].slice(overflow);
- pendingChars = cap;
- }
- return pendingChars;
-}
-
-function cleanupChild(session: ProcessSession): void {
- if (session.child) {
- session.child.stdin?.destroy?.();
- session.child.stdout?.destroy?.();
- session.child.stderr?.destroy?.();
- session.child.removeAllListeners();
- delete session.child;
- }
-}
-
-function pruneFinishedSessions(): void {
- const cutoff = Date.now() - DEFAULT_SESSION_TTL_MS;
- for (const [id, session] of finishedSessions.entries()) {
- if (session.endedAt < cutoff) {
- finishedSessions.delete(id);
- }
- }
-}
-
-function startSweeper(): void {
- if (sweeper) {
- return;
- }
- sweeper = setInterval(pruneFinishedSessions, 60_000);
- if (sweeper.unref) {
- sweeper.unref();
- }
-}
diff --git a/src/tools/process.ts b/src/tools/process.ts
deleted file mode 100644
index 9ba96af..0000000
--- a/src/tools/process.ts
+++ /dev/null
@@ -1,300 +0,0 @@
-import { killProcessTree, forceKillProcess } from "../shared/process-kill";
-import {
- getSession,
- getFinishedSession,
- deleteSession,
- drainSession,
- listRunningSessions,
- listFinishedSessions,
- clearFinished,
- tail,
-} from "./process-registry";
-import { Tool, ToolDefinition, ToolResult } from "./types";
-
-export class ProcessTool implements Tool {
- name = "process";
- description =
- "Manage backgrounded shell commands. Actions: list (show sessions), poll (get latest output), " +
- "log (get full output), kill (terminate), send (write to stdin), clear (remove finished), " +
- "remove (delete a specific session).";
-
- getDefinition(): ToolDefinition {
- return {
- name: this.name,
- description: this.description,
- parameters: {
- type: "object",
- properties: {
- action: {
- type: "string",
- description: "Action to perform.",
- enum: ["list", "poll", "log", "kill", "send", "clear", "remove"],
- },
- session_id: {
- type: "string",
- description: "Session ID (required for poll, log, kill, send, remove).",
- },
- data: {
- type: "string",
- description: "Data to send to stdin (only for action=send).",
- },
- },
- required: ["action"],
- },
- };
- }
-
- async execute(args: Record, signal?: AbortSignal): Promise {
- const action = args.action as string;
- const sessionId = args.session_id as string | undefined;
-
- if (signal?.aborted) {
- return { toolCallId: "", output: "Process tool execution aborted", isError: true };
- }
-
- switch (action) {
- case "list":
- return this.actionList();
- case "poll":
- return this.actionPoll(sessionId);
- case "log":
- return this.actionLog(sessionId);
- case "kill":
- return this.actionKill(sessionId);
- case "send":
- return this.actionSend(sessionId, args.data as string | undefined);
- case "clear":
- return this.actionClear();
- case "remove":
- return this.actionRemove(sessionId);
- default:
- return {
- toolCallId: "",
- output: `Unknown action: ${action}. Use: list, poll, log, kill, send, clear, remove.`,
- isError: true,
- };
- }
- }
-
- private actionList(): ToolResult {
- const running = listRunningSessions();
- const finished = listFinishedSessions();
-
- if (running.length === 0 && finished.length === 0) {
- return {
- toolCallId: "",
- output: "No active or finished background sessions.",
- isError: false,
- };
- }
-
- const lines: string[] = [];
-
- if (running.length > 0) {
- lines.push(`Running (${running.length}):`);
- for (const s of running) {
- const elapsed = Math.round((Date.now() - s.startedAt) / 1000);
- lines.push(
- ` [${s.id}] pid=${s.pid ?? "?"} elapsed=${elapsed}s cmd="${truncCmd(s.command)}"`,
- );
- }
- }
-
- if (finished.length > 0) {
- lines.push(`Finished (${finished.length}):`);
- for (const s of finished) {
- const dur = Math.round((s.endedAt - s.startedAt) / 1000);
- lines.push(
- ` [${s.id}] status=${s.status} exit=${s.exitCode ?? "?"} dur=${dur}s cmd="${truncCmd(s.command)}"`,
- );
- }
- }
-
- return { toolCallId: "", output: lines.join("\n"), isError: false };
- }
-
- private actionPoll(sessionId?: string): ToolResult {
- if (!sessionId) {
- return { toolCallId: "", output: "Error: session_id is required for poll.", isError: true };
- }
-
- const running = getSession(sessionId);
- if (running) {
- const drained = drainSession(running);
- const output = [drained.stdout, drained.stderr].filter(Boolean).join("\n");
- const elapsed = Math.round((Date.now() - running.startedAt) / 1000);
- const header = `[${running.id}] running, elapsed=${elapsed}s, pid=${running.pid ?? "?"}`;
- return {
- toolCallId: "",
- output: output ? `${header}\n${output}` : `${header}\n(no new output since last poll)`,
- isError: false,
- metadata: { status: "running", sessionId: running.id },
- };
- }
-
- const finished = getFinishedSession(sessionId);
- if (finished) {
- const dur = Math.round((finished.endedAt - finished.startedAt) / 1000);
- const header = `[${finished.id}] ${finished.status}, exit=${finished.exitCode ?? "?"}, dur=${dur}s`;
- return {
- toolCallId: "",
- output: `${header}\n${tail(finished.aggregated, 4000) || "(no output)"}`,
- isError: finished.status === "failed",
- metadata: {
- status: finished.status,
- exitCode: finished.exitCode,
- sessionId: finished.id,
- },
- };
- }
-
- return { toolCallId: "", output: `Session ${sessionId} not found.`, isError: true };
- }
-
- private actionLog(sessionId?: string): ToolResult {
- if (!sessionId) {
- return { toolCallId: "", output: "Error: session_id is required for log.", isError: true };
- }
-
- const running = getSession(sessionId);
- if (running) {
- const truncNote = running.truncated ? "\n(output was truncated)" : "";
- return {
- toolCallId: "",
- output: (running.aggregated || "(no output yet)") + truncNote,
- isError: false,
- metadata: { totalChars: running.totalOutputChars, truncated: running.truncated },
- };
- }
-
- const finished = getFinishedSession(sessionId);
- if (finished) {
- return {
- toolCallId: "",
- output: finished.aggregated || "(no output)",
- isError: false,
- metadata: { totalChars: finished.totalOutputChars, truncated: finished.truncated },
- };
- }
-
- return { toolCallId: "", output: `Session ${sessionId} not found.`, isError: true };
- }
-
- private actionKill(sessionId?: string): ToolResult {
- if (!sessionId) {
- return { toolCallId: "", output: "Error: session_id is required for kill.", isError: true };
- }
-
- const running = getSession(sessionId);
- if (!running) {
- return {
- toolCallId: "",
- output: `Session ${sessionId} not found or already exited.`,
- isError: true,
- };
- }
-
- if (running.child) {
- const childRef = running.child;
- try {
- killProcessTree(childRef, 3000);
- setTimeout(() => {
- try {
- if (!running.exited && childRef.pid) {
- forceKillProcess(childRef);
- }
- } catch {
- // Process may have already exited
- }
- }, 3000);
- } catch {
- // Best-effort kill
- }
- }
-
- const killMsg =
- process.platform === "win32"
- ? `Terminating session ${sessionId} (pid ${running.pid ?? "?"}) via taskkill.`
- : `Sent SIGTERM to session ${sessionId} (pid ${running.pid ?? "?"}). Will force-kill in 3s if still alive.`;
-
- return {
- toolCallId: "",
- output: killMsg,
- isError: false,
- };
- }
-
- private actionSend(sessionId?: string, data?: string): ToolResult {
- if (!sessionId) {
- return { toolCallId: "", output: "Error: session_id is required for send.", isError: true };
- }
- if (!data) {
- return { toolCallId: "", output: "Error: data is required for send.", isError: true };
- }
-
- const running = getSession(sessionId);
- if (!running) {
- return {
- toolCallId: "",
- output: `Session ${sessionId} not found or already exited.`,
- isError: true,
- };
- }
- if (!running.child || !running.child.stdin) {
- return {
- toolCallId: "",
- output: `Session ${sessionId} has no stdin available.`,
- isError: true,
- };
- }
-
- try {
- running.child.stdin.write(data);
- return {
- toolCallId: "",
- output: `Sent ${data.length} bytes to session ${sessionId}.`,
- isError: false,
- };
- } catch (err) {
- return {
- toolCallId: "",
- output: `Failed to write to stdin: ${err instanceof Error ? err.message : "Unknown error"}`,
- isError: true,
- };
- }
- }
-
- private actionClear(): ToolResult {
- const count = listFinishedSessions().length;
- clearFinished();
- return {
- toolCallId: "",
- output:
- count > 0 ? `Cleared ${count} finished session(s).` : "No finished sessions to clear.",
- isError: false,
- };
- }
-
- private actionRemove(sessionId?: string): ToolResult {
- if (!sessionId) {
- return { toolCallId: "", output: "Error: session_id is required for remove.", isError: true };
- }
- const running = getSession(sessionId);
- if (running?.child) {
- try {
- killProcessTree(running.child, 3000);
- } catch {
- // Best-effort kill before removal
- }
- }
- deleteSession(sessionId);
- return { toolCallId: "", output: `Session ${sessionId} removed.`, isError: false };
- }
-}
-
-function truncCmd(cmd: string, max = 60): string {
- if (cmd.length <= max) {
- return cmd;
- }
- return cmd.slice(0, max - 3) + "...";
-}
diff --git a/src/tools/registry.ts b/src/tools/registry.ts
deleted file mode 100644
index e659a03..0000000
--- a/src/tools/registry.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import { Tool, ToolCall, ToolDefinition, ToolResult, ParameterProperty } from "./types";
-
-export class ToolRegistry {
- private tools: Map = new Map();
-
- register(tool: Tool): void {
- this.tools.set(tool.name, tool);
- }
-
- getDefinitions(): ToolDefinition[] {
- return Array.from(this.tools.values()).map((t) => t.getDefinition());
- }
-
- getDefinitionsForProvider(provider: string): (Record | ToolDefinition)[] {
- const defs = this.getDefinitions();
-
- switch (provider) {
- case "anthropic":
- return defs.map((d) => ({
- name: d.name,
- description: d.description,
- input_schema: toJsonSchema(d.parameters),
- }));
-
- case "openai":
- return defs.map((d) => ({
- type: "function",
- function: {
- name: d.name,
- description: d.description,
- parameters: toJsonSchema(d.parameters),
- },
- }));
-
- case "gemini":
- return [
- {
- functionDeclarations: defs.map((d) => ({
- name: d.name,
- description: d.description,
- parameters: toGeminiSchema(d.parameters),
- })),
- },
- ];
-
- default:
- return defs;
- }
- }
-
- async execute(
- name: string,
- args: Record,
- signal?: AbortSignal,
- ): Promise {
- const tool = this.tools.get(name);
- if (!tool) {
- return { toolCallId: "", output: `Unknown tool: ${name}`, isError: true };
- }
-
- if (signal?.aborted) {
- return { toolCallId: "", output: "Tool execution aborted", isError: true };
- }
-
- return tool.execute(args, signal);
- }
-
- async executeAll(calls: ToolCall[], signal?: AbortSignal): Promise {
- const promises = calls.map(async (call) => {
- if (signal?.aborted) {
- return {
- toolCallId: call.id,
- output: "Tool execution aborted",
- isError: true,
- } as ToolResult;
- }
-
- const result = await this.execute(call.name, call.arguments, signal);
- result.toolCallId = call.id;
- return result;
- });
-
- return Promise.all(promises);
- }
-}
-
-function toJsonSchema(params: ToolDefinition["parameters"]): Record {
- return {
- type: "object",
- properties: Object.fromEntries(
- Object.entries(params.properties).map(([key, prop]) => [key, propertyToJsonSchema(prop)]),
- ),
- required: params.required,
- };
-}
-
-function propertyToJsonSchema(prop: ParameterProperty): Record {
- const schema: Record = {
- type: prop.type,
- description: prop.description,
- };
- if (prop.enum) {
- schema.enum = prop.enum;
- }
- if (prop.items) {
- schema.items = { type: prop.items.type };
- }
- if (prop.properties) {
- schema.properties = Object.fromEntries(
- Object.entries(prop.properties).map(([k, v]) => [k, propertyToJsonSchema(v)]),
- );
- if (prop.required) {
- schema.required = prop.required;
- }
- }
- if (prop.default !== undefined) {
- schema.default = prop.default;
- }
- return schema;
-}
-
-function toGeminiSchema(params: ToolDefinition["parameters"]): Record {
- return {
- type: "OBJECT",
- properties: Object.fromEntries(
- Object.entries(params.properties).map(([key, prop]) => [key, propertyToGeminiSchema(prop)]),
- ),
- required: params.required,
- };
-}
-
-function propertyToGeminiSchema(prop: ParameterProperty): Record {
- const schema: Record = {
- type: prop.type.toUpperCase(),
- description: prop.description,
- };
- if (prop.enum) {
- schema.enum = prop.enum;
- }
- if (prop.items) {
- schema.items = { type: prop.items.type.toUpperCase() };
- }
- if (prop.properties) {
- schema.properties = Object.fromEntries(
- Object.entries(prop.properties).map(([k, v]) => [k, propertyToGeminiSchema(v)]),
- );
- if (prop.required) {
- schema.required = prop.required;
- }
- }
- return schema;
-}
diff --git a/src/tools/search.ts b/src/tools/search.ts
deleted file mode 100644
index e6ea8ce..0000000
--- a/src/tools/search.ts
+++ /dev/null
@@ -1,323 +0,0 @@
-import fs from "fs";
-import path from "path";
-import { Tool, ToolDefinition, ToolResult } from "./types";
-
-const SKIP_DIRS = new Set([
- "node_modules",
- ".git",
- "dist",
- "build",
- ".next",
- ".nuxt",
- "__pycache__",
- ".cache",
- ".venv",
- "venv",
- "vendor",
- "target",
- ".gradle",
- ".idea",
- ".vs",
- "coverage",
- ".nyc_output",
- ".turbo",
- ".parcel-cache",
-]);
-
-const MAX_RESULTS = 200;
-const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
-const MAX_MATCH_CONTEXT = 200;
-
-function walkDir(
- dir: string,
- callback: (filePath: string, stat: fs.Stats) => boolean,
- depth: number = 0,
- maxDepth: number = 20,
-): void {
- if (depth > maxDepth) {
- return;
- }
-
- let entries: fs.Dirent[];
- try {
- entries = fs.readdirSync(dir, { withFileTypes: true });
- } catch {
- return;
- }
-
- for (const entry of entries) {
- if (SKIP_DIRS.has(entry.name)) {
- continue;
- }
- if (entry.name.startsWith(".") && entry.name !== ".env" && depth > 0) {
- continue;
- }
-
- const fullPath = path.join(dir, entry.name);
-
- if (entry.isDirectory()) {
- walkDir(fullPath, callback, depth + 1, maxDepth);
- } else if (entry.isFile()) {
- try {
- const stat = fs.statSync(fullPath);
- const shouldStop = callback(fullPath, stat);
- if (shouldStop) {
- return;
- }
- } catch {}
- }
- }
-}
-
-function matchGlob(filename: string, pattern: string): boolean {
- const regex = pattern
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
- .replace(/\*/g, ".*")
- .replace(/\?/g, ".");
- return new RegExp(`^${regex}$`, "i").test(filename);
-}
-
-export class SearchTool implements Tool {
- name = "search";
- description =
- "Search files and file contents in the project. Actions: grep (search content by regex/text), " +
- "glob (find files by name pattern), find (find files by extension/size/date). " +
- "Auto-skips node_modules, .git, dist, build. Results capped at 200.";
-
- private defaultCwd: string;
-
- constructor(opts?: { cwd?: string }) {
- this.defaultCwd = opts?.cwd || process.env.PROJECT_PATH || process.cwd();
- }
-
- getDefinition(): ToolDefinition {
- return {
- name: this.name,
- description: this.description,
- parameters: {
- type: "object",
- properties: {
- action: {
- type: "string",
- description: "Search action to perform.",
- enum: ["grep", "glob", "find"],
- },
- pattern: {
- type: "string",
- description:
- "Search pattern. For grep: regex or text. For glob: file pattern (e.g. *.ts, *.py). For find: file extension (e.g. ts, py).",
- },
- path: {
- type: "string",
- description: "Directory to search in. Defaults to project root.",
- },
- case_sensitive: {
- type: "boolean",
- description: "Case-sensitive search (default false for grep, true for glob/find).",
- },
- max_results: {
- type: "number",
- description: `Maximum results to return (default ${MAX_RESULTS}).`,
- },
- include: {
- type: "string",
- description:
- "Only search files matching this glob pattern (e.g. *.ts, *.py). For grep action.",
- },
- },
- required: ["action", "pattern"],
- },
- };
- }
-
- async execute(args: Record, signal?: AbortSignal): Promise {
- if (signal?.aborted) {
- return { toolCallId: "", output: "Search aborted", isError: true };
- }
-
- const action = args.action as string;
- const pattern = args.pattern as string;
- const searchPath = (args.path as string)?.trim() || this.defaultCwd;
- const caseSensitive = args.case_sensitive === true;
- const maxResults =
- typeof args.max_results === "number" ? Math.min(args.max_results, MAX_RESULTS) : MAX_RESULTS;
- const include = (args.include as string)?.trim() || "";
-
- if (!pattern) {
- return { toolCallId: "", output: "Error: pattern is required.", isError: true };
- }
-
- if (!fs.existsSync(searchPath)) {
- return { toolCallId: "", output: `Error: path does not exist: ${searchPath}`, isError: true };
- }
-
- switch (action) {
- case "grep":
- return this.actionGrep(searchPath, pattern, caseSensitive, maxResults, include);
- case "glob":
- return this.actionGlob(searchPath, pattern, maxResults);
- case "find":
- return this.actionFind(searchPath, pattern, maxResults);
- default:
- return {
- toolCallId: "",
- output: `Unknown action: ${action}. Use: grep, glob, find.`,
- isError: true,
- };
- }
- }
-
- private actionGrep(
- searchPath: string,
- pattern: string,
- caseSensitive: boolean,
- maxResults: number,
- include: string,
- ): ToolResult {
- let regex: RegExp;
- const hasNestedQuantifiers =
- /(\+|\*|\{)\??[^)]*(\+|\*|\{)/.test(pattern) || /\(([^)]*\|){10,}/.test(pattern);
- if (hasNestedQuantifiers) {
- regex = new RegExp(
- pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
- caseSensitive ? "g" : "gi",
- );
- } else {
- try {
- regex = new RegExp(pattern, caseSensitive ? "g" : "gi");
- } catch {
- regex = new RegExp(
- pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
- caseSensitive ? "g" : "gi",
- );
- }
- }
-
- const matches: string[] = [];
- let fileCount = 0;
-
- walkDir(searchPath, (filePath, stat) => {
- if (stat.size > MAX_FILE_SIZE) {
- return false;
- }
- if (include && !matchGlob(path.basename(filePath), include)) {
- return false;
- }
-
- let content: string;
- try {
- content = fs.readFileSync(filePath, "utf-8");
- } catch {
- return false;
- }
-
- // Skip binary files
- if (content.includes("\0")) {
- return false;
- }
-
- const lines = content.split("\n");
- const relativePath = path.relative(searchPath, filePath);
- let fileHasMatch = false;
-
- for (let i = 0; i < lines.length; i++) {
- regex.lastIndex = 0;
- if (regex.test(lines[i])) {
- if (!fileHasMatch) {
- fileHasMatch = true;
- fileCount++;
- }
- const lineContent =
- lines[i].length > MAX_MATCH_CONTEXT
- ? lines[i].substring(0, MAX_MATCH_CONTEXT) + "..."
- : lines[i];
- matches.push(`${relativePath}:${i + 1}: ${lineContent.trim()}`);
-
- if (matches.length >= maxResults) {
- return true;
- }
- }
- }
- return false;
- });
-
- if (matches.length === 0) {
- return { toolCallId: "", output: `No matches found for "${pattern}".`, isError: false };
- }
-
- const header = `Found ${matches.length} match(es) in ${fileCount} file(s):\n\n`;
- return {
- toolCallId: "",
- output: header + matches.join("\n"),
- isError: false,
- metadata: { matchCount: matches.length, fileCount },
- };
- }
-
- private actionGlob(searchPath: string, pattern: string, maxResults: number): ToolResult {
- const results: string[] = [];
-
- walkDir(searchPath, (filePath) => {
- const filename = path.basename(filePath);
- if (matchGlob(filename, pattern)) {
- results.push(path.relative(searchPath, filePath));
- if (results.length >= maxResults) {
- return true;
- }
- }
- return false;
- });
-
- if (results.length === 0) {
- return { toolCallId: "", output: `No files matching "${pattern}".`, isError: false };
- }
-
- return {
- toolCallId: "",
- output: `Found ${results.length} file(s):\n\n${results.join("\n")}`,
- isError: false,
- metadata: { count: results.length },
- };
- }
-
- private actionFind(searchPath: string, pattern: string, maxResults: number): ToolResult {
- const ext = pattern.startsWith(".") ? pattern : `.${pattern}`;
- const results: { path: string; size: string; modified: string }[] = [];
-
- walkDir(searchPath, (filePath, stat) => {
- if (filePath.endsWith(ext)) {
- results.push({
- path: path.relative(searchPath, filePath),
- size: formatSize(stat.size),
- modified: stat.mtime.toISOString().split("T")[0],
- });
- if (results.length >= maxResults) {
- return true;
- }
- }
- return false;
- });
-
- if (results.length === 0) {
- return { toolCallId: "", output: `No files with extension "${ext}".`, isError: false };
- }
-
- const lines = results.map((r) => `${r.path} (${r.size}, ${r.modified})`);
- return {
- toolCallId: "",
- output: `Found ${results.length} ${ext} file(s):\n\n${lines.join("\n")}`,
- isError: false,
- metadata: { count: results.length },
- };
- }
-}
-
-function formatSize(bytes: number): string {
- if (bytes < 1024) {
- return `${bytes}B`;
- }
- if (bytes < 1024 * 1024) {
- return `${(bytes / 1024).toFixed(1)}KB`;
- }
- return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
-}
diff --git a/src/tools/sysinfo.ts b/src/tools/sysinfo.ts
deleted file mode 100644
index 91da3ed..0000000
--- a/src/tools/sysinfo.ts
+++ /dev/null
@@ -1,277 +0,0 @@
-import { execFile } from "child_process";
-import os from "os";
-import { Tool, ToolDefinition, ToolResult } from "./types";
-
-const CMD_TIMEOUT = 10_000;
-
-function runCommand(cmd: string, args: string[], timeout: number = CMD_TIMEOUT): Promise {
- return new Promise((resolve) => {
- execFile(cmd, args, { timeout, maxBuffer: 512 * 1024 }, (err, stdout) => {
- resolve(stdout?.toString().trim() ?? "");
- });
- });
-}
-
-function formatBytes(bytes: number): string {
- const gb = bytes / (1024 * 1024 * 1024);
- if (gb >= 1) {
- return `${gb.toFixed(1)} GB`;
- }
- const mb = bytes / (1024 * 1024);
- return `${mb.toFixed(0)} MB`;
-}
-
-function formatUptime(seconds: number): string {
- const days = Math.floor(seconds / 86400);
- const hours = Math.floor((seconds % 86400) / 3600);
- const minutes = Math.floor((seconds % 3600) / 60);
-
- const parts: string[] = [];
- if (days > 0) {
- parts.push(`${days}d`);
- }
- if (hours > 0) {
- parts.push(`${hours}h`);
- }
- parts.push(`${minutes}m`);
- return parts.join(" ");
-}
-
-export class SysinfoTool implements Tool {
- name = "sysinfo";
- description =
- "System information and resource monitoring. Actions: overview (full summary), cpu, memory, " +
- "disk, uptime, processes (top resource consumers). Works on Windows, macOS, and Linux.";
-
- getDefinition(): ToolDefinition {
- return {
- name: this.name,
- description: this.description,
- parameters: {
- type: "object",
- properties: {
- action: {
- type: "string",
- description: "Info to retrieve.",
- enum: ["overview", "cpu", "memory", "disk", "uptime", "processes"],
- },
- count: {
- type: "number",
- description: "Number of top processes to show (default 15, for action=processes).",
- },
- },
- required: ["action"],
- },
- };
- }
-
- async execute(args: Record, signal?: AbortSignal): Promise {
- if (signal?.aborted) {
- return { toolCallId: "", output: "Sysinfo operation aborted", isError: true };
- }
-
- const action = args.action as string;
- const count = typeof args.count === "number" ? Math.min(args.count, 50) : 15;
-
- switch (action) {
- case "overview":
- return this.actionOverview();
- case "cpu":
- return this.actionCpu();
- case "memory":
- return this.actionMemory();
- case "disk":
- return this.actionDisk();
- case "uptime":
- return this.actionUptime();
- case "processes":
- return this.actionProcesses(count);
- default:
- return {
- toolCallId: "",
- output: `Unknown action: ${action}. Use: overview, cpu, memory, disk, uptime, processes.`,
- isError: true,
- };
- }
- }
-
- private async actionOverview(): Promise {
- const cpus = os.cpus();
- const totalMem = os.totalmem();
- const freeMem = os.freemem();
- const usedMem = totalMem - freeMem;
- const memPercent = ((usedMem / totalMem) * 100).toFixed(1);
-
- const platformNames: Record = {
- win32: "Windows",
- darwin: "macOS",
- linux: "Linux",
- freebsd: "FreeBSD",
- };
-
- const lines = [
- `System Overview`,
- ``,
- `Hostname: ${os.hostname()}`,
- `Platform: ${platformNames[os.platform()] || os.platform()} (${os.arch()})`,
- `OS: ${os.release()}`,
- `Uptime: ${formatUptime(os.uptime())}`,
- ``,
- `CPU: ${cpus[0]?.model || "Unknown"} (${cpus.length} cores)`,
- `Memory: ${formatBytes(usedMem)} / ${formatBytes(totalMem)} (${memPercent}% used)`,
- `Free mem: ${formatBytes(freeMem)}`,
- ``,
- `Home dir: ${os.homedir()}`,
- `Temp dir: ${os.tmpdir()}`,
- `Node.js: ${process.version}`,
- ];
-
- // Add load average on Unix
- if (os.platform() !== "win32") {
- const load = os.loadavg();
- lines.push(`Load avg: ${load.map((l) => l.toFixed(2)).join(", ")} (1m, 5m, 15m)`);
- }
-
- return { toolCallId: "", output: lines.join("\n"), isError: false };
- }
-
- private actionCpu(): ToolResult {
- const cpus = os.cpus();
- const lines = [
- `CPU: ${cpus[0]?.model || "Unknown"}`,
- `Cores: ${cpus.length}`,
- `Architecture: ${os.arch()}`,
- ``,
- ];
-
- for (let i = 0; i < cpus.length; i++) {
- const cpu = cpus[i];
- const total = Object.values(cpu.times).reduce((a, b) => a + b, 0);
- const idle = cpu.times.idle;
- const usage = (((total - idle) / total) * 100).toFixed(1);
- lines.push(` Core ${i}: ${cpu.speed}MHz, ${usage}% used`);
- }
-
- if (os.platform() !== "win32") {
- const load = os.loadavg();
- lines.push(``);
- lines.push(`Load average: ${load.map((l) => l.toFixed(2)).join(", ")} (1m, 5m, 15m)`);
- }
-
- return { toolCallId: "", output: lines.join("\n"), isError: false };
- }
-
- private actionMemory(): ToolResult {
- const totalMem = os.totalmem();
- const freeMem = os.freemem();
- const usedMem = totalMem - freeMem;
- const memPercent = ((usedMem / totalMem) * 100).toFixed(1);
-
- const lines = [
- `Memory`,
- ``,
- `Total: ${formatBytes(totalMem)}`,
- `Used: ${formatBytes(usedMem)} (${memPercent}%)`,
- `Free: ${formatBytes(freeMem)}`,
- ];
-
- // Process memory
- const procMem = process.memoryUsage();
- lines.push(``);
- lines.push(`Node.js process memory:`);
- lines.push(` RSS: ${formatBytes(procMem.rss)}`);
- lines.push(` Heap used: ${formatBytes(procMem.heapUsed)}`);
- lines.push(` Heap total: ${formatBytes(procMem.heapTotal)}`);
-
- return { toolCallId: "", output: lines.join("\n"), isError: false };
- }
-
- private async actionDisk(): Promise {
- const isWindows = process.platform === "win32";
-
- if (isWindows) {
- const output = await runCommand("powershell.exe", [
- "-NoProfile",
- "-NonInteractive",
- "-Command",
- `Get-PSDrive -PSProvider FileSystem | Select-Object Name, ` +
- `@{N='Used(GB)';E={[math]::Round($_.Used/1GB,1)}}, ` +
- `@{N='Free(GB)';E={[math]::Round($_.Free/1GB,1)}}, ` +
- `@{N='Total(GB)';E={[math]::Round(($_.Used+$_.Free)/1GB,1)}}, ` +
- `Root | Format-Table -AutoSize`,
- ]);
- return {
- toolCallId: "",
- output: output || "Could not retrieve disk info.",
- isError: !output,
- };
- }
-
- const isMac = process.platform === "darwin";
- const dfArgs = isMac
- ? ["-h"]
- : ["-h", "--type=ext4", "--type=xfs", "--type=btrfs", "--type=apfs", "--type=hfs"];
-
- let output = await runCommand("df", dfArgs);
- if (!output && !isMac) {
- output = await runCommand("df", ["-h"]);
- }
-
- return {
- toolCallId: "",
- output: output || "Could not retrieve disk info.",
- isError: !output,
- };
- }
-
- private actionUptime(): ToolResult {
- const uptime = os.uptime();
- const bootTime = new Date(Date.now() - uptime * 1000);
-
- const lines = [`Uptime: ${formatUptime(uptime)}`, `Boot time: ${bootTime.toISOString()}`];
-
- return { toolCallId: "", output: lines.join("\n"), isError: false };
- }
-
- private async actionProcesses(count: number): Promise {
- const isWindows = process.platform === "win32";
-
- if (isWindows) {
- const output = await runCommand("powershell.exe", [
- "-NoProfile",
- "-NonInteractive",
- "-Command",
- `Get-Process | Sort-Object CPU -Descending | Select-Object -First ${count} ` +
- `Id, ProcessName, ` +
- `@{N='CPU(s)';E={[math]::Round($_.CPU,1)}}, ` +
- `@{N='Mem(MB)';E={[math]::Round($_.WorkingSet64/1MB,1)}} ` +
- `| Format-Table -AutoSize`,
- ]);
- return {
- toolCallId: "",
- output: output
- ? `Top ${count} processes by CPU:\n\n${output}`
- : "Could not retrieve process list.",
- isError: !output,
- };
- }
-
- const isMac = process.platform === "darwin";
- const psArgs = isMac ? ["aux", "-r"] : ["aux", "--sort=-%cpu"];
- const output = await runCommand("ps", psArgs);
-
- if (!output) {
- return { toolCallId: "", output: "Could not retrieve process list.", isError: true };
- }
-
- const lines = output.split("\n");
- const header = lines[0] || "";
- const processes = lines.slice(1, count + 1);
-
- return {
- toolCallId: "",
- output: `Top ${count} processes by CPU:\n\n${header}\n${processes.join("\n")}`,
- isError: false,
- };
- }
-}
diff --git a/src/tools/terminal.ts b/src/tools/terminal.ts
deleted file mode 100644
index 7599f01..0000000
--- a/src/tools/terminal.ts
+++ /dev/null
@@ -1,460 +0,0 @@
-import { spawn, ChildProcessWithoutNullStreams } from "child_process";
-import { killProcessTree, forceKillProcess } from "../shared/process-kill";
-import {
- createSession,
- appendOutput,
- markExited,
- markBackgrounded,
- ProcessSession,
-} from "./process-registry";
-import { Tool, ToolDefinition, ToolResult } from "./types";
-
-const DANGEROUS_ENV_VARS = new Set([
- "LD_PRELOAD",
- "LD_LIBRARY_PATH",
- "LD_AUDIT",
- "DYLD_INSERT_LIBRARIES",
- "DYLD_LIBRARY_PATH",
- "NODE_OPTIONS",
- "NODE_PATH",
- "PYTHONPATH",
- "PYTHONHOME",
- "RUBYLIB",
- "PERL5LIB",
- "BASH_ENV",
- "ENV",
- "GCONV_PATH",
- "IFS",
- "SSLKEYLOGFILE",
-]);
-const DANGEROUS_ENV_PREFIXES = ["DYLD_", "LD_"];
-
-const DEFAULT_TIMEOUT_SEC = 120;
-const DEFAULT_YIELD_MS = 10_000;
-const MAX_YIELD_MS = 120_000;
-const MAX_OUTPUT_CHARS = 200_000;
-const PENDING_MAX_OUTPUT_CHARS = 30_000;
-
-function resolveShell(): { shell: string; buildArgs: (cmd: string) => string[] } {
- const isWindows = process.platform === "win32";
-
- if (isWindows) {
- // Prefer PowerShell (pwsh/powershell), fall back to cmd.exe
- for (const candidate of ["pwsh.exe", "powershell.exe"]) {
- try {
- require("child_process").execSync(`${candidate} -NoProfile -Command "echo ok"`, {
- stdio: "ignore",
- timeout: 3000,
- });
- return {
- shell: candidate,
- buildArgs: (cmd: string) => [
- "-NoProfile",
- "-NonInteractive",
- "-ExecutionPolicy",
- "Bypass",
- "-Command",
- cmd,
- ],
- };
- } catch {}
- }
-
- return {
- shell: process.env.COMSPEC || "cmd.exe",
- buildArgs: (cmd: string) => ["/c", cmd],
- };
- }
-
- // Unix: prefer bash, fall back to sh
- const fs = require("fs");
- for (const candidate of ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"]) {
- try {
- if (fs.existsSync(candidate)) {
- return { shell: candidate, buildArgs: (cmd: string) => ["-c", cmd] };
- }
- } catch {}
- }
-
- return { shell: "/bin/sh", buildArgs: (cmd: string) => ["-c", cmd] };
-}
-
-let cachedShell: ReturnType | null = null;
-
-function getShell(): ReturnType {
- if (!cachedShell) {
- cachedShell = resolveShell();
- }
- return cachedShell;
-}
-
-function validateEnv(env: Record): void {
- for (const key of Object.keys(env)) {
- const upper = key.toUpperCase();
- if (DANGEROUS_ENV_VARS.has(upper)) {
- throw new Error(`Security: environment variable '${key}' is blocked.`);
- }
- if (DANGEROUS_ENV_PREFIXES.some((p) => upper.startsWith(p))) {
- throw new Error(`Security: environment variable '${key}' is blocked.`);
- }
- if (upper === "PATH") {
- throw new Error(`Security: custom 'PATH' is blocked. Use workdir to change context.`);
- }
- }
-}
-
-export interface ExecOutcome {
- status: "completed" | "failed" | "backgrounded";
- exitCode: number | null;
- durationMs: number;
- aggregated: string;
- timedOut: boolean;
- sessionId?: string;
-}
-
-export class TerminalTool implements Tool {
- name = "exec";
- description =
- "Execute a shell command with full filesystem access. " +
- "You can run commands in ANY directory on the machine by setting workdir to an absolute path. " +
- "Supports env vars, timeout, and background execution via yieldMs. " +
- "Long-running commands auto-background; use the process tool to poll/kill them. " +
- "On Windows uses PowerShell, on macOS/Linux uses bash (falls back to sh).";
-
- private defaultCwd: string;
- private defaultTimeoutSec: number;
-
- constructor(opts?: { cwd?: string; timeoutSec?: number }) {
- this.defaultCwd = opts?.cwd || process.env.PROJECT_PATH || process.cwd();
- this.defaultTimeoutSec = opts?.timeoutSec ?? DEFAULT_TIMEOUT_SEC;
- }
-
- getDefinition(): ToolDefinition {
- return {
- name: this.name,
- description: this.description,
- parameters: {
- type: "object",
- properties: {
- command: {
- type: "string",
- description: "Shell command to execute.",
- },
- workdir: {
- type: "string",
- description:
- "Working directory — any absolute path on the machine (e.g. /home/user, C:\\Users\\user, /etc). " +
- "Defaults to the project root if not specified.",
- },
- env: {
- type: "object",
- description:
- "Additional environment variables as key-value pairs. PATH and dangerous vars are blocked.",
- },
- timeout: {
- type: "number",
- description: `Timeout in seconds (default ${DEFAULT_TIMEOUT_SEC}). Process is killed on expiry.`,
- },
- yieldMs: {
- type: "number",
- description:
- "Milliseconds to wait before backgrounding a still-running command (default 10000). " +
- "Set to 0 to background immediately. Use the process tool to check on backgrounded commands.",
- },
- background: {
- type: "boolean",
- description: "If true, run in background immediately (equivalent to yieldMs=0).",
- },
- },
- required: ["command"],
- },
- };
- }
-
- async execute(args: Record, signal?: AbortSignal): Promise {
- const command = args.command as string;
- if (!command) {
- return { toolCallId: "", output: "Error: no command provided.", isError: true };
- }
-
- if (signal?.aborted) {
- return { toolCallId: "", output: "Command execution aborted", isError: true };
- }
-
- const workdir = (args.workdir as string)?.trim() || this.defaultCwd;
-
- try {
- const fs = require("fs");
- if (!fs.existsSync(workdir)) {
- return {
- toolCallId: "",
- output: `Error: working directory does not exist: ${workdir}`,
- isError: true,
- };
- }
- const stat = fs.statSync(workdir);
- if (!stat.isDirectory()) {
- return {
- toolCallId: "",
- output: `Error: workdir path is not a directory: ${workdir}`,
- isError: true,
- };
- }
- } catch (err) {
- return {
- toolCallId: "",
- output: `Error: cannot access working directory: ${workdir} (${err instanceof Error ? err.message : "permission denied"})`,
- isError: true,
- };
- }
-
- const userEnv = args.env as Record | undefined;
- const timeoutSec =
- typeof args.timeout === "number" && args.timeout > 0 ? args.timeout : this.defaultTimeoutSec;
- const backgroundImmediate = args.background === true;
- const yieldMs = backgroundImmediate
- ? 0
- : typeof args.yieldMs === "number"
- ? Math.max(0, Math.min(args.yieldMs, MAX_YIELD_MS))
- : DEFAULT_YIELD_MS;
-
- if (userEnv) {
- try {
- validateEnv(userEnv);
- } catch (err) {
- return {
- toolCallId: "",
- output: err instanceof Error ? err.message : "Invalid env.",
- isError: true,
- };
- }
- }
-
- const env = userEnv ? { ...process.env, ...userEnv } : { ...process.env };
-
- const session = createSession({
- command,
- cwd: workdir,
- maxOutputChars: MAX_OUTPUT_CHARS,
- pendingMaxOutputChars: PENDING_MAX_OUTPUT_CHARS,
- });
-
- try {
- const outcome = await this.runProcess(
- session,
- command,
- workdir,
- env,
- timeoutSec,
- yieldMs,
- signal,
- );
- return this.formatResult(outcome, session);
- } catch (error) {
- return {
- toolCallId: "",
- output: `Exec failed: ${error instanceof Error ? error.message : "Unknown error"}`,
- isError: true,
- };
- }
- }
-
- private runProcess(
- session: ProcessSession,
- command: string,
- workdir: string,
- env: Record,
- timeoutSec: number,
- yieldMs: number,
- signal?: AbortSignal,
- ): Promise {
- return new Promise((resolve) => {
- const { shell, buildArgs: shellBuildArgs } = getShell();
- const shellArgs = shellBuildArgs(command);
- const startedAt = Date.now();
-
- let proc: ChildProcessWithoutNullStreams;
- try {
- proc = spawn(shell, shellArgs, { cwd: workdir, env: env as NodeJS.ProcessEnv });
- } catch (err) {
- markExited(session, null, null, "failed");
- resolve({
- status: "failed",
- exitCode: null,
- durationMs: Date.now() - startedAt,
- aggregated: `Spawn error: ${err instanceof Error ? err.message : String(err)}`,
- timedOut: false,
- });
- return;
- }
-
- session.child = proc;
- session.pid = proc.pid;
- let yielded = false;
- let processExited = false;
-
- const cleanupAbortListener = () => {
- if (signal && abortHandler) {
- signal.removeEventListener("abort", abortHandler);
- }
- };
-
- const abortHandler = () => {
- if (!processExited) {
- cleanupAbortListener();
- killProcessTree(proc);
- if (!yielded) {
- markExited(session, null, null, "killed");
- resolve({
- status: "failed",
- exitCode: null,
- durationMs: Date.now() - startedAt,
- aggregated: session.aggregated.trim() + "\n\n(Command aborted)",
- timedOut: false,
- });
- } else {
- markExited(session, null, null, "killed");
- }
- }
- };
-
- signal?.addEventListener("abort", abortHandler, { once: true });
-
- proc.stdout.on("data", (data: Buffer) => {
- appendOutput(session, "stdout", data.toString());
- });
-
- proc.stderr.on("data", (data: Buffer) => {
- appendOutput(session, "stderr", data.toString());
- });
-
- const timeoutMs = timeoutSec * 1000;
- const killTimer = setTimeout(() => {
- if (!processExited) {
- cleanupAbortListener();
- forceKillProcess(proc);
- if (!yielded) {
- markExited(session, null, null, "killed");
- resolve({
- status: "failed",
- exitCode: null,
- durationMs: Date.now() - startedAt,
- aggregated:
- session.aggregated.trim() + `\n\n(Command timed out after ${timeoutSec}s)`,
- timedOut: true,
- });
- } else {
- markExited(session, null, null, "killed");
- }
- }
- }, timeoutMs);
-
- let yieldTimer: ReturnType | null = null;
- if (yieldMs >= 0) {
- if (yieldMs === 0) {
- yielded = true;
- markBackgrounded(session);
- resolve({
- status: "backgrounded",
- exitCode: null,
- durationMs: 0,
- aggregated: "",
- timedOut: false,
- sessionId: session.id,
- });
- } else {
- yieldTimer = setTimeout(() => {
- if (!processExited && !yielded) {
- yielded = true;
- markBackgrounded(session);
- resolve({
- status: "backgrounded",
- exitCode: null,
- durationMs: Date.now() - startedAt,
- aggregated: session.tail,
- timedOut: false,
- sessionId: session.id,
- });
- }
- }, yieldMs);
- }
- }
-
- proc.on("close", (code, sig) => {
- processExited = true;
- cleanupAbortListener();
- clearTimeout(killTimer);
- if (yieldTimer) {
- clearTimeout(yieldTimer);
- }
-
- const status: "completed" | "failed" = code === 0 ? "completed" : "failed";
- markExited(session, code, sig?.toString() ?? null, status);
-
- if (!yielded) {
- const aggregated = session.aggregated.trim();
- const exitMsg = code !== null && code !== 0 ? `\n\n(exit code ${code})` : "";
- resolve({
- status,
- exitCode: code,
- durationMs: Date.now() - startedAt,
- aggregated: aggregated + exitMsg,
- timedOut: false,
- });
- }
- });
-
- proc.on("error", (err) => {
- processExited = true;
- cleanupAbortListener();
- clearTimeout(killTimer);
- if (yieldTimer) {
- clearTimeout(yieldTimer);
- }
-
- markExited(session, null, null, "failed");
-
- if (!yielded) {
- resolve({
- status: "failed",
- exitCode: null,
- durationMs: Date.now() - startedAt,
- aggregated: `Process error: ${err.message}`,
- timedOut: false,
- });
- }
- });
- });
- }
-
- private formatResult(outcome: ExecOutcome, session: ProcessSession): ToolResult {
- if (outcome.status === "backgrounded") {
- return {
- toolCallId: "",
- output:
- `Command backgrounded (session ${session.id}, pid ${session.pid ?? "n/a"}). ` +
- `Use the process tool with action "poll" and session_id "${session.id}" to check status.` +
- (outcome.aggregated ? `\n\nInitial output:\n${outcome.aggregated}` : ""),
- isError: false,
- metadata: {
- sessionId: session.id,
- pid: session.pid,
- status: "running",
- },
- };
- }
-
- const output = outcome.aggregated || "(no output)";
- return {
- toolCallId: "",
- output,
- isError: outcome.status === "failed",
- metadata: {
- exitCode: outcome.exitCode,
- durationMs: outcome.durationMs,
- timedOut: outcome.timedOut,
- truncated: session.truncated,
- },
- };
- }
-}
diff --git a/src/tools/types.ts b/src/tools/types.ts
deleted file mode 100644
index 1f26d11..0000000
--- a/src/tools/types.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-export type ParameterType = "string" | "number" | "boolean" | "object" | "array";
-
-export interface ParameterProperty {
- type: ParameterType;
- description: string;
- enum?: string[];
- items?: { type: ParameterType };
- properties?: Record;
- required?: string[];
- default?: unknown;
-}
-
-export interface ToolDefinition {
- name: string;
- description: string;
- parameters: {
- type: "object";
- properties: Record;
- required: string[];
- };
-}
-
-export interface ToolCall {
- id: string;
- name: string;
- arguments: Record;
-}
-
-export interface ToolResult {
- toolCallId: string;
- output: string;
- isError: boolean;
- metadata?: Record;
-}
-
-export interface Tool {
- name: string;
- description: string;
- getDefinition(): ToolDefinition;
- execute(args: Record, signal?: AbortSignal): Promise;
-}
diff --git a/test/unit/agent-commands.test.ts b/test/unit/agent-commands.test.ts
index c502333..786abbd 100644
--- a/test/unit/agent-commands.test.ts
+++ b/test/unit/agent-commands.test.ts
@@ -67,26 +67,6 @@ vi.mock("../../src/core/context-store", () => ({
loadLatestSession: vi.fn().mockReturnValue(null),
}));
-vi.mock("../../src/tools/terminal", () => ({
- TerminalTool: class {
- name = "terminal";
- execute() {}
- },
-}));
-
-vi.mock("../../src/tools/process", () => ({
- ProcessTool: class {
- name = "process";
- execute() {}
- },
-}));
-
-vi.mock("../../src/tools/registry", () => ({
- ToolRegistry: class {
- register() {}
- },
-}));
-
import { AgentCore } from "../../src/core/agent";
function msg(text: string, from = "user1"): Message {
diff --git a/test/unit/router.test.ts b/test/unit/router.test.ts
index aa6e92e..923fd4f 100644
--- a/test/unit/router.test.ts
+++ b/test/unit/router.test.ts
@@ -33,26 +33,6 @@ vi.mock("../../src/core/context-store", () => ({
loadLatestSession: vi.fn().mockReturnValue(null),
}));
-vi.mock("../../src/tools/terminal", () => ({
- TerminalTool: class {
- name = "terminal";
- execute() {}
- },
-}));
-
-vi.mock("../../src/tools/process", () => ({
- ProcessTool: class {
- name = "process";
- execute() {}
- },
-}));
-
-vi.mock("../../src/tools/registry", () => ({
- ToolRegistry: class {
- register() {}
- },
-}));
-
vi.mock("fs", () => ({
default: {
existsSync: vi.fn().mockReturnValue(false),
|