import fs from 'node:fs/promises'; import type { AgentTool } from '@earendil-works/pi-agent-core'; import { constants } from 'node:fs'; import { spawn } from 'node:child_process'; import { createCodingTools, type BashOperations } from '@earendil-works/pi-coding-agent'; const maxFile = 16 * 1024 * 1024; async function readFile(name: string): Promise { const file = await fs.open(name, constants.O_RDONLY | constants.O_NONBLOCK); try { const stat = await file.stat(); if (!stat.isFile() || stat.size > maxFile) throw new Error('Local read requires a regular file of at most 16 MiB; use shell ranges for large files.'); // Bound reads even if another local process grows the file concurrently. const buffer = Buffer.alloc(stat.size + 1); let offset = 0; while (offset < buffer.length) { const { bytesRead } = await file.read(buffer, offset, buffer.length - offset, offset); if (!bytesRead) break; offset += bytesRead; } if (offset > stat.size) throw new Error('Local file changed while reading.'); return buffer.subarray(0, offset); } finally { await file.close(); } } const operations: BashOperations = { exec: (command, cwd, options) => new Promise((resolve, reject) => { const child = spawn('/bin/bash', ['--noprofile', '--norc', '-c', command], { cwd, detached: true, env: { PATH: process.env.PATH, HOME: process.env.HOME, LANG: 'C.UTF-8' }, stdio: ['ignore', 'pipe', 'pipe'] }); let bytes = 0, capped = false, timedOut = false; const kill = () => { if (child.pid) { try { process.kill(-child.pid, 'SIGKILL'); } catch {} } }; const output = (data: Buffer) => { if (capped) return; const remaining = 1024 * 1024 - bytes; options.onData(data.subarray(0, remaining)); bytes += Math.min(data.length, remaining); if (data.length > remaining) { capped = true; options.onData(Buffer.from('\n[Local shell output capped at 1 MiB.]\n')); kill(); } }; child.stdout.on('data', output); child.stderr.on('data', output); options.signal?.addEventListener('abort', kill, { once: true }); if (options.signal?.aborted) kill(); const timer = setTimeout(() => { timedOut = true; kill(); }, Math.min(30000, (options.timeout ?? 30) * 1000)); child.on('error', () => reject(new Error('Local shell could not start.'))); child.on('close', code => { clearTimeout(timer); options.signal?.removeEventListener('abort', kill); if (options.signal?.aborted) reject(new Error('aborted')); else if (timedOut) reject(new Error(`timeout:${Math.min(30, options.timeout ?? 30)}`)); else resolve({ exitCode: capped ? 1 : code }); }); }), }; export function localTools(work: string): AgentTool[] { const tools = createCodingTools(work, { bash: { operations, exposeSessionEnvironment: false, shellPath: '/bin/bash' }, read: { operations: { readFile, access: p => fs.access(p, constants.R_OK), detectImageMimeType: async () => null } }, edit: { operations: { readFile, access: p => fs.access(p, constants.R_OK | constants.W_OK), writeFile: async (p, content) => { if (Buffer.byteLength(content) > maxFile) throw new Error('Local edit result exceeds 16 MiB.'); await fs.writeFile(p, content); } } }, }); const read = tools.find(t => t.name === 'read')!; read.description = 'Read regular text files up to 16 MiB. Output is truncated to 2000 lines or 50 KiB; use offset/limit for continuation. Use shell ranges for larger files. Image attachments are not supported by this text/tool model adapter.'; return tools; }