@gajae-code/coding-agent 0.12.21 → 0.13.0

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 (222) hide show
  1. package/CHANGELOG.md +67 -2
  2. package/dist/types/cli/args.d.ts +5 -2
  3. package/dist/types/cli/root-flags.d.ts +246 -0
  4. package/dist/types/cli.d.ts +11 -19
  5. package/dist/types/commands/daemon.d.ts +0 -30
  6. package/dist/types/commands/launch.d.ts +11 -19
  7. package/dist/types/commands/quick-lane.d.ts +32 -0
  8. package/dist/types/commands/sdk.d.ts +21 -15
  9. package/dist/types/config/file-lock-gc.d.ts +2 -0
  10. package/dist/types/config/model-equivalence.d.ts +20 -0
  11. package/dist/types/config/model-profile-activation.d.ts +62 -17
  12. package/dist/types/config/model-profiles.d.ts +10 -0
  13. package/dist/types/config/model-registry.d.ts +68 -1
  14. package/dist/types/config/model-resolver.d.ts +15 -4
  15. package/dist/types/config/models-config-schema.d.ts +7 -2
  16. package/dist/types/config/provider-selection-policy.d.ts +62 -0
  17. package/dist/types/config/settings-schema.d.ts +36 -78
  18. package/dist/types/config/settings.d.ts +2 -0
  19. package/dist/types/edit/renderer.d.ts +5 -2
  20. package/dist/types/edit/streaming.d.ts +13 -0
  21. package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
  22. package/dist/types/extensibility/extensions/compact-handler.d.ts +2 -1
  23. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  24. package/dist/types/gjc-runtime/gc-runtime.d.ts +19 -5
  25. package/dist/types/internal-urls/local-protocol.d.ts +1 -0
  26. package/dist/types/modes/components/model-selector.d.ts +0 -2
  27. package/dist/types/modes/components/provider-order-context.d.ts +45 -0
  28. package/dist/types/modes/components/provider-order-editor.d.ts +24 -0
  29. package/dist/types/modes/components/settings-defs.d.ts +5 -1
  30. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  31. package/dist/types/quick-lane/classify.d.ts +40 -0
  32. package/dist/types/runtime/stt-settings-setup.d.ts +18 -0
  33. package/dist/types/sdk/broker/broker.d.ts +56 -2
  34. package/dist/types/sdk/broker/operation-receipt-ledger.d.ts +25 -0
  35. package/dist/types/sdk/broker/session-index.d.ts +83 -2
  36. package/dist/types/sdk/broker/state-version.d.ts +1 -1
  37. package/dist/types/sdk/bus/notification-service.d.ts +6 -0
  38. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +8 -1
  39. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -1
  40. package/dist/types/sdk/bus/telegram-daemon.d.ts +26 -0
  41. package/dist/types/sdk/cli/rows.d.ts +98 -0
  42. package/dist/types/sdk/cli/session-cli.d.ts +35 -3
  43. package/dist/types/sdk/elevation/allowlist.d.ts +20 -0
  44. package/dist/types/sdk/elevation/audit.d.ts +93 -0
  45. package/dist/types/sdk/elevation/capability.d.ts +3 -0
  46. package/dist/types/sdk/elevation/digest.d.ts +72 -0
  47. package/dist/types/sdk/elevation/dispatch-receipt.d.ts +85 -0
  48. package/dist/types/sdk/elevation/grant-ledger.d.ts +163 -0
  49. package/dist/types/sdk/elevation/owner.d.ts +48 -0
  50. package/dist/types/sdk/guides/cache.d.ts +98 -0
  51. package/dist/types/sdk/guides/catalog.d.ts +156 -0
  52. package/dist/types/sdk/guides/cli.d.ts +39 -0
  53. package/dist/types/sdk/guides/manifest.d.ts +66 -0
  54. package/dist/types/sdk/guides/verify.d.ts +66 -0
  55. package/dist/types/sdk/host/control/dispatch.d.ts +8 -0
  56. package/dist/types/sdk/host/control/index.d.ts +1 -1
  57. package/dist/types/sdk/host/control/operations.d.ts +3 -2
  58. package/dist/types/sdk/host/query/cursor.d.ts +10 -0
  59. package/dist/types/sdk/host/query/handlers.d.ts +21 -0
  60. package/dist/types/sdk/host/session-runtime.d.ts +9 -0
  61. package/dist/types/sdk/mcp/server.d.ts +2 -2
  62. package/dist/types/sdk/prompt-status.d.ts +11 -0
  63. package/dist/types/sdk/session.d.ts +4 -0
  64. package/dist/types/sdk/transport/index.d.ts +1 -0
  65. package/dist/types/sdk/transport/relay.d.ts +1 -0
  66. package/dist/types/sdk/transport/serve-cli.d.ts +20 -1
  67. package/dist/types/session/agent-session.d.ts +32 -18
  68. package/dist/types/session/blob-store.d.ts +13 -7
  69. package/dist/types/session/fallback-chain-controller.d.ts +15 -0
  70. package/dist/types/session/internal/managed-session-storage.d.ts +25 -1
  71. package/dist/types/session/internal/session-memory-sidecar.d.ts +5 -5
  72. package/dist/types/session/session-manager.d.ts +139 -10
  73. package/dist/types/session/session-storage.d.ts +48 -0
  74. package/dist/types/setup/provider-onboarding.d.ts +7 -1
  75. package/dist/types/slash-commands/builtin-registry.d.ts +14 -0
  76. package/dist/types/stt/setup.d.ts +2 -0
  77. package/dist/types/task/executor.d.ts +10 -2
  78. package/dist/types/tools/browser/runtime-diagnostics.d.ts +65 -0
  79. package/dist/types/tools/browser/tab-protocol.d.ts +4 -0
  80. package/dist/types/tools/browser/tab-supervisor.d.ts +2 -0
  81. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  82. package/dist/types/tools/browser.d.ts +2 -0
  83. package/dist/types/tools/image-gen.d.ts +14 -18
  84. package/dist/types/tools/index.d.ts +2 -0
  85. package/dist/types/tools/resource-gc.d.ts +2 -0
  86. package/dist/types/utils/pasted-image-path.d.ts +6 -0
  87. package/package.json +9 -9
  88. package/scripts/generate-sdk-operation-inventory.ts +8 -0
  89. package/scripts/run-test-manifest.ts +61 -21
  90. package/scripts/verify-gjc-sdk-canonicalization.ts +9 -4
  91. package/src/cli/args.ts +62 -9
  92. package/src/cli/completion-cli.ts +1 -0
  93. package/src/cli/fast-help.ts +1 -0
  94. package/src/cli/root-flags.ts +100 -0
  95. package/src/cli/setup-cli.ts +6 -2
  96. package/src/cli.ts +42 -65
  97. package/src/commands/acp.ts +1 -1
  98. package/src/commands/daemon.ts +2 -31
  99. package/src/commands/gc.ts +1 -1
  100. package/src/commands/launch.ts +8 -129
  101. package/src/commands/quick-lane.ts +51 -0
  102. package/src/commands/sdk.ts +200 -26
  103. package/src/commands/setup.ts +4 -2
  104. package/src/config/file-lock-gc.ts +4 -1
  105. package/src/config/model-equivalence.ts +76 -7
  106. package/src/config/model-profile-activation.ts +1115 -265
  107. package/src/config/model-profiles.ts +134 -2
  108. package/src/config/model-registry.ts +352 -50
  109. package/src/config/model-resolver.ts +289 -38
  110. package/src/config/models-config-schema.ts +11 -4
  111. package/src/config/provider-selection-policy.ts +146 -0
  112. package/src/config/settings-schema.ts +50 -77
  113. package/src/config/settings.ts +40 -0
  114. package/src/coordinator-mcp/server.ts +32 -2
  115. package/src/edit/renderer.ts +175 -223
  116. package/src/edit/streaming.ts +108 -1
  117. package/src/export/html/index.ts +57 -7
  118. package/src/extensibility/custom-tools/types.ts +2 -0
  119. package/src/extensibility/extensions/compact-handler.ts +3 -2
  120. package/src/extensibility/extensions/runner.ts +6 -0
  121. package/src/extensibility/extensions/types.ts +3 -0
  122. package/src/gjc-runtime/gc-runtime.ts +379 -20
  123. package/src/gjc-runtime/launch-worktree.ts +5 -1
  124. package/src/gjc-runtime/team-runtime.ts +59 -2
  125. package/src/gjc-runtime/tmux-owner-isolation.ts +19 -0
  126. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +16 -1
  127. package/src/internal-urls/docs-index.generated.ts +17 -13
  128. package/src/internal-urls/local-protocol.ts +1 -0
  129. package/src/main.ts +40 -3
  130. package/src/memories/index.ts +4 -2
  131. package/src/modes/acp/acp-agent.ts +235 -91
  132. package/src/modes/components/model-selector.ts +206 -42
  133. package/src/modes/components/provider-order-context.ts +197 -0
  134. package/src/modes/components/provider-order-editor.ts +441 -0
  135. package/src/modes/components/settings-defs.ts +21 -1
  136. package/src/modes/components/settings-selector.ts +45 -2
  137. package/src/modes/components/tool-execution.ts +47 -15
  138. package/src/modes/controllers/command-controller.ts +6 -3
  139. package/src/modes/controllers/extension-ui-controller.ts +2 -0
  140. package/src/modes/controllers/input-controller.ts +73 -2
  141. package/src/modes/controllers/selector-controller.ts +260 -153
  142. package/src/modes/prompt-suggestion-controller.ts +1 -1
  143. package/src/modes/runtime-init.ts +1 -0
  144. package/src/modes/utils/ui-helpers.ts +18 -0
  145. package/src/prompts/tools/browser.md +1 -0
  146. package/src/quick-lane/classify.ts +257 -0
  147. package/src/rlm/index.ts +1 -1
  148. package/src/runtime/stt-settings-setup.ts +57 -0
  149. package/src/sdk/acp/adapter.ts +2 -1
  150. package/src/sdk/broker/broker.ts +780 -12
  151. package/src/sdk/broker/lifecycle.ts +70 -29
  152. package/src/sdk/broker/operation-receipt-ledger.ts +144 -0
  153. package/src/sdk/broker/session-index.ts +463 -48
  154. package/src/sdk/broker/state-version.ts +13 -9
  155. package/src/sdk/broker/transport.ts +14 -1
  156. package/src/sdk/bus/index.ts +34 -13
  157. package/src/sdk/bus/kind-aware-reconciliation.ts +21 -2
  158. package/src/sdk/bus/notification-service.ts +47 -1
  159. package/src/sdk/bus/prompt-reconciliation.ts +22 -3
  160. package/src/sdk/bus/telegram-daemon-contract.ts +4 -1
  161. package/src/sdk/bus/telegram-daemon.ts +54 -3
  162. package/src/sdk/cli/rows.ts +247 -0
  163. package/src/sdk/cli/session-cli.ts +930 -79
  164. package/src/sdk/elevation/allowlist.ts +54 -0
  165. package/src/sdk/elevation/audit.ts +208 -0
  166. package/src/sdk/elevation/capability.ts +41 -0
  167. package/src/sdk/elevation/digest.ts +129 -0
  168. package/src/sdk/elevation/dispatch-receipt.ts +184 -0
  169. package/src/sdk/elevation/grant-ledger.ts +1142 -0
  170. package/src/sdk/elevation/owner.ts +109 -0
  171. package/src/sdk/guides/cache.ts +363 -0
  172. package/src/sdk/guides/catalog.ts +554 -0
  173. package/src/sdk/guides/cli.ts +235 -0
  174. package/src/sdk/guides/manifest.ts +152 -0
  175. package/src/sdk/guides/verify.ts +178 -0
  176. package/src/sdk/host/control/dispatch.ts +49 -1
  177. package/src/sdk/host/control/index.ts +1 -0
  178. package/src/sdk/host/control/operations.ts +8 -1
  179. package/src/sdk/host/query/cursor.ts +47 -3
  180. package/src/sdk/host/query/handlers.ts +155 -2
  181. package/src/sdk/host/sdk-surface-parity.test.ts +1 -0
  182. package/src/sdk/host/session-runtime.test.ts +391 -1
  183. package/src/sdk/host/session-runtime.ts +308 -73
  184. package/src/sdk/host/surface-policy.ts +3 -0
  185. package/src/sdk/host/websocket-transport.ts +7 -1
  186. package/src/sdk/mcp/server.ts +88 -15
  187. package/src/sdk/prompt-status.ts +8 -0
  188. package/src/sdk/protocol/operation-inventory.generated.json +62 -0
  189. package/src/sdk/protocol/operation-registry.ts +3 -2
  190. package/src/sdk/session.ts +87 -39
  191. package/src/sdk/transport/index.ts +1 -0
  192. package/src/sdk/transport/relay.ts +3 -0
  193. package/src/sdk/transport/serve-cli.ts +111 -24
  194. package/src/secrets/obfuscator.ts +92 -12
  195. package/src/session/agent-session.ts +322 -93
  196. package/src/session/blob-store.ts +60 -8
  197. package/src/session/fallback-chain-controller.ts +35 -0
  198. package/src/session/internal/managed-session-scope.ts +25 -3
  199. package/src/session/internal/managed-session-storage.ts +574 -73
  200. package/src/session/internal/session-memory-sidecar.ts +19 -29
  201. package/src/session/session-manager.ts +2166 -743
  202. package/src/session/session-storage.ts +715 -48
  203. package/src/setup/provider-onboarding.ts +19 -3
  204. package/src/setup/provider-presets.json +3 -1
  205. package/src/slash-commands/builtin-registry.ts +85 -3
  206. package/src/stt/downloader.ts +2 -3
  207. package/src/stt/setup.ts +29 -4
  208. package/src/task/executor.ts +22 -18
  209. package/src/task/index.ts +24 -1
  210. package/src/tools/archive-reader.ts +11 -2
  211. package/src/tools/browser/runtime-diagnostics.ts +249 -0
  212. package/src/tools/browser/tab-protocol.ts +4 -0
  213. package/src/tools/browser/tab-supervisor.ts +5 -0
  214. package/src/tools/browser/tab-worker.ts +36 -1
  215. package/src/tools/browser.ts +7 -0
  216. package/src/tools/image-gen.ts +110 -166
  217. package/src/tools/index.ts +2 -0
  218. package/src/tools/read.ts +1 -1
  219. package/src/tools/resource-gc.ts +6 -1
  220. package/src/tools/tool-catalog.generated.ts +5 -1
  221. package/src/utils/pasted-image-path.ts +93 -0
  222. package/src/web/search/index.ts +3 -3
@@ -83,6 +83,19 @@ function validateRangeReadBounds(start: number, length: number): void {
83
83
  if (length > SESSION_RANGE_READ_MAX_BYTES) throw new RangeError("Session range read exceeds the bounded maximum");
84
84
  }
85
85
 
86
+ function normalizeBufferedWriterCapacity(value: number | undefined): number {
87
+ const capacity = value ?? SESSION_STORAGE_BUFFERED_WRITER_DEFAULT_BYTES;
88
+ if (
89
+ !Number.isSafeInteger(capacity) ||
90
+ capacity < SESSION_STORAGE_BUFFERED_WRITER_MIN_BYTES ||
91
+ capacity > SESSION_STORAGE_BUFFERED_WRITER_MAX_BYTES
92
+ )
93
+ throw new RangeError(
94
+ `Buffered writer capacity must be between ${SESSION_STORAGE_BUFFERED_WRITER_MIN_BYTES} and ${SESSION_STORAGE_BUFFERED_WRITER_MAX_BYTES} bytes`,
95
+ );
96
+ return capacity;
97
+ }
98
+
86
99
  function statFromNode(stats: fs.BigIntStats): SessionStorageStat {
87
100
  return {
88
101
  dev: stats.dev,
@@ -135,6 +148,23 @@ export class SessionStorageWriterRetryableCloseError extends Error {
135
148
  export interface SessionStorageWriterCloseAdapter {
136
149
  close(fd: number): void;
137
150
  }
151
+ /** Bounded buffered-writer capacity limits. */
152
+ export const SESSION_STORAGE_BUFFERED_WRITER_MIN_BYTES = 64 * 1024;
153
+ export const SESSION_STORAGE_BUFFERED_WRITER_MAX_BYTES = 512 * 1024;
154
+ export const SESSION_STORAGE_BUFFERED_WRITER_DEFAULT_BYTES = 256 * 1024;
155
+
156
+ /**
157
+ * Counters for a synchronous buffered sidecar writer. `bytesWritten` and
158
+ * `writeCalls` count operations against the backend, not calls accepted into
159
+ * the in-process buffer.
160
+ */
161
+ export interface SessionStorageBufferedWriterInstrumentation {
162
+ readonly bytesSubmitted: number;
163
+ readonly bytesWritten: number;
164
+ readonly writeCalls: number;
165
+ readonly flushCalls: number;
166
+ readonly bufferedBytes: number;
167
+ }
138
168
 
139
169
  /** Options for opening a {@link SessionStorageWriter}. */
140
170
  export interface SessionStorageWriterOpenOptions {
@@ -144,6 +174,8 @@ export interface SessionStorageWriterOpenOptions {
144
174
  closeAdapter?: SessionStorageWriterCloseAdapter;
145
175
  /** Opaque authority for default-computed managed destinations only. */
146
176
  securityContext?: SessionStorageSecurityContext;
177
+ /** Enable bounded synchronous buffering for disposable sidecar writes. */
178
+ bufferSize?: number;
147
179
  }
148
180
 
149
181
  /**
@@ -215,6 +247,20 @@ export interface SessionStorageWriter {
215
247
  /** Stored error for non-success close states (`close_failed_retryable`/`close_unknown`). */
216
248
  getCloseError(): Error | undefined;
217
249
  }
250
+
251
+ /** Synchronous byte-oriented writer with bounded buffering for disposable sidecars. */
252
+ export interface SessionStorageBufferedWriter extends SessionStorageWriter {
253
+ /** Append already-serialized bytes; the caller's view may be reused on return. */
254
+ writeBytesSync(bytes: Uint8Array): void;
255
+ /** Flush pending bytes to the backend without synchronizing them to stable storage. */
256
+ flushSync(): void;
257
+ /** Flush pending bytes, then synchronize the backend. */
258
+ fsyncSync(): void;
259
+ /** Flush pending bytes, then close the backend descriptor. */
260
+ closeSync(): void;
261
+ /** Snapshot backend-write counters and current pending capacity. */
262
+ getInstrumentation(): SessionStorageBufferedWriterInstrumentation;
263
+ }
218
264
  // =============================================================================
219
265
  // Staged streaming writer contract (two-pass fork publication, immutable destinations)
220
266
  // =============================================================================
@@ -261,6 +307,10 @@ export interface StagedStreamingWriter {
261
307
  publishNoReplace(): void;
262
308
  }
263
309
 
310
+ export interface SessionStorageExclusiveLock {
311
+ releaseSync(): void;
312
+ }
313
+
264
314
  export interface SessionStorage {
265
315
  ensureDirSync(dir: string): void;
266
316
  existsSync(path: string): boolean;
@@ -303,12 +353,19 @@ export interface SessionStorage {
303
353
  */
304
354
  deleteSessionVerified?(target: VerifiedSessionDeleteTarget): Promise<VerifiedSessionDeleteResult>;
305
355
  openWriter(path: string, options?: SessionStorageWriterOpenOptions): SessionStorageWriter;
356
+ /** Open a bounded synchronous byte-oriented writer for disposable sidecars. */
357
+ openBufferedWriter?(path: string, options?: SessionStorageWriterOpenOptions): SessionStorageBufferedWriter;
306
358
  /** Bounded recorded-length read with descriptor identity validation (additive). */
307
359
  readRangeSync?(path: string, start: number, length: number): SessionStorageRangeSnapshot;
308
360
  /** Async bounded recorded-length read with descriptor identity validation (additive). */
309
361
  readRange?(path: string, start: number, length: number): Promise<SessionStorageRangeSnapshot>;
310
362
  /** Open a staged streaming writer for one immutable one-shot destination (additive). */
311
363
  openStagedWriter?(path: string, options?: SessionStorageWriterOpenOptions): StagedStreamingWriter;
364
+ /** Acquire an owner-bound exclusive lock; returns undefined while another owner holds it. */
365
+ acquireExclusiveLockSync?(
366
+ path: string,
367
+ options?: { securityContext?: SessionStorageSecurityContext },
368
+ ): SessionStorageExclusiveLock | undefined;
312
369
  }
313
370
 
314
371
  // =============================================================================
@@ -608,6 +665,29 @@ function secureOwnerOnlyFileDescriptor(
608
665
  ): void {
609
666
  if (securityContext && !isValidManagedSecurityContext(securityContext))
610
667
  throw new Error("Invalid managed session security context");
668
+ if (securityContext) {
669
+ const root = fs.lstatSync(securityContext.rootAuthority.canonicalPath, { bigint: true });
670
+ if (
671
+ !root.isDirectory() ||
672
+ root.isSymbolicLink() ||
673
+ root.dev !== securityContext.rootAuthority.dev ||
674
+ root.ino !== securityContext.rootAuthority.ino
675
+ )
676
+ throw new Error("Managed writer root authority changed");
677
+ if (securityContext.retainedAuthority) {
678
+ const relative = path.relative(securityContext.sessionDir, pathname).split(path.sep).join("/");
679
+ const retained = securityContext.retainedAuthority.stat(relative);
680
+ const opened = fs.fstatSync(fd, { bigint: true });
681
+ if (
682
+ !retained.ok ||
683
+ !retained.identity ||
684
+ retained.identity.dev !== opened.dev.toString() ||
685
+ retained.identity.ino !== opened.ino.toString() ||
686
+ retained.identity.nlink !== opened.nlink.toString()
687
+ )
688
+ throw new Error("Managed writer descriptor escaped retained authority");
689
+ }
690
+ }
611
691
  if (process.platform !== "linux" || !securityContext) {
612
692
  if (operation === "apply") {
613
693
  const applied = validateNativeSecurityResult(
@@ -670,8 +750,9 @@ function fsyncDirectorySync(pathname: string): void {
670
750
  function unlinkOwnedStagedSync(stagingPath: string, expected: { dev: bigint; ino: bigint }): void {
671
751
  try {
672
752
  const named = fs.lstatSync(stagingPath, { bigint: true });
673
- if (named.dev !== expected.dev || named.ino !== expected.ino) return;
674
- fs.unlinkSync(stagingPath);
753
+ if (!named.isFile() || named.isSymbolicLink() || named.dev !== expected.dev || named.ino !== expected.ino) return;
754
+ const sha256 = createHash("sha256").update(fs.readFileSync(stagingPath)).digest("hex");
755
+ exactRemoveSessionStorageLockPath(stagingPath, named, sha256, named.nlink > 1n);
675
756
  } catch {
676
757
  // ENOENT means the name was already consumed or removed; cleanup is best-effort.
677
758
  return;
@@ -786,7 +867,11 @@ function createFileCommitMarkerCheckedSync(
786
867
  const opened = fs.fstatSync(fd, { bigint: true });
787
868
  stagedIdentity = { dev: opened.dev, ino: opened.ino };
788
869
  let offset = 0;
789
- while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
870
+ while (offset < bytes.byteLength) {
871
+ const written = fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
872
+ if (written === 0) throw new Error("Short write");
873
+ offset += written;
874
+ }
790
875
  fs.fsyncSync(fd);
791
876
  secureOwnerOnlyFileDescriptor(tempPath, fd, "verify", securityContext);
792
877
  const staged = fs.fstatSync(fd, { bigint: true });
@@ -855,7 +940,11 @@ function replaceFileCommitMarkerCheckedSync(
855
940
  const opened = fs.fstatSync(fd, { bigint: true });
856
941
  stagedIdentity = { dev: opened.dev, ino: opened.ino };
857
942
  let offset = 0;
858
- while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
943
+ while (offset < bytes.byteLength) {
944
+ const written = fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
945
+ if (written === 0) throw new Error("Short write");
946
+ offset += written;
947
+ }
859
948
  fs.fsyncSync(fd);
860
949
  secureOwnerOnlyFileDescriptor(tempPath, fd, "verify", securityContext);
861
950
  staged = fs.fstatSync(fd, { bigint: true });
@@ -926,7 +1015,7 @@ const writerRegistry = new FinalizationRegistry<number>(fd => {
926
1015
  }
927
1016
  });
928
1017
 
929
- class FileSessionStorageWriter implements SessionStorageWriter {
1018
+ class FileSessionStorageWriter implements SessionStorageBufferedWriter {
930
1019
  #fd: number;
931
1020
  #path: string;
932
1021
 
@@ -937,10 +1026,21 @@ class FileSessionStorageWriter implements SessionStorageWriter {
937
1026
  #closeAdapter: SessionStorageWriterCloseAdapter;
938
1027
  #securityContext: SessionStorageSecurityContext;
939
1028
 
1029
+ #bufferSize: number | undefined;
1030
+ #buffer: Buffer | undefined;
1031
+ #bufferedBytes = 0;
1032
+ #bytesSubmitted = 0;
1033
+ #bytesWritten = 0;
1034
+ #writeCalls = 0;
1035
+ #flushCalls = 0;
1036
+
940
1037
  constructor(fpath: string, options?: SessionStorageWriterOpenOptions) {
941
1038
  this.#onError = options?.onError;
942
1039
  this.#closeAdapter = options?.closeAdapter ?? defaultCloseAdapter;
943
1040
  this.#securityContext = options?.securityContext;
1041
+ this.#bufferSize =
1042
+ options?.bufferSize === undefined ? undefined : normalizeBufferedWriterCapacity(options.bufferSize);
1043
+ this.#buffer = this.#bufferSize === undefined ? undefined : Buffer.allocUnsafe(this.#bufferSize);
944
1044
  const flags = options?.flags ?? "a";
945
1045
  const dir = path.dirname(fpath);
946
1046
  assertNoReparsePath(dir);
@@ -987,47 +1087,97 @@ class FileSessionStorageWriter implements SessionStorageWriter {
987
1087
  }
988
1088
  }
989
1089
 
990
- writeLineSync(line: string): void {
1090
+ #assertOpen(): void {
991
1091
  if (this.#closeState !== "open") throw this.#nonOpenWriteError();
992
1092
  if (this.#error) throw this.#error;
1093
+ }
1094
+
1095
+ #asBuffer(bytes: Uint8Array): Buffer {
1096
+ if (Buffer.isBuffer(bytes)) return bytes;
1097
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1098
+ }
1099
+
1100
+ #writeToKernel(bytes: Uint8Array): void {
1101
+ const buffer = this.#asBuffer(bytes);
1102
+ let offset = 0;
1103
+ while (offset < buffer.byteLength) {
1104
+ this.#writeCalls++;
1105
+ const written = fs.writeSync(this.#fd, buffer, offset, buffer.byteLength - offset);
1106
+ if (written === 0) throw new Error("Short write");
1107
+ offset += written;
1108
+ this.#bytesWritten += written;
1109
+ }
1110
+ }
1111
+
1112
+ #flushPending(): void {
1113
+ this.#flushCalls++;
1114
+ if (!this.#buffer || this.#bufferedBytes === 0) return;
1115
+ this.#writeToKernel(this.#buffer.subarray(0, this.#bufferedBytes));
1116
+ this.#bufferedBytes = 0;
1117
+ }
1118
+
1119
+ #appendBytes(bytes: Uint8Array): void {
1120
+ if (bytes.byteLength === 0) return;
1121
+ if (!this.#buffer || !this.#bufferSize) {
1122
+ this.#writeToKernel(bytes);
1123
+ return;
1124
+ }
1125
+ const source = this.#asBuffer(bytes);
1126
+ if (source.byteLength >= this.#bufferSize) {
1127
+ this.#flushPending();
1128
+ this.#writeToKernel(source);
1129
+ return;
1130
+ }
1131
+ if (this.#bufferedBytes + source.byteLength > this.#bufferSize) this.#flushPending();
1132
+ source.copy(this.#buffer, this.#bufferedBytes);
1133
+ this.#bufferedBytes += source.byteLength;
1134
+ if (this.#bufferedBytes === this.#bufferSize) this.#flushPending();
1135
+ }
1136
+
1137
+ writeBytesSync(bytes: Uint8Array): void {
1138
+ this.#assertOpen();
993
1139
  try {
994
- const buf = Buffer.from(line, "utf-8");
995
- let offset = 0;
996
- while (offset < buf.length) {
997
- const written = fs.writeSync(this.#fd, buf, offset, buf.length - offset);
998
- if (written === 0) {
999
- throw new Error("Short write");
1000
- }
1001
- offset += written;
1002
- }
1140
+ this.#appendBytes(bytes);
1141
+ this.#bytesSubmitted += bytes.byteLength;
1003
1142
  } catch (err) {
1004
1143
  throw this.#recordError(err);
1005
1144
  }
1006
1145
  }
1007
1146
 
1147
+ writeLineSync(line: string): void {
1148
+ this.writeBytesSync(Buffer.from(line, "utf-8"));
1149
+ }
1150
+
1008
1151
  async writeLine(line: string): Promise<void> {
1009
1152
  this.writeLineSync(line);
1010
1153
  }
1011
1154
 
1155
+ flushSync(): void {
1156
+ this.#assertOpen();
1157
+ try {
1158
+ this.#flushPending();
1159
+ } catch (err) {
1160
+ throw this.#recordError(err);
1161
+ }
1162
+ }
1163
+
1012
1164
  async flush(): Promise<void> {
1013
- if (this.#closeState !== "open") throw this.#nonOpenWriteError();
1014
- if (this.#error) throw this.#error;
1015
- // OS buffers are flushed on fsync, nothing to do here
1165
+ this.flushSync();
1016
1166
  }
1017
1167
 
1018
1168
  fsyncSync(): void {
1019
- if (this.#closeState !== "open") throw this.#nonOpenWriteError();
1020
- if (this.#error) throw this.#error;
1169
+ this.#assertOpen();
1021
1170
  try {
1171
+ this.#flushPending();
1022
1172
  fs.fsyncSync(this.#fd);
1023
1173
  secureOwnerOnlyFileDescriptor(this.#path, this.#fd, "verify", this.#securityContext);
1024
1174
  } catch (err) {
1025
1175
  throw this.#recordError(err);
1026
1176
  }
1027
1177
  }
1178
+
1028
1179
  statSync(): SessionStorageStat {
1029
- if (this.#closeState !== "open") throw this.#nonOpenWriteError();
1030
- if (this.#error) throw this.#error;
1180
+ this.#assertOpen();
1031
1181
  return statFromNode(fs.fstatSync(this.#fd, { bigint: true }));
1032
1182
  }
1033
1183
 
@@ -1041,6 +1191,15 @@ class FileSessionStorageWriter implements SessionStorageWriter {
1041
1191
  // Dispatched close already threw: outcome is uncertain. Never dispatch OS close
1042
1192
  // for this numeric fd again; surface the stored non-quiescent error.
1043
1193
  if (this.#closeState === "close_unknown") throw this.#closeError!;
1194
+
1195
+ let flushError: Error | undefined;
1196
+ if (!this.#error) {
1197
+ try {
1198
+ this.#flushPending();
1199
+ } catch (err) {
1200
+ flushError = this.#recordError(err);
1201
+ }
1202
+ }
1044
1203
  // State is "open" or "close_failed_retryable": a close may be dispatched.
1045
1204
  try {
1046
1205
  secureOwnerOnlyFileDescriptor(this.#path, this.#fd, "verify", this.#securityContext);
@@ -1072,7 +1231,9 @@ class FileSessionStorageWriter implements SessionStorageWriter {
1072
1231
  // Successful underlying close confirms closed.
1073
1232
  this.#closeState = "closed";
1074
1233
  this.#closeError = undefined;
1234
+ this.#bufferedBytes = 0;
1075
1235
  writerRegistry.unregister(this);
1236
+ if (flushError) throw flushError;
1076
1237
  }
1077
1238
 
1078
1239
  async close(): Promise<void> {
@@ -1092,6 +1253,16 @@ class FileSessionStorageWriter implements SessionStorageWriter {
1092
1253
  getCloseError(): Error | undefined {
1093
1254
  return this.#closeError;
1094
1255
  }
1256
+
1257
+ getInstrumentation(): SessionStorageBufferedWriterInstrumentation {
1258
+ return {
1259
+ bytesSubmitted: this.#bytesSubmitted,
1260
+ bytesWritten: this.#bytesWritten,
1261
+ writeCalls: this.#writeCalls,
1262
+ flushCalls: this.#flushCalls,
1263
+ bufferedBytes: this.#bufferedBytes,
1264
+ };
1265
+ }
1095
1266
  }
1096
1267
  /**
1097
1268
  * File-backend staged streaming writer: streams lines to a sibling staging file and
@@ -1149,13 +1320,14 @@ class FileStagedStreamingWriter implements StagedStreamingWriter {
1149
1320
  this.#assertOpen();
1150
1321
  if (bytes.byteLength > STAGED_WRITER_LINE_MAX_BYTES)
1151
1322
  throw new RangeError("Staged line exceeds the bounded maximum");
1152
- const line = Buffer.concat([Buffer.from(bytes), newlineBuffer]);
1153
1323
  try {
1154
- let written = 0;
1155
- while (written < line.byteLength) {
1156
- const count = fs.writeSync(this.#fd, line, written, line.byteLength - written);
1157
- if (count === 0) throw new Error("Short write");
1158
- written += count;
1324
+ for (const chunk of [bytes, newlineBuffer]) {
1325
+ let written = 0;
1326
+ while (written < chunk.byteLength) {
1327
+ const count = fs.writeSync(this.#fd, chunk, written, chunk.byteLength - written);
1328
+ if (count === 0) throw new Error("Short write");
1329
+ written += count;
1330
+ }
1159
1331
  }
1160
1332
  this.#lineCount++;
1161
1333
  } catch (err) {
@@ -1381,7 +1553,348 @@ class FileStagedStreamingWriter implements StagedStreamingWriter {
1381
1553
  }
1382
1554
  }
1383
1555
 
1556
+ type SessionStorageLockOwner = {
1557
+ pid: number;
1558
+ incarnation?: string;
1559
+ token: string;
1560
+ };
1561
+
1562
+ type SessionStorageLockOwnerProbe =
1563
+ | { kind: "absent" }
1564
+ | { kind: "live"; incarnation?: string }
1565
+ | { kind: "unverifiable" };
1566
+
1567
+ function probeSessionStorageLockOwner(pid: number): SessionStorageLockOwnerProbe {
1568
+ try {
1569
+ const owner = nativeSessionStorage().Process.fromPid(pid) as { incarnation?: unknown } | null;
1570
+ if (!owner) return { kind: "absent" };
1571
+ return {
1572
+ kind: "live",
1573
+ ...(typeof owner.incarnation === "string" && owner.incarnation.length > 0
1574
+ ? { incarnation: owner.incarnation }
1575
+ : {}),
1576
+ };
1577
+ } catch {
1578
+ return { kind: "unverifiable" };
1579
+ }
1580
+ }
1581
+
1582
+ function parseSessionStorageLockOwner(bytes: Buffer): SessionStorageLockOwner | undefined {
1583
+ const text = bytes.toString("utf8").trim();
1584
+ try {
1585
+ const value = JSON.parse(text) as Partial<SessionStorageLockOwner>;
1586
+ if (
1587
+ !Number.isSafeInteger(value.pid) ||
1588
+ (value.pid ?? 0) <= 0 ||
1589
+ typeof value.token !== "string" ||
1590
+ value.token.length === 0 ||
1591
+ (value.incarnation !== undefined && (typeof value.incarnation !== "string" || value.incarnation.length === 0))
1592
+ )
1593
+ return undefined;
1594
+ return value as SessionStorageLockOwner;
1595
+ } catch {
1596
+ const legacy = /^(\d+):(.+)$/.exec(text);
1597
+ if (!legacy) return undefined;
1598
+ const pid = Number.parseInt(legacy[1]!, 10);
1599
+ if (!Number.isSafeInteger(pid) || pid <= 0) return undefined;
1600
+ return { pid, token: legacy[2]! };
1601
+ }
1602
+ }
1603
+
1604
+ function exactRemoveSessionStorageLockPath(
1605
+ lockPath: string,
1606
+ expected: fs.BigIntStats,
1607
+ sha256: string,
1608
+ allowHardLink = false,
1609
+ ): boolean {
1610
+ try {
1611
+ const parent = fs.lstatSync(path.dirname(lockPath), { bigint: true });
1612
+ if (!parent.isDirectory() || parent.isSymbolicLink()) return false;
1613
+ const result = nativeSessionStorage().exactUnlink(lockPath, {
1614
+ dev: expected.dev,
1615
+ ino: expected.ino,
1616
+ nlink: expected.nlink,
1617
+ parentDev: parent.dev,
1618
+ parentIno: parent.ino,
1619
+ size: expected.size,
1620
+ mtimeNs: expected.mtimeNs,
1621
+ sha256,
1622
+ quarantineName: `${path.basename(lockPath)}.reap-${randomUUID()}`,
1623
+ allowHardLink,
1624
+ });
1625
+ return result.ok || (result.code === "cleanup_pending" && typeof result.detachedPath === "string");
1626
+ } catch {
1627
+ return false;
1628
+ }
1629
+ }
1630
+
1631
+ function exactRemoveOwnedSessionStorageLockPath(
1632
+ lockPath: string,
1633
+ expected: Pick<fs.BigIntStats, "dev" | "ino">,
1634
+ ): boolean {
1635
+ let fd: number | undefined;
1636
+ try {
1637
+ fd = fs.openSync(lockPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
1638
+ const current = fs.fstatSync(fd, { bigint: true });
1639
+ if (
1640
+ !current.isFile() ||
1641
+ current.dev !== expected.dev ||
1642
+ current.ino !== expected.ino ||
1643
+ current.nlink < 1n ||
1644
+ current.nlink > 2n ||
1645
+ current.size > 4096n
1646
+ )
1647
+ return false;
1648
+ const bytes = Buffer.allocUnsafe(Number(current.size));
1649
+ let offset = 0;
1650
+ while (offset < bytes.byteLength) {
1651
+ const read = fs.readSync(fd, bytes, offset, bytes.byteLength - offset, offset);
1652
+ if (read === 0) return false;
1653
+ offset += read;
1654
+ }
1655
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
1656
+ fs.closeSync(fd);
1657
+ fd = undefined;
1658
+ return exactRemoveSessionStorageLockPath(lockPath, current, sha256);
1659
+ } catch {
1660
+ return false;
1661
+ } finally {
1662
+ if (fd !== undefined) fs.closeSync(fd);
1663
+ }
1664
+ }
1665
+
1666
+ function reclaimStaleSessionStorageLockSync(lockPath: string): boolean {
1667
+ let fd: number | undefined;
1668
+ let expected: fs.BigIntStats | undefined;
1669
+ let owner: SessionStorageLockOwner | undefined;
1670
+ let sha256: string | undefined;
1671
+ try {
1672
+ fd = fs.openSync(lockPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
1673
+ expected = fs.fstatSync(fd, { bigint: true });
1674
+ if (
1675
+ !expected.isFile() ||
1676
+ expected.nlink < 1n ||
1677
+ expected.nlink > 2n ||
1678
+ expected.size <= 0n ||
1679
+ expected.size > 4096n
1680
+ )
1681
+ return false;
1682
+ const bytes = Buffer.allocUnsafe(Number(expected.size));
1683
+ let offset = 0;
1684
+ while (offset < bytes.byteLength) {
1685
+ const read = fs.readSync(fd, bytes, offset, bytes.byteLength - offset, offset);
1686
+ if (read === 0) return false;
1687
+ offset += read;
1688
+ }
1689
+ owner = parseSessionStorageLockOwner(bytes);
1690
+ sha256 = createHash("sha256").update(bytes).digest("hex");
1691
+ } catch (error) {
1692
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return true;
1693
+ return false;
1694
+ } finally {
1695
+ if (fd !== undefined) fs.closeSync(fd);
1696
+ }
1697
+ if (!owner || !expected || !sha256) return false;
1698
+ const probe = probeSessionStorageLockOwner(owner.pid);
1699
+ const stale =
1700
+ probe.kind === "absent" ||
1701
+ (probe.kind === "live" &&
1702
+ owner.incarnation !== undefined &&
1703
+ probe.incarnation !== undefined &&
1704
+ probe.incarnation !== owner.incarnation);
1705
+ if (!stale) return false;
1706
+ let lockExpected = expected;
1707
+ const stagedPath = expected.nlink === 2n ? `${lockPath}.${owner.token}.owner.tmp` : undefined;
1708
+ if (stagedPath) {
1709
+ try {
1710
+ const staged = fs.lstatSync(stagedPath, { bigint: true });
1711
+ if (staged.dev !== expected.dev || staged.ino !== expected.ino || staged.isSymbolicLink()) return false;
1712
+ unlinkOwnedStagedSync(stagedPath, expected);
1713
+ if (fs.existsSync(stagedPath)) return false;
1714
+ lockExpected = fs.lstatSync(lockPath, { bigint: true });
1715
+ if (
1716
+ lockExpected.dev !== expected.dev ||
1717
+ lockExpected.ino !== expected.ino ||
1718
+ lockExpected.nlink !== 1n ||
1719
+ lockExpected.size !== expected.size ||
1720
+ lockExpected.mtimeNs !== expected.mtimeNs ||
1721
+ lockExpected.isSymbolicLink()
1722
+ )
1723
+ return false;
1724
+ } catch {
1725
+ return false;
1726
+ }
1727
+ }
1728
+ return exactRemoveSessionStorageLockPath(lockPath, lockExpected, sha256);
1729
+ }
1730
+
1384
1731
  export class FileSessionStorage implements SessionStorage {
1732
+ acquireExclusiveLockSync(
1733
+ lockPath: string,
1734
+ options?: { securityContext?: SessionStorageSecurityContext },
1735
+ ): SessionStorageExclusiveLock | undefined {
1736
+ const dir = path.dirname(lockPath);
1737
+ this.ensureDirSync(dir);
1738
+ for (let attempt = 0; attempt < 2; attempt++) {
1739
+ const token = randomUUID();
1740
+ const stagedPath = `${lockPath}.${token}.owner.tmp`;
1741
+ let fd: number | undefined;
1742
+ let stagedIdentity: fs.BigIntStats | undefined;
1743
+ let ownerDigest: string | undefined;
1744
+ let published = false;
1745
+ try {
1746
+ fd = fs.openSync(
1747
+ stagedPath,
1748
+ fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW ?? 0),
1749
+ 0o600,
1750
+ );
1751
+ secureOwnerOnlyFileDescriptor(stagedPath, fd, "apply", options?.securityContext);
1752
+ stagedIdentity = fs.fstatSync(fd, { bigint: true });
1753
+ const probe = probeSessionStorageLockOwner(process.pid);
1754
+ const owner: SessionStorageLockOwner = {
1755
+ pid: process.pid,
1756
+ ...(probe.kind === "live" && probe.incarnation ? { incarnation: probe.incarnation } : {}),
1757
+ token,
1758
+ };
1759
+ const bytes = Buffer.from(`${JSON.stringify(owner)}\n`, "utf8");
1760
+ ownerDigest = createHash("sha256").update(bytes).digest("hex");
1761
+ let offset = 0;
1762
+ while (offset < bytes.byteLength) {
1763
+ const written = fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
1764
+ if (written === 0) throw new Error("Short write");
1765
+ offset += written;
1766
+ }
1767
+ fs.fsyncSync(fd);
1768
+ secureOwnerOnlyFileDescriptor(stagedPath, fd, "verify", options?.securityContext);
1769
+ stagedIdentity = fs.fstatSync(fd, { bigint: true });
1770
+ let outcome = classifyNativePublishOutcome(
1771
+ nativeSessionStorage().renameNoReplacePath(stagedPath, lockPath),
1772
+ );
1773
+ let linkPublished = false;
1774
+ if (renameFlagsUnsupported(outcome)) {
1775
+ outcome = classifyNativePublishOutcome(nativeSessionStorage().linkNoReplacePath(stagedPath, lockPath));
1776
+ linkPublished = outcome.ok;
1777
+ }
1778
+ if (!outcome.ok) {
1779
+ fs.closeSync(fd);
1780
+ fd = undefined;
1781
+ exactRemoveSessionStorageLockPath(stagedPath, stagedIdentity, ownerDigest);
1782
+ if (outcome.reason === "destination_exists") {
1783
+ if (attempt === 0 && reclaimStaleSessionStorageLockSync(lockPath)) continue;
1784
+ return undefined;
1785
+ }
1786
+ throw new Error(`exclusive_lock_publish_rejected:${outcome.reason}`);
1787
+ }
1788
+ published = true;
1789
+ fs.closeSync(fd);
1790
+ fd = undefined;
1791
+ if (linkPublished) {
1792
+ const stagedNamed = fs.lstatSync(stagedPath, { bigint: true });
1793
+ if (
1794
+ stagedNamed.dev !== stagedIdentity.dev ||
1795
+ stagedNamed.ino !== stagedIdentity.ino ||
1796
+ stagedNamed.isSymbolicLink()
1797
+ )
1798
+ throw new Error("exclusive_lock_staging_identity_changed");
1799
+ unlinkOwnedStagedSync(stagedPath, stagedIdentity);
1800
+ if (fs.existsSync(stagedPath)) throw new Error("exclusive_lock_staging_cleanup_failed");
1801
+ }
1802
+ const publishedFd = fs.openSync(lockPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
1803
+ let expected: fs.BigIntStats;
1804
+ try {
1805
+ secureOwnerOnlyFileDescriptor(lockPath, publishedFd, "verify", options?.securityContext);
1806
+ expected = fs.fstatSync(publishedFd, { bigint: true });
1807
+ if (
1808
+ !expected.isFile() ||
1809
+ expected.isSymbolicLink() ||
1810
+ expected.dev !== stagedIdentity.dev ||
1811
+ expected.ino !== stagedIdentity.ino ||
1812
+ expected.nlink !== 1n ||
1813
+ expected.size !== BigInt(bytes.byteLength)
1814
+ )
1815
+ throw new Error("exclusive_lock_identity_changed");
1816
+ const installed = Buffer.allocUnsafe(bytes.byteLength);
1817
+ let readOffset = 0;
1818
+ while (readOffset < installed.byteLength) {
1819
+ const read = fs.readSync(
1820
+ publishedFd,
1821
+ installed,
1822
+ readOffset,
1823
+ installed.byteLength - readOffset,
1824
+ readOffset,
1825
+ );
1826
+ if (read === 0) throw new Error("exclusive_lock_short_read");
1827
+ readOffset += read;
1828
+ }
1829
+ if (createHash("sha256").update(installed).digest("hex") !== ownerDigest)
1830
+ throw new Error("exclusive_lock_content_changed");
1831
+ } finally {
1832
+ fs.closeSync(publishedFd);
1833
+ }
1834
+ fsyncDirectorySync(dir);
1835
+ let released = false;
1836
+ return {
1837
+ releaseSync: () => {
1838
+ if (released) return;
1839
+ if (!exactRemoveSessionStorageLockPath(lockPath, expected, ownerDigest!)) {
1840
+ try {
1841
+ const current = fs.lstatSync(lockPath, { bigint: true });
1842
+ if (current.dev === expected.dev && current.ino === expected.ino) {
1843
+ // The inode matches, but exactRemove refused — either the
1844
+ // content changed (replacement reused the inode) or cleanup
1845
+ // genuinely failed on the original. Re-verify the content
1846
+ // digest to distinguish: a replacement whose sha256 no longer
1847
+ // matches is not ours and must not be unlinked.
1848
+ let fd: number | undefined;
1849
+ try {
1850
+ fd = fs.openSync(lockPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
1851
+ const bytes = Buffer.allocUnsafe(Number(current.size));
1852
+ let offset = 0;
1853
+ while (offset < bytes.byteLength) {
1854
+ const read = fs.readSync(fd, bytes, offset, bytes.byteLength - offset, offset);
1855
+ if (read === 0) break;
1856
+ offset += read;
1857
+ }
1858
+ const currentDigest = createHash("sha256")
1859
+ .update(bytes.subarray(0, offset))
1860
+ .digest("hex");
1861
+ if (currentDigest === ownerDigest!) throw new Error("exclusive_lock_release_failed");
1862
+ } finally {
1863
+ if (fd !== undefined) fs.closeSync(fd);
1864
+ }
1865
+ }
1866
+ } catch (error) {
1867
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
1868
+ }
1869
+ }
1870
+ released = true;
1871
+ },
1872
+ };
1873
+ } catch (error) {
1874
+ if (fd !== undefined) fs.closeSync(fd);
1875
+ if (published && stagedIdentity && ownerDigest) {
1876
+ try {
1877
+ const named = fs.lstatSync(lockPath, { bigint: true });
1878
+ if (named.dev === stagedIdentity.dev && named.ino === stagedIdentity.ino)
1879
+ exactRemoveSessionStorageLockPath(lockPath, named, ownerDigest);
1880
+ } catch {
1881
+ // The published name is absent or belongs to another owner.
1882
+ }
1883
+ }
1884
+ if (stagedIdentity && ownerDigest) {
1885
+ try {
1886
+ const named = fs.lstatSync(stagedPath, { bigint: true });
1887
+ if (named.dev === stagedIdentity.dev && named.ino === stagedIdentity.ino)
1888
+ exactRemoveOwnedSessionStorageLockPath(stagedPath, stagedIdentity);
1889
+ } catch {
1890
+ // The staged name was consumed or removed.
1891
+ }
1892
+ }
1893
+ throw error;
1894
+ }
1895
+ }
1896
+ return undefined;
1897
+ }
1385
1898
  ensureDirSync(dir: string): void {
1386
1899
  if (!fs.existsSync(dir)) {
1387
1900
  fs.mkdirSync(dir, { recursive: true });
@@ -1624,6 +2137,12 @@ export class FileSessionStorage implements SessionStorage {
1624
2137
  openWriter(path: string, options?: SessionStorageWriterOpenOptions): SessionStorageWriter {
1625
2138
  return new FileSessionStorageWriter(path, options);
1626
2139
  }
2140
+ openBufferedWriter(path: string, options?: SessionStorageWriterOpenOptions): SessionStorageBufferedWriter {
2141
+ return new FileSessionStorageWriter(path, {
2142
+ ...options,
2143
+ bufferSize: options?.bufferSize ?? SESSION_STORAGE_BUFFERED_WRITER_DEFAULT_BYTES,
2144
+ }) as SessionStorageBufferedWriter;
2145
+ }
1627
2146
 
1628
2147
  openStagedWriter(path: string, options?: SessionStorageWriterOpenOptions): StagedStreamingWriter {
1629
2148
  return new FileStagedStreamingWriter(path, options);
@@ -2435,7 +2954,7 @@ class MemoryStagedStreamingWriter implements StagedStreamingWriter {
2435
2954
  }
2436
2955
  }
2437
2956
 
2438
- class MemorySessionStorageWriter implements SessionStorageWriter {
2957
+ class MemorySessionStorageWriter implements SessionStorageBufferedWriter {
2439
2958
  #storage: MemorySessionStorage;
2440
2959
  #path: string;
2441
2960
  #closeState: SessionStorageWriterCloseState = "open";
@@ -2446,12 +2965,22 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
2446
2965
  #closeAdapter: SessionStorageWriterCloseAdapter | undefined;
2447
2966
  #bytes: Buffer;
2448
2967
  #length = 0;
2968
+ #bufferSize: number | undefined;
2969
+ #buffer: Buffer | undefined;
2970
+ #bufferedBytes = 0;
2971
+ #bytesSubmitted = 0;
2972
+ #bytesWritten = 0;
2973
+ #writeCalls = 0;
2974
+ #flushCalls = 0;
2449
2975
 
2450
2976
  constructor(storage: MemorySessionStorage, path: string, options?: SessionStorageWriterOpenOptions) {
2451
2977
  this.#storage = storage;
2452
2978
  this.#path = path;
2453
2979
  this.#onError = options?.onError;
2454
2980
  this.#closeAdapter = options?.closeAdapter;
2981
+ this.#bufferSize =
2982
+ options?.bufferSize === undefined ? undefined : normalizeBufferedWriterCapacity(options.bufferSize);
2983
+ this.#buffer = this.#bufferSize === undefined ? undefined : Buffer.allocUnsafe(this.#bufferSize);
2455
2984
  const existing =
2456
2985
  options?.flags === "w" || !storage.existsSync(path)
2457
2986
  ? Buffer.alloc(0)
@@ -2469,40 +2998,127 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
2469
2998
  return error;
2470
2999
  }
2471
3000
 
2472
- writeLineSync(line: string): void {
2473
- if (this.#closeState !== "open") throw new Error("Writer closed");
3001
+ #nonOpenWriteError(): Error {
3002
+ switch (this.#closeState) {
3003
+ case "closed":
3004
+ return new Error("Writer closed");
3005
+ case "close_unknown":
3006
+ return this.#closeError ?? new Error("Writer close outcome is unknown; descriptor quarantined");
3007
+ case "close_failed_retryable":
3008
+ return this.#closeError ?? new Error("Writer close failed before dispatch (retryable); writes rejected");
3009
+ default:
3010
+ return new Error("Writer closed");
3011
+ }
3012
+ }
3013
+
3014
+ #assertOpen(): void {
3015
+ if (this.#closeState !== "open") throw this.#nonOpenWriteError();
2474
3016
  if (this.#error) throw this.#error;
3017
+ }
3018
+
3019
+ #asBuffer(bytes: Uint8Array): Buffer {
3020
+ if (Buffer.isBuffer(bytes)) return bytes;
3021
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
3022
+ }
3023
+
3024
+ #appendToBytes(bytes: Uint8Array): void {
3025
+ const source = this.#asBuffer(bytes);
3026
+ const nextLength = this.#length + source.byteLength;
3027
+ if (nextLength > this.#bytes.byteLength) {
3028
+ const expanded = Buffer.allocUnsafe(Math.max(nextLength, this.#bytes.byteLength * 2));
3029
+ this.#bytes.copy(expanded, 0, 0, this.#length);
3030
+ this.#bytes = expanded;
3031
+ }
3032
+ source.copy(this.#bytes, this.#length);
3033
+ this.#length = nextLength;
3034
+ }
3035
+
3036
+ #writeCurrent(bytesWritten: number): void {
3037
+ this.#writeCalls++;
3038
+ this.#storage.writeBytesOwnedSync(this.#path, Buffer.from(this.#bytes.subarray(0, this.#length)));
3039
+ this.#bytesWritten += bytesWritten;
3040
+ }
3041
+
3042
+ #flushPending(): void {
3043
+ this.#flushCalls++;
3044
+ if (!this.#buffer || this.#bufferedBytes === 0) return;
3045
+ const pendingBytes = this.#bufferedBytes;
3046
+ const nextLength = this.#length + pendingBytes;
3047
+ if (nextLength > this.#bytes.byteLength) {
3048
+ const expanded = Buffer.allocUnsafe(Math.max(nextLength, this.#bytes.byteLength * 2));
3049
+ this.#bytes.copy(expanded, 0, 0, this.#length);
3050
+ this.#bytes = expanded;
3051
+ }
3052
+ this.#buffer.copy(this.#bytes, this.#length, 0, pendingBytes);
3053
+ this.#writeCalls++;
3054
+ this.#storage.writeBytesOwnedSync(this.#path, Buffer.from(this.#bytes.subarray(0, nextLength)));
3055
+ this.#length = nextLength;
3056
+ this.#bufferedBytes = 0;
3057
+ this.#bytesWritten += pendingBytes;
3058
+ }
3059
+
3060
+ #appendBytes(bytes: Uint8Array): void {
3061
+ if (bytes.byteLength === 0) return;
3062
+ if (!this.#buffer || !this.#bufferSize) {
3063
+ this.#appendToBytes(bytes);
3064
+ this.#writeCurrent(bytes.byteLength);
3065
+ return;
3066
+ }
3067
+ const source = this.#asBuffer(bytes);
3068
+ if (source.byteLength >= this.#bufferSize) {
3069
+ this.#flushPending();
3070
+ this.#appendToBytes(source);
3071
+ this.#writeCurrent(source.byteLength);
3072
+ return;
3073
+ }
3074
+ if (this.#bufferedBytes + source.byteLength > this.#bufferSize) this.#flushPending();
3075
+ source.copy(this.#buffer, this.#bufferedBytes);
3076
+ this.#bufferedBytes += source.byteLength;
3077
+ if (this.#bufferedBytes === this.#bufferSize) this.#flushPending();
3078
+ }
3079
+
3080
+ writeBytesSync(bytes: Uint8Array): void {
3081
+ this.#assertOpen();
2475
3082
  try {
2476
- const bytes = Buffer.from(line, "utf8");
2477
- const nextLength = this.#length + bytes.byteLength;
2478
- if (nextLength > this.#bytes.byteLength) {
2479
- const expanded = Buffer.allocUnsafe(Math.max(nextLength, this.#bytes.byteLength * 2));
2480
- this.#bytes.copy(expanded, 0, 0, this.#length);
2481
- this.#bytes = expanded;
2482
- }
2483
- bytes.copy(this.#bytes, this.#length);
2484
- this.#length = nextLength;
2485
- this.#storage.writeBytesOwnedSync(this.#path, this.#bytes.subarray(0, this.#length));
3083
+ this.#appendBytes(bytes);
3084
+ this.#bytesSubmitted += bytes.byteLength;
2486
3085
  } catch (err) {
2487
3086
  throw this.#recordError(err);
2488
3087
  }
2489
3088
  }
2490
3089
 
3090
+ writeLineSync(line: string): void {
3091
+ this.writeBytesSync(Buffer.from(line, "utf8"));
3092
+ }
3093
+
2491
3094
  async writeLine(line: string): Promise<void> {
2492
3095
  this.writeLineSync(line);
2493
3096
  }
2494
3097
 
3098
+ flushSync(): void {
3099
+ this.#assertOpen();
3100
+ try {
3101
+ this.#flushPending();
3102
+ } catch (err) {
3103
+ throw this.#recordError(err);
3104
+ }
3105
+ }
3106
+
2495
3107
  async flush(): Promise<void> {
2496
- if (this.#closeState !== "open") throw new Error("Writer closed");
2497
- if (this.#error) throw this.#error;
3108
+ this.flushSync();
2498
3109
  }
3110
+
2499
3111
  fsyncSync(): void {
2500
- if (this.#closeState !== "open") throw new Error("Writer closed");
2501
- if (this.#error) throw this.#error;
3112
+ this.#assertOpen();
3113
+ try {
3114
+ this.#flushPending();
3115
+ } catch (err) {
3116
+ throw this.#recordError(err);
3117
+ }
2502
3118
  }
3119
+
2503
3120
  statSync(): SessionStorageStat {
2504
- if (this.#closeState !== "open") throw new Error("Writer closed");
2505
- if (this.#error) throw this.#error;
3121
+ this.#assertOpen();
2506
3122
  return this.#storage.statSync(this.#path);
2507
3123
  }
2508
3124
 
@@ -2517,6 +3133,15 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
2517
3133
  // succeeds. The sentinel fd (-1) signals "no real descriptor".
2518
3134
  if (this.#closeState === "closed") return;
2519
3135
  if (this.#closeState === "close_unknown") throw this.#closeError!;
3136
+
3137
+ let flushError: Error | undefined;
3138
+ if (!this.#error) {
3139
+ try {
3140
+ this.#flushPending();
3141
+ } catch (err) {
3142
+ flushError = this.#recordError(err);
3143
+ }
3144
+ }
2520
3145
  if (this.#closeAdapter) {
2521
3146
  try {
2522
3147
  this.#closeAdapter.close(-1);
@@ -2533,6 +3158,8 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
2533
3158
  }
2534
3159
  this.#closeState = "closed";
2535
3160
  this.#closeError = undefined;
3161
+ this.#bufferedBytes = 0;
3162
+ if (flushError) throw flushError;
2536
3163
  }
2537
3164
 
2538
3165
  async close(): Promise<void> {
@@ -2550,11 +3177,41 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
2550
3177
  getCloseError(): Error | undefined {
2551
3178
  return this.#closeError;
2552
3179
  }
3180
+
3181
+ getInstrumentation(): SessionStorageBufferedWriterInstrumentation {
3182
+ return {
3183
+ bytesSubmitted: this.#bytesSubmitted,
3184
+ bytesWritten: this.#bytesWritten,
3185
+ writeCalls: this.#writeCalls,
3186
+ flushCalls: this.#flushCalls,
3187
+ bufferedBytes: this.#bufferedBytes,
3188
+ };
3189
+ }
2553
3190
  }
2554
3191
 
2555
3192
  export class MemorySessionStorage implements SessionStorage {
2556
3193
  #files = new Map<string, { content: Buffer; mtimeMs: number; ino: bigint }>();
2557
3194
  #nextInode = 1n;
3195
+ acquireExclusiveLockSync(
3196
+ lockPath: string,
3197
+ _options?: { securityContext?: SessionStorageSecurityContext },
3198
+ ): SessionStorageExclusiveLock | undefined {
3199
+ if (this.#files.has(lockPath)) return undefined;
3200
+ const ino = this.#nextInode++;
3201
+ this.#files.set(lockPath, {
3202
+ content: Buffer.from(`${process.pid}:${randomUUID()}\n`, "utf8"),
3203
+ mtimeMs: Date.now(),
3204
+ ino,
3205
+ });
3206
+ let released = false;
3207
+ return {
3208
+ releaseSync: () => {
3209
+ if (released) return;
3210
+ released = true;
3211
+ if (this.#files.get(lockPath)?.ino === ino) this.#files.delete(lockPath);
3212
+ },
3213
+ };
3214
+ }
2558
3215
 
2559
3216
  #statFor(entry: { content: Buffer; mtimeMs: number; ino: bigint }): SessionStorageStat {
2560
3217
  return {
@@ -2719,8 +3376,12 @@ export class MemorySessionStorage implements SessionStorage {
2719
3376
 
2720
3377
  deleteSessionWithArtifacts(sessionPath: string): Promise<void> {
2721
3378
  this.#files.delete(sessionPath);
3379
+ const artifactPrefix = sessionPath.endsWith(".jsonl") ? `${sessionPath.slice(0, -6)}/` : `${sessionPath}/`;
2722
3380
  for (const candidate of [...this.#files.keys()]) {
2723
- if (candidate.startsWith(`${sessionPath}.spill.`) && isDerivedSessionMemoryFile(candidate))
3381
+ if (
3382
+ (candidate.startsWith(`${sessionPath}.spill.`) || candidate.startsWith(artifactPrefix)) &&
3383
+ isDerivedSessionMemoryFile(candidate)
3384
+ )
2724
3385
  this.#files.delete(candidate);
2725
3386
  }
2726
3387
  return Promise.resolve();
@@ -2792,6 +3453,12 @@ export class MemorySessionStorage implements SessionStorage {
2792
3453
  openWriter(path: string, options?: SessionStorageWriterOpenOptions): SessionStorageWriter {
2793
3454
  return new MemorySessionStorageWriter(this, path, options);
2794
3455
  }
3456
+ openBufferedWriter(path: string, options?: SessionStorageWriterOpenOptions): SessionStorageBufferedWriter {
3457
+ return new MemorySessionStorageWriter(this, path, {
3458
+ ...options,
3459
+ bufferSize: options?.bufferSize ?? SESSION_STORAGE_BUFFERED_WRITER_DEFAULT_BYTES,
3460
+ }) as SessionStorageBufferedWriter;
3461
+ }
2795
3462
 
2796
3463
  openStagedWriter(path: string): StagedStreamingWriter {
2797
3464
  return new MemoryStagedStreamingWriter(this, path);