@pentoshi/clai 3.8.34 → 3.8.39

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.
@@ -1,4 +1,4 @@
1
- import { appendFile, copyFile, mkdir, readdir, readFile, rm, writeFile, rename, } from "node:fs/promises";
1
+ import { appendFile, copyFile, mkdir, readdir, open, readFile, rm, stat, utimes, writeFile, rename, } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { isInternalChatMessage, } from "../types.js";
4
4
  import { redactSecrets } from "../llm/provider.js";
@@ -17,6 +17,12 @@ function dbFilePath() {
17
17
  function jsonlFilePath() {
18
18
  return join(historyDirPath(), "history.jsonl");
19
19
  }
20
+ function jsonlLockFilePath() {
21
+ return join(historyDirPath(), "history.jsonl.lock");
22
+ }
23
+ function jsonlLockReaperPath() {
24
+ return join(historyDirPath(), "history.jsonl.lock.reaper");
25
+ }
20
26
  /** Sessions pruned by retention land here — never hard-deleted on autosave. */
21
27
  function archiveFilePath() {
22
28
  return join(historyDirPath(), "history-archive.jsonl");
@@ -27,14 +33,6 @@ function backupDirPath() {
27
33
  }
28
34
  /** Max rolling backups kept under history-backups/. */
29
35
  const MAX_HISTORY_BACKUPS = 12;
30
- // We keep this string here (not as a literal) so the bundler doesn't try
31
- // to statically resolve a module that may not be installed. If a user has
32
- // `better-sqlite3` available (eg they explicitly added it for a richer
33
- // history experience) we'll happily use it; otherwise we transparently
34
- // fall back to the always-available JSONL log. This lets us drop
35
- // `better-sqlite3` from our optional dependencies — and with it the
36
- // deprecated `prebuild-install` warning — without losing functionality
37
- // for users who already had a SQLite-backed history.
38
36
  const sqliteModuleName = "better-sqlite3";
39
37
  /** Snapshot the active session workspace for history persistence. */
40
38
  function workspaceFieldsFromActive(existing) {
@@ -83,6 +81,8 @@ async function loadDatabase() {
83
81
  name TEXT,
84
82
  created_at TEXT NOT NULL,
85
83
  updated_at TEXT NOT NULL,
84
+ writer_generation TEXT,
85
+ revision INTEGER NOT NULL DEFAULT 0,
86
86
  cwd TEXT NOT NULL,
87
87
  messages_json TEXT NOT NULL
88
88
  );
@@ -100,6 +100,15 @@ async function loadDatabase() {
100
100
  CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at);
101
101
  CREATE INDEX IF NOT EXISTS idx_tool_calls_session_id ON tool_calls(session_id);
102
102
  `);
103
+ const sessionColumns = cachedDb
104
+ .prepare("PRAGMA table_info(sessions)")
105
+ .all();
106
+ if (!sessionColumns.some((column) => column.name === "writer_generation")) {
107
+ cachedDb.exec("ALTER TABLE sessions ADD COLUMN writer_generation TEXT;");
108
+ }
109
+ if (!sessionColumns.some((column) => column.name === "revision")) {
110
+ cachedDb.exec("ALTER TABLE sessions ADD COLUMN revision INTEGER NOT NULL DEFAULT 0;");
111
+ }
103
112
  return cachedDb;
104
113
  }
105
114
  catch (err) {
@@ -160,11 +169,101 @@ function scrubTranscript(items) {
160
169
  }
161
170
  });
162
171
  }
163
- async function appendJsonl(record) {
164
- await mutateJsonl((records) => {
165
- records.push(record);
166
- return records;
167
- });
172
+ const JSONL_LOCK_STALE_MS = 60_000;
173
+ const JSONL_LOCK_RETRIES = 200;
174
+ /** Serialize stale-lock reclamation and recheck the owner while holding it. */
175
+ async function reapStaleJsonlLock() {
176
+ let reaper;
177
+ const reaperToken = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
178
+ try {
179
+ reaper = await open(jsonlLockReaperPath(), "wx", 0o600);
180
+ try {
181
+ await reaper.writeFile(reaperToken);
182
+ }
183
+ catch (error) {
184
+ await reaper.close().catch(() => undefined);
185
+ await rm(jsonlLockReaperPath(), { force: true }).catch(() => undefined);
186
+ throw error;
187
+ }
188
+ }
189
+ catch (error) {
190
+ if (error?.code !== "EEXIST")
191
+ throw error;
192
+ const existingToken = await readFile(jsonlLockReaperPath(), "utf8").catch(() => undefined);
193
+ const reaperStat = await stat(jsonlLockReaperPath()).catch(() => undefined);
194
+ if (!reaperStat ||
195
+ Date.now() - reaperStat.mtimeMs <= JSONL_LOCK_STALE_MS) {
196
+ return;
197
+ }
198
+ const confirmed = await readFile(jsonlLockReaperPath(), "utf8").catch(() => undefined);
199
+ if (confirmed !== undefined && confirmed === existingToken) {
200
+ await rm(jsonlLockReaperPath(), { force: true }).catch(() => undefined);
201
+ }
202
+ return;
203
+ }
204
+ try {
205
+ const token = await readFile(jsonlLockFilePath(), "utf8").catch(() => undefined);
206
+ const lockStat = await stat(jsonlLockFilePath()).catch(() => undefined);
207
+ if (!lockStat || Date.now() - lockStat.mtimeMs <= JSONL_LOCK_STALE_MS) {
208
+ return;
209
+ }
210
+ // The live owner refreshes mtime periodically. A stale marker—including
211
+ // an empty marker left between open/write—is therefore safe to reclaim.
212
+ const confirmed = await readFile(jsonlLockFilePath(), "utf8").catch(() => undefined);
213
+ if (confirmed !== undefined && confirmed === token) {
214
+ await rm(jsonlLockFilePath(), { force: true });
215
+ }
216
+ }
217
+ finally {
218
+ await reaper.close().catch(() => undefined);
219
+ const currentReaper = await readFile(jsonlLockReaperPath(), "utf8").catch(() => undefined);
220
+ if (currentReaper === reaperToken) {
221
+ await rm(jsonlLockReaperPath(), { force: true }).catch(() => undefined);
222
+ }
223
+ }
224
+ }
225
+ /**
226
+ * Cross-process lock around JSONL read/modify/rename. Atomic rename protects
227
+ * readers, but without this lock two clai processes can both read the same
228
+ * base file and then each replace it, dropping whichever session they did not
229
+ * observe. The lock is transient and stale crash leftovers self-heal.
230
+ */
231
+ async function acquireJsonlWriteLock() {
232
+ await mkdir(historyDirPath(), { recursive: true });
233
+ for (let attempt = 0; attempt < JSONL_LOCK_RETRIES; attempt += 1) {
234
+ try {
235
+ const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
236
+ const handle = await open(jsonlLockFilePath(), "wx", 0o600);
237
+ try {
238
+ await handle.writeFile(token);
239
+ }
240
+ catch (error) {
241
+ await handle.close().catch(() => undefined);
242
+ await rm(jsonlLockFilePath(), { force: true }).catch(() => undefined);
243
+ throw error;
244
+ }
245
+ const heartbeat = setInterval(() => {
246
+ const now = new Date();
247
+ void utimes(jsonlLockFilePath(), now, now).catch(() => undefined);
248
+ }, JSONL_LOCK_STALE_MS / 3);
249
+ heartbeat.unref();
250
+ return async () => {
251
+ clearInterval(heartbeat);
252
+ await handle.close().catch(() => undefined);
253
+ const currentToken = await readFile(jsonlLockFilePath(), "utf8").catch(() => undefined);
254
+ if (currentToken === token) {
255
+ await rm(jsonlLockFilePath(), { force: true }).catch(() => undefined);
256
+ }
257
+ };
258
+ }
259
+ catch (error) {
260
+ if (error?.code !== "EEXIST")
261
+ throw error;
262
+ await reapStaleJsonlLock();
263
+ await new Promise((resolve) => setTimeout(resolve, 25));
264
+ }
265
+ }
266
+ throw new Error("timed out waiting for history write lock");
168
267
  }
169
268
  /**
170
269
  * Serializes every JSONL mutation through a single promise chain so concurrent
@@ -180,12 +279,18 @@ function mutateJsonl(update) {
180
279
  const run = jsonlWriteChain.then(async () => {
181
280
  try {
182
281
  await ensureHistoryRecovered();
183
- const current = await readJsonlRecordsFrom(jsonlFilePath());
184
- const next = update(current);
185
- await writeJsonlAtomic(next);
186
- // A list may have been loaded after the pre-write invalidation but
187
- // before the atomic rename completed. Never leave that snapshot cached.
188
- invalidateSessionListCache();
282
+ const releaseLock = await acquireJsonlWriteLock();
283
+ try {
284
+ const current = await readJsonlRecordsFrom(jsonlFilePath());
285
+ const next = update(current);
286
+ await writeJsonlAtomic(next);
287
+ // A list may have been loaded after the pre-write invalidation but
288
+ // before the atomic rename completed. Never leave that snapshot cached.
289
+ invalidateSessionListCache();
290
+ }
291
+ finally {
292
+ await releaseLock();
293
+ }
189
294
  }
190
295
  catch (err) {
191
296
  handlePermissionError(err);
@@ -199,14 +304,62 @@ function updatedAtMs(record) {
199
304
  const t = Date.parse(record.updatedAt || record.createdAt || "");
200
305
  return Number.isFinite(t) ? t : 0;
201
306
  }
202
- /** Keep the newest version of each session id. */
307
+ function historyRevision(record) {
308
+ const revision = record?.revision;
309
+ return typeof revision === "number" &&
310
+ Number.isSafeInteger(revision) &&
311
+ revision > 0
312
+ ? revision
313
+ : 0;
314
+ }
315
+ function historyWriterGeneration(record) {
316
+ const generation = record?.writerGeneration;
317
+ return typeof generation === "string" && generation.length > 0
318
+ ? generation
319
+ : undefined;
320
+ }
321
+ /**
322
+ * Compare snapshots by writer generation, then capture revision. Legacy rows
323
+ * without a generation retain revision/timestamp fallback for compatibility.
324
+ */
325
+ export function compareHistoryFreshness(left, right) {
326
+ const leftGeneration = historyWriterGeneration(left);
327
+ const rightGeneration = historyWriterGeneration(right);
328
+ if (leftGeneration || rightGeneration) {
329
+ if (!leftGeneration)
330
+ return -1;
331
+ if (!rightGeneration)
332
+ return 1;
333
+ const generationDelta = leftGeneration.localeCompare(rightGeneration);
334
+ if (generationDelta !== 0)
335
+ return generationDelta;
336
+ }
337
+ const revisionDelta = historyRevision(left) - historyRevision(right);
338
+ if (revisionDelta !== 0)
339
+ return revisionDelta;
340
+ if (historyRevision(left) > 0)
341
+ return 0;
342
+ return updatedAtMs(left) - updatedAtMs(right);
343
+ }
344
+ function freshestHistoryRecord(records) {
345
+ let freshest;
346
+ for (const record of records) {
347
+ if (!freshest || compareHistoryFreshness(record, freshest) > 0) {
348
+ freshest = record;
349
+ }
350
+ }
351
+ return freshest;
352
+ }
353
+ /** Keep the newest captured version of each session id. */
203
354
  export function dedupeHistoryById(records) {
204
355
  const byId = new Map();
205
356
  for (const record of records) {
206
357
  if (!record?.id)
207
358
  continue;
208
359
  const prev = byId.get(record.id);
209
- if (!prev || updatedAtMs(record) >= updatedAtMs(prev)) {
360
+ // Preserve the first source on an exact tie. JSONL is passed first during
361
+ // cross-backend merge and is the durable canonical copy.
362
+ if (!prev || compareHistoryFreshness(record, prev) > 0) {
210
363
  byId.set(record.id, record);
211
364
  }
212
365
  }
@@ -297,92 +450,101 @@ async function backupActiveHistory() {
297
450
  export async function recoverOrphanedHistory() {
298
451
  const sources = [];
299
452
  const extras = [];
453
+ const releaseLock = await acquireJsonlWriteLock();
300
454
  try {
301
- const names = await readdir(historyDirPath());
302
- for (const name of names) {
303
- // Live write temps: history.jsonl.<pid>.<stamp>.tmp
304
- if (name.startsWith("history.jsonl.") &&
305
- name.endsWith(".tmp")) {
306
- const path = join(historyDirPath(), name);
307
- const rows = await readJsonlRecordsFrom(path);
308
- if (rows.length === 0) {
309
- // Empty crash leftovers — safe to remove.
310
- await rm(path, { force: true }).catch(() => undefined);
311
- continue;
455
+ try {
456
+ const names = await readdir(historyDirPath());
457
+ for (const name of names) {
458
+ // Live write temps: history.jsonl.<pid>.<stamp>.tmp
459
+ if (name.startsWith("history.jsonl.") &&
460
+ name.endsWith(".tmp")) {
461
+ const path = join(historyDirPath(), name);
462
+ const rows = await readJsonlRecordsFrom(path);
463
+ if (rows.length === 0) {
464
+ // Empty crash leftovers safe to remove.
465
+ await rm(path, { force: true }).catch(() => undefined);
466
+ continue;
467
+ }
468
+ extras.push(...rows);
469
+ sources.push(name);
312
470
  }
313
- extras.push(...rows);
314
- sources.push(name);
315
471
  }
316
472
  }
317
- }
318
- catch {
319
- /* dir may not exist yet */
320
- }
321
- // Also fold in archive (sessions previously pruned).
322
- if (await safeExists(archiveFilePath())) {
323
- const archived = await readJsonlRecordsFrom(archiveFilePath());
324
- if (archived.length > 0) {
325
- extras.push(...archived);
326
- sources.push("history-archive.jsonl");
473
+ catch {
474
+ /* dir may not exist yet */
327
475
  }
328
- }
329
- // Rolling backups (last-resort recovery of wiped active files).
330
- try {
331
- if (await safeExists(backupDirPath())) {
332
- const backups = (await readdir(backupDirPath()))
333
- .filter((n) => n.startsWith("history-") && n.endsWith(".jsonl"))
334
- .sort()
335
- .reverse()
336
- .slice(0, 3);
337
- for (const name of backups) {
338
- const rows = await readJsonlRecordsFrom(join(backupDirPath(), name));
339
- if (rows.length > 0) {
340
- extras.push(...rows);
341
- sources.push(`history-backups/${name}`);
476
+ // Also fold in archive (sessions previously pruned).
477
+ if (await safeExists(archiveFilePath())) {
478
+ const archived = await readJsonlRecordsFrom(archiveFilePath());
479
+ if (archived.length > 0) {
480
+ extras.push(...archived);
481
+ sources.push("history-archive.jsonl");
482
+ }
483
+ }
484
+ // Rolling backups (last-resort recovery of wiped active files).
485
+ try {
486
+ if (await safeExists(backupDirPath())) {
487
+ const backups = (await readdir(backupDirPath()))
488
+ .filter((n) => n.startsWith("history-") && n.endsWith(".jsonl"))
489
+ .sort()
490
+ .reverse()
491
+ .slice(0, 3);
492
+ for (const name of backups) {
493
+ const rows = await readJsonlRecordsFrom(join(backupDirPath(), name));
494
+ if (rows.length > 0) {
495
+ extras.push(...rows);
496
+ sources.push(`history-backups/${name}`);
497
+ }
342
498
  }
343
499
  }
344
500
  }
345
- }
346
- catch {
347
- /* ignore */
348
- }
349
- if (extras.length === 0)
350
- return { recovered: 0, sources: [] };
351
- const active = await readJsonlRecordsFrom(jsonlFilePath());
352
- const beforeIds = new Set(active.map((r) => r.id));
353
- const merged = dedupeHistoryById([...active, ...extras]);
354
- const newCount = merged.filter((r) => !beforeIds.has(r.id)).length;
355
- if (newCount === 0 && merged.length <= active.length) {
356
- return { recovered: 0, sources };
357
- }
358
- // Write WITHOUT applying retention so recovery cannot re-prune.
359
- await mkdir(historyDirPath(), { recursive: true });
360
- await fixOwner(historyDirPath());
361
- if (await safeExists(jsonlFilePath()))
362
- await backupActiveHistory();
363
- const sorted = sortHistoryByUpdatedDesc(merged);
364
- // Stable chronological file order (oldest first) for append-friendly diffs.
365
- sorted.reverse();
366
- const body = sorted.length
367
- ? `${sorted.map((item) => JSON.stringify(item)).join("\n")}\n`
368
- : "";
369
- const tmpFile = `${jsonlFilePath()}.recover.${process.pid}.${Date.now().toString(36)}.tmp`;
370
- await writeFile(tmpFile, body, { mode: 0o600 });
371
- try {
372
- await rename(tmpFile, jsonlFilePath());
373
- }
374
- catch (err) {
375
- await rm(tmpFile, { force: true }).catch(() => undefined);
376
- throw err;
377
- }
378
- await fixOwner(jsonlFilePath());
379
- // Successful recovery: drop non-empty orphan temps we already merged.
380
- for (const name of sources) {
381
- if (name.startsWith("history.jsonl.") && name.endsWith(".tmp")) {
382
- await rm(join(historyDirPath(), name), { force: true }).catch(() => undefined);
501
+ catch {
502
+ /* ignore */
383
503
  }
504
+ if (extras.length === 0)
505
+ return { recovered: 0, sources: [] };
506
+ const active = await readJsonlRecordsFrom(jsonlFilePath());
507
+ const activeById = new Map(active.map((record) => [record.id, record]));
508
+ const merged = dedupeHistoryById([...active, ...extras]);
509
+ const recoveredCount = merged.filter((record) => {
510
+ const previous = activeById.get(record.id);
511
+ return !previous || compareHistoryFreshness(record, previous) > 0;
512
+ }).length;
513
+ if (recoveredCount === 0) {
514
+ return { recovered: 0, sources };
515
+ }
516
+ // Write WITHOUT applying retention so recovery cannot re-prune.
517
+ await mkdir(historyDirPath(), { recursive: true });
518
+ await fixOwner(historyDirPath());
519
+ if (await safeExists(jsonlFilePath()))
520
+ await backupActiveHistory();
521
+ const sorted = sortHistoryByUpdatedDesc(merged);
522
+ // Stable chronological file order (oldest first) for append-friendly diffs.
523
+ sorted.reverse();
524
+ const body = sorted.length
525
+ ? `${sorted.map((item) => JSON.stringify(item)).join("\n")}\n`
526
+ : "";
527
+ const tmpFile = `${jsonlFilePath()}.recover.${process.pid}.${Date.now().toString(36)}.tmp`;
528
+ await writeFile(tmpFile, body, { mode: 0o600 });
529
+ try {
530
+ await rename(tmpFile, jsonlFilePath());
531
+ }
532
+ catch (err) {
533
+ await rm(tmpFile, { force: true }).catch(() => undefined);
534
+ throw err;
535
+ }
536
+ await fixOwner(jsonlFilePath());
537
+ // Successful recovery: drop non-empty orphan temps we already merged.
538
+ for (const name of sources) {
539
+ if (name.startsWith("history.jsonl.") && name.endsWith(".tmp")) {
540
+ await rm(join(historyDirPath(), name), { force: true }).catch(() => undefined);
541
+ }
542
+ }
543
+ return { recovered: recoveredCount, sources };
544
+ }
545
+ finally {
546
+ await releaseLock();
384
547
  }
385
- return { recovered: newCount, sources };
386
548
  }
387
549
  function startHistoryRecovery() {
388
550
  if (!recoveryPromise) {
@@ -471,7 +633,40 @@ async function writeJsonlAtomic(records) {
471
633
  }
472
634
  await fixOwner(jsonlFilePath());
473
635
  }
474
- export async function saveSession(messages, name, transcript, contextUsage) {
636
+ function serializeSessionPayload(record) {
637
+ return JSON.stringify({
638
+ messages: record.messages,
639
+ transcript: record.transcript,
640
+ ...(record.contextUsage ? { contextUsage: record.contextUsage } : {}),
641
+ ...(record.workspaceFolder
642
+ ? {
643
+ workspaceFolder: record.workspaceFolder,
644
+ workspaceCode: record.workspaceCode,
645
+ }
646
+ : {}),
647
+ });
648
+ }
649
+ /** SQLite mirror write with atomic generation/revision rejection. */
650
+ function upsertSqlite(db, record) {
651
+ db.prepare(`INSERT INTO sessions
652
+ (id, name, created_at, updated_at, writer_generation, revision, cwd, messages_json)
653
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
654
+ ON CONFLICT(id) DO UPDATE SET
655
+ name = excluded.name,
656
+ created_at = excluded.created_at,
657
+ updated_at = excluded.updated_at,
658
+ writer_generation = excluded.writer_generation,
659
+ revision = excluded.revision,
660
+ cwd = excluded.cwd,
661
+ messages_json = excluded.messages_json
662
+ WHERE (sessions.writer_generation IS NULL AND excluded.writer_generation IS NOT NULL)
663
+ OR excluded.writer_generation > sessions.writer_generation
664
+ OR (excluded.writer_generation = sessions.writer_generation
665
+ AND excluded.revision >= sessions.revision)
666
+ OR (sessions.writer_generation IS NULL AND excluded.writer_generation IS NULL
667
+ AND excluded.revision >= sessions.revision)`).run(record.id, record.name ?? null, record.createdAt, record.updatedAt, historyWriterGeneration(record) ?? null, historyRevision(record), record.cwd, serializeSessionPayload(record));
668
+ }
669
+ export async function saveSession(messages, name, transcript, contextUsage, revision, writerGeneration) {
475
670
  // Auto-derive a readable name from the first real user message if none provided
476
671
  if (!name) {
477
672
  const firstUser = messages.find((m) => m.role === "user" && !isInternalChatMessage(m));
@@ -484,6 +679,10 @@ export async function saveSession(messages, name, transcript, contextUsage) {
484
679
  const workspace = workspaceFieldsFromActive();
485
680
  const record = {
486
681
  id: newId(),
682
+ ...(writerGeneration ? { writerGeneration } : {}),
683
+ revision: typeof revision === "number" && Number.isSafeInteger(revision) && revision > 0
684
+ ? revision
685
+ : 1,
487
686
  name,
488
687
  createdAt: now,
489
688
  updatedAt: now,
@@ -495,41 +694,38 @@ export async function saveSession(messages, name, transcript, contextUsage) {
495
694
  };
496
695
  // Private mode: never persist chat content. Caller still gets a record
497
696
  // back (so /save echoes a usable id) but nothing hits disk.
498
- if (getConfig().privateMode) {
697
+ if (getConfig().privateMode)
499
698
  return record;
500
- }
501
699
  invalidateSessionListCache();
502
700
  const db = await loadDatabase();
701
+ const canonical = await upsertJsonl(record);
503
702
  if (db) {
504
- db.prepare("INSERT INTO sessions (id, name, created_at, updated_at, cwd, messages_json) VALUES (?, ?, ?, ?, ?, ?)").run(record.id, record.name ?? null, record.createdAt, record.updatedAt, record.cwd, JSON.stringify({
505
- messages: record.messages,
506
- transcript: record.transcript,
507
- ...(contextUsage ? { contextUsage } : {}),
508
- ...(record.workspaceFolder
509
- ? {
510
- workspaceFolder: record.workspaceFolder,
511
- workspaceCode: record.workspaceCode,
512
- }
513
- : {}),
514
- }));
703
+ upsertSqlite(db, canonical);
515
704
  await enforceSqliteRetention(db);
516
705
  invalidateSessionListCache();
517
706
  }
518
- else {
519
- await appendJsonl(record);
520
- }
521
- return record;
707
+ return canonical;
522
708
  }
523
- export async function upsertSession(id, messages, name, transcript, contextUsage) {
709
+ export async function upsertSession(id, messages, name, transcript, contextUsage, revision, writerGeneration) {
524
710
  const existing = await getSession(id);
525
- const firstUser = messages.find((m) => m.role === "user" && !isInternalChatMessage(m));
711
+ const requestedRevision = typeof revision === "number" && Number.isSafeInteger(revision) && revision > 0
712
+ ? revision
713
+ : undefined;
714
+ const firstUser = messages.find((message) => message.role === "user" && !isInternalChatMessage(message));
526
715
  const derivedName = firstUser
527
- ? firstUser.content.slice(0, 60).replace(/\n/g, " ").trim() + (firstUser.content.length > 60 ? "…" : "")
716
+ ? firstUser.content.slice(0, 60).replace(/\n/g, " ").trim() +
717
+ (firstUser.content.length > 60 ? "…" : "")
528
718
  : undefined;
529
719
  const now = new Date().toISOString();
530
720
  const workspace = workspaceFieldsFromActive(existing);
721
+ const effectiveWriterGeneration = writerGeneration ?? existing?.writerGeneration;
531
722
  const record = {
532
723
  id,
724
+ ...(effectiveWriterGeneration
725
+ ? { writerGeneration: effectiveWriterGeneration }
726
+ : {}),
727
+ revision: requestedRevision ??
728
+ (writerGeneration ? 1 : historyRevision(existing) + 1),
533
729
  name: name ?? existing?.name ?? derivedName,
534
730
  createdAt: existing?.createdAt ?? now,
535
731
  updatedAt: now,
@@ -543,29 +739,22 @@ export async function upsertSession(id, messages, name, transcript, contextUsage
543
739
  : {}),
544
740
  ...workspace,
545
741
  };
742
+ if (existing &&
743
+ requestedRevision !== undefined &&
744
+ compareHistoryFreshness(record, existing) <= 0) {
745
+ return existing;
746
+ }
546
747
  if (getConfig().privateMode)
547
748
  return record;
548
749
  invalidateSessionListCache();
549
750
  const db = await loadDatabase();
751
+ const canonical = await upsertJsonl(record);
550
752
  if (db) {
551
- db.prepare("INSERT OR REPLACE INTO sessions (id, name, created_at, updated_at, cwd, messages_json) VALUES (?, ?, ?, ?, ?, ?)").run(record.id, record.name ?? null, record.createdAt, record.updatedAt, record.cwd, JSON.stringify({
552
- messages: record.messages,
553
- transcript: record.transcript,
554
- ...(record.contextUsage ? { contextUsage: record.contextUsage } : {}),
555
- ...(record.workspaceFolder
556
- ? {
557
- workspaceFolder: record.workspaceFolder,
558
- workspaceCode: record.workspaceCode,
559
- }
560
- : {}),
561
- }));
753
+ upsertSqlite(db, canonical);
562
754
  await enforceSqliteRetention(db);
563
755
  invalidateSessionListCache();
564
756
  }
565
- else {
566
- await upsertJsonl(record);
567
- }
568
- return record;
757
+ return canonical;
569
758
  }
570
759
  async function enforceSqliteRetention(db) {
571
760
  const limit = getConfig().historyRetentionLimit;
@@ -575,7 +764,7 @@ async function enforceSqliteRetention(db) {
575
764
  // a JSONL archive copy.
576
765
  try {
577
766
  const doomed = db
578
- .prepare(`SELECT id, name, created_at, updated_at, cwd, messages_json FROM sessions
767
+ .prepare(`SELECT id, name, created_at, updated_at, writer_generation, revision, cwd, messages_json FROM sessions
579
768
  WHERE id NOT IN (SELECT id FROM sessions ORDER BY updated_at DESC LIMIT ?)`)
580
769
  .all(Math.floor(limit));
581
770
  const records = doomed.map(rowToSession);
@@ -588,14 +777,24 @@ async function enforceSqliteRetention(db) {
588
777
  db.exec(`DELETE FROM sessions WHERE id NOT IN (SELECT id FROM sessions ORDER BY updated_at DESC LIMIT ${Math.floor(limit)});`);
589
778
  }
590
779
  async function upsertJsonl(record) {
780
+ let canonical = record;
591
781
  await mutateJsonl((records) => {
592
782
  const idx = records.findIndex((item) => item.id === record.id);
593
- if (idx >= 0)
594
- records[idx] = record;
595
- else
783
+ if (idx >= 0) {
784
+ const current = records[idx];
785
+ if (current && compareHistoryFreshness(record, current) > 0) {
786
+ records[idx] = record;
787
+ }
788
+ else if (current) {
789
+ canonical = current;
790
+ }
791
+ }
792
+ else {
596
793
  records.push(record);
794
+ }
597
795
  return records;
598
796
  });
797
+ return canonical;
599
798
  }
600
799
  export async function saveToolCall(sessionId, call, result) {
601
800
  const record = {
@@ -621,6 +820,10 @@ function rowToSession(row) {
621
820
  const messages = Array.isArray(parsed) ? parsed : parsed.messages ?? [];
622
821
  return {
623
822
  id: data.id,
823
+ writerGeneration: data.writer_generation ?? undefined,
824
+ revision: typeof data.revision === "number" && data.revision > 0
825
+ ? data.revision
826
+ : undefined,
624
827
  name: data.name ?? undefined,
625
828
  createdAt: data.created_at,
626
829
  updatedAt: data.updated_at,
@@ -646,10 +849,6 @@ async function listJsonlSessions(limit) {
646
849
  return [];
647
850
  }
648
851
  }
649
- /**
650
- * Merge SQLite + JSONL sources so enabling better-sqlite3 never hides an
651
- * existing JSONL history (or vice versa). Prefer the newer updatedAt per id.
652
- */
653
852
  function mergeSessionLists(...lists) {
654
853
  return sortHistoryByUpdatedDesc(dedupeHistoryById(lists.flat()));
655
854
  }
@@ -670,9 +869,6 @@ export async function listSessions(limit = 20, options = {}) {
670
869
  // Do not let a slow pre-write/pre-recovery read overwrite a newer cache.
671
870
  // Every mutation increments this generation before and after persistence.
672
871
  const loadGeneration = sessionListGeneration;
673
- // JSONL parsing and optional SQLite startup are independent. Running them
674
- // concurrently removes dynamic SQLite import latency from /history's
675
- // critical path instead of paying both costs serially.
676
872
  const [fromJsonl, db] = await Promise.all([
677
873
  listJsonlSessions(0),
678
874
  loadDatabase(),
@@ -681,7 +877,7 @@ export async function listSessions(limit = 20, options = {}) {
681
877
  if (db) {
682
878
  try {
683
879
  const rows = db
684
- .prepare("SELECT id, name, created_at, updated_at, cwd, messages_json FROM sessions ORDER BY updated_at DESC")
880
+ .prepare("SELECT id, name, created_at, updated_at, writer_generation, revision, cwd, messages_json FROM sessions ORDER BY updated_at DESC")
685
881
  .all();
686
882
  fromDb = rows.map(rowToSession);
687
883
  }
@@ -710,7 +906,7 @@ export async function getSession(sessionId) {
710
906
  let fromDb;
711
907
  if (db) {
712
908
  const row = db
713
- .prepare("SELECT id, name, created_at, updated_at, cwd, messages_json FROM sessions WHERE id = ?")
909
+ .prepare("SELECT id, name, created_at, updated_at, writer_generation, revision, cwd, messages_json FROM sessions WHERE id = ?")
714
910
  .get(sessionId);
715
911
  if (row)
716
912
  fromDb = rowToSession(row);
@@ -720,28 +916,30 @@ export async function getSession(sessionId) {
720
916
  const fromArchive = fromJsonl
721
917
  ? undefined
722
918
  : (await readJsonlRecordsFrom(archiveFilePath())).find((s) => s.id === sessionId);
723
- const candidates = [fromDb, fromJsonl, fromArchive].filter((r) => Boolean(r));
724
- if (candidates.length === 0)
919
+ const candidates = [fromJsonl, fromDb, fromArchive].filter((record) => Boolean(record));
920
+ const freshest = freshestHistoryRecord(candidates);
921
+ if (!freshest)
725
922
  return undefined;
726
- return sortHistoryByUpdatedDesc(candidates)[0];
923
+ // Heal split-brain stores on selection. This is especially important across
924
+ // upgrades where optional SQLite availability changes between launches.
925
+ if (!fromJsonl || compareHistoryFreshness(freshest, fromJsonl) > 0) {
926
+ await upsertJsonl(freshest);
927
+ }
928
+ if (db && (!fromDb || compareHistoryFreshness(freshest, fromDb) > 0)) {
929
+ upsertSqlite(db, freshest);
930
+ await enforceSqliteRetention(db);
931
+ invalidateSessionListCache();
932
+ }
933
+ return freshest;
727
934
  }
728
935
  export function getHistoryPath() {
729
936
  // Prefer JSONL as the durable path users can inspect/backup; SQLite is
730
937
  // optional acceleration when better-sqlite3 is installed.
731
938
  return jsonlFilePath();
732
939
  }
733
- /**
734
- * Clear active history after archiving a full snapshot. Never unrecoverably
735
- * destroys chats — a timestamped copy is written under history-backups/ and
736
- * the previous active file is moved into history-archive.jsonl.
737
- */
738
940
  export async function clearAllHistory() {
739
941
  let detail = "";
740
942
  await ensureHistoryRecovered();
741
- // Snapshot + move aside. Do NOT write into history-archive.jsonl here —
742
- // that file is auto-reimported by recoverOrphanedHistory, which would
743
- // immediately undo a clear. Intentional clears go to history-cleared-*.jsonl
744
- // (and rolling backups) only.
745
943
  try {
746
944
  const snapshot = await readJsonlRecordsFrom(jsonlFilePath());
747
945
  if (snapshot.length > 0) {