@gajae-code/coding-agent 0.12.8 → 0.12.11

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 (51) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/types/cli/notify-cli.d.ts +1 -0
  3. package/dist/types/commands/sdk.d.ts +23 -0
  4. package/dist/types/discovery/agents-md.d.ts +11 -1
  5. package/dist/types/modes/types.d.ts +6 -0
  6. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -1
  7. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  8. package/dist/types/sdk/bus/notification-service.d.ts +23 -0
  9. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -3
  10. package/dist/types/session/internal/managed-session-storage.d.ts +20 -2
  11. package/dist/types/session/internal/native-publish-outcome.d.ts +2 -2
  12. package/dist/types/session/session-manager.d.ts +17 -0
  13. package/dist/types/session/session-storage.d.ts +1 -0
  14. package/dist/types/system-prompt.d.ts +12 -1
  15. package/dist/types/tools/composer-bash-policy.d.ts +9 -1
  16. package/dist/types/tools/computer.d.ts +35 -2
  17. package/dist/types/tools/todo-write.d.ts +2 -0
  18. package/package.json +8 -8
  19. package/scripts/verify-gjc-sdk-canonicalization.ts +14 -1
  20. package/src/cli/notify-cli.ts +12 -6
  21. package/src/commands/sdk.ts +54 -0
  22. package/src/config/model-profiles.ts +8 -0
  23. package/src/config/model-registry.ts +4 -1
  24. package/src/discovery/agents-md.ts +76 -19
  25. package/src/gjc-runtime/psmux-detect.ts +12 -16
  26. package/src/gjc-runtime/tmux-sessions.ts +8 -1
  27. package/src/internal-urls/docs-index.generated.ts +6 -5
  28. package/src/modes/components/extensions/inspector-panel.ts +9 -5
  29. package/src/modes/controllers/selector-controller.ts +61 -43
  30. package/src/modes/print-mode.ts +3 -0
  31. package/src/modes/types.ts +6 -0
  32. package/src/prompts/system/system-prompt.md +1 -0
  33. package/src/prompts/tools/computer.md +6 -1
  34. package/src/sdk/bus/chat-daemon-control.ts +5 -3
  35. package/src/sdk/bus/daemon-paths.ts +2 -0
  36. package/src/sdk/bus/notification-service.ts +140 -7
  37. package/src/sdk/bus/telegram-daemon-contract.ts +4 -3
  38. package/src/sdk/session.ts +24 -7
  39. package/src/session/agent-session.ts +176 -34
  40. package/src/session/internal/managed-session-storage.ts +280 -248
  41. package/src/session/internal/native-publish-outcome.ts +12 -2
  42. package/src/session/session-manager.ts +140 -6
  43. package/src/session/session-storage.ts +3 -0
  44. package/src/slash-commands/builtin-registry.ts +35 -11
  45. package/src/system-prompt.ts +28 -15
  46. package/src/tools/bash.ts +3 -1
  47. package/src/tools/composer-bash-policy.ts +21 -5
  48. package/src/tools/computer.ts +322 -45
  49. package/src/tools/todo-write.ts +29 -11
  50. package/src/utils/command-args.ts +19 -30
  51. package/src/utils/lang-from-path.ts +2 -4
@@ -7,9 +7,10 @@ import {
7
7
  applyOwnerOnlyPathSecurity,
8
8
  exactRemoveDirectoryTree,
9
9
  exactReplacePath,
10
- exactRestore,
11
10
  exactUnlink,
11
+ linkNoReplacePath,
12
12
  type NativeDirectoryTreeSnapshot,
13
+ type NativeExactUnlinkResult,
13
14
  type NativeOwnerOnlySecurityResult,
14
15
  openRecoveryFsRoot,
15
16
  type RecoveryFsRoot,
@@ -54,6 +55,28 @@ export class ManagedPublishError extends Error {
54
55
  this.diagnostic = formatNativePublishDiagnostic(outcome);
55
56
  }
56
57
  }
58
+ export class ManagedReplaceError extends Error {
59
+ readonly code: string;
60
+ readonly cleanupReceiptPath: string;
61
+ readonly detachedPath: string | undefined;
62
+ readonly retainedSuccessorPath: string | undefined;
63
+ readonly retainedPlaceholderPath: string | undefined;
64
+ readonly retainedUnknownPath: string | undefined;
65
+ readonly replacementCause: unknown;
66
+
67
+ constructor(result: NativeExactUnlinkResult, cleanupReceiptPath: string, replacementCause?: unknown) {
68
+ const code = result.code ?? "unknown";
69
+ super(`managed_replace_failed:${code}`, replacementCause === undefined ? undefined : { cause: replacementCause });
70
+ this.name = "ManagedReplaceError";
71
+ this.code = code;
72
+ this.cleanupReceiptPath = cleanupReceiptPath;
73
+ this.detachedPath = result.detachedPath;
74
+ this.retainedSuccessorPath = result.retainedSuccessorPath;
75
+ this.retainedPlaceholderPath = result.retainedPlaceholderPath;
76
+ this.retainedUnknownPath = result.retainedUnknownPath;
77
+ this.replacementCause = replacementCause;
78
+ }
79
+ }
57
80
 
58
81
  function publishFailure(outcome: NativePublishOutcome): ManagedPublishError {
59
82
  const classification =
@@ -73,6 +96,17 @@ function publishFailure(outcome: NativePublishOutcome): ManagedPublishError {
73
96
  return new ManagedPublishError(classification, outcome);
74
97
  }
75
98
 
99
+ function exactUnlinkCompleted(result: NativeExactUnlinkResult): boolean {
100
+ return (
101
+ result.ok ||
102
+ (result.code === "cleanup_pending" &&
103
+ result.payloadDurable === true &&
104
+ result.detachedPath !== undefined &&
105
+ result.retainedSuccessorPath === undefined &&
106
+ result.retainedUnknownPath === undefined)
107
+ );
108
+ }
109
+
76
110
  /** A same-filesystem rename updates the moved root's ctime but no other tree identity. */
77
111
  function sameDirectoryTreeSnapshotAfterRename(
78
112
  left: NativeDirectoryTreeSnapshot,
@@ -114,6 +148,21 @@ export function mayCleanManagedTreeStaging(error: unknown): boolean {
114
148
  return !(error instanceof ManagedTreeMoveOutcomeError) || error.stagingCleanupSafe;
115
149
  }
116
150
 
151
+ /**
152
+ * A filesystem that implements no `renameat2` rename flag at all rejects the no-replace publish
153
+ * before mutating anything: NFS answers `EINVAL` (`invalid_request`) and kernels older than 3.15
154
+ * answer `ENOSYS` (`atomic_unavailable`). Only those two pre-mutation outcomes authorize the
155
+ * `linkat` stand-in; every other failure describes a publish that was actually attempted, and
156
+ * retrying it under a different primitive could publish twice.
157
+ */
158
+ export function renameFlagsUnsupported(outcome: NativePublishOutcome): boolean {
159
+ return (
160
+ !outcome.ok &&
161
+ mayCleanCurrentStaging(outcome) &&
162
+ (outcome.reason === "atomic_unavailable" || outcome.reason === "invalid_request")
163
+ );
164
+ }
165
+
117
166
  export type ManagedSessionSecurityPolicy = "default" | "windows-existing-verify-first";
118
167
 
119
168
  export type ManagedStorageFailure =
@@ -150,27 +199,69 @@ export interface ManagedFileSnapshot {
150
199
  };
151
200
  }
152
201
 
202
+ type SerializedReplacementIdentity = {
203
+ dev: string;
204
+ ino: string;
205
+ nlink: string;
206
+ size: string;
207
+ mtimeNs: string;
208
+ ctimeNs: string;
209
+ sha256: string;
210
+ };
211
+
153
212
  type ReplacementCleanupReceipt = {
154
- version: 1;
155
- predecessor: string;
156
- successor: string;
157
- identity: {
158
- dev: string;
159
- ino: string;
160
- nlink: string;
161
- size: string;
162
- mtimeNs: string;
163
- ctimeNs: string;
164
- sha256: string;
165
- };
213
+ version: 3;
214
+ staging: string;
215
+ destination: string;
216
+ successor: SerializedReplacementIdentity;
217
+ predecessor: SerializedReplacementIdentity;
166
218
  };
167
219
 
168
220
  const U64_MAX = 18_446_744_073_709_551_615n;
169
221
 
170
- function parseCanonicalU64(value: unknown): bigint | undefined {
171
- if (typeof value !== "string" || !/^(0|[1-9][0-9]*)$/.test(value)) return undefined;
172
- const parsed = BigInt(value);
173
- return parsed <= U64_MAX ? parsed : undefined;
222
+ function replacementCleanupReceiptBinding(
223
+ name: string,
224
+ ): { predecessor: { dev: bigint; ino: bigint }; receipt: { dev: bigint; ino: bigint } } | undefined {
225
+ const match =
226
+ /^\.gjc-replace-cleanup-(0|[1-9a-f][0-9a-f]*)-(0|[1-9a-f][0-9a-f]*)-receipt-(0|[1-9a-f][0-9a-f]*)-(0|[1-9a-f][0-9a-f]*)\.json$/.exec(
227
+ name,
228
+ );
229
+ if (!match?.[1] || !match[2] || !match[3] || !match[4]) return undefined;
230
+ const predecessor = { dev: BigInt(`0x${match[1]}`), ino: BigInt(`0x${match[2]}`) };
231
+ const receipt = { dev: BigInt(`0x${match[3]}`), ino: BigInt(`0x${match[4]}`) };
232
+ return predecessor.dev <= U64_MAX && predecessor.ino <= U64_MAX && receipt.dev <= U64_MAX && receipt.ino <= U64_MAX
233
+ ? { predecessor, receipt }
234
+ : undefined;
235
+ }
236
+
237
+ function serializeReplacementIdentity(identity: ManagedFileSnapshot["identity"]): SerializedReplacementIdentity {
238
+ return {
239
+ dev: identity.dev.toString(),
240
+ ino: identity.ino.toString(),
241
+ nlink: identity.nlink.toString(),
242
+ size: String(identity.size),
243
+ mtimeNs: identity.mtimeNs.toString(),
244
+ ctimeNs: identity.ctimeNs.toString(),
245
+ sha256: identity.sha256,
246
+ };
247
+ }
248
+
249
+ function replacementReceiptPath(
250
+ baseDir: string,
251
+ predecessor: ManagedFileSnapshot["identity"],
252
+ receipt: ManagedFileSnapshot["identity"],
253
+ ): string {
254
+ return path.join(
255
+ baseDir,
256
+ `.gjc-replace-cleanup-${predecessor.dev.toString(16)}-${predecessor.ino.toString(16)}-receipt-${receipt.dev.toString(16)}-${receipt.ino.toString(16)}.json`,
257
+ );
258
+ }
259
+
260
+ function replacementReceiptRetirementName(
261
+ receipt: ManagedFileSnapshot["identity"],
262
+ predecessor: ManagedFileSnapshot["identity"],
263
+ ): string {
264
+ return `.gjc-receipt-remove-${receipt.dev.toString(16)}-${receipt.ino.toString(16)}-${predecessor.dev.toString(16)}-${predecessor.ino.toString(16)}`;
174
265
  }
175
266
 
176
267
  const ACL_FAILURE_CODES = new Set(["acl_denied", "acl_io_error", "acl_present", "acl_malformed", "acl_unknown"]);
@@ -694,72 +785,44 @@ export class ManagedSessionDescendantStore {
694
785
  }
695
786
  }
696
787
 
788
+ #detachReplacementCleanupReceipt(receiptPath: string): void {
789
+ const binding = replacementCleanupReceiptBinding(path.basename(receiptPath));
790
+ if (!binding) throw new Error("managed_replace_cleanup_receipt_invalid");
791
+ const receipt = captureManagedFileNoFollow(receiptPath);
792
+ if (receipt.identity.dev !== binding.receipt.dev || receipt.identity.ino !== binding.receipt.ino)
793
+ throw new Error("managed_replace_cleanup_receipt_invalid");
794
+ const predecessor = binding.predecessor;
795
+ const quarantineName = `.gjc-receipt-remove-${receipt.identity.dev.toString(16)}-${receipt.identity.ino.toString(16)}-${predecessor.dev.toString(16)}-${predecessor.ino.toString(16)}`;
796
+ const detached = exactUnlink(receiptPath, {
797
+ dev: receipt.identity.dev,
798
+ ino: receipt.identity.ino,
799
+ nlink: receipt.identity.nlink,
800
+ parentDev: this.#subtreeRoot.dev,
801
+ parentIno: this.#subtreeRoot.ino,
802
+ size: BigInt(receipt.identity.size),
803
+ mtimeNs: receipt.identity.mtimeNs,
804
+ sha256: receipt.identity.sha256,
805
+ detachOnly: true,
806
+ quarantineName,
807
+ });
808
+ if (
809
+ (!detached.ok && detached.code !== "cleanup_pending") ||
810
+ detached.detachedPath !== path.join(this.#baseDir, quarantineName) ||
811
+ detached.retainedSuccessorPath ||
812
+ detached.retainedUnknownPath
813
+ )
814
+ throw new Error(`managed_replace_receipt_cleanup_pending:${detached.code ?? "unknown"}`);
815
+ fsyncDirectory(this.#baseDir);
816
+ }
817
+
697
818
  #reconcileReplacementCleanupReceipts(): void {
698
819
  if (this.#reconcilingReplacementCleanup) return;
699
820
  this.#reconcilingReplacementCleanup = true;
700
821
  try {
701
822
  for (const name of fs.readdirSync(this.#baseDir)) {
702
- const match = /^\.gjc-replace-cleanup-([0-9a-f]+)-([0-9a-f]+)\.json$/.exec(name);
703
- if (!match) continue;
704
- const receiptPath = path.join(this.#baseDir, name);
705
- const parsed = JSON.parse(
706
- captureManagedFileNoFollow(receiptPath).bytes.toString("utf8"),
707
- ) as Partial<ReplacementCleanupReceipt>;
708
- const identity = parsed.identity;
709
- const dev = parseCanonicalU64(identity?.dev);
710
- const ino = parseCanonicalU64(identity?.ino);
711
- const nlink = parseCanonicalU64(identity?.nlink);
712
- const size = parseCanonicalU64(identity?.size);
713
- const mtimeNs = parseCanonicalU64(identity?.mtimeNs);
714
- const ctimeNs = parseCanonicalU64(identity?.ctimeNs);
715
- const expectedPredecessor =
716
- dev !== undefined && ino !== undefined
717
- ? path.join(this.#baseDir, `.gjc-exact-replace-destination-${dev.toString(16)}-${ino.toString(16)}`)
718
- : undefined;
719
- if (
720
- parsed.version !== 1 ||
721
- dev === undefined ||
722
- ino === undefined ||
723
- nlink === undefined ||
724
- size === undefined ||
725
- mtimeNs === undefined ||
726
- ctimeNs === undefined ||
727
- typeof identity?.sha256 !== "string" ||
728
- !match[1] ||
729
- !match[2] ||
730
- BigInt(`0x${match[1]}`) !== dev ||
731
- BigInt(`0x${match[2]}`) !== ino ||
732
- typeof parsed.predecessor !== "string" ||
733
- path.resolve(parsed.predecessor) !== expectedPredecessor ||
734
- typeof parsed.successor !== "string" ||
735
- path.dirname(path.resolve(parsed.successor)) !== this.#baseDir
736
- )
737
- throw new Error("managed_replace_cleanup_receipt_invalid");
738
- const retired = exactUnlink(expectedPredecessor, {
739
- dev,
740
- ino,
741
- nlink,
742
- parentDev: this.#subtreeRoot.dev,
743
- parentIno: this.#subtreeRoot.ino,
744
- size,
745
- mtimeNs,
746
- sha256: identity.sha256,
747
- quarantineName: `.gjc-replace-retry-${dev.toString(16)}-${ino.toString(16)}`,
748
- });
749
- if (!retired.ok) throw new Error(`managed_replace_cleanup_pending:${retired.code ?? "unknown"}`);
750
- const receipt = captureManagedFileNoFollow(receiptPath);
751
- const removed = exactUnlink(receiptPath, {
752
- dev: receipt.identity.dev,
753
- ino: receipt.identity.ino,
754
- nlink: receipt.identity.nlink,
755
- parentDev: this.#subtreeRoot.dev,
756
- parentIno: this.#subtreeRoot.ino,
757
- size: BigInt(receipt.identity.size),
758
- mtimeNs: receipt.identity.mtimeNs,
759
- sha256: receipt.identity.sha256,
760
- quarantineName: `.gjc-receipt-remove-${receipt.identity.dev.toString(16)}-${receipt.identity.ino.toString(16)}`,
761
- });
762
- if (!removed.ok) throw new Error(`managed_replace_receipt_cleanup_pending:${removed.code ?? "unknown"}`);
823
+ if (!name.startsWith(".gjc-replace-cleanup-")) continue;
824
+ if (!replacementCleanupReceiptBinding(name)) throw new Error("managed_replace_cleanup_receipt_invalid");
825
+ this.#detachReplacementCleanupReceipt(path.join(this.#baseDir, name));
763
826
  }
764
827
  } finally {
765
828
  this.#reconcilingReplacementCleanup = false;
@@ -845,21 +908,7 @@ export class ManagedSessionDescendantStore {
845
908
  const current = captureManagedFileNoFollow(resolved);
846
909
  if (!sameIdentity(current.identity, expected.identity) || current.identity.sha256 !== expected.identity.sha256)
847
910
  throw new Error("managed_replace_identity_mismatch");
848
- replaceManagedFileSync(
849
- resolved,
850
- bytes,
851
- this.#subtreeRoot,
852
- this.#policy,
853
- () => {
854
- const currentAtCommit = captureManagedFileNoFollow(resolved);
855
- if (
856
- !sameIdentity(currentAtCommit.identity, expected.identity) ||
857
- currentAtCommit.identity.sha256 !== expected.identity.sha256
858
- )
859
- throw new Error("managed_replace_identity_mismatch");
860
- },
861
- expected.identity,
862
- );
911
+ replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy, undefined, expected.identity);
863
912
  return;
864
913
  }
865
914
  const replaced = (this.#authority as RecoveryFsRoot & RetainedManagedReplacer).replaceManaged(
@@ -898,7 +947,7 @@ export class ManagedSessionDescendantStore {
898
947
  this.#beforeMutation();
899
948
  this.#assertBound();
900
949
  const resolved = this.#resolve(relativePath);
901
- let existing = this.readExpected(relativePath);
950
+ const existing = this.readExpected(relativePath);
902
951
  if (!existing) throw new Error("managed_append_missing");
903
952
  if (this.#authority) {
904
953
  const appended = this.#authority.appendManaged(
@@ -915,52 +964,18 @@ export class ManagedSessionDescendantStore {
915
964
  this.#assertBound();
916
965
  return;
917
966
  }
918
- // On Darwin, the first write-append open can change only ctime (e.g. com.apple.provenance)
919
- // while leaving dev/ino/size/mtime/content intact. Allow one bounded refresh+retry for that
920
- // transition only; any other identity change or a second ctime transition stays fail-closed.
921
- // See https://github.com/Yeachan-Heo/gajae-code/issues/2944
922
- let fd: number | undefined;
923
- let darwinCtimeRefreshConsumed = false;
924
- try {
925
- for (;;) {
926
- fd = fs.openSync(resolved, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_NOFOLLOW);
927
- const before = identity(fs.fstatSync(fd, { bigint: true }));
928
- if (!sameIdentity(before, existing.identity)) {
929
- const allowDarwinCtimeRefresh =
930
- process.platform === "darwin" &&
931
- !darwinCtimeRefreshConsumed &&
932
- isCtimeOnlyIdentityMismatch(before, existing.identity);
933
- if (!allowDarwinCtimeRefresh) throw new Error("identity_mismatch");
934
- fs.closeSync(fd);
935
- fd = undefined;
936
- darwinCtimeRefreshConsumed = true;
937
- const original = existing;
938
- const refreshed = this.readExpected(relativePath);
939
- if (
940
- !refreshed ||
941
- !sameStableIdentityIgnoringCtime(refreshed.identity, original.identity) ||
942
- refreshed.identity.sha256 !== original.identity.sha256 ||
943
- !refreshed.bytes.equals(original.bytes)
944
- ) {
945
- throw new Error("identity_mismatch");
946
- }
947
- existing = refreshed;
948
- continue;
949
- }
950
- secureFileDescriptor(resolved, fd, "verify");
951
- let offset = 0;
952
- while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
953
- fs.fsyncSync(fd);
954
- secureFileDescriptor(resolved, fd, "verify");
955
- const after = identity(fs.fstatSync(fd, { bigint: true }));
956
- const named = identity(fs.lstatSync(resolved, { bigint: true }));
957
- if (!sameIdentity(after, named) || after.dev !== before.dev || after.ino !== before.ino)
958
- throw new Error("identity_mismatch");
959
- break;
960
- }
961
- } finally {
962
- if (fd !== undefined) fs.closeSync(fd);
963
- }
967
+ // Darwin has no retained native root authority. Do not append in place:
968
+ // a short write can leave a malformed JSONL tail with no safe recovery
969
+ // boundary. Replace the exact captured file instead so the old transcript
970
+ // or the complete successor remains recoverable after every reported error.
971
+ replaceManagedFileSync(
972
+ resolved,
973
+ Buffer.concat([existing.bytes, Buffer.from(bytes)]),
974
+ this.#subtreeRoot,
975
+ this.#policy,
976
+ undefined,
977
+ existing.identity,
978
+ );
964
979
  this.#assertBound();
965
980
  }
966
981
 
@@ -1409,25 +1424,19 @@ function sameIdentity(left: ManagedFileSnapshot["identity"], right: ManagedFileS
1409
1424
  left.ctimeNs === right.ctimeNs
1410
1425
  );
1411
1426
  }
1412
-
1413
- /** Content/path identity that remains meaningful across a Darwin write-open ctime-only transition. */
1414
- function sameStableIdentityIgnoringCtime(
1427
+ function sameReplacementIdentity(
1415
1428
  left: ManagedFileSnapshot["identity"],
1416
1429
  right: ManagedFileSnapshot["identity"],
1417
1430
  ): boolean {
1418
1431
  return (
1419
- left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs
1432
+ left.dev === right.dev &&
1433
+ left.ino === right.ino &&
1434
+ left.nlink === right.nlink &&
1435
+ left.size === right.size &&
1436
+ left.mtimeNs === right.mtimeNs
1420
1437
  );
1421
1438
  }
1422
1439
 
1423
- /** True when observed identity matches expected on every field except ctimeNs. */
1424
- function isCtimeOnlyIdentityMismatch(
1425
- observed: ManagedFileSnapshot["identity"],
1426
- expected: ManagedFileSnapshot["identity"],
1427
- ): boolean {
1428
- return sameStableIdentityIgnoringCtime(observed, expected) && observed.ctimeNs !== expected.ctimeNs;
1429
- }
1430
-
1431
1440
  function parseLockBytes(bytes: Uint8Array): LockRecord | undefined {
1432
1441
  try {
1433
1442
  const value: unknown = JSON.parse(Buffer.from(bytes).toString("utf8"));
@@ -1580,6 +1589,7 @@ export async function publishManagedFileNoReplace(
1580
1589
  let failure: unknown;
1581
1590
  let outcome: NativePublishOutcome | undefined;
1582
1591
  let renameAttempted = false;
1592
+ let linkPublished = false;
1583
1593
 
1584
1594
  try {
1585
1595
  assertOwned?.();
@@ -1599,6 +1609,16 @@ export async function publishManagedFileNoReplace(
1599
1609
 
1600
1610
  renameAttempted = true;
1601
1611
  outcome = classifyNativePublishOutcome(renameNoReplacePath(staging, destination));
1612
+ if (renameFlagsUnsupported(outcome)) {
1613
+ // linkat publishes the destination without consuming the staging name, so the
1614
+ // secured staging descriptor stays authoritative across publication exactly as
1615
+ // it does across a rename. The staging link is removed in the finally block
1616
+ // below, after that descriptor is closed: unlinking a still-open name on NFS
1617
+ // silly-renames it instead of removing it, which would leave a second link on
1618
+ // the published inode.
1619
+ outcome = classifyNativePublishOutcome(linkNoReplacePath(staging, destination));
1620
+ linkPublished = outcome.ok;
1621
+ }
1602
1622
 
1603
1623
  if (!outcome.ok) throw publishFailure(outcome);
1604
1624
 
@@ -1621,7 +1641,7 @@ export async function publishManagedFileNoReplace(
1621
1641
  } finally {
1622
1642
  if (fd !== undefined) fs.closeSync(fd);
1623
1643
 
1624
- if (stagingIdentity && (!renameAttempted || (outcome && mayCleanCurrentStaging(outcome)))) {
1644
+ if (stagingIdentity && (linkPublished || !renameAttempted || (outcome && mayCleanCurrentStaging(outcome)))) {
1625
1645
  await fsp
1626
1646
  .lstat(staging, { bigint: true })
1627
1647
  .then(stat => {
@@ -1641,14 +1661,16 @@ export function publishManagedFileNoReplaceSync(
1641
1661
  bytes: Uint8Array,
1642
1662
  root?: ManagedDirectoryRoot,
1643
1663
  policy: ManagedSessionSecurityPolicy = "default",
1644
- ): void {
1664
+ ): ManagedFileSnapshot["identity"] {
1645
1665
  const parent = path.dirname(destination);
1646
1666
  ensureManagedDirectory(parent, root, policy);
1647
1667
  const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.staging`);
1648
1668
  let fd: number | undefined;
1649
1669
  let stagingIdentity: { dev: bigint; ino: bigint } | undefined;
1670
+ let publishedIdentity: ManagedFileSnapshot["identity"] | undefined;
1650
1671
  let failure: unknown;
1651
1672
  let outcome: NativePublishOutcome | undefined;
1673
+ let linkPublished = false;
1652
1674
 
1653
1675
  try {
1654
1676
  fd = fs.openSync(
@@ -1663,8 +1685,15 @@ export function publishManagedFileNoReplaceSync(
1663
1685
  secureFileDescriptor(staging, fd, "verify");
1664
1686
  const staged = fs.fstatSync(fd, { bigint: true });
1665
1687
  stagingIdentity = { dev: staged.dev, ino: staged.ino };
1688
+ publishedIdentity = identity(staged, createHash("sha256").update(bytes).digest("hex"));
1666
1689
 
1667
1690
  outcome = classifyNativePublishOutcome(renameNoReplacePath(staging, destination));
1691
+ if (renameFlagsUnsupported(outcome)) {
1692
+ // See publishManagedFileNoReplace: the staging link outlives this publication
1693
+ // and is removed only after the secured descriptor is closed.
1694
+ outcome = classifyNativePublishOutcome(linkNoReplacePath(staging, destination));
1695
+ linkPublished = outcome.ok;
1696
+ }
1668
1697
  if (!outcome.ok) throw publishFailure(outcome);
1669
1698
 
1670
1699
  const named = fs.lstatSync(destination, { bigint: true });
@@ -1680,7 +1709,7 @@ export function publishManagedFileNoReplaceSync(
1680
1709
  } finally {
1681
1710
  if (fd !== undefined) fs.closeSync(fd);
1682
1711
  }
1683
- if (stagingIdentity && outcome && mayCleanCurrentStaging(outcome)) {
1712
+ if (stagingIdentity && (linkPublished || (outcome && mayCleanCurrentStaging(outcome)))) {
1684
1713
  try {
1685
1714
  const named = fs.lstatSync(staging, { bigint: true });
1686
1715
  if (named.dev !== stagingIdentity.dev || named.ino !== stagingIdentity.ino) {
@@ -1692,6 +1721,8 @@ export function publishManagedFileNoReplaceSync(
1692
1721
  }
1693
1722
  }
1694
1723
  if (failure !== undefined) throw failure;
1724
+ if (!publishedIdentity) throw new Error("managed_publish_identity_unavailable");
1725
+ return publishedIdentity;
1695
1726
  }
1696
1727
 
1697
1728
  /** Replace one managed regular file while retaining the secured staging fd through publication. */
@@ -1709,6 +1740,16 @@ export function replaceManagedFileSync(
1709
1740
  let fd: number | undefined;
1710
1741
  let stagedIdentity: { dev: bigint; ino: bigint } | undefined;
1711
1742
  let preserveStaging = false;
1743
+ let receiptCleanup:
1744
+ | {
1745
+ path: string;
1746
+ parentDev: bigint;
1747
+ parentIno: bigint;
1748
+ identity: ManagedFileSnapshot["identity"];
1749
+ predecessor: ManagedFileSnapshot["identity"];
1750
+ }
1751
+ | undefined;
1752
+ let failure: unknown;
1712
1753
  try {
1713
1754
  fd = fs.openSync(
1714
1755
  staging,
@@ -1728,23 +1769,54 @@ export function replaceManagedFileSync(
1728
1769
  }
1729
1770
  assertManagedDirectoryRoot(root);
1730
1771
  assertFence?.();
1731
- if (process.platform !== "win32" && expectedDestination) {
1732
- throw new Error("managed_replace_exact_unavailable");
1733
- }
1734
- if (process.platform === "win32" && expectedDestination) {
1772
+ if (expectedDestination) {
1735
1773
  const parentIdentity = fs.lstatSync(parent, { bigint: true });
1774
+ const successor = identity(staged, createHash("sha256").update(bytes).digest("hex"));
1775
+ const receiptStagingPath = path.join(parent, `.gjc-replace-receipt-pending-${randomUUID()}.json`);
1776
+ preserveStaging = true;
1777
+ const publishedReceiptIdentity = publishManagedFileNoReplaceSync(
1778
+ receiptStagingPath,
1779
+ Buffer.from(
1780
+ JSON.stringify({
1781
+ version: 3,
1782
+ staging,
1783
+ destination,
1784
+ successor: serializeReplacementIdentity(successor),
1785
+ predecessor: serializeReplacementIdentity(expectedDestination),
1786
+ } satisfies ReplacementCleanupReceipt),
1787
+ ),
1788
+ root,
1789
+ policy,
1790
+ );
1791
+ const receiptPath = replacementReceiptPath(parent, expectedDestination, publishedReceiptIdentity);
1792
+ const receiptPublish = classifyNativePublishOutcome(renameNoReplacePath(receiptStagingPath, receiptPath));
1793
+ if (!receiptPublish.ok) throw publishFailure(receiptPublish);
1794
+ const namedReceipt = captureManagedFileNoFollow(receiptPath);
1795
+ if (
1796
+ namedReceipt.identity.dev !== publishedReceiptIdentity.dev ||
1797
+ namedReceipt.identity.ino !== publishedReceiptIdentity.ino
1798
+ )
1799
+ throw new Error("managed_replace_cleanup_receipt_identity_changed");
1800
+ fsyncDirectory(parent);
1801
+ receiptCleanup = {
1802
+ path: receiptPath,
1803
+ parentDev: parentIdentity.dev,
1804
+ parentIno: parentIdentity.ino,
1805
+ identity: publishedReceiptIdentity,
1806
+ predecessor: expectedDestination,
1807
+ };
1736
1808
  const replaced = exactReplacePath(
1737
1809
  staging,
1738
1810
  destination,
1739
1811
  {
1740
- dev: staged.dev,
1741
- ino: staged.ino,
1742
- nlink: staged.nlink,
1812
+ dev: successor.dev,
1813
+ ino: successor.ino,
1814
+ nlink: successor.nlink,
1743
1815
  parentDev: parentIdentity.dev,
1744
1816
  parentIno: parentIdentity.ino,
1745
- size: staged.size,
1746
- mtimeNs: staged.mtimeNs,
1747
- sha256: createHash("sha256").update(bytes).digest("hex"),
1817
+ size: BigInt(successor.size),
1818
+ mtimeNs: successor.mtimeNs,
1819
+ sha256: successor.sha256,
1748
1820
  },
1749
1821
  {
1750
1822
  dev: expectedDestination.dev,
@@ -1757,88 +1829,12 @@ export function replaceManagedFileSync(
1757
1829
  sha256: expectedDestination.sha256,
1758
1830
  },
1759
1831
  );
1760
- if (!replaced.ok) {
1761
- const committedWithPredecessor =
1762
- replaced.detachedPath === replaced.retainedPlaceholderPath &&
1763
- replaced.retainedSuccessorPath === destination;
1764
- if (committedWithPredecessor) {
1765
- const retired = exactUnlink(replaced.retainedPlaceholderPath!, {
1766
- dev: expectedDestination.dev,
1767
- ino: expectedDestination.ino,
1768
- nlink: expectedDestination.nlink,
1769
- parentDev: parentIdentity.dev,
1770
- parentIno: parentIdentity.ino,
1771
- size: BigInt(expectedDestination.size),
1772
- mtimeNs: expectedDestination.mtimeNs,
1773
- sha256: expectedDestination.sha256,
1774
- quarantineName: `.gjc-replace-cleanup-${expectedDestination.dev.toString(16)}-${expectedDestination.ino.toString(16)}`,
1775
- });
1776
- if (!retired.ok) {
1777
- const predecessor =
1778
- retired.detachedPath ?? retired.retainedPlaceholderPath ?? replaced.retainedPlaceholderPath;
1779
- const receiptPath = path.join(
1780
- parent,
1781
- `.gjc-replace-cleanup-${expectedDestination.dev.toString(16)}-${expectedDestination.ino.toString(16)}.json`,
1782
- );
1783
- publishManagedFileNoReplaceSync(
1784
- receiptPath,
1785
- Buffer.from(
1786
- JSON.stringify({
1787
- version: 1,
1788
- predecessor,
1789
- successor: destination,
1790
- identity: {
1791
- dev: expectedDestination.dev.toString(),
1792
- ino: expectedDestination.ino.toString(),
1793
- nlink: expectedDestination.nlink.toString(),
1794
- size: String(expectedDestination.size),
1795
- mtimeNs: expectedDestination.mtimeNs.toString(),
1796
- ctimeNs: expectedDestination.ctimeNs.toString(),
1797
- sha256: expectedDestination.sha256,
1798
- },
1799
- }),
1800
- ),
1801
- root,
1802
- policy,
1803
- );
1804
- throw new Error(
1805
- `managed_replace_cleanup_pending:receipt=${receiptPath}:predecessor=${predecessor}:successor=${destination}:code=${retired.code ?? "unknown"}`,
1806
- );
1807
- }
1808
- }
1809
- if (!committedWithPredecessor) {
1810
- if (replaced.detachedPath) {
1811
- const restored = exactRestore(replaced.detachedPath, staging, {
1812
- dev: staged.dev,
1813
- ino: staged.ino,
1814
- nlink: staged.nlink,
1815
- parentDev: parentIdentity.dev,
1816
- parentIno: parentIdentity.ino,
1817
- size: staged.size,
1818
- mtimeNs: staged.mtimeNs,
1819
- sha256: createHash("sha256").update(bytes).digest("hex"),
1820
- });
1821
- if (restored.ok) preserveStaging = true;
1822
- if (!restored.ok)
1823
- throw new Error(
1824
- `managed_replace_failed:${replaced.code ?? "unknown"}:retained=${replaced.detachedPath}:restore=${restored.code ?? "unknown"}`,
1825
- );
1826
- }
1827
- const retained = [
1828
- replaced.detachedPath,
1829
- replaced.retainedSuccessorPath,
1830
- replaced.retainedPlaceholderPath,
1831
- replaced.retainedUnknownPath,
1832
- ]
1833
- .filter((pathname): pathname is string => typeof pathname === "string")
1834
- .join(",");
1835
- throw new Error(
1836
- `managed_replace_failed:${replaced.code ?? "unknown"}${retained ? `:retained=${retained}` : ""}`,
1837
- );
1838
- }
1839
- }
1832
+ if (!replaced.ok) throw new ManagedReplaceError(replaced, receiptCleanup.path);
1833
+ const named = captureManagedFileNoFollow(destination);
1834
+ if (!sameReplacementIdentity(named.identity, successor) || named.identity.sha256 !== successor.sha256)
1835
+ throw new Error("destination_identity_changed");
1836
+ fsyncDirectory(parent);
1840
1837
  } else fs.renameSync(staging, destination);
1841
- assertFence?.();
1842
1838
  assertManagedDirectoryRoot(root);
1843
1839
  const named = fs.lstatSync(destination, { bigint: true });
1844
1840
  if (!named.isFile() || named.isSymbolicLink() || named.dev !== staged.dev || named.ino !== staged.ino) {
@@ -1850,17 +1846,53 @@ export function replaceManagedFileSync(
1850
1846
  fd = undefined;
1851
1847
  }
1852
1848
  fsyncDirectory(parent);
1849
+ if (receiptCleanup) {
1850
+ try {
1851
+ const removed = exactUnlink(receiptCleanup.path, {
1852
+ dev: receiptCleanup.identity.dev,
1853
+ ino: receiptCleanup.identity.ino,
1854
+ nlink: receiptCleanup.identity.nlink,
1855
+ parentDev: receiptCleanup.parentDev,
1856
+ parentIno: receiptCleanup.parentIno,
1857
+ size: BigInt(receiptCleanup.identity.size),
1858
+ mtimeNs: receiptCleanup.identity.mtimeNs,
1859
+ sha256: receiptCleanup.identity.sha256,
1860
+ quarantineName: replacementReceiptRetirementName(receiptCleanup.identity, receiptCleanup.predecessor),
1861
+ });
1862
+ if (!exactUnlinkCompleted(removed) && removed.code !== "not_found")
1863
+ throw new ManagedReplaceError(removed, receiptCleanup.path);
1864
+ try {
1865
+ fsyncDirectory(parent);
1866
+ } catch (error) {
1867
+ throw new ManagedReplaceError(
1868
+ { ...removed, ok: false, code: "durability_failed" },
1869
+ receiptCleanup.path,
1870
+ error,
1871
+ );
1872
+ }
1873
+ } catch (error) {
1874
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
1875
+ }
1876
+ }
1877
+ } catch (error) {
1878
+ failure = error;
1853
1879
  } finally {
1854
1880
  if (fd !== undefined) fs.closeSync(fd);
1855
1881
  if (stagedIdentity && !preserveStaging) {
1856
1882
  try {
1857
1883
  const named = fs.lstatSync(staging, { bigint: true });
1858
1884
  if (named.dev === stagedIdentity.dev && named.ino === stagedIdentity.ino) fs.unlinkSync(staging);
1859
- } catch {
1860
- // Never delete an object whose staging identity is no longer proven.
1885
+ } catch (error) {
1886
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
1887
+ failure =
1888
+ failure === undefined
1889
+ ? error
1890
+ : new AggregateError([failure, error], "Managed replacement and staging cleanup both failed.");
1891
+ }
1861
1892
  }
1862
1893
  }
1863
1894
  }
1895
+ if (failure !== undefined) throw failure;
1864
1896
  }
1865
1897
 
1866
1898
  export async function replaceManagedFile(