aegiscode 6.5.0 → 6.5.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegiscode",
3
3
  "productName": "AEGIS Code",
4
- "version": "6.5.0",
4
+ "version": "6.5.1",
5
5
  "description": "aegiscode \u2014 the command-line version of AEGIS Desktop. The shared tool surface in your shell, over the same thin transport and tool registry as the MCP plugin and the desktop app. Ships transport + UI only; no brain.",
6
6
  "author": {
7
7
  "name": "AEGIS Code",
@@ -104,6 +104,98 @@ function atomicWrite(file, data) {
104
104
  } catch {}
105
105
  }
106
106
 
107
+ /** How long a lock may be held before another writer treats it as abandoned. */
108
+ const LOCK_STALE_MS = 2000;
109
+ /** Total time a writer will wait for the lock before proceeding unlocked. */
110
+ const LOCK_WAIT_MS = 500;
111
+
112
+ function lockFile(dir) {
113
+ return `${storeFile(dir)}.lock`;
114
+ }
115
+
116
+ /**
117
+ * Best-effort advisory lock around the read-modify-write in `mutate()`.
118
+ *
119
+ * A private store owned by one process did not need this. A *shared* one does:
120
+ * the desktop app and a terminal session are two long-lived processes that both
121
+ * rewrite this file wholesale, and interleaved read-modify-write would drop
122
+ * whichever turn lost the race. The lock is deliberately soft — a crash must
123
+ * not wedge every future write, so a lock older than LOCK_STALE_MS is stolen,
124
+ * and a writer that cannot get it in LOCK_WAIT_MS proceeds anyway (losing at
125
+ * worst the same race that exists today, rather than refusing to save).
126
+ *
127
+ * @returns {boolean} whether the lock was acquired
128
+ */
129
+ function acquireLock(dir) {
130
+ const file = lockFile(dir);
131
+ // The lock is taken *before* the write that used to create the directory, so
132
+ // this is now the first thing to touch it — a first run (or a fresh
133
+ // AEGISCODE_HOME) would otherwise spin here forever waiting on a lock it can
134
+ // never create.
135
+ try {
136
+ fs.mkdirSync(path.dirname(file), { recursive: true });
137
+ } catch {
138
+ return false;
139
+ }
140
+ const deadline = Date.now() + LOCK_WAIT_MS;
141
+ // Bounded, so no combination of "lock vanished" / "lock unreadable" can spin
142
+ // this loop: a writer that cannot decide always proceeds unlocked instead.
143
+ const MAX_ATTEMPTS = 200;
144
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
145
+ try {
146
+ const fd = fs.openSync(file, 'wx');
147
+ fs.writeSync(fd, String(process.pid));
148
+ fs.closeSync(fd);
149
+ return true;
150
+ } catch {
151
+ let age = null;
152
+ try {
153
+ age = Date.now() - fs.statSync(file).mtimeMs;
154
+ } catch {
155
+ // Released between the open and the stat — try immediately.
156
+ continue;
157
+ }
158
+ if (age > LOCK_STALE_MS) {
159
+ try {
160
+ fs.unlinkSync(file);
161
+ } catch {}
162
+ continue;
163
+ }
164
+ if (Date.now() >= deadline) return false;
165
+ // Busy-wait: the critical section is a few hundred microseconds of
166
+ // stringify + rename, so sleeping the event loop would cost more than it
167
+ // saves, and this path is not on the streaming critical path.
168
+ const until = Date.now() + 5;
169
+ while (Date.now() < until) {
170
+ /* spin a few ms */
171
+ }
172
+ }
173
+ }
174
+ return false;
175
+ }
176
+
177
+ function releaseLock(dir) {
178
+ try {
179
+ fs.unlinkSync(lockFile(dir));
180
+ } catch {}
181
+ }
182
+
183
+ /**
184
+ * Read-modify-write the whole store under the lock. Every mutation goes through
185
+ * here so the locking is one behaviour, not a thing each caller must remember.
186
+ */
187
+ function mutate(dir, mutator) {
188
+ const locked = acquireLock(dir);
189
+ try {
190
+ const sessions = load(dir);
191
+ const result = mutator(sessions);
192
+ save(dir, sessions);
193
+ return result;
194
+ } finally {
195
+ if (locked) releaseLock(dir);
196
+ }
197
+ }
198
+
107
199
  function save(dir, sessions) {
108
200
  atomicWrite(storeFile(dir), sessions);
109
201
  }
@@ -124,29 +216,29 @@ function trimMessages(messages) {
124
216
  function upsertSession(dir, session) {
125
217
  const id = session && session.id;
126
218
  if (!id) throw new Error('session.id is required');
127
- const sessions = load(dir);
128
- const prev = sessions[id] || { messages: [] };
129
- sessions[id] = { ...prev, ...session, id, version: STORE_VERSION };
130
- if (session.pending !== false) sessions[id].pending = true;
131
- if (!sessions[id].updatedAt) sessions[id].updatedAt = Date.now();
132
- sessions[id].seq = nextSeq(sessions);
133
- save(dir, sessions);
134
- return sessions[id];
219
+ return mutate(dir, (sessions) => {
220
+ const prev = sessions[id] || { messages: [] };
221
+ sessions[id] = { ...prev, ...session, id, version: STORE_VERSION };
222
+ if (session.pending !== false) sessions[id].pending = true;
223
+ if (!sessions[id].updatedAt) sessions[id].updatedAt = Date.now();
224
+ sessions[id].seq = nextSeq(sessions);
225
+ return sessions[id];
226
+ });
135
227
  }
136
228
 
137
229
  /** Append one message to a session (crash-safe). */
138
230
  function appendMessage(dir, sessionId, message) {
139
231
  if (!sessionId) throw new Error('sessionId is required');
140
- const sessions = load(dir);
141
- const session = sessions[sessionId] || { id: sessionId, messages: [] };
142
- const messages = Array.isArray(session.messages) ? session.messages : [];
143
- session.messages = trimMessages(messages.concat(message));
144
- session.updatedAt = Date.now();
145
- session.pending = true;
146
- session.seq = nextSeq(sessions);
147
- sessions[sessionId] = session;
148
- save(dir, sessions);
149
- return session;
232
+ return mutate(dir, (sessions) => {
233
+ const session = sessions[sessionId] || { id: sessionId, messages: [] };
234
+ const messages = Array.isArray(session.messages) ? session.messages : [];
235
+ session.messages = trimMessages(messages.concat(message));
236
+ session.updatedAt = Date.now();
237
+ session.pending = true;
238
+ session.seq = nextSeq(sessions);
239
+ sessions[sessionId] = session;
240
+ return session;
241
+ });
150
242
  }
151
243
 
152
244
  /**
@@ -166,34 +258,34 @@ function appendMessage(dir, sessionId, message) {
166
258
  function recordExchange(dir, exchange) {
167
259
  const e = exchange || {};
168
260
  if (!e.sessionId) return null;
169
- const sessions = load(dir);
170
261
  const id = e.sessionId;
171
- const session = sessions[id] || { id, messages: [] };
172
- const messages = Array.isArray(session.messages) ? session.messages : [];
173
- const ts = e.ts || new Date().toISOString();
174
- const userMessage = { role: 'user', content: e.prompt == null ? '' : String(e.prompt), ts };
175
- const assistantMessage = { role: 'assistant', content: e.reply == null ? '' : String(e.reply), ts };
176
- if (e.tokens) assistantMessage.tokens = e.tokens;
177
- if (typeof e.costUsd === 'number') assistantMessage.costUsd = e.costUsd;
178
- if (e.status) assistantMessage.status = e.status;
179
- if (e.origin) {
180
- userMessage.origin = e.origin;
181
- assistantMessage.origin = e.origin;
182
- }
183
- session.messages = trimMessages(messages.concat([userMessage, assistantMessage]));
184
- session.title = session.title || String(e.prompt || '').slice(0, 60);
185
- if (e.cwd) session.cwd = e.cwd;
186
- session.origin = e.origin || session.origin || 'unknown';
187
- session.updatedAt = Date.now();
188
- // Explicit, not merely "leave it unset": `listPending` treats an absent flag
189
- // as pending (sessions predating the field default to queued), so an
190
- // undefined here would quietly enrol every terminal session in the desktop's
191
- // push queue the exact thing the `pending: false` default is for.
192
- session.pending = e.pending === true ? true : false;
193
- session.seq = nextSeq(sessions);
194
- sessions[id] = session;
195
- save(dir, sessions);
196
- return session;
262
+ return mutate(dir, (sessions) => {
263
+ const session = sessions[id] || { id, messages: [] };
264
+ const messages = Array.isArray(session.messages) ? session.messages : [];
265
+ const ts = e.ts || new Date().toISOString();
266
+ const userMessage = { role: 'user', content: e.prompt == null ? '' : String(e.prompt), ts };
267
+ const assistantMessage = { role: 'assistant', content: e.reply == null ? '' : String(e.reply), ts };
268
+ if (e.tokens) assistantMessage.tokens = e.tokens;
269
+ if (typeof e.costUsd === 'number') assistantMessage.costUsd = e.costUsd;
270
+ if (e.status) assistantMessage.status = e.status;
271
+ if (e.origin) {
272
+ userMessage.origin = e.origin;
273
+ assistantMessage.origin = e.origin;
274
+ }
275
+ session.messages = trimMessages(messages.concat([userMessage, assistantMessage]));
276
+ session.title = session.title || String(e.prompt || '').slice(0, 60);
277
+ if (e.cwd) session.cwd = e.cwd;
278
+ session.origin = e.origin || session.origin || 'unknown';
279
+ session.updatedAt = Date.now();
280
+ // Explicit, not merely "leave it unset": `listPending` treats an absent flag
281
+ // as pending (sessions predating the field default to queued), so an
282
+ // undefined here would quietly enrol every terminal session in the desktop's
283
+ // push queue the exact thing the `pending: false` default is for.
284
+ session.pending = e.pending === true ? true : false;
285
+ session.seq = nextSeq(sessions);
286
+ sessions[id] = session;
287
+ return session;
288
+ });
197
289
  }
198
290
 
199
291
  function listSessions(dir) {
@@ -212,37 +304,35 @@ function getSession(dir, id) {
212
304
  }
213
305
 
214
306
  function deleteSession(dir, id) {
215
- const sessions = load(dir);
216
- delete sessions[id];
217
- save(dir, sessions);
218
- return { ok: true };
307
+ return mutate(dir, (sessions) => {
308
+ delete sessions[id];
309
+ return { ok: true };
310
+ });
219
311
  }
220
312
 
221
313
  /** Clear the pending flag after a successful cloud push. `remote.remoteId`,
222
314
  * when the server assigns its own conversation id, is stashed alongside. */
223
315
  function markSynced(dir, id, remote) {
224
- const sessions = load(dir);
225
- const session = sessions[id];
226
- if (!session) return null;
227
- session.pending = false;
228
- session.lastSyncedAt = Date.now();
229
- if (remote && remote.remoteId) session.remoteId = remote.remoteId;
230
- session.seq = nextSeq(sessions);
231
- sessions[id] = session;
232
- save(dir, sessions);
233
- return session;
316
+ return mutate(dir, (sessions) => {
317
+ const session = sessions[id];
318
+ if (!session) return null;
319
+ session.pending = false;
320
+ session.lastSyncedAt = Date.now();
321
+ if (remote && remote.remoteId) session.remoteId = remote.remoteId;
322
+ session.seq = nextSeq(sessions);
323
+ return session;
324
+ });
234
325
  }
235
326
 
236
327
  /** Force a session back into the retry queue (e.g. a push that partially failed). */
237
328
  function markPending(dir, id) {
238
- const sessions = load(dir);
239
- const session = sessions[id];
240
- if (!session) return null;
241
- session.pending = true;
242
- session.seq = nextSeq(sessions);
243
- sessions[id] = session;
244
- save(dir, sessions);
245
- return session;
329
+ return mutate(dir, (sessions) => {
330
+ const session = sessions[id];
331
+ if (!session) return null;
332
+ session.pending = true;
333
+ session.seq = nextSeq(sessions);
334
+ return session;
335
+ });
246
336
  }
247
337
 
248
338
  /** Sessions with local content the cloud hasn't confirmed yet (including
@@ -259,32 +349,33 @@ function listPending(dir) {
259
349
  */
260
350
  function mergeRemoteSessions(dir, remoteSessions) {
261
351
  const list = Array.isArray(remoteSessions) ? remoteSessions : [];
262
- const sessions = load(dir);
263
- let merged = 0;
264
- for (const remote of list) {
265
- const id = remote && (remote.session_id || remote.id);
266
- if (!id) continue;
267
- const local = sessions[id];
268
- const remoteUpdatedAt = toEpochMs(remote.updated_at ?? remote.updatedAt);
269
- if (local && (local.pending || (local.updatedAt || 0) >= remoteUpdatedAt)) {
270
- continue;
352
+ if (!list.length) return 0;
353
+ return mutate(dir, (sessions) => {
354
+ let merged = 0;
355
+ for (const remote of list) {
356
+ const id = remote && (remote.session_id || remote.id);
357
+ if (!id) continue;
358
+ const local = sessions[id];
359
+ const remoteUpdatedAt = toEpochMs(remote.updated_at ?? remote.updatedAt);
360
+ if (local && (local.pending || (local.updatedAt || 0) >= remoteUpdatedAt)) {
361
+ continue;
362
+ }
363
+ sessions[id] = {
364
+ id,
365
+ title: remote.title || (local && local.title) || '',
366
+ messages: Array.isArray(remote.messages) ? remote.messages : [],
367
+ updatedAt: remoteUpdatedAt || Date.now(),
368
+ pending: false,
369
+ lastSyncedAt: Date.now(),
370
+ remoteId: remote.session_id || remote.id,
371
+ origin: remote.source || (local && local.origin) || 'cloud',
372
+ version: STORE_VERSION,
373
+ };
374
+ sessions[id].seq = nextSeq(sessions);
375
+ merged += 1;
271
376
  }
272
- sessions[id] = {
273
- id,
274
- title: remote.title || (local && local.title) || '',
275
- messages: Array.isArray(remote.messages) ? remote.messages : [],
276
- updatedAt: remoteUpdatedAt || Date.now(),
277
- pending: false,
278
- lastSyncedAt: Date.now(),
279
- remoteId: remote.session_id || remote.id,
280
- origin: remote.source || (local && local.origin) || 'cloud',
281
- version: STORE_VERSION,
282
- };
283
- sessions[id].seq = nextSeq(sessions);
284
- merged += 1;
285
- }
286
- if (merged) save(dir, sessions);
287
- return merged;
377
+ return merged;
378
+ });
288
379
  }
289
380
 
290
381
  /**
@@ -355,14 +446,16 @@ function adopt(dir, fromDir) {
355
446
  if (listSessions(dir).length) {
356
447
  return { adopted: false, sessions: 0, from, reason: 'store already has sessions' };
357
448
  }
358
- const next = { ...legacy, __seq: legacy.__seq || 0 };
359
- for (const s of incoming) {
360
- // Adopted sessions keep their own history; they are already local content.
361
- s.seq = nextSeq(next);
362
- s.adoptedFrom = from;
363
- }
364
- next.version = STORE_VERSION;
365
- save(dir, next);
449
+ mutate(dir, (sessions) => {
450
+ for (const s of incoming) {
451
+ // Adopted sessions keep their own history; they are already local content.
452
+ sessions[s.id] = { ...s, adoptedFrom: from, version: STORE_VERSION };
453
+ sessions[s.id].seq = nextSeq(sessions);
454
+ }
455
+ sessions.__seq = Math.max(sessions.__seq || 0, legacy.__seq || 0);
456
+ sessions.version = STORE_VERSION;
457
+ return null;
458
+ });
366
459
  return { adopted: true, sessions: incoming.length, from };
367
460
  }
368
461
 
@@ -397,6 +490,9 @@ module.exports = {
397
490
  MAX_MESSAGES_PER_SESSION,
398
491
  storeDir,
399
492
  storeFile,
493
+ lockFile,
494
+ LOCK_STALE_MS,
495
+ LOCK_WAIT_MS,
400
496
  toEpochMs,
401
497
  load,
402
498
  save,