/** * Grok-class tools, sandboxed to granted workspace roots. * Excludes image/video generation. */ const path = require('path'); const fs = require('fs'); const sandbox = require('./sandbox.js'); const memory = require('./memory.js'); const toolSet = require('./tool-set.js'); const planMode = require('./plan-mode.js'); const todos = require('./todos.js'); const goalMod = require('./goal.js'); const sr = require('./search-replace.js'); const grepUtil = require('./grep-util.js'); const truncate = require('./truncate.js'); const MAX_READ = 400 * 1024; const MAX_GREP_HITS = 50; function readFileSafe(abs, offset, limit) { const st = fs.statSync(abs); if (st.isDirectory()) throw new Error('is a directory'); let buf = fs.readFileSync(abs); if (buf.length > MAX_READ) buf = buf.subarray(0, MAX_READ); let text = buf.toString('utf8'); const lines = text.split('\n'); const start = Math.max(0, (offset || 1) - 1); const end = limit ? start + limit : lines.length; const slice = lines.slice(start, end); const numbered = slice.map((l, i) => String(start + i + 1).padStart(6) + '| ' + l); return numbered.join('\n'); } function listDirSafe(abs, recursive) { const out = []; function walk(dir, depth) { let ents = []; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; } for (const ent of ents) { if (out.length >= 500) return; const child = path.join(dir, ent.name); out.push({ path: child, name: ent.name, isDirectory: typeof ent.isDirectory === 'function' ? ent.isDirectory() : false, isFile: typeof ent.isFile === 'function' ? ent.isFile() : false, }); if (recursive && depth < 8 && ent.isDirectory && ent.isDirectory()) walk(child, depth + 1); } } walk(abs, 0); return out; } function grepWalk(abs, re, hits, glob, relBase) { let ents = []; try { ents = fs.readdirSync(abs, { withFileTypes: true }); } catch (_) { return; } for (const ent of ents) { if (hits.length >= MAX_GREP_HITS) return; if (ent.name === 'node_modules' || ent.name === '.git') continue; const child = path.join(abs, ent.name); const rel = relBase ? relBase + '/' + ent.name : ent.name; try { if (ent.isDirectory && ent.isDirectory()) { grepWalk(child, re, hits, glob, rel); } else if (ent.isFile && ent.isFile()) { if (glob && !grepUtil.matchGlob(rel, glob)) continue; const st = fs.statSync(child); if (st.size > MAX_READ) continue; const text = fs.readFileSync(child, 'utf8'); const lines = text.split('\n'); for (let i = 0; i < lines.length; i++) { if (re.test(lines[i])) { hits.push({ path: child, line: i + 1, text: lines[i].slice(0, 240) }); if (hits.length >= MAX_GREP_HITS) return; } } } } catch (_) {} } } async function rgGrep(root, pattern, glob, timeoutMs) { let spawn; try { spawn = require('child_process').spawn; } catch (_) { return null; } const args = ['-n', '-i', '--no-heading', '--color', 'never', '-m', String(MAX_GREP_HITS)]; if (glob) args.push('--glob', String(glob)); args.push('--', String(pattern), root); return new Promise((resolve) => { let proc; try { proc = spawn('rg', args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] }); } catch (_) { resolve(null); return; } let stdout = ''; let stderr = ''; if (proc.stdout) { proc.stdout.on('data', (d) => { stdout += d.toString(); }); } if (proc.stderr) { proc.stderr.on('data', (d) => { stderr += d.toString(); }); } const t = setTimeout(() => { try { proc.kill(); } catch (_) {} resolve(null); }, timeoutMs || 15000); proc.on('exit', (code) => { clearTimeout(t); if (code !== 0 && code !== 1) { resolve(null); return; } const hits = []; const lines = stdout.split('\n'); for (const line of lines) { if (!line.trim() || hits.length >= MAX_GREP_HITS) break; const m = line.match(/^(.*?):(\d+):(.*)$/); if (!m) continue; hits.push({ path: m[1], line: Number(m[2]), text: m[3].slice(0, 240) }); } resolve({ hits, truncated: hits.length >= MAX_GREP_HITS, via: 'rg' }); }); proc.on('error', () => { clearTimeout(t); resolve(null); }); }); } function collectStream(stream, maxChars) { let buf = ''; if (!stream) return () => buf; const append = (chunk) => { buf += Buffer.isBuffer(chunk) || chunk instanceof Uint8Array ? Buffer.from(chunk).toString() : String(chunk); if (buf.length > maxChars) buf = buf.slice(-maxChars); }; if (typeof stream.on === 'function') stream.on('data', append); if (typeof stream.resume === 'function') stream.resume(); return () => buf; } function formatShellResult(result) { const exitCode = result && result.exitCode != null ? result.exitCode : 0; const stdout = String((result && result.stdout) || '').trimEnd(); const stderr = String((result && result.stderr) || '').trimEnd(); const parts = []; if (stdout) parts.push(stdout); if (stderr) parts.push(stderr); if (!parts.length) parts.push('(no output)'); parts.push('exit ' + String(exitCode)); return parts.join('\n'); } async function runShell(cwd, command, timeoutMs) { let spawn; try { spawn = require('child_process').spawn; } catch (_) { throw new Error('child_process not available'); } const isWin = process.platform === 'win32'; const cmd = isWin ? 'cmd.exe' : '/bin/sh'; const args = isWin ? ['/c', command] : ['-c', command]; return new Promise((resolve, reject) => { const proc = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); const readOut = collectStream(proc.stdout, 200000); const readErr = collectStream(proc.stderr, 80000); let settled = false; const t = setTimeout(() => { try { proc.kill(); } catch (_) {} finish(new Error('command timed out')); }, timeoutMs || 30000); const finish = (err, code) => { if (settled) return; settled = true; clearTimeout(t); if (err) reject(err); else resolve({ exitCode: code, stdout: readOut(), stderr: readErr() }); }; // Bare's subprocess emits `exit` before it resumes stdio pipes. Wait for // `close` so stdout/stderr are actually collected. if (typeof proc.on === 'function') { proc.on('close', (code) => finish(null, code)); proc.on('error', (err) => finish(err)); } else { finish(new Error('spawned process has no event API')); } }); } const SCHEMAS = [ { type: 'function', name: 'read_file', description: 'Read a text file with line numbers.', parameters: { type: 'object', properties: { path: { type: 'string' }, offset: { type: 'number' }, limit: { type: 'number' } }, required: ['path'] } }, { type: 'function', name: 'search_replace', description: 'Replace an exact string in a file. old_string must match once unless replace_all is true. Empty old_string creates a new file only if it does not already have content.', parameters: { type: 'object', properties: { path: { type: 'string' }, old_string: { type: 'string' }, new_string: { type: 'string' }, replace_all: { type: 'boolean' } }, required: ['path', 'new_string'] } }, { type: 'function', name: 'write_file', description: 'Create or overwrite a text file in the workspace. Use search_replace for small edits.', parameters: { type: 'object', properties: { path: { type: 'string' }, contents: { type: 'string' } }, required: ['path', 'contents'] } }, { type: 'function', name: 'grep', description: 'Search workspace files. Prefer ripgrep when available. output_mode: content | files_with_matches | count.', parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' }, glob: { type: 'string' }, output_mode: { type: 'string' } }, required: ['pattern'] } }, { type: 'function', name: 'list_dir', description: 'List a directory.', parameters: { type: 'object', properties: { path: { type: 'string' }, recursive: { type: 'boolean' } } } }, { type: 'function', name: 'run_terminal_cmd', description: 'Run a shell command in the workspace cwd.', parameters: { type: 'object', properties: { command: { type: 'string' }, timeout_ms: { type: 'number' } }, required: ['command'] } }, { type: 'function', name: 'todo_write', description: 'Merge or replace session todos. Status: pending | in_progress | completed | cancelled.', parameters: { type: 'object', properties: { todos: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, content: { type: 'string' }, status: { type: 'string', enum: ['pending', 'in_progress', 'completed', 'cancelled'] } } } }, merge: { type: 'boolean', description: 'If true (default), merge by id. If false, replace the list.' } }, required: ['todos'] } }, { type: 'function', name: 'web_search', description: 'Search the public web (DuckDuckGo HTML).', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } }, { type: 'function', name: 'web_fetch', description: 'Fetch any http or https URL as text, including public internet hosts.', parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } }, { type: 'function', name: 'memory_search', description: 'Search local agent memory notes.', parameters: { type: 'object', properties: { query: { type: 'string' } } } }, { type: 'function', name: 'memory_get', description: 'Read a memory note by name.', parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } }, { type: 'function', name: 'memory_write', description: 'Write a local agent memory note.', parameters: { type: 'object', properties: { name: { type: 'string' }, text: { type: 'string' } }, required: ['name', 'text'] } }, { type: 'function', name: 'enter_plan_mode', description: 'Switch to plan mode. Only plan.md is writable until the plan is approved.', parameters: { type: 'object', properties: {} } }, { type: 'function', name: 'exit_plan_mode', description: 'Present the plan for user approval and exit plan mode if approved.', parameters: { type: 'object', properties: {} } }, { type: 'function', name: 'update_goal', description: 'Update the active goal. Call with completed true when the objective is met, or blocked_reason if stuck.', parameters: { type: 'object', properties: { notes: { type: 'string' }, completed: { type: 'boolean' }, blocked_reason: { type: 'string' } } } }, { type: 'function', name: 'ask_user_question', description: 'Ask the user a structured question.', parameters: { type: 'object', properties: { question: { type: 'string' }, options: { type: 'array', items: { type: 'string' } } }, required: ['question'] } }, { type: 'function', name: 'task', description: 'Spawn a subagent with a focused prompt (same model). subagent_type: explore (read-only) or general (can write). Max 2 concurrent.', parameters: { type: 'object', properties: { prompt: { type: 'string' }, label: { type: 'string' }, subagent_type: { type: 'string' } }, required: ['prompt'] } }, { type: 'function', name: 'send_subagent_message', description: 'Send a follow-up message to a subagent task.', parameters: { type: 'object', properties: { task_id: { type: 'string' }, message: { type: 'string' } }, required: ['task_id', 'message'] } }, { type: 'function', name: 'get_task_output', description: 'Get status/output of a subagent task.', parameters: { type: 'object', properties: { task_id: { type: 'string' } }, required: ['task_id'] } }, { type: 'function', name: 'wait_tasks', description: 'Wait until subagent tasks finish (or timeout).', parameters: { type: 'object', properties: { timeout_ms: { type: 'number' } } } }, { type: 'function', name: 'kill_task', description: 'Mark a running subagent task as killed.', parameters: { type: 'object', properties: { task_id: { type: 'string' } }, required: ['task_id'] } }, { type: 'function', name: 'search_tool', description: 'Search registered MCP tools.', parameters: { type: 'object', properties: { query: { type: 'string' } } } }, { type: 'function', name: 'use_tool', description: 'Invoke an MCP tool by server__name.', parameters: { type: 'object', properties: { name: { type: 'string' }, arguments: { type: 'object' } }, required: ['name'] } }, ]; function defs(opts) { return toolSet.filterBuiltinSchemas(SCHEMAS, opts); } async function webSearch(query) { const net = require('../lib/net.js'); const url = 'https://html.duckduckgo.com/html/?q=' + encodeURIComponent(query); net.assertPublicHttpUrl(url); const res = await fetch(url, { headers: { 'user-agent': 'agent-harness/0.1' } }); const text = await res.text(); const hits = []; const re = /]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi; let m; while ((m = re.exec(text)) && hits.length < 8) { hits.push({ url: m[1], title: m[2].replace(/<[^>]+>/g, '').trim() }); } return hits; } async function webFetch(url) { const net = require('../lib/net.js'); net.assertHttpUrl(url); const res = await fetch(url, { headers: { 'user-agent': 'agent-harness/0.1' } }); let text = await res.text(); text = truncate.truncateWithMarker(text, 80000); return { status: res.status, url: String(res.url || url), text }; } async function execute(ctx, name, args) { const origin = ctx.origin; const cwd = ctx.cwd; args = args || {}; const blocked = planMode.gateWrite(ctx.planTracker || ctx.planMode, name, args); if (blocked) return blocked; if (toolSet.isHostWorkspaceTool(name) && ctx.hostWorkspace === false) { throw new Error('host workspace tools are disabled for this session'); } switch (name) { case 'read_file': { const abs = sandbox.resolvePath(origin, args.path, cwd); return readFileSafe(abs, args.offset, args.limit); } case 'search_replace': { const abs = sandbox.resolvePath(origin, args.path, cwd); let cur = ''; let existed = true; try { cur = fs.readFileSync(abs, 'utf8'); } catch (_) { cur = ''; existed = false; } const old = args.old_string || args.oldString || ''; const neu = args.new_string != null ? args.new_string : args.newString; if (neu == null) throw new Error('new_string required'); const applied = sr.applySearchReplace(cur, old, neu, !!(args.replace_all || args.replaceAll)); ensureParent(abs); fs.writeFileSync(abs, applied.text); const snippet = sr.contextSnippet(applied.text, neu, 3); return { path: abs, created: applied.created || !existed, replacements: applied.replacements, context: snippet, }; } case 'write_file': { const abs = sandbox.resolvePath(origin, args.path, cwd); const contents = args.contents != null ? String(args.contents) : args.content != null ? String(args.content) : ''; ensureParent(abs); fs.writeFileSync(abs, contents); return 'wrote ' + abs + ' (' + contents.length + ' bytes)'; } case 'grep': { const root = args.path ? sandbox.resolvePath(origin, args.path, cwd) : cwd; const glob = args.glob || args.include; const mode = args.output_mode || args.outputMode || 'content'; const viaRg = await rgGrep(root, args.pattern, glob); let hits; let truncated = false; let via = 'js'; if (viaRg && viaRg.hits) { hits = viaRg.hits; truncated = !!viaRg.truncated; via = 'rg'; } else { const re = new RegExp(args.pattern, 'i'); hits = []; grepWalk(root, re, hits, glob, ''); truncated = hits.length >= MAX_GREP_HITS; } const formatted = grepUtil.formatHits(hits, mode, truncated); formatted.via = via; return formatted; } case 'list_dir': { const abs = sandbox.resolvePath(origin, args.path || '.', cwd); return listDirSafe(abs, !!args.recursive); } case 'run_terminal_cmd': { if (!sandbox.isAllowed(origin, cwd)) throw new Error('cwd not allowlisted'); const command = String(args.command || '').trim(); if (!command) throw new Error('command required'); // HUD/CLI permission is the gate. The coding-agent allowlist would reject // desktop commands the user already approved (and Bare would then look // like a silent empty result). const raw = await runShell(cwd, command, args.timeout_ms || args.timeoutMs); return formatShellResult(raw); } case 'todo_write': { const mode = args.merge === false || args.replace === true ? 'replace' : 'merge'; ctx.session.plan = todos.merge(ctx.session.plan, args.todos || [], mode); require('./sessions.js').saveSummary(ctx.session); return { ok: true, todos: ctx.session.plan }; } case 'web_search': return webSearch(args.query); case 'web_fetch': return webFetch(args.url); case 'memory_search': return memory.search(origin, args.query); case 'memory_get': return memory.readNote(origin, args.name); case 'memory_write': { const file = memory.writeNote(origin, args.name, args.text != null ? args.text : args.content); return { ok: true, file }; } case 'enter_plan_mode': { const tracker = ctx.planTracker || planMode.create(ctx.session && ctx.session.planMode); planMode.activate(tracker); ctx.planTracker = tracker; ctx.planMode = true; if (ctx.session) { ctx.session.planMode = planMode.snapshot(tracker); require('./sessions.js').saveSummary(ctx.session); } return { type: 'enter_plan_mode', planMode: planMode.snapshot(tracker) }; } case 'exit_plan_mode': return { type: 'exit_plan_mode' }; case 'ask_user_question': return { type: 'ask_user', question: args.question, options: args.options || [] }; case 'update_goal': { const g = (ctx.session && ctx.session.goal) || goalMod.create(''); if (args.notes) g.notes = String(args.notes); if (ctx.session) ctx.session.goal = g; if (args.blocked_reason) { g.status = 'blocked'; g.blockedReason = String(args.blocked_reason); if (ctx.session) require('./sessions.js').saveSummary(ctx.session); return { type: 'goal_blocked', goal: goalMod.snapshot(g), blocked_reason: g.blockedReason }; } if (args.completed) { if (todos.hasOpen(ctx.session && ctx.session.plan)) { return { error: 'Goal not complete: todos are still pending or in_progress. Finish or cancel them before update_goal({ completed: true }).', todos: ctx.session && ctx.session.plan, }; } g.status = 'verifying'; if (ctx.session) require('./sessions.js').saveSummary(ctx.session); return { type: 'goal_completed', goal: goalMod.snapshot(g), verify: g.verify !== false }; } if (ctx.session) require('./sessions.js').saveSummary(ctx.session); return { ok: true, goal: goalMod.snapshot(g) }; } case 'send_subagent_message': return require('./tasks.js').appendMessage(args.task_id || args.taskId, args.message); case 'get_task_output': return require('./tasks.js').get(args.task_id || args.taskId); case 'wait_tasks': return require('./tasks.js').waitAll({ timeoutMs: args.timeout_ms || args.timeoutMs }); case 'kill_task': return require('./tasks.js').kill(args.task_id || args.taskId); case 'search_tool': return require('./mcp.js').search(args.query); case 'use_tool': return require('./mcp.js').call(args.name, args.arguments || args.args || {}); default: throw new Error('unknown tool: ' + name); } } function ensureParent(abs) { const dir = path.dirname(abs); try { fs.mkdirSync(dir, { recursive: true }); } catch (_) {} } module.exports = { defs, execute, SCHEMAS, HOST_WORKSPACE_TOOLS: toolSet.HOST_WORKSPACE_TOOLS, runShell, formatShellResult };