@lardexx/arenacode 2.10.0 → 2.11.0

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
@@ -271,6 +271,41 @@ Every file path is resolved **inside the open folder**; paths that escape it are
271
271
  the folder itself. Local operations run with your user's permissions — `--confirm-writes` adds a confirmation for
272
272
  mutating file operations, `--yes` skips confirmations for terminal commands.
273
273
 
274
+ ### Project context
275
+
276
+ Every folder gets its own log, `context_<project>.md`, next to the files you are working on. It is the desktop
277
+ client's file, with the desktop's own template (mined from `ArenaCode-x64.exe`):
278
+
279
+ ```markdown
280
+ # Contesto del progetto progetto-web
281
+
282
+ Sessione: 2026-11-02 21:45 (nome-del-bridge.tunnl.gg)
283
+ _Nessun task ancora._
284
+ <!-- agente:inizio -->
285
+ <!-- agente:fine -->
286
+
287
+ Aggiornato automaticamente da ArenaCode a fine di ogni job. La API key non è scritta qui.
288
+ ```
289
+
290
+ The title, the session label, the placeholder and the footer follow the interface language (`it`, `en`, `es`).
291
+ What happens when:
292
+
293
+ * **The folder opens or a session starts** → the client creates the file if it is missing, and adds a `Sessione:`
294
+ line for the new session. An existing file is **never overwritten**: the agent's block lives inside it.
295
+ * **At the end of every task** the agent rewrites the block between `<!-- agente:inizio -->` and
296
+ `<!-- agente:fine -->` (date, task id, files changed, commits, decisions, what is still open, how it was checked —
297
+ at most 40 lines). The client saves it **at once, without Accept or Reject**: the card only shows what changed.
298
+ * **The first task of a new bridge session** carries the whole file as `project_context`, so the bridge writes it
299
+ to `runtime/project_context.txt` and tells the agent to read it in parts (`agentctl.py context --part N`). Later
300
+ tasks do not repeat it — a new bridge (`/health` → `started_at`) or a different folder makes it travel again.
301
+ The file is never summarised or truncated.
302
+ * **Commits** never include it: `git add -A` runs, then the sheet is unstaged — `git add -A, never
303
+ context_<project>.md`, as the desktop puts it.
304
+
305
+ `test/context.js` checks the template verbatim in the three languages, the "create once, never overwrite" rule,
306
+ the send-once-per-session rule and the git exclusion; the integration test checks that a real task creates the
307
+ sheet and that the bridge receives it.
308
+
274
309
  ### Shells
275
310
 
276
311
  Terminal cards run in a real shell, and which one is not fixed:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lardexx/arenacode",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "description": "Terminal client for ArenaCode: talks to an arena-bridge, streams the Agent Mode timeline, and runs local commands, file operations and computer automation on your PC (windows-use.dll on Windows, Xlib/XTest on Linux, osascript on macOS). Node port of the ArenaCode desktop client.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -20,7 +20,7 @@
20
20
  "vendor/"
21
21
  ],
22
22
  "scripts": {
23
- "test": "node test/shell.js && node test/integration.js && node test/parity.js && node test/automation.js && node test/native.js"
23
+ "test": "node test/shell.js && node test/context.js && node test/integration.js && node test/parity.js && node test/automation.js && node test/native.js"
24
24
  },
25
25
  "keywords": [
26
26
  "arenacode",
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
 
@@ -286,16 +287,42 @@ function agentPrompt() {
286
287
  }
287
288
 
288
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
+
289
308
  async function createTask(ctx, instruction, { extraInstructions = '' } = {}) {
290
309
  const meta = buildMeta(ctx.folder, { lang: ctx.lang, pcAccess: ctx.pcAccess, activeFile: '' });
291
- const projectContext = readProjectContext(ctx.folder);
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) : '';
292
316
  const body = {
293
317
  instruction,
294
318
  wait: 0,
295
319
  meta,
296
320
  agent_prompt: agentPrompt(),
297
321
  };
298
- if (projectContext) body.project_context = projectContext;
322
+ if (projectContext) {
323
+ body.project_context = projectContext;
324
+ markContextSent({ folder: ctx.folder, url: ctx.url, startedAt });
325
+ }
299
326
  if (extraInstructions) body.instruction = `${instruction}\n\n${extraInstructions}`;
300
327
 
301
328
  const res = await ctx.bridge.createTask(body);
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;
@@ -426,6 +435,8 @@ function runFsOp(op, body, root) {
426
435
  case 'git_commit': {
427
436
  const add = git(['add', '-A'], root);
428
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);
429
440
  const commit = git(['commit', '-m', String(body.content || 'ArenaCode')], root);
430
441
  return { ok: commit.ok, output: commit.output };
431
442
  }
@@ -475,7 +486,9 @@ async function handleLocalFs(bridge, taskId, request, opts) {
475
486
  write(` ${c.bold(c.yellow('▤ files'))} ${c.dim('·')} ${c.cyan(op)} ${c.gray(request.path || '.')}${request.to ? c.gray(` → ${request.to}`) : ''}`);
476
487
 
477
488
  const mutating = ['write', 'replace', 'rm', 'mv', 'mkdir'].includes(op);
478
- const verdict = mutating
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
479
492
  ? decide({
480
493
  approval: opts.approval,
481
494
  mode: session.modeOf(taskId),
@@ -485,7 +498,7 @@ async function handleLocalFs(bridge, taskId, request, opts) {
485
498
  })
486
499
  : 'auto';
487
500
  // --confirm-writes keeps asking even when the client runs in Proceed
488
- const mustAsk = mutating && (verdict === 'ask' || opts.confirmWrites);
501
+ const mustAsk = mutating && !isProjectLog && (verdict === 'ask' || opts.confirmWrites);
489
502
  if (mustAsk) {
490
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']);
491
504
  if (key === 'n') {
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 client sends the content of `context_<project>.md` from the
122
- * open folder when a bridge session starts, so the agent can resume the project.
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
- const root = path.resolve(folder);
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 };
package/src/tui/app.js CHANGED
@@ -16,6 +16,7 @@ const { Bridge } = require('../bridge');
16
16
  const { buildMeta, readProjectContext } = require('../meta');
17
17
  const { runShell, runFsOp, resolveInRoot, detectShell, availableShells, shellCommand } = require('../local');
18
18
  const { readConfig, patchConfig, deviceId, resolveUrl, resolveKey, openFolder, VERSION } = require('../config');
19
+ const { ensureContextFile, touchSession } = require('../context');
19
20
 
20
21
  const { MODE_LINES, modeLabel, nextMode, normaliseMode } = require('../modes');
21
22
  const { attachRefs, attachImage, decorate, strings } = require('../attach');
@@ -90,6 +91,14 @@ class App {
90
91
 
91
92
  // ---------------------------------------------------------------- lifecycle
92
93
  async start() {
94
+ // the sheet belongs to the folder, not to the task: it is there as soon as the
95
+ // folder is open, and it records this session next to the previous ones
96
+ try {
97
+ const made = ensureContextFile(this.folder, { lang: this.lang, bridgeUrl: this.url });
98
+ if (!made.created) touchSession(this.folder, { lang: this.lang, bridgeUrl: this.url });
99
+ } catch {
100
+ /* a read-only folder must not stop the client */
101
+ }
93
102
  this.term.enter();
94
103
  this.term.on('key', (chunk) => {
95
104
  this.parser.feed(chunk);