@lardexx/arenacode 2.5.10 → 2.6.1

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/README.md CHANGED
@@ -106,16 +106,41 @@ How the copy is built and tested: [`docs/TUI.md`](docs/TUI.md).
106
106
  |---|---|
107
107
  | `enter` | send · `shift+enter` newline |
108
108
  | `esc` | interrupt the turn · clear the input · quit |
109
- | `tab` | switch agent ↔ plan |
109
+ | `tab` | switch mode: Build → Plan → Computer |
110
110
  | `ctrl+p` | command palette (`↑↓` select, `enter` run, `esc` close) |
111
111
  | `/` | slash commands with an inline list above the prompt |
112
- | `@file` | attach a file from the open folder |
112
+ | `@file` | attach a file from the open folder (uploaded to `uploads/`) |
113
+ | `@folder` | inline the folder listing in the task |
114
+ | `ctrl+v` | paste: a clipboard image becomes `images/clipboard-*.png` and is attached |
113
115
  | `!command` | run a command locally |
114
116
  | `↑` / `↓` | prompt history (or move through the slash list) |
115
117
  | `pgup` / `pgdn` | scroll the transcript |
116
- | `ctrl+x` then | `q` quit · `n` new session · `l` task queue · `t` theme · `d` tool output · `b` sidebar · `s` stop |
118
+ | `ctrl+x` then | `q` quit · `n` new session · `l` task queue · `t` theme · `a` Ask and Proceed / Proceed · `d` tool output · `s` stop |
117
119
  | `ctrl+c` | interrupt, then quit |
118
120
 
121
+ **The same four flows as the desktop client.** The Windows app is the reference for how a task reaches the bridge,
122
+ so the port reproduces it byte for byte:
123
+
124
+ - **Paste an image.** `ctrl+v` reads the clipboard image, saves it as `<folder>/images/clipboard-YYYYMMDD-HHMMSS.png`
125
+ (the desktop's naming), uploads it and lists it in the task, so the agent can open it with `read`.
126
+ - **Switch agent.** `tab` cycles the three modes of the desktop client — Build (`[ArenaCode mode: agent]`),
127
+ Plan (`[ArenaCode mode: plan]`) and Computer (`[ArenaCode mode: computer-agent]`) — and the first line of every
128
+ task is the desktop's own mode line, verbatim.
129
+ - **Send a message.** `POST /task` carries `instruction`, `meta`, `agent_prompt` and `project_context`, the same
130
+ body the desktop client builds.
131
+ - **Mention something.** `@file` is uploaded to `uploads/<timestamp>/` and named in `[attachments in the workspace: …]`;
132
+ `@folder` is inlined as `[folder contents … (n files, list only, not attached): …]`; missing paths outside the
133
+ folder are reported under `[paths on the PC: …]`. Messages follow the interface language (`it`, `en`, `es`).
134
+
135
+ The exact strings are checked by `test/parity.js` against the ones extracted from `ArenaCode-x64.exe`.
136
+
137
+ **Ask and Proceed / Proceed.** The desktop's Settings switch, `ctrl+x a` here (also `/ask`, `/proceed`,
138
+ `--proceed`, and `arenacode config set approval proceed`). The default, **Ask and Proceed**, asks before commands,
139
+ file changes and plans; in Computer mode it asks once, on the plan, and after that the commands run on their own
140
+ while administrator commands still ask. **Proceed** approves its own plan, runs commands and installations and
141
+ writes files without asking: turning it on shows the desktop's own warning (*"Pericolo: modalità Proceed"*).
142
+ `--yes` is the older switch and still answers yes to commands and files, but never to a plan.
143
+
119
144
  **Dialogs.** Local actions open opencode-shaped dialogs — panel background, title on the left, `esc` on the right,
120
145
  the selected row painted in the primary colour, hints underneath: `Run once / Don't run` for terminal commands,
121
146
  `Approve / Reject / Revise` for the agent's plan, and file operations reported back to the bridge. `--yes` skips the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lardexx/arenacode",
3
- "version": "2.5.10",
3
+ "version": "2.6.1",
4
4
  "description": "Terminal client for ArenaCode: talks to an arena-bridge, streams the Agent Mode timeline, and runs local commands and file operations on your PC. Node port of the ArenaCode desktop client.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -19,7 +19,7 @@
19
19
  "LICENSE"
20
20
  ],
21
21
  "scripts": {
22
- "test": "node test/integration.js"
22
+ "test": "node test/integration.js && node test/parity.js"
23
23
  },
24
24
  "keywords": [
25
25
  "arenacode",
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ // The desktop client has one setting, Settings → "Ask and Proceed" (default) or
4
+ // "Proceed", and it changes every approval the client makes:
5
+ //
6
+ // Ask and Proceed (IDE mode) file changes wait for Accept/Reject, commands wait for Run
7
+ // Ask and Proceed (Computer) one confirmation, on the plan; then commands run alone
8
+ // (commands with administrator rights still ask)
9
+ // Proceed the client approves its own plan, runs commands and
10
+ // installations and uses mouse and keyboard without asking
11
+ //
12
+ // `--yes` is the old switch of this client and keeps meaning "say yes to commands
13
+ // and file changes", but never to a plan: a plan is the user's decision.
14
+ const APPROVALS = ['ask', 'proceed'];
15
+
16
+ const DANGER = {
17
+ it: {
18
+ title: 'Pericolo: modalità Proceed',
19
+ body:
20
+ 'PERICOLO: in Proceed il Computer Agent approva da solo il piano, esegue comandi e installazioni ' +
21
+ 'e usa mouse e tastiera senza chiedere. Può cancellare o modificare dati e programmi.',
22
+ },
23
+ en: {
24
+ title: 'Danger: Proceed mode',
25
+ body:
26
+ 'DANGER: in Proceed the Computer Agent approves its own plan, runs commands and installations, and ' +
27
+ 'uses mouse and keyboard without asking. It can delete or change data and programs.',
28
+ },
29
+ es: {
30
+ title: 'Peligro: modo Proceed',
31
+ body:
32
+ 'PELIGRO: en Proceed el Computer Agent aprueba su propio plan, ejecuta comandos e instalaciones y ' +
33
+ 'usa el ratón y el teclado sin preguntar. Puede borrar o modificar datos y programas.',
34
+ },
35
+ };
36
+
37
+ const HELP_TEXT = {
38
+ it: {
39
+ ide:
40
+ 'Ask and Proceed: Arena chiede conferma per comandi, modifiche ai file e piani. ' +
41
+ 'Proceed: esegue tutto da solo.',
42
+ computer:
43
+ 'Ask and Proceed: il Computer Agent chiede conferma una sola volta, sul piano. Dopo ' +
44
+ "l'approvazione esegue i comandi da solo (i comandi con permessi di amministratore chiedono " +
45
+ 'ancora conferma). Proceed: agisce da solo, senza chiedere nulla.',
46
+ },
47
+ en: {
48
+ ide:
49
+ 'Ask and Proceed: Arena asks before commands, file changes and plans. ' +
50
+ 'Proceed: does everything on its own.',
51
+ computer:
52
+ 'Ask and Proceed: the Computer Agent asks for confirmation once, on the plan. After approval ' +
53
+ 'it runs commands on its own (commands with administrator rights still ask). Proceed: it acts ' +
54
+ 'on its own, without asking anything.',
55
+ },
56
+ es: {
57
+ ide:
58
+ 'Ask and Proceed: Arena pide confirmación para comandos, cambios en archivos y planes. ' +
59
+ 'Proceed: hace todo por sí solo.',
60
+ computer:
61
+ 'Ask and Proceed: el Computer Agent pide confirmación una sola vez, sobre el plan. Tras ' +
62
+ 'aprobarlo ejecuta los comandos por sí solo (los comandos con permisos de administrador ' +
63
+ 'siguen pidiendo confirmación). Proceed: actúa por sí solo, sin preguntar nada.',
64
+ },
65
+ };
66
+
67
+ function normaliseApproval(value) {
68
+ return APPROVALS.includes(value) ? value : 'ask';
69
+ }
70
+
71
+ /**
72
+ * What the client should do with one request from the agent.
73
+ * kind: 'plan' | 'fs' | 'exec'
74
+ * Returns 'auto' (answer yes without showing anything) or 'ask' (show the card).
75
+ */
76
+ function decide({ approval = 'ask', mode = 'agent', planApproved = false, kind, admin = false, yes = false }) {
77
+ if (normaliseApproval(approval) === 'proceed') return 'auto';
78
+ if (kind === 'plan') return 'ask';
79
+ if (yes) return 'auto';
80
+ if (kind === 'fs') return 'ask';
81
+ if (kind === 'exec') {
82
+ if (admin) return 'ask';
83
+ if (mode === 'computer-agent' && planApproved) return 'auto';
84
+ return 'ask';
85
+ }
86
+ return 'ask';
87
+ }
88
+
89
+ function dangerText(lang) {
90
+ return DANGER[lang] || DANGER.en;
91
+ }
92
+
93
+ function approvalHelp(lang, mode) {
94
+ const set = HELP_TEXT[lang] || HELP_TEXT.en;
95
+ return mode === 'computer-agent' ? set.computer : set.ide;
96
+ }
97
+
98
+ module.exports = { APPROVALS, decide, dangerText, approvalHelp, normaliseApproval, DANGER, HELP_TEXT };
package/src/attach.js ADDED
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { stamp } = require('./clipboard');
6
+
7
+ const SKIP_DIRS = new Set([
8
+ 'node_modules',
9
+ '__pycache__',
10
+ '.git',
11
+ '.venv',
12
+ '.next',
13
+ '.cache',
14
+ '.idea',
15
+ '.vscode',
16
+ 'dist',
17
+ 'build',
18
+ ]);
19
+
20
+ const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']);
21
+ const MAX_FILES = 300;
22
+ const MAX_BYTES = 64 * 1024 * 1024;
23
+
24
+ const TEXT = {
25
+ it: {
26
+ attachments: '[allegati nel workspace: ',
27
+ folder: '[contenuto della cartella ',
28
+ empty: '[cartella vuota: ',
29
+ other: ' (altri %d file, solo elenco, non allegati): ',
30
+ paths: '[percorsi sul PC: ',
31
+ tooMany: 'troppi file (max %d) o file oltre %d MB',
32
+ noImage: 'Immagine negli appunti non leggibile.',
33
+ pasted: 'immagine allegata',
34
+ },
35
+ en: {
36
+ attachments: '[attachments in the workspace: ',
37
+ folder: '[folder contents ',
38
+ empty: '[empty folder: ',
39
+ other: ' (%d files, list only, not attached): ',
40
+ paths: '[paths on the PC: ',
41
+ tooMany: 'too many files (max %d) or files over %d MB',
42
+ noImage: 'Clipboard image could not be read.',
43
+ pasted: 'image attached',
44
+ },
45
+ es: {
46
+ attachments: '[adjuntos en el espacio de trabajo: ',
47
+ folder: '[contenido de la carpeta ',
48
+ empty: '[carpeta vacía: ',
49
+ other: ' (%d archivos, solo lista, no adjuntos): ',
50
+ paths: '[rutas en el PC: ',
51
+ tooMany: 'demasiados archivos (máx %d) o archivos de más de %d MB',
52
+ noImage: 'La imagen del portapapeles no se pudo leer.',
53
+ pasted: 'imagen adjunta',
54
+ },
55
+ };
56
+
57
+ function strings(lang) {
58
+ return TEXT[lang] || TEXT.en;
59
+ }
60
+
61
+ /** Lists the files of a folder the way the desktop client inlines them. */
62
+ function folderListing(folder, rel, lang) {
63
+ const s = strings(lang);
64
+ const root = path.resolve(folder, rel);
65
+ const names = [];
66
+ let truncated = false;
67
+
68
+ const walk = (dir, prefix, depth) => {
69
+ let entries;
70
+ try {
71
+ entries = fs.readdirSync(dir, { withFileTypes: true });
72
+ } catch {
73
+ return;
74
+ }
75
+ entries.sort((a, b) => a.name.localeCompare(b.name));
76
+ for (const e of entries) {
77
+ if (names.length >= MAX_FILES) {
78
+ truncated = true;
79
+ return;
80
+ }
81
+ if (e.name.startsWith('.') && depth === 0 && SKIP_DIRS.has(e.name)) continue;
82
+ if (SKIP_DIRS.has(e.name)) continue;
83
+ const childRel = prefix ? `${prefix}/${e.name}` : e.name;
84
+ if (e.isDirectory()) {
85
+ walk(path.join(dir, e.name), childRel, depth + 1);
86
+ } else {
87
+ names.push(childRel);
88
+ }
89
+ }
90
+ };
91
+ walk(root, '', 0);
92
+
93
+ if (!names.length) return `${s.empty}${rel || '.'}]`;
94
+ const total = names.length + (truncated ? 1 : 0);
95
+ return `${s.folder}${rel || '.'}${s.other.replace('%d', total)}${names.join(', ')}${truncated ? ', …' : ''}]`;
96
+ }
97
+
98
+ function isInside(folder, abs) {
99
+ const rel = path.relative(folder, abs);
100
+ return rel && !rel.startsWith('..') && !path.isAbsolute(rel);
101
+ }
102
+
103
+ async function upload(bridge, folder, abs, name) {
104
+ const stat = fs.statSync(abs);
105
+ if (stat.size > MAX_BYTES) return { error: 'size' };
106
+ const dir = `uploads/${stamp()}`;
107
+ const remote = `${dir}/${name}`;
108
+ const res = await bridge.upload(remote, fs.readFileSync(abs));
109
+ if (res.status !== 200) return { error: `HTTP ${res.status}` };
110
+ return { remote: (res.json && res.json.path) || remote, size: stat.size };
111
+ }
112
+
113
+ /**
114
+ * Turns `@refs` into the same instruction blocks the desktop client produces:
115
+ * files land in the bridge workspace, folders are inlined as a listing.
116
+ */
117
+ async function attachRefs(bridge, folder, refs, { lang = 'en' } = {}) {
118
+ const s = strings(lang);
119
+ const uploaded = [];
120
+ const blocks = [];
121
+ const paths = [];
122
+ const warnings = [];
123
+
124
+ for (const ref of refs) {
125
+ const abs = path.resolve(folder, ref);
126
+ let exists = true;
127
+ let stat = null;
128
+ try {
129
+ stat = fs.statSync(abs);
130
+ } catch {
131
+ exists = false;
132
+ }
133
+ if (!exists) {
134
+ if (/^[a-zA-Z]:[\\/]/.test(ref) || ref.startsWith('/')) paths.push(ref);
135
+ else warnings.push(`not found: ${ref}`);
136
+ continue;
137
+ }
138
+ if (stat.isDirectory()) {
139
+ blocks.push(folderListing(folder, ref, lang));
140
+ continue;
141
+ }
142
+ const up = await upload(bridge, folder, abs, path.basename(abs));
143
+ if (up.error === 'size') {
144
+ warnings.push(s.tooMany.replace('%d', MAX_FILES).replace('%d', MAX_BYTES / (1024 * 1024)));
145
+ continue;
146
+ }
147
+ if (up.error) {
148
+ warnings.push(`upload failed: ${ref} (${up.error})`);
149
+ continue;
150
+ }
151
+ uploaded.push(up.remote);
152
+ }
153
+
154
+ return { uploaded, blocks, paths, warnings, s };
155
+ }
156
+
157
+ /** Moves a clipboard PNG into <folder>/images and uploads it, like the desktop. */
158
+ async function attachImage(bridge, folder, tmpPng, { lang = 'en', name } = {}) {
159
+ const id = name || `clipboard-${stamp()}.png`;
160
+ const localDir = path.join(folder, 'images');
161
+ fs.mkdirSync(localDir, { recursive: true });
162
+ const local = path.join(localDir, id);
163
+ try {
164
+ fs.renameSync(tmpPng, local);
165
+ } catch {
166
+ fs.copyFileSync(tmpPng, local);
167
+ try {
168
+ fs.unlinkSync(tmpPng);
169
+ } catch {
170
+ /* ignore */
171
+ }
172
+ }
173
+ const up = await upload(bridge, folder, local, id);
174
+ if (up.error) return { error: up.error, local };
175
+ return { local, rel: path.relative(folder, local), remote: up.remote };
176
+ }
177
+
178
+ /** Appends the blocks the desktop appends to the instruction text. */
179
+ function decorate(text, { uploaded = [], blocks = [], paths = [], lang = 'en' } = {}) {
180
+ const s = strings(lang);
181
+ const extra = [...blocks];
182
+ if (uploaded.length) extra.push(`${s.attachments}${uploaded.join(', ')}]`);
183
+ if (paths.length) extra.push(`${s.paths}${paths.join(', ')}]`);
184
+ if (!extra.length) return text;
185
+ return `${text}\n\n${extra.join('\n\n')}`;
186
+ }
187
+
188
+ module.exports = { attachRefs, attachImage, decorate, folderListing, strings, IMAGE_EXT, MAX_FILES, MAX_BYTES };
package/src/cli.js CHANGED
@@ -9,6 +9,10 @@ const {
9
9
  resolveUrl, resolveKey, openFolder,
10
10
  } = require('./config');
11
11
  const { Bridge } = require('./bridge');
12
+ const { MODE_LINES, MODES, normaliseMode } = require('./modes');
13
+ const { attachRefs, decorate } = require('./attach');
14
+ const { decide, dangerText, normaliseApproval } = require('./approval');
15
+ const session = require('./session');
12
16
  const { buildMeta, readProjectContext } = require('./meta');
13
17
  const { followTask } = require('./turn');
14
18
  const { c, write, ask, banner, truncate, indentLines } = require('./ui');
@@ -30,23 +34,12 @@ const INSTALL_HINT = process.platform === 'win32'
30
34
  ? 'npm install -g @lardexx/arenacode (then reopen the terminal)'
31
35
  : 'npm install -g @lardexx/arenacode';
32
36
 
33
- const MODE_LINES = {
34
- agent:
35
- '[ArenaCode mode: agent]\n' +
36
- 'AGENT MODE (not plan mode). Carry the task out directly: do not make a plan, do not send ui plan, ' +
37
- 'do not write a plan. This line overrides any plan instruction from earlier tasks or from the chat, ' +
38
- 'even if you remember one. Work, verify each change, and reply.',
39
- plan:
40
- '[ArenaCode mode: plan]\n' +
41
- 'PLAN MODE. Send the plan first with `ui plan`: the steps, the files involved, the risks and what you ' +
42
- 'will not do. Then stop and wait for the user to approve it before acting.',
43
- };
44
37
 
45
38
  // --------------------------------------------------------------------------- //
46
39
  // argument parsing
47
40
  // --------------------------------------------------------------------------- //
48
41
 
49
- const BOOLEAN_FLAGS = new Set(['yes', 'json', 'help', 'quiet', 'version', 'confirm-writes', 'no-pc', 'piped']);
42
+ const BOOLEAN_FLAGS = new Set(['yes', 'json', 'help', 'quiet', 'version', 'confirm-writes', 'no-pc', 'piped', 'proceed', 'ask']);
50
43
 
51
44
  function parseArgs(argv) {
52
45
  const flags = {};
@@ -104,11 +97,12 @@ function context(flags) {
104
97
  folder,
105
98
  device,
106
99
  lang: flags.lang || cfg.lang || 'it',
107
- mode: flags.mode === 'plan' ? 'plan' : flags.mode === 'agent' ? 'agent' : cfg.mode || 'agent',
100
+ mode: normaliseMode(flags.mode || cfg.mode || 'agent'),
108
101
  pcAccess: flags['no-pc'] ? false : cfg.pc_access !== false,
109
102
  yes: Boolean(flags.yes),
110
103
  json: Boolean(flags.json),
111
104
  confirmWrites: Boolean(flags['confirm-writes']),
105
+ approval: normaliseApproval(flags.proceed ? 'proceed' : flags.ask ? 'ask' : cfg.approval),
112
106
  idleTimeout: Number(flags['idle-timeout'] || cfg.idle_timeout || 180),
113
107
  bridge: new Bridge(url, key, device),
114
108
  };
@@ -200,7 +194,13 @@ function cmdConfig(positional, flags) {
200
194
  const name = key;
201
195
  const val = rest.join(' ');
202
196
  if (!name || val === '') {
203
- write(`usage: ${SELF} config set <url|key|lang|mode|pc_access> <value>`);
197
+ write(`usage: ${SELF} config set <url|key|lang|mode|approval|pc_access> <value>`);
198
+ return 2;
199
+ }
200
+ if (name === 'approval' && !['ask', 'proceed'].includes(String(val))) {
201
+ write(c.red(' ✗ approval must be ask or proceed'));
202
+ write(c.gray(` ask ${'Ask and Proceed'}: the client asks before commands, file changes and plans`));
203
+ write(c.gray(` proceed Proceed: the client does everything on its own`));
204
204
  return 2;
205
205
  }
206
206
  const patch = {};
@@ -209,6 +209,12 @@ function cmdConfig(positional, flags) {
209
209
  else patch[name] = val;
210
210
  const file = patchConfig(patch);
211
211
  write(` ${c.green('✓')} ${name} saved ${c.gray(file)}`);
212
+ if (name === 'approval' && patch.approval === 'proceed') {
213
+ const d = dangerText(cfg.lang);
214
+ write('');
215
+ write(` ${c.bold(c.red('⚠ ' + d.title))}`);
216
+ write(indentLines(c.yellow(d.body), ' '));
217
+ }
212
218
  return 0;
213
219
  }
214
220
  if (action === 'clear') {
@@ -292,6 +298,7 @@ async function createTask(ctx, instruction, { extraInstructions = '' } = {}) {
292
298
  if (extraInstructions) body.instruction = `${instruction}\n\n${extraInstructions}`;
293
299
 
294
300
  const res = await ctx.bridge.createTask(body);
301
+ if (res.status === 202 || res.status === 200) session.remember(res.json && res.json.task_id, ctx.mode);
295
302
  if (res.status !== 202 && res.status !== 200) {
296
303
  const reason = (res.json && (res.json.error || res.json.hint)) || `HTTP ${res.status}`;
297
304
  throw new Error(reason);
@@ -303,23 +310,15 @@ async function runInstruction(ctx, text, { attachments = [] } = {}) {
303
310
  let instruction = `${MODE_LINES[ctx.mode] || MODE_LINES.agent}\n${text}`;
304
311
 
305
312
  if (attachments.length) {
306
- const uploaded = [];
307
- for (const file of attachments) {
308
- const abs = path.resolve(file);
309
- if (!fs.existsSync(abs)) {
310
- write(c.yellow(` ! missing attachment: ${file}`));
311
- continue;
312
- }
313
- const name = `attachments/${Date.now()}-${path.basename(abs)}`;
314
- const res = await ctx.bridge.upload(name, fs.readFileSync(abs));
315
- if (res.status !== 200) {
316
- write(c.yellow(` ! upload failed: ${file}`));
317
- continue;
318
- }
319
- uploaded.push(res.json.path);
320
- write(` ${c.green('↑')} ${c.gray(`${path.basename(abs)} → ${res.json.path} (${res.json.size} B)`)}`);
321
- }
322
- if (uploaded.length) instruction += `\n\n[attachments in the workspace: ${uploaded.join(', ')}]`;
313
+ const att = await attachRefs(ctx.bridge, ctx.folder, attachments, { lang: ctx.lang });
314
+ for (const w of att.warnings) write(c.yellow(` ! ${w}`));
315
+ for (const remote of att.uploaded) write(` ${c.green('↑')} ${c.gray(remote)}`);
316
+ instruction = decorate(instruction, {
317
+ uploaded: att.uploaded,
318
+ blocks: att.blocks,
319
+ paths: att.paths,
320
+ lang: ctx.lang,
321
+ });
323
322
  }
324
323
 
325
324
  let taskId = null;
@@ -339,6 +338,7 @@ async function runInstruction(ctx, text, { attachments = [] } = {}) {
339
338
  folder: ctx.folder,
340
339
  lang: ctx.lang,
341
340
  yes: ctx.yes,
341
+ approval: ctx.approval,
342
342
  confirmWrites: ctx.confirmWrites,
343
343
  idleTimeout: ctx.idleTimeout,
344
344
  });
@@ -398,6 +398,7 @@ async function cmdUi(flags) {
398
398
  lang: ctx.lang,
399
399
  mode: ctx.mode,
400
400
  yes: ctx.yes,
401
+ approval: ctx.approval,
401
402
  confirmWrites: ctx.confirmWrites,
402
403
  });
403
404
  await app.start();
@@ -756,11 +757,13 @@ function help() {
756
757
  write(` ${c.cyan('options')}`);
757
758
  write(` --folder <dir> open folder (the project the agent works on)`);
758
759
  write(` --url <url> --key <key> override the saved bridge settings`);
759
- write(` --mode agent|plan task mode (default: ${c.gray('agent')})`);
760
+ write(` --mode agent|plan|computer-agent task mode (default: ${c.gray('agent')})`);
760
761
  write(` --lang it|en|es language for instructions and cards`);
761
762
  write(` --no-pc tell the agent the PC terminal is off`);
762
763
  write(` --yes run local commands without asking`);
763
- write(` --confirm-writes ask before write/replace/rm/mv on your folder`);
764
+ write(` --ask Ask and Proceed: ask before commands, file changes and plans (default)
765
+ --proceed Proceed: approve the plan and run everything without asking
766
+ --confirm-writes ask before write/replace/rm/mv on your folder`);
764
767
  write(` --attach a,b upload files with the task`);
765
768
  write(` --json raw JSON output where available`);
766
769
  write('');
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+
7
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
8
+
9
+ // The desktop client stores a pasted screenshot as <folder>\images\clipboard-YYYYMMDD-HHMMSS.png
10
+ function stamp(d = new Date()) {
11
+ const p = (n) => String(n).padStart(2, '0');
12
+ return (
13
+ `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-` +
14
+ `${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
15
+ );
16
+ }
17
+
18
+ function isPng(buf) {
19
+ return Buffer.isBuffer(buf) && buf.length > 8 && buf.subarray(0, 8).equals(PNG_MAGIC);
20
+ }
21
+
22
+ // Windows: System.Windows.Forms.Clipboard needs an STA thread, so run
23
+ // PowerShell with -STA. The image is written straight to `dest`.
24
+ function grabWindows(dest) {
25
+ const script = [
26
+ 'Add-Type -AssemblyName System.Windows.Forms',
27
+ 'Add-Type -AssemblyName System.Drawing',
28
+ '$img = [System.Windows.Forms.Clipboard]::GetImage()',
29
+ 'if ($null -eq $img) { Write-Output "none"; exit 2 }',
30
+ `$img.Save('${dest.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png)`,
31
+ '$img.Dispose()',
32
+ 'Write-Output "ok"',
33
+ ].join('; ');
34
+ for (const exe of ['powershell', 'pwsh']) {
35
+ const r = spawnSync(exe, ['-NoProfile', '-NonInteractive', '-STA', '-Command', script], {
36
+ timeout: 20_000,
37
+ windowsHide: true,
38
+ });
39
+ if (r.error && r.error.code === 'ENOENT') continue;
40
+ if (r.status === 0 && fs.existsSync(dest) && fs.statSync(dest).size > 0) return true;
41
+ // No image on the clipboard, or no PowerShell: nothing to paste.
42
+ if (r.status === 2) return false;
43
+ return false;
44
+ }
45
+ return false;
46
+ }
47
+
48
+ function grabUnix(dest) {
49
+ const attempts = [
50
+ ['wl-paste', ['--no-newline', '--type', 'image/png']],
51
+ ['xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']],
52
+ ];
53
+ for (const [cmd, args] of attempts) {
54
+ const r = spawnSync(cmd, args, { timeout: 10_000, maxBuffer: 64 * 1024 * 1024 });
55
+ if (r.error) continue;
56
+ if (r.status === 0 && isPng(r.stdout)) {
57
+ fs.writeFileSync(dest, r.stdout);
58
+ return true;
59
+ }
60
+ }
61
+ return false;
62
+ }
63
+
64
+ /**
65
+ * Saves the image currently on the clipboard as a PNG in `dest`.
66
+ * Returns true when an image was written, false when the clipboard holds
67
+ * no image (or the platform has no clipboard access).
68
+ */
69
+ function grabImage(dest) {
70
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
71
+ // test/automation hook: treat this file as the clipboard image
72
+ const hook = process.env.ARENACODE_CLIPBOARD_IMAGE;
73
+ if (hook) {
74
+ try {
75
+ if (fs.existsSync(hook)) {
76
+ fs.copyFileSync(hook, dest);
77
+ return true;
78
+ }
79
+ } catch {
80
+ /* ignore */
81
+ }
82
+ }
83
+ try {
84
+ if (process.platform === 'win32') return grabWindows(dest);
85
+ return grabUnix(dest);
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ /** Text on the clipboard, or '' when there is none. */
92
+ function readClipboardText() {
93
+ try {
94
+ if (process.platform === 'win32') {
95
+ for (const exe of ['powershell', 'pwsh']) {
96
+ const r = spawnSync(exe, ['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'], {
97
+ timeout: 10_000,
98
+ encoding: 'utf8',
99
+ windowsHide: true,
100
+ });
101
+ if (r.error && r.error.code === 'ENOENT') continue;
102
+ return r.status === 0 && r.stdout ? r.stdout.replace(/\r\n/g, '\n').replace(/\n$/, '') : '';
103
+ }
104
+ return '';
105
+ }
106
+ for (const [cmd, args] of [
107
+ ['wl-paste', ['--no-newline']],
108
+ ['xclip', ['-selection', 'clipboard', '-o']],
109
+ ]) {
110
+ const r = spawnSync(cmd, args, { timeout: 10_000, encoding: 'utf8' });
111
+ if (!r.error && r.status === 0) return r.stdout || '';
112
+ }
113
+ } catch {
114
+ /* ignore */
115
+ }
116
+ return '';
117
+ }
118
+
119
+ /** True when the clipboard looks like it holds an image (cheap check, no write). */
120
+ function hasImage() {
121
+ const tmp = path.join(require('os').tmpdir(), `arenacode-probe-${process.pid}-${Date.now()}.png`);
122
+ const ok = grabImage(tmp);
123
+ if (ok) {
124
+ try {
125
+ fs.unlinkSync(tmp);
126
+ } catch {
127
+ /* ignore */
128
+ }
129
+ }
130
+ return ok;
131
+ }
132
+
133
+ module.exports = { grabImage, readClipboardText, hasImage, stamp, isPng };
package/src/config.js CHANGED
@@ -29,6 +29,7 @@ const DEFAULTS = {
29
29
  folder: '',
30
30
  lang: 'it',
31
31
  mode: 'agent',
32
+ approval: 'ask',
32
33
  pc_access: true,
33
34
  device: '',
34
35
  vision: false,
package/src/local.js CHANGED
@@ -1,5 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ const { decide } = require('./approval');
4
+ const session = require('./session');
5
+
3
6
  const fs = require('node:fs');
4
7
  const os = require('node:os');
5
8
  const path = require('node:path');
@@ -98,7 +101,18 @@ async function handleLocalExec(bridge, taskId, request, opts) {
98
101
  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
102
  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
103
 
101
- let approved = opts.yes === true;
104
+ const verdict = decide({
105
+ approval: opts.approval,
106
+ mode: session.modeOf(taskId),
107
+ planApproved: session.planApproved(taskId),
108
+ kind: 'exec',
109
+ admin: Boolean(admin),
110
+ yes: opts.yes === true,
111
+ });
112
+ let approved = verdict === 'auto';
113
+ if (verdict === 'auto') {
114
+ write(` ${c.green('✓')} ${c.gray(t(lang, 'approvato dal client (Proceed)', 'approved by the client (Proceed)', 'aprobado por el cliente (Proceed)'))}`);
115
+ }
102
116
  if (!approved) {
103
117
  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
118
  approved = key === 'r';
@@ -368,7 +382,18 @@ async function handleLocalFs(bridge, taskId, request, opts) {
368
382
  write(` ${c.bold(c.yellow('▤ files'))} ${c.dim('·')} ${c.cyan(op)} ${c.gray(request.path || '.')}${request.to ? c.gray(` → ${request.to}`) : ''}`);
369
383
 
370
384
  const mutating = ['write', 'replace', 'rm', 'mv', 'mkdir'].includes(op);
371
- if (mutating && opts.confirmWrites) {
385
+ const verdict = mutating
386
+ ? decide({
387
+ approval: opts.approval,
388
+ mode: session.modeOf(taskId),
389
+ planApproved: session.planApproved(taskId),
390
+ kind: 'fs',
391
+ yes: opts.yes === true,
392
+ })
393
+ : 'auto';
394
+ // --confirm-writes keeps asking even when the client runs in Proceed
395
+ const mustAsk = mutating && (verdict === 'ask' || opts.confirmWrites);
396
+ if (mustAsk) {
372
397
  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
398
  if (key === 'n') {
374
399
  await bridge.localResult(taskId, { rid, output: JSON.stringify({ ok: false, error: 'rejected by the user' }), code: 1 });
@@ -406,6 +431,20 @@ async function handleLocalUi(bridge, taskId, request, opts) {
406
431
  const lang = opts.lang;
407
432
 
408
433
  if (op === 'plan') {
434
+ const planVerdict = decide({
435
+ approval: opts.approval,
436
+ mode: session.modeOf(taskId),
437
+ kind: 'plan',
438
+ yes: opts.yes === true,
439
+ });
440
+ if (planVerdict === 'auto') {
441
+ session.markPlanApproved(taskId);
442
+ write('');
443
+ write(` ${c.bold(c.magenta('✦ plan'))} ${c.dim('·')} ${t(lang, 'approvato dal client (Proceed)', 'approved by the client (Proceed)', 'aprobado por el cliente (Proceed)')}`);
444
+ write(indentLines(request.text || '', ' '));
445
+ await bridge.localResult(taskId, { rid, output: JSON.stringify({ ok: true, approved: true }), code: 0 });
446
+ return true;
447
+ }
409
448
  write('');
410
449
  write(` ${c.bold(c.magenta('✦ plan'))} ${c.dim('·')} ${t(lang, 'da approvare', 'to approve', 'para aprobar')}`);
411
450
  write(indentLines(request.text || '', ' '));
@@ -415,6 +454,7 @@ async function handleLocalUi(bridge, taskId, request, opts) {
415
454
  ['a', 'r', 'f'],
416
455
  );
417
456
  if (key === 'a') {
457
+ session.markPlanApproved(taskId);
418
458
  await bridge.localResult(taskId, { rid, output: JSON.stringify({ ok: true, approved: true }), code: 0 });
419
459
  write(` ${c.green('✓')} ${t(lang, 'piano approvato', 'plan approved', 'plan aprobado')}`);
420
460
  } else if (key === 'f') {
package/src/modes.js ADDED
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ // Mode lines copied verbatim from the Windows client (ArenaCode-x64.exe, UTF-16
4
+ // string table). The first line of every task must be one of these: the agent
5
+ // treats it as the only valid mode for that task.
6
+ const MODE_LINES = {
7
+ agent:
8
+ '[ArenaCode mode: agent]\n' +
9
+ 'AGENT MODE (not plan mode). Carry the task out directly: do not make a plan, do not send ui plan, ' +
10
+ 'do not write a plan. This line overrides any plan instruction from earlier tasks or from the chat, ' +
11
+ 'even if you remember one. Work, verify each change, and reply.',
12
+ plan:
13
+ '[ArenaCode mode: plan]\n' +
14
+ 'PLAN ONLY until the plan is approved. Do not edit files, do not run commands and do not change ' +
15
+ 'anything on the PC. Study the request and the project (read-only), then send a short numbered plan ' +
16
+ 'with the command python3 bridge/agentctl.py ui plan <task id> --text "..." (steps, files involved, ' +
17
+ 'risks, what you will not do). Wait for its result. If it says ok:true, the user approved: carry the ' +
18
+ 'plan out now, in this same turn, step by step, and report what you changed. If it says revise:true ' +
19
+ 'with feedback, the user wants a different plan: reword the plan taking the feedback into account and ' +
20
+ 'send it again with ui plan, in this same turn. If it is refused or returns an error, do not change ' +
21
+ 'anything and reply.',
22
+ 'computer-agent': '[ArenaCode mode: computer-agent]',
23
+ 'plan-approved':
24
+ '[ArenaCode mode: plan approved]\n' +
25
+ 'The user approved the plan in your previous message. Carry it out now, step by step, following that ' +
26
+ 'plan. At the end, report what you changed.',
27
+ };
28
+
29
+ // The three modes the desktop client can start a task in, in the order tab
30
+ // cycles through them.
31
+ const MODES = ['agent', 'plan', 'computer-agent'];
32
+
33
+ const MODE_LABELS = {
34
+ agent: 'Build',
35
+ plan: 'Plan',
36
+ 'computer-agent': 'Computer',
37
+ };
38
+
39
+ function modeLabel(mode) {
40
+ return MODE_LABELS[mode] || MODE_LABELS.agent;
41
+ }
42
+
43
+ function nextMode(mode) {
44
+ const i = MODES.indexOf(mode);
45
+ return MODES[(i < 0 ? 0 : i + 1) % MODES.length];
46
+ }
47
+
48
+ function normaliseMode(mode) {
49
+ const m = String(mode || '').toLowerCase();
50
+ if (m === 'computer' || m === 'computer-agent' || m === 'computer_agent') return 'computer-agent';
51
+ if (m === 'plan') return 'plan';
52
+ return 'agent';
53
+ }
54
+
55
+ module.exports = { MODE_LINES, MODES, MODE_LABELS, modeLabel, nextMode, normaliseMode };
package/src/session.js ADDED
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ // Per-task state the approval rules need: the mode the task was started in and
4
+ // whether its plan has already been approved (Computer Agent mode runs its
5
+ // commands alone after that one approval).
6
+ const tasks = new Map();
7
+
8
+ function remember(taskId, mode) {
9
+ if (!taskId) return;
10
+ tasks.set(taskId, { mode: mode || 'agent', planApproved: false });
11
+ }
12
+
13
+ function modeOf(taskId) {
14
+ const t = tasks.get(taskId);
15
+ return t ? t.mode : 'agent';
16
+ }
17
+
18
+ function markPlanApproved(taskId) {
19
+ const t = tasks.get(taskId);
20
+ if (t) t.planApproved = true;
21
+ else if (taskId) tasks.set(taskId, { mode: 'agent', planApproved: true });
22
+ }
23
+
24
+ function planApproved(taskId) {
25
+ const t = tasks.get(taskId);
26
+ return Boolean(t && t.planApproved);
27
+ }
28
+
29
+ function forget(taskId) {
30
+ tasks.delete(taskId);
31
+ }
32
+
33
+ module.exports = { remember, modeOf, markPlanApproved, planApproved, forget };
package/src/tui/app.js CHANGED
@@ -17,6 +17,12 @@ const { buildMeta, readProjectContext } = require('../meta');
17
17
  const { runShell, runFsOp, resolveInRoot } = require('../local');
18
18
  const { readConfig, patchConfig, deviceId, resolveUrl, resolveKey, openFolder, VERSION } = require('../config');
19
19
 
20
+ const { MODE_LINES, modeLabel, nextMode, normaliseMode } = require('../modes');
21
+ const { attachRefs, attachImage, decorate, strings } = require('../attach');
22
+ const { grabImage, readClipboardText, stamp } = require('../clipboard');
23
+ const { decide, dangerText, approvalHelp, normaliseApproval } = require('../approval');
24
+ const session = require('../session');
25
+
20
26
  const AGENT_PROMPT = (() => {
21
27
  try {
22
28
  return fs.readFileSync(path.join(__dirname, '..', 'prompts', 'agent-prompt.md'), 'utf8');
@@ -25,18 +31,6 @@ const AGENT_PROMPT = (() => {
25
31
  }
26
32
  })();
27
33
 
28
- const MODE_LINES = {
29
- agent:
30
- '[ArenaCode mode: agent]\n' +
31
- 'AGENT MODE (not plan mode). Carry the task out directly: do not make a plan, do not send ui plan, ' +
32
- 'do not write a plan. This line overrides any plan instruction from earlier tasks or from the chat, ' +
33
- 'even if you remember one. Work, verify each change, and reply.',
34
- plan:
35
- '[ArenaCode mode: plan]\n' +
36
- 'PLAN MODE. Send the plan first with `ui plan`: the steps, the files involved, the risks and what you ' +
37
- 'will not do. Then stop and wait for the user to approve it before acting.',
38
- };
39
-
40
34
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
41
35
 
42
36
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -54,13 +48,15 @@ class App {
54
48
  this.theme = getTheme(this.themeName);
55
49
  this.autoApprove = Boolean(opts.yes);
56
50
  this.confirmWrites = Boolean(opts.confirmWrites);
51
+ this.approval = normaliseApproval(opts.approval);
57
52
 
58
53
  this.state = {
59
- mode: opts.mode === 'plan' ? 'plan' : cfg.mode || 'agent',
54
+ mode: normaliseMode(opts.mode || cfg.mode || 'agent'),
60
55
  status: 'idle',
61
56
  details: true,
62
57
  items: [],
63
58
  seqItems: new Map(),
59
+ pendingImages: [],
64
60
  input: '',
65
61
  cursor: 0,
66
62
  history: [],
@@ -100,6 +96,12 @@ class App {
100
96
  });
101
97
  this.term.on('resize', () => this.invalidate());
102
98
  this.running = true;
99
+ if (this.approval === 'proceed') {
100
+ this.push({
101
+ kind: 'error',
102
+ text: `approval: Proceed · ${approvalHelp(this.lang, this.state.mode)}`,
103
+ });
104
+ }
103
105
 
104
106
  const interval = setInterval(() => {
105
107
  this.state.spinner = (this.state.spinner + 1) % SPINNER.length;
@@ -224,9 +226,13 @@ class App {
224
226
  this.invalidate();
225
227
  return undefined;
226
228
  }
229
+ if (key.ctrl && key.name === 'v') {
230
+ this.pasteClipboard();
231
+ return undefined;
232
+ }
227
233
  if (key.name === 'tab') {
228
- st.mode = st.mode === 'agent' ? 'plan' : 'agent';
229
- this.toast(`mode: ${st.mode}`);
234
+ st.mode = nextMode(st.mode);
235
+ this.toast(`mode: ${modeLabel(st.mode)}`);
230
236
  return undefined;
231
237
  }
232
238
  if (key.name === 'f1') return this.openHelp();
@@ -288,6 +294,8 @@ class App {
288
294
  return undefined;
289
295
  case 's':
290
296
  return this.stopTurn();
297
+ case 'a':
298
+ return this.toggleApproval();
291
299
  case 'p':
292
300
  return this.openPalette();
293
301
  default:
@@ -449,10 +457,11 @@ class App {
449
457
  this.state.details = !this.state.details;
450
458
  this.toast(`details ${this.state.details ? 'on' : 'off'}`);
451
459
  } },
452
- { title: 'Switch mode (agent / plan)', hint: 'tab', run: () => {
453
- this.state.mode = this.state.mode === 'agent' ? 'plan' : 'agent';
454
- this.toast(`mode: ${this.state.mode}`);
460
+ { title: 'Switch mode (Build / Plan / Computer)', hint: 'tab', run: () => {
461
+ this.state.mode = nextMode(this.state.mode);
462
+ this.toast(`mode: ${modeLabel(this.state.mode)}`);
455
463
  } },
464
+ { title: `Approval: ${this.approval === 'proceed' ? 'Proceed' : 'Ask and Proceed'}`, hint: 'ctrl+x a', run: () => this.toggleApproval() },
456
465
  { title: 'Theme', hint: 'ctrl+x t', run: () => {
457
466
  const names = themeNames();
458
467
  const next = names[(names.indexOf(this.themeName) + 1) % names.length];
@@ -473,17 +482,62 @@ class App {
473
482
 
474
483
  /** `/help`, `/new`, `/tasks` … — opencode-style slash commands */
475
484
  slashCommands() {
485
+ const by = (prefix) => {
486
+ const found = this.commands().find((cmd) => cmd.title.startsWith(prefix));
487
+ return () => (found ? found.run() : undefined);
488
+ };
476
489
  return [
477
- { slash: '/new', title: 'Start a new session', hint: 'ctrl+x n', group: 'Session', run: () => this.commands()[1].run() },
490
+ { slash: '/new', title: 'Start a new session', hint: 'ctrl+x n', group: 'Session', run: by('New session') },
478
491
  { slash: '/tasks', title: 'Show the task queue', hint: 'ctrl+x l', group: 'Session', run: () => this.openTasks() },
479
- { slash: '/mode', title: 'Switch agent / plan', hint: 'tab', group: 'Session', run: () => this.commands()[5].run() },
480
- { slash: '/theme', title: 'Cycle the colour theme', hint: 'ctrl+x t', group: 'Session', run: () => this.commands()[6].run() },
481
- { slash: '/status', title: 'Ping the bridge', hint: '', group: 'Session', run: () => this.commands()[7].run() },
492
+ { slash: '/mode', title: 'Switch agent / plan', hint: 'tab', group: 'Session', run: by('Switch mode') },
493
+ { slash: '/ask', title: 'Ask and Proceed: ask before commands, files and plans', hint: '', group: 'Session', run: () => this.setApproval('ask') },
494
+ { slash: '/proceed', title: 'Proceed: approve the plan and run everything without asking', hint: '', group: 'Session', run: () => this.setApproval('proceed') },
495
+ { slash: '/theme', title: 'Cycle the colour theme', hint: 'ctrl+x t', group: 'Session', run: by('Theme') },
496
+ { slash: '/status', title: 'Ping the bridge', hint: '', group: 'Session', run: by('Bridge status') },
482
497
  { slash: '/help', title: 'Every keybinding', hint: 'f1', group: 'Session', run: () => this.openHelp() },
483
498
  { slash: '/quit', title: 'Leave arenacode', hint: 'ctrl+x q', group: 'Session', run: () => this.quit() },
484
499
  ];
485
500
  }
486
501
 
502
+ /** Ask and Proceed ↔ Proceed, the desktop's Settings switch. */
503
+ async toggleApproval() {
504
+ await this.setApproval(this.approval === 'proceed' ? 'ask' : 'proceed');
505
+ }
506
+
507
+ async setApproval(mode) {
508
+ const next = normaliseApproval(mode);
509
+ if (next === this.approval) {
510
+ this.toast(`approval: ${next === 'proceed' ? 'Proceed' : 'Ask and Proceed'}`);
511
+ return;
512
+ }
513
+ if (next === 'proceed') {
514
+ const danger = dangerText(this.lang);
515
+ const answer = await this.confirm({
516
+ title: danger.title,
517
+ subject: danger.body,
518
+ options: [
519
+ { key: 'y', label: 'enable Proceed', tone: 'err' },
520
+ { key: 'n', label: 'keep Ask and Proceed', tone: 'ok' },
521
+ ],
522
+ });
523
+ if (answer !== 'y') {
524
+ this.toast('approval: Ask and Proceed');
525
+ return;
526
+ }
527
+ }
528
+ this.approval = next;
529
+ try {
530
+ patchConfig({ approval: next });
531
+ } catch {
532
+ /* a read-only HOME keeps the switch in memory only */
533
+ }
534
+ this.push({
535
+ kind: next === 'proceed' ? 'error' : 'system',
536
+ text: `approval: ${next === 'proceed' ? 'Proceed' : 'Ask and Proceed'} · ${approvalHelp(this.lang, this.state.mode)}`,
537
+ });
538
+ this.toast(`approval: ${next === 'proceed' ? 'Proceed' : 'Ask and Proceed'}`, next === 'proceed' ? 'warn' : 'ok');
539
+ }
540
+
487
541
  onOverlayKey(key) {
488
542
  const ov = this.state.overlay;
489
543
  if (key.name === 'escape') {
@@ -565,6 +619,42 @@ class App {
565
619
  }
566
620
  }
567
621
 
622
+ /** ctrl+v: a clipboard image becomes an attachment, otherwise text is pasted. */
623
+ async pasteClipboard() {
624
+ const st = this.state;
625
+ if (st.status === 'running') {
626
+ this.toast('a turn is running: press esc to stop', 'warn');
627
+ return;
628
+ }
629
+ if (!this.url) {
630
+ this.toast('no bridge URL: run arenacode init', 'error');
631
+ return;
632
+ }
633
+ const tmp = path.join(require('os').tmpdir(), `arenacode-clip-${stamp()}-${process.pid}.png`);
634
+ if (grabImage(tmp)) {
635
+ const res = await attachImage(this.bridge, this.folder, tmp, { lang: this.lang });
636
+ if (res.error) {
637
+ this.toast(strings(this.lang).noImage, 'error');
638
+ return;
639
+ }
640
+ st.pendingImages.push(res.remote);
641
+ this.push({ kind: 'system', text: `${strings(this.lang).pasted}: ${res.rel}` });
642
+ this.toast(`${strings(this.lang).pasted}: ${res.rel}`);
643
+ this.invalidate();
644
+ return;
645
+ }
646
+ const text = readClipboardText();
647
+ if (text) {
648
+ const before = st.input.slice(0, st.cursor);
649
+ const after = st.input.slice(st.cursor);
650
+ st.input = before + text + after;
651
+ st.cursor += text.length;
652
+ this.invalidate();
653
+ } else {
654
+ this.toast(strings(this.lang).noImage, 'warn');
655
+ }
656
+ }
657
+
568
658
  // ---------------------------------------------------------------- turn
569
659
  async submit(text) {
570
660
  const st = this.state;
@@ -590,18 +680,19 @@ class App {
590
680
  return;
591
681
  }
592
682
 
593
- let instruction = text;
594
- const attachments = [];
683
+ // @file uploads the file, @folder inlines its listing, exactly like the desktop client
595
684
  const refs = [...text.matchAll(/@([^\s]+)/g)].map((m) => m[1]);
596
- for (const ref of refs) {
597
- const abs = path.resolve(this.folder, ref);
598
- if (!fs.existsSync(abs)) continue;
599
- const name = `attachments/${Date.now()}-${path.basename(abs)}`;
600
- const up = await this.bridge.upload(name, fs.readFileSync(abs));
601
- if (up.status === 200) attachments.push(up.json.path);
602
- }
603
- if (attachments.length) {
604
- instruction += `\n\n[attachments in the workspace: ${attachments.join(', ')}]`;
685
+ let instruction = text;
686
+ if (refs.length || st.pendingImages.length) {
687
+ const att = await attachRefs(this.bridge, this.folder, refs, { lang: this.lang });
688
+ for (const w of att.warnings) this.toast(w, 'warn');
689
+ const images = st.pendingImages.splice(0, st.pendingImages.length);
690
+ instruction = decorate(text, {
691
+ uploaded: [...att.uploaded, ...images],
692
+ blocks: att.blocks,
693
+ paths: att.paths,
694
+ lang: this.lang,
695
+ });
605
696
  }
606
697
 
607
698
  this.push({ kind: 'user', text: text.replace(/@([^\s]+)/g, '$1') });
@@ -625,6 +716,7 @@ class App {
625
716
  return;
626
717
  }
627
718
  st.taskId = res.json.task_id;
719
+ session.remember(st.taskId, st.mode);
628
720
  st.since = 0;
629
721
  st.seqItems.clear();
630
722
  }
@@ -805,7 +897,15 @@ class App {
805
897
 
806
898
  if (type === 'exec') {
807
899
  item.kindDetail = payload.command;
808
- const answer = this.autoApprove
900
+ const verdict = decide({
901
+ approval: this.approval,
902
+ mode: session.modeOf(taskId),
903
+ planApproved: session.planApproved(taskId),
904
+ kind: 'exec',
905
+ admin: Boolean(payload.admin),
906
+ yes: this.autoApprove,
907
+ });
908
+ const answer = verdict === 'auto'
809
909
  ? 'y'
810
910
  : await this.confirm({
811
911
  title: 'terminal',
@@ -839,7 +939,16 @@ class App {
839
939
  if (type === 'fs') {
840
940
  const op = String(payload.op || '');
841
941
  const mutating = ['write', 'replace', 'rm', 'mv', 'mkdir'].includes(op);
842
- if (mutating && this.confirmWrites) {
942
+ const fsVerdict = mutating
943
+ ? decide({
944
+ approval: this.approval,
945
+ mode: session.modeOf(taskId),
946
+ planApproved: session.planApproved(taskId),
947
+ kind: 'fs',
948
+ yes: this.autoApprove,
949
+ })
950
+ : 'auto';
951
+ if (mutating && (fsVerdict === 'ask' || this.confirmWrites)) {
843
952
  const approved = (await this.confirm({
844
953
  title: `file · ${op}`,
845
954
  subject: payload.path || '',
@@ -876,6 +985,20 @@ class App {
876
985
  if (type === 'ui') {
877
986
  const op = String(payload.op || '');
878
987
  if (op === 'plan') {
988
+ const planVerdict = decide({
989
+ approval: this.approval,
990
+ mode: session.modeOf(taskId),
991
+ kind: 'plan',
992
+ yes: false,
993
+ });
994
+ if (planVerdict === 'auto') {
995
+ session.markPlanApproved(taskId);
996
+ this.push({ kind: 'system', text: `plan approved by the client (Proceed): ${String(payload.text || '').split('\n')[0]}` });
997
+ await this.bridge.localResult(taskId, { rid: payload.rid, output: JSON.stringify({ ok: true, approved: true }), code: 0 });
998
+ item.state = 'done';
999
+ this.invalidate();
1000
+ return;
1001
+ }
879
1002
  const answer = await this.confirm({
880
1003
  title: 'plan',
881
1004
  subject: payload.text || '',
@@ -887,7 +1010,10 @@ class App {
887
1010
  ],
888
1011
  });
889
1012
  let payloadOut;
890
- if (answer === 'a') payloadOut = { ok: true, approved: true };
1013
+ if (answer === 'a') {
1014
+ payloadOut = { ok: true, approved: true };
1015
+ session.markPlanApproved(taskId);
1016
+ }
891
1017
  else if (answer === 'f') {
892
1018
  const feedback = await this.prompt('how should it be revised?');
893
1019
  payloadOut = { ok: false, approved: false, revise: true, feedback: feedback || '' };
@@ -1032,12 +1158,14 @@ class App {
1032
1158
  }
1033
1159
 
1034
1160
  agentName() {
1035
- return this.state.mode === 'plan' ? 'Plan' : 'Build';
1161
+ return modeLabel(this.state.mode);
1036
1162
  }
1037
1163
 
1038
1164
  agentColor() {
1039
1165
  // opencode gives each agent its own colour; Build is the secondary blue, Plan the accent violet
1040
- return this.state.mode === 'plan' ? this.theme.c.accent : this.theme.c.secondary;
1166
+ if (this.state.mode === 'plan') return this.theme.c.accent;
1167
+ if (this.state.mode === 'computer-agent') return this.theme.c.success;
1168
+ return this.theme.c.secondary;
1041
1169
  }
1042
1170
 
1043
1171
  promptWidth(W) {
@@ -1054,7 +1182,7 @@ class App {
1054
1182
  const hints = busy
1055
1183
  ? []
1056
1184
  : [
1057
- ['tab', st.mode === 'plan' ? 'plan' : 'agents'],
1185
+ ['tab', modeLabel(st.mode).toLowerCase()],
1058
1186
  ['ctrl+p', 'commands'],
1059
1187
  ];
1060
1188
  const block = W2.promptBlock(t, {
@@ -1339,14 +1467,16 @@ function HELP_ROWS() {
1339
1467
  ['Prompt', 'enter', 'send the message'],
1340
1468
  ['Prompt', 'shift+enter', 'newline'],
1341
1469
  ['Prompt', 'esc', 'interrupt the turn'],
1342
- ['Prompt', 'tab', 'switch agent / plan'],
1470
+ ['Prompt', 'tab', 'switch mode: Build / Plan / Computer'],
1343
1471
  ['Prompt', 'ctrl+p', 'command palette'],
1344
1472
  ['Prompt', '/', 'slash commands'],
1345
- ['Prompt', '@', 'attach a file from the folder'],
1473
+ ['Prompt', '@', 'mention a file or a folder'],
1474
+ ['Prompt', 'ctrl+v', 'paste: a clipboard image becomes an attachment'],
1346
1475
  ['Prompt', '!', 'run a command locally'],
1347
1476
  ['Prompt', 'up / down', 'history'],
1348
1477
  ['Session', 'ctrl+x n', 'new session'],
1349
1478
  ['Session', 'ctrl+x l', 'task queue'],
1479
+ ['Session', 'ctrl+x a', 'Ask and Proceed / Proceed'],
1350
1480
  ['Session', 'ctrl+x t', 'switch theme'],
1351
1481
  ['Session', 'ctrl+x d', 'toggle tool output'],
1352
1482
  ['Session', 'pgup / pgdn', 'scroll'],