rootrework 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,391 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { createRequire } from 'node:module';
6
+ import { spawn } from 'node:child_process';
7
+ import { CHUNK_BYTES, finished, delay, fail, validateInput, integer, text, json, atomic, privateDir, executable, shells, shellArgs, alive, processIdentity, platformInfo } from './process.mjs';
8
+
9
+ const moduleRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
10
+ const S = { type: 'string' }, I = { type: 'integer', minimum: 0 }, B = { type: 'boolean' };
11
+ const taskId = { task_id: S };
12
+ function tool(name, description, properties, required = []) { const readOnly = ['system_info', 'task_get', 'task_events', 'task_list'].includes(name); return { name, description, inputSchema: { type: 'object', properties, required, additionalProperties: false }, annotations: { readOnlyHint: readOnly, destructiveHint: !readOnly && name !== 'search', openWorldHint: !readOnly } }; }
13
+ const execFields = { executable: S, args: { type: 'array', items: S }, cwd: S, env: { type: 'object', additionalProperties: { type: ['string', 'null'] } }, shell: S, command: S, stdin: S, stdin_encoding: { enum: ['utf8', 'base64'] }, timeout_ms: I, wait_ms: I, request_id: S };
14
+ const schemas = [
15
+ tool('system_info', 'Discover this device, platform, current runtime, executable shells and available backend features.', {}),
16
+ tool('filesystem', 'Read and modify native paths. Byte reads and writes are bounded with continuation. Offset writes accept exact replay and reject gaps. Use expected_size or expected_text to detect concurrent edits. A list cursor is a stable snapshot.', { operation: { enum: ['stat', 'list', 'read', 'write', 'append', 'edit', 'mkdir', 'copy', 'move', 'delete'] }, path: S, destination: S, cursor: S, limit: I, offset: I, length: I, encoding: { enum: ['utf8', 'base64'] }, data: S, expected_size: I, expected_text: S, old_text: S, new_text: S, expected_occurrences: I, recursive: B, overwrite: B, create_parents: B, mode: I }, ['operation']),
17
+ tool('search', 'Search names or UTF-8/UTF-16 text in a detached cancellable task. Continue using cursor; every page includes errors and explicit exclusions. Symlinks are followed only when requested and cycles are reported. Results persist across reconnects.', { path: S, query: S, mode: { enum: ['filename', 'content'] }, regex: B, case_sensitive: B, follow_symlinks: B, include_hidden: B, cursor: S, limit: I, wait_ms: I, request_id: S }, []),
18
+ tool('exec', 'Execute an executable plus exact argument array, or a command with an explicit shell. Native environment defaults are inherited; env adds or removes exact values. Optional stdin bytes are followed by EOF. Returns durable task state; output is available through task_events. Reuse request_id to reconcile an uncertain launch; commands are never automatically retried.', execFields),
19
+ tool('task_get', 'Get durable process state and output sizes. A lost supervisor is reported as unknown_outcome, never a successful completion.', { ...taskId, wait_ms: I }, ['task_id']),
20
+ tool('task_events', 'Read raw output by byte offset. Each stream has its own offset; base64 preserves arbitrary bytes and split UTF-8 sequences. Continue until the returned byte totals are reached.', { ...taskId, stream: { enum: ['stdout', 'stderr', 'terminal'] }, offset: I, length: I }, ['task_id']),
21
+ tool('task_list', 'List persistent tasks with pagination; command arguments and environment values are not included.', { cursor: S, limit: I }),
22
+ tool('task_cancel', 'Cancel the owned process tree. A transport timeout does not imply cancellation; inspect the returned task state.', { ...taskId, wait_ms: I }, ['task_id']),
23
+ tool('terminal_open', 'Open a real native PTY or ConPTY. Its supervisor survives agent reconnects. Use task_events with stream terminal for exact output and the returned task_id for reattachment.', { ...execFields, cols: { type: 'integer', minimum: 1 }, rows: { type: 'integer', minimum: 1 } }),
24
+ tool('terminal_input', 'Write exact text or base64 bytes to a live terminal. No newline is added automatically.', { ...taskId, data: S, encoding: { enum: ['utf8', 'base64'] }, request_id: S }, ['task_id', 'data']),
25
+ tool('terminal_resize', 'Resize the actual native terminal.', { ...taskId, cols: { type: 'integer', minimum: 1 }, rows: { type: 'integer', minimum: 1 } }, ['task_id', 'cols', 'rows']),
26
+ tool('terminal_interrupt', 'Send Ctrl-C to the native terminal foreground process group.', taskId, ['task_id']),
27
+ tool('terminal_close', 'Terminate this terminal and its owned process tree. Does not revoke a device or remove persisted results.', { ...taskId, wait_ms: I }, ['task_id'])
28
+ ];
29
+ function decode(data, encoding = 'utf8') {
30
+ text(data, 'data');
31
+ if (encoding !== 'utf8' && encoding !== 'base64') fail('INVALID_ARGUMENT', 'encoding must be utf8 or base64.');
32
+ if (encoding === 'base64' && !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data)) fail('INVALID_ARGUMENT', 'Malformed base64.');
33
+ const b = Buffer.from(data, encoding);
34
+ if (b.length > CHUNK_BYTES) fail('CHUNK_TOO_LARGE', `Use chunks of at most ${CHUNK_BYTES} bytes.`);
35
+ return b;
36
+ }
37
+ function cursor(value) { return Buffer.from(JSON.stringify(value)).toString('base64url'); }
38
+ function uncursor(value, kind) {
39
+ try { const v = JSON.parse(Buffer.from(text(value), 'base64url').toString()); if (v.kind !== kind || typeof v.id !== 'string' || !/^[\w-]+$/.test(v.id)) throw 0; integer(v.offset, 0); return v; }
40
+ catch { fail('INVALID_CURSOR', 'The continuation cursor is invalid.'); }
41
+ }
42
+ function metadata(s) { return { type: s.isSymbolicLink() ? 'symlink' : s.isDirectory() ? 'directory' : s.isFile() ? 'file' : 'other', size: s.size, mode: s.mode & 0o7777, modified_at: s.mtime.toISOString(), created_at: s.birthtime.toISOString(), uid: s.uid, gid: s.gid }; }
43
+ function safeId(id) { if (typeof id !== 'string' || !/^[\w-]{1,128}$/.test(id)) fail('INVALID_ARGUMENT', 'Invalid task identifier.'); return id; }
44
+ function stable(value) { if (Array.isArray(value)) return '[' + value.map(stable).join(',') + ']'; if (value && typeof value === 'object') return '{' + Object.keys(value).sort().map(k => JSON.stringify(k) + ':' + stable(value[k])).join(',') + '}'; return JSON.stringify(value); }
45
+ function commandIdentity(spec) { const { packageRoot, ...operation } = spec; return stable(operation); }
46
+ function sameCommand(saved, spec) {
47
+ if (saved === commandIdentity(spec)) return true;
48
+ try { return commandIdentity(JSON.parse(saved)) === commandIdentity(spec); } catch { return false; }
49
+ }
50
+ async function claimJSON(file) {
51
+ const until = Date.now() + 1500;
52
+ while (true) {
53
+ try { return await json(file); }
54
+ catch (e) { if (!(e instanceof SyntaxError)) throw e; if (Date.now() >= until) fail('UNKNOWN_OUTCOME', 'A prior operation record is incomplete; it was not retried.'); await delay(25); }
55
+ }
56
+ }
57
+
58
+ export function createBackend({ stateDir, packageRoot = moduleRoot, deviceId } = {}) {
59
+ if (!stateDir) fail('INVALID_ARGUMENT', 'stateDir is required.');
60
+ stateDir = path.resolve(stateDir); packageRoot = path.resolve(packageRoot);
61
+ const tasksDir = path.join(stateDir, 'tasks'), requestsDir = path.join(stateDir, 'requests'), cursorsDir = path.join(stateDir, 'cursors');
62
+ const ready = Promise.all([privateDir(tasksDir), privateDir(requestsDir), privateDir(cursorsDir)]);
63
+ const locks = new Map(), supervisors = new Map();
64
+ let closed = false, ptyAvailable = false;
65
+ try { createRequire(path.join(packageRoot, 'package.json'))('@lydell/node-pty'); ptyAvailable = true; } catch {}
66
+ const tools = schemas.filter(t => ptyAvailable || !t.name.startsWith('terminal_'));
67
+ const dirFor = id => path.join(tasksDir, safeId(id));
68
+ async function withLock(file, operation) {
69
+ const before = locks.get(file) || Promise.resolve();
70
+ const next = before.catch(() => {}).then(operation); locks.set(file, next);
71
+ try { return await next; } finally { if (locks.get(file) === next) locks.delete(file); }
72
+ }
73
+ async function get(id, wait = 0) {
74
+ const dir = dirFor(id); let state;
75
+ const until = Date.now() + integer(wait, 0, 10000);
76
+ do {
77
+ state = await json(path.join(dir, 'state.json'));
78
+ if (finished.has(state.state)) break;
79
+ const launch = await json(path.join(dir, 'launch.json')).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
80
+ const pid = state.supervisor_pid || launch?.supervisor_pid;
81
+ const running = pid ? await alive(pid, state.supervisor_identity || launch?.supervisor_identity) : await alive(launch?.launcher_pid, launch?.launcher_identity);
82
+ if (pid && !state.supervisor_pid) state.supervisor_pid = pid;
83
+ const stale = Date.now() - Date.parse(state.updated_at);
84
+ if (!running && stale > 3000) {
85
+ const final = await json(path.join(dir, 'state.json'));
86
+ if (finished.has(final.state)) { state = final; break; }
87
+ if (final.updated_at !== state.updated_at || final.state !== state.state || final.supervisor_pid && final.supervisor_pid !== pid) { state = final; continue; }
88
+ const failure = await json(path.join(dir, 'supervisor-error.json')).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
89
+ state = { ...state, state: 'unknown_outcome', observed_state: final.state, uncertainty: 'Supervisor is unavailable; command effects may have occurred. The command was not retried.', ...(failure ? { supervisor_error: failure } : {}), ...(launch?.exited_at ? { supervisor_exit: { code: launch.exit_code, signal: launch.signal, at: launch.exited_at } } : {}) }; break;
90
+ }
91
+ if (running && stale > 15000) state.heartbeat_stale = true;
92
+ if (Date.now() >= until) break;
93
+ await delay(Math.min(75, until - Date.now()));
94
+ } while (true);
95
+ for (const stream of ['stdout', 'stderr', 'terminal']) {
96
+ try { state.output_bytes ||= {}; state.output_bytes[stream] = (await fs.stat(path.join(dir, `${stream}.bin`))).size; } catch {}
97
+ }
98
+ const { supervisor_identity, ...visible } = state;
99
+ return visible;
100
+ }
101
+ async function launch(kind, args) {
102
+ const cwd = path.resolve(args.cwd === undefined ? process.cwd() : text(args.cwd, 'cwd'));
103
+ if (!(await fs.stat(cwd)).isDirectory()) fail('NOT_DIRECTORY', 'cwd is not a directory.');
104
+ let file, argv;
105
+ if (args.shell !== undefined) {
106
+ if (args.executable !== undefined || args.args !== undefined || args.command === undefined) fail('INVALID_ARGUMENT', 'Use shell plus command, or executable plus args.');
107
+ file = await executable(text(args.shell)); if (!file) fail('ENOENT', 'Selected shell is unavailable.');
108
+ argv = shellArgs(file, text(args.command, 'command'));
109
+ } else {
110
+ if (args.command !== undefined) fail('INVALID_ARGUMENT', 'command requires an explicit shell.');
111
+ file = args.executable === undefined && kind === 'terminal' ? (await shells())[0] : text(args.executable, 'executable');
112
+ if (!file) fail('ENOENT', 'No executable shell is available.');
113
+ argv = args.args === undefined ? [] : args.args;
114
+ if (!Array.isArray(argv)) fail('INVALID_ARGUMENT', 'args must be an array.');
115
+ argv = argv.map(a => text(a, 'argument'));
116
+ }
117
+ if (kind === 'terminal' && !ptyAvailable) fail('UNAVAILABLE', 'Native terminal support is not installed for this platform.');
118
+ const spec = { kind, executable: file, args: argv, cwd, env: args.env || {}, timeout_ms: integer(args.timeout_ms, 0), packageRoot };
119
+ if (args.stdin !== undefined) spec.stdin = decode(args.stdin, args.stdin_encoding).toString('base64');
120
+ if (kind === 'terminal') { spec.cols = integer(args.cols, 80, 32767); spec.rows = integer(args.rows, 24, 32767); if (!spec.cols || !spec.rows) fail('INVALID_ARGUMENT', 'Terminal dimensions must be positive.'); }
121
+ if (args.request_id !== undefined) {
122
+ text(args.request_id, 'request_id');
123
+ if (!args.request_id.length || Buffer.byteLength(args.request_id) > 128) fail('INVALID_ARGUMENT', 'request_id must contain 1 to 128 bytes.');
124
+ const key = path.join(requestsDir, Buffer.from(args.request_id).toString('base64url') + '.json');
125
+ return withLock(key, async () => {
126
+ try {
127
+ const previous = await claimJSON(key);
128
+ if (!sameCommand(previous.specification, spec)) fail('REQUEST_CONFLICT', 'request_id is already associated with a different operation.');
129
+ return { ...(await get(previous.task_id, args.wait_ms)), reused: true };
130
+ } catch (e) { if (e.code !== 'ENOENT') throw e; }
131
+ return start(spec, args, key);
132
+ });
133
+ }
134
+ return start(spec, args);
135
+ }
136
+ async function start(spec, args, requestFile) {
137
+ const id = randomUUID(), dir = dirFor(id), now = new Date().toISOString();
138
+ await privateDir(dir); await privateDir(path.join(dir, 'controls'));
139
+ await atomic(path.join(dir, 'spec.json'), spec);
140
+ await atomic(path.join(dir, 'state.json'), { task_id: id, device_id: deviceId, kind: spec.kind, state: 'queued', created_at: now, updated_at: now, output_bytes: { stdout: 0, stderr: 0, terminal: 0 }, cwd: spec.cwd, executable: spec.executable });
141
+ if (requestFile) {
142
+ try { const h = await fs.open(requestFile, 'wx', 0o600); try { await h.writeFile(JSON.stringify({ task_id: id, specification: commandIdentity(spec) })); await h.sync(); } finally { await h.close(); } }
143
+ catch (e) {
144
+ await fs.rm(dir, { recursive: true, force: true });
145
+ if (e.code !== 'EEXIST') throw e;
146
+ const previous = await claimJSON(requestFile);
147
+ if (!sameCommand(previous.specification, spec)) fail('REQUEST_CONFLICT', 'request_id is already associated with a different operation.');
148
+ return { ...(await get(previous.task_id, args.wait_ms)), reused: true };
149
+ }
150
+ }
151
+ let child;
152
+ try {
153
+ const launchFile = path.join(dir, 'launch.json');
154
+ await atomic(launchFile, { launcher_pid: process.pid, launcher_identity: await processIdentity(process.pid), created_at: new Date().toISOString() });
155
+ const updateLaunch = values => withLock(launchFile, async () => atomic(launchFile, { ...await json(launchFile), ...values }));
156
+ child = spawn(process.execPath, [path.join(moduleRoot, 'src', 'supervisor.mjs'), dir], { detached: true, stdio: 'ignore', windowsHide: true, cwd: spec.cwd });
157
+ const exited = new Promise(resolve => {
158
+ child.once('exit', (code, signal) => { void updateLaunch({ exit_code: code, signal, exited_at: new Date().toISOString() }).catch(() => {}).finally(resolve); });
159
+ child.once('error', resolve);
160
+ });
161
+ supervisors.set(id, { child, exited });
162
+ void exited.then(() => supervisors.delete(id));
163
+ await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
164
+ child.unref();
165
+ await updateLaunch({ supervisor_pid: child.pid, supervisor_identity: await processIdentity(child.pid), launched_at: new Date().toISOString() });
166
+ } catch (e) {
167
+ if (child?.pid) return { ...await get(id, args.wait_ms === undefined ? 100 : args.wait_ms), launch_warning: 'The supervisor started, but its launch identity could not be saved. Inspect this task before retrying.' };
168
+ const s = await json(path.join(dir, 'state.json'));
169
+ await atomic(path.join(dir, 'state.json'), { ...s, state: 'completed_failure', error: { code: e.code || 'SPAWN_FAILED', message: 'Supervisor could not start.' }, finished_at: new Date().toISOString() });
170
+ await fs.rm(path.join(dir, 'spec.json'), { force: true });
171
+ }
172
+ return get(id, args.wait_ms === undefined ? 100 : args.wait_ms);
173
+ }
174
+ async function events(args) {
175
+ const state = await get(args.task_id);
176
+ const stream = args.stream || (state.kind === 'terminal' ? 'terminal' : 'stdout');
177
+ if (!['stdout', 'stderr', 'terminal'].includes(stream)) fail('INVALID_ARGUMENT', 'Invalid output stream.');
178
+ const offset = integer(args.offset, 0), length = integer(args.length, CHUNK_BYTES, CHUNK_BYTES);
179
+ let bytes = Buffer.alloc(0), size = 0;
180
+ try {
181
+ const h = await fs.open(path.join(dirFor(args.task_id), `${stream}.bin`), 'r');
182
+ try { size = (await h.stat()).size; if (offset > size) fail('INVALID_OFFSET', 'Offset is beyond available output.'); const b = Buffer.alloc(Math.min(length, size - offset)); const read = await h.read(b, 0, b.length, offset); bytes = b.subarray(0, read.bytesRead); } finally { await h.close(); }
183
+ } catch (e) { if (e.code !== 'ENOENT') throw e; }
184
+ return { task_id: args.task_id, state: state.state, stream, offset, next_offset: offset + bytes.length, total_bytes: size, data: bytes.toString('base64'), encoding: 'base64', has_more: offset + bytes.length < size, complete: finished.has(state.state) && state.state !== 'unknown_outcome' && offset + bytes.length >= size, truncated: false };
185
+ }
186
+ async function control(args, operation, extra = {}) {
187
+ const state = await get(args.task_id);
188
+ if (finished.has(state.state)) return { task: state, accepted: false };
189
+ if (operation !== 'cancel' && state.kind !== 'terminal') fail('NOT_TERMINAL', 'This task is not a terminal.');
190
+ const id = args.request_id === undefined ? randomUUID() : safeId(args.request_id);
191
+ const dir = path.join(dirFor(args.task_id), 'controls');
192
+ const request = path.join(dir, `${id}.request.json`), resultFile = path.join(dir, `${id}.result.json`), claim = path.join(dir, `${id}.command.json`);
193
+ const specification = { operation, ...extra };
194
+ let created = false;
195
+ try { const h = await fs.open(claim, 'wx', 0o600); try { await h.writeFile(JSON.stringify(specification)); await h.sync(); created = true; } finally { await h.close(); } }
196
+ catch (e) { if (e.code !== 'EEXIST') throw e; if (stable(await claimJSON(claim)) !== stable(specification)) fail('REQUEST_CONFLICT', 'Control request_id already identifies a different input.'); }
197
+ if (created) await atomic(request, specification);
198
+ const until = Date.now() + 3000;
199
+ do {
200
+ try { const result = await json(resultFile); return { ...result, task_id: args.task_id, control_id: id, reused: !created, task: await get(args.task_id, args.wait_ms || 0) }; }
201
+ catch (e) { if (e.code !== 'ENOENT') throw e; }
202
+ if (Date.now() >= until) return { task_id: args.task_id, control_id: id, accepted: null, state: 'pending', task: await get(args.task_id) };
203
+ await delay(50);
204
+ } while (true);
205
+ }
206
+ async function paged(kind, values, args) {
207
+ let key, offset = 0;
208
+ if (args.cursor) { const c = uncursor(args.cursor, kind); key = c.id; offset = c.offset; values = await json(path.join(cursorsDir, `${key}.json`)); }
209
+ else { key = randomUUID(); await atomic(path.join(cursorsDir, `${key}.json`), values); }
210
+ const limit = integer(args.limit, 100, 1000);
211
+ if (!limit) fail('INVALID_ARGUMENT', 'limit must be positive.');
212
+ const entries = []; let size = 0;
213
+ for (const entry of values.slice(offset, offset + limit)) { const n = Buffer.byteLength(JSON.stringify(entry)); if (size + n > CHUNK_BYTES && entries.length) break; entries.push(entry); size += n; }
214
+ const next = offset + entries.length;
215
+ return { entries, total: values.length, cursor: next < values.length ? cursor({ kind, id: key, offset: next }) : null, snapshot: true, truncated: false };
216
+ }
217
+ async function filesystem(args) {
218
+ if (args.operation === 'list' && args.cursor) return paged('list', null, args);
219
+ const file = path.resolve(text(args.path, 'path'));
220
+ if (args.operation === 'stat') { const s = await fs.lstat(file); return { path: file, ...metadata(s), ...(s.isSymbolicLink() ? { target: await fs.readlink(file) } : {}) }; }
221
+ if (args.operation === 'list') {
222
+ const entries = [];
223
+ for await (const d of await fs.opendir(file)) {
224
+ const target = path.join(file, d.name);
225
+ try { entries.push({ name: d.name, path: target, ...metadata(await fs.lstat(target)) }); }
226
+ catch (e) { entries.push({ name: d.name, path: target, error: e.code }); }
227
+ }
228
+ entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
229
+ return { path: file, ...(await paged('list', entries, args)) };
230
+ }
231
+ if (args.operation === 'read') {
232
+ const offset = integer(args.offset, 0), length = integer(args.length, CHUNK_BYTES, CHUNK_BYTES);
233
+ const h = await fs.open(file, 'r');
234
+ try {
235
+ const before = await h.stat(); if (!before.isFile()) fail('NOT_FILE', 'Only regular file bytes can be read.');
236
+ if (offset > before.size) fail('INVALID_OFFSET', 'Offset is beyond the file.');
237
+ const buffer = Buffer.alloc(Math.min(length, before.size - offset)); const { bytesRead } = await h.read(buffer, 0, buffer.length, offset); const b = buffer.subarray(0, bytesRead);
238
+ const result = { path: file, offset, next_offset: offset + bytesRead, size: before.size, data: b.toString('base64'), encoding: 'base64', eof: offset + bytesRead >= before.size, modified_at: before.mtime.toISOString(), truncated: false };
239
+ if (args.encoding === 'utf8') {
240
+ try {
241
+ const value = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(b);
242
+ if (Buffer.byteLength(JSON.stringify(value)) <= 90000) { result.data = value; result.encoding = 'utf8'; }
243
+ else result.text_reason = 'JSON escaping exceeds the response budget; exact bytes are returned as base64.';
244
+ }
245
+ catch { result.text_reason = 'The byte chunk includes invalid or partial UTF-8. Exact bytes are returned as base64.'; }
246
+ } else if (args.encoding !== undefined && args.encoding !== 'base64') fail('INVALID_ARGUMENT', 'Invalid encoding.');
247
+ return result;
248
+ } finally { await h.close(); }
249
+ }
250
+ return withLock(file, async () => {
251
+ if (args.operation === 'mkdir') { await fs.mkdir(file, { recursive: !!args.recursive, mode: args.mode === undefined ? undefined : integer(args.mode, 0, 0o7777) }); return { path: file, ...(metadata(await fs.stat(file))) }; }
252
+ if (args.operation === 'delete') { await fs.rm(file, { recursive: !!args.recursive }); return { path: file, deleted: true }; }
253
+ if (args.operation === 'copy' || args.operation === 'move') {
254
+ const destination = path.resolve(text(args.destination, 'destination'));
255
+ if (file === destination || destination.startsWith(file + path.sep)) fail('INVALID_ARGUMENT', 'Destination must be outside the source.');
256
+ let exists = false; try { await fs.lstat(destination); exists = true; } catch (e) { if (e.code !== 'ENOENT') throw e; }
257
+ if (exists && !args.overwrite) fail('EEXIST', 'Destination exists.');
258
+ if (args.create_parents) await fs.mkdir(path.dirname(destination), { recursive: true });
259
+ if (args.operation === 'move') {
260
+ try { await fs.rename(file, destination); return { path: destination, moved: true }; }
261
+ catch (e) { if (e.code !== 'EXDEV') throw e; }
262
+ }
263
+ await fs.cp(file, destination, { recursive: !!args.recursive, force: !!args.overwrite, errorOnExist: !args.overwrite, preserveTimestamps: true, verbatimSymlinks: true });
264
+ if (args.operation === 'move') { await compareTree(file, destination); await fs.rm(file, { recursive: !!args.recursive }); }
265
+ return { path: destination, copied: true, moved: args.operation === 'move' };
266
+ }
267
+ if (!['write', 'append', 'edit'].includes(args.operation)) fail('INVALID_ARGUMENT', 'Unsupported filesystem operation.');
268
+ if (args.create_parents) await fs.mkdir(path.dirname(file), { recursive: true });
269
+ let current;
270
+ try { const descriptor = await fs.open(file, 'r'); try { current = await descriptor.stat({ bigint: true }); } finally { await descriptor.close(); } }
271
+ catch (e) { if (e.code !== 'ENOENT') throw e; }
272
+ if (current && !current.isFile()) fail('NOT_FILE', 'Target is not a regular file.');
273
+ if (args.expected_size !== undefined && BigInt(integer(args.expected_size)) !== (current?.size || 0n)) fail('REVISION_CONFLICT', 'File size changed.');
274
+ if (args.operation === 'edit') {
275
+ const original = await fs.readFile(file);
276
+ let before; try { before = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(original); } catch { fail('INVALID_ENCODING', 'Text editing requires valid UTF-8. Use byte writes for binary data.'); }
277
+ if (args.expected_text !== undefined && before !== text(args.expected_text)) fail('REVISION_CONFLICT', 'File content changed.');
278
+ const old = text(args.old_text, 'old_text'), replacement = text(args.new_text, 'new_text');
279
+ if (!old.length) fail('INVALID_ARGUMENT', 'old_text must not be empty.');
280
+ const occurrences = before.split(old).length - 1;
281
+ if (occurrences !== integer(args.expected_occurrences, 1)) fail('REVISION_CONFLICT', 'The number of matching occurrences differs from the expected count.');
282
+ await replaceContents(file, Buffer.from(before.split(old).join(replacement)), current, original);
283
+ return { path: file, replacements: occurrences, size: (await fs.stat(file)).size };
284
+ }
285
+ const bytes = decode(args.data, args.encoding);
286
+ if (args.operation === 'write' && args.offset === undefined) {
287
+ const expected = args.expected_text === undefined ? null : Buffer.from(text(args.expected_text));
288
+ await replaceContents(file, bytes, current, expected, args.mode === undefined ? 0o600 : integer(args.mode, 0, 0o7777));
289
+ return { path: file, bytes_written: bytes.length, size: bytes.length, next_offset: bytes.length, verified: (await fs.readFile(file)).equals(bytes) };
290
+ }
291
+ const offset = args.operation === 'append' ? Number(current?.size || 0n) : integer(args.offset);
292
+ const h = await fs.open(file, current ? 'r+' : 'wx+', current ? undefined : 0o600);
293
+ try {
294
+ const size = (await h.stat()).size;
295
+ if (args.expected_size !== undefined && size !== args.expected_size) fail('REVISION_CONFLICT', 'File size changed.');
296
+ if (offset > size) fail('INVALID_OFFSET', 'Chunk would leave a gap.');
297
+ let prefix = 0;
298
+ if (offset < size) {
299
+ const old = Buffer.alloc(Math.min(bytes.length, size - offset)); await h.read(old, 0, old.length, offset);
300
+ if (!old.equals(bytes.subarray(0, old.length))) fail('REVISION_CONFLICT', 'This offset contains different bytes.');
301
+ if (old.length === bytes.length) return { path: file, replayed: true, bytes_written: 0, next_offset: offset + bytes.length, size };
302
+ prefix = old.length;
303
+ }
304
+ let done = prefix; while (done < bytes.length) done += (await h.write(bytes, done, bytes.length - done, offset + done)).bytesWritten;
305
+ await h.sync();
306
+ return { path: file, bytes_written: done - prefix, resumed_bytes: prefix, next_offset: offset + done, size: (await h.stat()).size };
307
+ } finally { await h.close(); }
308
+ });
309
+ }
310
+ async function replaceContents(file, bytes, previous, expected, mode = 0o600) {
311
+ const h = await fs.open(file, previous ? 'r+' : 'wx+', mode);
312
+ try {
313
+ const now = await h.stat({ bigint: true });
314
+ if (previous && (now.dev !== previous.dev || now.ino !== previous.ino || now.size !== previous.size || now.mtimeNs !== previous.mtimeNs)) fail('REVISION_CONFLICT', 'File changed before writing.');
315
+ if (expected !== null && expected !== undefined) {
316
+ const actual = await h.readFile(); if (!actual.equals(expected)) fail('REVISION_CONFLICT', 'File content changed.');
317
+ }
318
+ let at = 0; while (at < bytes.length) at += (await h.write(bytes, at, bytes.length - at, at)).bytesWritten;
319
+ await h.truncate(bytes.length); await h.sync();
320
+ } finally { await h.close(); }
321
+ }
322
+ async function compareTree(source, target) {
323
+ const a = await fs.lstat(source), b = await fs.lstat(target);
324
+ if (a.isSymbolicLink()) { if (!b.isSymbolicLink() || await fs.readlink(source) !== await fs.readlink(target)) fail('VERIFY_FAILED', 'Copied link differs.'); }
325
+ else if (a.isDirectory()) {
326
+ const names = (await fs.readdir(source)).sort(); if (JSON.stringify(names) !== JSON.stringify((await fs.readdir(target)).sort())) fail('VERIFY_FAILED', 'Copied directory differs.');
327
+ for (const name of names) await compareTree(path.join(source, name), path.join(target, name));
328
+ } else {
329
+ if (a.size !== b.size || !a.isFile() || !b.isFile()) fail('VERIFY_FAILED', 'Copied file differs.');
330
+ const x = await fs.open(source), y = await fs.open(target);
331
+ try { for (let at = 0; at < a.size; at += CHUNK_BYTES) { const left = Buffer.alloc(Math.min(CHUNK_BYTES, a.size - at)), right = Buffer.alloc(left.length); await x.read(left, 0, left.length, at); await y.read(right, 0, right.length, at); if (!left.equals(right)) fail('VERIFY_FAILED', 'Copied bytes differ.'); } }
332
+ finally { await x.close(); await y.close(); }
333
+ }
334
+ }
335
+ async function search(args) {
336
+ let id, offset = 0;
337
+ if (args.cursor) { const c = uncursor(args.cursor, 'search'); id = c.id; offset = c.offset; }
338
+ else {
339
+ const options = { path: path.resolve(text(args.path, 'path')), query: text(args.query, 'query'), mode: args.mode || 'filename', regex: !!args.regex, case_sensitive: !!args.case_sensitive, follow_symlinks: !!args.follow_symlinks, include_hidden: args.include_hidden !== false };
340
+ if (!['filename', 'content'].includes(options.mode)) fail('INVALID_ARGUMENT', 'Invalid search mode.');
341
+ if (options.regex) { try { new RegExp(options.query, options.case_sensitive ? 'u' : 'iu'); } catch { fail('INVALID_ARGUMENT', 'Invalid regular expression.'); } }
342
+ const task = await launch('search', { executable: process.execPath, args: [path.join(moduleRoot, 'src', 'search-worker.mjs'), JSON.stringify(options)], request_id: args.request_id, wait_ms: args.wait_ms === undefined ? 250 : args.wait_ms });
343
+ id = task.task_id;
344
+ }
345
+ const task = await get(id, args.wait_ms || 0);
346
+ if (task.kind !== 'search') fail('INVALID_CURSOR', 'Cursor is not a search.');
347
+ const h = await fs.open(path.join(dirFor(id), 'stdout.bin'), 'r').catch(e => { if (e.code === 'ENOENT') return null; throw e; });
348
+ let entries = [], next = offset;
349
+ if (h) {
350
+ try {
351
+ const size = (await h.stat()).size, b = Buffer.alloc(Math.min(CHUNK_BYTES, Math.max(0, size - offset))); const read = await h.read(b, 0, b.length, offset); const buffer = b.subarray(0, read.bytesRead);
352
+ const limit = integer(args.limit, 100, 1000); if (!limit) fail('INVALID_ARGUMENT', 'limit must be positive.');
353
+ let from = 0;
354
+ while (entries.length < limit) { const end = buffer.indexOf(10, from); if (end < 0) break; entries.push(JSON.parse(buffer.subarray(from, end).toString('utf8'))); next = offset + end + 1; from = end + 1; }
355
+ } finally { await h.close(); }
356
+ }
357
+ const complete = finished.has(task.state) && next >= (task.output_bytes.stdout || 0);
358
+ return { task_id: id, state: task.state, entries, cursor: complete ? null : cursor({ kind: 'search', id, offset: next }), complete, truncated: false, error: task.error || null };
359
+ }
360
+ return {
361
+ tools,
362
+ async call(name, args = {}) {
363
+ await ready; if (closed) fail('CLOSED', 'Backend is closed.');
364
+ if (!tools.some(t => t.name === name)) fail('UNKNOWN_TOOL', 'Tool is not available on this device.');
365
+ if (!args || typeof args !== 'object' || Array.isArray(args)) fail('INVALID_ARGUMENT', 'Arguments must be an object.');
366
+ validateInput(args, tools.find(tool => tool.name === name).inputSchema);
367
+ if (name === 'system_info') return { device_id: deviceId, ...(await platformInfo()), features: { durable_tasks: true, native_terminal: ptyAvailable, byte_chunks: CHUNK_BYTES, terminal_survives_agent_restart: true, terminal_survives_os_reboot: false } };
368
+ if (name === 'filesystem') return filesystem(args);
369
+ if (name === 'search') return search(args);
370
+ if (name === 'exec' || name === 'terminal_open') return launch(name === 'exec' ? 'exec' : 'terminal', args);
371
+ if (name === 'task_get') return get(args.task_id, args.wait_ms);
372
+ if (name === 'task_events') return events(args);
373
+ if (name === 'task_list') { if (args.cursor) return paged('tasks', null, args); const list = []; for (const id of await fs.readdir(tasksDir)) { try { list.push(await get(id)); } catch {} } list.sort((a, b) => a.created_at.localeCompare(b.created_at)); return paged('tasks', list, args); }
374
+ if (name === 'task_cancel' || name === 'terminal_close') return control(args, name === 'task_cancel' ? 'cancel' : 'close');
375
+ if (name === 'terminal_input') return control(args, 'input', { data: decode(args.data, args.encoding).toString('base64') });
376
+ if (name === 'terminal_resize') { const cols = integer(args.cols, undefined, 32767), rows = integer(args.rows, undefined, 32767); if (!cols || !rows) fail('INVALID_ARGUMENT', 'Terminal dimensions must be positive.'); return control(args, 'resize', { cols, rows }); }
377
+ if (name === 'terminal_interrupt') return control(args, 'interrupt');
378
+ },
379
+ async close() {
380
+ closed = true; await ready;
381
+ await Promise.all([...supervisors].map(async ([id, { child, exited }]) => {
382
+ const state = await json(path.join(dirFor(id), 'state.json'));
383
+ if (finished.has(state.state)) {
384
+ child.ref();
385
+ try { await exited; } finally { child.unref(); }
386
+ }
387
+ }));
388
+ await Promise.allSettled(locks.values());
389
+ }
390
+ };
391
+ }