@lardexx/arenacode 0.0.0-stage → 2.5.3

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.
package/src/config.js ADDED
@@ -0,0 +1,141 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const { execFileSync } = require('node:child_process');
8
+
9
+ const VERSION = '2.5.2';
10
+ const CLIENT_NAME = `ArenaCode ${VERSION}`;
11
+
12
+ /**
13
+ * Config file location. Mirrors the desktop client (which uses
14
+ * %APPDATA%\ArenaCode\config.ini) but stores JSON so it stays portable
15
+ * between Windows, macOS and Linux.
16
+ */
17
+ function configPath() {
18
+ if (process.env.ARENACODE_CONFIG) return path.resolve(process.env.ARENACODE_CONFIG);
19
+ if (process.platform === 'win32') {
20
+ const base = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
21
+ return path.join(base, 'ArenaCode', 'config.json');
22
+ }
23
+ return path.join(os.homedir(), '.arenacode', 'config.json');
24
+ }
25
+
26
+ const DEFAULTS = {
27
+ url: '',
28
+ key: '',
29
+ folder: '',
30
+ lang: 'it',
31
+ mode: 'agent',
32
+ pc_access: true,
33
+ device: '',
34
+ vision: false,
35
+ idle_timeout: 180,
36
+ };
37
+
38
+ function readConfig() {
39
+ const file = configPath();
40
+ try {
41
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
42
+ return { ...DEFAULTS, ...raw };
43
+ } catch {
44
+ return { ...DEFAULTS };
45
+ }
46
+ }
47
+
48
+ function writeConfig(cfg) {
49
+ const file = configPath();
50
+ fs.mkdirSync(path.dirname(file), { recursive: true });
51
+ const clean = { ...DEFAULTS, ...cfg };
52
+ fs.writeFileSync(file, JSON.stringify(clean, null, 2), 'utf8');
53
+ try {
54
+ fs.chmodSync(file, 0o600);
55
+ } catch {
56
+ /* Windows: no POSIX modes */
57
+ }
58
+ return file;
59
+ }
60
+
61
+ function patchConfig(patch) {
62
+ return writeConfig({ ...readConfig(), ...patch });
63
+ }
64
+
65
+ /**
66
+ * X-Arena-Device: the desktop client sends the Windows MachineGuid, which is what
67
+ * binds an API key to one PC. We use the same value where it exists, and fall back
68
+ * to the machine-id / a generated id elsewhere. Must match ^[A-Za-z0-9-]{8,128}$.
69
+ */
70
+ function readMachineGuid() {
71
+ if (process.platform === 'win32') {
72
+ try {
73
+ const out = execFileSync('reg', ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid'], {
74
+ encoding: 'utf8',
75
+ windowsHide: true,
76
+ timeout: 5000,
77
+ });
78
+ const match = /MachineGuid\s+REG_SZ\s+([0-9a-fA-F-]{8,})/.exec(out);
79
+ if (match) return match[1];
80
+ } catch {
81
+ /* fall through */
82
+ }
83
+ }
84
+ try {
85
+ const id = fs.readFileSync('/etc/machine-id', 'utf8').trim();
86
+ if (id) return id;
87
+ } catch {
88
+ /* fall through */
89
+ }
90
+ return '';
91
+ }
92
+
93
+ function deviceId(cfg = readConfig()) {
94
+ const valid = (v) => /^[A-Za-z0-9-]{8,128}$/.test(String(v || ''));
95
+ if (valid(cfg.device)) return cfg.device;
96
+ const guid = readMachineGuid();
97
+ if (valid(guid)) {
98
+ try {
99
+ patchConfig({ device: guid });
100
+ } catch {
101
+ /* read-only config: keep it in memory */
102
+ }
103
+ return guid;
104
+ }
105
+ const generated = crypto.createHash('sha256').update(`${os.hostname()}:${os.userInfo().username}`).digest('hex').slice(0, 32);
106
+ try {
107
+ patchConfig({ device: generated });
108
+ } catch {
109
+ /* ignore */
110
+ }
111
+ return generated;
112
+ }
113
+
114
+ /** Bridge base URL: --url flag, then ARENACODE_URL, then config. */
115
+ function resolveUrl(cfg, flagUrl) {
116
+ const url = String(flagUrl || process.env.ARENACODE_URL || cfg.url || '').trim().replace(/\/+$/, '');
117
+ return url;
118
+ }
119
+
120
+ function resolveKey(cfg, flagKey) {
121
+ return String(flagKey || process.env.ARENACODE_KEY || cfg.key || '').trim();
122
+ }
123
+
124
+ function openFolder(cfg, flagFolder) {
125
+ const folder = path.resolve(flagFolder || process.env.ARENACODE_FOLDER || cfg.folder || process.cwd());
126
+ return folder;
127
+ }
128
+
129
+ module.exports = {
130
+ VERSION,
131
+ CLIENT_NAME,
132
+ DEFAULTS,
133
+ configPath,
134
+ readConfig,
135
+ writeConfig,
136
+ patchConfig,
137
+ deviceId,
138
+ resolveUrl,
139
+ resolveKey,
140
+ openFolder,
141
+ };
package/src/local.js ADDED
@@ -0,0 +1,507 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const { spawn, execFileSync } = require('node:child_process');
7
+
8
+ const { c, ask, askKey, write, indentLines } = require('./ui');
9
+
10
+ const MAX_READ_BYTES = 2 * 1024 * 1024;
11
+ const MAX_MATCHES = 200;
12
+ const SKIP_DIRS = new Set(['.git', 'node_modules', '.venv', 'venv', '__pycache__', 'dist', 'build', 'target', '.next']);
13
+
14
+ const REFUSED = {
15
+ it: "[ArenaCode] Comando non eseguito: rifiutato dall'utente",
16
+ en: '[ArenaCode] Command not run: rejected by the user',
17
+ es: '[ArenaCode] Comando no ejecutado: rechazado por el usuario',
18
+ };
19
+
20
+ function t(lang, it, en, es) {
21
+ if (lang === 'en') return en;
22
+ if (lang === 'es') return es;
23
+ return it;
24
+ }
25
+
26
+ /** Resolve a path inside the open folder, refusing escapes and absolute paths. */
27
+ function resolveInRoot(root, rel) {
28
+ const clean = String(rel || '').replace(/\\/g, '/').trim();
29
+ const abs = path.isAbsolute(clean) ? clean : path.resolve(root, clean);
30
+ const rootResolved = path.resolve(root);
31
+ if (abs !== rootResolved && !abs.startsWith(rootResolved + path.sep)) {
32
+ throw new Error(`path outside the open folder: ${rel}`);
33
+ }
34
+ return abs;
35
+ }
36
+
37
+ function relativize(root, abs) {
38
+ const rel = path.relative(root, abs).replace(/\\/g, '/');
39
+ return rel === '' ? '.' : rel;
40
+ }
41
+
42
+ // --------------------------------------------------------------------------- //
43
+ // Local terminal (local-exec)
44
+ // --------------------------------------------------------------------------- //
45
+
46
+ function shellCommand(shell, command) {
47
+ if (process.platform === 'win32') {
48
+ switch (shell) {
49
+ case 'cmd':
50
+ return { file: 'cmd.exe', args: ['/d', '/s', '/c', command] };
51
+ case 'wsl':
52
+ return { file: 'wsl.exe', args: ['sh', '-lc', command] };
53
+ case 'powershell':
54
+ default:
55
+ return {
56
+ file: shell === 'pwsh' ? 'pwsh.exe' : 'powershell.exe',
57
+ args: ['-NoProfile', '-NonInteractive', '-Command', command],
58
+ };
59
+ }
60
+ }
61
+ return { file: '/bin/sh', args: ['-lc', command] };
62
+ }
63
+
64
+ /** Runs a command and collects stdout+stderr and the exit code. */
65
+ function runShell(command, { cwd, shell, quiet = false } = {}) {
66
+ const { file, args } = shellCommand(shell, command);
67
+ return new Promise((resolve) => {
68
+ let out = '';
69
+ let child;
70
+ try {
71
+ child = spawn(file, args, { cwd, windowsHide: true, env: process.env });
72
+ } catch (e) {
73
+ resolve({ output: `${e.message}\n`, code: 127 });
74
+ return;
75
+ }
76
+ child.stdout.on('data', (d) => {
77
+ out += d.toString();
78
+ // in the TUI the output is rendered inside the card, never written raw to the screen
79
+ if (!quiet && !process.env.ARENACODE_QUIET_STREAM) process.stdout.write(c.gray(d.toString()));
80
+ });
81
+ child.stderr.on('data', (d) => {
82
+ out += d.toString();
83
+ if (!quiet) process.stderr.write(c.gray(d.toString()));
84
+ });
85
+ child.on('error', (e) => resolve({ output: `${out}${e.message}\n`, code: 127 }));
86
+ child.on('close', (code) => resolve({ output: out, code: code === null ? 1 : code }));
87
+ });
88
+ }
89
+
90
+ async function handleLocalExec(bridge, taskId, request, opts) {
91
+ const { rid, command, cwd: reqCwd, shell, admin, sandbox_reason: sandboxReason } = request;
92
+ const lang = opts.lang;
93
+ const dir = reqCwd ? resolveInRoot(opts.folder, reqCwd) : opts.folder;
94
+
95
+ write('');
96
+ write(` ${c.bold(c.yellow('⌘ terminal'))} ${c.dim('·')} ${c.cyan(shell || 'shell')} ${c.dim('·')} ${c.gray(dir)}`);
97
+ write(indentLines(command, ' '));
98
+ if (admin) write(` ${c.yellow('!')} ${t(lang, 'Richiede i permessi di amministratore: qui il comando gira senza UAC.', 'Needs administrator rights: here it runs without UAC.', 'Necesita permisos de administrador: aquí se ejecuta sin UAC.')}`);
99
+ if (sandboxReason) write(` ${c.yellow('!')} ${t(lang, 'L’agente chiede di installare sul PC', 'The agent asks to install on the PC', 'El agente pide instalar en el PC')}: ${c.gray(sandboxReason)}`);
100
+
101
+ let approved = opts.yes === true;
102
+ if (!approved) {
103
+ const key = await askKey(` ${t(lang, 'Eseguo? [r] sì [d] no', 'Run it? [r] yes [d] no', '¿Ejecuto? [r] sí [d] no')} `, ['r', 'd']);
104
+ approved = key === 'r';
105
+ }
106
+ if (!approved) {
107
+ await bridge.localResult(taskId, { rid, output: REFUSED[lang] || REFUSED.it, code: -1 });
108
+ write(` ${c.red('✗')} ${t(lang, 'rifiutato', 'rejected', 'rechazado')}`);
109
+ return true;
110
+ }
111
+
112
+ const { output, code } = await runShell(command, { cwd: dir, shell });
113
+ await bridge.localResult(taskId, { rid, output, code });
114
+ write(` ${code === 0 ? c.green('✓') : c.red('✗')} ${c.gray(`exit ${code}`)}`);
115
+ return true;
116
+ }
117
+
118
+ // --------------------------------------------------------------------------- //
119
+ // Local files (local-fs)
120
+ // --------------------------------------------------------------------------- //
121
+
122
+ function looksBinary(buffer) {
123
+ const slice = buffer.subarray(0, 4096);
124
+ for (const byte of slice) if (byte === 0) return true;
125
+ return false;
126
+ }
127
+
128
+ function walk(root, dir, depth, out, maxDepth) {
129
+ if (depth > maxDepth) return;
130
+ let entries;
131
+ try {
132
+ entries = fs.readdirSync(dir, { withFileTypes: true });
133
+ } catch {
134
+ return;
135
+ }
136
+ for (const entry of entries) {
137
+ if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue;
138
+ const abs = path.join(dir, entry.name);
139
+ out.push({ abs, dir: entry.isDirectory() });
140
+ if (entry.isDirectory() && out.length < 5000) walk(root, abs, depth + 1, out, maxDepth);
141
+ }
142
+ }
143
+
144
+ function globToRegex(glob) {
145
+ const escaped = String(glob).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*').replace(/\?/g, '.');
146
+ return new RegExp(`^${escaped}$`, 'i');
147
+ }
148
+
149
+ function git(args, cwd) {
150
+ try {
151
+ const out = execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
152
+ return { ok: true, output: out };
153
+ } catch (e) {
154
+ return { ok: false, output: `${(e.stdout || '') + (e.stderr || '') || e.message}`.trim(), code: e.status };
155
+ }
156
+ }
157
+
158
+ /** Executes one local-fs operation and returns the JSON the agent expects. */
159
+ function runFsOp(op, body, root) {
160
+ const rel = body.path || '';
161
+ const abs = rel ? resolveInRoot(root, rel) : root;
162
+
163
+ switch (op) {
164
+ case 'ls': {
165
+ const entries = [];
166
+ const target = abs;
167
+ if (!fs.existsSync(target)) return { ok: false, error: `not found: ${rel}` };
168
+ const maxDepth = Number.isFinite(body.depth) ? body.depth : 1;
169
+ const list = [{ abs: target, dir: true }];
170
+ for (let d = 0; d < maxDepth; d += 1) {
171
+ const snapshot = list.slice();
172
+ for (const item of snapshot) {
173
+ if (!item.dir) continue;
174
+ let children = [];
175
+ try {
176
+ children = fs.readdirSync(item.abs, { withFileTypes: true });
177
+ } catch {
178
+ continue;
179
+ }
180
+ for (const child of children) {
181
+ if (child.name.startsWith('.') || SKIP_DIRS.has(child.name)) continue;
182
+ list.push({ abs: path.join(item.abs, child.name), dir: child.isDirectory() });
183
+ }
184
+ }
185
+ }
186
+ const seen = new Set();
187
+ for (const item of list.slice(1)) {
188
+ if (seen.has(item.abs)) continue;
189
+ seen.add(item.abs);
190
+ let size = 0;
191
+ try {
192
+ size = item.dir ? 0 : fs.statSync(item.abs).size;
193
+ } catch {
194
+ /* ignore */
195
+ }
196
+ entries.push({ path: relativize(root, item.abs), name: path.basename(item.abs), dir: item.dir, size });
197
+ }
198
+ return { ok: true, path: rel || '.', root: path.basename(root), entries, truncated: false };
199
+ }
200
+
201
+ case 'stat': {
202
+ if (!fs.existsSync(abs)) return { ok: false, error: `not found: ${rel}` };
203
+ const st = fs.statSync(abs);
204
+ return { ok: true, path: rel, dir: st.isDirectory(), size: st.isDirectory() ? 0 : st.size, mtime: Math.floor(st.mtimeMs / 1000) };
205
+ }
206
+
207
+ case 'read':
208
+ case 'pull': {
209
+ if (!fs.existsSync(abs)) return { ok: false, error: `not found: ${rel}` };
210
+ const st = fs.statSync(abs);
211
+ if (st.isDirectory()) return { ok: false, error: `is a directory: ${rel}` };
212
+ const raw = fs.readFileSync(abs);
213
+ const binary = looksBinary(raw);
214
+ const truncated = raw.length > MAX_READ_BYTES;
215
+ const slice = truncated ? raw.subarray(0, MAX_READ_BYTES) : raw;
216
+ return {
217
+ ok: true,
218
+ path: rel,
219
+ size: st.size,
220
+ binary,
221
+ truncated,
222
+ content: binary ? undefined : slice.toString('utf8'),
223
+ b64: binary ? slice.toString('base64') : undefined,
224
+ };
225
+ }
226
+
227
+ case 'write': {
228
+ const data = body.b64 ? Buffer.from(String(body.content || ''), 'base64') : Buffer.from(String(body.content ?? ''), 'utf8');
229
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
230
+ if (body.append) fs.appendFileSync(abs, data);
231
+ else fs.writeFileSync(abs, data);
232
+ return { ok: true, path: rel, size: data.length };
233
+ }
234
+
235
+ case 'replace': {
236
+ if (!fs.existsSync(abs)) return { ok: false, error: `not found: ${rel}` };
237
+ const before = fs.readFileSync(abs, 'utf8');
238
+ const oldText = String(body.old ?? '');
239
+ const newText = String(body.new ?? '');
240
+ if (!oldText) return { ok: false, error: 'old is empty' };
241
+ if (!before.includes(oldText)) return { ok: false, error: 'old text not found in the file', path: rel };
242
+ const count = before.split(oldText).length - 1;
243
+ const after = body.all ? before.split(oldText).join(newText) : before.replace(oldText, newText);
244
+ fs.writeFileSync(abs, after, 'utf8');
245
+ return { ok: true, path: rel, replaced: body.all ? count : 1, size: Buffer.byteLength(after) };
246
+ }
247
+
248
+ case 'mkdir':
249
+ fs.mkdirSync(abs, { recursive: true });
250
+ return { ok: true, path: rel };
251
+
252
+ case 'mv': {
253
+ const dest = resolveInRoot(root, body.to);
254
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
255
+ fs.renameSync(abs, dest);
256
+ return { ok: true, path: rel, to: body.to };
257
+ }
258
+
259
+ case 'rm': {
260
+ if (abs === path.resolve(root)) return { ok: false, error: 'refusing to delete the open folder' };
261
+ if (!fs.existsSync(abs)) return { ok: false, error: `not found: ${rel}` };
262
+ fs.rmSync(abs, { recursive: Boolean(body.recursive) || fs.statSync(abs).isDirectory(), force: false });
263
+ return { ok: true, path: rel };
264
+ }
265
+
266
+ case 'find': {
267
+ const pattern = body.pattern ? new RegExp(String(body.pattern), 'i') : null;
268
+ const glob = body.glob ? globToRegex(body.glob) : null;
269
+ const max = Number.isFinite(body.max) ? body.max : MAX_MATCHES;
270
+ const files = [];
271
+ walk(root, abs, 0, files, 12);
272
+ const matches = [];
273
+ for (const entry of files) {
274
+ if (entry.dir) continue;
275
+ const relPath = relativize(root, entry.abs);
276
+ if (glob && !glob.test(path.basename(entry.abs)) && !glob.test(relPath)) continue;
277
+ if (!pattern) {
278
+ matches.push({ path: relPath });
279
+ if (matches.length >= max) return { ok: true, matches, truncated: true };
280
+ continue;
281
+ }
282
+ let text = '';
283
+ try {
284
+ const buf = fs.readFileSync(entry.abs);
285
+ if (looksBinary(buf)) continue;
286
+ text = buf.toString('utf8');
287
+ } catch {
288
+ continue;
289
+ }
290
+ const lines = text.split('\n');
291
+ for (let i = 0; i < lines.length; i += 1) {
292
+ if (pattern.test(lines[i])) {
293
+ matches.push({ path: relPath, line: i + 1, text: lines[i].slice(0, 400) });
294
+ if (matches.length >= max) return { ok: true, matches, truncated: true };
295
+ }
296
+ }
297
+ }
298
+ return { ok: true, matches, truncated: false };
299
+ }
300
+
301
+ // --- git ---------------------------------------------------------------
302
+ case 'git_status': {
303
+ const r = git(['status', '--short', '--branch'], root);
304
+ return { ok: r.ok, output: r.output };
305
+ }
306
+ case 'git_log': {
307
+ const args = ['log', '--pretty=%h %s', '-n', String(body.count || 15)];
308
+ if (body.ref) args.push(`--grep=${body.ref}`, '-i');
309
+ const r = git(args, root);
310
+ return { ok: r.ok, output: r.output, commits: r.output.split('\n').filter(Boolean).map((l) => l.trim()) };
311
+ }
312
+ case 'git_diff': {
313
+ const args = ['diff'];
314
+ if (body.ref) args.push(`${body.ref}~1`, body.ref);
315
+ if (body.path) args.push('--', body.path);
316
+ const r = git(args, root);
317
+ return { ok: r.ok, output: r.output };
318
+ }
319
+ case 'git_commit': {
320
+ const add = git(['add', '-A'], root);
321
+ if (!add.ok) return { ok: false, error: add.output };
322
+ const commit = git(['commit', '-m', String(body.content || 'ArenaCode')], root);
323
+ return { ok: commit.ok, output: commit.output };
324
+ }
325
+ case 'git_revert': {
326
+ const r = git(['revert', '--no-edit', String(body.ref)], root);
327
+ return { ok: r.ok, output: r.output };
328
+ }
329
+ case 'git_publish': {
330
+ if (!execFileSyncSafe('gh')) {
331
+ return { ok: false, error: 'GitHub CLI (gh) is not installed on this PC' };
332
+ }
333
+ const name = body.content || path.basename(root);
334
+ const create = git(['remote', 'get-url', 'origin'], root);
335
+ if (!create.ok) {
336
+ const args = ['repo', 'create', name, '--source', '.', '--push'];
337
+ if (String(body.ref || '') === 'public') args.push('--public');
338
+ else args.push('--private');
339
+ try {
340
+ const out = execFileSync('gh', args, { cwd: root, encoding: 'utf8', timeout: 120_000, windowsHide: true });
341
+ return { ok: true, output: out };
342
+ } catch (e) {
343
+ return { ok: false, error: `${(e.stdout || '') + (e.stderr || '') || e.message}`.trim() };
344
+ }
345
+ }
346
+ const push = git(['push', '-u', 'origin', 'HEAD'], root);
347
+ return { ok: push.ok, output: push.output };
348
+ }
349
+
350
+ default:
351
+ return { ok: false, error: `unsupported op: ${op}` };
352
+ }
353
+ }
354
+
355
+ function execFileSyncSafe(cmd) {
356
+ try {
357
+ execFileSync(process.platform === 'win32' ? 'where' : 'which', [cmd], { stdio: 'ignore', timeout: 3000, windowsHide: true });
358
+ return true;
359
+ } catch {
360
+ return false;
361
+ }
362
+ }
363
+
364
+ async function handleLocalFs(bridge, taskId, request, opts) {
365
+ const { rid, op } = request;
366
+ const lang = opts.lang;
367
+ write('');
368
+ write(` ${c.bold(c.yellow('▤ files'))} ${c.dim('·')} ${c.cyan(op)} ${c.gray(request.path || '.')}${request.to ? c.gray(` → ${request.to}`) : ''}`);
369
+
370
+ const mutating = ['write', 'replace', 'rm', 'mv', 'mkdir'].includes(op);
371
+ if (mutating && opts.confirmWrites) {
372
+ const key = await askKey(` ${t(lang, 'Applico? [s] sì [n] no', 'Apply? [y] yes [n] no', '¿Aplico? [s] sí [n] no')} `, ['s', 'y', 'n']);
373
+ if (key === 'n') {
374
+ await bridge.localResult(taskId, { rid, output: JSON.stringify({ ok: false, error: 'rejected by the user' }), code: 1 });
375
+ return true;
376
+ }
377
+ }
378
+
379
+ let result;
380
+ try {
381
+ result = runFsOp(op, request, opts.folder);
382
+ } catch (e) {
383
+ result = { ok: false, error: e.message };
384
+ }
385
+
386
+ const payload = JSON.stringify(result);
387
+ const code = result.ok ? 0 : 1;
388
+ await bridge.localResult(taskId, { rid, output: payload, code });
389
+
390
+ if (result.ok) {
391
+ const extra = result.size ? c.gray(` ${result.size} B`) : '';
392
+ write(` ${c.green('✓')} ${c.gray(op)}${extra}`);
393
+ } else {
394
+ write(` ${c.red('✗')} ${c.gray(result.error || 'failed')}`);
395
+ }
396
+ return true;
397
+ }
398
+
399
+ // --------------------------------------------------------------------------- //
400
+ // Local UI (local-ui) — the terminal cannot drive the mouse, but plan approval,
401
+ // waits and system info work.
402
+ // --------------------------------------------------------------------------- //
403
+
404
+ async function handleLocalUi(bridge, taskId, request, opts) {
405
+ const { rid, op } = request;
406
+ const lang = opts.lang;
407
+
408
+ if (op === 'plan') {
409
+ write('');
410
+ write(` ${c.bold(c.magenta('✦ plan'))} ${c.dim('·')} ${t(lang, 'da approvare', 'to approve', 'para aprobar')}`);
411
+ write(indentLines(request.text || '', ' '));
412
+ write('');
413
+ const key = await askKey(
414
+ ` ${t(lang, '[a] Approva [r] Rifiuta [f] Riformula', '[a] Approve [r] Reject [f] Revise', '[a] Aprobar [r] Rechazar [f] Reformular')} `,
415
+ ['a', 'r', 'f'],
416
+ );
417
+ if (key === 'a') {
418
+ await bridge.localResult(taskId, { rid, output: JSON.stringify({ ok: true, approved: true }), code: 0 });
419
+ write(` ${c.green('✓')} ${t(lang, 'piano approvato', 'plan approved', 'plan aprobado')}`);
420
+ } else if (key === 'f') {
421
+ const feedback = await ask(` ${t(lang, 'Come riformularlo? ', 'How should it be revised? ', '¿Cómo reformularlo? ')}`);
422
+ await bridge.localResult(taskId, {
423
+ rid,
424
+ output: JSON.stringify({ ok: false, approved: false, revise: true, feedback: feedback || '' }),
425
+ code: 1,
426
+ });
427
+ write(` ${c.yellow('↻')} ${t(lang, 'riformulazione inviata', 'revision sent', 'reformulación enviada')}`);
428
+ } else {
429
+ await bridge.localResult(taskId, { rid, output: JSON.stringify({ ok: false, approved: false }), code: 1 });
430
+ write(` ${c.red('✗')} ${t(lang, 'piano rifiutato', 'plan rejected', 'plan rechazado')}`);
431
+ }
432
+ return true;
433
+ }
434
+
435
+ if (op === 'wait') {
436
+ let duration = 1;
437
+ try {
438
+ duration = JSON.parse(request.args_json || '{}').duration ?? 1;
439
+ } catch {
440
+ duration = 1;
441
+ }
442
+ await new Promise((r) => setTimeout(r, Math.min(60, Math.max(0, duration)) * 1000));
443
+ await bridge.localResult(taskId, { rid, output: JSON.stringify({ ok: true }), code: 0 });
444
+ return true;
445
+ }
446
+
447
+ if (op === 'sysinfo') {
448
+ const info = {
449
+ ok: true,
450
+ os: `${process.platform} ${available()}`,
451
+ hostname: os.hostname(),
452
+ arch: os.arch(),
453
+ cpus: os.cpus().length,
454
+ memory_mb: Math.round(os.totalmem() / 1048576),
455
+ node: process.version,
456
+ shell: require('./meta').which('pwsh') ? 'pwsh' : process.platform === 'win32' ? 'powershell' : 'sh',
457
+ };
458
+ await bridge.localResult(taskId, { rid, output: JSON.stringify(info), code: 0 });
459
+ write(` ${c.green('✓')} ${c.gray('sysinfo')}`);
460
+ return true;
461
+ }
462
+
463
+ // control / type / keystroke / screenshot / app / state / apps / multi_*
464
+ const error = t(
465
+ lang,
466
+ `operazione "${op}" non disponibile nel client da terminale: serve il client grafico ArenaCode`,
467
+ `operation "${op}" is not available in the terminal client: the ArenaCode desktop client is required`,
468
+ `la operación "${op}" no está disponible en el cliente de terminal: requiere el cliente gráfico ArenaCode`,
469
+ );
470
+ await bridge.localResult(taskId, { rid, output: JSON.stringify({ ok: false, error }), code: 1 });
471
+ write(` ${c.red('✗')} ${c.gray(op)} ${c.gray('— ' + error)}`);
472
+ return true;
473
+ }
474
+
475
+ function available() {
476
+ return os.release();
477
+ }
478
+
479
+ // --------------------------------------------------------------------------- //
480
+ // Files sent from the agent workspace to this PC (local_file)
481
+ // --------------------------------------------------------------------------- //
482
+
483
+ async function handleLocalFile(bridge, taskId, request, opts) {
484
+ const remote = String(request.remote || '');
485
+ const dest = String(request.dest || request.name || path.basename(remote));
486
+ const res = await bridge.fileRaw(remote);
487
+ if (res.status !== 200) {
488
+ write(` ${c.red('✗')} ${c.gray(`download failed: ${remote}`)}`);
489
+ return true;
490
+ }
491
+ const target = resolveInRoot(opts.folder, dest);
492
+ fs.mkdirSync(path.dirname(target), { recursive: true });
493
+ fs.writeFileSync(target, res.buffer);
494
+ write(` ${c.green('✓')} ${c.gray(`${remote} → ${relativize(opts.folder, target)} (${res.buffer.length} B)`)}`);
495
+ return true;
496
+ }
497
+
498
+ module.exports = {
499
+ REFUSED,
500
+ resolveInRoot,
501
+ runShell,
502
+ runFsOp,
503
+ handleLocalExec,
504
+ handleLocalFs,
505
+ handleLocalUi,
506
+ handleLocalFile,
507
+ };