Files
homeclaw/src/gateway/chat/execute-tool.ts
T
kimandClaude Opus 5 abac4f7940 v4.3.26: coder_patch_file의 SEARCH/REPLACE 적용부 분리 + 테스트 15개
execute-tool.ts에서 파일 편집 알고리즘을 순수 모듈로 추출. 1,529 → 1,498줄.

이 함수는 툴 레이어에서 가장 위험한 순수 로직임 — 사용자의 실제 소스 파일을
다시 쓰는데, 실패해도 예외를 던지지 않고 잘못된 바이트를 쓰거나 "3건 적용"이라
보고하고 2건만 적용한다. executeTool()의 1,349줄 본문 안에서 fs.writeFileSync
바로 옆에 인라인으로 있어서 디스크를 건드리지 않고는 검증할 방법이 없었음.

테스트가 고정한 성질(피해 큰 순):
1. 적용하지 않은 편집을 성공으로 세지 않는다
2. 엉뚱한 구간을 치환하지 않는다
3. 매칭 구간 밖의 바이트(들여쓰기·후행공백)를 보존한다
4. 실패를 보고해 모델이 재시도할 수 있게 한다
+ /g 정규식 lastIndex 오염으로 두 번째 호출이 편집을 건너뛰는 회귀 방지

추출 중 확인 — 계획 정정:
당초 "early return 평탄화"를 하려 했으나 실측해보니 이 파일의 깊은 중첩은
방어적 가드 피라미드가 아니라 알고리즘 자체의 깊이였음(공백 정규화 텍스트 검색,
Promise 래핑+파싱 루프 등). 평탄화해도 나아지지 않고 테스트 없는 I/O 코드를
기계적으로 건드리는 위험만 남으므로, 오늘 효과가 확인된 방식(파묻힌 순수 로직
추출)으로 방향을 바꿈.

알려진 날카로운 모서리를 문서화(수정하지 않음):
매칭이 줄 단위 앵커가 아니라 부분 문자열이라, 파일보다 얕게 들여쓴 SEARCH가
더 깊은 줄 안에서 매칭된다("    deep()"가 "        deep()"에 적중). 대개 무해
하지만 한 줄을 노린 편집이 다른 줄에 갈 수 있는 경로임. 줄 앵커로 바꾸면 모든
호출자의 파일 편집 의미가 달라지므로 조용히 "고치지" 않고 테스트로 고정만 함.

전체 117개 테스트 통과. 실디스크 검증: coder_patch_file로 return 1 → return 42
수정이 들여쓰기·구조 보존한 채 정확히 반영됨.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:30:24 +09:00

1499 lines
72 KiB
TypeScript

import { applySearchReplaceEdits } from './search-replace-edit';
import path from 'path';
import fs from 'fs';
import { getConfig } from '../../config/config';
import { getToolRegistry } from '../../tools/registry.js';
import { getMCPManager } from '../infra/mcp-manager';
import { SubagentManager } from '../tasks/subagent-manager';
import { addMemoryVector } from '../memory/memory-vector';
import { validateLinksInText, filterImageMarkdown } from '../guards/link-validator';
import {
browserOpen,
browserSnapshot,
browserClick,
browserFill,
browserPressKey,
browserWait,
browserScroll,
browserClose,
browserGetImages,
} from '../automation/browser-tools';
import {
desktopScreenshot,
desktopFindWindow,
desktopFocusWindow,
desktopClick,
desktopDrag,
desktopWait,
desktopType,
desktopPressKey,
desktopGetClipboard,
desktopSetClipboard,
} from '../automation/desktop-tools';
export interface ToolResult {
name: string;
args: any;
result: string;
error: boolean;
isImage?: boolean;
data?: any;
}
type ScheduleJobAction =
| 'list'
| 'create'
| 'update'
| 'pause'
| 'resume'
| 'delete'
| 'run_now';
function normalizeScheduleJobAction(raw: any): ScheduleJobAction | null {
const v = String(raw || '').trim().toLowerCase();
if (!v) return null;
if (v === 'run-now') return 'run_now';
if (['list', 'create', 'update', 'pause', 'resume', 'delete', 'run_now'].includes(v)) {
return v as ScheduleJobAction;
}
return null;
}
function summarizeCronJob(job: any): Record<string, any> {
return {
id: String(job?.id || ''),
name: String(job?.name || ''),
type: String(job?.type || 'recurring'),
status: String(job?.status || 'scheduled'),
enabled: job?.enabled !== false,
schedule: job?.schedule || null,
runAt: job?.runAt || null,
tz: job?.tz || null,
nextRun: job?.nextRun || null,
lastRun: job?.lastRun || null,
lastResult: job?.lastResult || null,
sessionTarget: job?.sessionTarget || 'isolated',
model: job?.model || null,
};
}
function normalizeDeliveryChannel(raw: any): 'web' | 'telegram' | 'discord' | 'whatsapp' {
const v = String(raw || 'web').trim().toLowerCase();
if (v === 'telegram' || v === 'discord' || v === 'whatsapp') return v;
return 'web';
}
// Safe commands allowlist for run_command
const isWindows = process.platform === 'win32';
const isMac = process.platform === 'darwin';
const SAFE_COMMANDS: Record<string, string> = isWindows
? {
'chrome': 'start chrome',
'browser': 'start chrome',
'firefox': 'start firefox',
'edge': 'start msedge',
'notepad': 'start notepad',
'calc': 'start calc',
'calculator': 'start calc',
'explorer': 'start explorer',
'terminal': 'start cmd',
'cmd': 'start cmd',
'powershell': 'start powershell',
}
: isMac
? {
'chrome': 'open -a "Google Chrome"',
'browser': 'open',
'firefox': 'open -a "Firefox"',
'edge': 'open -a "Microsoft Edge"',
'notepad': 'open -a "TextEdit"',
'calc': 'open -a "Calculator"',
'calculator': 'open -a "Calculator"',
'explorer': 'open .',
'terminal': 'open -a "Terminal"',
'cmd': 'open -a "Terminal"',
'powershell': 'open -a "Terminal"',
}
: {
'chrome': 'google-chrome',
'browser': 'xdg-open',
'firefox': 'firefox',
'edge': 'microsoft-edge',
'notepad': 'gedit',
'calc': 'gnome-calculator',
'calculator': 'gnome-calculator',
'explorer': 'xdg-open .',
'terminal': 'x-terminal-emulator',
'cmd': 'x-terminal-emulator',
'powershell': 'pwsh',
};
function quoteShellArg(value: string): string {
return `"${String(value || '').replace(/"/g, '\\"')}"`;
}
function buildUrlOpenCommand(url: string): string {
if (isWindows) return `start "" ${quoteShellArg(url)}`;
if (isMac) return `open ${quoteShellArg(url)}`;
return `xdg-open ${quoteShellArg(url)}`;
}
function buildBrowserLaunchCommand(app: string, url: string): string {
const appCmd = SAFE_COMMANDS[app] || SAFE_COMMANDS.browser;
if (isWindows) return `${appCmd} ${quoteShellArg(url)}`;
if (app === 'browser') return buildUrlOpenCommand(url);
return `${appCmd} ${quoteShellArg(url)}`;
}
function hasUriScheme(value: string): boolean {
return /^[a-z][a-z0-9+.-]*:/i.test(String(value || '').trim());
}
const BLOCKED_PATTERNS = ['del ', 'rm ', 'format', 'shutdown', 'restart', 'rmdir', 'rd ', 'taskkill', 'reg '];
// Track last-used filename per session for when model forgets to pass it
const lastFilenameUsed: Map<string, string> = new Map();
// isPathInsideDir/cronScheduler/resolvePromptPath/webSearch/webFetch/imageSearch/
// IMAGE_TYPES/codeAiLocalSessions/handleTaskControlAction/broadcastWS all stay
// defined in server.ts (each used well beyond this one function — e.g. imageSearch
// is also handed to routes-pptx.ts, codeAiLocalSessions is filled by the POST
// /api/chat handler), so they're threaded in as deps via this factory.
export interface ExecuteToolDeps {
isPathInsideDir: (base: string, target: string) => boolean;
cronScheduler: {
getJobs: () => any[];
createJob: (job: any) => any;
updateJob: (id: string, patch: any) => any;
deleteJob: (id: string) => boolean;
runJobNow: (id: string, opts?: { respectActiveHours?: boolean }) => Promise<void>;
};
resolvePromptPath: (workspacePath: string, filename: string) => string;
webSearch: (query: string) => Promise<string>;
webFetch: (url: string) => Promise<string>;
imageSearch: (query: string, count?: number) => Promise<string>;
IMAGE_TYPES: Record<string, string>;
codeAiLocalSessions: Set<string>;
handleTaskControlAction: (sessionId: string, args: any) => Promise<any>;
broadcastWS: (data: object) => void;
}
export function createExecuteTool(deps: ExecuteToolDeps) {
const {
isPathInsideDir,
cronScheduler,
resolvePromptPath,
webSearch,
webFetch,
imageSearch,
IMAGE_TYPES,
codeAiLocalSessions,
handleTaskControlAction,
broadcastWS,
} = deps;
return async function executeTool(name: string, args: any, workspacePath: string, sessionId: string = 'default', sendSSE?: (type: string, data: any) => void, username?: string): Promise<ToolResult> {
// Filename inference: if the model forgot to pass filename, use the last one
const needsFilename = ['coder_read_file', 'coder_write_file', 'coder_overwrite_lines', 'coder_insert_lines', 'coder_delete_lines', 'coder_str_replace', 'coder_patch_file', 'coder_delete_file'];
if (needsFilename.includes(name)) {
// Normalize: secondary AI sometimes returns "path" or "file" instead of "filename"
if (!args.filename && !args.name) {
if (args.path) { args.filename = args.path; }
else if (args.file) { args.filename = args.file; }
}
const fn = args.filename || args.name;
if (fn) {
lastFilenameUsed.set(sessionId, fn);
} else if (lastFilenameUsed.has(sessionId)) {
args.filename = lastFilenameUsed.get(sessionId);
console.log(`[v2] AUTO-FIX: Injected missing filename "${args.filename}" for ${name}`);
}
}
// Local-folder code session: the browser owns the files, not the server
// workspace. Short-circuit file tools with a synthetic result — the client
// intercepts the tool_call and applies the change to the local folder. We never
// touch the server disk here (avoids "not found" errors and workspace clutter).
if (codeAiLocalSessions.has(sessionId)) {
const fname = String(args.filename || args.name || '(file)');
switch (name) {
case 'coder_write_file': return { name, args, result: `Created ${fname}.`, error: false };
case 'coder_patch_file': return { name, args, result: `Applied edits to ${fname}.`, error: false };
case 'coder_overwrite_lines': return { name, args, result: `Updated ${fname} (replaced lines ${args.start_line}-${args.end_line}).`, error: false };
case 'coder_insert_lines': return { name, args, result: `Updated ${fname} (inserted after line ${args.after_line}).`, error: false };
case 'coder_delete_lines': return { name, args, result: `Updated ${fname} (deleted lines ${args.start_line}-${args.end_line}).`, error: false };
case 'coder_str_replace': return { name, args, result: `Updated ${fname}.`, error: false };
case 'coder_delete_file': return { name, args, result: `Deleted ${fname}.`, error: false };
case 'coder_move_file': return { name, args, result: `${args.source || fname} → ${args.destination || '(destination)'}`, error: false };
case 'coder_read_file': return { name, args, result: `[LOCAL-FILE-CONTENT-PENDING] 클라이언트가 "${fname}" 내용을 주입 중입니다. 컨텍스트의 [파일: ${fname}] 블록을 확인하세요. 없다면 컨텍스트에 있는 다른 파일을 바탕으로 작업을 계속 진행하세요. 사용자에게 파일을 열어달라고 요청하지 마세요.`, error: false };
case 'coder_list_files':
case 'search_files': return { name, args, result: `프로젝트 파일과 내용은 이미 대화 컨텍스트의 [파일: ...] 블록에 포함되어 있습니다. 별도 조회 없이 컨텍스트를 참고하세요.`, error: false };
// Shell/run tools see the server workspace, not the client's local folder.
// Returning a clear message prevents the model from mistaking "file not found
// on server" for a failed coder_write_file and re-creating the file server-side.
case 'shell':
case 'run_command':
case 'bash': return { name, args, result: `[ERROR] 클라이언트 로컬 모드에서는 셸 명령을 사용할 수 없습니다. 파일은 브라우저의 로컬 폴더에 저장됩니다. shell/run_command/bash 도구를 호출하지 마세요.`, error: true };
}
}
try {
switch (name) {
case 'coder_list_files': {
let dirArg: string = args.directory || '';
let dir = dirArg ? path.join(workspacePath, dirArg) : workspacePath;
if (!isPathInsideDir(workspacePath, dir)) return { name, args, result: 'Access denied: path escapes workspace', error: true };
if (!fs.existsSync(dir) && dirArg) {
// Fuzzy fallback: try spaces↔underscores variants
const variants = [
dirArg.replace(/ /g, '_'),
dirArg.replace(/_/g, ' '),
];
for (const v of variants) {
const candidate = path.join(workspacePath, v);
if (fs.existsSync(candidate)) { dir = candidate; dirArg = v; break; }
}
// Prefix match: given name is a prefix of an actual folder name
if (!fs.existsSync(dir)) {
const prefix = dirArg.replace(/ /g, '_');
try {
const match = fs.readdirSync(workspacePath).find(e => {
const full = path.join(workspacePath, e);
return e.startsWith(prefix) && fs.statSync(full).isDirectory();
});
if (match) { dir = path.join(workspacePath, match); dirArg = match; }
} catch {}
}
}
if (!fs.existsSync(dir)) return { name, args, result: `Directory not found: ${args.directory || '/'}`, error: true };
const entries = fs.readdirSync(dir).map(f => {
try {
const full = path.join(dir, f);
const stat = fs.statSync(full);
return stat.isDirectory() ? `${f}/` : f;
} catch { return f; }
});
return { name, args, result: JSON.stringify(entries), error: false };
}
// Resolve filename for edit tools: if the raw filename doesn't exist but
// the code-directory version does, use that. This lets the model call
// coder_overwrite_lines("file.py") and find the auto-redirected code/file.py.
function resolveCodeFilename(raw: string, wsPath: string): string {
if (!raw || raw.includes('/') || raw.includes('\\')) return raw;
const direct = path.join(wsPath, raw);
if (fs.existsSync(direct)) return raw;
const codeSubDir = (getConfig().getConfig() as any)?.workspace?.codeDir || 'code';
const inCodeDir = path.join(wsPath, codeSubDir, raw);
if (fs.existsSync(inCodeDir)) return `${codeSubDir}/${raw}`;
return raw; // not found anywhere — let the tool report the error
}
case 'coder_read_file': {
const filename = resolveCodeFilename(args.filename || args.name, workspacePath);
const filePath = path.join(workspacePath, filename);
if (!isPathInsideDir(workspacePath, filePath)) return { name, args, result: 'Access denied: path escapes workspace', error: true };
if (!fs.existsSync(filePath)) {
// Auto-download PMC fulltext when the file doesn't exist yet
const pmcMatch = String(filename || '').match(/PMC(\d+)\.txt$/i);
if (pmcMatch) {
const { pubmedFulltextTool } = await import('../../tools/pubmed.js');
const tr = await pubmedFulltextTool.execute({ pmcid: `PMC${pmcMatch[1]}`, _workspacePath: workspacePath });
if (tr.success) {
const content = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : (tr.stdout || '');
const numbered = content.split('\n').map((line: string, i: number) => `${i + 1}: ${line}`).join('\n');
return { name, args, result: `[Auto-downloaded PMC${pmcMatch[1]}]\n${filename} (${content.split('\n').length} lines):\n${numbered}`, error: false };
}
return { name, args, result: `pubmed_fulltext failed: ${tr.error || 'unknown error'}`, error: true };
}
return { name, args, result: `File "${filename}" not found`, error: true };
}
const ext = path.extname(filePath).toLowerCase();
if (IMAGE_TYPES[ext]?.startsWith('image/')) {
return { name, args, result: `![${filename}](/api/files/${filename})`, error: false, isImage: true };
}
// Block reading binary files (pptx, pdf, zip, etc.) — they blow up the context window
const BINARY_EXTENSIONS = new Set(['.pptx', '.pdf', '.zip', '.tar', '.gz', '.rar', '.7z', '.exe', '.dll', '.so', '.dylib', '.bin', '.dat', '.db', '.sqlite', '.sqlite3', '.woff', '.woff2', '.ttf', '.otf', '.eot', '.mp3', '.mp4', '.avi', '.mov', '.mkv', '.wav', '.flac', '.ogg', '.webm']);
if (ext === '.pptx') {
// Extract slide text so the model can inspect/edit slide content.
// For visual image inspection, use: coder_list_files("<folder>/preview") then coder_read_file each PNG.
try {
const { execFile: execFileCb } = await import('child_process');
const pythonCmd = process.platform === 'win32' ? 'python' : 'python3';
const script = `
import sys, json
from pptx import Presentation
prs = Presentation(sys.argv[1])
slides = []
for i, slide in enumerate(prs.slides):
layout = slide.slide_layout.name if slide.slide_layout else ''
texts = []
images = []
for shape in slide.shapes:
if shape.has_text_frame:
for para in shape.text_frame.paragraphs:
t = para.text.strip()
if t: texts.append(t)
if shape.shape_type == 13 and getattr(shape, 'name', '') != 'background_skin':
images.append(getattr(shape, 'name', f'image_{len(images)+1}'))
slides.append({'slide': i+1, 'layout': layout, 'texts': texts, 'images': images})
print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=False))
`;
const result = await new Promise<string>((resolve, reject) => {
execFileCb(pythonCmd, ['-c', script, filePath], { timeout: 15000, maxBuffer: 1024 * 512, encoding: 'utf-8' }, (err, stdout, stderr) => {
if (err) reject(new Error(stderr || err.message));
else resolve(stdout.trim());
});
});
const parsed = JSON.parse(result);
const lines = [
`PPTX: ${filename} (${parsed.total} slides)`,
`[To inspect slide images visually: coder_list_files("${path.dirname(filename)}/preview") then coder_read_file each PNG]`,
];
for (const s of parsed.slides) {
lines.push(`\nSlide ${s.slide} [${s.layout}]${s.images.length ? ` 🖼×${s.images.length}` : ''}`);
for (const t of s.texts) lines.push(` ${t}`);
}
return { name, args, result: lines.join('\n'), error: false };
} catch (e: any) {
const stats = fs.statSync(filePath);
return { name, args, result: `File "${filename}" is a binary PPTX (${(stats.size / 1024).toFixed(1)} KB). Text extraction failed: ${e.message}\n[To inspect slides visually: coder_list_files("${path.dirname(filename)}/preview") then coder_read_file each PNG]`, error: true };
}
}
if (BINARY_EXTENSIONS.has(ext)) {
const stats = fs.statSync(filePath);
return { name, args, result: `File "${filename}" is a binary file (${ext}, ${(stats.size / 1024).toFixed(1)} KB) and cannot be read as text. Use other tools or download it via ${filename}.`, error: true };
}
const content = fs.readFileSync(filePath, 'utf-8');
const MAX_READ_CHARS = 200_000; // ~200KB — prevents single coder_read_file from flooding context
const truncated = content.length > MAX_READ_CHARS;
const displayContent = truncated ? content.slice(0, MAX_READ_CHARS) : content;
const numbered = displayContent.split('\n').map((line, i) => `${i + 1}: ${line}`).join('\n');
const truncNote = truncated ? `\n…[파일이 너무 큼 — 전체 ${Math.round(content.length / 1024)}KB 중 앞 ${Math.round(MAX_READ_CHARS / 1024)}KB만 표시]` : '';
return { name, args, result: `${filename} (${content.split('\n').length} lines):\n${numbered}${truncNote}`, error: false };
}
case 'coder_write_file': {
const rawFilename = args.filename || args.name;
const codeSubDir = (getConfig().getConfig() as any)?.workspace?.codeDir || 'code';
const CODE_EXTS = new Set(['py','js','ts','jsx','tsx','c','cpp','cs','java','kt','rs','go','html','css','scss','vue','svelte','sh','bash','sql','swift','dart','lua','rb','php','ex','hs','r','zig','fish']);
// Auto-redirect code files to code directory if they have no path prefix
let filename = rawFilename;
const ext = (filename || '').split('.').pop()?.toLowerCase() || '';
if (filename && !filename.includes('/') && !filename.includes('\\') && CODE_EXTS.has(ext)) {
filename = `${codeSubDir}/${filename}`;
}
const BINARY_EXTS: Record<string, string> = {
xlsx: 'excel_write', xls: 'excel_write',
pptx: 'pptx builder skill', ppt: 'pptx builder skill',
docx: 'a document-generation tool', doc: 'a document-generation tool',
pdf: 'a PDF-generation tool', zip: 'shell (e.g. python zipfile)',
};
if (BINARY_EXTS[ext]) {
return {
name, args,
result: `[BLOCKED] coder_write_file cannot create "${filename}" — it writes plain text, and binary ${ext.toUpperCase()} content cannot be represented as text (writing escape sequences like "PK\\u0003\\u0004" produces a corrupted file, not real binary). Use ${BINARY_EXTS[ext]} to create this file instead.`,
error: true,
};
}
const filePath = path.join(workspacePath, filename);
if (!isPathInsideDir(workspacePath, filePath)) return { name, args, result: 'Access denied: path escapes workspace', error: true };
if (fs.existsSync(filePath)) {
// File exists — compute a surgical diff and apply only the changed lines,
// then tell the model to use coder_overwrite_lines/coder_insert_lines next time.
console.log(`[v2] coder_write_file("${filename}"): file exists, auto-converting to diff edit`);
const oldLines = fs.readFileSync(filePath, 'utf-8').split('\n');
const newLines = (args.content || '').split('\n');
// Find common prefix length (unchanged lines at the start)
let prefixLen = 0;
while (prefixLen < oldLines.length && prefixLen < newLines.length && oldLines[prefixLen] === newLines[prefixLen]) prefixLen++;
// Find common suffix length (unchanged lines at the end), not overlapping the prefix
let suffixLen = 0;
while (
suffixLen < (oldLines.length - prefixLen) &&
suffixLen < (newLines.length - prefixLen) &&
oldLines[oldLines.length - 1 - suffixLen] === newLines[newLines.length - 1 - suffixLen]
) suffixLen++;
const startLine = prefixLen + 1; // 1-based
const endLine = oldLines.length - suffixLen; // 1-based, inclusive
const replacementLines = newLines.slice(prefixLen, newLines.length - suffixLen);
const replacementContent = replacementLines.join('\n');
if (startLine > endLine) {
// Pure insertion (old had fewer lines in the changed region)
// Use coder_insert_lines with line just before the insertion point
const insertAfterLine = startLine - 1;
const oldContent = fs.readFileSync(filePath, 'utf-8');
const allLines = oldContent.split('\n');
allLines.splice(insertAfterLine, 0, ...replacementLines);
fs.writeFileSync(filePath, allLines.join('\n'), 'utf-8');
return {
name, args,
result: `${filename} updated — inserted ${replacementLines.length} line(s) after line ${insertAfterLine} (auto-converted from coder_write_file). Next time, use coder_insert_lines or coder_overwrite_lines for edits.`,
error: false,
};
}
// Apply the surgical edit: replace lines startLine..endLine with replacementContent
const oldContent = fs.readFileSync(filePath, 'utf-8');
const allLines = oldContent.split('\n');
const removedCount = endLine - startLine + 1;
allLines.splice(startLine - 1, removedCount, ...replacementLines);
fs.writeFileSync(filePath, allLines.join('\n'), 'utf-8');
const addedCount = replacementLines.length;
const lineWord = addedCount === removedCount ? `${addedCount} line(s)` : `${removedCount}→${addedCount} line(s)`;
return {
name, args,
result: `${filename} updated — replaced lines ${startLine}-${endLine} (${lineWord}, auto-converted from coder_write_file). Next time, use coder_overwrite_lines or coder_insert_lines for edits.`,
error: false,
};
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, args.content || '', 'utf-8');
console.log(`[v2] coder_write_file("${filename}"): new file created (${(args.content || '').length} chars)`);
return { name, args, result: `${filename} created`, error: false };
}
case 'coder_overwrite_lines': {
const filename = resolveCodeFilename(args.filename || args.name, workspacePath);
const startLine = Math.max(1, Math.floor(Number(args.start_line) || 1));
const endLine = Math.max(startLine, Math.floor(Number(args.end_line) || startLine));
const newContent = args.new_content || '';
const filePath = path.join(workspacePath, filename);
if (!isPathInsideDir(workspacePath, filePath)) return { name, args, result: 'Access denied: path escapes workspace', error: true };
if (!fs.existsSync(filePath)) return { name, args, result: `"${filename}" not found`, error: true };
const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
if (startLine > lines.length) return { name, args, result: `Line ${startLine} past end (${lines.length} lines)`, error: true };
const end = Math.min(endLine, lines.length);
lines.splice(startLine - 1, end - startLine + 1, ...newContent.split('\n'));
fs.writeFileSync(filePath, lines.join('\n'), 'utf-8');
return { name, args, result: `${filename}: replaced lines ${startLine}-${end} (now ${lines.length} lines)`, error: false };
}
case 'coder_insert_lines': {
const filename = resolveCodeFilename(args.filename || args.name, workspacePath);
const afterLine = Math.max(0, Math.floor(Number(args.after_line) || 0));
const content = String(args.content || '').replace(/\\n/g, '\n');
const filePath = path.join(workspacePath, filename);
if (!isPathInsideDir(workspacePath, filePath)) return { name, args, result: 'Access denied: path escapes workspace', error: true };
if (!fs.existsSync(filePath)) return { name, args, result: `"${filename}" not found`, error: true };
const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
const insertAt = Math.min(afterLine, lines.length);
lines.splice(insertAt, 0, ...content.split('\n'));
fs.writeFileSync(filePath, lines.join('\n'), 'utf-8');
return { name, args, result: `${filename}: inserted after line ${afterLine} (now ${lines.length} lines)`, error: false };
}
case 'coder_delete_lines': {
const filename = resolveCodeFilename(args.filename || args.name, workspacePath);
const startLine = Math.max(1, Math.floor(Number(args.start_line) || 1));
const endLine = Math.max(startLine, Math.floor(Number(args.end_line) || startLine));
const filePath = path.join(workspacePath, filename);
if (!isPathInsideDir(workspacePath, filePath)) return { name, args, result: 'Access denied: path escapes workspace', error: true };
if (!fs.existsSync(filePath)) return { name, args, result: `"${filename}" not found`, error: true };
const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
const end = Math.min(endLine, lines.length);
lines.splice(startLine - 1, end - startLine + 1);
fs.writeFileSync(filePath, lines.join('\n'), 'utf-8');
return { name, args, result: `${filename}: deleted lines ${startLine}-${end} (now ${lines.length} lines)`, error: false };
}
case 'coder_str_replace': {
const filename = resolveCodeFilename(args.filename || args.name, workspacePath);
const find = args.find || '';
const replace = args.replace ?? '';
const filePath = path.join(workspacePath, filename);
if (!isPathInsideDir(workspacePath, filePath)) return { name, args, result: 'Access denied: path escapes workspace', error: true };
if (!fs.existsSync(filePath)) return { name, args, result: `"${filename}" not found`, error: true };
const content = fs.readFileSync(filePath, 'utf-8');
if (!content.includes(find)) return { name, args, result: `Text not found. Use coder_read_file to check exact content.`, error: true };
fs.writeFileSync(filePath, content.replace(find, replace), 'utf-8');
return { name, args, result: `${filename} updated`, error: false };
}
case 'coder_patch_file': {
const filename = resolveCodeFilename(args.filename || args.name, workspacePath);
const filePath = path.join(workspacePath, filename);
if (!isPathInsideDir(workspacePath, filePath)) return { name, args, result: 'Access denied: path escapes workspace', error: true };
if (!fs.existsSync(filePath)) return { name, args, result: `"${filename}" not found. Use coder_write_file for new files.`, error: true };
const content = fs.readFileSync(filePath, 'utf-8');
const edits = String(args.edits || '');
const _patch = applySearchReplaceEdits(content, edits);
const result = _patch.content;
const editCount = _patch.editCount;
const failedEdits = _patch.failedEdits;
if (editCount > 0) {
fs.writeFileSync(filePath, result, 'utf-8');
const msg = `${filename}: ${editCount} edit(s) applied`;
if (failedEdits.length > 0) {
return { name, args, result: `${msg}. ${failedEdits.length} edit(s) could not find exact match: ${failedEdits.join('; ')}. Use coder_read_file to verify exact content.`, error: false };
}
return { name, args, result: msg, error: false };
}
// No blocks parsed at all — the model used a different format.
if (!_patch.hadAnyBlock && edits.length > 0) {
return { name, args, result: `No SEARCH/REPLACE blocks found. Use the format:\n------- SEARCH\nexact text\n=======\nreplacement\n+++++++ REPLACE`, error: true };
}
return { name, args, result: `No edits applied. SEARCH text not found in "${filename}". Use coder_read_file to verify exact content, including whitespace and indentation. Failed matches:\n${failedEdits.join('\n')}`, error: true };
}
case 'coder_delete_file': {
const filename = resolveCodeFilename(args.filename || args.name, workspacePath);
const filePath = path.join(workspacePath, filename);
if (!isPathInsideDir(workspacePath, filePath)) return { name, args, result: 'Access denied: path escapes workspace', error: true };
if (!fs.existsSync(filePath)) return { name, args, result: `"${filename}" not found`, error: true };
fs.unlinkSync(filePath);
return { name, args, result: `${filename} deleted`, error: false };
}
case 'coder_move_file': {
const srcRaw = resolveCodeFilename(args.source || args.from, workspacePath);
const dstRaw = resolveCodeFilename(args.destination || args.to, workspacePath);
const srcPath = path.join(workspacePath, srcRaw);
const dstPath = path.join(workspacePath, dstRaw);
if (!isPathInsideDir(workspacePath, srcPath) || !isPathInsideDir(workspacePath, dstPath))
return { name, args, result: 'Access denied: path escapes workspace', error: true };
if (!fs.existsSync(srcPath)) return { name, args, result: `"${srcRaw}" not found`, error: true };
fs.mkdirSync(path.dirname(dstPath), { recursive: true });
fs.renameSync(srcPath, dstPath);
return { name, args, result: `${srcRaw} → ${dstRaw}`, error: false };
}
case 'web_search': {
const raw = await webSearch(args.query || '');
const result = await validateLinksInText(raw);
return { name, args, result, error: false };
}
case 'search_images': {
const query = String(args.query || '');
const count = Math.min(Math.max(Number(args.count) || 3, 1), 6);
// 1) Try the API path first (Pexels → Pixabay → Unsplash, fast).
// Browser scrape only runs as a last-resort fallback below.
const raw = await imageSearch(query, count);
const apiHasResults = !raw.startsWith('No image API') && !raw.startsWith('No images found');
if (apiHasResults) {
const filtered = await filterImageMarkdown(raw);
if (filtered.liveCount > 0) {
return { name, args, result: filtered.text, error: false, isImage: true };
}
console.log(`[search_images] APIs returned ${filtered.deadCount} dead URLs for "${query}", falling back to browser`);
} else {
console.log(`[search_images] APIs found nothing for "${query}", falling back to browser`);
}
// 2) Browser fallback — Google Images via Playwright. Slow (cold start
// + page load), so only used when APIs failed to produce live results.
const tmpSessionId = `img-${Date.now()}`;
try {
const searchUrl = `https://www.google.com/search?tbm=isch&q=${encodeURIComponent(query)}`;
let result = await browserOpen(tmpSessionId, searchUrl);
if (result.startsWith('ERROR:')) {
return { name, args, result: `No images found for "${query}".`, error: true };
}
await browserWait(tmpSessionId, 2500);
result = await browserGetImages(tmpSessionId, { max_images: count + 4, download: false });
if (result.startsWith('ERROR:') || result.includes('Found 0 images')) {
return { name, args, result: `No images found for "${query}".`, error: true };
}
const urls: string[] = [];
const regex = /^\s*-\s*\[[^\]]+\]\s+(https?:\/\/\S+)/gm;
let m;
while ((m = regex.exec(result)) !== null) {
if (m[1].includes('gstatic.com/images') || m[1].includes('/favicon')) continue;
if (!urls.includes(m[1])) urls.push(m[1]);
}
if (!urls.length) {
return { name, args, result: `No images found for "${query}".`, error: true };
}
const markdown = urls.slice(0, count)
.map((url, i) => `![${query} ${i + 1}](${url})`)
.join('\n\n');
return { name, args, result: markdown, error: false, isImage: true };
} catch (err: any) {
return { name, args, result: `Browser image search error: ${err.message}`, error: true };
} finally {
try { await browserClose(tmpSessionId); } catch {}
}
}
case 'web_fetch': {
const fetchUrl = String(args.url || '');
// Block local/internal URLs — these are not web pages
if (/^(https?:\/\/)?(localhost|127\.0\.0\.1|0\.0\.0\.0|::1)(:\d+)?\//i.test(fetchUrl) || fetchUrl.startsWith('/api/')) {
return { name, args, result: `Cannot fetch local URLs. If the user uploaded an image, describe what you see based on the filename and ask the user for details. Do NOT try to fetch local file URLs with any tool.`, error: true };
}
const result = await webFetch(fetchUrl);
return { name, args, result, error: result.startsWith('Fetch failed') || result.startsWith('Fetch error') || result.startsWith('Fetch timed') };
}
case 'spawn_agent': {
const { spawnAgent } = await import('../../agents/spawner.js');
const spawnResult = await spawnAgent({
agentId: String(args.agentId || ''),
task: String(args.task || ''),
context: args.context ? String(args.context) : undefined,
maxSteps: args.maxSteps ? Number(args.maxSteps) : undefined,
});
return {
name, args,
result: spawnResult.success
? `[${spawnResult.agentName}] ${spawnResult.result}`
: `[${spawnResult.agentName}] FAILED: ${spawnResult.error}`,
error: !spawnResult.success,
};
}
case 'shell': {
// workspacePath comes from the outer executeTool() parameter — already
// resolved per-user by handleChat. Do NOT re-derive here without a
// username or it will silently fall back to the global workspace.
const command = String(args.command || '').trim();
const cwd = args.cwd ? path.resolve(String(args.cwd)) : path.resolve(workspacePath);
if (!command) {
return { name, args, result: 'Error: empty command', error: true };
}
// Security: path confinement check
const shellPerms = getConfig().getConfig().tools.permissions.shell;
if (shellPerms.workspace_only && !isPathInsideDir(path.resolve(workspacePath), cwd)) {
return { name, args, result: `Security: Command execution outside workspace is not allowed.`, error: true };
}
// Security: blocked patterns from config
for (const pattern of (shellPerms.blocked_patterns || [])) {
if (command.includes(pattern)) {
return { name, args, result: `Security: Command blocked due to dangerous pattern: "${pattern}"`, error: true };
}
}
// Security: hardcoded dangerous commands
const dangerousCommands: Array<[RegExp, string]> = [
[/rm\s+-rf\s+\//, 'rm -rf /'],
[/mkfs/, 'filesystem format'],
[/\bsudo\b/, 'privilege escalation'],
[/\bcurl\b.*\|.*\bbash\b/, 'curl-pipe-bash'],
];
for (const [pattern, label] of dangerousCommands) {
if (pattern.test(command)) {
return { name, args, result: `Security: Potentially destructive command detected (${label})`, error: true };
}
}
try {
const { exec } = await import('child_process');
const env = {
...process.env,
PYTHONIOENCODING: 'utf-8',
PYTHONUTF8: '1',
// Force UTF-8 output on Windows cmd.exe
...(process.platform === 'win32' ? { CHCP: '65001' } : {}),
};
// Snapshot files before execution to detect newly created files
const downloadExts = ['.pptx', '.pdf', '.xlsx', '.xls', '.docx', '.doc', '.zip', '.csv', '.mp4', '.mp3'];
const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp'];
const allDetectExts = [...downloadExts, ...imageExts];
const filesBefore = new Set<string>();
try {
for (const f of fs.readdirSync(cwd)) {
if (fs.statSync(path.join(cwd, f)).isFile() && allDetectExts.includes(path.extname(f).toLowerCase())) {
filesBefore.add(f);
}
}
} catch {}
// On Windows, use cmd.exe (default shell) with chcp 65001 for UTF-8.
// Prepend chcp so all subsequent output is UTF-8.
const isWin = process.platform === 'win32';
const execCmd = isWin ? `chcp 65001 >nul && ${command}` : command;
const shellOpt: string | undefined = isWin ? undefined : '/bin/bash';
const result = await new Promise<{ stdout: string; stderr: string; code: number }>((resolve) => {
exec(execCmd, {
cwd,
maxBuffer: 4 * 1024 * 1024,
timeout: 120000,
shell: shellOpt,
env,
} as any, (err: any, stdout: string, stderr: string) => {
resolve({ stdout: stdout || '', stderr: stderr || '', code: err ? (err as any).code ?? 1 : 0 });
});
});
const output = (result.stdout + (result.stderr ? '\n' + result.stderr : '')).trim();
// Detect only NEWLY created downloadable/image files
const downloadLinks: string[] = [];
const imageLinks: string[] = [];
try {
for (const f of fs.readdirSync(cwd)) {
const ext = path.extname(f).toLowerCase();
if (fs.statSync(path.join(cwd, f)).isFile()
&& allDetectExts.includes(ext)
&& !filesBefore.has(f)) {
if (imageExts.includes(ext)) {
imageLinks.push(`![${f}](/api/files/${encodeURIComponent(f)})`);
} else {
downloadLinks.push(`[${f}](/api/files/${encodeURIComponent(f)})`);
}
}
}
} catch {}
const mediaSuffix = [
imageLinks.length > 0 ? '\n\n' + imageLinks.join('\n') : '',
downloadLinks.length > 0 ? `\n\nDownload:\n${downloadLinks.join('\n')}` : '',
].join('');
return { name, args, result: (output || `(exit code ${result.code})`) + mediaSuffix, error: result.code !== 0 };
} catch (err: any) {
return { name, args, result: `Error: ${err.message}`, error: true };
}
}
case 'run_command': {
const rawCmd = (args.command || '').trim();
const cmd = rawCmd.toLowerCase();
// Check blocked patterns
for (const blocked of BLOCKED_PATTERNS) {
if (cmd.includes(blocked.toLowerCase())) {
return { name, args, result: `Blocked: "${cmd}" contains unsafe pattern "${blocked}"`, error: true };
}
}
let execCmd = '';
// 1. Check allowlist (exact match)
if (SAFE_COMMANDS[cmd]) {
execCmd = SAFE_COMMANDS[cmd];
}
// 2. "chrome <url>" or "browser <url>" → open browser with URL
else if (/^(chrome|browser|firefox|edge)\s+/.test(cmd)) {
const parts = rawCmd.split(/\s+/);
const app = parts[0].toLowerCase();
let url = parts.slice(1).join(' ');
// Add https:// only when no URI scheme is present.
// This preserves file://, chrome://, about:, etc.
if (url && !hasUriScheme(url)) url = 'https://' + url;
execCmd = buildBrowserLaunchCommand(app, url);
}
// 3. URL/URI → open in default browser
else if (/^(https?:\/\/|file:\/\/|chrome:\/\/|about:|www\.)/.test(cmd)) {
const url = cmd.startsWith('www.') ? 'https://' + rawCmd : rawCmd;
execCmd = buildUrlOpenCommand(url);
}
// 4. Bare domain like "youtube.com" → open in browser
else if (/^[a-z0-9-]+\.[a-z]{2,}/.test(cmd) && !cmd.includes(' ')) {
execCmd = buildUrlOpenCommand(`https://${rawCmd}`);
}
// 5. "code <path>" → VS Code
else if (cmd.startsWith('code ')) {
execCmd = rawCmd;
}
// 6. Windows-only: "start <url>" → pass through
else if (isWindows && (cmd.startsWith('start http') || cmd.startsWith('start https'))) {
execCmd = rawCmd;
}
// 7. Windows-only: "explorer <path>"
else if (isWindows && cmd.startsWith('explorer ')) {
execCmd = rawCmd;
}
if (!execCmd) {
return {
name,
args,
result: `Command "${rawCmd}" not recognized. Try: chrome, chrome youtube.com, notepad, code <path>, or a URL`,
error: true,
};
}
try {
const { exec } = await import('child_process');
exec(execCmd);
return { name, args, result: `Executed: ${execCmd}`, error: false };
} catch (err: any) {
return { name, args, result: `Failed: ${err.message}`, error: true };
}
}
case 'start_task': {
// This is handled specially in handleChat — shouldn't reach here
return { name, args, result: 'Task system ready. Use the task endpoint.', error: false };
}
case 'task_control': {
const out = await handleTaskControlAction(sessionId, args);
return {
name,
args,
result: JSON.stringify(out, null, 2),
error: out.success !== true,
};
}
case 'schedule_job': {
const action = normalizeScheduleJobAction(args.action);
if (!action) {
return {
name,
args,
result: 'schedule_job requires a valid action: list, create, update, pause, resume, delete, run_now',
error: true,
};
}
const requiresConfirm = action === 'create' || action === 'update' || action === 'delete';
if (requiresConfirm && args.confirm !== true) {
return {
name,
args,
result: JSON.stringify({
success: false,
needs_confirmation: true,
action,
message: `Action "${action}" requires explicit confirmation. Re-run with confirm=true after user says yes.`,
}, null, 2),
error: true,
};
}
if (action === 'list') {
const limitRaw = Number(args.limit);
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(200, Math.floor(limitRaw)) : 50;
const jobs = cronScheduler.getJobs().map(summarizeCronJob).slice(0, limit);
return {
name,
args,
result: JSON.stringify({ success: true, count: jobs.length, jobs }, null, 2),
error: false,
};
}
const jobId = String(args.job_id || args.jobId || '').trim();
if (action === 'create') {
const instructionPrompt = String(args.instruction_prompt || args.prompt || '').trim();
if (!instructionPrompt) {
return { name, args, result: 'schedule_job(create) requires instruction_prompt', error: true };
}
const schedule = (args.schedule && typeof args.schedule === 'object') ? args.schedule : {};
const rawKind = String(schedule.kind || args.kind || 'recurring').trim().toLowerCase();
const kind: 'recurring' | 'one-shot' = (rawKind === 'one_shot' || rawKind === 'one-shot') ? 'one-shot' : 'recurring';
const cron = String(schedule.cron || args.cron || '').trim();
const runAtRaw = String(schedule.run_at || args.run_at || '').trim();
const timezone = String(args.timezone || args.tz || '').trim() || undefined;
const delivery = (args.delivery && typeof args.delivery === 'object') ? args.delivery : {};
const channel = normalizeDeliveryChannel(delivery.channel || args.channel);
const sessionTarget = String(delivery.session_target || args.session_target || 'isolated').toLowerCase() === 'main'
? 'main'
: 'isolated';
const modelOverride = String(args.model_override || args.model || '').trim() || undefined;
const nameValue = String(args.name || '').trim() || `Scheduled task ${new Date().toLocaleString()}`;
if (channel !== 'web') {
return {
name,
args,
result: `Delivery channel "${channel}" is not enabled for scheduler jobs yet. Use channel "web" for now.`,
error: true,
};
}
if (kind === 'one-shot') {
if (!runAtRaw) return { name, args, result: 'schedule.kind=one_shot requires schedule.run_at (ISO datetime)', error: true };
const parsed = new Date(runAtRaw);
if (!Number.isFinite(parsed.getTime())) {
return { name, args, result: `Invalid run_at value: "${runAtRaw}"`, error: true };
}
} else if (!cron) {
return { name, args, result: 'schedule.kind=recurring requires schedule.cron', error: true };
}
const created = cronScheduler.createJob({
name: nameValue,
prompt: instructionPrompt,
type: kind,
schedule: kind === 'recurring' ? cron : undefined,
runAt: kind === 'one-shot' ? new Date(runAtRaw).toISOString() : undefined,
tz: timezone,
sessionTarget,
model: modelOverride,
ownerUsername: username,
} as any);
return {
name,
args,
result: JSON.stringify({
success: true,
action: 'create',
job: summarizeCronJob(created),
message: `Scheduled job "${created.name}" created.`,
}, null, 2),
error: false,
};
}
if (!jobId) {
return { name, args, result: `schedule_job(${action}) requires job_id`, error: true };
}
if (action === 'pause') {
const updated = cronScheduler.updateJob(jobId, { status: 'paused', enabled: false } as any);
if (!updated) return { name, args, result: `Job not found: ${jobId}`, error: true };
return { name, args, result: JSON.stringify({ success: true, action: 'pause', job: summarizeCronJob(updated) }, null, 2), error: false };
}
if (action === 'resume') {
const updated = cronScheduler.updateJob(jobId, { status: 'scheduled', enabled: true } as any);
if (!updated) return { name, args, result: `Job not found: ${jobId}`, error: true };
return { name, args, result: JSON.stringify({ success: true, action: 'resume', job: summarizeCronJob(updated) }, null, 2), error: false };
}
if (action === 'run_now') {
const exists = cronScheduler.getJobs().some(j => j.id === jobId);
if (!exists) return { name, args, result: `Job not found: ${jobId}`, error: true };
cronScheduler.runJobNow(jobId, { respectActiveHours: false }).catch(err =>
console.error(`[schedule_job] run_now failed for ${jobId}:`, err?.message || err)
);
return {
name,
args,
result: JSON.stringify({ success: true, action: 'run_now', job_id: jobId, message: 'Job queued for immediate run.' }, null, 2),
error: false,
};
}
if (action === 'delete') {
const ok = cronScheduler.deleteJob(jobId);
if (!ok) return { name, args, result: `Job not found: ${jobId}`, error: true };
return {
name,
args,
result: JSON.stringify({ success: true, action: 'delete', job_id: jobId, message: 'Job deleted.' }, null, 2),
error: false,
};
}
if (action === 'update') {
const schedule = (args.schedule && typeof args.schedule === 'object') ? args.schedule : {};
const patch: Record<string, any> = {};
if (args.name !== undefined) patch.name = String(args.name || '').trim();
if (args.instruction_prompt !== undefined || args.prompt !== undefined) {
patch.prompt = String(args.instruction_prompt || args.prompt || '').trim();
}
if (args.timezone !== undefined || args.tz !== undefined) {
patch.tz = String(args.timezone || args.tz || '').trim();
}
if (args.model_override !== undefined || args.model !== undefined) {
const mv = String(args.model_override || args.model || '').trim();
patch.model = mv || undefined;
}
if (args.delivery !== undefined || args.channel !== undefined) {
const delivery = (args.delivery && typeof args.delivery === 'object') ? args.delivery : {};
const channel = normalizeDeliveryChannel(delivery.channel || args.channel);
if (channel !== 'web') {
return {
name,
args,
result: `Delivery channel "${channel}" is not enabled for scheduler jobs yet. Use channel "web" for now.`,
error: true,
};
}
const sessionTarget = String(delivery.session_target || args.session_target || '').toLowerCase();
if (sessionTarget === 'main' || sessionTarget === 'isolated') patch.sessionTarget = sessionTarget;
}
const rawKind = String(schedule.kind || args.kind || '').trim().toLowerCase();
if (rawKind === 'one_shot' || rawKind === 'one-shot') patch.type = 'one-shot';
if (rawKind === 'recurring') patch.type = 'recurring';
if (schedule.cron !== undefined || args.cron !== undefined) patch.schedule = String(schedule.cron || args.cron || '').trim();
if (schedule.run_at !== undefined || args.run_at !== undefined) patch.runAt = String(schedule.run_at || args.run_at || '').trim();
if (Object.keys(patch).length === 0) {
return { name, args, result: 'No update fields provided for schedule_job(update).', error: true };
}
if (patch.type === 'one-shot' && !patch.runAt) {
return { name, args, result: 'Updating to one_shot requires schedule.run_at', error: true };
}
if (patch.type === 'recurring' && patch.schedule === '') {
return { name, args, result: 'Updating to recurring requires schedule.cron', error: true };
}
if (patch.runAt) {
const parsed = new Date(String(patch.runAt));
if (!Number.isFinite(parsed.getTime())) {
return { name, args, result: `Invalid run_at value: "${patch.runAt}"`, error: true };
}
patch.runAt = parsed.toISOString();
}
const updated = cronScheduler.updateJob(jobId, patch as any);
if (!updated) return { name, args, result: `Job not found: ${jobId}`, error: true };
return {
name,
args,
result: JSON.stringify({
success: true,
action: 'update',
job: summarizeCronJob(updated),
message: `Scheduled job "${updated.name}" updated.`,
}, null, 2),
error: false,
};
}
return { name, args, result: `Unsupported schedule_job action: ${action}`, error: true };
}
case 'spawn_subagent': {
// Spawn a specialized sub-agent with restricted tool set
// This is used by primary agents to delegate work to secondary specialists
try {
const subagentId = String(args.subagent_id || '').trim();
const taskPrompt = String(args.task_prompt || '').trim();
const contextData = args.context_data && typeof args.context_data === 'object' ? args.context_data : undefined;
const createIfMissing = args.create_if_missing && typeof args.create_if_missing === 'object' ? args.create_if_missing : undefined;
if (!subagentId) {
return { name, args, result: 'spawn_subagent requires subagent_id', error: true };
}
if (!taskPrompt) {
return { name, args, result: 'spawn_subagent requires task_prompt', error: true };
}
// Use the per-user workspace passed in from handleChat — never
// re-derive from global config or subagents inherit the wrong identity.
const subagentMgr = new SubagentManager(workspacePath, broadcastWS);
// Call the subagent (creates if missing)
const result = await subagentMgr.callSubagent(
{
subagent_id: subagentId,
task_prompt: taskPrompt,
context_data: contextData,
create_if_missing: createIfMissing,
},
sessionId // parent task ID (session context)
);
return {
name,
args,
result: JSON.stringify(result, null, 2),
error: false,
};
} catch (err: any) {
return { name, args, result: `spawn_subagent error: ${err.message}`, error: true };
}
}
case 'parse_schedule_pattern': {
const text = String(args.text || '').trim();
if (!text) {
return { name, args, result: 'parse_schedule_pattern requires text parameter', error: true };
}
try {
let cron = '';
let preview = '';
const t = text.toLowerCase().trim();
// Helper: extract time from text and handle AM/PM
function extractTime(text: string): { hour: number; minute: number } | null {
const timeMatch = text.match(/(\d{1,2}):?(\d{2})?\s*(am|pm)?/i);
if (!timeMatch) return null;
let hour = parseInt(timeMatch[1], 10);
const minute = timeMatch[2] ? parseInt(timeMatch[2], 10) : 0;
const period = timeMatch[3]?.toLowerCase();
if (period === 'pm' && hour !== 12) {
hour += 12;
} else if (period === 'am' && hour === 12) {
hour = 0;
}
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
return { hour, minute };
}
if (t.includes('daily') || t.includes('every day')) {
const timeInfo = extractTime(t);
if (timeInfo) {
const hourStr = String(timeInfo.hour).padStart(2, '0');
const minStr = String(timeInfo.minute).padStart(2, '0');
cron = `${timeInfo.minute} ${timeInfo.hour} * * *`;
preview = `Daily at ${hourStr}:${minStr}`;
} else {
cron = '0 9 * * *';
preview = 'Daily at 09:00';
}
} else if (t.includes('weekly')) {
const timeInfo = extractTime(t);
if (timeInfo) {
cron = `${timeInfo.minute} ${timeInfo.hour} * * 1`;
preview = `Weekly on Monday at ${String(timeInfo.hour).padStart(2, '0')}:${String(timeInfo.minute).padStart(2, '0')}`;
} else {
cron = '0 9 * * 1';
preview = 'Weekly on Monday at 09:00';
}
} else if (t.includes('monday') || t.includes('tuesday') || t.includes('wednesday') || t.includes('thursday') || t.includes('friday')) {
const timeInfo = extractTime(t);
if (timeInfo) {
cron = `${timeInfo.minute} ${timeInfo.hour} * * 1-5`;
preview = `Weekdays at ${String(timeInfo.hour).padStart(2, '0')}:${String(timeInfo.minute).padStart(2, '0')}`;
} else {
cron = '0 9 * * 1-5';
preview = 'Weekdays at 09:00';
}
} else if (/^\d{1,2} \d{1,2} \d|\d \d \*/.test(t)) {
cron = t;
preview = 'Custom cron pattern';
} else {
return {
name,
args,
result: JSON.stringify({
success: false,
error: 'Could not parse pattern. Try: "daily at 3:13pm", "daily at 15:13", "weekly", or cron like "0 9 * * *"',
}, null, 2),
error: true,
};
}
return {
name,
args,
result: JSON.stringify({
success: true,
cron,
preview,
timezone: args.timezone || 'UTC',
}, null, 2),
error: false,
};
} catch (err: any) {
return { name, args, result: `parse_schedule_pattern error: ${err.message}`, error: true };
}
}
// Browser automation tools
case 'browser_open': {
const result = await browserOpen(sessionId, args.url || '');
return { name, args, result, error: result.startsWith('ERROR') };
}
case 'browser_snapshot': {
const result = await browserSnapshot(sessionId);
return { name, args, result, error: result.startsWith('ERROR') };
}
case 'browser_click': {
const result = await browserClick(sessionId, Number(args.ref || 0));
return { name, args, result, error: result.startsWith('ERROR') };
}
case 'browser_fill': {
const result = await browserFill(sessionId, Number(args.ref || 0), String(args.text || ''));
return { name, args, result, error: result.startsWith('ERROR') };
}
case 'browser_press_key': {
const result = await browserPressKey(sessionId, String(args.key || 'Enter'));
return { name, args, result, error: result.startsWith('ERROR') };
}
case 'browser_wait': {
const result = await browserWait(sessionId, Number(args.ms || 2000));
return { name, args, result, error: result.startsWith('ERROR') };
}
case 'browser_scroll': {
const dir = String(args.direction || 'down').toLowerCase() === 'up' ? 'up' : 'down';
const result = await browserScroll(sessionId, dir, Number(args.multiplier || 1));
return { name, args, result, error: result.startsWith('ERROR') };
}
case 'browser_close': {
const result = await browserClose(sessionId);
return { name, args, result, error: false };
}
case 'browser_get_images': {
const result = await browserGetImages(sessionId, {
url: args.url,
max_images: args.max_images,
min_size: args.min_size,
max_size: args.max_size,
image_types: args.image_types,
download: args.download,
save_metadata: args.save_metadata,
});
return { name, args, result, error: result.startsWith('ERROR') };
}
// Desktop automation tools
case 'desktop_screenshot': {
const result = await desktopScreenshot(sessionId);
return { name, args, result, error: result.startsWith('ERROR') };
}
case 'desktop_find_window': {
const result = await desktopFindWindow(String(args.name || ''));
return { name, args, result, error: result.startsWith('ERROR') };
}
case 'desktop_focus_window': {
const result = await desktopFocusWindow(String(args.name || ''));
return { name, args, result, error: result.startsWith('ERROR') };
}
case 'desktop_click': {
const result = await desktopClick(
Number(args.x),
Number(args.y),
String(args.button || 'left').toLowerCase() === 'right' ? 'right' : 'left',
args.double_click === true,
);
return { name, args, result, error: result.startsWith('ERROR') };
}
case 'desktop_drag': {
const result = await desktopDrag(
Number(args.from_x),
Number(args.from_y),
Number(args.to_x),
Number(args.to_y),
Number(args.steps || 20),
);
return { name, args, result, error: result.startsWith('ERROR') };
}
case 'desktop_wait': {
const result = await desktopWait(Number(args.ms || 500));
return { name, args, result, error: result.startsWith('ERROR') };
}
case 'desktop_type': {
const result = await desktopType(String(args.text || ''));
return { name, args, result, error: result.startsWith('ERROR') };
}
case 'desktop_press_key': {
const result = await desktopPressKey(String(args.key || 'Enter'));
return { name, args, result, error: result.startsWith('ERROR') };
}
case 'desktop_get_clipboard': {
const result = await desktopGetClipboard();
return { name, args, result, error: result.startsWith('ERROR') };
}
case 'desktop_set_clipboard': {
const result = await desktopSetClipboard(String(args.text || ''));
return { name, args, result, error: result.startsWith('ERROR') };
}
case 'memory_browse': {
const mbFile = String(args.file || 'user').toLowerCase().trim();
const mbFilename = mbFile === 'soul' ? 'SOUL.md' : 'USER.md';
const mbPath = resolvePromptPath(workspacePath, mbFilename);
if (!fs.existsSync(mbPath)) {
return { name, args, result: `${mbFilename} not found. Create it first.`, error: true };
}
const mbContent = fs.readFileSync(mbPath, 'utf-8');
const mbMatches = mbContent.match(/^## (.+)/gm) || [];
const mbCategories = mbMatches.map((m: string) => m.replace(/^## /, '').trim());
if (mbCategories.length === 0) {
return { name, args, result: `${mbFilename} has no categories yet. Use memory_write to create the first one.`, error: false };
}
return { name, args, result: `${mbFilename} categories:\n${mbCategories.map((c: string) => `- ${c}`).join('\n')}\n\nUse memory_write(file="${mbFile}", category="<name>", content="...") to add a fact.`, error: false };
}
case 'memory_write': {
const mwFile = String(args.file || 'user').toLowerCase().trim();
const mwCategory = String(args.category || '').trim().toLowerCase().replace(/\s+/g, '_');
const mwContent = String(args.content || '').trim();
if (!mwCategory) return { name, args, result: 'memory_write: category is required', error: true };
if (!mwContent) return { name, args, result: 'memory_write: content is required', error: true };
const mwFilename = mwFile === 'soul' ? 'SOUL.md' : 'USER.md';
const mwPath = resolvePromptPath(workspacePath, mwFilename);
if (!fs.existsSync(mwPath)) return { name, args, result: `${mwFilename} not found`, error: true };
let mwFileContent = fs.readFileSync(mwPath, 'utf-8');
const mwDate = new Date().toISOString().split('T')[0];
const mwEntry = `- ${mwContent} [${mwDate}]`;
const mwSectionHeader = `## ${mwCategory}`;
const mwSectionIdx = mwFileContent.indexOf(`\n${mwSectionHeader}`);
if (mwSectionIdx !== -1) {
// Section exists — find end of section, insert before next ## or EOF
const afterHeader = mwSectionIdx + mwSectionHeader.length + 1;
const nextSection = mwFileContent.indexOf('\n## ', afterHeader);
const insertAt = nextSection !== -1 ? nextSection : mwFileContent.length;
mwFileContent = mwFileContent.slice(0, insertAt) + '\n' + mwEntry + mwFileContent.slice(insertAt);
} else {
// New category — append before closing --- or at end
const closingComment = mwFileContent.lastIndexOf('\n---');
const insertAt = closingComment !== -1 ? closingComment : mwFileContent.length;
mwFileContent = mwFileContent.slice(0, insertAt) + '\n\n' + mwSectionHeader + '\n' + mwEntry + mwFileContent.slice(insertAt);
}
fs.writeFileSync(mwPath, mwFileContent, 'utf-8');
// Best-effort: also index in the vector store so it survives USER.md/SOUL.md's fixed
// char-cap truncation and can be retrieved by relevance later, not just recency. Never
// let a Chroma/embedding hiccup fail the primary (file) write.
addMemoryVector({
id: `${workspacePath}:${mwFile}:${mwCategory}:${Date.now()}`,
text: mwContent,
metadata: { workspace: workspacePath, file: mwFile, category: mwCategory, date: mwDate },
}).catch(err => console.warn('[memory_write] vector index failed (non-fatal):', err.message));
return { name, args, result: `Written to ${mwFilename} [${mwCategory}]: ${mwContent}`, error: false };
}
case 'memory_read': {
const mrFile = String(args.file || 'user').toLowerCase().trim();
const mrFilename = mrFile === 'soul' ? 'SOUL.md' : 'USER.md';
const mrPath = resolvePromptPath(workspacePath, mrFilename);
if (!fs.existsSync(mrPath)) return { name, args, result: `${mrFilename} not found`, error: true };
const mrContent = fs.readFileSync(mrPath, 'utf-8');
return { name, args, result: mrContent, error: false };
}
case 'write_note': {
const noteContent = String(args.content || '').trim();
if (!noteContent) {
return { name, args, result: 'write_note: empty content', error: true };
}
const noteTag = String(args.tag || args.step || 'general').trim();
const noteTaskId = args.task_id ? String(args.task_id) : null;
// Always write to intraday notes file (works in all sessions)
try {
const noteDate = new Date().toISOString().split('T')[0];
const memDir = path.join(workspacePath, 'memory');
if (!fs.existsSync(memDir)) fs.mkdirSync(memDir, { recursive: true });
const intradayFile = path.join(memDir, `${noteDate}-intraday-notes.md`);
const timestamp = new Date().toISOString();
let entry = `\n### [${noteTag.toUpperCase()}] ${timestamp}\n${noteContent}`;
if (noteTaskId) entry += `\n_Related task: ${noteTaskId}_`;
fs.appendFileSync(intradayFile, entry + '\n');
} catch (err: any) {
return { name, args, result: `write_note: failed to write intraday note: ${err.message}`, error: true };
}
// Also append to task journal if in a task session
const isTaskSession = String(sessionId || '').startsWith('task_');
if (isTaskSession) {
try {
const taskId = sessionId.replace(/^task_/, '');
const { appendJournal } = require('../tasks/task-store');
appendJournal(taskId, {
type: 'write_note',
content: `[${noteTag}] ${noteContent.slice(0, 300)}`,
detail: noteContent.slice(0, 2000),
});
} catch {}
}
return { name, args, result: `Note saved [${noteTag}] (${noteContent.length} chars) → intraday-notes`, error: false };
}
case 'create_presentation': {
try {
const pptxTool = getToolRegistry().get('create_presentation');
if (!pptxTool) return { name, args, result: 'PPTX tool not available', error: true };
const result = await pptxTool.execute({ ...args, _workspacePath: workspacePath, _sendSSE: sendSSE || undefined });
if (!result.success) {
console.error(`[v2] create_presentation failed: ${result.error}`);
}
return {
name,
args,
result: result.success ? (result.stdout || 'Presentation created.') : `PPTX error: ${result.error}`,
error: !result.success,
data: result.data,
};
} catch (e: any) {
console.error(`[v2] create_presentation exception: ${e.message}`);
return { name, args, result: `PPTX exception: ${e.message}`, error: true };
}
}
case 'edit_presentation': {
try {
const editTool = getToolRegistry().get('edit_presentation');
if (!editTool) return { name, args, result: 'PPTX edit tool not available', error: true };
const result = await editTool.execute({ ...args, _workspacePath: workspacePath, _sendSSE: sendSSE || undefined });
if (!result.success) {
console.error(`[v2] edit_presentation failed: ${result.error}`);
}
return {
name,
args,
result: result.success ? (result.stdout || 'Presentation updated.') : `PPTX edit error: ${result.error}`,
error: !result.success,
data: result.data,
};
} catch (e: any) {
console.error(`[v2] edit_presentation exception: ${e.message}`);
return { name, args, result: `PPTX edit exception: ${e.message}`, error: true };
}
}
default: {
// MCP tool: mcp__<serverId>__<toolName>
if (name.startsWith('mcp__')) {
const parts = name.split('__');
const serverId = parts[1];
const toolName = parts.slice(2).join('__');
try {
const result = await getMCPManager().callTool(serverId, toolName, args);
let text = result.content.map((c) => c.text ?? '').join('\n');
// dental-dict-sqlite: inject image markdown into tool result so model outputs it
if (serverId === 'dental-dict-sqlite' && toolName === 'query') {
try {
const rows: any[] = JSON.parse(text);
if (Array.isArray(rows)) {
const imgs = rows
.filter(r => r.image_url)
.map(r => `![${r.korean || r.english || '이미지'}](${r.image_url})`);
if (imgs.length > 0) {
text += '\n\n[IMAGES — include these in your response exactly as written]\n' + imgs.join('\n');
}
}
} catch {}
}
return { name, args, result: text, error: !!result.isError };
} catch (e: any) {
return { name, args, result: `MCP error: ${e.message}`, error: true };
}
}
// Fallback: delegate to registry. Any tool registered in tools/registry.ts
// works without an explicit case here. Custom-logic cases above (file ops,
// browser/desktop, memory, workflow, presentation) keep their own branches.
const tool = getToolRegistry().get(name);
if (!tool) return { name, args, result: `Unknown tool: ${name}`, error: true };
const tr = await tool.execute({ ...args, _workspace: workspacePath, _workspacePath: workspacePath });
const _resultText = tr.stdout || tr.error || '';
const _hasImageMd = /!\[[^\]]*\]\(\/api\/files\/[^)]+\)/.test(_resultText);
// Email attachments: push file cards via SSE so they always appear regardless of AI phrasing
if (name === 'email_read' && Array.isArray(tr.data?.savedRelPaths) && tr.data.savedRelPaths.length > 0) {
const links = (tr.data.savedRelPaths as string[]).map((p: string) => ({
name: path.basename(p),
url: '/api/files/' + p.replace(/\\/g, '/'),
}));
sendSSE?.('files', { links });
}
// weather_map_screenshot only ever runs in a weather-app session (gated in
// handle-chat.ts). The screenshot itself is for the model's own vision only —
// the visible map only updates when the raw Windy URL appears as text in the
// reply, and models unreliably skip re-emitting it on follow-up "move the map"
// requests. Push it via SSE so the map updates regardless of what the model writes.
if (name === 'weather_map_screenshot' && tr.success && typeof args?.url === 'string') {
sendSSE?.('token', { text: `\n${args.url}\n` });
}
// satellite_snapshot: observed the model re-typing the returned markdown image
// instead of copying it verbatim, dropping the /api/files/ prefix — the photo
// tab then never opens client-side. Push the tool's own (guaranteed-correct)
// markdown straight through so it never depends on the model reproducing it.
if (name === 'satellite_snapshot' && tr.success) {
const imgMatch = _resultText.match(/!\[[^\]]*\]\(\/api\/files\/satellite-images\/[^)]+\)/);
if (imgMatch) sendSSE?.('token', { text: `\n${imgMatch[0]}\n` });
}
return { name, args, result: _resultText, error: !tr.success, ...(_hasImageMd ? { isImage: true } : {}), ...(tr.data !== undefined ? { data: tr.data } : {}) };
}
}
} catch (err: any) {
return { name, args, result: `Error: ${err.message}`, error: true };
}
};
}