@lardexx/arenacode 2.9.0 → 2.11.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 +68 -12
- package/package.json +2 -2
- package/src/automation/linux.js +2 -40
- package/src/automation/mac.js +657 -0
- package/src/automation/png.js +47 -0
- package/src/automation.js +20 -3
- package/src/cli.js +34 -4
- package/src/config.js +1 -0
- package/src/context.js +234 -0
- package/src/local.js +121 -13
- package/src/meta.js +5 -18
- package/src/tui/app.js +49 -2
package/src/automation.js
CHANGED
|
@@ -1003,11 +1003,24 @@ let nativeKind = null;
|
|
|
1003
1003
|
|
|
1004
1004
|
try {
|
|
1005
1005
|
if (isLinux) {
|
|
1006
|
+
// Linux has no fallback any more: the FFI backend *is* the backend (the old one
|
|
1007
|
+
// shelled out to xdotool, which is exactly what we do not want).
|
|
1006
1008
|
nativeBackend = require('./automation/linux').backend;
|
|
1007
1009
|
nativeKind = 'x11';
|
|
1008
1010
|
} else if (isWin) {
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
+
// The DLL ships in the package; PowerShell stays only as a safety net for the case
|
|
1012
|
+
// where it cannot be loaded at all.
|
|
1013
|
+
const dll = require('./automation/win-dll');
|
|
1014
|
+
if (dll.load()) {
|
|
1015
|
+
nativeBackend = dll.backend;
|
|
1016
|
+
nativeKind = 'dll';
|
|
1017
|
+
}
|
|
1018
|
+
} else if (isMac) {
|
|
1019
|
+
const mac = require('./automation/mac');
|
|
1020
|
+
if (mac.available().ok) {
|
|
1021
|
+
nativeBackend = mac.backend;
|
|
1022
|
+
nativeKind = 'mac';
|
|
1023
|
+
}
|
|
1011
1024
|
}
|
|
1012
1025
|
} catch (err) {
|
|
1013
1026
|
nativeBackend = null;
|
|
@@ -1023,7 +1036,11 @@ if (nativeBackend) {
|
|
|
1023
1036
|
};
|
|
1024
1037
|
|
|
1025
1038
|
Object.assign(automation, {
|
|
1026
|
-
native: {
|
|
1039
|
+
native: {
|
|
1040
|
+
kind: nativeKind,
|
|
1041
|
+
name: nativeBackend.name,
|
|
1042
|
+
dll: nativeKind === 'dll' ? require('./automation/win-dll').locate() : null,
|
|
1043
|
+
},
|
|
1027
1044
|
available: () => nativeBackend.available(),
|
|
1028
1045
|
screenSize: () => nativeBackend.screenSize(),
|
|
1029
1046
|
windows: () => nativeBackend.windows(),
|
package/src/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ const { attachRefs, decorate } = require('./attach');
|
|
|
14
14
|
const { decide, dangerText, normaliseApproval } = require('./approval');
|
|
15
15
|
const session = require('./session');
|
|
16
16
|
const { buildMeta, readProjectContext } = require('./meta');
|
|
17
|
+
const { ensureContextFile, shouldSend: shouldSendContext, markSent: markContextSent } = require('./context');
|
|
17
18
|
const { followTask } = require('./turn');
|
|
18
19
|
const { c, write, ask, banner, truncate, indentLines } = require('./ui');
|
|
19
20
|
|
|
@@ -103,6 +104,7 @@ function context(flags) {
|
|
|
103
104
|
json: Boolean(flags.json),
|
|
104
105
|
confirmWrites: Boolean(flags['confirm-writes']),
|
|
105
106
|
approval: normaliseApproval(flags.proceed ? 'proceed' : flags.ask ? 'ask' : cfg.approval),
|
|
107
|
+
shell: String(flags.shell || process.env.ARENACODE_SHELL || cfg.shell || '').trim(),
|
|
106
108
|
idleTimeout: Number(flags['idle-timeout'] || cfg.idle_timeout || 180),
|
|
107
109
|
bridge: new Bridge(url, key, device),
|
|
108
110
|
};
|
|
@@ -194,7 +196,7 @@ function cmdConfig(positional, flags) {
|
|
|
194
196
|
const name = key;
|
|
195
197
|
const val = rest.join(' ');
|
|
196
198
|
if (!name || val === '') {
|
|
197
|
-
write(`usage: ${SELF} config set <url|key|lang|mode|approval|pc_access> <value>`);
|
|
199
|
+
write(`usage: ${SELF} config set <url|key|lang|mode|approval|pc_access|shell> <value>`);
|
|
198
200
|
return 2;
|
|
199
201
|
}
|
|
200
202
|
if (name === 'approval' && !['ask', 'proceed'].includes(String(val))) {
|
|
@@ -285,16 +287,42 @@ function agentPrompt() {
|
|
|
285
287
|
}
|
|
286
288
|
|
|
287
289
|
/** POST /task exactly like the desktop client does. */
|
|
290
|
+
/**
|
|
291
|
+
* The bridge says it itself: `started_at` in /health changes every time the bridge
|
|
292
|
+
* starts. That is how the client knows a session is new and the sheet has to be sent.
|
|
293
|
+
*/
|
|
294
|
+
const bridgeStarts = new Map();
|
|
295
|
+
async function bridgeStartedAt(bridge, url) {
|
|
296
|
+
if (bridgeStarts.has(url)) return bridgeStarts.get(url);
|
|
297
|
+
let startedAt = null;
|
|
298
|
+
try {
|
|
299
|
+
const res = await bridge.health();
|
|
300
|
+
if (res.status === 200 && res.json) startedAt = res.json.started_at ?? null;
|
|
301
|
+
} catch {
|
|
302
|
+
startedAt = null;
|
|
303
|
+
}
|
|
304
|
+
bridgeStarts.set(url, startedAt);
|
|
305
|
+
return startedAt;
|
|
306
|
+
}
|
|
307
|
+
|
|
288
308
|
async function createTask(ctx, instruction, { extraInstructions = '' } = {}) {
|
|
289
309
|
const meta = buildMeta(ctx.folder, { lang: ctx.lang, pcAccess: ctx.pcAccess, activeFile: '' });
|
|
290
|
-
|
|
310
|
+
// the sheet exists from the moment the folder is used: the agent fills the block
|
|
311
|
+
// between the markers at the end of every task, the client only writes the frame
|
|
312
|
+
ensureContextFile(ctx.folder, { lang: ctx.lang, bridgeUrl: ctx.url });
|
|
313
|
+
const startedAt = await bridgeStartedAt(ctx.bridge, ctx.url);
|
|
314
|
+
const sendContext = shouldSendContext({ folder: ctx.folder, url: ctx.url, startedAt });
|
|
315
|
+
const projectContext = sendContext ? readProjectContext(ctx.folder) : '';
|
|
291
316
|
const body = {
|
|
292
317
|
instruction,
|
|
293
318
|
wait: 0,
|
|
294
319
|
meta,
|
|
295
320
|
agent_prompt: agentPrompt(),
|
|
296
321
|
};
|
|
297
|
-
if (projectContext)
|
|
322
|
+
if (projectContext) {
|
|
323
|
+
body.project_context = projectContext;
|
|
324
|
+
markContextSent({ folder: ctx.folder, url: ctx.url, startedAt });
|
|
325
|
+
}
|
|
298
326
|
if (extraInstructions) body.instruction = `${instruction}\n\n${extraInstructions}`;
|
|
299
327
|
|
|
300
328
|
const res = await ctx.bridge.createTask(body);
|
|
@@ -337,6 +365,7 @@ async function runInstruction(ctx, text, { attachments = [] } = {}) {
|
|
|
337
365
|
const result = await followTask(ctx.bridge, taskId, {
|
|
338
366
|
folder: ctx.folder,
|
|
339
367
|
lang: ctx.lang,
|
|
368
|
+
shell: ctx.shell,
|
|
340
369
|
yes: ctx.yes,
|
|
341
370
|
approval: ctx.approval,
|
|
342
371
|
confirmWrites: ctx.confirmWrites,
|
|
@@ -400,6 +429,7 @@ async function cmdUi(flags) {
|
|
|
400
429
|
yes: ctx.yes,
|
|
401
430
|
approval: ctx.approval,
|
|
402
431
|
confirmWrites: ctx.confirmWrites,
|
|
432
|
+
shell: ctx.shell,
|
|
403
433
|
});
|
|
404
434
|
await app.start();
|
|
405
435
|
return 0;
|
|
@@ -688,7 +718,7 @@ async function cmdHandshake(flags) {
|
|
|
688
718
|
const USAGE = {
|
|
689
719
|
init: [['init', 'configure bridge URL + API key, then test the connection']],
|
|
690
720
|
config: [['config [show|path]', 'read the saved configuration'],
|
|
691
|
-
['config set <key> <value>', 'url · key · lang · mode · pc_access']],
|
|
721
|
+
['config set <key> <value>', 'url · key · lang · mode · pc_access · shell']],
|
|
692
722
|
ping: [['ping', 'bridge status and API key check']],
|
|
693
723
|
doctor: [['doctor', 'local checks: node, platform, folder, git, bridge']],
|
|
694
724
|
ask: [['ask "instruction"', 'run one task and stream the timeline']],
|
package/src/config.js
CHANGED
package/src/context.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The project context sheet: `context_<project>.md` in the open folder.
|
|
4
|
+
//
|
|
5
|
+
// Mined from ArenaCode-x64.exe (the literals are the desktop client's own, UTF-16 in
|
|
6
|
+
// .rdata, built by the function at 0x14002f7f0):
|
|
7
|
+
//
|
|
8
|
+
// '# Contesto del progetto ' + name + '\n\n'
|
|
9
|
+
// 'Sessione: ' + … + ' (' + … + ')\n'
|
|
10
|
+
// '_Nessun task ancora._\n'
|
|
11
|
+
// '<!-- agente:inizio -->\n'
|
|
12
|
+
// '<!-- agente:fine -->\n\n'
|
|
13
|
+
// 'Aggiornato automaticamente da ArenaCode a fine di ogni job. La API key non è scritta qui.\n\n'
|
|
14
|
+
//
|
|
15
|
+
// The client creates the file when the folder opens or a session starts, sends the whole
|
|
16
|
+
// file to the bridge with the first task of a new bridge session (bridge_server.py:
|
|
17
|
+
// "arriva con il primo task di un bridge nuovo"), and never summarises it. The agent
|
|
18
|
+
// rewrites the block between the two HTML comments at the end of every task — the client
|
|
19
|
+
// only writes the frame, never the block.
|
|
20
|
+
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const os = require('node:os');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
|
|
25
|
+
const MARK_START = '<!-- agente:inizio -->';
|
|
26
|
+
const MARK_END = '<!-- agente:fine -->';
|
|
27
|
+
|
|
28
|
+
const TEXT = {
|
|
29
|
+
it: {
|
|
30
|
+
title: '# Contesto del progetto ',
|
|
31
|
+
session: 'Sessione: ',
|
|
32
|
+
noTask: '_Nessun task ancora._',
|
|
33
|
+
footer: 'Aggiornato automaticamente da ArenaCode a fine di ogni job. La API key non è scritta qui.',
|
|
34
|
+
},
|
|
35
|
+
en: {
|
|
36
|
+
title: '# Project context ',
|
|
37
|
+
session: 'Session: ',
|
|
38
|
+
noTask: '_No task yet._',
|
|
39
|
+
footer: 'Updated automatically by ArenaCode at the end of each job. The API key is not written here.',
|
|
40
|
+
},
|
|
41
|
+
es: {
|
|
42
|
+
title: '# Contexto del proyecto ',
|
|
43
|
+
session: 'Sesión: ',
|
|
44
|
+
noTask: '_Ningún trabajo todavía._',
|
|
45
|
+
footer: 'Actualizado automáticamente por ArenaCode al final de cada trabajo. La clave API no se escribe aquí.',
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function pack(lang) {
|
|
50
|
+
return TEXT[lang] || TEXT.it;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `Progetto web` → `Progetto-web`; keeps the name readable, drops what a filesystem hates. */
|
|
54
|
+
function projectName(folder) {
|
|
55
|
+
const base = path.basename(path.resolve(String(folder || '.'))) || 'progetto';
|
|
56
|
+
const clean = base
|
|
57
|
+
.replace(/[\\/:*?"<>|]/g, '-')
|
|
58
|
+
.replace(/\s+/g, '-')
|
|
59
|
+
.replace(/^[.-]+/, '')
|
|
60
|
+
.slice(0, 60);
|
|
61
|
+
return clean || 'progetto';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function contextFileName(folder) {
|
|
65
|
+
return `context_${projectName(folder)}.md`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function contextFilePath(folder) {
|
|
69
|
+
return path.join(path.resolve(String(folder || '.')), contextFileName(folder));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function stamp(date = new Date()) {
|
|
73
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
74
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Where the session line's parenthetical comes from: the bridge, or the machine. */
|
|
78
|
+
function sessionHost(bridgeUrl) {
|
|
79
|
+
const raw = String(bridgeUrl || '').trim();
|
|
80
|
+
if (raw) {
|
|
81
|
+
try {
|
|
82
|
+
return new URL(raw.includes('://') ? raw : `https://${raw}`).host;
|
|
83
|
+
} catch {
|
|
84
|
+
return raw;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return os.hostname();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The frame the client writes, byte for byte in the desktop's order. */
|
|
91
|
+
function buildTemplate({ folder, lang = 'it', bridgeUrl = '', now = new Date() } = {}) {
|
|
92
|
+
const t = pack(lang);
|
|
93
|
+
return (
|
|
94
|
+
`${t.title}${projectName(folder)}\n` +
|
|
95
|
+
'\n' +
|
|
96
|
+
`${t.session}${stamp(now)} (${sessionHost(bridgeUrl)})\n` +
|
|
97
|
+
`${t.noTask}\n` +
|
|
98
|
+
`${MARK_START}\n` +
|
|
99
|
+
`${MARK_END}\n` +
|
|
100
|
+
'\n' +
|
|
101
|
+
`${t.footer}\n`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Creates the file if the folder does not have one yet. An existing file is never
|
|
107
|
+
* touched: it is the project's log and the agent's block lives inside it.
|
|
108
|
+
*/
|
|
109
|
+
function ensureContextFile(folder, { lang = 'it', bridgeUrl = '', now = new Date() } = {}) {
|
|
110
|
+
const root = path.resolve(String(folder || '.'));
|
|
111
|
+
const file = contextFilePath(root);
|
|
112
|
+
try {
|
|
113
|
+
const stat = fs.statSync(file);
|
|
114
|
+
if (stat.isFile()) return { ok: true, file, created: false, name: contextFileName(root) };
|
|
115
|
+
} catch {
|
|
116
|
+
/* not there yet */
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
fs.mkdirSync(root, { recursive: true });
|
|
120
|
+
fs.writeFileSync(file, buildTemplate({ folder: root, lang, bridgeUrl, now }), { encoding: 'utf8', flag: 'wx' });
|
|
121
|
+
return { ok: true, file, created: true, name: contextFileName(root) };
|
|
122
|
+
} catch (err) {
|
|
123
|
+
// EEXIST: another window created it first — fine, nothing to do
|
|
124
|
+
if (err.code === 'EEXIST') return { ok: true, file, created: false, name: contextFileName(root) };
|
|
125
|
+
return { ok: false, file, created: false, error: err.message };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Reads the sheet the way the desktop sends it: whole, untouched. */
|
|
130
|
+
function readContext(folder, { maxBytes = 200_000 } = {}) {
|
|
131
|
+
const root = path.resolve(String(folder || '.'));
|
|
132
|
+
let names = [];
|
|
133
|
+
try {
|
|
134
|
+
names = fs.readdirSync(root).filter((name) => /^context_.*\.md$/i.test(name));
|
|
135
|
+
} catch {
|
|
136
|
+
return '';
|
|
137
|
+
}
|
|
138
|
+
if (!names.length) return '';
|
|
139
|
+
// the one for this folder first, then any other context sheet
|
|
140
|
+
const file = names.find((name) => name === contextFileName(root)) || names[0];
|
|
141
|
+
try {
|
|
142
|
+
const full = path.join(root, file);
|
|
143
|
+
if (fs.statSync(full).size > maxBytes) return '';
|
|
144
|
+
return fs.readFileSync(full, 'utf8');
|
|
145
|
+
} catch {
|
|
146
|
+
return '';
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ------------------------------------------------------------------ sending it --
|
|
151
|
+
// The bridge writes what it receives to runtime/project_context.txt and tells the agent
|
|
152
|
+
// to read it in parts; it expects it once per bridge session, not on every task.
|
|
153
|
+
|
|
154
|
+
const STATE_FILE = () => path.join(path.dirname(configDir()), 'context-state.json');
|
|
155
|
+
|
|
156
|
+
function configDir() {
|
|
157
|
+
if (process.platform === 'win32') {
|
|
158
|
+
const base = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
|
159
|
+
return path.join(base, 'ArenaCode', 'config.json');
|
|
160
|
+
}
|
|
161
|
+
return path.join(os.homedir(), '.arenacode', 'config.json');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function readState() {
|
|
165
|
+
try {
|
|
166
|
+
return JSON.parse(fs.readFileSync(STATE_FILE(), 'utf8'));
|
|
167
|
+
} catch {
|
|
168
|
+
return {};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function writeState(state) {
|
|
173
|
+
try {
|
|
174
|
+
fs.mkdirSync(path.dirname(STATE_FILE()), { recursive: true });
|
|
175
|
+
fs.writeFileSync(STATE_FILE(), JSON.stringify(state, null, 2), 'utf8');
|
|
176
|
+
} catch {
|
|
177
|
+
/* a read-only HOME only costs one extra send */
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Should this task carry the project context? Yes on the first task of a bridge
|
|
183
|
+
* session and whenever the folder changes, no for the following tasks.
|
|
184
|
+
*/
|
|
185
|
+
function shouldSend({ folder, url = '', startedAt = null } = {}) {
|
|
186
|
+
const state = readState();
|
|
187
|
+
const project = path.resolve(String(folder || '.'));
|
|
188
|
+
if (state.folder !== project) return true;
|
|
189
|
+
if (!startedAt) return false; // same folder, bridge identity unknown: it already got it
|
|
190
|
+
return state.url !== String(url) || Number(state.startedAt) !== Number(startedAt);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function markSent({ folder, url = '', startedAt = null } = {}) {
|
|
194
|
+
writeState({ folder: path.resolve(String(folder || '.')), url: String(url), startedAt: startedAt ? Number(startedAt) : null });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Appends a session line to the sheet when the client opens it again later. */
|
|
198
|
+
function touchSession(folder, { lang = 'it', bridgeUrl = '', now = new Date() } = {}) {
|
|
199
|
+
const file = contextFilePath(folder);
|
|
200
|
+
let text;
|
|
201
|
+
try {
|
|
202
|
+
text = fs.readFileSync(file, 'utf8');
|
|
203
|
+
} catch {
|
|
204
|
+
return { ok: false, error: 'context file missing' };
|
|
205
|
+
}
|
|
206
|
+
const t = pack(lang);
|
|
207
|
+
const line = `${t.session}${stamp(now)} (${sessionHost(bridgeUrl)})\n`;
|
|
208
|
+
if (text.includes(line)) return { ok: true, file, added: false };
|
|
209
|
+
const at = text.indexOf('\n\n');
|
|
210
|
+
const updated = at >= 0 ? `${text.slice(0, at + 2)}${line}${text.slice(at + 2)}` : `${text}${line}`;
|
|
211
|
+
try {
|
|
212
|
+
fs.writeFileSync(file, updated, 'utf8');
|
|
213
|
+
return { ok: true, file, added: true };
|
|
214
|
+
} catch (err) {
|
|
215
|
+
return { ok: false, file, error: err.message };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = {
|
|
220
|
+
MARK_START,
|
|
221
|
+
MARK_END,
|
|
222
|
+
TEXT,
|
|
223
|
+
projectName,
|
|
224
|
+
contextFileName,
|
|
225
|
+
contextFilePath,
|
|
226
|
+
buildTemplate,
|
|
227
|
+
ensureContextFile,
|
|
228
|
+
readContext,
|
|
229
|
+
shouldSend,
|
|
230
|
+
markSent,
|
|
231
|
+
touchSession,
|
|
232
|
+
sessionHost,
|
|
233
|
+
stamp,
|
|
234
|
+
};
|
package/src/local.js
CHANGED
|
@@ -39,6 +39,15 @@ function resolveInRoot(root, rel) {
|
|
|
39
39
|
return abs;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** `context_<project>.md` — the project log, never committed and never asked about. */
|
|
43
|
+
function contextFiles(root) {
|
|
44
|
+
try {
|
|
45
|
+
return fs.readdirSync(root).filter((name) => /^context_.*\.md$/i.test(name));
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
function relativize(root, abs) {
|
|
43
52
|
const rel = path.relative(root, abs).replace(/\\/g, '/');
|
|
44
53
|
return rel === '' ? '.' : rel;
|
|
@@ -48,22 +57,110 @@ function relativize(root, abs) {
|
|
|
48
57
|
// Local terminal (local-exec)
|
|
49
58
|
// --------------------------------------------------------------------------- //
|
|
50
59
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
60
|
+
// Every shell the client can drive. The bridge may ask for one by name (`shell` in the
|
|
61
|
+
// local-exec request), the user can pin one (`arenacode config set shell zsh`, `/shell`),
|
|
62
|
+
// and otherwise the client takes the user's login shell, like a terminal would.
|
|
63
|
+
const UNIX_SHELLS = {
|
|
64
|
+
sh: '/bin/sh',
|
|
65
|
+
bash: '/bin/bash',
|
|
66
|
+
zsh: '/bin/zsh',
|
|
67
|
+
fish: '/usr/bin/fish',
|
|
68
|
+
dash: '/bin/dash',
|
|
69
|
+
ksh: '/bin/ksh',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const WINDOWS_SHELLS = ['powershell', 'pwsh', 'cmd', 'wsl', 'bash'];
|
|
73
|
+
|
|
74
|
+
function shellExists(file) {
|
|
75
|
+
try {
|
|
76
|
+
fs.accessSync(file, fs.constants.X_OK);
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** sh/bash/zsh/dash/ksh: -l login, -c command. fish spells the same as -l -c. */
|
|
84
|
+
function unixShell(name) {
|
|
85
|
+
const wanted = String(name || '').toLowerCase().replace(/\.exe$/, '');
|
|
86
|
+
const alias = { 'git-bash': 'bash', gitbash: 'bash' }[wanted] || wanted;
|
|
87
|
+
let file = UNIX_SHELLS[alias];
|
|
88
|
+
if (!file && (alias === 'sh' || alias.startsWith('/'))) file = alias === 'sh' ? '/bin/sh' : alias;
|
|
89
|
+
if (!file) file = whichPath(alias);
|
|
90
|
+
if (!file) file = UNIX_SHELLS[alias] || null;
|
|
91
|
+
if (!file) return null;
|
|
92
|
+
const base = path.basename(file);
|
|
93
|
+
const args = base === 'fish' ? ['-l', '-c', null] : ['-lc', null];
|
|
94
|
+
return { file, args };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function whichPath(name) {
|
|
98
|
+
for (const dir of ['/bin', '/usr/bin', '/usr/local/bin', '/opt/homebrew/bin']) {
|
|
99
|
+
const file = path.join(dir, name);
|
|
100
|
+
if (shellExists(file)) return file;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The shell to use when nobody asked for a specific one. */
|
|
106
|
+
function detectShell(platform = process.platform) {
|
|
107
|
+
if (platform === 'win32') {
|
|
108
|
+
if (shellExists('pwsh.exe') || whichPath('pwsh')) return 'pwsh';
|
|
109
|
+
return 'powershell';
|
|
110
|
+
}
|
|
111
|
+
const login = path.basename(process.env.SHELL || '');
|
|
112
|
+
if (login && (UNIX_SHELLS[login] || whichPath(login))) return login;
|
|
113
|
+
return 'sh';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** What the client can offer on this machine — for `sysinfo`, `/shell` and the help. */
|
|
117
|
+
function availableShells(platform = process.platform) {
|
|
118
|
+
if (platform === 'win32') {
|
|
119
|
+
return WINDOWS_SHELLS.filter((name) => name === 'powershell' || name === 'cmd' || Boolean(whichPath(name)));
|
|
120
|
+
}
|
|
121
|
+
const found = Object.keys(UNIX_SHELLS).filter((name) => shellExists(UNIX_SHELLS[name]) || Boolean(whichPath(name)));
|
|
122
|
+
return found.length ? found : ['sh'];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Command template for one shell. `command` is passed as its own argv entry, never
|
|
127
|
+
* through a second round of quoting, so quotes and `$` survive.
|
|
128
|
+
*/
|
|
129
|
+
function shellCommand(shell, command, platform = process.platform) {
|
|
130
|
+
const name = String(shell || detectShell(platform)).toLowerCase().replace(/\.exe$/, '');
|
|
131
|
+
if (platform === 'win32') {
|
|
132
|
+
switch (name) {
|
|
54
133
|
case 'cmd':
|
|
55
134
|
return { file: 'cmd.exe', args: ['/d', '/s', '/c', command] };
|
|
56
135
|
case 'wsl':
|
|
57
136
|
return { file: 'wsl.exe', args: ['sh', '-lc', command] };
|
|
137
|
+
case 'bash': {
|
|
138
|
+
// Git Bash when it is installed, otherwise the WSL bash
|
|
139
|
+
const gitBash = [
|
|
140
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
141
|
+
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
142
|
+
].find((file) => {
|
|
143
|
+
try {
|
|
144
|
+
return fs.existsSync(file);
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
return gitBash
|
|
150
|
+
? { file: gitBash, args: ['-lc', command] }
|
|
151
|
+
: { file: 'wsl.exe', args: ['bash', '-lc', command] };
|
|
152
|
+
}
|
|
153
|
+
case 'pwsh':
|
|
154
|
+
return { file: 'pwsh.exe', args: ['-NoProfile', '-NonInteractive', '-Command', command] };
|
|
58
155
|
case 'powershell':
|
|
156
|
+
case 'ps':
|
|
59
157
|
default:
|
|
60
|
-
return {
|
|
61
|
-
file: shell === 'pwsh' ? 'pwsh.exe' : 'powershell.exe',
|
|
62
|
-
args: ['-NoProfile', '-NonInteractive', '-Command', command],
|
|
63
|
-
};
|
|
158
|
+
return { file: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-Command', command] };
|
|
64
159
|
}
|
|
65
160
|
}
|
|
66
|
-
|
|
161
|
+
const template = unixShell(name) || unixShell('sh');
|
|
162
|
+
const args = template.args.map((arg) => (arg === null ? command : arg));
|
|
163
|
+
return { file: template.file, args };
|
|
67
164
|
}
|
|
68
165
|
|
|
69
166
|
/** Runs a command and collects stdout+stderr and the exit code. */
|
|
@@ -96,9 +193,12 @@ async function handleLocalExec(bridge, taskId, request, opts) {
|
|
|
96
193
|
const { rid, command, cwd: reqCwd, shell, admin, sandbox_reason: sandboxReason } = request;
|
|
97
194
|
const lang = opts.lang;
|
|
98
195
|
const dir = reqCwd ? resolveInRoot(opts.folder, reqCwd) : opts.folder;
|
|
196
|
+
// the bridge names the shell it wrote the command for; otherwise the user's choice
|
|
197
|
+
// from `arenacode config set shell …` / `/shell`, otherwise the login shell
|
|
198
|
+
const chosenShell = shell || opts.shell || detectShell();
|
|
99
199
|
|
|
100
200
|
write('');
|
|
101
|
-
write(` ${c.bold(c.yellow('⌘ terminal'))} ${c.dim('·')} ${c.cyan(
|
|
201
|
+
write(` ${c.bold(c.yellow('⌘ terminal'))} ${c.dim('·')} ${c.cyan(chosenShell)} ${c.dim('·')} ${c.gray(dir)}`);
|
|
102
202
|
write(indentLines(command, ' '));
|
|
103
203
|
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.')}`);
|
|
104
204
|
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)}`);
|
|
@@ -125,7 +225,7 @@ async function handleLocalExec(bridge, taskId, request, opts) {
|
|
|
125
225
|
return true;
|
|
126
226
|
}
|
|
127
227
|
|
|
128
|
-
const { output, code } = await runShell(command, { cwd: dir, shell });
|
|
228
|
+
const { output, code } = await runShell(command, { cwd: dir, shell: chosenShell });
|
|
129
229
|
await bridge.localResult(taskId, { rid, output, code });
|
|
130
230
|
write(` ${code === 0 ? c.green('✓') : c.red('✗')} ${c.gray(`exit ${code}`)}`);
|
|
131
231
|
return true;
|
|
@@ -335,6 +435,8 @@ function runFsOp(op, body, root) {
|
|
|
335
435
|
case 'git_commit': {
|
|
336
436
|
const add = git(['add', '-A'], root);
|
|
337
437
|
if (!add.ok) return { ok: false, error: add.output };
|
|
438
|
+
// the project log stays out of the history, exactly like the desktop client does
|
|
439
|
+
for (const name of contextFiles(root)) git(['reset', '-q', '--', name], root);
|
|
338
440
|
const commit = git(['commit', '-m', String(body.content || 'ArenaCode')], root);
|
|
339
441
|
return { ok: commit.ok, output: commit.output };
|
|
340
442
|
}
|
|
@@ -384,7 +486,9 @@ async function handleLocalFs(bridge, taskId, request, opts) {
|
|
|
384
486
|
write(` ${c.bold(c.yellow('▤ files'))} ${c.dim('·')} ${c.cyan(op)} ${c.gray(request.path || '.')}${request.to ? c.gray(` → ${request.to}`) : ''}`);
|
|
385
487
|
|
|
386
488
|
const mutating = ['write', 'replace', 'rm', 'mv', 'mkdir'].includes(op);
|
|
387
|
-
|
|
489
|
+
// the project log is saved at once: the card only shows what changed, like the desktop
|
|
490
|
+
const isProjectLog = /^context_.*\.md$/i.test(path.basename(String(request.path || '')));
|
|
491
|
+
const verdict = mutating && !isProjectLog
|
|
388
492
|
? decide({
|
|
389
493
|
approval: opts.approval,
|
|
390
494
|
mode: session.modeOf(taskId),
|
|
@@ -394,7 +498,7 @@ async function handleLocalFs(bridge, taskId, request, opts) {
|
|
|
394
498
|
})
|
|
395
499
|
: 'auto';
|
|
396
500
|
// --confirm-writes keeps asking even when the client runs in Proceed
|
|
397
|
-
const mustAsk = mutating && (verdict === 'ask' || opts.confirmWrites);
|
|
501
|
+
const mustAsk = mutating && !isProjectLog && (verdict === 'ask' || opts.confirmWrites);
|
|
398
502
|
if (mustAsk) {
|
|
399
503
|
const key = await askKey(` ${t(lang, 'Applico? [s] sì [n] no', 'Apply? [y] yes [n] no', '¿Aplico? [s] sí [n] no')} `, ['s', 'y', 'n']);
|
|
400
504
|
if (key === 'n') {
|
|
@@ -495,7 +599,8 @@ async function handleLocalUi(bridge, taskId, request, opts) {
|
|
|
495
599
|
cpus: os.cpus().length,
|
|
496
600
|
memory_mb: Math.round(os.totalmem() / 1048576),
|
|
497
601
|
node: process.version,
|
|
498
|
-
shell:
|
|
602
|
+
shell: detectShell(),
|
|
603
|
+
shells: availableShells(),
|
|
499
604
|
};
|
|
500
605
|
await bridge.localResult(taskId, { rid, output: JSON.stringify(info), code: 0 });
|
|
501
606
|
write(` ${c.green('✓')} ${c.gray('sysinfo')}`);
|
|
@@ -683,6 +788,9 @@ async function handleLocalFile(bridge, taskId, request, opts) {
|
|
|
683
788
|
|
|
684
789
|
module.exports = {
|
|
685
790
|
runUiAction,
|
|
791
|
+
shellCommand,
|
|
792
|
+
detectShell,
|
|
793
|
+
availableShells,
|
|
686
794
|
REFUSED,
|
|
687
795
|
resolveInRoot,
|
|
688
796
|
runShell,
|
package/src/meta.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const os = require('node:os');
|
|
5
5
|
const path = require('node:path');
|
|
6
|
+
const { readContext } = require('./context');
|
|
6
7
|
const { execFileSync } = require('node:child_process');
|
|
7
8
|
|
|
8
9
|
const { CLIENT_NAME, VERSION } = require('./config');
|
|
@@ -118,26 +119,12 @@ function buildMeta(folder, { lang = 'it', pcAccess = true, activeFile = '', open
|
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
/**
|
|
121
|
-
* Project context: the
|
|
122
|
-
*
|
|
122
|
+
* Project context: the content of `context_<project>.md` from the open folder, sent to
|
|
123
|
+
* the bridge with the first task of a new session (see src/context.js for the sheet and
|
|
124
|
+
* for the gating).
|
|
123
125
|
*/
|
|
124
126
|
function readProjectContext(folder) {
|
|
125
|
-
|
|
126
|
-
let names = [];
|
|
127
|
-
try {
|
|
128
|
-
names = fs.readdirSync(root).filter((n) => /^context_.*\.md$/i.test(n));
|
|
129
|
-
} catch {
|
|
130
|
-
return '';
|
|
131
|
-
}
|
|
132
|
-
if (!names.length) return '';
|
|
133
|
-
const file = path.join(root, names[0]);
|
|
134
|
-
try {
|
|
135
|
-
const stat = fs.statSync(file);
|
|
136
|
-
if (stat.size > MAX_CONTEXT_BYTES) return '';
|
|
137
|
-
return fs.readFileSync(file, 'utf8');
|
|
138
|
-
} catch {
|
|
139
|
-
return '';
|
|
140
|
-
}
|
|
127
|
+
return readContext(folder, { maxBytes: MAX_CONTEXT_BYTES });
|
|
141
128
|
}
|
|
142
129
|
|
|
143
130
|
module.exports = { POLICY, VERSION, buildMeta, workspaceMeta, readProjectContext, detectTools, which };
|