@lardexx/arenacode 2.11.8 → 2.11.9

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
@@ -327,17 +327,31 @@ Every timeline in the interface is a **session**: its own transcript, its own ta
327
327
  typed in it; `enter` switches to the selected session, `n` (or the `+ new session` row) starts a fresh one.
328
328
  `ctrl+x n` is the shortcut for a new session.
329
329
 
330
- **The history is on disk.** Every session — transcript, task id, mode, label and the file diffs — is saved to
330
+ **The history is on disk, one file per session.** The sessions live next to the config file — the same base
331
+ folder on the three systems, so a portable install (`ARENACODE_CONFIG`) carries its history with it:
332
+
333
+ | System | Directory |
334
+ |---|---|
335
+ | Windows | `%APPDATA%\ArenaCode\sessions\<folder>-<hash>\` |
336
+ | macOS | `~/.arenacode/sessions/<folder>-<hash>/` |
337
+ | Linux | `~/.arenacode/sessions/<folder>-<hash>/` |
338
+
339
+ Inside that directory there is **one JSON file per session** plus a small `index.json`:
331
340
 
332
341
  ```
333
- ~/.arenacode/sessions/<folder>-<hash>.json
342
+ session-1.json the transcript of session 1: items, task id, mode, label, diffs
343
+ session-2.json the transcript of session 2 — writing session 1 never touches this
344
+ index.json the order of the sessions and which one was open
334
345
  ```
335
346
 
336
- one file per folder, so two projects never mix. The file is written a moment after each change and again on
337
- `/quit`, atomically (a temporary file, then a rename), and it is read back at the next start: the sessions you
338
- had are there, with the one you left open active. A file that cannot be parsed counts as "no history" and
339
- never stops the client, and it is capped: the last 20 sessions, 400 entries each, 80 diff rows per change, and
340
- a turn that was still running when the client closed comes back marked `closed`.
347
+ One file per session means a turn rewrites only its own transcript, and nothing is capped: no limit on the
348
+ number of sessions, on the entries of a session, on the length of a message or on the rows of a diff. Writes
349
+ are atomic (a temporary file in the same directory, then a rename; on Windows, where an indexer can hold the
350
+ file, it retries and then writes in place) and happen a moment after each change, on `/quit`, and on the way
351
+ out through EOF, `Ctrl+C` or a signal. The file name is sanitised so a project called `a folder: <weird>?` still
352
+ gives a legal name on Windows, and the folder carries a hash of the absolute path: two folders with the same
353
+ name never mix. A corrupted `index.json` is rebuilt from the files, a corrupted `session-*.json` is skipped and
354
+ the others still load; a turn that was running when the client closed comes back marked `closed`.
341
355
 
342
356
  The bridge keeps the tasks themselves — `/queue` shows them with their state and id.
343
357
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lardexx/arenacode",
3
- "version": "2.11.8",
3
+ "version": "2.11.9",
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",
package/src/tui/app.js CHANGED
@@ -114,6 +114,32 @@ class App {
114
114
  this.flush();
115
115
  });
116
116
  this.term.on('resize', () => this.invalidate());
117
+ // Leaving through EOF, Ctrl+C or a signal skips quit(): the history is written here too,
118
+ // synchronously, because the debounced save is not guaranteed to run before exit.
119
+ const flush = () => {
120
+ try {
121
+ if (this._saveTimer) {
122
+ clearTimeout(this._saveTimer);
123
+ this._saveTimer = null;
124
+ }
125
+ this.persistSessions();
126
+ } catch {
127
+ /* nothing to do on the way out */
128
+ }
129
+ };
130
+ process.once('exit', flush);
131
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
132
+ try {
133
+ process.once(signal, () => {
134
+ flush();
135
+ this.running = false;
136
+ this.term.exit();
137
+ process.exit(0);
138
+ });
139
+ } catch {
140
+ /* a platform without that signal */
141
+ }
142
+ }
117
143
  this.running = true;
118
144
  if (this.approval === 'proceed') {
119
145
  this.push({
@@ -447,7 +473,7 @@ class App {
447
473
 
448
474
  // ---------------------------------------------------------------- sessions
449
475
 
450
- /** Brings back the sessions of this folder from ~/.arenacode/sessions (if any). */
476
+ /** Brings back the sessions of this folder from the history directory (if any). */
451
477
  restoreSessions() {
452
478
  let saved = null;
453
479
  try {
@@ -456,29 +482,37 @@ class App {
456
482
  saved = null;
457
483
  }
458
484
  if (!saved) return false;
459
- this.sessions = saved.sessions.map((slot) => ({
460
- ...slot,
461
- seqItems: new Map(),
462
- since: 0,
463
- }));
485
+ this.sessions = saved.sessions;
464
486
  this.sessionSeq = this.sessions.reduce((max, slot) => Math.max(max, slot.id || 0), this.sessions.length);
465
487
  this.sessionIndex = saved.active;
466
488
  this.applySession();
467
- this.historyFile = history.fileFor(this.folder);
489
+ this.historyDir = saved.dir;
468
490
  // a transient note, not a transcript line: the transcript must be exactly what was saved
469
491
  this.toast(`history: ${this.sessions.length} session(s) restored`);
470
492
  return true;
471
493
  }
472
494
 
473
- /** Writes the sessions now (called on quit and from the debounced saver). */
474
- persistSessions() {
475
- this.saveSession();
476
- this.historyFile = history.fileFor(this.folder);
477
- const res = history.save(this.folder, { sessions: this.sessions, active: this.sessionIndex });
495
+ /** Writes one session to its own file. */
496
+ persistSlot(slot) {
497
+ if (!slot) return null;
498
+ const res = history.saveSession(this.folder, slot);
478
499
  if (!res.ok) this.historyError = res.error;
479
500
  return res;
480
501
  }
481
502
 
503
+ /**
504
+ * Writes the active session and the index. One file per session means a turn rewrites
505
+ * only its own transcript; the other sessions are untouched.
506
+ */
507
+ persistSessions() {
508
+ this.saveSession();
509
+ const res = this.persistSlot(this.sessions[this.sessionIndex]);
510
+ const index = history.saveIndex(this.folder, { sessions: this.sessions, active: this.sessionIndex });
511
+ this.historyDir = history.folderDir(this.folder);
512
+ if (!index.ok) this.historyError = index.error;
513
+ return res || index;
514
+ }
515
+
482
516
  /** Saves at most a few times a second: a turn pushes items in bursts. */
483
517
  scheduleSave() {
484
518
  if (this._saveTimer) return;
@@ -526,6 +560,7 @@ class App {
526
560
 
527
561
  newSession({ silent = false } = {}) {
528
562
  this.saveSession();
563
+ this.persistSlot(this.sessions[this.sessionIndex]);
529
564
  this.sessionSeq += 1;
530
565
  this.sessions.push({
531
566
  id: this.sessionSeq,
@@ -550,6 +585,7 @@ class App {
550
585
  return;
551
586
  }
552
587
  this.saveSession();
588
+ this.persistSlot(this.sessions[this.sessionIndex]);
553
589
  this.sessionIndex = index;
554
590
  this.applySession();
555
591
  const slot = this.sessions[index];
@@ -1,144 +1,258 @@
1
1
  'use strict';
2
2
 
3
- // The chat history of every session, on disk.
3
+ // Chat history on disk: one file per session, plus a small index.
4
4
  //
5
- // One file per folder, so two projects never mix and the file name says which is which:
5
+ // Windows %APPDATA%\ArenaCode\sessions\<folder>-<hash>\session-<id>.json
6
+ // macOS ~/.arenacode/sessions/<folder>-<hash>/session-<id>.json
7
+ // Linux ~/.arenacode/sessions/<folder>-<hash>/session-<id>.json
6
8
  //
7
- // ~/.arenacode/sessions/<folder>-<hash>.json
9
+ // The base folder is the one the config file lives in (`dirname(configPath())`), so the
10
+ // history follows the client's own configuration on the three systems, including when
11
+ // ARENACODE_CONFIG points somewhere else (tests, portable installs).
8
12
  //
9
- // The shape is small on purpose — sessions with their transcript, the active index and a
10
- // version — because this file is read at every start of the client.
13
+ // One directory per project folder — the name carries a hash of the absolute path, so two
14
+ // folders with the same last segment never mix — and inside it one JSON file per session,
15
+ // so writing a session never rewrites the others.
11
16
 
12
17
  const fs = require('node:fs');
13
- const os = require('node:os');
14
18
  const path = require('node:path');
15
19
  const crypto = require('node:crypto');
16
20
 
21
+ const { configPath } = require('../config');
22
+
17
23
  const VERSION = 1;
18
- const MAX_SESSIONS = 20; // older sessions are dropped, newest kept
19
- const MAX_ITEMS = 400; // per session
20
- const MAX_TEXT = 8000; // characters per text/detail field
21
- const MAX_DIFF_ROWS = 80; // rows stored for a change card
24
+ const INDEX = 'index.json';
25
+
26
+ /** Where the client keeps its configuration: the history lives next to it. */
27
+ function baseDir() {
28
+ return path.dirname(configPath());
29
+ }
22
30
 
23
- function dir() {
24
- return path.join(os.homedir(), '.arenacode', 'sessions');
31
+ /** A folder name that is legal on Windows, macOS and Linux whatever the project is called. */
32
+ function slug(name) {
33
+ const clean = String(name || '')
34
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
35
+ .replace(/^[-.]+|[-.]+$/g, '')
36
+ .slice(0, 40);
37
+ return clean || 'root';
25
38
  }
26
39
 
27
- function fileFor(folder) {
40
+ /** The directory of one project folder. */
41
+ function folderDir(folder) {
28
42
  const abs = path.resolve(folder || '.');
29
43
  const hash = crypto.createHash('sha1').update(abs).digest('hex').slice(0, 10);
30
- const base = path.basename(abs).replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 40) || 'root';
31
- return path.join(dir(), `${base}-${hash}.json`);
44
+ return path.join(baseDir(), 'sessions', `${slug(path.basename(abs))}-${hash}`);
32
45
  }
33
46
 
34
- function clip(value, max = MAX_TEXT) {
35
- const text = String(value ?? '');
36
- return text.length > max ? `${text.slice(0, max)}…` : text;
47
+ function sessionFile(folder, id) {
48
+ return path.join(folderDir(folder), `session-${slug(String(id))}.json`);
37
49
  }
38
50
 
39
- /** Only what can be written as JSON and read back: no functions, no cycles, no giant blobs. */
51
+ function indexFile(folder) {
52
+ return path.join(folderDir(folder), INDEX);
53
+ }
54
+
55
+ /**
56
+ * Atomic write. `rename` replaces the target on POSIX and on Windows (Node asks for
57
+ * MOVEFILE_REPLACE_EXISTING), and the temporary file lives in the same directory, so it
58
+ * never crosses a filesystem. If Windows refuses the rename (an indexer or an antivirus
59
+ * holding the file), it retries, then writes in place so nothing is lost.
60
+ */
61
+ function writeAtomic(file, body) {
62
+ fs.mkdirSync(path.dirname(file), { recursive: true });
63
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
64
+ fs.writeFileSync(tmp, body, 'utf8');
65
+ for (let attempt = 0; attempt < 3; attempt += 1) {
66
+ try {
67
+ fs.renameSync(tmp, file);
68
+ return { ok: true, file, bytes: Buffer.byteLength(body) };
69
+ } catch (err) {
70
+ if (attempt === 2) {
71
+ try {
72
+ fs.writeFileSync(file, body, 'utf8');
73
+ fs.rmSync(tmp, { force: true });
74
+ return { ok: true, file, bytes: Buffer.byteLength(body), fallback: err.code || err.message };
75
+ } catch (second) {
76
+ fs.rmSync(tmp, { force: true });
77
+ return { ok: false, error: second.message, file };
78
+ }
79
+ }
80
+ // a short, synchronous pause is enough for a lock to be released
81
+ const until = Date.now() + 40;
82
+ while (Date.now() < until) {
83
+ /* spin */
84
+ }
85
+ }
86
+ }
87
+ return { ok: false, error: 'rename failed', file };
88
+ }
89
+
90
+ /** JSON with the fields that are worth keeping; anything else is dropped, never truncated. */
40
91
  function cleanItem(item) {
41
92
  if (!item || typeof item !== 'object') return null;
42
93
  const out = {};
43
- for (const key of ['kind', 'text', 'label', 'detail', 'state', 'icon', 'meta', 'command', 'secs', 'seq', 'color']) {
44
- if (item[key] === undefined || item[key] === null) continue;
45
- out[key] = typeof item[key] === 'number' ? item[key] : clip(item[key]);
94
+ for (const key of ['kind', 'text', 'label', 'detail', 'state', 'icon', 'meta', 'command', 'secs', 'seq', 'color', 't0', 'shell']) {
95
+ if (item[key] === undefined) continue;
96
+ out[key] = item[key];
46
97
  }
47
- if (item.files && Array.isArray(item.files)) out.files = item.files.slice(0, 20).map((f) => clip(f, 300));
48
- if (item.shell) out.shell = true;
98
+ if (Array.isArray(item.files)) out.files = item.files.slice();
49
99
  if (item.diff && Array.isArray(item.diff.rows)) {
50
100
  out.diff = {
51
- file: clip(item.diff.file || '', 300),
101
+ file: item.diff.file || '',
52
102
  additions: Number(item.diff.additions) || 0,
53
103
  deletions: Number(item.diff.deletions) || 0,
54
- rows: item.diff.rows.slice(0, MAX_DIFF_ROWS).map((row) => ({
104
+ rows: item.diff.rows.map((row) => ({
55
105
  type: row.type,
56
- oldNo: row.oldNo === null || row.oldNo === undefined ? null : Number(row.oldNo),
57
- newNo: row.newNo === null || row.newNo === undefined ? null : Number(row.newNo),
58
- text: clip(row.text, 400),
106
+ oldNo: row.oldNo === undefined ? null : row.oldNo,
107
+ newNo: row.newNo === undefined ? null : row.newNo,
108
+ text: row.text,
59
109
  })),
60
110
  };
61
111
  }
62
112
  return out;
63
113
  }
64
114
 
65
- function cleanSession(slot, index) {
115
+ function cleanSession(slot, fallbackId) {
66
116
  if (!slot || typeof slot !== 'object') return null;
67
- const items = (Array.isArray(slot.items) ? slot.items : [])
68
- .slice(-MAX_ITEMS)
69
- .map(cleanItem)
70
- .filter(Boolean)
71
- // a turn that was running when the client closed is not running any more
72
- .map((item) => (item.state === 'running' ? { ...item, state: 'done', meta: item.meta || 'closed' } : item));
73
117
  return {
74
- id: Number(slot.id) || index + 1,
75
- label: clip(slot.label || '', 120),
76
- taskId: slot.taskId ? String(slot.taskId) : null,
118
+ version: VERSION,
119
+ id: Number(slot.id) || fallbackId,
120
+ label: slot.label || '',
121
+ taskId: slot.taskId || null,
77
122
  mode: slot.mode || 'agent',
78
123
  startedAt: Number(slot.startedAt) || Date.now(),
79
- items,
124
+ updatedAt: new Date().toISOString(),
125
+ items: (Array.isArray(slot.items) ? slot.items : []).map(cleanItem).filter(Boolean),
80
126
  };
81
127
  }
82
128
 
83
- function payload(folder, { sessions, active }) {
84
- const clean = (Array.isArray(sessions) ? sessions : [])
85
- .slice(-MAX_SESSIONS)
86
- .map(cleanSession)
87
- .filter(Boolean);
88
- const wanted = Number(active);
89
- return {
129
+ /** Writes one session to its own file. */
130
+ function saveSession(folder, slot) {
131
+ const clean = cleanSession(slot, Date.now());
132
+ if (!clean) return { ok: false, error: 'nothing to save', file: null };
133
+ const file = sessionFile(folder, clean.id);
134
+ const res = writeAtomic(file, JSON.stringify(clean));
135
+ return { ...res, id: clean.id };
136
+ }
137
+
138
+ /**
139
+ * Writes the index: the order of the sessions and which one was open. Small on purpose —
140
+ * the session list is drawn from here without reading a single transcript.
141
+ */
142
+ function saveIndex(folder, { sessions = [], active = 0 } = {}) {
143
+ const clean = sessions
144
+ .map((slot, i) => ({
145
+ id: Number(slot && slot.id) || i + 1,
146
+ label: (slot && slot.label) || '',
147
+ taskId: (slot && slot.taskId) || null,
148
+ mode: (slot && slot.mode) || 'agent',
149
+ startedAt: Number(slot && slot.startedAt) || 0,
150
+ updatedAt: (slot && slot.updatedAt) || new Date().toISOString(),
151
+ items: Array.isArray(slot && slot.items) ? slot.items.length : 0,
152
+ }))
153
+ .sort((a, b) => a.id - b.id);
154
+ const body = {
90
155
  version: VERSION,
91
156
  folder: path.resolve(folder || '.'),
92
157
  savedAt: new Date().toISOString(),
93
- active: Number.isFinite(wanted) ? Math.max(0, Math.min(clean.length - 1, wanted)) : 0,
158
+ active: Math.max(0, Math.min(Math.max(0, clean.length - 1), Number(active) || 0)),
94
159
  sessions: clean,
95
160
  };
161
+ return writeAtomic(indexFile(folder), JSON.stringify(body));
96
162
  }
97
163
 
98
- /** Reads the saved sessions of a folder. Never throws: a broken file is "no history". */
99
- function load(folder) {
100
- let raw;
164
+ function readJson(file) {
101
165
  try {
102
- raw = fs.readFileSync(fileFor(folder), 'utf8');
166
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
103
167
  } catch {
104
168
  return null;
105
169
  }
106
- let data;
170
+ }
171
+
172
+ /**
173
+ * Reads the sessions of a folder: the order comes from the index, a session whose file is
174
+ * missing or broken is skipped (never "all or nothing"), and with no usable index the
175
+ * files themselves are used, sorted by id.
176
+ */
177
+ function load(folder) {
178
+ const dir = folderDir(folder);
179
+ const index = readJson(indexFile(folder));
180
+ const wanted = new Set();
181
+ if (index && Array.isArray(index.sessions)) for (const entry of index.sessions) wanted.add(`${entry.id}.json`);
182
+
183
+ let files = [];
107
184
  try {
108
- data = JSON.parse(raw);
185
+ files = fs
186
+ .readdirSync(dir)
187
+ .filter((name) => /^session-.+\.json$/.test(name))
188
+ .sort((a, b) => String(a).localeCompare(String(b), undefined, { numeric: true }));
109
189
  } catch {
110
- return null;
190
+ files = [];
191
+ }
192
+ // the index decides the order; files the index does not know about are appended
193
+ const ordered = [
194
+ ...files.filter((name) => wanted.has(name.replace(/^session-/, ''))),
195
+ ...files.filter((name) => !wanted.has(name.replace(/^session-/, ''))),
196
+ ];
197
+
198
+ const sessions = [];
199
+ for (const name of ordered) {
200
+ const data = readJson(path.join(dir, name));
201
+ if (!data || !Array.isArray(data.items)) continue;
202
+ sessions.push({
203
+ id: Number(data.id) || sessions.length + 1,
204
+ label: data.label || '',
205
+ taskId: data.taskId || null,
206
+ mode: data.mode || 'agent',
207
+ startedAt: Number(data.startedAt) || 0,
208
+ updatedAt: data.updatedAt || null,
209
+ items: data.items,
210
+ seqItems: new Map(),
211
+ since: 0,
212
+ });
111
213
  }
112
- if (!data || data.version !== VERSION || !Array.isArray(data.sessions) || !data.sessions.length) return null;
113
- const sessions = data.sessions.map(cleanSession).filter(Boolean);
114
214
  if (!sessions.length) return null;
115
- const active = Number.isFinite(Number(data.active)) ? Math.max(0, Math.min(sessions.length - 1, Number(data.active))) : 0;
116
- return { sessions, active, savedAt: data.savedAt || null };
215
+ sessions.sort((a, b) => a.id - b.id);
216
+
217
+ // which session was open: the index knows its id, and the id survives a missing file
218
+ let active = sessions.length - 1;
219
+ if (index && Array.isArray(index.sessions)) {
220
+ const entry = index.sessions[Number(index.active)];
221
+ const at = entry ? sessions.findIndex((slot) => slot.id === Number(entry.id)) : -1;
222
+ active = at >= 0 ? at : Math.max(0, Math.min(sessions.length - 1, Number(index.active) || 0));
223
+ }
224
+ return { sessions, active, dir };
117
225
  }
118
226
 
119
- /** Atomic write: a crash in the middle leaves the previous file intact. */
120
- function save(folder, state) {
121
- const file = fileFor(folder);
122
- const body = JSON.stringify(payload(folder, state || {}), null, 0);
227
+ function remove(folder, id) {
123
228
  try {
124
- fs.mkdirSync(path.dirname(file), { recursive: true });
125
- const tmp = `${file}.${process.pid}.tmp`;
126
- fs.writeFileSync(tmp, body, 'utf8');
127
- fs.renameSync(tmp, file);
128
- return { ok: true, file, bytes: Buffer.byteLength(body) };
129
- } catch (err) {
130
- return { ok: false, error: err.message, file };
229
+ fs.rmSync(sessionFile(folder, id), { force: true });
230
+ return true;
231
+ } catch {
232
+ return false;
131
233
  }
132
234
  }
133
235
 
134
- /** Removes the history of a folder (not wired to a command yet, used by the tests). */
236
+ /** Deletes every session of a folder. */
135
237
  function clear(folder) {
136
238
  try {
137
- fs.unlinkSync(fileFor(folder));
239
+ fs.rmSync(folderDir(folder), { recursive: true, force: true });
138
240
  return true;
139
241
  } catch {
140
242
  return false;
141
243
  }
142
244
  }
143
245
 
144
- module.exports = { fileFor, load, save, clear, cleanItem, VERSION, MAX_SESSIONS, MAX_ITEMS };
246
+ module.exports = {
247
+ VERSION,
248
+ baseDir,
249
+ folderDir,
250
+ sessionFile,
251
+ indexFile,
252
+ saveSession,
253
+ saveIndex,
254
+ load,
255
+ remove,
256
+ clear,
257
+ cleanItem,
258
+ };