cursor-opencode-provider 0.8.0 → 0.8.3

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.
Files changed (132) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/README.md +230 -44
  3. package/SECURITY.md +60 -12
  4. package/dist/activity.d.ts +73 -1
  5. package/dist/activity.js +231 -5
  6. package/dist/agent-url.js +37 -9
  7. package/dist/auth-renewal.d.ts +1 -1
  8. package/dist/auth-renewal.js +58 -26
  9. package/dist/billing.d.ts +105 -0
  10. package/dist/billing.js +212 -0
  11. package/dist/classic-tool-results.d.ts +1 -0
  12. package/dist/classic-tool-results.js +42 -0
  13. package/dist/context/build.d.ts +23 -4
  14. package/dist/context/build.js +65 -10
  15. package/dist/context/dynamic-catalog.js +6 -1
  16. package/dist/context/env.js +27 -3
  17. package/dist/context/epoch.js +9 -5
  18. package/dist/context/frozen.js +6 -2
  19. package/dist/context/git.d.ts +5 -2
  20. package/dist/context/git.js +17 -7
  21. package/dist/context/index.d.ts +2 -0
  22. package/dist/context/index.js +2 -0
  23. package/dist/context/paths.d.ts +45 -18
  24. package/dist/context/paths.js +72 -22
  25. package/dist/context/skills-bridge.d.ts +22 -0
  26. package/dist/context/skills-bridge.js +20 -0
  27. package/dist/context/skills.d.ts +34 -0
  28. package/dist/context/skills.js +211 -0
  29. package/dist/debug.js +13 -2
  30. package/dist/early-tool-rows.d.ts +60 -0
  31. package/dist/early-tool-rows.js +96 -0
  32. package/dist/errors.d.ts +49 -7
  33. package/dist/errors.js +163 -8
  34. package/dist/host-agent-mode.d.ts +53 -4
  35. package/dist/host-agent-mode.js +117 -13
  36. package/dist/host-interrupt.d.ts +12 -0
  37. package/dist/host-interrupt.js +18 -0
  38. package/dist/host-plan-file.d.ts +28 -0
  39. package/dist/host-plan-file.js +76 -0
  40. package/dist/host-steer.d.ts +71 -0
  41. package/dist/host-steer.js +232 -0
  42. package/dist/image-input.d.ts +18 -0
  43. package/dist/image-input.js +122 -30
  44. package/dist/image-save.d.ts +10 -2
  45. package/dist/image-save.js +33 -21
  46. package/dist/index.d.ts +2 -2
  47. package/dist/language-model.d.ts +149 -30
  48. package/dist/language-model.js +3127 -673
  49. package/dist/lost-request.d.ts +39 -0
  50. package/dist/lost-request.js +111 -0
  51. package/dist/model-config.d.ts +12 -1
  52. package/dist/model-config.js +78 -30
  53. package/dist/models.d.ts +26 -3
  54. package/dist/models.js +101 -45
  55. package/dist/opencode2/catalog.d.ts +1 -0
  56. package/dist/opencode2/catalog.js +2 -0
  57. package/dist/opencode2/image-save-tool.d.ts +8 -0
  58. package/dist/opencode2/image-save-tool.js +84 -0
  59. package/dist/opencode2/tool-results.d.ts +18 -0
  60. package/dist/opencode2/tool-results.js +66 -0
  61. package/dist/opencode2/types.d.ts +15 -0
  62. package/dist/parallel-step.d.ts +65 -0
  63. package/dist/parallel-step.js +102 -0
  64. package/dist/plugin-opencode2.js +233 -65
  65. package/dist/plugin.js +97 -23
  66. package/dist/pricing-data.d.ts +40 -3
  67. package/dist/pricing-data.js +40 -3
  68. package/dist/pricing.d.ts +1 -1
  69. package/dist/pricing.js +7 -4
  70. package/dist/process-shared.d.ts +14 -0
  71. package/dist/process-shared.js +18 -0
  72. package/dist/protocol/apply-patch.d.ts +25 -13
  73. package/dist/protocol/apply-patch.js +79 -30
  74. package/dist/protocol/ask-question.d.ts +12 -1
  75. package/dist/protocol/ask-question.js +77 -3
  76. package/dist/protocol/blob-store.d.ts +5 -1
  77. package/dist/protocol/blob-store.js +10 -6
  78. package/dist/protocol/client-version.js +6 -4
  79. package/dist/protocol/conversation-cache-header.d.ts +23 -0
  80. package/dist/protocol/conversation-cache-header.js +149 -0
  81. package/dist/protocol/conversation-persistence.d.ts +27 -1
  82. package/dist/protocol/conversation-persistence.js +628 -126
  83. package/dist/protocol/conversation-state.d.ts +8 -1
  84. package/dist/protocol/conversation-state.js +28 -7
  85. package/dist/protocol/create-plan.d.ts +47 -25
  86. package/dist/protocol/create-plan.js +33 -23
  87. package/dist/protocol/host-notes.d.ts +31 -0
  88. package/dist/protocol/host-notes.js +85 -0
  89. package/dist/protocol/interactions.d.ts +26 -17
  90. package/dist/protocol/interactions.js +80 -31
  91. package/dist/protocol/messages.js +164 -21
  92. package/dist/protocol/request.d.ts +40 -19
  93. package/dist/protocol/request.js +55 -28
  94. package/dist/protocol/sent-history-images.d.ts +49 -0
  95. package/dist/protocol/sent-history-images.js +102 -0
  96. package/dist/protocol/switch-mode.d.ts +66 -27
  97. package/dist/protocol/switch-mode.js +147 -73
  98. package/dist/protocol/token-details.d.ts +2 -0
  99. package/dist/protocol/token-details.js +11 -1
  100. package/dist/protocol/tool-call-bridge.d.ts +6 -0
  101. package/dist/protocol/tool-call-bridge.js +24 -0
  102. package/dist/protocol/tools.d.ts +90 -9
  103. package/dist/protocol/tools.js +543 -151
  104. package/dist/protocol/turn-provenance.d.ts +19 -4
  105. package/dist/protocol/turn-provenance.js +77 -20
  106. package/dist/replay-safety.d.ts +22 -4
  107. package/dist/replay-safety.js +86 -27
  108. package/dist/retry-budget.d.ts +21 -0
  109. package/dist/retry-budget.js +94 -0
  110. package/dist/runtime-files.d.ts +23 -0
  111. package/dist/runtime-files.js +581 -0
  112. package/dist/session.d.ts +129 -10
  113. package/dist/session.js +198 -35
  114. package/dist/shared.d.ts +1 -1
  115. package/dist/shared.js +1 -1
  116. package/dist/shell-timeout.d.ts +54 -10
  117. package/dist/shell-timeout.js +362 -92
  118. package/dist/stream-write.d.ts +20 -0
  119. package/dist/stream-write.js +87 -0
  120. package/dist/terminal-runner.d.ts +15 -0
  121. package/dist/terminal-runner.js +528 -0
  122. package/dist/todo-store.d.ts +2 -0
  123. package/dist/todo-store.js +19 -0
  124. package/dist/transport/connect.d.ts +10 -1
  125. package/dist/transport/connect.js +124 -27
  126. package/dist/usage.d.ts +37 -17
  127. package/dist/usage.js +85 -34
  128. package/package.json +5 -3
  129. package/dist/plan-execution-kickoff.d.ts +0 -57
  130. package/dist/plan-execution-kickoff.js +0 -186
  131. package/dist/protocol/thinking.d.ts +0 -15
  132. package/dist/protocol/thinking.js +0 -17
@@ -1,16 +1,81 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import path from "node:path";
3
- import { chmod, link, mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
4
- import { constants as zlibConstants, gunzipSync, gzipSync } from "node:zlib";
3
+ import { constants as fsConstants } from "node:fs";
4
+ import { chmod, link, lstat, mkdir, open, opendir, rename, rmdir, unlink, writeFile } from "node:fs/promises";
5
+ import { constants as zlibConstants, gunzip, gzip } from "node:zlib";
6
+ import { promisify } from "node:util";
5
7
  import protobuf from "protobufjs";
6
8
  import { CONVERSATION_CACHE_DIR, CONVERSATION_CACHE_SCHEMA_VERSION, CONVERSATION_CACHE_TTL_MS, } from "../shared.js";
7
9
  import { decodeMessageSparse, encodeMessage } from "./messages.js";
8
10
  import { trace } from "../debug.js";
11
+ import { readConversationCacheHeader } from "./conversation-cache-header.js";
9
12
  const stores = new Map();
10
13
  const initializedRoots = new Map();
11
14
  const startupDiscardedStatuses = new Map();
12
15
  const MAX_CONVERSATION_CACHE_BYTES = 256 * 1024 * 1024;
13
16
  const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
17
+ const gunzipAsync = promisify(gunzip);
18
+ const gzipAsync = promisify(gzip);
19
+ const MAINTENANCE_INTERVAL_MS = 60 * 60 * 1000;
20
+ const maintenanceRoots = new Map();
21
+ const fileOperations = new Map();
22
+ /** `.${snapshot}.${pid}.${uuid}.tmp`, as written by writeConversationFile. */
23
+ const WRITER_TEMP_FILE = /^\..+\.pb\.gz\.\d+\.[^.]+\.tmp$/;
24
+ let testHook;
25
+ function errorCode(error) {
26
+ return error && typeof error === "object" && "code" in error ? error.code : undefined;
27
+ }
28
+ function sameFile(left, right) {
29
+ return left.dev === right.dev && left.ino === right.ino;
30
+ }
31
+ async function withFileOperation(filePath, operation) {
32
+ const previous = fileOperations.get(filePath) ?? Promise.resolve();
33
+ const result = previous.catch(() => { }).then(operation);
34
+ const settled = result.then(() => { }, () => { });
35
+ fileOperations.set(filePath, settled);
36
+ void settled.then(() => {
37
+ if (fileOperations.get(filePath) === settled)
38
+ fileOperations.delete(filePath);
39
+ });
40
+ return result;
41
+ }
42
+ function quarantineDirectory(filePath) {
43
+ return path.join(path.dirname(filePath), ".gc", path.basename(filePath));
44
+ }
45
+ /**
46
+ * Entries of a directory another collector may remove at any time. Bun reads
47
+ * a Dir lazily, so that ENOENT can surface from iteration instead of opendir;
48
+ * either way the directory is treated as empty.
49
+ */
50
+ async function* removableDirectoryEntries(directory) {
51
+ let handle;
52
+ try {
53
+ handle = await opendir(directory);
54
+ }
55
+ catch (error) {
56
+ if (errorCode(error) === "ENOENT")
57
+ return;
58
+ throw error;
59
+ }
60
+ try {
61
+ await testHook?.("directory-opened", directory);
62
+ }
63
+ catch (error) {
64
+ await handle.close();
65
+ throw error;
66
+ }
67
+ try {
68
+ for await (const entry of handle)
69
+ yield entry;
70
+ }
71
+ catch (error) {
72
+ if (errorCode(error) !== "ENOENT")
73
+ throw error;
74
+ }
75
+ }
76
+ function openSnapshot(filePath) {
77
+ return open(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
78
+ }
14
79
  function recordStartupDiscard(cacheDir, fileName, status) {
15
80
  const root = path.resolve(cacheDir);
16
81
  let statuses = startupDiscardedStatuses.get(root);
@@ -39,6 +104,7 @@ function cloneConversation(value) {
39
104
  requestContext: structuredClone(value.requestContext),
40
105
  toolCatalog: structuredClone(value.toolCatalog),
41
106
  postCompactionRebase: value.postCompactionRebase,
107
+ ...(value.sentImageHashes ? { sentImageHashes: [...value.sentImageHashes] } : {}),
42
108
  };
43
109
  }
44
110
  /**
@@ -48,7 +114,8 @@ function cloneConversation(value) {
48
114
  * ConversationCache: schema_version=1, session_key=2, conversation_id=3,
49
115
  * updated_at=4, checkpoint=5, blobs=6, request_context=7, tool_catalog=8,
50
116
  * post_compaction_rebase=9, host_agent=10, system_prompt_hash=11,
51
- * turn_provenance_json=12.
117
+ * turn_provenance_json=12, host_note=13, early_steers_json=14,
118
+ * lost_request_json=15, sent_image_hashes=16 (repeated), sent_images_checkpoint_sha256=17.
52
119
  * Blob: id=1, data=2. Tool: name=1, description=2,
53
120
  * input_schema_json=3, source_name=4.
54
121
  *
@@ -179,6 +246,17 @@ function encodeCacheFile(value) {
179
246
  writer.uint32(fieldTag(11, 2)).string(value.systemPromptHash);
180
247
  if (value.turnProvenance)
181
248
  writer.uint32(fieldTag(12, 2)).string(value.turnProvenance);
249
+ if (value.hostNote)
250
+ writer.uint32(fieldTag(13, 2)).string(value.hostNote);
251
+ if (value.earlySteers)
252
+ writer.uint32(fieldTag(14, 2)).string(value.earlySteers);
253
+ if (value.lostRequest)
254
+ writer.uint32(fieldTag(15, 2)).string(value.lostRequest);
255
+ if (value.sentImagesCheckpoint && value.sentImageHashes?.length) {
256
+ for (const hash of value.sentImageHashes)
257
+ writer.uint32(fieldTag(16, 2)).string(hash);
258
+ writer.uint32(fieldTag(17, 2)).string(value.sentImagesCheckpoint);
259
+ }
182
260
  return { protobufBytes: writer.finish(), requestContextBytes: requestContext.length };
183
261
  }
184
262
  function decodeProtobuf(data) {
@@ -195,27 +273,32 @@ function decodeProtobuf(data) {
195
273
  let hostAgent;
196
274
  let systemPromptHash;
197
275
  let turnProvenance;
276
+ let hostNote;
277
+ let earlySteers;
278
+ let lostRequest;
279
+ const sentImageHashes = [];
280
+ let sentImagesCheckpoint;
198
281
  while (reader.pos < reader.len) {
199
282
  const tag = reader.uint32();
200
283
  const wireType = tag & 7;
201
284
  switch (tag >>> 3) {
202
285
  case 1:
203
- if (wireType !== 0)
286
+ if (wireType !== 0 || schemaVersion !== undefined)
204
287
  throw new Error("invalid schema version");
205
288
  schemaVersion = reader.uint32();
206
289
  break;
207
290
  case 2:
208
- if (wireType !== 2)
291
+ if (wireType !== 2 || sessionKey !== undefined)
209
292
  throw new Error("invalid session key");
210
293
  sessionKey = reader.string();
211
294
  break;
212
295
  case 3:
213
- if (wireType !== 2)
296
+ if (wireType !== 2 || conversationId !== undefined)
214
297
  throw new Error("invalid conversation id");
215
298
  conversationId = reader.string();
216
299
  break;
217
300
  case 4:
218
- if (wireType !== 0)
301
+ if (wireType !== 0 || updatedAt !== undefined)
219
302
  throw new Error("invalid update timestamp");
220
303
  updatedAt = readUint64(reader);
221
304
  break;
@@ -259,6 +342,31 @@ function decodeProtobuf(data) {
259
342
  throw new Error("invalid turn provenance");
260
343
  turnProvenance = reader.string();
261
344
  break;
345
+ case 13:
346
+ if (wireType !== 2)
347
+ throw new Error("invalid host note");
348
+ hostNote = reader.string();
349
+ break;
350
+ case 14:
351
+ if (wireType !== 2)
352
+ throw new Error("invalid early steers");
353
+ earlySteers = reader.string();
354
+ break;
355
+ case 15:
356
+ if (wireType !== 2)
357
+ throw new Error("invalid lost request");
358
+ lostRequest = reader.string();
359
+ break;
360
+ case 16:
361
+ if (wireType !== 2)
362
+ throw new Error("invalid sent image hash");
363
+ sentImageHashes.push(reader.string());
364
+ break;
365
+ case 17:
366
+ if (wireType !== 2)
367
+ throw new Error("invalid sent images checkpoint");
368
+ sentImagesCheckpoint = reader.string();
369
+ break;
262
370
  default:
263
371
  reader.skipType(wireType);
264
372
  }
@@ -280,90 +388,199 @@ function decodeProtobuf(data) {
280
388
  ...(hostAgent ? { hostAgent } : {}),
281
389
  ...(systemPromptHash ? { systemPromptHash } : {}),
282
390
  ...(turnProvenance ? { turnProvenance } : {}),
391
+ ...(hostNote ? { hostNote } : {}),
392
+ ...(earlySteers ? { earlySteers } : {}),
393
+ ...(lostRequest ? { lostRequest } : {}),
394
+ ...(sentImagesCheckpoint && sentImageHashes.length > 0 ? { sentImageHashes, sentImagesCheckpoint } : {}),
283
395
  };
284
396
  }
285
- function decodeCacheFile(compressed, expectedSessionKey) {
286
- try {
287
- if (compressed.length > MAX_CONVERSATION_CACHE_BYTES)
288
- return undefined;
289
- const protobufBytes = gunzipSync(compressed, { maxOutputLength: MAX_CONVERSATION_CACHE_BYTES });
290
- const conversation = decodeProtobuf(protobufBytes);
291
- if (expectedSessionKey && conversation.sessionKey !== expectedSessionKey)
292
- return undefined;
293
- return conversation;
294
- }
295
- catch {
296
- return undefined;
297
- }
298
- }
299
397
  function isFresh(value, now) {
300
398
  return now - value.updatedAt <= CONVERSATION_CACHE_TTL_MS;
301
399
  }
302
- async function readConversationFile(filePath, expectedSessionKey) {
400
+ /** Recover only this session's moved generations; never scan the conversation root. */
401
+ async function openConversationFile(filePath) {
303
402
  try {
304
- return decodeCacheFile(await readFile(filePath), expectedSessionKey);
403
+ return { handle: await openSnapshot(filePath), sourcePath: filePath };
305
404
  }
306
405
  catch (error) {
307
- const code = error && typeof error === "object" && "code" in error
308
- ? error.code
309
- : undefined;
310
- if (code !== "ENOENT")
311
- return undefined;
312
- return undefined;
406
+ if (errorCode(error) !== "ENOENT")
407
+ throw error;
313
408
  }
314
- }
315
- async function readConversationFileWithStatus(filePath, expectedSessionKey) {
409
+ let selected;
410
+ let newest = -Infinity;
316
411
  try {
317
- const value = decodeCacheFile(await readFile(filePath), expectedSessionKey);
318
- return value ? { status: "restored", value } : { status: "invalid" };
412
+ for await (const entry of removableDirectoryEntries(quarantineDirectory(filePath))) {
413
+ if (!entry.isFile() || !entry.name.endsWith(".snapshot"))
414
+ continue;
415
+ const sourcePath = path.join(quarantineDirectory(filePath), entry.name);
416
+ let handle;
417
+ try {
418
+ handle = await openSnapshot(sourcePath);
419
+ }
420
+ catch (error) {
421
+ if (errorCode(error) === "ENOENT")
422
+ continue;
423
+ throw error;
424
+ }
425
+ try {
426
+ const header = await readConversationCacheHeader(handle);
427
+ const updatedAt = header.status === "metadata" ? header.value.updatedAt : -Infinity;
428
+ if (!selected || updatedAt > newest) {
429
+ await selected?.handle.close();
430
+ selected = { handle, sourcePath };
431
+ newest = updatedAt;
432
+ }
433
+ else
434
+ await handle.close();
435
+ }
436
+ catch (error) {
437
+ await handle.close();
438
+ throw error;
439
+ }
440
+ }
441
+ // A collector may have restored its candidate, or a writer published a new
442
+ // generation while we inspected quarantine. The live name wins either way.
443
+ try {
444
+ const handle = await openSnapshot(filePath);
445
+ await selected?.handle.close();
446
+ return { handle, sourcePath: filePath };
447
+ }
448
+ catch (error) {
449
+ if (errorCode(error) !== "ENOENT")
450
+ throw error;
451
+ }
452
+ return selected;
319
453
  }
320
454
  catch (error) {
321
- const code = error && typeof error === "object" && "code" in error
322
- ? error.code
323
- : undefined;
324
- return { status: code === "ENOENT" ? "missing" : "invalid" };
455
+ await selected?.handle.close();
456
+ throw error;
325
457
  }
326
458
  }
327
- /**
328
- * Remove an invalid/stale candidate without deleting a fresh atomic replacement.
329
- * The candidate is first moved aside. If it turns out another process replaced
330
- * it between inspection and rename, hard-link it back only when the live path is
331
- * still empty; otherwise the writer's newer path wins.
332
- */
333
- async function discardCacheCandidate(filePath, now) {
334
- const quarantinePath = `${filePath}.${process.pid}.${randomUUID()}.stale`;
459
+ async function readConversationFileWithStatus(filePath, expectedSessionKey, now) {
460
+ const opened = await openConversationFile(filePath);
461
+ if (!opened)
462
+ return { status: "missing" };
463
+ const { handle, sourcePath } = opened;
464
+ let keepOpen = false;
335
465
  try {
336
- await rename(filePath, quarantinePath);
466
+ const stat = await handle.stat({ bigint: true });
467
+ const identity = { dev: stat.dev, ino: stat.ino };
468
+ const discarded = (status) => {
469
+ keepOpen = true;
470
+ return { status, identity, sourcePath, pin: handle };
471
+ };
472
+ if (!stat.isFile())
473
+ throw new Error("conversation snapshot is not a regular file");
474
+ if (stat.size > MAX_CONVERSATION_CACHE_BYTES)
475
+ return discarded("invalid");
476
+ const header = await readConversationCacheHeader(handle);
477
+ if (header.status === "invalid")
478
+ return discarded("invalid");
479
+ if (header.status === "metadata") {
480
+ if (header.value.sessionKey !== expectedSessionKey)
481
+ return discarded("invalid");
482
+ if (!isFresh(header.value, now))
483
+ return discarded("expired");
484
+ if (header.value.schemaVersion !== CONVERSATION_CACHE_SCHEMA_VERSION)
485
+ return discarded("invalid");
486
+ }
487
+ const compressed = await handle.readFile();
488
+ if (compressed.length > MAX_CONVERSATION_CACHE_BYTES)
489
+ return discarded("invalid");
490
+ await testHook?.("full-decode", filePath);
491
+ let value;
492
+ try {
493
+ const bytes = await gunzipAsync(compressed, { maxOutputLength: MAX_CONVERSATION_CACHE_BYTES });
494
+ value = decodeProtobuf(bytes);
495
+ }
496
+ catch {
497
+ return discarded("invalid");
498
+ }
499
+ if (value.sessionKey !== expectedSessionKey)
500
+ return discarded("invalid");
501
+ return isFresh(value, now)
502
+ ? { status: "restored", value, identity, sourcePath }
503
+ : discarded("expired");
337
504
  }
338
- catch (error) {
339
- const code = error && typeof error === "object" && "code" in error
340
- ? error.code
341
- : undefined;
342
- if (code === "ENOENT")
343
- return undefined;
344
- throw error;
505
+ finally {
506
+ if (!keepOpen)
507
+ await handle.close();
345
508
  }
509
+ }
510
+ /**
511
+ * Quarantine before deletion, then compare FILE IDENTITY rather than age. An
512
+ * atomic replacement has a different inode and must be restored without clobbering
513
+ * the live name. Readers can open this session's quarantine during that interval.
514
+ */
515
+ async function discardCacheCandidate(filePath, identity, signal) {
516
+ const directory = quarantineDirectory(filePath);
517
+ const quarantinePath = path.join(directory, `${randomUUID()}.snapshot`);
518
+ for (let attempt = 0;; attempt++) {
519
+ await ensureCacheDirectory(directory);
520
+ if (attempt === 0)
521
+ await testHook?.("before-quarantine", filePath);
522
+ signal?.throwIfAborted();
523
+ try {
524
+ await rename(filePath, quarantinePath);
525
+ break;
526
+ }
527
+ catch (error) {
528
+ if (errorCode(error) !== "ENOENT")
529
+ throw error;
530
+ }
531
+ // ENOENT names either side: the candidate is gone, or another collector
532
+ // removed this emptied quarantine directory after we created it.
533
+ try {
534
+ await lstat(filePath);
535
+ }
536
+ catch (error) {
537
+ if (errorCode(error) === "ENOENT")
538
+ return "missing";
539
+ throw error;
540
+ }
541
+ if (attempt === 2)
542
+ return "preserved";
543
+ }
544
+ let removable = false;
346
545
  try {
347
- const moved = await readConversationFile(quarantinePath);
348
- if (moved && isFresh(moved, now)) {
349
- try {
350
- await link(quarantinePath, filePath);
351
- return moved;
352
- }
353
- catch (error) {
354
- const code = error && typeof error === "object" && "code" in error
355
- ? error.code
356
- : undefined;
357
- if (code !== "EEXIST")
358
- throw error;
359
- return await readConversationFile(filePath);
360
- }
546
+ await testHook?.("after-quarantine", filePath);
547
+ let moved;
548
+ try {
549
+ moved = await openSnapshot(quarantinePath);
550
+ }
551
+ catch (error) {
552
+ // Another collector can finish/recover this immutable candidate first.
553
+ if (errorCode(error) === "ENOENT")
554
+ return "missing";
555
+ throw error;
556
+ }
557
+ try {
558
+ const stat = await moved.stat({ bigint: true });
559
+ removable = !signal?.aborted && sameFile(identity, stat);
560
+ }
561
+ finally {
562
+ await moved.close();
361
563
  }
362
564
  }
363
565
  finally {
364
- await unlink(quarantinePath).catch(() => { });
566
+ if (!removable) {
567
+ // If restoration fails, retain the discoverable quarantine for the next
568
+ // reader/pass. Never unlink the only surviving copy in a finally block.
569
+ await link(quarantinePath, filePath).catch((error) => {
570
+ if (errorCode(error) !== "EEXIST" && errorCode(error) !== "ENOENT")
571
+ throw error;
572
+ });
573
+ }
574
+ await unlink(quarantinePath).catch((error) => {
575
+ if (errorCode(error) !== "ENOENT")
576
+ throw error;
577
+ });
578
+ await rmdir(directory).catch((error) => {
579
+ if (errorCode(error) !== "ENOENT" && errorCode(error) !== "ENOTEMPTY")
580
+ throw error;
581
+ });
365
582
  }
366
- return await readConversationFile(filePath);
583
+ return removable ? "removed" : "preserved";
367
584
  }
368
585
  async function writeConversationFile(cacheDir, value) {
369
586
  const directory = conversationCacheDirectoryPath(cacheDir);
@@ -371,7 +588,7 @@ async function writeConversationFile(cacheDir, value) {
371
588
  const tempPath = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
372
589
  const encoded = encodeCacheFile(value);
373
590
  const { protobufBytes } = encoded;
374
- const compressed = gzipSync(protobufBytes, { level: zlibConstants.Z_BEST_COMPRESSION });
591
+ const compressed = await gzipAsync(protobufBytes, { level: zlibConstants.Z_BEST_COMPRESSION });
375
592
  await ensureCacheDirectory(directory);
376
593
  try {
377
594
  await writeFile(tempPath, compressed, { mode: 0o600 });
@@ -388,64 +605,265 @@ async function writeConversationFile(cacheDir, value) {
388
605
  }
389
606
  }
390
607
  async function ensureCacheDirectory(directory) {
391
- await mkdir(directory, { recursive: true, mode: 0o700 });
392
- if (process.platform !== "win32")
393
- await chmod(directory, 0o700);
608
+ for (let attempt = 0;; attempt++) {
609
+ await mkdir(directory, { recursive: true, mode: 0o700 });
610
+ if (process.platform === "win32")
611
+ return;
612
+ try {
613
+ await chmod(directory, 0o700);
614
+ return;
615
+ }
616
+ catch (error) {
617
+ // A concurrent collector can rmdir an emptied quarantine directory
618
+ // between mkdir and chmod; recreate it instead of failing the pass.
619
+ if (errorCode(error) !== "ENOENT" || attempt === 2)
620
+ throw error;
621
+ }
622
+ }
394
623
  }
395
624
  function queueWrite(store, value) {
396
625
  const snapshot = cloneConversation(value);
397
- const write = store.writeChain.catch(() => { }).then(() => writeConversationFile(store.cacheDir, snapshot));
626
+ const filePath = conversationCacheFilePath(store.cacheDir, store.sessionKey);
627
+ const write = store.writeChain.catch(() => { }).then(() => withFileOperation(filePath, () => writeConversationFile(store.cacheDir, snapshot)));
398
628
  store.writeChain = write;
399
629
  return write;
400
630
  }
401
- function queueDelete(store) {
631
+ function queueDelete(store, expectedConversationId) {
402
632
  const write = store.writeChain.catch(() => { }).then(async () => {
403
- await unlink(conversationCacheFilePath(store.cacheDir, store.sessionKey)).catch((error) => {
404
- const code = error && typeof error === "object" && "code" in error
405
- ? error.code
406
- : undefined;
407
- if (code !== "ENOENT")
408
- throw error;
633
+ const filePath = conversationCacheFilePath(store.cacheDir, store.sessionKey);
634
+ await withFileOperation(filePath, async () => {
635
+ const opened = await openConversationFile(filePath);
636
+ if (!opened)
637
+ return;
638
+ try {
639
+ const stat = await opened.handle.stat({ bigint: true });
640
+ const header = await readConversationCacheHeader(opened.handle);
641
+ if (header.status !== "metadata" || header.value.sessionKey !== store.sessionKey ||
642
+ header.value.conversationId !== expectedConversationId)
643
+ return;
644
+ if (opened.sourcePath === filePath)
645
+ await discardCacheCandidate(filePath, stat);
646
+ else
647
+ await unlink(opened.sourcePath).catch((error) => {
648
+ if (errorCode(error) !== "ENOENT")
649
+ throw error;
650
+ });
651
+ }
652
+ finally {
653
+ await opened.handle.close();
654
+ }
409
655
  });
410
656
  });
411
657
  store.writeChain = write;
412
658
  return write;
413
659
  }
414
- async function pruneConversationDirectory(cacheDir, now) {
660
+ async function recoverQuarantine(filePath, now, signal) {
661
+ for await (const entry of removableDirectoryEntries(quarantineDirectory(filePath))) {
662
+ signal.throwIfAborted();
663
+ if (!entry.isFile() || !entry.name.endsWith(".snapshot"))
664
+ continue;
665
+ const candidate = path.join(quarantineDirectory(filePath), entry.name);
666
+ let handle;
667
+ try {
668
+ handle = await openSnapshot(candidate);
669
+ }
670
+ catch (error) {
671
+ if (errorCode(error) === "ENOENT")
672
+ continue;
673
+ throw error;
674
+ }
675
+ let status;
676
+ try {
677
+ const header = await readConversationCacheHeader(handle, signal);
678
+ if (header.status === "invalid")
679
+ status = "invalid";
680
+ else if (header.status === "metadata" && !isFresh(header.value, now))
681
+ status = "expired";
682
+ }
683
+ finally {
684
+ await handle.close();
685
+ }
686
+ signal.throwIfAborted();
687
+ if (!status) {
688
+ await link(candidate, filePath).catch((error) => {
689
+ if (errorCode(error) !== "EEXIST" && errorCode(error) !== "ENOENT")
690
+ throw error;
691
+ });
692
+ }
693
+ await unlink(candidate).catch((error) => {
694
+ if (errorCode(error) !== "ENOENT")
695
+ throw error;
696
+ });
697
+ if (status)
698
+ recordStartupDiscard(path.dirname(path.dirname(filePath)), path.basename(filePath), status);
699
+ }
700
+ await rmdir(quarantineDirectory(filePath)).catch((error) => {
701
+ if (errorCode(error) !== "ENOENT" && errorCode(error) !== "ENOTEMPTY")
702
+ throw error;
703
+ });
704
+ }
705
+ async function pruneConversationDirectory(cacheDir, now, signal) {
415
706
  const directory = conversationCacheDirectoryPath(cacheDir);
416
- await ensureCacheDirectory(directory);
417
- const entries = await readdir(directory, { withFileTypes: true });
418
- await Promise.all(entries.map(async (entry) => {
707
+ // Recover crash leftovers before inspecting canonical names. Only per-session
708
+ // directories are read, and readers use exactly the same recovery location.
709
+ for await (const entry of removableDirectoryEntries(path.join(directory, ".gc"))) {
710
+ signal.throwIfAborted();
711
+ if (!entry.isDirectory() || !entry.name.endsWith(".pb.gz"))
712
+ continue;
713
+ const filePath = path.join(directory, entry.name);
714
+ await maintainEntry(entry.name, signal, () => withFileOperation(filePath, () => recoverQuarantine(filePath, now, signal)));
715
+ }
716
+ const entries = await opendir(directory);
717
+ for await (const entry of entries) {
718
+ signal.throwIfAborted();
419
719
  if (!entry.isFile())
420
- return;
720
+ continue;
421
721
  const filePath = path.join(directory, entry.name);
422
722
  if (entry.name.endsWith(".json")) {
423
- trace(`conversation persistence: startup removed legacy JSON snapshot file=${entry.name}`);
424
- await unlink(filePath).catch(() => { });
425
- return;
723
+ await maintainEntry(entry.name, signal, () => unlink(filePath).catch((error) => {
724
+ if (errorCode(error) !== "ENOENT")
725
+ throw error;
726
+ }));
727
+ continue;
728
+ }
729
+ if (WRITER_TEMP_FILE.test(entry.name)) {
730
+ await maintainEntry(entry.name, signal, () => collectWriterTemp(filePath, now, signal));
731
+ continue;
426
732
  }
427
733
  if (!entry.name.endsWith(".pb.gz"))
734
+ continue;
735
+ // In-process loads/writes/deletes share this per-file queue, so a loaded
736
+ // session's snapshot still expires by its persisted age without restart.
737
+ await maintainEntry(entry.name, signal, () => withFileOperation(filePath, async () => {
738
+ signal.throwIfAborted();
739
+ let handle;
740
+ try {
741
+ handle = await openSnapshot(filePath);
742
+ }
743
+ catch (error) {
744
+ if (errorCode(error) === "ENOENT")
745
+ return;
746
+ throw error;
747
+ }
748
+ try {
749
+ const stat = await handle.stat({ bigint: true });
750
+ if (!stat.isFile())
751
+ return;
752
+ const identity = { dev: stat.dev, ino: stat.ino };
753
+ let status;
754
+ const header = await readConversationCacheHeader(handle, signal);
755
+ if (header.status === "invalid")
756
+ status = "invalid";
757
+ else if (header.status === "metadata" && !isFresh(header.value, now))
758
+ status = "expired";
759
+ if (!status)
760
+ return;
761
+ signal.throwIfAborted();
762
+ if (await discardCacheCandidate(filePath, identity, signal) === "removed") {
763
+ recordStartupDiscard(cacheDir, entry.name, status);
764
+ trace(`conversation persistence: maintenance discarded ${status} snapshot file=${entry.name}`);
765
+ }
766
+ }
767
+ finally {
768
+ await handle.close();
769
+ }
770
+ }));
771
+ }
772
+ }
773
+ /** One entry's I/O failure must not starve every later entry of collection. */
774
+ async function maintainEntry(fileName, signal, operation) {
775
+ try {
776
+ await operation();
777
+ }
778
+ catch (error) {
779
+ if (signal.aborted)
780
+ throw error;
781
+ trace(`conversation persistence: maintenance skipped file=${fileName}: ${String(error)}`);
782
+ }
783
+ }
784
+ /**
785
+ * A writer killed between writeFile and rename leaves its temp file behind.
786
+ * An in-flight writer's temp always carries a fresh timestamp, so only a
787
+ * readable expired prefix proves abandonment; unreadable temps are kept.
788
+ */
789
+ async function collectWriterTemp(filePath, now, signal) {
790
+ let handle;
791
+ try {
792
+ handle = await openSnapshot(filePath);
793
+ }
794
+ catch (error) {
795
+ if (errorCode(error) === "ENOENT")
428
796
  return;
429
- const loaded = await readConversationFileWithStatus(filePath);
430
- if (loaded.status === "invalid") {
431
- recordStartupDiscard(cacheDir, entry.name, "invalid");
432
- trace(`conversation persistence: startup discarded invalid snapshot file=${entry.name}`);
433
- await discardCacheCandidate(filePath, now);
434
- }
435
- else if (loaded.value && !isFresh(loaded.value, now)) {
436
- recordStartupDiscard(cacheDir, entry.name, "expired");
437
- trace(`conversation persistence: startup discarded expired snapshot ` +
438
- `sessionKey=${loaded.value.sessionKey} ageMs=${Math.max(0, now - loaded.value.updatedAt)}`);
439
- await discardCacheCandidate(filePath, now);
440
- }
441
- }));
797
+ throw error;
798
+ }
799
+ let expired = false;
800
+ try {
801
+ if (!(await handle.stat()).isFile())
802
+ return;
803
+ const header = await readConversationCacheHeader(handle, signal);
804
+ expired = header.status === "metadata" && !isFresh(header.value, now);
805
+ }
806
+ finally {
807
+ await handle.close();
808
+ }
809
+ if (!expired)
810
+ return;
811
+ signal.throwIfAborted();
812
+ await unlink(filePath).catch((error) => {
813
+ if (errorCode(error) !== "ENOENT")
814
+ throw error;
815
+ });
816
+ trace(`conversation persistence: maintenance discarded abandoned temp file=${path.basename(filePath)}`);
442
817
  }
443
- /** Create the per-session cache directory and prune records older than 24 hours once per process. */
818
+ function scheduleMaintenance(cacheDir, now, force = false) {
819
+ let state = maintenanceRoots.get(cacheDir);
820
+ if (!state) {
821
+ state = { controller: new AbortController() };
822
+ maintenanceRoots.set(cacheDir, state);
823
+ }
824
+ if (state.task || state.controller.signal.aborted)
825
+ return;
826
+ if (!force && state.lastStarted !== undefined && now - state.lastStarted < MAINTENANCE_INTERVAL_MS)
827
+ return;
828
+ const current = state;
829
+ const signal = current.controller.signal;
830
+ const task = (async () => {
831
+ await new Promise((resolve) => {
832
+ const abort = () => {
833
+ clearImmediate(current.scheduled);
834
+ resolve();
835
+ };
836
+ current.scheduled = setImmediate(() => {
837
+ signal.removeEventListener("abort", abort);
838
+ current.scheduled = undefined;
839
+ resolve();
840
+ });
841
+ current.scheduled.unref();
842
+ signal.addEventListener("abort", abort, { once: true });
843
+ });
844
+ signal.throwIfAborted();
845
+ current.lastStarted = now;
846
+ await testHook?.("maintenance-start", cacheDir);
847
+ signal.throwIfAborted();
848
+ await pruneConversationDirectory(cacheDir, now, signal);
849
+ })();
850
+ current.task = task;
851
+ const done = () => {
852
+ if (current.task === task)
853
+ current.task = undefined;
854
+ };
855
+ void task.then(done, (error) => {
856
+ done();
857
+ if (!signal.aborted)
858
+ trace(`conversation persistence: maintenance failed: ${String(error)}`);
859
+ });
860
+ }
861
+ /** Readiness includes directory permissions only; maintenance never gates hydration. */
444
862
  export async function initializeConversationPersistence(cacheDir, now = Date.now()) {
445
863
  const key = path.resolve(cacheDir);
446
864
  let pending = initializedRoots.get(key);
447
865
  if (!pending) {
448
- pending = pruneConversationDirectory(key, now);
866
+ pending = ensureCacheDirectory(conversationCacheDirectoryPath(key));
449
867
  initializedRoots.set(key, pending);
450
868
  pending.catch(() => {
451
869
  if (initializedRoots.get(key) === pending)
@@ -453,29 +871,83 @@ export async function initializeConversationPersistence(cacheDir, now = Date.now
453
871
  });
454
872
  }
455
873
  await pending;
874
+ if (initializedRoots.get(key) === pending)
875
+ scheduleMaintenance(key, now);
456
876
  }
457
877
  async function loadStore(cacheDir, sessionKey, now) {
458
878
  await initializeConversationPersistence(cacheDir, now);
459
879
  const filePath = conversationCacheFilePath(cacheDir, sessionKey);
460
- const loaded = await readConversationFileWithStatus(filePath, sessionKey);
461
- let value = loaded.value;
462
- let loadStatus = loaded.status === "missing"
880
+ const loaded = await withFileOperation(filePath, async () => {
881
+ const candidate = await readConversationFileWithStatus(filePath, sessionKey, now);
882
+ try {
883
+ if ((candidate.status === "invalid" || candidate.status === "expired") && candidate.identity) {
884
+ if (candidate.sourcePath === filePath)
885
+ await discardCacheCandidate(filePath, candidate.identity);
886
+ else if (candidate.sourcePath)
887
+ await unlink(candidate.sourcePath).catch((error) => {
888
+ if (errorCode(error) !== "ENOENT")
889
+ throw error;
890
+ });
891
+ // A concurrent writer/recovery can replace the candidate during removal.
892
+ const replacement = await readConversationFileWithStatus(filePath, sessionKey, now);
893
+ await replacement.pin?.close();
894
+ if (replacement.status !== "missing")
895
+ return replacement;
896
+ }
897
+ return candidate;
898
+ }
899
+ finally {
900
+ await candidate.pin?.close();
901
+ }
902
+ });
903
+ const value = loaded.value && loaded.value.sessionKey === sessionKey && isFresh(loaded.value, now)
904
+ ? loaded.value
905
+ : undefined;
906
+ const loadStatus = loaded.status === "missing"
463
907
  ? startupDiscardedStatuses.get(path.resolve(cacheDir))?.get(path.basename(filePath)) ?? "missing"
464
908
  : loaded.status;
465
- if (value && !isFresh(value, now)) {
466
- loadStatus = "expired";
467
- value = await discardCacheCandidate(filePath, now);
468
- if (value && isFresh(value, now))
469
- loadStatus = "restored";
470
- }
471
909
  return {
472
910
  cacheDir,
473
911
  sessionKey,
474
- value: value && value.sessionKey === sessionKey && isFresh(value, now) ? value : undefined,
912
+ ...(value ? { head: conversationHead(value), value } : {}),
475
913
  loadStatus,
476
914
  writeChain: Promise.resolve(),
477
915
  };
478
916
  }
917
+ function conversationHead(value) {
918
+ return {
919
+ conversationId: value.conversationId,
920
+ updatedAt: value.updatedAt,
921
+ ...(value.turnProvenance ? { turnProvenance: value.turnProvenance } : {}),
922
+ };
923
+ }
924
+ /**
925
+ * Hand the loaded snapshot to its first reader, then read the durable copy.
926
+ * Hydration needs it only while the session has no live binding, so keeping
927
+ * a process-lifetime clone of every checkpoint and blob graph buys nothing.
928
+ */
929
+ async function takeConversation(store, now) {
930
+ const head = store.head;
931
+ if (!head)
932
+ return { status: store.loadStatus };
933
+ const held = store.value;
934
+ store.value = undefined;
935
+ if (held && isFresh(held, now))
936
+ return { status: "restored", value: held };
937
+ await store.writeChain.catch(() => { });
938
+ const filePath = conversationCacheFilePath(store.cacheDir, store.sessionKey);
939
+ const loaded = await withFileOperation(filePath, async () => {
940
+ const candidate = await readConversationFileWithStatus(filePath, store.sessionKey, now);
941
+ await candidate.pin?.close();
942
+ return candidate;
943
+ });
944
+ // A persist/delete that ran meanwhile owns the head; only report this read.
945
+ if (store.head === head) {
946
+ store.head = loaded.value ? conversationHead(loaded.value) : undefined;
947
+ store.loadStatus = loaded.status;
948
+ }
949
+ return loaded.value ? { status: "restored", value: loaded.value } : { status: loaded.status };
950
+ }
479
951
  async function getStore(cacheDir, sessionKey, now = Date.now()) {
480
952
  const root = path.resolve(cacheDir);
481
953
  const key = `${root}\0${sessionKey}`;
@@ -491,42 +963,72 @@ async function getStore(cacheDir, sessionKey, now = Date.now()) {
491
963
  return pending;
492
964
  }
493
965
  export async function getPersistedConversation(cacheDir, sessionKey, now = Date.now()) {
494
- const store = await getStore(cacheDir, sessionKey, now);
495
- return store.value ? cloneConversation(store.value) : undefined;
966
+ return (await loadPersistedConversation(cacheDir, sessionKey, now)).value;
496
967
  }
497
968
  /** Read a snapshot together with the reason restart hydration did not restore it. */
498
969
  export async function loadPersistedConversation(cacheDir, sessionKey, now = Date.now()) {
499
- const store = await getStore(cacheDir, sessionKey, now);
500
- return store.value
501
- ? { status: "restored", value: cloneConversation(store.value) }
502
- : { status: store.loadStatus };
970
+ return takeConversation(await getStore(cacheDir, sessionKey, now), now);
971
+ }
972
+ /** Turn provenance of the current snapshot, without decoding its payload. */
973
+ export async function getPersistedTurnProvenance(cacheDir, sessionKey, now = Date.now()) {
974
+ const head = (await getStore(cacheDir, sessionKey, now)).head;
975
+ if (!head?.turnProvenance || !isFresh(head, now))
976
+ return undefined;
977
+ return { conversationId: head.conversationId, turnProvenance: head.turnProvenance };
503
978
  }
504
979
  /** Replace one session's durable snapshot and refresh its 24-hour lease. */
505
980
  export async function persistConversation(cacheDir, value, now = Date.now()) {
506
981
  const store = await getStore(cacheDir, value.sessionKey, now);
507
- const snapshot = cloneConversation({
982
+ // queueWrite takes its own copy; the store keeps only the head.
983
+ const snapshot = {
508
984
  ...value,
509
985
  updatedAt: now,
510
- toolCatalog: structuredClone(value.toolCatalog ?? []),
986
+ toolCatalog: value.toolCatalog ?? [],
511
987
  postCompactionRebase: value.postCompactionRebase === true,
512
- });
513
- store.value = snapshot;
988
+ };
989
+ store.head = conversationHead(snapshot);
990
+ store.value = undefined;
514
991
  store.loadStatus = "restored";
515
992
  startupDiscardedStatuses.get(path.resolve(cacheDir))?.delete(path.basename(conversationCacheFilePath(cacheDir, value.sessionKey)));
993
+ await testHook?.("before-write", conversationCacheFilePath(cacheDir, value.sessionKey));
516
994
  await queueWrite(store, snapshot);
995
+ scheduleMaintenance(path.resolve(cacheDir), now);
517
996
  }
518
997
  /** Delete only the expected binding so an overlapping newer turn cannot be removed. */
519
998
  export async function deletePersistedConversation(cacheDir, sessionKey, expectedConversationId) {
520
999
  const store = await getStore(cacheDir, sessionKey);
521
- const current = store.value;
1000
+ const current = store.head;
522
1001
  if (!current || (expectedConversationId && current.conversationId !== expectedConversationId))
523
1002
  return;
1003
+ store.head = undefined;
524
1004
  store.value = undefined;
525
1005
  store.loadStatus = "missing";
526
- await queueDelete(store);
1006
+ await queueDelete(store, current.conversationId);
1007
+ }
1008
+ /** Full snapshots still held in memory; the store should keep heads only. */
1009
+ export async function retainedConversationSnapshotsForTests() {
1010
+ const settled = await Promise.allSettled(stores.values());
1011
+ return settled.filter((result) => result.status === "fulfilled" && result.value.value).length;
527
1012
  }
528
1013
  export function resetConversationPersistenceForTests() {
1014
+ for (const state of maintenanceRoots.values())
1015
+ state.controller.abort();
1016
+ maintenanceRoots.clear();
1017
+ fileOperations.clear();
1018
+ testHook = undefined;
529
1019
  stores.clear();
530
1020
  initializedRoots.clear();
531
1021
  startupDiscardedStatuses.clear();
532
1022
  }
1023
+ /** Deterministic maintenance barriers/race injection; not a request-path API. */
1024
+ export function setConversationPersistenceHookForTests(hook) {
1025
+ testHook = hook;
1026
+ }
1027
+ export async function waitForConversationMaintenanceForTests(cacheDir, now = Date.now()) {
1028
+ const root = path.resolve(cacheDir);
1029
+ await initializeConversationPersistence(root, now);
1030
+ scheduleMaintenance(root, now, true);
1031
+ const state = maintenanceRoots.get(root);
1032
+ state.scheduled?.ref();
1033
+ await state.task;
1034
+ }