@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 +28 -3
- package/package.json +2 -2
- package/src/approval.js +98 -0
- package/src/attach.js +188 -0
- package/src/cli.js +36 -33
- package/src/clipboard.js +133 -0
- package/src/config.js +1 -0
- package/src/local.js +42 -2
- package/src/modes.js +55 -0
- package/src/session.js +33 -0
- package/src/tui/app.js +171 -41
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
|
|
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 · `
|
|
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.
|
|
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",
|
package/src/approval.js
ADDED
|
@@ -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
|
|
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
|
|
307
|
-
for (const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
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(` --
|
|
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('');
|
package/src/clipboard.js
ADDED
|
@@ -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
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
453
|
-
this.state.mode = this.state.mode
|
|
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: (
|
|
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: (
|
|
480
|
-
{ slash: '/
|
|
481
|
-
{ slash: '/
|
|
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
|
-
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
const
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
|
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
|
-
|
|
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')
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1470
|
+
['Prompt', 'tab', 'switch mode: Build / Plan / Computer'],
|
|
1343
1471
|
['Prompt', 'ctrl+p', 'command palette'],
|
|
1344
1472
|
['Prompt', '/', 'slash commands'],
|
|
1345
|
-
['Prompt', '@', '
|
|
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'],
|