@gajae-code/coding-agent 0.12.18 → 0.12.20

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 +26 -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/bus/telegram-daemon-contract.d.ts +4 -2
  25. package/dist/types/sdk/host/query/revision-store.d.ts +5 -0
  26. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  27. package/dist/types/sdk/lifecycle-session.d.ts +13 -2
  28. package/dist/types/sdk/prompt-failure.d.ts +13 -0
  29. package/dist/types/sdk/prompt-watchdog.d.ts +82 -0
  30. package/dist/types/sdk/session-reconnect.d.ts +20 -0
  31. package/dist/types/sdk/startup-capability.d.ts +1 -1
  32. package/dist/types/session/agent-session.d.ts +14 -4
  33. package/dist/types/session/blob-store.d.ts +32 -0
  34. package/dist/types/session/internal/managed-session-storage.d.ts +40 -2
  35. package/dist/types/session/internal/session-memory-sidecar.d.ts +881 -0
  36. package/dist/types/session/session-manager.d.ts +301 -27
  37. package/dist/types/session/session-storage.d.ts +170 -0
  38. package/dist/types/task/executor.d.ts +2 -1
  39. package/dist/types/tools/bash.d.ts +1 -0
  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/bus/telegram-daemon-contract.ts +4 -2
  77. package/src/sdk/bus/topic-registry.ts +7 -1
  78. package/src/sdk/cli/session-cli.ts +8 -1
  79. package/src/sdk/host/query/handlers.ts +1 -1
  80. package/src/sdk/host/query/revision-store.ts +48 -12
  81. package/src/sdk/host/session-runtime.test.ts +50 -0
  82. package/src/sdk/host/session-runtime.ts +12 -2
  83. package/src/sdk/lifecycle-session.ts +26 -3
  84. package/src/sdk/mcp/server.ts +6 -1
  85. package/src/sdk/prompt-failure.ts +42 -0
  86. package/src/sdk/prompt-watchdog.ts +162 -0
  87. package/src/sdk/session-reconnect.ts +43 -0
  88. package/src/sdk/startup-capability.ts +13 -4
  89. package/src/session/agent-session.ts +525 -222
  90. package/src/session/blob-store.ts +89 -0
  91. package/src/session/internal/managed-session-scope.ts +36 -26
  92. package/src/session/internal/managed-session-storage.ts +303 -32
  93. package/src/session/internal/session-memory-sidecar.ts +2160 -0
  94. package/src/session/session-manager.ts +7027 -394
  95. package/src/session/session-storage.ts +1134 -11
  96. package/src/task/executor.ts +17 -4
  97. package/src/tools/bash.ts +109 -7
  98. package/src/tools/find.ts +55 -26
  99. package/src/tools/todo-contract.ts +40 -8
  100. package/src/tui/output-block.ts +11 -1
@@ -1523,3 +1523,92 @@ export function resolveResidentImageDataSync(
1523
1523
  }
1524
1524
  return buffer.toString("base64");
1525
1525
  }
1526
+
1527
+ // =============================================================================
1528
+ // Canonical-store mark-and-sweep primitives (`gjc gc --disk`)
1529
+ // =============================================================================
1530
+ //
1531
+ // The resident-cache sweep above bounds *cache* directories. These primitives
1532
+ // expose the canonical `~/.gjc/agent/blobs` store to an out-of-process retention
1533
+ // pass so it can mark live hashes from surviving transcripts and sweep the rest.
1534
+ // They own no policy: the caller decides what "unreferenced" means.
1535
+
1536
+ /** One canonical blob file (`<blobsDir>/<sha256-hex>`) observed on disk. */
1537
+ export interface CanonicalBlobEntry {
1538
+ readonly hash: string;
1539
+ readonly path: string;
1540
+ readonly bytes: number;
1541
+ readonly mtimeMs: number;
1542
+ }
1543
+
1544
+ const CANONICAL_BLOB_NAME = /^[0-9a-f]{64}$/;
1545
+ const BLOB_REFERENCE = /blob:sha256:([0-9a-f]{64})/g;
1546
+
1547
+ /** Longest possible `blob:sha256:<hash>` reference, used to bound chunked scans. */
1548
+ export const BLOB_REFERENCE_MAX_LENGTH = BLOB_PREFIX.length + 64;
1549
+
1550
+ /** Collect every `blob:sha256:<hash>` reference that appears in `text`. */
1551
+ export function collectBlobReferences(text: string, into: Set<string>): void {
1552
+ for (const match of text.matchAll(BLOB_REFERENCE)) into.add(match[1]!);
1553
+ }
1554
+
1555
+ /**
1556
+ * List canonical blob files. Anything that is not a plain, owner-visible regular
1557
+ * file named after its own hash (temp files, symlinks, subdirectories) is
1558
+ * skipped: a sweep must never reason about entries it cannot classify.
1559
+ */
1560
+ export async function listCanonicalBlobs(dir: string): Promise<CanonicalBlobEntry[]> {
1561
+ let names: string[];
1562
+ try {
1563
+ names = await fsp.readdir(dir);
1564
+ } catch (error) {
1565
+ if (isEnoent(error)) return [];
1566
+ throw error;
1567
+ }
1568
+
1569
+ const entries: CanonicalBlobEntry[] = [];
1570
+ for (const name of names) {
1571
+ if (!CANONICAL_BLOB_NAME.test(name)) continue;
1572
+ const blobPath = path.join(dir, name);
1573
+ let stat: fs.Stats;
1574
+ try {
1575
+ stat = await fsp.lstat(blobPath);
1576
+ } catch {
1577
+ continue;
1578
+ }
1579
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) continue;
1580
+ entries.push({ hash: name, path: blobPath, bytes: stat.size, mtimeMs: stat.mtimeMs });
1581
+ }
1582
+ return entries;
1583
+ }
1584
+
1585
+ /**
1586
+ * Remove one canonical blob, fail-closed on identity drift. The entry captured
1587
+ * at scan time must still describe the file at unlink time (same inode, size and
1588
+ * mtime, still a single-link regular file), otherwise the blob is left in place.
1589
+ *
1590
+ * `failed` distinguishes an actual IO failure (which a caller should surface as
1591
+ * a failed reclaim) from a revalidation refusal (which is an ordinary KEEP).
1592
+ */
1593
+ export async function removeCanonicalBlob(
1594
+ entry: CanonicalBlobEntry,
1595
+ ): Promise<{ removed: true } | { removed: false; reason: string; failed?: true }> {
1596
+ let stat: fs.Stats;
1597
+ try {
1598
+ stat = await fsp.lstat(entry.path);
1599
+ } catch (error) {
1600
+ if (isEnoent(error)) return { removed: false, reason: "blob_disappeared" };
1601
+ return { removed: false, reason: `blob_unverifiable: ${String(error)}`, failed: true };
1602
+ }
1603
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) {
1604
+ return { removed: false, reason: "blob_not_plain_file" };
1605
+ }
1606
+ if (stat.size !== entry.bytes || stat.mtimeMs !== entry.mtimeMs) return { removed: false, reason: "blob_changed" };
1607
+ try {
1608
+ await fsp.unlink(entry.path);
1609
+ return { removed: true };
1610
+ } catch (error) {
1611
+ if (isEnoent(error)) return { removed: false, reason: "blob_disappeared" };
1612
+ return { removed: false, reason: `blob_unlink_failed: ${String(error)}`, failed: true };
1613
+ }
1614
+ }
@@ -37,6 +37,7 @@ import {
37
37
  copyManagedFileNoReplace,
38
38
  ensureManagedDirectory,
39
39
  fsyncManagedArtifactTree,
40
+ inspectManagedFileNoFollow,
40
41
  MANAGED_ARTIFACT_COPY_BATCH_SIZE,
41
42
  MANAGED_ARTIFACT_MAX_FILES,
42
43
  MANAGED_ARTIFACT_MAX_TOTAL_BYTES,
@@ -784,7 +785,7 @@ function matchesPreflightIdentity(candidate: ManagedCandidate, preflight: Candid
784
785
 
785
786
  function inspectCandidate(filePath: string, provenance: "v2" | "legacy"): ManagedCandidate | { code: string } {
786
787
  try {
787
- const snapshot = captureManagedFileNoFollow(filePath);
788
+ const snapshot = inspectManagedFileNoFollow(filePath, HEADER_MAX_BYTES);
788
789
  const lineEnd = snapshot.bytes.subarray(0, HEADER_MAX_BYTES).indexOf(0x0a);
789
790
  if (lineEnd < 0) return { code: "invalid_header" };
790
791
  const value: unknown = JSON.parse(snapshot.bytes.subarray(0, lineEnd).toString("utf8"));
@@ -816,7 +817,7 @@ function inspectCandidate(filePath: string, provenance: "v2" | "legacy"): Manage
816
817
  ...snapshot.identity,
817
818
  nlink: snapshot.identity.nlink,
818
819
  mtimeMs: Number(named.mtimeMs),
819
- sha256: createHash("sha256").update(snapshot.bytes).digest("hex"),
820
+ sha256: snapshot.identity.sha256,
820
821
  },
821
822
  };
822
823
  } catch (error) {
@@ -3376,32 +3377,37 @@ function receiptPair(scope: ManagedScope, candidate: ManagedCandidate): ManagedC
3376
3377
  const directory = path.join(managedInternalDirectory(scope), MANAGED_RECEIPTS_DIRECTORY);
3377
3378
  try {
3378
3379
  for (const name of fs.readdirSync(directory)) {
3379
- const pathname = path.join(directory, name);
3380
- const value: unknown = JSON.parse(captureManagedFileNoFollow(pathname).bytes.toString("utf8"));
3381
- if (!value || typeof value !== "object") continue;
3382
- const record = value as { source?: { path?: unknown }; destination?: { path?: unknown } };
3383
- const otherPath =
3384
- record.source?.path === candidate.path
3385
- ? record.destination?.path
3386
- : record.destination?.path === candidate.path
3387
- ? record.source?.path
3388
- : undefined;
3389
- if (typeof otherPath !== "string") continue;
3390
- const other = inspectCandidate(otherPath, candidate.provenance === "v2" ? "legacy" : "v2");
3391
- if (
3392
- "code" in other ||
3393
- !receiptMatches(
3394
- pathname,
3395
- candidate.provenance === "legacy" ? candidate : other,
3396
- candidate.provenance === "v2" ? candidate : other,
3397
- scope,
3380
+ try {
3381
+ const pathname = path.join(directory, name);
3382
+ const value: unknown = JSON.parse(captureManagedFileNoFollow(pathname).bytes.toString("utf8"));
3383
+ if (!value || typeof value !== "object") continue;
3384
+ const record = value as { source?: { path?: unknown }; destination?: { path?: unknown } };
3385
+ const otherPath =
3386
+ record.source?.path === candidate.path
3387
+ ? record.destination?.path
3388
+ : record.destination?.path === candidate.path
3389
+ ? record.source?.path
3390
+ : undefined;
3391
+ if (typeof otherPath !== "string") continue;
3392
+ const other = inspectCandidate(otherPath, candidate.provenance === "v2" ? "legacy" : "v2");
3393
+ if (
3394
+ "code" in other ||
3395
+ !receiptMatches(
3396
+ pathname,
3397
+ candidate.provenance === "legacy" ? candidate : other,
3398
+ candidate.provenance === "v2" ? candidate : other,
3399
+ scope,
3400
+ )
3398
3401
  )
3399
- )
3400
- continue;
3401
- return other;
3402
+ continue;
3403
+ return other;
3404
+ } catch {
3405
+ // Native cleanup residues, symlinks, and malformed files grant no authority;
3406
+ // continue to the deterministic committed receipt instead of aborting the scan.
3407
+ }
3402
3408
  }
3403
3409
  } catch {
3404
- /* no committed pair grants no shadow authority */
3410
+ /* a missing receipts directory grants no shadow authority */
3405
3411
  }
3406
3412
  return undefined;
3407
3413
  }
@@ -3857,8 +3863,12 @@ async function openManagedCandidateForWriteInternal(
3857
3863
  assertPublicationConsent,
3858
3864
  scopeRoot(scope),
3859
3865
  );
3866
+ assertPublicationConsent();
3860
3867
  } catch (error) {
3861
- if ((error as Error).message !== "destination_conflict") throw error;
3868
+ if ((error as Error).message !== "destination_conflict") {
3869
+ await removeStagedReceipts(scope, afterLock);
3870
+ throw error;
3871
+ }
3862
3872
  }
3863
3873
 
3864
3874
  revalidatePickerConsent(scope, afterLock, expectedIdentity);
@@ -2,14 +2,14 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as fsp from "node:fs/promises";
4
4
  import * as path from "node:path";
5
-
6
5
  import type {
7
6
  NativeDirectoryTreeSnapshot,
8
7
  NativeExactUnlinkResult,
9
8
  NativeOwnerOnlySecurityResult,
9
+ RecoveryFsIdentity,
10
10
  RecoveryFsRoot,
11
11
  } from "@gajae-code/natives";
12
-
12
+ import type { SessionStorageStat } from "../session-storage";
13
13
  import {
14
14
  classifyNativePublishOutcome,
15
15
  formatNativePublishDiagnostic,
@@ -208,6 +208,68 @@ export interface ManagedFileSnapshot {
208
208
  sha256: string;
209
209
  };
210
210
  }
211
+ /**
212
+ * Descriptor identity of one managed file object. Deliberately excludes the
213
+ * content sha256: append receipts must bind the post-operation object without
214
+ * re-reading the whole payload.
215
+ */
216
+ export interface ManagedFileIdentity {
217
+ dev: bigint;
218
+ ino: bigint;
219
+ nlink: bigint;
220
+ size: number;
221
+ mtimeNs: bigint;
222
+ ctimeNs: bigint;
223
+ }
224
+
225
+ /**
226
+ * Post-operation evidence for one managed append. `descriptor` is the
227
+ * `SessionStorageStat`-shaped snapshot of the retained/successor object captured
228
+ * AFTER the append has been synchronized (never a pathname `statSync`); `identity`
229
+ * carries the same fields as the object identity. The SessionManager feeds
230
+ * `descriptor` directly into the commit-marker snapshot.
231
+ */
232
+ export interface ManagedAppendReceipt {
233
+ descriptor: SessionStorageStat;
234
+ identity: ManagedFileIdentity;
235
+ }
236
+
237
+ export interface ManagedBoundedAppendExpectation {
238
+ readonly dev: string;
239
+ readonly ino: string;
240
+ readonly size: string;
241
+ readonly mtimeNs: string;
242
+ readonly ctimeNs: string;
243
+ readonly sha256: string;
244
+ }
245
+
246
+ function managedFileIdentityFromNative(identity: RecoveryFsIdentity): ManagedFileIdentity {
247
+ return {
248
+ dev: BigInt(identity.dev),
249
+ ino: BigInt(identity.ino),
250
+ nlink: BigInt(identity.nlink),
251
+ size: Number(identity.size),
252
+ mtimeNs: BigInt(identity.mtimeNs),
253
+ ctimeNs: BigInt(identity.ctimeNs),
254
+ };
255
+ }
256
+
257
+ function managedAppendReceiptFromIdentity(identity: ManagedFileIdentity): ManagedAppendReceipt {
258
+ return {
259
+ identity,
260
+ descriptor: {
261
+ dev: identity.dev,
262
+ ino: identity.ino,
263
+ nlink: identity.nlink,
264
+ size: identity.size,
265
+ mtimeMs: Number(identity.mtimeNs) / 1_000_000,
266
+ mtimeNs: identity.mtimeNs,
267
+ ctimeNs: identity.ctimeNs,
268
+ mtime: new Date(Number(identity.mtimeNs) / 1_000_000),
269
+ isFile: true,
270
+ },
271
+ };
272
+ }
211
273
 
212
274
  type SerializedReplacementIdentity = {
213
275
  dev: string;
@@ -1207,40 +1269,90 @@ export class ManagedSessionDescendantStore {
1207
1269
  this.#assertBound();
1208
1270
  }
1209
1271
 
1210
- appendSync(relativePath: string, bytes: Uint8Array): void {
1272
+ captureBoundedAppendExpectation(relativePath: string): ManagedBoundedAppendExpectation | undefined {
1273
+ this.#assertBound();
1274
+ if (!this.#authority) return undefined;
1275
+ const observed = this.#authority.stat(this.#relative(this.#resolve(relativePath)));
1276
+ if (!observed.ok || !observed.identity) return undefined;
1277
+ const sha256 = observed.identity.sha256;
1278
+ if (typeof sha256 !== "string" || !/^[0-9a-f]{64}$/i.test(sha256)) return undefined;
1279
+ return {
1280
+ dev: observed.identity.dev,
1281
+ ino: observed.identity.ino,
1282
+ size: observed.identity.size,
1283
+ mtimeNs: observed.identity.mtimeNs,
1284
+ ctimeNs: observed.identity.ctimeNs,
1285
+ sha256: sha256.toLowerCase(),
1286
+ };
1287
+ }
1288
+
1289
+ appendExpectedSync(
1290
+ relativePath: string,
1291
+ bytes: Uint8Array,
1292
+ expected: ManagedBoundedAppendExpectation,
1293
+ ): ManagedAppendReceipt {
1294
+ this.#beforeMutation();
1295
+ this.#assertBound();
1296
+ if (!this.#authority) throw new Error("managed_bounded_append_unavailable");
1297
+ const relative = this.#relative(this.#resolve(relativePath));
1298
+ const appended = this.#authority.appendManaged(
1299
+ relative,
1300
+ bytes,
1301
+ expected.dev,
1302
+ expected.ino,
1303
+ expected.size,
1304
+ expected.mtimeNs,
1305
+ expected.ctimeNs,
1306
+ expected.sha256,
1307
+ );
1308
+ if (!appended.ok) throw new Error(appended.code ?? "managed_append_failed");
1309
+ const stat = this.#authority.stat(relative);
1310
+ if (!stat.ok || !stat.identity) throw new Error(stat.code ?? "managed_append_identity_unavailable");
1311
+ this.#assertBound();
1312
+ return managedAppendReceiptFromIdentity(managedFileIdentityFromNative(stat.identity));
1313
+ }
1314
+
1315
+ appendSync(relativePath: string, bytes: Uint8Array): ManagedAppendReceipt {
1211
1316
  this.#beforeMutation();
1212
1317
  this.#assertBound();
1213
1318
  const resolved = this.#resolve(relativePath);
1214
- const existing = this.readExpected(relativePath);
1215
- if (!existing) throw new Error("managed_append_missing");
1216
1319
  if (this.#authority) {
1320
+ const relative = this.#relative(resolved);
1321
+ const observed = this.#authority.stat(relative);
1322
+ if (!observed.ok || !observed.identity) throw new Error(observed.code ?? "managed_append_missing");
1323
+ const nativeSha256 = observed.identity.sha256;
1324
+ const expectedSha256 =
1325
+ typeof nativeSha256 === "string" && /^[0-9a-f]{64}$/i.test(nativeSha256)
1326
+ ? nativeSha256.toLowerCase()
1327
+ : this.readExpected(relativePath)?.identity.sha256;
1328
+ if (!expectedSha256) throw new Error("managed_append_missing");
1217
1329
  const appended = this.#authority.appendManaged(
1218
- this.#relative(resolved),
1330
+ relative,
1219
1331
  bytes,
1220
- existing.identity.dev.toString(),
1221
- existing.identity.ino.toString(),
1222
- existing.identity.size.toString(),
1223
- existing.identity.mtimeNs.toString(),
1224
- existing.identity.ctimeNs.toString(),
1225
- existing.identity.sha256,
1332
+ observed.identity.dev,
1333
+ observed.identity.ino,
1334
+ observed.identity.size,
1335
+ observed.identity.mtimeNs,
1336
+ observed.identity.ctimeNs,
1337
+ expectedSha256,
1226
1338
  );
1227
1339
  if (!appended.ok) throw new Error(appended.code ?? "managed_append_failed");
1340
+ // Post-fsync descriptor capture through retained authority: the native append
1341
+ // is synchronous and durable, so the stat reflects the committed object.
1342
+ const stat = this.#authority.stat(relative);
1343
+ if (!stat.ok || !stat.identity) throw new Error(stat.code ?? "managed_append_identity_unavailable");
1344
+ const receipt = managedAppendReceiptFromIdentity(managedFileIdentityFromNative(stat.identity));
1228
1345
  this.#assertBound();
1229
- return;
1346
+ return receipt;
1230
1347
  }
1231
- // Darwin has no retained native root authority. Do not append in place:
1232
- // a short write can leave a malformed JSONL tail with no safe recovery
1233
- // boundary. Replace the exact captured file instead so the old transcript
1234
- // or the complete successor remains recoverable after every reported error.
1235
- replaceManagedFileSync(
1236
- resolved,
1237
- Buffer.concat([existing.bytes, Buffer.from(bytes)]),
1238
- this.#subtreeRoot,
1239
- this.#policy,
1240
- undefined,
1241
- existing.identity,
1242
- );
1348
+ // Darwin has no retained native root authority. Copy the predecessor through a
1349
+ // fixed 64 KiB buffer into an exact-replacement staging file, append the new
1350
+ // record bytes, and publish through the existing cleanup-receipt protocol.
1351
+ // The transcript-linear disk pass is unavoidable; resident memory is bounded.
1352
+ const successor = appendManagedFileStreamingSync(resolved, bytes, this.#subtreeRoot, this.#policy);
1353
+ const receipt = managedAppendReceiptFromIdentity(successor);
1243
1354
  this.#assertBound();
1355
+ return receipt;
1244
1356
  }
1245
1357
 
1246
1358
  #relative(resolved: string): string {
@@ -1349,6 +1461,23 @@ export class ManagedSessionDescendantStore {
1349
1461
  if (!replaced.ok) throw new Error(replaced.code ?? "managed_replace_failed");
1350
1462
  }
1351
1463
 
1464
+ /** Capture descriptor identity without copying file bytes when retained authority is available. */
1465
+ descriptorExpected(relativePath: string): SessionStorageStat | null {
1466
+ this.#assertBound();
1467
+ const resolved = this.#resolve(relativePath);
1468
+ if (!this.#authority) {
1469
+ const snapshot = this.readExpected(relativePath);
1470
+ return snapshot ? managedAppendReceiptFromIdentity(snapshot.identity).descriptor : null;
1471
+ }
1472
+ const stat = this.#authority.stat(this.#relative(resolved));
1473
+ if (!stat.ok) {
1474
+ if (stat.code === "not_found") return null;
1475
+ throw new Error(stat.code ?? "managed_stat_failed");
1476
+ }
1477
+ if (!stat.identity) throw new Error("managed_stat_identity_unavailable");
1478
+ return managedAppendReceiptFromIdentity(managedFileIdentityFromNative(stat.identity)).descriptor;
1479
+ }
1480
+
1352
1481
  /** Read an exact managed file without exposing its pathname as authority. */
1353
1482
  readExpected(relativePath: string): ManagedFileSnapshot | null {
1354
1483
  this.#assertBound();
@@ -1905,6 +2034,62 @@ function captureManagedFileNoFollowLimit(pathname: string, maxBytes?: number): M
1905
2034
  }
1906
2035
  }
1907
2036
 
2037
+ /** Streams a managed file once while retaining only a bounded header prefix and the full descriptor-bound digest. */
2038
+ export function inspectManagedFileNoFollow(pathname: string, prefixLimit: number): ManagedFileSnapshot {
2039
+ if (!Number.isSafeInteger(prefixLimit) || prefixLimit < 0) throw new Error("invalid_capture_limit");
2040
+ const fd = fs.openSync(pathname, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
2041
+ try {
2042
+ const before = fs.fstatSync(fd, { bigint: true });
2043
+ if (!before.isFile() || before.nlink > 1) throw new Error("source_changed");
2044
+ const prefix = Buffer.alloc(Math.min(Number(before.size), prefixLimit));
2045
+ const hash = createHash("sha256");
2046
+ const chunk = Buffer.alloc(64 * 1024);
2047
+ let offset = 0;
2048
+ for (;;) {
2049
+ const count = fs.readSync(fd, chunk, 0, chunk.byteLength, null);
2050
+ if (count === 0) break;
2051
+ hash.update(chunk.subarray(0, count));
2052
+ if (offset < prefix.byteLength) {
2053
+ const copied = Math.min(count, prefix.byteLength - offset);
2054
+ chunk.copy(prefix, offset, 0, copied);
2055
+ }
2056
+ offset += count;
2057
+ }
2058
+ if (offset !== Number(before.size)) throw new Error("source_changed");
2059
+ const after = fs.fstatSync(fd, { bigint: true });
2060
+ if (!sameIdentity(identity(before), identity(after))) throw new Error("source_changed");
2061
+ const named = fs.lstatSync(pathname, { bigint: true });
2062
+ if (!named.isFile() || named.isSymbolicLink() || !sameIdentity(identity(before), identity(named)))
2063
+ throw new Error("source_changed");
2064
+ return { bytes: prefix, identity: identity(before, hash.digest("hex")) };
2065
+ } finally {
2066
+ fs.closeSync(fd);
2067
+ }
2068
+ }
2069
+
2070
+ function captureManagedFileIdentityStreamingNoFollow(pathname: string): ManagedFileSnapshot["identity"] {
2071
+ const fd = fs.openSync(pathname, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
2072
+ try {
2073
+ const before = fs.fstatSync(fd, { bigint: true });
2074
+ if (!before.isFile() || before.nlink > 1) throw new Error("source_changed");
2075
+ const hash = createHash("sha256");
2076
+ const chunk = Buffer.alloc(64 * 1024);
2077
+ for (;;) {
2078
+ const count = fs.readSync(fd, chunk, 0, chunk.byteLength, null);
2079
+ if (count === 0) break;
2080
+ hash.update(chunk.subarray(0, count));
2081
+ }
2082
+ const after = fs.fstatSync(fd, { bigint: true });
2083
+ if (!sameIdentity(identity(before), identity(after))) throw new Error("source_changed");
2084
+ const named = fs.lstatSync(pathname, { bigint: true });
2085
+ if (!named.isFile() || named.isSymbolicLink() || !sameIdentity(identity(before), identity(named)))
2086
+ throw new Error("source_changed");
2087
+ return identity(before, hash.digest("hex"));
2088
+ } finally {
2089
+ fs.closeSync(fd);
2090
+ }
2091
+ }
2092
+
1908
2093
  /** Atomically publishes bytes without replacing an existing destination. */
1909
2094
  export async function publishManagedFileNoReplace(
1910
2095
  destination: string,
@@ -2058,9 +2243,9 @@ export function publishManagedFileNoReplaceSync(
2058
2243
  }
2059
2244
 
2060
2245
  /** Replace one managed regular file while retaining the secured staging fd through publication. */
2061
- export function replaceManagedFileSync(
2246
+ function replaceManagedFileGeneratedSync(
2062
2247
  destination: string,
2063
- bytes: Uint8Array,
2248
+ writeContent: (fd: number) => { size: number; sha256: string },
2064
2249
  root: ManagedDirectoryRoot,
2065
2250
  policy: ManagedSessionSecurityPolicy = "default",
2066
2251
  assertFence?: () => void,
@@ -2089,8 +2274,7 @@ export function replaceManagedFileSync(
2089
2274
  0o600,
2090
2275
  );
2091
2276
  secureFileDescriptor(staging, fd, "apply");
2092
- let offset = 0;
2093
- while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
2277
+ const generated = writeContent(fd);
2094
2278
  fs.fsyncSync(fd);
2095
2279
  secureFileDescriptor(staging, fd, "verify");
2096
2280
  const staged = fs.fstatSync(fd, { bigint: true });
@@ -2103,7 +2287,8 @@ export function replaceManagedFileSync(
2103
2287
  assertFence?.();
2104
2288
  if (expectedDestination) {
2105
2289
  const parentIdentity = fs.lstatSync(parent, { bigint: true });
2106
- const successor = identity(staged, createHash("sha256").update(bytes).digest("hex"));
2290
+ if (Number(staged.size) !== generated.size) throw new Error("managed_replace_generated_size_changed");
2291
+ const successor = identity(staged, generated.sha256);
2107
2292
  const receiptStagingPath = path.join(parent, `.gjc-replace-receipt-pending-${randomUUID()}.json`);
2108
2293
  preserveStaging = true;
2109
2294
  const publishedReceiptIdentity = publishManagedFileNoReplaceSync(
@@ -2164,8 +2349,8 @@ export function replaceManagedFileSync(
2164
2349
  },
2165
2350
  );
2166
2351
  if (!replaced.ok) throw new ManagedReplaceError(replaced, receiptCleanup.path);
2167
- const named = captureManagedFileNoFollow(destination);
2168
- if (!sameReplacementIdentity(named.identity, successor) || named.identity.sha256 !== successor.sha256)
2352
+ const named = captureManagedFileIdentityStreamingNoFollow(destination);
2353
+ if (!sameReplacementIdentity(named, successor) || named.sha256 !== successor.sha256)
2169
2354
  throw new Error("destination_identity_changed");
2170
2355
  fsyncDirectory(parent);
2171
2356
  } else fs.renameSync(staging, destination);
@@ -2229,6 +2414,92 @@ export function replaceManagedFileSync(
2229
2414
  if (failure !== undefined) throw failure;
2230
2415
  }
2231
2416
 
2417
+ export function replaceManagedFileSync(
2418
+ destination: string,
2419
+ bytes: Uint8Array,
2420
+ root: ManagedDirectoryRoot,
2421
+ policy: ManagedSessionSecurityPolicy = "default",
2422
+ assertFence?: () => void,
2423
+ expectedDestination?: ManagedFileSnapshot["identity"],
2424
+ ): void {
2425
+ replaceManagedFileGeneratedSync(
2426
+ destination,
2427
+ fd => {
2428
+ const hash = createHash("sha256");
2429
+ let offset = 0;
2430
+ while (offset < bytes.byteLength) {
2431
+ const written = fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
2432
+ if (written === 0) throw new Error("managed_replace_short_write");
2433
+ hash.update(bytes.subarray(offset, offset + written));
2434
+ offset += written;
2435
+ }
2436
+ return { size: offset, sha256: hash.digest("hex") };
2437
+ },
2438
+ root,
2439
+ policy,
2440
+ assertFence,
2441
+ expectedDestination,
2442
+ );
2443
+ }
2444
+
2445
+ function appendManagedFileStreamingSync(
2446
+ destination: string,
2447
+ appendedBytes: Uint8Array,
2448
+ root: ManagedDirectoryRoot,
2449
+ policy: ManagedSessionSecurityPolicy,
2450
+ ): ManagedFileSnapshot["identity"] {
2451
+ const predecessor = captureManagedFileIdentityStreamingNoFollow(destination);
2452
+ replaceManagedFileGeneratedSync(
2453
+ destination,
2454
+ stagingFd => {
2455
+ const sourceFd = fs.openSync(destination, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
2456
+ try {
2457
+ const before = fs.fstatSync(sourceFd, { bigint: true });
2458
+ if (!sameReplacementIdentity(identity(before, predecessor.sha256), predecessor))
2459
+ throw new Error("managed_replace_identity_mismatch");
2460
+ const hash = createHash("sha256");
2461
+ const chunk = Buffer.alloc(64 * 1024);
2462
+ let total = 0;
2463
+ for (;;) {
2464
+ const count = fs.readSync(sourceFd, chunk, 0, chunk.byteLength, null);
2465
+ if (count === 0) break;
2466
+ let written = 0;
2467
+ while (written < count) {
2468
+ const amount = fs.writeSync(stagingFd, chunk, written, count - written);
2469
+ if (amount === 0) throw new Error("managed_replace_short_write");
2470
+ written += amount;
2471
+ }
2472
+ hash.update(chunk.subarray(0, count));
2473
+ total += count;
2474
+ }
2475
+ const after = fs.fstatSync(sourceFd, { bigint: true });
2476
+ const named = fs.lstatSync(destination, { bigint: true });
2477
+ if (
2478
+ !sameReplacementIdentity(identity(after, predecessor.sha256), predecessor) ||
2479
+ !sameReplacementIdentity(identity(named, predecessor.sha256), predecessor)
2480
+ )
2481
+ throw new Error("managed_replace_identity_mismatch");
2482
+ if (hash.copy().digest("hex") !== predecessor.sha256) throw new Error("managed_replace_identity_mismatch");
2483
+ let appended = 0;
2484
+ while (appended < appendedBytes.byteLength) {
2485
+ const amount = fs.writeSync(stagingFd, appendedBytes, appended, appendedBytes.byteLength - appended);
2486
+ if (amount === 0) throw new Error("managed_replace_short_write");
2487
+ hash.update(appendedBytes.subarray(appended, appended + amount));
2488
+ appended += amount;
2489
+ }
2490
+ return { size: total + appended, sha256: hash.digest("hex") };
2491
+ } finally {
2492
+ fs.closeSync(sourceFd);
2493
+ }
2494
+ },
2495
+ root,
2496
+ policy,
2497
+ undefined,
2498
+ predecessor,
2499
+ );
2500
+ return captureManagedFileIdentityStreamingNoFollow(destination);
2501
+ }
2502
+
2232
2503
  export async function replaceManagedFile(
2233
2504
  destination: string,
2234
2505
  bytes: Uint8Array,