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 +1 -1
- package/vendor/client/session-store.js +196 -100
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegiscode",
|
|
3
3
|
"productName": "AEGIS Code",
|
|
4
|
-
"version": "6.5.
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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,
|