@gajae-code/coding-agent 0.12.19 → 0.12.21

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 (100) hide show
  1. package/CHANGELOG.md +27 -9
  2. package/dist/types/commands/gc.d.ts +4 -0
  3. package/dist/types/commands/sdk.d.ts +66 -0
  4. package/dist/types/config/settings-schema.d.ts +34 -0
  5. package/dist/types/gjc-runtime/gc-runtime.d.ts +97 -1
  6. package/dist/types/modes/acp/acp-agent.d.ts +33 -1
  7. package/dist/types/modes/components/bash-execution.d.ts +7 -0
  8. package/dist/types/modes/components/eval-execution.d.ts +7 -0
  9. package/dist/types/modes/utils/ui-helpers.d.ts +10 -1
  10. package/dist/types/sdk/acp/adapter.d.ts +9 -17
  11. package/dist/types/sdk/acp/index.d.ts +1 -1
  12. package/dist/types/sdk/broker/broker.d.ts +93 -1
  13. package/dist/types/sdk/broker/ensure.d.ts +17 -0
  14. package/dist/types/sdk/broker/index.d.ts +1 -0
  15. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +10 -1
  16. package/dist/types/sdk/broker/lifecycle.d.ts +81 -2
  17. package/dist/types/sdk/broker/startup-budget.d.ts +44 -0
  18. package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
  19. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +11 -1
  20. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -0
  21. package/dist/types/sdk/bus/index.d.ts +20 -9
  22. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -6
  23. package/dist/types/sdk/bus/slack-daemon.d.ts +2 -2
  24. package/dist/types/sdk/host/query/revision-store.d.ts +5 -0
  25. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  26. package/dist/types/sdk/lifecycle-session.d.ts +13 -2
  27. package/dist/types/sdk/prompt-failure.d.ts +13 -0
  28. package/dist/types/sdk/prompt-watchdog.d.ts +82 -0
  29. package/dist/types/sdk/session-reconnect.d.ts +20 -0
  30. package/dist/types/sdk/startup-capability.d.ts +1 -1
  31. package/dist/types/session/agent-session.d.ts +14 -4
  32. package/dist/types/session/blob-store.d.ts +32 -0
  33. package/dist/types/session/internal/managed-session-storage.d.ts +40 -2
  34. package/dist/types/session/internal/session-memory-sidecar.d.ts +881 -0
  35. package/dist/types/session/session-manager.d.ts +302 -27
  36. package/dist/types/session/session-storage.d.ts +170 -0
  37. package/dist/types/task/executor.d.ts +2 -1
  38. package/dist/types/tools/bash.d.ts +1 -0
  39. package/dist/types/tools/todo-contract.d.ts +10 -1
  40. package/dist/types/tui/output-block.d.ts +6 -0
  41. package/package.json +8 -8
  42. package/src/cli/args.ts +75 -34
  43. package/src/commands/acp.ts +7 -1
  44. package/src/commands/gc.ts +31 -2
  45. package/src/commands/sdk.ts +141 -7
  46. package/src/config/settings-schema.ts +43 -0
  47. package/src/coordinator-mcp/server.ts +6 -1
  48. package/src/export/html/index.ts +83 -39
  49. package/src/gjc-runtime/gc-runtime.ts +1283 -4
  50. package/src/internal-urls/docs-index.generated.ts +1 -1
  51. package/src/internal-urls/local-protocol.ts +14 -0
  52. package/src/main.ts +40 -9
  53. package/src/modes/acp/acp-agent.ts +564 -37
  54. package/src/modes/acp/terminal-auth.ts +2 -2
  55. package/src/modes/components/bash-execution.ts +14 -0
  56. package/src/modes/components/eval-execution.ts +14 -0
  57. package/src/modes/controllers/command-controller.ts +63 -8
  58. package/src/modes/utils/ui-helpers.ts +81 -11
  59. package/src/sdk/acp/adapter.ts +46 -45
  60. package/src/sdk/acp/index.ts +1 -1
  61. package/src/sdk/broker/broker.ts +392 -11
  62. package/src/sdk/broker/ensure.ts +131 -61
  63. package/src/sdk/broker/index.ts +1 -0
  64. package/src/sdk/broker/lifecycle-ledger.ts +59 -5
  65. package/src/sdk/broker/lifecycle.ts +278 -63
  66. package/src/sdk/broker/startup-budget.ts +103 -0
  67. package/src/sdk/bus/chat-daemon-control.ts +9 -3
  68. package/src/sdk/bus/chat-daemon-runtime.ts +565 -29
  69. package/src/sdk/bus/conversation-store.ts +1 -1
  70. package/src/sdk/bus/discord-daemon.ts +2 -1
  71. package/src/sdk/bus/discord-live-provider.ts +34 -6
  72. package/src/sdk/bus/index.ts +118 -20
  73. package/src/sdk/bus/kind-aware-reconciliation.ts +2 -1
  74. package/src/sdk/bus/prompt-reconciliation.ts +4 -9
  75. package/src/sdk/bus/slack-daemon.ts +91 -18
  76. package/src/sdk/cli/session-cli.ts +8 -1
  77. package/src/sdk/host/query/handlers.ts +1 -1
  78. package/src/sdk/host/query/revision-store.ts +48 -12
  79. package/src/sdk/host/session-runtime.test.ts +50 -0
  80. package/src/sdk/host/session-runtime.ts +12 -2
  81. package/src/sdk/lifecycle-session.ts +26 -3
  82. package/src/sdk/mcp/server.ts +6 -1
  83. package/src/sdk/prompt-failure.ts +42 -0
  84. package/src/sdk/prompt-watchdog.ts +162 -0
  85. package/src/sdk/session-reconnect.ts +43 -0
  86. package/src/sdk/startup-capability.ts +13 -4
  87. package/src/session/agent-session.ts +541 -226
  88. package/src/session/blob-store.ts +89 -0
  89. package/src/session/internal/managed-session-scope.ts +36 -26
  90. package/src/session/internal/managed-session-storage.ts +303 -32
  91. package/src/session/internal/session-memory-sidecar.ts +2160 -0
  92. package/src/session/session-manager.ts +7057 -399
  93. package/src/session/session-storage.ts +1134 -11
  94. package/src/task/executor.ts +17 -4
  95. package/src/tools/bash.ts +109 -7
  96. package/src/tools/descriptors.test.ts +51 -0
  97. package/src/tools/find.ts +55 -26
  98. package/src/tools/todo-contract.ts +82 -13
  99. package/src/tools/todo-write.ts +2 -5
  100. package/src/tui/output-block.ts +11 -1
@@ -1,4 +1,4 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
 
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
@@ -18,10 +18,19 @@ import { isEnoent, pathIsWithin, peekFile, toError } from "@gajae-code/utils";
18
18
  import {
19
19
  assertManagedDirectoryRoot,
20
20
  type ManagedDirectoryRoot,
21
+ renameFlagsUnsupported,
22
+ shouldFsyncManagedDirectory,
21
23
  validateNativeSecurityResult,
22
24
  } from "./internal/managed-session-storage";
25
+ import {
26
+ classifyNativePublishOutcome,
27
+ mayCleanCurrentStaging,
28
+ type NativePublishOutcome,
29
+ } from "./internal/native-publish-outcome";
30
+ import { isDerivedSessionMemoryFile } from "./internal/session-memory-sidecar";
23
31
 
24
32
  const utf8Decoder = new TextDecoder("utf-8");
33
+ const newlineBuffer = Buffer.from("\n", "utf8");
25
34
  function canonicalPathSync(value: string): string {
26
35
  try {
27
36
  return fs.realpathSync.native(value);
@@ -49,6 +58,31 @@ export interface SessionStorageSnapshot {
49
58
  stat: SessionStorageStat;
50
59
  }
51
60
 
61
+ export interface SessionStorageExactReplacementExpectation {
62
+ readonly stat: SessionStorageStat;
63
+ readonly sha256: string;
64
+ }
65
+ /** Upper bound for one descriptor-validated recorded range read. */
66
+ export const SESSION_RANGE_READ_MAX_BYTES = 64 * 1024 * 1024;
67
+
68
+ /**
69
+ * One bounded recorded-length read validated against a single opened descriptor.
70
+ * `bytes` is exactly `length` bytes from `[start, start + length)` of the same
71
+ * regular-file object; `stat` is the fresh post-read descriptor snapshot so the
72
+ * caller can compare dev/ino/nlink against the pathname before committing an index.
73
+ */
74
+ export interface SessionStorageRangeSnapshot {
75
+ stat: SessionStorageStat;
76
+ bytes: Uint8Array;
77
+ }
78
+
79
+ function validateRangeReadBounds(start: number, length: number): void {
80
+ if (!Number.isSafeInteger(start) || start < 0 || !Number.isSafeInteger(length) || length < 0)
81
+ throw new RangeError("Invalid session range read bounds");
82
+ if (start > Number.MAX_SAFE_INTEGER - length) throw new RangeError("Session range read start overflows");
83
+ if (length > SESSION_RANGE_READ_MAX_BYTES) throw new RangeError("Session range read exceeds the bounded maximum");
84
+ }
85
+
52
86
  function statFromNode(stats: fs.BigIntStats): SessionStorageStat {
53
87
  return {
54
88
  dev: stats.dev,
@@ -163,6 +197,10 @@ export interface SessionStorageWriter {
163
197
  writeLineSync(line: string): void;
164
198
  flush(): Promise<void>;
165
199
  fsync(): Promise<void>;
200
+ /** Synchronously fsync all prior writes when the backend supports durable sidecar publication. */
201
+ fsyncSync?(): void;
202
+ /** Descriptor-bound identity captured from the still-open writer after fsync. */
203
+ statSync?(): SessionStorageStat;
166
204
  close(): Promise<void>;
167
205
  /**
168
206
  * Synchronously close the underlying descriptor. The certainty-aware close
@@ -177,6 +215,51 @@ export interface SessionStorageWriter {
177
215
  /** Stored error for non-success close states (`close_failed_retryable`/`close_unknown`). */
178
216
  getCloseError(): Error | undefined;
179
217
  }
218
+ // =============================================================================
219
+ // Staged streaming writer contract (two-pass fork publication, immutable destinations)
220
+ // =============================================================================
221
+
222
+ /** Upper bound for one staged line (excluding the trailing newline). */
223
+ export const STAGED_WRITER_LINE_MAX_BYTES = 64 * 1024 * 1024;
224
+ /** Upper bound for aggregated different-length patches buffered for the publish-time overlay pass. */
225
+ export const STAGED_WRITER_PATCH_LIMIT_BYTES = 8 * 1024 * 1024;
226
+ export const STAGED_WRITER_PATCH_MAX_COUNT = 65_536;
227
+ export const STAGED_MEMORY_WRITER_MAX_BYTES = 20 * 1024 * 1024;
228
+ export const STAGED_MEMORY_WRITER_MAX_LINES = STAGED_WRITER_PATCH_MAX_COUNT + 1;
229
+ const STAGED_WRITER_COPY_CHUNK_BYTES = 64 * 1024;
230
+
231
+ /**
232
+ * Bounded staged streaming writer for one immutable one-shot destination (fork /
233
+ * capture). Lines are streamed to a sibling staging file; {@link publishNoReplace}
234
+ * atomically publishes the staged file only while the destination is still absent,
235
+ * so publication never materializes the whole file in memory. Different-length
236
+ * {@link patchLine} replacements are buffered (bounded) and applied by a second
237
+ * bounded streaming pass at publish time.
238
+ *
239
+ * `publishNoReplace` is reserved for immutable one-shot destinations and must never
240
+ * be used for the mutable `.spill.commit` marker (checked create/replace helpers
241
+ * exist for that path).
242
+ */
243
+ export interface StagedStreamingWriter {
244
+ /** Append one complete line; the writer adds the trailing newline. */
245
+ writeLine(bytes: Uint8Array): void;
246
+ /** Move the line cursor to `ordinal` (0-based) so a later patchLine targets it. */
247
+ seekToLine(ordinal: number): void;
248
+ /**
249
+ * Replace the line at `ordinal` with `bytes`. Same-length replacements are
250
+ * applied in place; different-length replacements are buffered (bounded) and
251
+ * applied by the publish-time overlay pass.
252
+ */
253
+ patchLine(ordinal: number, bytes: Uint8Array): void;
254
+ /** Hand buffered writes to the kernel (the staged descriptor is unbuffered). */
255
+ flush(): void;
256
+ /** Synchronize the staged file. */
257
+ fsync(): void;
258
+ /** Close the staged descriptor; required before {@link publishNoReplace}. */
259
+ closeSync(): void;
260
+ /** Atomically publish the staged file at the destination only while it is absent. */
261
+ publishNoReplace(): void;
262
+ }
180
263
 
181
264
  export interface SessionStorage {
182
265
  ensureDirSync(dir: string): void;
@@ -203,6 +286,12 @@ export interface SessionStorage {
203
286
  writeText(path: string, content: string): Promise<void>;
204
287
  rename(path: string, nextPath: string): Promise<void>;
205
288
  renameSync(path: string, nextPath: string): void;
289
+ /** Replace only while the destination still has the expected exact identity and bytes. */
290
+ replaceExactSync?(
291
+ sourcePath: string,
292
+ destinationPath: string,
293
+ expected: SessionStorageExactReplacementExpectation,
294
+ ): boolean;
206
295
  unlink(path: string): Promise<void>;
207
296
  unlinkSync(path: string): void;
208
297
  deleteSessionWithArtifacts(sessionPath: string): Promise<void>;
@@ -214,6 +303,12 @@ export interface SessionStorage {
214
303
  */
215
304
  deleteSessionVerified?(target: VerifiedSessionDeleteTarget): Promise<VerifiedSessionDeleteResult>;
216
305
  openWriter(path: string, options?: SessionStorageWriterOpenOptions): SessionStorageWriter;
306
+ /** Bounded recorded-length read with descriptor identity validation (additive). */
307
+ readRangeSync?(path: string, start: number, length: number): SessionStorageRangeSnapshot;
308
+ /** Async bounded recorded-length read with descriptor identity validation (additive). */
309
+ readRange?(path: string, start: number, length: number): Promise<SessionStorageRangeSnapshot>;
310
+ /** Open a staged streaming writer for one immutable one-shot destination (additive). */
311
+ openStagedWriter?(path: string, options?: SessionStorageWriterOpenOptions): StagedStreamingWriter;
217
312
  }
218
313
 
219
314
  // =============================================================================
@@ -557,6 +652,270 @@ function assertNoReparsePath(pathname: string): void {
557
652
  }
558
653
  }
559
654
  }
655
+ // =============================================================================
656
+ // Commit-marker checked create/replace (mutable `.spill.commit` publication)
657
+ // =============================================================================
658
+
659
+ function fsyncDirectorySync(pathname: string): void {
660
+ if (!shouldFsyncManagedDirectory()) return;
661
+ const fd = fs.openSync(pathname, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
662
+ try {
663
+ fs.fsyncSync(fd);
664
+ } finally {
665
+ fs.closeSync(fd);
666
+ }
667
+ }
668
+
669
+ /** Best-effort identity-checked removal of one of our own staged temp names. */
670
+ function unlinkOwnedStagedSync(stagingPath: string, expected: { dev: bigint; ino: bigint }): void {
671
+ try {
672
+ const named = fs.lstatSync(stagingPath, { bigint: true });
673
+ if (named.dev !== expected.dev || named.ino !== expected.ino) return;
674
+ fs.unlinkSync(stagingPath);
675
+ } catch {
676
+ // ENOENT means the name was already consumed or removed; cleanup is best-effort.
677
+ return;
678
+ }
679
+ }
680
+
681
+ function sameDescriptorIdentity(left: SessionStorageStat, right: SessionStorageStat): boolean {
682
+ return (
683
+ left.dev === right.dev &&
684
+ left.ino === right.ino &&
685
+ left.nlink === right.nlink &&
686
+ left.size === right.size &&
687
+ left.mtimeNs === right.mtimeNs &&
688
+ left.ctimeNs === right.ctimeNs
689
+ );
690
+ }
691
+
692
+ /** Physically observed commit-marker state; corrupt JSON is still `present`. */
693
+ export type SessionCommitMarkerState =
694
+ | { kind: "missing" }
695
+ | { kind: "present"; rawBytesSha256: string; stat: SessionStorageStat };
696
+
697
+ /** Exact `present` expectation for one checked commit-marker replacement. */
698
+ export interface SessionCommitMarkerPresentExpectation {
699
+ /** SHA-256 of the exact raw marker bytes physically on disk (corrupt JSON included). */
700
+ rawBytesSha256: string;
701
+ /** Descriptor snapshot of the marker object expected to be replaced. */
702
+ descriptorIdentity: SessionStorageStat;
703
+ }
704
+
705
+ /** Snapshot one commit marker's physical state without granting write authority. */
706
+ export function readSessionCommitMarkerSync(storage: SessionStorage, markerPath: string): SessionCommitMarkerState {
707
+ if (!storage.existsSync(markerPath)) return { kind: "missing" };
708
+ if (!storage.readBytesSync) throw new Error("Commit marker reads require exact-bytes storage");
709
+ const bytes = storage.readBytesSync(markerPath);
710
+ const stat = storage.statSync(markerPath);
711
+ return { kind: "present", rawBytesSha256: createHash("sha256").update(bytes).digest("hex"), stat };
712
+ }
713
+
714
+ /**
715
+ * Checked commit-marker create: publishes only while the marker is still `missing`.
716
+ * Temp + fsync + atomic create-if-absent + directory fsync (file backend); the
717
+ * in-memory backend mirrors the same missing-expectation abort. Leftover temps are
718
+ * removed on any failure. Runs inside the caller's persistence fence.
719
+ */
720
+ export function createSessionCommitMarkerCheckedSync(
721
+ storage: SessionStorage,
722
+ markerPath: string,
723
+ bytes: Uint8Array,
724
+ options?: { securityContext?: SessionStorageSecurityContext },
725
+ ): void {
726
+ if (storage instanceof FileSessionStorage) {
727
+ createFileCommitMarkerCheckedSync(storage, markerPath, bytes, options?.securityContext);
728
+ return;
729
+ }
730
+ if (storage instanceof MemorySessionStorage) {
731
+ if (storage.existsSync(markerPath)) throw new Error("commit_marker_expected_missing");
732
+ storage.writeTextSync(markerPath, Buffer.from(bytes).toString("utf8"));
733
+ return;
734
+ }
735
+ throw new Error("Commit marker checked publication requires a file or memory storage backend");
736
+ }
737
+
738
+ /**
739
+ * Checked commit-marker replace: replaces only on an exact `present` raw/hash +
740
+ * descriptor identity match (corrupt-present included). Temp + fsync + checked
741
+ * atomic rename + directory fsync (file backend); any mismatch aborts with the
742
+ * current marker untouched. The in-memory backend mirrors the same aborts. Runs
743
+ * inside the caller's persistence fence.
744
+ */
745
+ export function replaceSessionCommitMarkerCheckedSync(
746
+ storage: SessionStorage,
747
+ markerPath: string,
748
+ bytes: Uint8Array,
749
+ expected: SessionCommitMarkerPresentExpectation,
750
+ options?: { securityContext?: SessionStorageSecurityContext },
751
+ ): void {
752
+ if (storage instanceof FileSessionStorage) {
753
+ replaceFileCommitMarkerCheckedSync(storage, markerPath, bytes, expected, options?.securityContext);
754
+ return;
755
+ }
756
+ if (storage instanceof MemorySessionStorage) {
757
+ replaceMemoryCommitMarkerCheckedSync(storage, markerPath, bytes, expected);
758
+ return;
759
+ }
760
+ throw new Error("Commit marker checked publication requires a file or memory storage backend");
761
+ }
762
+
763
+ function createFileCommitMarkerCheckedSync(
764
+ _storage: FileSessionStorage,
765
+ markerPath: string,
766
+ bytes: Uint8Array,
767
+ securityContext: SessionStorageSecurityContext,
768
+ ): void {
769
+ const dir = path.dirname(markerPath);
770
+ assertNoReparsePath(dir);
771
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
772
+ assertNoReparsePath(markerPath);
773
+ const tempPath = path.join(dir, `.${path.basename(markerPath)}.${randomUUID()}.tmp`);
774
+ let fd: number | undefined;
775
+ let stagedIdentity: { dev: bigint; ino: bigint } | undefined;
776
+ let failure: unknown;
777
+ let outcome: NativePublishOutcome | undefined;
778
+ let linkPublished = false;
779
+ try {
780
+ fd = fs.openSync(
781
+ tempPath,
782
+ fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW ?? 0),
783
+ 0o600,
784
+ );
785
+ secureOwnerOnlyFileDescriptor(tempPath, fd, "apply", securityContext);
786
+ const opened = fs.fstatSync(fd, { bigint: true });
787
+ stagedIdentity = { dev: opened.dev, ino: opened.ino };
788
+ let offset = 0;
789
+ while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
790
+ fs.fsyncSync(fd);
791
+ secureOwnerOnlyFileDescriptor(tempPath, fd, "verify", securityContext);
792
+ const staged = fs.fstatSync(fd, { bigint: true });
793
+ stagedIdentity = { dev: staged.dev, ino: staged.ino };
794
+ fs.closeSync(fd);
795
+ fd = undefined;
796
+
797
+ outcome = classifyNativePublishOutcome(nativeSessionStorage().renameNoReplacePath(tempPath, markerPath));
798
+ if (renameFlagsUnsupported(outcome)) {
799
+ outcome = classifyNativePublishOutcome(nativeSessionStorage().linkNoReplacePath(tempPath, markerPath));
800
+ linkPublished = outcome.ok;
801
+ }
802
+ if (!outcome.ok) {
803
+ if (outcome.reason === "destination_exists") throw new Error("commit_marker_expected_missing");
804
+ throw new Error(`commit_marker_create_rejected:${outcome.reason}`);
805
+ }
806
+ const named = fs.lstatSync(markerPath, { bigint: true });
807
+ if (
808
+ !named.isFile() ||
809
+ named.isSymbolicLink() ||
810
+ named.dev !== stagedIdentity.dev ||
811
+ named.ino !== stagedIdentity.ino
812
+ )
813
+ throw new Error("destination_identity_changed");
814
+ fsyncDirectorySync(dir);
815
+ } catch (error) {
816
+ failure = error;
817
+ } finally {
818
+ if (fd !== undefined) fs.closeSync(fd);
819
+ if (stagedIdentity && (outcome === undefined || linkPublished || mayCleanCurrentStaging(outcome))) {
820
+ unlinkOwnedStagedSync(tempPath, stagedIdentity);
821
+ }
822
+ }
823
+ if (failure !== undefined) throw failure;
824
+ }
825
+
826
+ function replaceFileCommitMarkerCheckedSync(
827
+ storage: FileSessionStorage,
828
+ markerPath: string,
829
+ bytes: Uint8Array,
830
+ expected: SessionCommitMarkerPresentExpectation,
831
+ securityContext: SessionStorageSecurityContext,
832
+ ): void {
833
+ // Expected-state check runs before any mutation (inside the caller's fence).
834
+ const current = readSessionCommitMarkerSync(storage, markerPath);
835
+ if (current.kind !== "present") throw new Error("commit_marker_expected_present");
836
+ if (current.rawBytesSha256 !== expected.rawBytesSha256) throw new Error("commit_marker_raw_hash_mismatch");
837
+ if (!sameDescriptorIdentity(current.stat, expected.descriptorIdentity))
838
+ throw new Error("commit_marker_identity_mismatch");
839
+
840
+ const dir = path.dirname(markerPath);
841
+ const parentIdentity = fs.statSync(dir, { bigint: true });
842
+ const tempPath = path.join(dir, `.${path.basename(markerPath)}.${randomUUID()}.tmp`);
843
+ let fd: number | undefined;
844
+ let staged: fs.BigIntStats | undefined;
845
+ let stagedIdentity: { dev: bigint; ino: bigint } | undefined;
846
+ const stagedSha256 = createHash("sha256").update(bytes).digest("hex");
847
+ let failure: unknown;
848
+ try {
849
+ fd = fs.openSync(
850
+ tempPath,
851
+ fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW ?? 0),
852
+ 0o600,
853
+ );
854
+ secureOwnerOnlyFileDescriptor(tempPath, fd, "apply", securityContext);
855
+ const opened = fs.fstatSync(fd, { bigint: true });
856
+ stagedIdentity = { dev: opened.dev, ino: opened.ino };
857
+ let offset = 0;
858
+ while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
859
+ fs.fsyncSync(fd);
860
+ secureOwnerOnlyFileDescriptor(tempPath, fd, "verify", securityContext);
861
+ staged = fs.fstatSync(fd, { bigint: true });
862
+ fs.closeSync(fd);
863
+ fd = undefined;
864
+
865
+ // Checked atomic rename: replaces the destination only while it is still the
866
+ // expected marker object (exact dev/ino/nlink/size/mtimeNs + raw sha256).
867
+ const replaced = nativeSessionStorage().exactReplacePath(
868
+ tempPath,
869
+ markerPath,
870
+ {
871
+ dev: staged.dev,
872
+ ino: staged.ino,
873
+ nlink: staged.nlink,
874
+ parentDev: parentIdentity.dev,
875
+ parentIno: parentIdentity.ino,
876
+ size: BigInt(bytes.byteLength),
877
+ mtimeNs: staged.mtimeNs,
878
+ sha256: stagedSha256,
879
+ },
880
+ {
881
+ dev: expected.descriptorIdentity.dev,
882
+ ino: expected.descriptorIdentity.ino,
883
+ nlink: expected.descriptorIdentity.nlink,
884
+ parentDev: parentIdentity.dev,
885
+ parentIno: parentIdentity.ino,
886
+ size: BigInt(expected.descriptorIdentity.size),
887
+ mtimeNs: expected.descriptorIdentity.mtimeNs,
888
+ sha256: expected.rawBytesSha256,
889
+ },
890
+ );
891
+ if (!replaced.ok) throw new Error(`commit_marker_replace_rejected:${replaced.code ?? "unknown"}`);
892
+ const named = fs.lstatSync(markerPath, { bigint: true });
893
+ if (!named.isFile() || named.isSymbolicLink() || named.dev !== staged.dev || named.ino !== staged.ino)
894
+ throw new Error("destination_identity_changed");
895
+ fsyncDirectorySync(dir);
896
+ } catch (error) {
897
+ failure = error;
898
+ } finally {
899
+ if (fd !== undefined) fs.closeSync(fd);
900
+ if (staged) unlinkOwnedStagedSync(tempPath, { dev: staged.dev, ino: staged.ino });
901
+ else if (stagedIdentity) unlinkOwnedStagedSync(tempPath, stagedIdentity);
902
+ }
903
+ if (failure !== undefined) throw failure;
904
+ }
905
+
906
+ function replaceMemoryCommitMarkerCheckedSync(
907
+ storage: MemorySessionStorage,
908
+ markerPath: string,
909
+ bytes: Uint8Array,
910
+ expected: SessionCommitMarkerPresentExpectation,
911
+ ): void {
912
+ const current = readSessionCommitMarkerSync(storage, markerPath);
913
+ if (current.kind !== "present") throw new Error("commit_marker_expected_present");
914
+ if (current.rawBytesSha256 !== expected.rawBytesSha256) throw new Error("commit_marker_raw_hash_mismatch");
915
+ if (!sameDescriptorIdentity(current.stat, expected.descriptorIdentity))
916
+ throw new Error("commit_marker_identity_mismatch");
917
+ storage.writeTextSync(markerPath, Buffer.from(bytes).toString("utf8"));
918
+ }
560
919
 
561
920
  // FinalizationRegistry to clean up leaked file descriptors
562
921
  const writerRegistry = new FinalizationRegistry<number>(fd => {
@@ -656,7 +1015,7 @@ class FileSessionStorageWriter implements SessionStorageWriter {
656
1015
  // OS buffers are flushed on fsync, nothing to do here
657
1016
  }
658
1017
 
659
- async fsync(): Promise<void> {
1018
+ fsyncSync(): void {
660
1019
  if (this.#closeState !== "open") throw this.#nonOpenWriteError();
661
1020
  if (this.#error) throw this.#error;
662
1021
  try {
@@ -666,6 +1025,15 @@ class FileSessionStorageWriter implements SessionStorageWriter {
666
1025
  throw this.#recordError(err);
667
1026
  }
668
1027
  }
1028
+ statSync(): SessionStorageStat {
1029
+ if (this.#closeState !== "open") throw this.#nonOpenWriteError();
1030
+ if (this.#error) throw this.#error;
1031
+ return statFromNode(fs.fstatSync(this.#fd, { bigint: true }));
1032
+ }
1033
+
1034
+ async fsync(): Promise<void> {
1035
+ this.fsyncSync();
1036
+ }
669
1037
 
670
1038
  closeSync(): void {
671
1039
  // Repeated close after success is a harmless idempotent no-op.
@@ -725,6 +1093,293 @@ class FileSessionStorageWriter implements SessionStorageWriter {
725
1093
  return this.#closeError;
726
1094
  }
727
1095
  }
1096
+ /**
1097
+ * File-backend staged streaming writer: streams lines to a sibling staging file and
1098
+ * publishes no-replace to the immutable destination. Different-length patchLine
1099
+ * replacements are applied by a bounded publish-time second pass (64 KiB chunks),
1100
+ * so publication never materializes the whole file in memory.
1101
+ */
1102
+ class FileStagedStreamingWriter implements StagedStreamingWriter {
1103
+ #fd: number;
1104
+ #stagingPath: string;
1105
+ #destinationPath: string;
1106
+ #securityContext: SessionStorageSecurityContext;
1107
+ #lineCount = 0;
1108
+ #pendingPatches = new Map<number, Uint8Array>();
1109
+ #pendingPatchBytes = 0;
1110
+ #closed = false;
1111
+ #published = false;
1112
+ #error: Error | undefined;
1113
+
1114
+ constructor(destinationPath: string, options?: SessionStorageWriterOpenOptions) {
1115
+ this.#securityContext = options?.securityContext;
1116
+ const dir = path.dirname(destinationPath);
1117
+ assertNoReparsePath(dir);
1118
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
1119
+ assertNoReparsePath(dir);
1120
+ this.#destinationPath = destinationPath;
1121
+ this.#stagingPath = path.join(dir, `.${path.basename(destinationPath)}.${randomUUID()}.staged`);
1122
+ const fd = fs.openSync(
1123
+ this.#stagingPath,
1124
+ fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW ?? 0),
1125
+ 0o600,
1126
+ );
1127
+ try {
1128
+ secureOwnerOnlyFileDescriptor(this.#stagingPath, fd, "apply", this.#securityContext);
1129
+ } catch (error) {
1130
+ fs.closeSync(fd);
1131
+ throw error;
1132
+ }
1133
+ this.#fd = fd;
1134
+ writerRegistry.register(this, this.#fd, this);
1135
+ }
1136
+
1137
+ #recordError(err: unknown): Error {
1138
+ const error = toError(err);
1139
+ if (!this.#error) this.#error = error;
1140
+ return error;
1141
+ }
1142
+
1143
+ #assertOpen(): void {
1144
+ if (this.#closed) throw new Error("Staged writer is closed");
1145
+ if (this.#error) throw this.#error;
1146
+ }
1147
+
1148
+ writeLine(bytes: Uint8Array): void {
1149
+ this.#assertOpen();
1150
+ if (bytes.byteLength > STAGED_WRITER_LINE_MAX_BYTES)
1151
+ throw new RangeError("Staged line exceeds the bounded maximum");
1152
+ const line = Buffer.concat([Buffer.from(bytes), newlineBuffer]);
1153
+ 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;
1159
+ }
1160
+ this.#lineCount++;
1161
+ } catch (err) {
1162
+ throw this.#recordError(err);
1163
+ }
1164
+ }
1165
+
1166
+ seekToLine(ordinal: number): void {
1167
+ this.#assertOpen();
1168
+ this.#findLine(ordinal);
1169
+ }
1170
+
1171
+ patchLine(ordinal: number, bytes: Uint8Array): void {
1172
+ this.#assertOpen();
1173
+ const existing = this.#findLine(ordinal);
1174
+ const lineLength = bytes.byteLength + 1;
1175
+ const prior = this.#pendingPatches.get(ordinal);
1176
+ if (prior) {
1177
+ const nextPatchBytes = this.#pendingPatchBytes - prior.byteLength + bytes.byteLength;
1178
+ if (nextPatchBytes > STAGED_WRITER_PATCH_LIMIT_BYTES) {
1179
+ this.#error = new Error("staged_overlay_capacity_exceeded");
1180
+ throw this.#error;
1181
+ }
1182
+ this.#pendingPatches.set(ordinal, Buffer.from(bytes));
1183
+ this.#pendingPatchBytes = nextPatchBytes;
1184
+ return;
1185
+ }
1186
+ if (lineLength === existing.length) {
1187
+ try {
1188
+ fs.writeSync(this.#fd, Buffer.from(bytes), 0, bytes.byteLength, existing.offset);
1189
+ } catch (err) {
1190
+ throw this.#recordError(err);
1191
+ }
1192
+ return;
1193
+ }
1194
+ if (
1195
+ this.#pendingPatches.size >= STAGED_WRITER_PATCH_MAX_COUNT ||
1196
+ this.#pendingPatchBytes + bytes.byteLength > STAGED_WRITER_PATCH_LIMIT_BYTES
1197
+ ) {
1198
+ this.#error = new Error("staged_overlay_capacity_exceeded");
1199
+ throw this.#error;
1200
+ }
1201
+ this.#pendingPatches.set(ordinal, Buffer.from(bytes));
1202
+ this.#pendingPatchBytes += bytes.byteLength;
1203
+ }
1204
+
1205
+ #findLine(ordinal: number): { offset: number; length: number } {
1206
+ if (ordinal < 0 || ordinal >= this.#lineCount) throw new RangeError("Line ordinal is not staged");
1207
+ let fd: number | undefined;
1208
+ try {
1209
+ fd = fs.openSync(this.#stagingPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
1210
+ const writerIdentity = fs.fstatSync(this.#fd, { bigint: true });
1211
+ const readerIdentity = fs.fstatSync(fd, { bigint: true });
1212
+ if (writerIdentity.dev !== readerIdentity.dev || writerIdentity.ino !== readerIdentity.ino)
1213
+ throw new Error("staged_source_identity_changed");
1214
+ const chunk = Buffer.alloc(STAGED_WRITER_COPY_CHUNK_BYTES);
1215
+ let offset = 0;
1216
+ let lineStart = 0;
1217
+ let current = 0;
1218
+ for (;;) {
1219
+ const count = fs.readSync(fd, chunk, 0, chunk.byteLength, offset);
1220
+ if (count === 0) break;
1221
+ for (let index = 0; index < count; index++) {
1222
+ if (chunk[index] !== 0x0a) continue;
1223
+ const lineEnd = offset + index + 1;
1224
+ if (current === ordinal) return { offset: lineStart, length: lineEnd - lineStart };
1225
+ current++;
1226
+ lineStart = lineEnd;
1227
+ }
1228
+ offset += count;
1229
+ }
1230
+ throw new RangeError("Line ordinal is not staged");
1231
+ } finally {
1232
+ if (fd !== undefined) fs.closeSync(fd);
1233
+ }
1234
+ }
1235
+
1236
+ flush(): void {
1237
+ this.#assertOpen();
1238
+ // writeSync already handed the bytes to the kernel; nothing is buffered here.
1239
+ }
1240
+
1241
+ fsync(): void {
1242
+ this.#assertOpen();
1243
+ try {
1244
+ fs.fsyncSync(this.#fd);
1245
+ secureOwnerOnlyFileDescriptor(this.#stagingPath, this.#fd, "verify", this.#securityContext);
1246
+ } catch (err) {
1247
+ throw this.#recordError(err);
1248
+ }
1249
+ }
1250
+
1251
+ closeSync(): void {
1252
+ if (this.#closed) return;
1253
+ if (this.#error) throw this.#error;
1254
+ try {
1255
+ secureOwnerOnlyFileDescriptor(this.#stagingPath, this.#fd, "verify", this.#securityContext);
1256
+ fs.closeSync(this.#fd);
1257
+ } catch (err) {
1258
+ throw this.#recordError(err);
1259
+ }
1260
+ this.#closed = true;
1261
+ writerRegistry.unregister(this);
1262
+ }
1263
+
1264
+ publishNoReplace(): void {
1265
+ if (this.#published) throw new Error("Staged writer already published");
1266
+ if (!this.#closed) throw new Error("Staged writer must be closed before publication");
1267
+ if (this.#error) throw this.#error;
1268
+ const dir = path.dirname(this.#destinationPath);
1269
+ const originalStaging = fs.lstatSync(this.#stagingPath, { bigint: true });
1270
+ const originalStagingIdentity = { dev: originalStaging.dev, ino: originalStaging.ino };
1271
+ let publishSource = this.#stagingPath;
1272
+ let materialized: string | undefined;
1273
+ let stagedIdentity: { dev: bigint; ino: bigint } | undefined;
1274
+ let outcome: NativePublishOutcome | undefined;
1275
+ let linkPublished = false;
1276
+ let failure: unknown;
1277
+ try {
1278
+ if (this.#pendingPatches.size > 0) {
1279
+ materialized = this.#materializePatchedCopy();
1280
+ publishSource = materialized;
1281
+ }
1282
+ const staged = fs.lstatSync(publishSource, { bigint: true });
1283
+ stagedIdentity = { dev: staged.dev, ino: staged.ino };
1284
+ outcome = classifyNativePublishOutcome(
1285
+ nativeSessionStorage().renameNoReplacePath(publishSource, this.#destinationPath),
1286
+ );
1287
+ if (renameFlagsUnsupported(outcome)) {
1288
+ outcome = classifyNativePublishOutcome(
1289
+ nativeSessionStorage().linkNoReplacePath(publishSource, this.#destinationPath),
1290
+ );
1291
+ linkPublished = outcome.ok;
1292
+ }
1293
+ if (!outcome.ok) throw new Error(`staged_publish_rejected:${outcome.reason}`);
1294
+ const named = fs.lstatSync(this.#destinationPath, { bigint: true });
1295
+ if (
1296
+ !named.isFile() ||
1297
+ named.isSymbolicLink() ||
1298
+ named.dev !== stagedIdentity.dev ||
1299
+ named.ino !== stagedIdentity.ino
1300
+ )
1301
+ throw new Error("destination_identity_changed");
1302
+ fsyncDirectorySync(dir);
1303
+ this.#published = true;
1304
+ } catch (error) {
1305
+ failure = error;
1306
+ } finally {
1307
+ // Only a validated pre-mutation outcome authorizes removing our own staged
1308
+ // name: a committed outcome may have made publishSource the destination.
1309
+ if (materialized) {
1310
+ if (stagedIdentity && (linkPublished || (outcome && mayCleanCurrentStaging(outcome))))
1311
+ unlinkOwnedStagedSync(materialized, stagedIdentity);
1312
+ unlinkOwnedStagedSync(this.#stagingPath, originalStagingIdentity);
1313
+ } else if (stagedIdentity && (linkPublished || (outcome && mayCleanCurrentStaging(outcome)))) {
1314
+ unlinkOwnedStagedSync(this.#stagingPath, stagedIdentity);
1315
+ }
1316
+ }
1317
+ if (failure !== undefined) throw failure;
1318
+ }
1319
+
1320
+ /** Second bounded streaming pass: materialize staged lines with pending patches applied. */
1321
+ #materializePatchedCopy(): string {
1322
+ const dir = path.dirname(this.#stagingPath);
1323
+ const copyPath = path.join(dir, `.${path.basename(this.#destinationPath)}.${randomUUID()}.staged-final`);
1324
+ let copyFd: number | undefined;
1325
+ let sourceFd: number | undefined;
1326
+ let copyIdentity: { dev: bigint; ino: bigint } | undefined;
1327
+ try {
1328
+ copyFd = fs.openSync(
1329
+ copyPath,
1330
+ fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW ?? 0),
1331
+ 0o600,
1332
+ );
1333
+ secureOwnerOnlyFileDescriptor(copyPath, copyFd, "apply", this.#securityContext);
1334
+ const created = fs.fstatSync(copyFd, { bigint: true });
1335
+ copyIdentity = { dev: created.dev, ino: created.ino };
1336
+ sourceFd = fs.openSync(this.#stagingPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
1337
+ let ordinal = 0;
1338
+ let carry = Buffer.alloc(0);
1339
+ const chunk = Buffer.alloc(STAGED_WRITER_COPY_CHUNK_BYTES);
1340
+ for (;;) {
1341
+ const count = fs.readSync(sourceFd, chunk, 0, chunk.byteLength, null);
1342
+ if (count === 0) break;
1343
+ const combined =
1344
+ carry.byteLength === 0 ? chunk.subarray(0, count) : Buffer.concat([carry, chunk.subarray(0, count)]);
1345
+ let lineStart = 0;
1346
+ for (;;) {
1347
+ const newline = combined.indexOf(0x0a, lineStart);
1348
+ if (newline === -1) break;
1349
+ const line = this.#pendingPatches.get(ordinal) ?? combined.subarray(lineStart, newline);
1350
+ this.#writeCopyLine(copyFd, line);
1351
+ ordinal++;
1352
+ lineStart = newline + 1;
1353
+ }
1354
+ carry = Buffer.from(combined.subarray(lineStart));
1355
+ }
1356
+ if (carry.byteLength > 0) throw new Error("staged_file_malformed_tail");
1357
+ fs.fsyncSync(copyFd);
1358
+ return copyPath;
1359
+ } catch (error) {
1360
+ if (copyIdentity) unlinkOwnedStagedSync(copyPath, copyIdentity);
1361
+ throw this.#recordError(error);
1362
+ } finally {
1363
+ if (sourceFd !== undefined) fs.closeSync(sourceFd);
1364
+ if (copyFd !== undefined) fs.closeSync(copyFd);
1365
+ }
1366
+ }
1367
+
1368
+ #writeCopyLine(fd: number, line: Uint8Array): void {
1369
+ let written = 0;
1370
+ while (written < line.byteLength) {
1371
+ const count = fs.writeSync(fd, line, written, line.byteLength - written);
1372
+ if (count === 0) throw new Error("Short write");
1373
+ written += count;
1374
+ }
1375
+ let nlWritten = 0;
1376
+ while (nlWritten < newlineBuffer.byteLength) {
1377
+ const count = fs.writeSync(fd, newlineBuffer, nlWritten, newlineBuffer.byteLength - nlWritten);
1378
+ if (count === 0) throw new Error("Short write");
1379
+ nlWritten += count;
1380
+ }
1381
+ }
1382
+ }
728
1383
 
729
1384
  export class FileSessionStorage implements SessionStorage {
730
1385
  ensureDirSync(dir: string): void {
@@ -762,6 +1417,65 @@ export class FileSessionStorage implements SessionStorage {
762
1417
  fs.closeSync(fd);
763
1418
  }
764
1419
  }
1420
+ /**
1421
+ * Bounded recorded-length read with descriptor identity validation: opens one
1422
+ * no-follow descriptor, verifies the requested range is fully present, reads
1423
+ * exactly `length` bytes, and revalidates dev/ino/nlink on the same descriptor
1424
+ * plus the pathname (append-only size growth is tolerated; an object swap is
1425
+ * rejected). No path-based Bun Blob reads for managed authority.
1426
+ */
1427
+ readRangeSync(fpath: string, start: number, length: number): SessionStorageRangeSnapshot {
1428
+ validateRangeReadBounds(start, length);
1429
+ const flags = fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0);
1430
+ const fd = fs.openSync(fpath, flags);
1431
+ try {
1432
+ const before = fs.fstatSync(fd, { bigint: true });
1433
+ if (!before.isFile() || before.nlink > 1) throw new Error("source_changed");
1434
+ if (Number(before.size) < start + length) throw new Error("range_not_present");
1435
+ const bytes = Buffer.alloc(length);
1436
+ let offset = 0;
1437
+ while (offset < length) {
1438
+ const count = fs.readSync(fd, bytes, offset, length - offset, start + offset);
1439
+ if (count === 0) throw new Error("range_not_present");
1440
+ offset += count;
1441
+ }
1442
+ const after = fs.fstatSync(fd, { bigint: true });
1443
+ if (after.dev !== before.dev || after.ino !== before.ino || after.nlink !== before.nlink)
1444
+ throw new Error("source_changed");
1445
+ const named = fs.lstatSync(fpath, { bigint: true });
1446
+ if (!named.isFile() || named.isSymbolicLink() || named.dev !== before.dev || named.ino !== before.ino)
1447
+ throw new Error("source_changed");
1448
+ return { bytes, stat: statFromNode(after) };
1449
+ } finally {
1450
+ fs.closeSync(fd);
1451
+ }
1452
+ }
1453
+
1454
+ async readRange(fpath: string, start: number, length: number): Promise<SessionStorageRangeSnapshot> {
1455
+ validateRangeReadBounds(start, length);
1456
+ const handle = await fs.promises.open(fpath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
1457
+ try {
1458
+ const before = await handle.stat({ bigint: true });
1459
+ if (!before.isFile() || before.nlink > 1) throw new Error("source_changed");
1460
+ if (Number(before.size) < start + length) throw new Error("range_not_present");
1461
+ const bytes = Buffer.alloc(length);
1462
+ let offset = 0;
1463
+ while (offset < length) {
1464
+ const { bytesRead } = await handle.read(bytes, offset, length - offset, start + offset);
1465
+ if (bytesRead === 0) throw new Error("range_not_present");
1466
+ offset += bytesRead;
1467
+ }
1468
+ const after = await handle.stat({ bigint: true });
1469
+ if (after.dev !== before.dev || after.ino !== before.ino || after.nlink !== before.nlink)
1470
+ throw new Error("source_changed");
1471
+ const named = fs.lstatSync(fpath, { bigint: true });
1472
+ if (!named.isFile() || named.isSymbolicLink() || named.dev !== before.dev || named.ino !== before.ino)
1473
+ throw new Error("source_changed");
1474
+ return { bytes, stat: statFromNode(after) };
1475
+ } finally {
1476
+ await handle.close();
1477
+ }
1478
+ }
765
1479
 
766
1480
  statSync(path: string): SessionStorageStat {
767
1481
  return statFromNode(fs.statSync(path, { bigint: true }));
@@ -769,7 +1483,9 @@ export class FileSessionStorage implements SessionStorage {
769
1483
 
770
1484
  listFilesSync(dir: string, pattern: string): string[] {
771
1485
  try {
772
- return Array.from(new Bun.Glob(pattern).scanSync(dir)).map(name => path.join(dir, name));
1486
+ return Array.from(new Bun.Glob(pattern).scanSync({ cwd: dir, dot: pattern.startsWith(".") })).map(name =>
1487
+ path.join(dir, name),
1488
+ );
773
1489
  } catch {
774
1490
  return [];
775
1491
  }
@@ -835,6 +1551,68 @@ export class FileSessionStorage implements SessionStorage {
835
1551
  }
836
1552
  }
837
1553
 
1554
+ replaceExactSync(
1555
+ sourcePath: string,
1556
+ destinationPath: string,
1557
+ expected: SessionStorageExactReplacementExpectation,
1558
+ ): boolean {
1559
+ const dir = path.dirname(destinationPath);
1560
+ const parent = fs.statSync(dir, { bigint: true });
1561
+ let fd: number | undefined;
1562
+ try {
1563
+ fd = fs.openSync(sourcePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
1564
+ const before = fs.fstatSync(fd, { bigint: true });
1565
+ if (!before.isFile()) return false;
1566
+ const hash = createHash("sha256");
1567
+ const chunk = Buffer.alloc(STAGED_WRITER_COPY_CHUNK_BYTES);
1568
+ for (;;) {
1569
+ const count = fs.readSync(fd, chunk, 0, chunk.byteLength, null);
1570
+ if (count === 0) break;
1571
+ hash.update(chunk.subarray(0, count));
1572
+ }
1573
+ const after = fs.fstatSync(fd, { bigint: true });
1574
+ if (
1575
+ before.dev !== after.dev ||
1576
+ before.ino !== after.ino ||
1577
+ before.nlink !== after.nlink ||
1578
+ before.size !== after.size ||
1579
+ before.mtimeNs !== after.mtimeNs
1580
+ )
1581
+ return false;
1582
+ fs.closeSync(fd);
1583
+ fd = undefined;
1584
+ const outcome = nativeSessionStorage().exactReplacePath(
1585
+ sourcePath,
1586
+ destinationPath,
1587
+ {
1588
+ dev: before.dev,
1589
+ ino: before.ino,
1590
+ nlink: before.nlink,
1591
+ parentDev: parent.dev,
1592
+ parentIno: parent.ino,
1593
+ size: before.size,
1594
+ mtimeNs: before.mtimeNs,
1595
+ sha256: hash.digest("hex"),
1596
+ },
1597
+ {
1598
+ dev: expected.stat.dev,
1599
+ ino: expected.stat.ino,
1600
+ nlink: expected.stat.nlink ?? 1n,
1601
+ parentDev: parent.dev,
1602
+ parentIno: parent.ino,
1603
+ size: BigInt(expected.stat.size),
1604
+ mtimeNs: expected.stat.mtimeNs,
1605
+ sha256: expected.sha256,
1606
+ },
1607
+ );
1608
+ if (!outcome.ok) return false;
1609
+ fsyncDirectorySync(dir);
1610
+ return true;
1611
+ } finally {
1612
+ if (fd !== undefined) fs.closeSync(fd);
1613
+ }
1614
+ }
1615
+
838
1616
  unlink(path: string): Promise<void> {
839
1617
  return fs.promises.unlink(path);
840
1618
  }
@@ -847,6 +1625,10 @@ export class FileSessionStorage implements SessionStorage {
847
1625
  return new FileSessionStorageWriter(path, options);
848
1626
  }
849
1627
 
1628
+ openStagedWriter(path: string, options?: SessionStorageWriterOpenOptions): StagedStreamingWriter {
1629
+ return new FileStagedStreamingWriter(path, options);
1630
+ }
1631
+
850
1632
  /**
851
1633
  * Delete a session and sibling artifacts in an operator-selected explicit directory.
852
1634
  * Default managed roots use deleteSessionVerified and never call this path.
@@ -857,6 +1639,15 @@ export class FileSessionStorage implements SessionStorage {
857
1639
  } catch (error) {
858
1640
  if (!isEnoent(error)) throw error;
859
1641
  }
1642
+ for (const candidate of this.listFilesSync(path.dirname(sessionPath), `${path.basename(sessionPath)}.spill.*`)) {
1643
+ if (candidate.startsWith(`${sessionPath}.spill.`) && isDerivedSessionMemoryFile(candidate)) {
1644
+ try {
1645
+ await this.unlink(candidate);
1646
+ } catch (error) {
1647
+ if (!isEnoent(error)) throw error;
1648
+ }
1649
+ }
1650
+ }
860
1651
  const artifactsDir = sessionPath.slice(0, -6);
861
1652
  try {
862
1653
  await fs.promises.rm(artifactsDir, { recursive: true, force: true });
@@ -1524,11 +2315,125 @@ function parseFirstJsonlLine(bytes: Uint8Array): Record<string, unknown> | undef
1524
2315
 
1525
2316
  function matchesPattern(name: string, pattern: string): boolean {
1526
2317
  if (pattern === "*") return true;
2318
+ if (pattern === ".*") return name.startsWith(".");
1527
2319
  if (pattern.startsWith("*.")) {
1528
2320
  return name.endsWith(pattern.slice(1));
1529
2321
  }
1530
2322
  return name === pattern;
1531
2323
  }
2324
+ /**
2325
+ * In-memory staged streaming writer: mirrors the file-backend contract exactly
2326
+ * (line streaming, in-place same-length patchLine, bounded buffered overlay for
2327
+ * different-length patches, missing-only no-replace publication) against the
2328
+ * backing MemorySessionStorage so parity tests can exercise the full surface
2329
+ * without touching the filesystem.
2330
+ */
2331
+ class MemoryStagedStreamingWriter implements StagedStreamingWriter {
2332
+ #storage: MemorySessionStorage;
2333
+ #path: string;
2334
+ #lines: Buffer[] = [];
2335
+ #pendingPatches = new Map<number, Uint8Array>();
2336
+ #pendingPatchBytes = 0;
2337
+ #retainedLineBytes = 0;
2338
+ #closed = false;
2339
+ #published = false;
2340
+ #error: Error | undefined;
2341
+
2342
+ constructor(storage: MemorySessionStorage, path: string) {
2343
+ this.#storage = storage;
2344
+ this.#path = path;
2345
+ }
2346
+
2347
+ #assertOpen(): void {
2348
+ if (this.#closed) throw new Error("Staged writer is closed");
2349
+ if (this.#error) throw this.#error;
2350
+ }
2351
+
2352
+ writeLine(bytes: Uint8Array): void {
2353
+ this.#assertOpen();
2354
+ if (bytes.byteLength > STAGED_WRITER_LINE_MAX_BYTES)
2355
+ throw new RangeError("Staged line exceeds the bounded maximum");
2356
+ if (
2357
+ this.#lines.length >= STAGED_MEMORY_WRITER_MAX_LINES ||
2358
+ this.#retainedLineBytes + bytes.byteLength + 1 > STAGED_MEMORY_WRITER_MAX_BYTES
2359
+ )
2360
+ throw new Error("staged_memory_capacity_exceeded");
2361
+ this.#lines.push(Buffer.from(bytes));
2362
+ this.#retainedLineBytes += bytes.byteLength + 1;
2363
+ }
2364
+
2365
+ seekToLine(ordinal: number): void {
2366
+ this.#assertOpen();
2367
+ if (ordinal < 0 || ordinal >= this.#lines.length) throw new RangeError("Line ordinal is not staged");
2368
+ }
2369
+
2370
+ patchLine(ordinal: number, bytes: Uint8Array): void {
2371
+ this.#assertOpen();
2372
+ if (ordinal < 0 || ordinal >= this.#lines.length) throw new RangeError("Line ordinal is not staged");
2373
+ const existing = this.#lines[ordinal];
2374
+ if (!existing) throw new RangeError("Line ordinal is not staged");
2375
+ if (bytes.byteLength === existing.byteLength) {
2376
+ this.#lines[ordinal] = Buffer.from(bytes);
2377
+ return;
2378
+ }
2379
+ const prior = this.#pendingPatches.get(ordinal);
2380
+ if (prior) {
2381
+ const nextPatchBytes = this.#pendingPatchBytes - prior.byteLength + bytes.byteLength;
2382
+ if (nextPatchBytes > STAGED_WRITER_PATCH_LIMIT_BYTES) {
2383
+ this.#error = new Error("staged_overlay_capacity_exceeded");
2384
+ throw this.#error;
2385
+ }
2386
+ this.#pendingPatches.set(ordinal, Buffer.from(bytes));
2387
+ this.#pendingPatchBytes = nextPatchBytes;
2388
+ return;
2389
+ }
2390
+ if (
2391
+ this.#pendingPatches.size >= STAGED_WRITER_PATCH_MAX_COUNT ||
2392
+ this.#pendingPatchBytes + bytes.byteLength > STAGED_WRITER_PATCH_LIMIT_BYTES
2393
+ ) {
2394
+ this.#error = new Error("staged_overlay_capacity_exceeded");
2395
+ throw this.#error;
2396
+ }
2397
+ this.#pendingPatches.set(ordinal, Buffer.from(bytes));
2398
+ this.#pendingPatchBytes += bytes.byteLength;
2399
+ }
2400
+
2401
+ flush(): void {
2402
+ this.#assertOpen();
2403
+ }
2404
+
2405
+ fsync(): void {
2406
+ this.#assertOpen();
2407
+ }
2408
+
2409
+ closeSync(): void {
2410
+ if (this.#closed) return;
2411
+ if (this.#error) throw this.#error;
2412
+ this.#closed = true;
2413
+ }
2414
+
2415
+ publishNoReplace(): void {
2416
+ if (this.#published) throw new Error("Staged writer already published");
2417
+ if (!this.#closed) throw new Error("Staged writer must be closed before publication");
2418
+ if (this.#error) throw this.#error;
2419
+ if (this.#storage.existsSync(this.#path)) throw new Error("destination_conflict");
2420
+ const selectedLines = this.#lines.map((line, index) => this.#pendingPatches.get(index) ?? line);
2421
+ const totalBytes = selectedLines.reduce((total, line) => total + line.byteLength + 1, 0);
2422
+ if (totalBytes > STAGED_MEMORY_WRITER_MAX_BYTES + STAGED_WRITER_PATCH_LIMIT_BYTES)
2423
+ throw new Error("staged_memory_capacity_exceeded");
2424
+ const content = Buffer.allocUnsafe(totalBytes);
2425
+ let offset = 0;
2426
+ for (const line of selectedLines) {
2427
+ Buffer.from(line).copy(content, offset);
2428
+ offset += line.byteLength;
2429
+ content[offset++] = 0x0a;
2430
+ }
2431
+ this.#lines.length = 0;
2432
+ this.#pendingPatches.clear();
2433
+ this.#storage.writeBytesOwnedSync(this.#path, content);
2434
+ this.#published = true;
2435
+ }
2436
+ }
1532
2437
 
1533
2438
  class MemorySessionStorageWriter implements SessionStorageWriter {
1534
2439
  #storage: MemorySessionStorage;
@@ -1539,15 +2444,22 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
1539
2444
  #onError: ((err: Error) => void) | undefined;
1540
2445
 
1541
2446
  #closeAdapter: SessionStorageWriterCloseAdapter | undefined;
2447
+ #bytes: Buffer;
2448
+ #length = 0;
1542
2449
 
1543
2450
  constructor(storage: MemorySessionStorage, path: string, options?: SessionStorageWriterOpenOptions) {
1544
2451
  this.#storage = storage;
1545
2452
  this.#path = path;
1546
2453
  this.#onError = options?.onError;
1547
2454
  this.#closeAdapter = options?.closeAdapter;
1548
- if ((options?.flags ?? "a") === "w") {
1549
- this.#storage.writeTextSync(path, "");
1550
- }
2455
+ const existing =
2456
+ options?.flags === "w" || !storage.existsSync(path)
2457
+ ? Buffer.alloc(0)
2458
+ : Buffer.from(storage.readBytesSync(path));
2459
+ this.#length = existing.byteLength;
2460
+ this.#bytes = Buffer.allocUnsafe(Math.max(existing.byteLength, 4096));
2461
+ existing.copy(this.#bytes);
2462
+ if (options?.flags === "w") this.#storage.writeBytesOwnedSync(path, this.#bytes.subarray(0, 0));
1551
2463
  }
1552
2464
 
1553
2465
  #recordError(err: unknown): Error {
@@ -1561,8 +2473,16 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
1561
2473
  if (this.#closeState !== "open") throw new Error("Writer closed");
1562
2474
  if (this.#error) throw this.#error;
1563
2475
  try {
1564
- const existing = this.#storage.existsSync(this.#path) ? this.#storage.readTextSync(this.#path) : "";
1565
- this.#storage.writeTextSync(this.#path, `${existing}${line}`);
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));
1566
2486
  } catch (err) {
1567
2487
  throw this.#recordError(err);
1568
2488
  }
@@ -1576,11 +2496,18 @@ class MemorySessionStorageWriter implements SessionStorageWriter {
1576
2496
  if (this.#closeState !== "open") throw new Error("Writer closed");
1577
2497
  if (this.#error) throw this.#error;
1578
2498
  }
1579
-
1580
- async fsync(): Promise<void> {
1581
- // No-op for in-memory storage
2499
+ fsyncSync(): void {
2500
+ if (this.#closeState !== "open") throw new Error("Writer closed");
2501
+ if (this.#error) throw this.#error;
2502
+ }
2503
+ statSync(): SessionStorageStat {
1582
2504
  if (this.#closeState !== "open") throw new Error("Writer closed");
1583
2505
  if (this.#error) throw this.#error;
2506
+ return this.#storage.statSync(this.#path);
2507
+ }
2508
+
2509
+ async fsync(): Promise<void> {
2510
+ this.fsyncSync();
1584
2511
  }
1585
2512
 
1586
2513
  closeSync(): void {
@@ -1651,6 +2578,15 @@ export class MemorySessionStorage implements SessionStorage {
1651
2578
  return this.#files.has(path);
1652
2579
  }
1653
2580
 
2581
+ writeBytesOwnedSync(path: string, content: Buffer): void {
2582
+ const existing = this.#files.get(path);
2583
+ this.#files.set(path, {
2584
+ content,
2585
+ mtimeMs: Date.now(),
2586
+ ino: existing?.ino ?? this.#nextInode++,
2587
+ });
2588
+ }
2589
+
1654
2590
  writeTextSync(path: string, content: string): void {
1655
2591
  const existing = this.#files.get(path);
1656
2592
  this.#files.set(path, {
@@ -1675,6 +2611,22 @@ export class MemorySessionStorage implements SessionStorage {
1675
2611
  if (!entry) throw new Error(`File not found: ${path}`);
1676
2612
  return { bytes: Buffer.from(entry.content), stat: this.#statFor(entry) };
1677
2613
  }
2614
+ /**
2615
+ * Bounded recorded-length read with descriptor identity validation: mirrors the
2616
+ * file backend's contract (dev/ino/nlink identity, exact `length` bytes present)
2617
+ * against the in-memory file model so parity tests can compare backends.
2618
+ */
2619
+ readRangeSync(path: string, start: number, length: number): SessionStorageRangeSnapshot {
2620
+ validateRangeReadBounds(start, length);
2621
+ const entry = this.#files.get(path);
2622
+ if (!entry) throw new Error(`File not found: ${path}`);
2623
+ if (entry.content.byteLength < start + length) throw new Error("range_not_present");
2624
+ return { bytes: Buffer.from(entry.content.subarray(start, start + length)), stat: this.#statFor(entry) };
2625
+ }
2626
+
2627
+ async readRange(path: string, start: number, length: number): Promise<SessionStorageRangeSnapshot> {
2628
+ return this.readRangeSync(path, start, length);
2629
+ }
1678
2630
 
1679
2631
  statSync(path: string): SessionStorageStat {
1680
2632
  const entry = this.#files.get(path);
@@ -1741,6 +2693,21 @@ export class MemorySessionStorage implements SessionStorage {
1741
2693
  this.#files.delete(path);
1742
2694
  }
1743
2695
 
2696
+ replaceExactSync(
2697
+ sourcePath: string,
2698
+ destinationPath: string,
2699
+ expected: SessionStorageExactReplacementExpectation,
2700
+ ): boolean {
2701
+ const source = this.#files.get(sourcePath);
2702
+ const destination = this.#files.get(destinationPath);
2703
+ if (!source || !destination) return false;
2704
+ if (!sameDescriptorIdentity(this.#statFor(destination), expected.stat)) return false;
2705
+ if (createHash("sha256").update(destination.content).digest("hex") !== expected.sha256) return false;
2706
+ this.#files.set(destinationPath, source);
2707
+ this.#files.delete(sourcePath);
2708
+ return true;
2709
+ }
2710
+
1744
2711
  unlink(path: string): Promise<void> {
1745
2712
  this.#files.delete(path);
1746
2713
  return Promise.resolve();
@@ -1752,6 +2719,10 @@ export class MemorySessionStorage implements SessionStorage {
1752
2719
 
1753
2720
  deleteSessionWithArtifacts(sessionPath: string): Promise<void> {
1754
2721
  this.#files.delete(sessionPath);
2722
+ for (const candidate of [...this.#files.keys()]) {
2723
+ if (candidate.startsWith(`${sessionPath}.spill.`) && isDerivedSessionMemoryFile(candidate))
2724
+ this.#files.delete(candidate);
2725
+ }
1755
2726
  return Promise.resolve();
1756
2727
  }
1757
2728
 
@@ -1810,6 +2781,10 @@ export class MemorySessionStorage implements SessionStorage {
1810
2781
  new SessionDeleteVerificationError("artifacts", "Artifact path exists but is not a directory"),
1811
2782
  );
1812
2783
  }
2784
+ for (const candidate of [...this.#files.keys()]) {
2785
+ if (candidate.startsWith(`${transcriptPath}.spill.`) && isDerivedSessionMemoryFile(candidate))
2786
+ this.#files.delete(candidate);
2787
+ }
1813
2788
  this.#files.delete(transcriptPath);
1814
2789
  return Promise.resolve({ kind: "deleted" });
1815
2790
  }
@@ -1817,4 +2792,152 @@ export class MemorySessionStorage implements SessionStorage {
1817
2792
  openWriter(path: string, options?: SessionStorageWriterOpenOptions): SessionStorageWriter {
1818
2793
  return new MemorySessionStorageWriter(this, path, options);
1819
2794
  }
2795
+
2796
+ openStagedWriter(path: string): StagedStreamingWriter {
2797
+ return new MemoryStagedStreamingWriter(this, path);
2798
+ }
2799
+ }
2800
+
2801
+ /**
2802
+ * Outcome of a disk-retention retirement attempt.
2803
+ *
2804
+ * `kept` means the transcript survived and nothing of the session was
2805
+ * destroyed, with the exact reason so `gjc gc --disk` can report it.
2806
+ * `cleanup_pending` means the delete authority already detached or removed the
2807
+ * session's artifact tree (or quarantined the transcript) before it stopped:
2808
+ * the record survives, the session does not, and a caller must NOT report that
2809
+ * as an ordinary keep.
2810
+ */
2811
+ export type SessionRetirementOutcome =
2812
+ | { kind: "retired" }
2813
+ | { kind: "kept"; reason: string }
2814
+ | { kind: "cleanup_pending"; reason: string };
2815
+
2816
+ /** A retirement that cleared every precondition the retention pass itself owns. */
2817
+ type SessionRetirementPlan =
2818
+ | { kind: "retirable"; target: VerifiedSessionDeleteTarget; artifactsPresent: boolean }
2819
+ | { kind: "kept"; reason: string };
2820
+
2821
+ /**
2822
+ * Evaluate the preconditions retirement owns — the transcript must be readable
2823
+ * and carry a usable session header — and bind the delete target to the exact
2824
+ * bytes just read. Nothing here mutates the store.
2825
+ */
2826
+ function planSessionRetirement(
2827
+ storage: FileSessionStorage,
2828
+ sessionsRoot: string,
2829
+ transcriptPath: string,
2830
+ ): SessionRetirementPlan {
2831
+ let snapshot: SessionStorageSnapshot;
2832
+ try {
2833
+ snapshot = storage.readSnapshotSync(transcriptPath);
2834
+ } catch (err) {
2835
+ return { kind: "kept", reason: `transcript_unreadable: ${toError(err).message}` };
2836
+ }
2837
+
2838
+ const header = parseFirstJsonlLine(snapshot.bytes);
2839
+ if (header?.type !== "session" || typeof header.id !== "string" || typeof header.cwd !== "string") {
2840
+ return { kind: "kept", reason: "transcript_header_unusable" };
2841
+ }
2842
+
2843
+ // The verified delete authority only unlinks a single-link transcript, so a
2844
+ // hard-linked one can never be retired. Refusing here keeps the dry run from
2845
+ // promising bytes prune cannot release, and — because this runs before the
2846
+ // artifact phase — keeps a doomed retirement from destroying the artifact
2847
+ // tree of a session whose transcript will survive anyway.
2848
+ if (snapshot.stat.nlink === undefined || snapshot.stat.nlink !== 1n) {
2849
+ return { kind: "kept", reason: "transcript_not_single_link" };
2850
+ }
2851
+
2852
+ const directory = path.dirname(transcriptPath);
2853
+ return {
2854
+ kind: "retirable",
2855
+ artifactsPresent: storage.existsSync(transcriptPath.slice(0, -".jsonl".length)),
2856
+ target: {
2857
+ sessionsRoot,
2858
+ transcriptPath,
2859
+ sessionId: header.id,
2860
+ cwd: header.cwd,
2861
+ transcriptIdentity: {
2862
+ dev: snapshot.stat.dev,
2863
+ ino: snapshot.stat.ino,
2864
+ nlink: snapshot.stat.nlink,
2865
+ size: snapshot.stat.size,
2866
+ mtimeNs: snapshot.stat.mtimeNs,
2867
+ sha256: createHash("sha256").update(snapshot.bytes).digest("hex"),
2868
+ },
2869
+ plannedArtifactsPath: path.join(directory, `.gjc-delete-gc-${randomUUID()}-artifacts`),
2870
+ plannedTranscriptPath: path.join(directory, `.gjc-delete-gc-${randomUUID()}-transcript`),
2871
+ },
2872
+ };
2873
+ }
2874
+
2875
+ /**
2876
+ * Non-mutating projection of {@link retireSessionTranscript}: would the
2877
+ * retention pass's own preconditions let this transcript be retired at all?
2878
+ *
2879
+ * `gjc gc --disk` runs it so a dry run reports the verdict a prune would reach
2880
+ * instead of promising bytes the delete authority will refuse to release. The
2881
+ * authority's verdict (containment, identity, artifact tree) is deliberately
2882
+ * not predicted here — it is re-derived against live state at delete time.
2883
+ */
2884
+ export function probeSessionRetirement(
2885
+ storage: FileSessionStorage,
2886
+ sessionsRoot: string,
2887
+ transcriptPath: string,
2888
+ ): { kind: "retirable" } | { kind: "kept"; reason: string } {
2889
+ const plan = planSessionRetirement(storage, sessionsRoot, transcriptPath);
2890
+ return plan.kind === "retirable" ? { kind: "retirable" } : plan;
2891
+ }
2892
+
2893
+ /**
2894
+ * A transcript that survives after its artifacts are already gone is not a
2895
+ * benign keep: the session is half-destroyed and the caller has to surface it.
2896
+ */
2897
+ function retirementIncomplete(artifactsRemoved: boolean, reason: string): SessionRetirementOutcome {
2898
+ return artifactsRemoved ? { kind: "cleanup_pending", reason } : { kind: "kept", reason };
2899
+ }
2900
+
2901
+ /**
2902
+ * Retire one managed session transcript through the verified hard-delete
2903
+ * authority.
2904
+ *
2905
+ * This is the only supported way for a retention pass to remove a transcript:
2906
+ * identity, containment, header id/cwd, artifact tree and parent directory are
2907
+ * all re-verified inside {@link FileSessionStorage.deleteSessionVerified}, and
2908
+ * anything ambiguous fails closed as `kept` rather than deleting bytes. The
2909
+ * caller owns the retention policy; this function owns nothing but the delete
2910
+ * authority.
2911
+ */
2912
+ export async function retireSessionTranscript(
2913
+ storage: FileSessionStorage,
2914
+ sessionsRoot: string,
2915
+ transcriptPath: string,
2916
+ ): Promise<SessionRetirementOutcome> {
2917
+ const plan = planSessionRetirement(storage, sessionsRoot, transcriptPath);
2918
+ if (plan.kind === "kept") return plan;
2919
+
2920
+ let artifactsRemoved = false;
2921
+ try {
2922
+ // Phase 1 removes the sibling artifact directory (or proves it absent);
2923
+ // phase 2 unlinks the transcript only against the same bound identity.
2924
+ const artifacts = await storage.deleteSessionVerified(plan.target);
2925
+ if (artifacts.kind === "deleted") return { kind: "retired" };
2926
+ if (artifacts.kind === "cleanup_pending") {
2927
+ return { kind: "cleanup_pending", reason: `cleanup_pending_${artifacts.phase}: ${artifacts.error.message}` };
2928
+ }
2929
+ // Phase 1 is durable: whatever artifact tree the session had is gone, so
2930
+ // every later refusal leaves a record without its artifacts.
2931
+ artifactsRemoved = plan.artifactsPresent;
2932
+ const deletion = await storage.deleteSessionVerified({ ...plan.target, artifactsRemoved: true });
2933
+ if (deletion.kind === "deleted") return { kind: "retired" };
2934
+ if (deletion.kind === "cleanup_pending") {
2935
+ return { kind: "cleanup_pending", reason: `cleanup_pending_${deletion.phase}: ${deletion.error.message}` };
2936
+ }
2937
+ return retirementIncomplete(artifactsRemoved, "transcript_not_deleted");
2938
+ } catch (err) {
2939
+ const error = toError(err);
2940
+ const kind = err instanceof SessionDeleteVerificationError ? err.kind : "error";
2941
+ return retirementIncomplete(artifactsRemoved, `verified_delete_rejected(${kind}): ${error.message}`);
2942
+ }
1820
2943
  }