@gajae-code/coding-agent 0.12.7 → 0.12.8

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 (95) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/types/commands/notify.d.ts +4 -0
  3. package/dist/types/config/file-lock.d.ts +3 -0
  4. package/dist/types/config/model-discovery-manager.d.ts +6 -1
  5. package/dist/types/config/model-registry.d.ts +2 -0
  6. package/dist/types/config/resolve-config-value.d.ts +2 -1
  7. package/dist/types/config/settings-schema.d.ts +17 -1
  8. package/dist/types/main.d.ts +1 -0
  9. package/dist/types/modes/components/hook-selector.d.ts +20 -0
  10. package/dist/types/modes/components/notifications-settings-editor.d.ts +53 -4
  11. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  12. package/dist/types/modes/controllers/selector-controller.d.ts +4 -2
  13. package/dist/types/sdk/broker/broker.d.ts +5 -1
  14. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
  15. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
  16. package/dist/types/sdk/bus/config.d.ts +75 -113
  17. package/dist/types/sdk/bus/discord-live-provider.d.ts +8 -2
  18. package/dist/types/sdk/bus/discord-provider.d.ts +19 -0
  19. package/dist/types/sdk/bus/index.d.ts +1 -1
  20. package/dist/types/sdk/bus/notification-orchestration.d.ts +127 -18
  21. package/dist/types/sdk/bus/notification-service.d.ts +31 -4
  22. package/dist/types/sdk/bus/session-control.d.ts +8 -3
  23. package/dist/types/sdk/bus/slack-live-provider.d.ts +9 -2
  24. package/dist/types/sdk/bus/slack-provider.d.ts +22 -0
  25. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  26. package/dist/types/sdk/client/discovery.d.ts +2 -1
  27. package/dist/types/sdk/host/query/handlers.d.ts +2 -0
  28. package/dist/types/sdk/index.d.ts +1 -0
  29. package/dist/types/sdk/providers.d.ts +22 -0
  30. package/dist/types/sdk/session-directory.d.ts +1 -1
  31. package/dist/types/session/internal/managed-session-scope.d.ts +17 -1
  32. package/dist/types/session/internal/managed-session-storage.d.ts +11 -1
  33. package/dist/types/session/internal/native-publish-outcome.d.ts +1 -0
  34. package/dist/types/session/internal/session-open-errors.d.ts +5 -0
  35. package/dist/types/session/session-manager.d.ts +3 -1
  36. package/dist/types/session/session-storage.d.ts +2 -0
  37. package/package.json +10 -8
  38. package/scripts/build-sdk-package-smoke.ts +51 -0
  39. package/scripts/capture-notifications-settings-showcase.ts +1 -1
  40. package/src/cli/notify-cli.ts +204 -94
  41. package/src/commands/notify.ts +19 -8
  42. package/src/config/file-lock.ts +6 -0
  43. package/src/config/model-discovery-manager.ts +50 -8
  44. package/src/config/model-registry.ts +862 -75
  45. package/src/config/resolve-config-value.ts +10 -8
  46. package/src/config/settings-schema.ts +25 -15
  47. package/src/config/settings.ts +15 -15
  48. package/src/gjc-runtime/session-state-sidecar.ts +12 -2
  49. package/src/internal-urls/docs-index.generated.ts +6 -6
  50. package/src/main.ts +43 -7
  51. package/src/modes/DESIGN.md +11 -11
  52. package/src/modes/components/hook-selector.ts +155 -44
  53. package/src/modes/components/model-selector.ts +242 -49
  54. package/src/modes/components/notifications-settings-editor.ts +631 -85
  55. package/src/modes/controllers/extension-ui-controller.ts +114 -15
  56. package/src/modes/controllers/selector-controller.ts +464 -50
  57. package/src/sdk/broker/broker.ts +54 -10
  58. package/src/sdk/broker/lifecycle-ledger.ts +34 -1
  59. package/src/sdk/broker/lifecycle.ts +47 -50
  60. package/src/sdk/bus/chat-daemon-cli.ts +12 -5
  61. package/src/sdk/bus/chat-daemon-control.ts +35 -6
  62. package/src/sdk/bus/chat-daemon-runtime.ts +16 -2
  63. package/src/sdk/bus/config.ts +534 -286
  64. package/src/sdk/bus/discord-live-provider.ts +57 -2
  65. package/src/sdk/bus/discord-provider.ts +21 -0
  66. package/src/sdk/bus/index.ts +97 -35
  67. package/src/sdk/bus/notification-orchestration.ts +371 -68
  68. package/src/sdk/bus/notification-service.ts +431 -130
  69. package/src/sdk/bus/session-control.ts +119 -44
  70. package/src/sdk/bus/slack-live-provider.ts +78 -2
  71. package/src/sdk/bus/slack-provider.ts +24 -0
  72. package/src/sdk/bus/telegram-daemon-cli.ts +9 -2
  73. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  74. package/src/sdk/bus/telegram-daemon-control.ts +3 -3
  75. package/src/sdk/bus/telegram-daemon.ts +23 -15
  76. package/src/sdk/client/discovery.ts +10 -4
  77. package/src/sdk/host/query/handlers.ts +20 -1
  78. package/src/sdk/index.ts +1 -0
  79. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  80. package/src/sdk/protocol/operation-registry.ts +5 -2
  81. package/src/sdk/providers.ts +51 -0
  82. package/src/sdk/session-directory.ts +1 -0
  83. package/src/sdk/session.ts +10 -7
  84. package/src/session/agent-session.ts +44 -24
  85. package/src/session/internal/managed-session-scope.ts +981 -450
  86. package/src/session/internal/managed-session-storage.ts +382 -45
  87. package/src/session/internal/native-publish-outcome.ts +3 -1
  88. package/src/session/internal/session-open-errors.ts +11 -0
  89. package/src/session/session-manager.ts +27 -3
  90. package/src/session/session-storage.ts +72 -5
  91. package/src/slash-commands/builtin-registry.ts +82 -4
  92. package/src/task/index.ts +9 -3
  93. package/src/tools/ask.ts +1 -1
  94. package/src/tools/bash.ts +1 -1
  95. package/src/tools/telegram-send.ts +4 -2
@@ -6,6 +6,8 @@ import {
6
6
  applyOwnerOnlyFdSecurity,
7
7
  applyOwnerOnlyPathSecurity,
8
8
  exactRemoveDirectoryTree,
9
+ exactReplacePath,
10
+ exactRestore,
9
11
  exactUnlink,
10
12
  type NativeDirectoryTreeSnapshot,
11
13
  type NativeOwnerOnlySecurityResult,
@@ -34,7 +36,6 @@ const LOCK_LEASE_MS = 60_000;
34
36
  const LOCK_HEARTBEAT_MS = 10_000;
35
37
  const LOCK_WAIT_MS = 5_000;
36
38
 
37
- const LOCK_STALE_RECHECK_MS = 100;
38
39
  export class ManagedPublishError extends Error {
39
40
  readonly classification:
40
41
  | "destination_conflict"
@@ -138,7 +139,38 @@ export interface ManagedStorageLock {
138
139
 
139
140
  export interface ManagedFileSnapshot {
140
141
  bytes: Buffer;
141
- identity: { dev: bigint; ino: bigint; size: number; mtimeNs: bigint; ctimeNs: bigint; sha256: string };
142
+ identity: {
143
+ dev: bigint;
144
+ ino: bigint;
145
+ nlink: bigint;
146
+ size: number;
147
+ mtimeNs: bigint;
148
+ ctimeNs: bigint;
149
+ sha256: string;
150
+ };
151
+ }
152
+
153
+ 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
+ };
166
+ };
167
+
168
+ const U64_MAX = 18_446_744_073_709_551_615n;
169
+
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;
142
174
  }
143
175
 
144
176
  const ACL_FAILURE_CODES = new Set(["acl_denied", "acl_io_error", "acl_present", "acl_malformed", "acl_unknown"]);
@@ -264,8 +296,19 @@ type LockRecord = {
264
296
  createdAt: number;
265
297
  heartbeatAt: number;
266
298
  leaseExpiresAt: number;
299
+ released?: boolean;
267
300
  };
268
301
 
302
+ export interface ManagedLockRetirementTestEvent {
303
+ readonly path: string;
304
+ readonly attemptId: string;
305
+ }
306
+
307
+ /** Test-only seam immediately before exact retirement of one observed lock identity. */
308
+ export const ManagedLockTestHooks: {
309
+ beforeObservedRetirement?: (event: ManagedLockRetirementTestEvent) => void;
310
+ } = {};
311
+
269
312
  /** Captured configured-root authority for managed paths only. */
270
313
  export interface ManagedDirectoryRoot {
271
314
  readonly canonicalPath: string;
@@ -475,6 +518,7 @@ export class ManagedSessionDescendantStore {
475
518
  readonly #authority: RecoveryFsRoot | undefined;
476
519
  #ownsAuthority = false;
477
520
  #closed = false;
521
+ #reconcilingReplacementCleanup = false;
478
522
  readonly #authorityBaseDir: string;
479
523
  /** Logical profile root inherited by nested managed session destinations. */
480
524
  readonly #profileAgentDir: string;
@@ -650,7 +694,86 @@ export class ManagedSessionDescendantStore {
650
694
  }
651
695
  }
652
696
 
697
+ #reconcileReplacementCleanupReceipts(): void {
698
+ if (this.#reconcilingReplacementCleanup) return;
699
+ this.#reconcilingReplacementCleanup = true;
700
+ try {
701
+ 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"}`);
763
+ }
764
+ } finally {
765
+ this.#reconcilingReplacementCleanup = false;
766
+ }
767
+ }
768
+
769
+ #beforeMutation(): void {
770
+ this.#assertBound();
771
+ this.#reconcileReplacementCleanupReceipts();
772
+ this.#assertBound();
773
+ }
774
+
653
775
  ensureDirectory(relativePath = ""): ManagedDirectoryRoot {
776
+ this.#beforeMutation();
654
777
  this.#assertBound();
655
778
  if (this.#authority) {
656
779
  const relative = this.#relative(this.#resolve(relativePath));
@@ -670,6 +793,7 @@ export class ManagedSessionDescendantStore {
670
793
  }
671
794
 
672
795
  async publishNoReplace(relativePath: string, bytes: Uint8Array): Promise<void> {
796
+ this.#beforeMutation();
673
797
  const resolved = this.#resolve(relativePath);
674
798
  if (this.#authority) {
675
799
  this.#assertBound();
@@ -681,6 +805,7 @@ export class ManagedSessionDescendantStore {
681
805
  }
682
806
 
683
807
  publishNoReplaceSync(relativePath: string, bytes: Uint8Array): void {
808
+ this.#beforeMutation();
684
809
  const resolved = this.#resolve(relativePath);
685
810
  if (!this.#authority) {
686
811
  publishManagedFileNoReplaceSync(resolved, bytes, this.#root, this.#policy);
@@ -693,10 +818,17 @@ export class ManagedSessionDescendantStore {
693
818
  }
694
819
 
695
820
  async replace(relativePath: string, bytes: Uint8Array): Promise<void> {
821
+ this.#beforeMutation();
696
822
  this.#assertBound();
697
823
  const resolved = this.#resolve(relativePath);
698
824
  if (!this.#authority) {
699
- await replaceManagedFile(resolved, bytes, this.#subtreeRoot, this.#policy);
825
+ try {
826
+ const expected = captureManagedFileNoFollow(resolved);
827
+ replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy, undefined, expected.identity);
828
+ } catch (error) {
829
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
830
+ await publishManagedFileNoReplace(resolved, bytes, undefined, this.#subtreeRoot, this.#policy);
831
+ }
700
832
  this.#assertBound();
701
833
  return;
702
834
  }
@@ -705,11 +837,56 @@ export class ManagedSessionDescendantStore {
705
837
  this.#assertBound();
706
838
  }
707
839
 
840
+ replaceExpected(relativePath: string, bytes: Uint8Array, expected: ManagedFileSnapshot): void {
841
+ this.#beforeMutation();
842
+ this.#assertBound();
843
+ const resolved = this.#resolve(relativePath);
844
+ if (!this.#authority) {
845
+ const current = captureManagedFileNoFollow(resolved);
846
+ if (!sameIdentity(current.identity, expected.identity) || current.identity.sha256 !== expected.identity.sha256)
847
+ 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
+ );
863
+ return;
864
+ }
865
+ const replaced = (this.#authority as RecoveryFsRoot & RetainedManagedReplacer).replaceManaged(
866
+ this.#relative(resolved),
867
+ bytes,
868
+ expected.identity.dev.toString(),
869
+ expected.identity.ino.toString(),
870
+ String(expected.identity.size),
871
+ expected.identity.mtimeNs.toString(),
872
+ expected.identity.ctimeNs.toString(),
873
+ expected.identity.sha256,
874
+ );
875
+ if (!replaced.ok) throw new Error(replaced.code ?? "managed_replace_failed");
876
+ this.#assertBound();
877
+ }
708
878
  replaceSync(relativePath: string, bytes: Uint8Array): void {
879
+ this.#beforeMutation();
709
880
  this.#assertBound();
710
881
  const resolved = this.#resolve(relativePath);
711
882
  if (!this.#authority) {
712
- replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy);
883
+ try {
884
+ const expected = captureManagedFileNoFollow(resolved);
885
+ replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy, undefined, expected.identity);
886
+ } catch (error) {
887
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
888
+ publishManagedFileNoReplaceSync(resolved, bytes, this.#subtreeRoot, this.#policy);
889
+ }
713
890
  this.#assertBound();
714
891
  return;
715
892
  }
@@ -718,6 +895,7 @@ export class ManagedSessionDescendantStore {
718
895
  }
719
896
 
720
897
  appendSync(relativePath: string, bytes: Uint8Array): void {
898
+ this.#beforeMutation();
721
899
  this.#assertBound();
722
900
  const resolved = this.#resolve(relativePath);
723
901
  let existing = this.readExpected(relativePath);
@@ -915,6 +1093,7 @@ export class ManagedSessionDescendantStore {
915
1093
  identity: {
916
1094
  dev: BigInt(read.identity.dev),
917
1095
  ino: BigInt(read.identity.ino),
1096
+ nlink: BigInt(read.identity.nlink),
918
1097
  size: Number(read.identity.size),
919
1098
  mtimeNs: BigInt(read.identity.mtimeNs),
920
1099
  ctimeNs: BigInt(read.identity.ctimeNs),
@@ -925,6 +1104,7 @@ export class ManagedSessionDescendantStore {
925
1104
 
926
1105
  /** Remove an exact captured file without reopening its pathname as authority. */
927
1106
  removeExpected(relativePath: string, expected: ManagedFileSnapshot): void {
1107
+ this.#beforeMutation();
928
1108
  this.#assertBound();
929
1109
  if (!this.#authority) {
930
1110
  const removed = exactUnlink(this.#resolve(relativePath), {
@@ -964,6 +1144,7 @@ export class ManagedSessionDescendantStore {
964
1144
  }
965
1145
  /** Read and remove one managed descendant through retained authority. */
966
1146
  async consume(relativePath: string): Promise<Uint8Array | null> {
1147
+ this.#beforeMutation();
967
1148
  this.#assertBound();
968
1149
  const resolved = this.#resolve(relativePath);
969
1150
  if (this.#authority) {
@@ -1002,6 +1183,7 @@ export class ManagedSessionDescendantStore {
1002
1183
 
1003
1184
  /** Remove one managed descendant through retained authority when it exists. */
1004
1185
  async remove(relativePath: string): Promise<void> {
1186
+ this.#beforeMutation();
1005
1187
  await this.consume(relativePath);
1006
1188
  }
1007
1189
 
@@ -1025,6 +1207,7 @@ export class ManagedSessionDescendantStore {
1025
1207
  destinationRelativePath: string,
1026
1208
  snapshot: NativeDirectoryTreeSnapshot,
1027
1209
  ): Promise<void> {
1210
+ this.#beforeMutation();
1028
1211
  const actual = this.captureTree(sourceRelativePath);
1029
1212
  if (JSON.stringify(actual) !== JSON.stringify(snapshot)) throw new Error("artifact_source_changed");
1030
1213
  this.ensureDirectory(destinationRelativePath);
@@ -1071,6 +1254,7 @@ export class ManagedSessionDescendantStore {
1071
1254
  destinationRelativePath: string,
1072
1255
  expected: NativeDirectoryTreeSnapshot,
1073
1256
  ): NativeDirectoryTreeSnapshot {
1257
+ this.#beforeMutation();
1074
1258
  this.#assertBound();
1075
1259
  const moved = this.#authority
1076
1260
  ? this.#authority.renameManagedTreeNoReplace(
@@ -1098,6 +1282,7 @@ export class ManagedSessionDescendantStore {
1098
1282
  }
1099
1283
 
1100
1284
  removeTreeExpected(relativePath: string, expected: NativeDirectoryTreeSnapshot): void {
1285
+ this.#beforeMutation();
1101
1286
  this.#assertBound();
1102
1287
  if (!this.#authority) {
1103
1288
  const removed = exactRemoveDirectoryTree(this.#resolve(relativePath), expected);
@@ -1110,6 +1295,7 @@ export class ManagedSessionDescendantStore {
1110
1295
  this.#assertBound();
1111
1296
  }
1112
1297
  fsyncTree(): NativeDirectoryTreeSnapshot {
1298
+ this.#beforeMutation();
1113
1299
  this.#assertBound();
1114
1300
  if (!this.#authority) return fsyncManagedArtifactTree(this.#baseDir);
1115
1301
  const baseRelative = this.#relative(this.#baseDir);
@@ -1205,6 +1391,7 @@ function identity(stat: fs.BigIntStats, sha256 = ""): ManagedFileSnapshot["ident
1205
1391
  return {
1206
1392
  dev: stat.dev,
1207
1393
  ino: stat.ino,
1394
+ nlink: stat.nlink,
1208
1395
  size: Number(stat.size),
1209
1396
  mtimeNs: stat.mtimeNs,
1210
1397
  ctimeNs: stat.ctimeNs,
@@ -1216,6 +1403,7 @@ function sameIdentity(left: ManagedFileSnapshot["identity"], right: ManagedFileS
1216
1403
  return (
1217
1404
  left.dev === right.dev &&
1218
1405
  left.ino === right.ino &&
1406
+ left.nlink === right.nlink &&
1219
1407
  left.size === right.size &&
1220
1408
  left.mtimeNs === right.mtimeNs &&
1221
1409
  left.ctimeNs === right.ctimeNs
@@ -1240,11 +1428,9 @@ function isCtimeOnlyIdentityMismatch(
1240
1428
  return sameStableIdentityIgnoringCtime(observed, expected) && observed.ctimeNs !== expected.ctimeNs;
1241
1429
  }
1242
1430
 
1243
- function parseLock(pathname: string): LockRecord | undefined {
1431
+ function parseLockBytes(bytes: Uint8Array): LockRecord | undefined {
1244
1432
  try {
1245
- const stat = fs.lstatSync(pathname);
1246
- if (!stat.isFile() || stat.isSymbolicLink()) return undefined;
1247
- const value: unknown = JSON.parse(fs.readFileSync(pathname, "utf8"));
1433
+ const value: unknown = JSON.parse(Buffer.from(bytes).toString("utf8"));
1248
1434
  if (!value || typeof value !== "object") return undefined;
1249
1435
  const record = value as Partial<LockRecord>;
1250
1436
  return typeof record.attemptId === "string" &&
@@ -1252,7 +1438,8 @@ function parseLock(pathname: string): LockRecord | undefined {
1252
1438
  typeof record.processStartId === "string" &&
1253
1439
  typeof record.leaseExpiresAt === "number" &&
1254
1440
  typeof record.heartbeatAt === "number" &&
1255
- typeof record.createdAt === "number"
1441
+ typeof record.createdAt === "number" &&
1442
+ (record.released === undefined || typeof record.released === "boolean")
1256
1443
  ? (record as LockRecord)
1257
1444
  : undefined;
1258
1445
  } catch {
@@ -1260,6 +1447,26 @@ function parseLock(pathname: string): LockRecord | undefined {
1260
1447
  }
1261
1448
  }
1262
1449
 
1450
+ function parseLock(pathname: string): LockRecord | undefined {
1451
+ try {
1452
+ const stat = fs.lstatSync(pathname);
1453
+ if (!stat.isFile() || stat.isSymbolicLink()) return undefined;
1454
+ return parseLockBytes(fs.readFileSync(pathname));
1455
+ } catch {
1456
+ return undefined;
1457
+ }
1458
+ }
1459
+
1460
+ function captureLock(pathname: string): { record: LockRecord; snapshot: ManagedFileSnapshot } | undefined {
1461
+ try {
1462
+ const snapshot = captureManagedFileNoFollow(pathname);
1463
+ const record = parseLockBytes(snapshot.bytes);
1464
+ return record ? { record, snapshot } : undefined;
1465
+ } catch {
1466
+ return undefined;
1467
+ }
1468
+ }
1469
+
1263
1470
  function ownerDefinitelyGone(record: LockRecord): boolean {
1264
1471
  if (record.bootId && bootId() && record.bootId !== bootId()) return true;
1265
1472
  try {
@@ -1493,12 +1700,15 @@ export function replaceManagedFileSync(
1493
1700
  bytes: Uint8Array,
1494
1701
  root: ManagedDirectoryRoot,
1495
1702
  policy: ManagedSessionSecurityPolicy = "default",
1703
+ assertFence?: () => void,
1704
+ expectedDestination?: ManagedFileSnapshot["identity"],
1496
1705
  ): void {
1497
1706
  const parent = path.dirname(destination);
1498
1707
  ensureManagedDirectory(parent, root, policy);
1499
1708
  const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.replacement`);
1500
1709
  let fd: number | undefined;
1501
1710
  let stagedIdentity: { dev: bigint; ino: bigint } | undefined;
1711
+ let preserveStaging = false;
1502
1712
  try {
1503
1713
  fd = fs.openSync(
1504
1714
  staging,
@@ -1512,20 +1722,137 @@ export function replaceManagedFileSync(
1512
1722
  secureFileDescriptor(staging, fd, "verify");
1513
1723
  const staged = fs.fstatSync(fd, { bigint: true });
1514
1724
  stagedIdentity = { dev: staged.dev, ino: staged.ino };
1725
+ if (process.platform === "win32" && expectedDestination) {
1726
+ fs.closeSync(fd);
1727
+ fd = undefined;
1728
+ }
1515
1729
  assertManagedDirectoryRoot(root);
1516
- fs.renameSync(staging, destination);
1730
+ assertFence?.();
1731
+ if (process.platform !== "win32" && expectedDestination) {
1732
+ throw new Error("managed_replace_exact_unavailable");
1733
+ }
1734
+ if (process.platform === "win32" && expectedDestination) {
1735
+ const parentIdentity = fs.lstatSync(parent, { bigint: true });
1736
+ const replaced = exactReplacePath(
1737
+ staging,
1738
+ destination,
1739
+ {
1740
+ dev: staged.dev,
1741
+ ino: staged.ino,
1742
+ nlink: staged.nlink,
1743
+ parentDev: parentIdentity.dev,
1744
+ parentIno: parentIdentity.ino,
1745
+ size: staged.size,
1746
+ mtimeNs: staged.mtimeNs,
1747
+ sha256: createHash("sha256").update(bytes).digest("hex"),
1748
+ },
1749
+ {
1750
+ dev: expectedDestination.dev,
1751
+ ino: expectedDestination.ino,
1752
+ nlink: expectedDestination.nlink,
1753
+ parentDev: parentIdentity.dev,
1754
+ parentIno: parentIdentity.ino,
1755
+ size: BigInt(expectedDestination.size),
1756
+ mtimeNs: expectedDestination.mtimeNs,
1757
+ sha256: expectedDestination.sha256,
1758
+ },
1759
+ );
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
+ }
1840
+ } else fs.renameSync(staging, destination);
1841
+ assertFence?.();
1517
1842
  assertManagedDirectoryRoot(root);
1518
1843
  const named = fs.lstatSync(destination, { bigint: true });
1519
1844
  if (!named.isFile() || named.isSymbolicLink() || named.dev !== staged.dev || named.ino !== staged.ino) {
1520
1845
  throw new Error("destination_identity_changed");
1521
1846
  }
1522
- secureFileDescriptor(destination, fd, "verify");
1523
- fs.closeSync(fd);
1524
- fd = undefined;
1847
+ if (fd !== undefined) {
1848
+ secureFileDescriptor(destination, fd, "verify");
1849
+ fs.closeSync(fd);
1850
+ fd = undefined;
1851
+ }
1525
1852
  fsyncDirectory(parent);
1526
1853
  } finally {
1527
1854
  if (fd !== undefined) fs.closeSync(fd);
1528
- if (stagedIdentity) {
1855
+ if (stagedIdentity && !preserveStaging) {
1529
1856
  try {
1530
1857
  const named = fs.lstatSync(staging, { bigint: true });
1531
1858
  if (named.dev === stagedIdentity.dev && named.ino === stagedIdentity.ino) fs.unlinkSync(staging);
@@ -1571,7 +1898,6 @@ export async function acquireManagedLock(
1571
1898
  ensureManagedDirectory(locksDirectory, root, policy);
1572
1899
  const lockPath = path.join(locksDirectory, `${name}.lock`);
1573
1900
  const deadline = Date.now() + LOCK_WAIT_MS;
1574
- let staleObservedAt: number | undefined;
1575
1901
  while (true) {
1576
1902
  const attemptId = randomUUID();
1577
1903
  const now = Date.now();
@@ -1604,12 +1930,8 @@ export async function acquireManagedLock(
1604
1930
  let descriptorClosed = false;
1605
1931
  const closeDescriptor = (): void => {
1606
1932
  if (descriptorClosed) return;
1607
- try {
1608
- secureFileDescriptor(lockPath, fd, "verify");
1609
- } finally {
1610
- fs.closeSync(fd);
1611
- descriptorClosed = true;
1612
- }
1933
+ fs.closeSync(fd);
1934
+ descriptorClosed = true;
1613
1935
  };
1614
1936
  const assertOwned = (): void => {
1615
1937
  const current = parseLock(lockPath);
@@ -1623,17 +1945,23 @@ export async function acquireManagedLock(
1623
1945
  released ||
1624
1946
  descriptorClosed ||
1625
1947
  !current ||
1948
+ current.released === true ||
1626
1949
  !sameFileIdentity(lockIdentity, named) ||
1627
- current.attemptId !== attemptId ||
1628
- current.leaseExpiresAt < Date.now()
1950
+ current.attemptId !== attemptId
1629
1951
  )
1630
1952
  throw new Error("migration_busy");
1953
+ const now = Date.now();
1954
+ if (current.leaseExpiresAt < now + LOCK_HEARTBEAT_MS) {
1955
+ writeLockDescriptor(fd, {
1956
+ ...record,
1957
+ heartbeatAt: now,
1958
+ leaseExpiresAt: now + LOCK_LEASE_MS,
1959
+ });
1960
+ }
1631
1961
  };
1632
1962
  const heartbeat = setInterval(() => {
1633
1963
  try {
1634
1964
  assertOwned();
1635
- const now = Date.now();
1636
- writeLockDescriptor(fd, { ...record, heartbeatAt: now, leaseExpiresAt: now + LOCK_LEASE_MS });
1637
1965
  } catch {
1638
1966
  /* fencing rejects later publication */
1639
1967
  }
@@ -1646,10 +1974,16 @@ export async function acquireManagedLock(
1646
1974
  clearInterval(heartbeat);
1647
1975
  try {
1648
1976
  assertOwned();
1977
+ secureFileDescriptor(lockPath, fd, "verify");
1649
1978
  const now = Date.now();
1650
- // Do not unlink by pathname: a stale owner could otherwise remove a successor.
1651
- // The lease is retired through the verified inode-bound descriptor instead.
1652
- writeLockDescriptor(fd, { ...record, heartbeatAt: now, leaseExpiresAt: now });
1979
+ // A released record is the only live-process reclaim authority. Expiry alone
1980
+ // never authorizes stealing from a holder whose process is still present.
1981
+ writeLockDescriptor(fd, {
1982
+ ...record,
1983
+ released: true,
1984
+ heartbeatAt: now,
1985
+ leaseExpiresAt: now,
1986
+ });
1653
1987
  fsyncDirectory(locksDirectory);
1654
1988
  } finally {
1655
1989
  released = true;
@@ -1659,24 +1993,27 @@ export async function acquireManagedLock(
1659
1993
  };
1660
1994
  } catch (error) {
1661
1995
  if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
1662
- const owner = parseLock(lockPath);
1663
- if (owner && owner.leaseExpiresAt < Date.now()) {
1664
- const ownerGone = ownerDefinitelyGone(owner);
1665
- if (staleObservedAt === undefined) staleObservedAt = Date.now();
1666
- if (ownerGone || Date.now() - staleObservedAt >= LOCK_STALE_RECHECK_MS) {
1667
- const quarantine = `${lockPath}.${randomUUID()}.stale`;
1668
- try {
1669
- fs.renameSync(lockPath, quarantine);
1670
- const quarantined = parseLock(quarantine);
1671
- if (!quarantined || quarantined.attemptId !== owner.attemptId) throw new Error("migration_busy");
1672
- fs.unlinkSync(quarantine);
1673
- fsyncDirectory(locksDirectory);
1674
- } catch {
1675
- /* retry owner observation */
1676
- }
1677
- staleObservedAt = undefined;
1996
+ const observed = captureLock(lockPath);
1997
+ const owner = observed?.record;
1998
+ const reclaimable =
1999
+ owner?.released === true ||
2000
+ (owner !== undefined && owner.leaseExpiresAt < Date.now() && ownerDefinitelyGone(owner));
2001
+ if (observed && owner && reclaimable) {
2002
+ try {
2003
+ ManagedLockTestHooks.beforeObservedRetirement?.({ path: lockPath, attemptId: owner.attemptId });
2004
+ const removed = exactUnlink(lockPath, {
2005
+ dev: observed.snapshot.identity.dev,
2006
+ ino: observed.snapshot.identity.ino,
2007
+ size: BigInt(observed.snapshot.identity.size),
2008
+ mtimeNs: observed.snapshot.identity.mtimeNs,
2009
+ sha256: observed.snapshot.identity.sha256,
2010
+ quarantineName: `.gjc-lock-${randomUUID()}.stale`,
2011
+ });
2012
+ if (removed.ok || removed.code === "cleanup_pending") fsyncDirectory(locksDirectory);
2013
+ } catch {
2014
+ /* retry owner observation */
1678
2015
  }
1679
- } else staleObservedAt = undefined;
2016
+ }
1680
2017
  if (Date.now() >= deadline) throw new Error("migration_busy");
1681
2018
  await new Promise<void>(resolve => setTimeout(resolve, 50));
1682
2019
  }
@@ -48,6 +48,7 @@ export type NativePublishIdentity = {
48
48
  readonly dev: string;
49
49
  readonly ino: string;
50
50
  readonly size: string;
51
+ readonly nlink?: string;
51
52
  readonly mtimeNs: string;
52
53
  readonly ctimeNs: string;
53
54
  readonly sha256?: string;
@@ -126,12 +127,13 @@ const malformed: NativePublishOutcome = Object.freeze({
126
127
 
127
128
  function validIdentity(value: unknown): boolean {
128
129
  if (value === undefined) return true;
129
- if (!ownPlainRecord(value) || !exactKeys(value, ["dev", "ino", "size", "mtimeNs", "ctimeNs", "sha256"]))
130
+ if (!ownPlainRecord(value) || !exactKeys(value, ["dev", "ino", "nlink", "size", "mtimeNs", "ctimeNs", "sha256"]))
130
131
  return false;
131
132
  const decimal = (field: unknown) => typeof field === "string" && /^-?[0-9]{1,32}$/.test(field);
132
133
  return (
133
134
  decimal(value.dev) &&
134
135
  decimal(value.ino) &&
136
+ (value.nlink === undefined || decimal(value.nlink)) &&
135
137
  decimal(value.size) &&
136
138
  decimal(value.mtimeNs) &&
137
139
  decimal(value.ctimeNs) &&