@gajae-code/coding-agent 0.12.7 → 0.12.10

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 (117) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/types/cli/notify-cli.d.ts +1 -0
  3. package/dist/types/commands/notify.d.ts +4 -0
  4. package/dist/types/commands/sdk.d.ts +23 -0
  5. package/dist/types/config/file-lock.d.ts +3 -0
  6. package/dist/types/config/model-discovery-manager.d.ts +6 -1
  7. package/dist/types/config/model-registry.d.ts +2 -0
  8. package/dist/types/config/resolve-config-value.d.ts +2 -1
  9. package/dist/types/config/settings-schema.d.ts +17 -1
  10. package/dist/types/discovery/agents-md.d.ts +11 -1
  11. package/dist/types/main.d.ts +1 -0
  12. package/dist/types/modes/components/hook-selector.d.ts +20 -0
  13. package/dist/types/modes/components/notifications-settings-editor.d.ts +53 -4
  14. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  15. package/dist/types/modes/controllers/selector-controller.d.ts +4 -2
  16. package/dist/types/modes/types.d.ts +6 -0
  17. package/dist/types/sdk/broker/broker.d.ts +5 -1
  18. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
  19. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
  20. package/dist/types/sdk/bus/config.d.ts +75 -113
  21. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  22. package/dist/types/sdk/bus/discord-live-provider.d.ts +8 -2
  23. package/dist/types/sdk/bus/discord-provider.d.ts +19 -0
  24. package/dist/types/sdk/bus/index.d.ts +1 -1
  25. package/dist/types/sdk/bus/notification-orchestration.d.ts +127 -18
  26. package/dist/types/sdk/bus/notification-service.d.ts +54 -4
  27. package/dist/types/sdk/bus/session-control.d.ts +8 -3
  28. package/dist/types/sdk/bus/slack-live-provider.d.ts +9 -2
  29. package/dist/types/sdk/bus/slack-provider.d.ts +22 -0
  30. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  31. package/dist/types/sdk/client/discovery.d.ts +2 -1
  32. package/dist/types/sdk/host/query/handlers.d.ts +2 -0
  33. package/dist/types/sdk/index.d.ts +1 -0
  34. package/dist/types/sdk/providers.d.ts +22 -0
  35. package/dist/types/sdk/session-directory.d.ts +1 -1
  36. package/dist/types/session/internal/managed-session-scope.d.ts +17 -1
  37. package/dist/types/session/internal/managed-session-storage.d.ts +19 -1
  38. package/dist/types/session/internal/native-publish-outcome.d.ts +3 -2
  39. package/dist/types/session/internal/session-open-errors.d.ts +5 -0
  40. package/dist/types/session/session-manager.d.ts +3 -1
  41. package/dist/types/session/session-storage.d.ts +2 -0
  42. package/dist/types/system-prompt.d.ts +12 -1
  43. package/dist/types/tools/composer-bash-policy.d.ts +9 -1
  44. package/package.json +10 -8
  45. package/scripts/build-sdk-package-smoke.ts +51 -0
  46. package/scripts/capture-notifications-settings-showcase.ts +1 -1
  47. package/scripts/verify-gjc-sdk-canonicalization.ts +14 -1
  48. package/src/cli/notify-cli.ts +215 -99
  49. package/src/commands/notify.ts +19 -8
  50. package/src/commands/sdk.ts +54 -0
  51. package/src/config/file-lock.ts +6 -0
  52. package/src/config/model-discovery-manager.ts +50 -8
  53. package/src/config/model-profiles.ts +8 -0
  54. package/src/config/model-registry.ts +866 -76
  55. package/src/config/resolve-config-value.ts +10 -8
  56. package/src/config/settings-schema.ts +25 -15
  57. package/src/config/settings.ts +15 -15
  58. package/src/discovery/agents-md.ts +76 -19
  59. package/src/gjc-runtime/psmux-detect.ts +12 -16
  60. package/src/gjc-runtime/session-state-sidecar.ts +12 -2
  61. package/src/gjc-runtime/tmux-sessions.ts +8 -1
  62. package/src/internal-urls/docs-index.generated.ts +10 -9
  63. package/src/main.ts +43 -7
  64. package/src/modes/DESIGN.md +11 -11
  65. package/src/modes/components/extensions/inspector-panel.ts +9 -5
  66. package/src/modes/components/hook-selector.ts +155 -44
  67. package/src/modes/components/model-selector.ts +242 -49
  68. package/src/modes/components/notifications-settings-editor.ts +631 -85
  69. package/src/modes/controllers/extension-ui-controller.ts +114 -15
  70. package/src/modes/controllers/selector-controller.ts +525 -93
  71. package/src/modes/print-mode.ts +3 -0
  72. package/src/modes/types.ts +6 -0
  73. package/src/prompts/system/system-prompt.md +1 -0
  74. package/src/sdk/broker/broker.ts +54 -10
  75. package/src/sdk/broker/lifecycle-ledger.ts +34 -1
  76. package/src/sdk/broker/lifecycle.ts +47 -50
  77. package/src/sdk/bus/chat-daemon-cli.ts +12 -5
  78. package/src/sdk/bus/chat-daemon-control.ts +35 -6
  79. package/src/sdk/bus/chat-daemon-runtime.ts +16 -2
  80. package/src/sdk/bus/config.ts +534 -286
  81. package/src/sdk/bus/daemon-paths.ts +2 -0
  82. package/src/sdk/bus/discord-live-provider.ts +57 -2
  83. package/src/sdk/bus/discord-provider.ts +21 -0
  84. package/src/sdk/bus/index.ts +97 -35
  85. package/src/sdk/bus/notification-orchestration.ts +371 -68
  86. package/src/sdk/bus/notification-service.ts +571 -137
  87. package/src/sdk/bus/session-control.ts +119 -44
  88. package/src/sdk/bus/slack-live-provider.ts +78 -2
  89. package/src/sdk/bus/slack-provider.ts +24 -0
  90. package/src/sdk/bus/telegram-daemon-cli.ts +9 -2
  91. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  92. package/src/sdk/bus/telegram-daemon-control.ts +3 -3
  93. package/src/sdk/bus/telegram-daemon.ts +23 -15
  94. package/src/sdk/client/discovery.ts +10 -4
  95. package/src/sdk/host/query/handlers.ts +20 -1
  96. package/src/sdk/index.ts +1 -0
  97. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  98. package/src/sdk/protocol/operation-registry.ts +5 -2
  99. package/src/sdk/providers.ts +51 -0
  100. package/src/sdk/session-directory.ts +1 -0
  101. package/src/sdk/session.ts +34 -14
  102. package/src/session/agent-session.ts +109 -37
  103. package/src/session/internal/managed-session-scope.ts +981 -450
  104. package/src/session/internal/managed-session-storage.ts +418 -47
  105. package/src/session/internal/native-publish-outcome.ts +15 -3
  106. package/src/session/internal/session-open-errors.ts +11 -0
  107. package/src/session/session-manager.ts +27 -3
  108. package/src/session/session-storage.ts +72 -5
  109. package/src/slash-commands/builtin-registry.ts +117 -15
  110. package/src/system-prompt.ts +28 -15
  111. package/src/task/index.ts +9 -3
  112. package/src/tools/ask.ts +1 -1
  113. package/src/tools/bash.ts +4 -2
  114. package/src/tools/composer-bash-policy.ts +21 -5
  115. package/src/tools/telegram-send.ts +4 -2
  116. package/src/utils/command-args.ts +19 -30
  117. package/src/utils/lang-from-path.ts +2 -4
@@ -6,7 +6,10 @@ import {
6
6
  applyOwnerOnlyFdSecurity,
7
7
  applyOwnerOnlyPathSecurity,
8
8
  exactRemoveDirectoryTree,
9
+ exactReplacePath,
10
+ exactRestore,
9
11
  exactUnlink,
12
+ linkNoReplacePath,
10
13
  type NativeDirectoryTreeSnapshot,
11
14
  type NativeOwnerOnlySecurityResult,
12
15
  openRecoveryFsRoot,
@@ -34,7 +37,6 @@ const LOCK_LEASE_MS = 60_000;
34
37
  const LOCK_HEARTBEAT_MS = 10_000;
35
38
  const LOCK_WAIT_MS = 5_000;
36
39
 
37
- const LOCK_STALE_RECHECK_MS = 100;
38
40
  export class ManagedPublishError extends Error {
39
41
  readonly classification:
40
42
  | "destination_conflict"
@@ -113,6 +115,21 @@ export function mayCleanManagedTreeStaging(error: unknown): boolean {
113
115
  return !(error instanceof ManagedTreeMoveOutcomeError) || error.stagingCleanupSafe;
114
116
  }
115
117
 
118
+ /**
119
+ * A filesystem that implements no `renameat2` rename flag at all rejects the no-replace publish
120
+ * before mutating anything: NFS answers `EINVAL` (`invalid_request`) and kernels older than 3.15
121
+ * answer `ENOSYS` (`atomic_unavailable`). Only those two pre-mutation outcomes authorize the
122
+ * `linkat` stand-in; every other failure describes a publish that was actually attempted, and
123
+ * retrying it under a different primitive could publish twice.
124
+ */
125
+ export function renameFlagsUnsupported(outcome: NativePublishOutcome): boolean {
126
+ return (
127
+ !outcome.ok &&
128
+ mayCleanCurrentStaging(outcome) &&
129
+ (outcome.reason === "atomic_unavailable" || outcome.reason === "invalid_request")
130
+ );
131
+ }
132
+
116
133
  export type ManagedSessionSecurityPolicy = "default" | "windows-existing-verify-first";
117
134
 
118
135
  export type ManagedStorageFailure =
@@ -138,7 +155,38 @@ export interface ManagedStorageLock {
138
155
 
139
156
  export interface ManagedFileSnapshot {
140
157
  bytes: Buffer;
141
- identity: { dev: bigint; ino: bigint; size: number; mtimeNs: bigint; ctimeNs: bigint; sha256: string };
158
+ identity: {
159
+ dev: bigint;
160
+ ino: bigint;
161
+ nlink: bigint;
162
+ size: number;
163
+ mtimeNs: bigint;
164
+ ctimeNs: bigint;
165
+ sha256: string;
166
+ };
167
+ }
168
+
169
+ type ReplacementCleanupReceipt = {
170
+ version: 1;
171
+ predecessor: string;
172
+ successor: string;
173
+ identity: {
174
+ dev: string;
175
+ ino: string;
176
+ nlink: string;
177
+ size: string;
178
+ mtimeNs: string;
179
+ ctimeNs: string;
180
+ sha256: string;
181
+ };
182
+ };
183
+
184
+ const U64_MAX = 18_446_744_073_709_551_615n;
185
+
186
+ function parseCanonicalU64(value: unknown): bigint | undefined {
187
+ if (typeof value !== "string" || !/^(0|[1-9][0-9]*)$/.test(value)) return undefined;
188
+ const parsed = BigInt(value);
189
+ return parsed <= U64_MAX ? parsed : undefined;
142
190
  }
143
191
 
144
192
  const ACL_FAILURE_CODES = new Set(["acl_denied", "acl_io_error", "acl_present", "acl_malformed", "acl_unknown"]);
@@ -264,8 +312,19 @@ type LockRecord = {
264
312
  createdAt: number;
265
313
  heartbeatAt: number;
266
314
  leaseExpiresAt: number;
315
+ released?: boolean;
267
316
  };
268
317
 
318
+ export interface ManagedLockRetirementTestEvent {
319
+ readonly path: string;
320
+ readonly attemptId: string;
321
+ }
322
+
323
+ /** Test-only seam immediately before exact retirement of one observed lock identity. */
324
+ export const ManagedLockTestHooks: {
325
+ beforeObservedRetirement?: (event: ManagedLockRetirementTestEvent) => void;
326
+ } = {};
327
+
269
328
  /** Captured configured-root authority for managed paths only. */
270
329
  export interface ManagedDirectoryRoot {
271
330
  readonly canonicalPath: string;
@@ -475,6 +534,7 @@ export class ManagedSessionDescendantStore {
475
534
  readonly #authority: RecoveryFsRoot | undefined;
476
535
  #ownsAuthority = false;
477
536
  #closed = false;
537
+ #reconcilingReplacementCleanup = false;
478
538
  readonly #authorityBaseDir: string;
479
539
  /** Logical profile root inherited by nested managed session destinations. */
480
540
  readonly #profileAgentDir: string;
@@ -650,7 +710,86 @@ export class ManagedSessionDescendantStore {
650
710
  }
651
711
  }
652
712
 
713
+ #reconcileReplacementCleanupReceipts(): void {
714
+ if (this.#reconcilingReplacementCleanup) return;
715
+ this.#reconcilingReplacementCleanup = true;
716
+ try {
717
+ for (const name of fs.readdirSync(this.#baseDir)) {
718
+ const match = /^\.gjc-replace-cleanup-([0-9a-f]+)-([0-9a-f]+)\.json$/.exec(name);
719
+ if (!match) continue;
720
+ const receiptPath = path.join(this.#baseDir, name);
721
+ const parsed = JSON.parse(
722
+ captureManagedFileNoFollow(receiptPath).bytes.toString("utf8"),
723
+ ) as Partial<ReplacementCleanupReceipt>;
724
+ const identity = parsed.identity;
725
+ const dev = parseCanonicalU64(identity?.dev);
726
+ const ino = parseCanonicalU64(identity?.ino);
727
+ const nlink = parseCanonicalU64(identity?.nlink);
728
+ const size = parseCanonicalU64(identity?.size);
729
+ const mtimeNs = parseCanonicalU64(identity?.mtimeNs);
730
+ const ctimeNs = parseCanonicalU64(identity?.ctimeNs);
731
+ const expectedPredecessor =
732
+ dev !== undefined && ino !== undefined
733
+ ? path.join(this.#baseDir, `.gjc-exact-replace-destination-${dev.toString(16)}-${ino.toString(16)}`)
734
+ : undefined;
735
+ if (
736
+ parsed.version !== 1 ||
737
+ dev === undefined ||
738
+ ino === undefined ||
739
+ nlink === undefined ||
740
+ size === undefined ||
741
+ mtimeNs === undefined ||
742
+ ctimeNs === undefined ||
743
+ typeof identity?.sha256 !== "string" ||
744
+ !match[1] ||
745
+ !match[2] ||
746
+ BigInt(`0x${match[1]}`) !== dev ||
747
+ BigInt(`0x${match[2]}`) !== ino ||
748
+ typeof parsed.predecessor !== "string" ||
749
+ path.resolve(parsed.predecessor) !== expectedPredecessor ||
750
+ typeof parsed.successor !== "string" ||
751
+ path.dirname(path.resolve(parsed.successor)) !== this.#baseDir
752
+ )
753
+ throw new Error("managed_replace_cleanup_receipt_invalid");
754
+ const retired = exactUnlink(expectedPredecessor, {
755
+ dev,
756
+ ino,
757
+ nlink,
758
+ parentDev: this.#subtreeRoot.dev,
759
+ parentIno: this.#subtreeRoot.ino,
760
+ size,
761
+ mtimeNs,
762
+ sha256: identity.sha256,
763
+ quarantineName: `.gjc-replace-retry-${dev.toString(16)}-${ino.toString(16)}`,
764
+ });
765
+ if (!retired.ok) throw new Error(`managed_replace_cleanup_pending:${retired.code ?? "unknown"}`);
766
+ const receipt = captureManagedFileNoFollow(receiptPath);
767
+ const removed = exactUnlink(receiptPath, {
768
+ dev: receipt.identity.dev,
769
+ ino: receipt.identity.ino,
770
+ nlink: receipt.identity.nlink,
771
+ parentDev: this.#subtreeRoot.dev,
772
+ parentIno: this.#subtreeRoot.ino,
773
+ size: BigInt(receipt.identity.size),
774
+ mtimeNs: receipt.identity.mtimeNs,
775
+ sha256: receipt.identity.sha256,
776
+ quarantineName: `.gjc-receipt-remove-${receipt.identity.dev.toString(16)}-${receipt.identity.ino.toString(16)}`,
777
+ });
778
+ if (!removed.ok) throw new Error(`managed_replace_receipt_cleanup_pending:${removed.code ?? "unknown"}`);
779
+ }
780
+ } finally {
781
+ this.#reconcilingReplacementCleanup = false;
782
+ }
783
+ }
784
+
785
+ #beforeMutation(): void {
786
+ this.#assertBound();
787
+ this.#reconcileReplacementCleanupReceipts();
788
+ this.#assertBound();
789
+ }
790
+
653
791
  ensureDirectory(relativePath = ""): ManagedDirectoryRoot {
792
+ this.#beforeMutation();
654
793
  this.#assertBound();
655
794
  if (this.#authority) {
656
795
  const relative = this.#relative(this.#resolve(relativePath));
@@ -670,6 +809,7 @@ export class ManagedSessionDescendantStore {
670
809
  }
671
810
 
672
811
  async publishNoReplace(relativePath: string, bytes: Uint8Array): Promise<void> {
812
+ this.#beforeMutation();
673
813
  const resolved = this.#resolve(relativePath);
674
814
  if (this.#authority) {
675
815
  this.#assertBound();
@@ -681,6 +821,7 @@ export class ManagedSessionDescendantStore {
681
821
  }
682
822
 
683
823
  publishNoReplaceSync(relativePath: string, bytes: Uint8Array): void {
824
+ this.#beforeMutation();
684
825
  const resolved = this.#resolve(relativePath);
685
826
  if (!this.#authority) {
686
827
  publishManagedFileNoReplaceSync(resolved, bytes, this.#root, this.#policy);
@@ -693,10 +834,17 @@ export class ManagedSessionDescendantStore {
693
834
  }
694
835
 
695
836
  async replace(relativePath: string, bytes: Uint8Array): Promise<void> {
837
+ this.#beforeMutation();
696
838
  this.#assertBound();
697
839
  const resolved = this.#resolve(relativePath);
698
840
  if (!this.#authority) {
699
- await replaceManagedFile(resolved, bytes, this.#subtreeRoot, this.#policy);
841
+ try {
842
+ const expected = captureManagedFileNoFollow(resolved);
843
+ replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy, undefined, expected.identity);
844
+ } catch (error) {
845
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
846
+ await publishManagedFileNoReplace(resolved, bytes, undefined, this.#subtreeRoot, this.#policy);
847
+ }
700
848
  this.#assertBound();
701
849
  return;
702
850
  }
@@ -705,11 +853,56 @@ export class ManagedSessionDescendantStore {
705
853
  this.#assertBound();
706
854
  }
707
855
 
856
+ replaceExpected(relativePath: string, bytes: Uint8Array, expected: ManagedFileSnapshot): void {
857
+ this.#beforeMutation();
858
+ this.#assertBound();
859
+ const resolved = this.#resolve(relativePath);
860
+ if (!this.#authority) {
861
+ const current = captureManagedFileNoFollow(resolved);
862
+ if (!sameIdentity(current.identity, expected.identity) || current.identity.sha256 !== expected.identity.sha256)
863
+ throw new Error("managed_replace_identity_mismatch");
864
+ replaceManagedFileSync(
865
+ resolved,
866
+ bytes,
867
+ this.#subtreeRoot,
868
+ this.#policy,
869
+ () => {
870
+ const currentAtCommit = captureManagedFileNoFollow(resolved);
871
+ if (
872
+ !sameIdentity(currentAtCommit.identity, expected.identity) ||
873
+ currentAtCommit.identity.sha256 !== expected.identity.sha256
874
+ )
875
+ throw new Error("managed_replace_identity_mismatch");
876
+ },
877
+ expected.identity,
878
+ );
879
+ return;
880
+ }
881
+ const replaced = (this.#authority as RecoveryFsRoot & RetainedManagedReplacer).replaceManaged(
882
+ this.#relative(resolved),
883
+ bytes,
884
+ expected.identity.dev.toString(),
885
+ expected.identity.ino.toString(),
886
+ String(expected.identity.size),
887
+ expected.identity.mtimeNs.toString(),
888
+ expected.identity.ctimeNs.toString(),
889
+ expected.identity.sha256,
890
+ );
891
+ if (!replaced.ok) throw new Error(replaced.code ?? "managed_replace_failed");
892
+ this.#assertBound();
893
+ }
708
894
  replaceSync(relativePath: string, bytes: Uint8Array): void {
895
+ this.#beforeMutation();
709
896
  this.#assertBound();
710
897
  const resolved = this.#resolve(relativePath);
711
898
  if (!this.#authority) {
712
- replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy);
899
+ try {
900
+ const expected = captureManagedFileNoFollow(resolved);
901
+ replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy, undefined, expected.identity);
902
+ } catch (error) {
903
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
904
+ publishManagedFileNoReplaceSync(resolved, bytes, this.#subtreeRoot, this.#policy);
905
+ }
713
906
  this.#assertBound();
714
907
  return;
715
908
  }
@@ -718,6 +911,7 @@ export class ManagedSessionDescendantStore {
718
911
  }
719
912
 
720
913
  appendSync(relativePath: string, bytes: Uint8Array): void {
914
+ this.#beforeMutation();
721
915
  this.#assertBound();
722
916
  const resolved = this.#resolve(relativePath);
723
917
  let existing = this.readExpected(relativePath);
@@ -915,6 +1109,7 @@ export class ManagedSessionDescendantStore {
915
1109
  identity: {
916
1110
  dev: BigInt(read.identity.dev),
917
1111
  ino: BigInt(read.identity.ino),
1112
+ nlink: BigInt(read.identity.nlink),
918
1113
  size: Number(read.identity.size),
919
1114
  mtimeNs: BigInt(read.identity.mtimeNs),
920
1115
  ctimeNs: BigInt(read.identity.ctimeNs),
@@ -925,6 +1120,7 @@ export class ManagedSessionDescendantStore {
925
1120
 
926
1121
  /** Remove an exact captured file without reopening its pathname as authority. */
927
1122
  removeExpected(relativePath: string, expected: ManagedFileSnapshot): void {
1123
+ this.#beforeMutation();
928
1124
  this.#assertBound();
929
1125
  if (!this.#authority) {
930
1126
  const removed = exactUnlink(this.#resolve(relativePath), {
@@ -964,6 +1160,7 @@ export class ManagedSessionDescendantStore {
964
1160
  }
965
1161
  /** Read and remove one managed descendant through retained authority. */
966
1162
  async consume(relativePath: string): Promise<Uint8Array | null> {
1163
+ this.#beforeMutation();
967
1164
  this.#assertBound();
968
1165
  const resolved = this.#resolve(relativePath);
969
1166
  if (this.#authority) {
@@ -1002,6 +1199,7 @@ export class ManagedSessionDescendantStore {
1002
1199
 
1003
1200
  /** Remove one managed descendant through retained authority when it exists. */
1004
1201
  async remove(relativePath: string): Promise<void> {
1202
+ this.#beforeMutation();
1005
1203
  await this.consume(relativePath);
1006
1204
  }
1007
1205
 
@@ -1025,6 +1223,7 @@ export class ManagedSessionDescendantStore {
1025
1223
  destinationRelativePath: string,
1026
1224
  snapshot: NativeDirectoryTreeSnapshot,
1027
1225
  ): Promise<void> {
1226
+ this.#beforeMutation();
1028
1227
  const actual = this.captureTree(sourceRelativePath);
1029
1228
  if (JSON.stringify(actual) !== JSON.stringify(snapshot)) throw new Error("artifact_source_changed");
1030
1229
  this.ensureDirectory(destinationRelativePath);
@@ -1071,6 +1270,7 @@ export class ManagedSessionDescendantStore {
1071
1270
  destinationRelativePath: string,
1072
1271
  expected: NativeDirectoryTreeSnapshot,
1073
1272
  ): NativeDirectoryTreeSnapshot {
1273
+ this.#beforeMutation();
1074
1274
  this.#assertBound();
1075
1275
  const moved = this.#authority
1076
1276
  ? this.#authority.renameManagedTreeNoReplace(
@@ -1098,6 +1298,7 @@ export class ManagedSessionDescendantStore {
1098
1298
  }
1099
1299
 
1100
1300
  removeTreeExpected(relativePath: string, expected: NativeDirectoryTreeSnapshot): void {
1301
+ this.#beforeMutation();
1101
1302
  this.#assertBound();
1102
1303
  if (!this.#authority) {
1103
1304
  const removed = exactRemoveDirectoryTree(this.#resolve(relativePath), expected);
@@ -1110,6 +1311,7 @@ export class ManagedSessionDescendantStore {
1110
1311
  this.#assertBound();
1111
1312
  }
1112
1313
  fsyncTree(): NativeDirectoryTreeSnapshot {
1314
+ this.#beforeMutation();
1113
1315
  this.#assertBound();
1114
1316
  if (!this.#authority) return fsyncManagedArtifactTree(this.#baseDir);
1115
1317
  const baseRelative = this.#relative(this.#baseDir);
@@ -1205,6 +1407,7 @@ function identity(stat: fs.BigIntStats, sha256 = ""): ManagedFileSnapshot["ident
1205
1407
  return {
1206
1408
  dev: stat.dev,
1207
1409
  ino: stat.ino,
1410
+ nlink: stat.nlink,
1208
1411
  size: Number(stat.size),
1209
1412
  mtimeNs: stat.mtimeNs,
1210
1413
  ctimeNs: stat.ctimeNs,
@@ -1216,6 +1419,7 @@ function sameIdentity(left: ManagedFileSnapshot["identity"], right: ManagedFileS
1216
1419
  return (
1217
1420
  left.dev === right.dev &&
1218
1421
  left.ino === right.ino &&
1422
+ left.nlink === right.nlink &&
1219
1423
  left.size === right.size &&
1220
1424
  left.mtimeNs === right.mtimeNs &&
1221
1425
  left.ctimeNs === right.ctimeNs
@@ -1240,11 +1444,9 @@ function isCtimeOnlyIdentityMismatch(
1240
1444
  return sameStableIdentityIgnoringCtime(observed, expected) && observed.ctimeNs !== expected.ctimeNs;
1241
1445
  }
1242
1446
 
1243
- function parseLock(pathname: string): LockRecord | undefined {
1447
+ function parseLockBytes(bytes: Uint8Array): LockRecord | undefined {
1244
1448
  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"));
1449
+ const value: unknown = JSON.parse(Buffer.from(bytes).toString("utf8"));
1248
1450
  if (!value || typeof value !== "object") return undefined;
1249
1451
  const record = value as Partial<LockRecord>;
1250
1452
  return typeof record.attemptId === "string" &&
@@ -1252,7 +1454,8 @@ function parseLock(pathname: string): LockRecord | undefined {
1252
1454
  typeof record.processStartId === "string" &&
1253
1455
  typeof record.leaseExpiresAt === "number" &&
1254
1456
  typeof record.heartbeatAt === "number" &&
1255
- typeof record.createdAt === "number"
1457
+ typeof record.createdAt === "number" &&
1458
+ (record.released === undefined || typeof record.released === "boolean")
1256
1459
  ? (record as LockRecord)
1257
1460
  : undefined;
1258
1461
  } catch {
@@ -1260,6 +1463,26 @@ function parseLock(pathname: string): LockRecord | undefined {
1260
1463
  }
1261
1464
  }
1262
1465
 
1466
+ function parseLock(pathname: string): LockRecord | undefined {
1467
+ try {
1468
+ const stat = fs.lstatSync(pathname);
1469
+ if (!stat.isFile() || stat.isSymbolicLink()) return undefined;
1470
+ return parseLockBytes(fs.readFileSync(pathname));
1471
+ } catch {
1472
+ return undefined;
1473
+ }
1474
+ }
1475
+
1476
+ function captureLock(pathname: string): { record: LockRecord; snapshot: ManagedFileSnapshot } | undefined {
1477
+ try {
1478
+ const snapshot = captureManagedFileNoFollow(pathname);
1479
+ const record = parseLockBytes(snapshot.bytes);
1480
+ return record ? { record, snapshot } : undefined;
1481
+ } catch {
1482
+ return undefined;
1483
+ }
1484
+ }
1485
+
1263
1486
  function ownerDefinitelyGone(record: LockRecord): boolean {
1264
1487
  if (record.bootId && bootId() && record.bootId !== bootId()) return true;
1265
1488
  try {
@@ -1373,6 +1596,7 @@ export async function publishManagedFileNoReplace(
1373
1596
  let failure: unknown;
1374
1597
  let outcome: NativePublishOutcome | undefined;
1375
1598
  let renameAttempted = false;
1599
+ let linkPublished = false;
1376
1600
 
1377
1601
  try {
1378
1602
  assertOwned?.();
@@ -1392,6 +1616,16 @@ export async function publishManagedFileNoReplace(
1392
1616
 
1393
1617
  renameAttempted = true;
1394
1618
  outcome = classifyNativePublishOutcome(renameNoReplacePath(staging, destination));
1619
+ if (renameFlagsUnsupported(outcome)) {
1620
+ // linkat publishes the destination without consuming the staging name, so the
1621
+ // secured staging descriptor stays authoritative across publication exactly as
1622
+ // it does across a rename. The staging link is removed in the finally block
1623
+ // below, after that descriptor is closed: unlinking a still-open name on NFS
1624
+ // silly-renames it instead of removing it, which would leave a second link on
1625
+ // the published inode.
1626
+ outcome = classifyNativePublishOutcome(linkNoReplacePath(staging, destination));
1627
+ linkPublished = outcome.ok;
1628
+ }
1395
1629
 
1396
1630
  if (!outcome.ok) throw publishFailure(outcome);
1397
1631
 
@@ -1414,7 +1648,7 @@ export async function publishManagedFileNoReplace(
1414
1648
  } finally {
1415
1649
  if (fd !== undefined) fs.closeSync(fd);
1416
1650
 
1417
- if (stagingIdentity && (!renameAttempted || (outcome && mayCleanCurrentStaging(outcome)))) {
1651
+ if (stagingIdentity && (linkPublished || !renameAttempted || (outcome && mayCleanCurrentStaging(outcome)))) {
1418
1652
  await fsp
1419
1653
  .lstat(staging, { bigint: true })
1420
1654
  .then(stat => {
@@ -1442,6 +1676,7 @@ export function publishManagedFileNoReplaceSync(
1442
1676
  let stagingIdentity: { dev: bigint; ino: bigint } | undefined;
1443
1677
  let failure: unknown;
1444
1678
  let outcome: NativePublishOutcome | undefined;
1679
+ let linkPublished = false;
1445
1680
 
1446
1681
  try {
1447
1682
  fd = fs.openSync(
@@ -1458,6 +1693,12 @@ export function publishManagedFileNoReplaceSync(
1458
1693
  stagingIdentity = { dev: staged.dev, ino: staged.ino };
1459
1694
 
1460
1695
  outcome = classifyNativePublishOutcome(renameNoReplacePath(staging, destination));
1696
+ if (renameFlagsUnsupported(outcome)) {
1697
+ // See publishManagedFileNoReplace: the staging link outlives this publication
1698
+ // and is removed only after the secured descriptor is closed.
1699
+ outcome = classifyNativePublishOutcome(linkNoReplacePath(staging, destination));
1700
+ linkPublished = outcome.ok;
1701
+ }
1461
1702
  if (!outcome.ok) throw publishFailure(outcome);
1462
1703
 
1463
1704
  const named = fs.lstatSync(destination, { bigint: true });
@@ -1473,7 +1714,7 @@ export function publishManagedFileNoReplaceSync(
1473
1714
  } finally {
1474
1715
  if (fd !== undefined) fs.closeSync(fd);
1475
1716
  }
1476
- if (stagingIdentity && outcome && mayCleanCurrentStaging(outcome)) {
1717
+ if (stagingIdentity && (linkPublished || (outcome && mayCleanCurrentStaging(outcome)))) {
1477
1718
  try {
1478
1719
  const named = fs.lstatSync(staging, { bigint: true });
1479
1720
  if (named.dev !== stagingIdentity.dev || named.ino !== stagingIdentity.ino) {
@@ -1493,12 +1734,15 @@ export function replaceManagedFileSync(
1493
1734
  bytes: Uint8Array,
1494
1735
  root: ManagedDirectoryRoot,
1495
1736
  policy: ManagedSessionSecurityPolicy = "default",
1737
+ assertFence?: () => void,
1738
+ expectedDestination?: ManagedFileSnapshot["identity"],
1496
1739
  ): void {
1497
1740
  const parent = path.dirname(destination);
1498
1741
  ensureManagedDirectory(parent, root, policy);
1499
1742
  const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.replacement`);
1500
1743
  let fd: number | undefined;
1501
1744
  let stagedIdentity: { dev: bigint; ino: bigint } | undefined;
1745
+ let preserveStaging = false;
1502
1746
  try {
1503
1747
  fd = fs.openSync(
1504
1748
  staging,
@@ -1512,20 +1756,137 @@ export function replaceManagedFileSync(
1512
1756
  secureFileDescriptor(staging, fd, "verify");
1513
1757
  const staged = fs.fstatSync(fd, { bigint: true });
1514
1758
  stagedIdentity = { dev: staged.dev, ino: staged.ino };
1759
+ if (process.platform === "win32" && expectedDestination) {
1760
+ fs.closeSync(fd);
1761
+ fd = undefined;
1762
+ }
1515
1763
  assertManagedDirectoryRoot(root);
1516
- fs.renameSync(staging, destination);
1764
+ assertFence?.();
1765
+ if (process.platform !== "win32" && expectedDestination) {
1766
+ throw new Error("managed_replace_exact_unavailable");
1767
+ }
1768
+ if (process.platform === "win32" && expectedDestination) {
1769
+ const parentIdentity = fs.lstatSync(parent, { bigint: true });
1770
+ const replaced = exactReplacePath(
1771
+ staging,
1772
+ destination,
1773
+ {
1774
+ dev: staged.dev,
1775
+ ino: staged.ino,
1776
+ nlink: staged.nlink,
1777
+ parentDev: parentIdentity.dev,
1778
+ parentIno: parentIdentity.ino,
1779
+ size: staged.size,
1780
+ mtimeNs: staged.mtimeNs,
1781
+ sha256: createHash("sha256").update(bytes).digest("hex"),
1782
+ },
1783
+ {
1784
+ dev: expectedDestination.dev,
1785
+ ino: expectedDestination.ino,
1786
+ nlink: expectedDestination.nlink,
1787
+ parentDev: parentIdentity.dev,
1788
+ parentIno: parentIdentity.ino,
1789
+ size: BigInt(expectedDestination.size),
1790
+ mtimeNs: expectedDestination.mtimeNs,
1791
+ sha256: expectedDestination.sha256,
1792
+ },
1793
+ );
1794
+ if (!replaced.ok) {
1795
+ const committedWithPredecessor =
1796
+ replaced.detachedPath === replaced.retainedPlaceholderPath &&
1797
+ replaced.retainedSuccessorPath === destination;
1798
+ if (committedWithPredecessor) {
1799
+ const retired = exactUnlink(replaced.retainedPlaceholderPath!, {
1800
+ dev: expectedDestination.dev,
1801
+ ino: expectedDestination.ino,
1802
+ nlink: expectedDestination.nlink,
1803
+ parentDev: parentIdentity.dev,
1804
+ parentIno: parentIdentity.ino,
1805
+ size: BigInt(expectedDestination.size),
1806
+ mtimeNs: expectedDestination.mtimeNs,
1807
+ sha256: expectedDestination.sha256,
1808
+ quarantineName: `.gjc-replace-cleanup-${expectedDestination.dev.toString(16)}-${expectedDestination.ino.toString(16)}`,
1809
+ });
1810
+ if (!retired.ok) {
1811
+ const predecessor =
1812
+ retired.detachedPath ?? retired.retainedPlaceholderPath ?? replaced.retainedPlaceholderPath;
1813
+ const receiptPath = path.join(
1814
+ parent,
1815
+ `.gjc-replace-cleanup-${expectedDestination.dev.toString(16)}-${expectedDestination.ino.toString(16)}.json`,
1816
+ );
1817
+ publishManagedFileNoReplaceSync(
1818
+ receiptPath,
1819
+ Buffer.from(
1820
+ JSON.stringify({
1821
+ version: 1,
1822
+ predecessor,
1823
+ successor: destination,
1824
+ identity: {
1825
+ dev: expectedDestination.dev.toString(),
1826
+ ino: expectedDestination.ino.toString(),
1827
+ nlink: expectedDestination.nlink.toString(),
1828
+ size: String(expectedDestination.size),
1829
+ mtimeNs: expectedDestination.mtimeNs.toString(),
1830
+ ctimeNs: expectedDestination.ctimeNs.toString(),
1831
+ sha256: expectedDestination.sha256,
1832
+ },
1833
+ }),
1834
+ ),
1835
+ root,
1836
+ policy,
1837
+ );
1838
+ throw new Error(
1839
+ `managed_replace_cleanup_pending:receipt=${receiptPath}:predecessor=${predecessor}:successor=${destination}:code=${retired.code ?? "unknown"}`,
1840
+ );
1841
+ }
1842
+ }
1843
+ if (!committedWithPredecessor) {
1844
+ if (replaced.detachedPath) {
1845
+ const restored = exactRestore(replaced.detachedPath, staging, {
1846
+ dev: staged.dev,
1847
+ ino: staged.ino,
1848
+ nlink: staged.nlink,
1849
+ parentDev: parentIdentity.dev,
1850
+ parentIno: parentIdentity.ino,
1851
+ size: staged.size,
1852
+ mtimeNs: staged.mtimeNs,
1853
+ sha256: createHash("sha256").update(bytes).digest("hex"),
1854
+ });
1855
+ if (restored.ok) preserveStaging = true;
1856
+ if (!restored.ok)
1857
+ throw new Error(
1858
+ `managed_replace_failed:${replaced.code ?? "unknown"}:retained=${replaced.detachedPath}:restore=${restored.code ?? "unknown"}`,
1859
+ );
1860
+ }
1861
+ const retained = [
1862
+ replaced.detachedPath,
1863
+ replaced.retainedSuccessorPath,
1864
+ replaced.retainedPlaceholderPath,
1865
+ replaced.retainedUnknownPath,
1866
+ ]
1867
+ .filter((pathname): pathname is string => typeof pathname === "string")
1868
+ .join(",");
1869
+ throw new Error(
1870
+ `managed_replace_failed:${replaced.code ?? "unknown"}${retained ? `:retained=${retained}` : ""}`,
1871
+ );
1872
+ }
1873
+ }
1874
+ } else fs.renameSync(staging, destination);
1875
+ assertFence?.();
1517
1876
  assertManagedDirectoryRoot(root);
1518
1877
  const named = fs.lstatSync(destination, { bigint: true });
1519
1878
  if (!named.isFile() || named.isSymbolicLink() || named.dev !== staged.dev || named.ino !== staged.ino) {
1520
1879
  throw new Error("destination_identity_changed");
1521
1880
  }
1522
- secureFileDescriptor(destination, fd, "verify");
1523
- fs.closeSync(fd);
1524
- fd = undefined;
1881
+ if (fd !== undefined) {
1882
+ secureFileDescriptor(destination, fd, "verify");
1883
+ fs.closeSync(fd);
1884
+ fd = undefined;
1885
+ }
1525
1886
  fsyncDirectory(parent);
1526
1887
  } finally {
1527
1888
  if (fd !== undefined) fs.closeSync(fd);
1528
- if (stagedIdentity) {
1889
+ if (stagedIdentity && !preserveStaging) {
1529
1890
  try {
1530
1891
  const named = fs.lstatSync(staging, { bigint: true });
1531
1892
  if (named.dev === stagedIdentity.dev && named.ino === stagedIdentity.ino) fs.unlinkSync(staging);
@@ -1571,7 +1932,6 @@ export async function acquireManagedLock(
1571
1932
  ensureManagedDirectory(locksDirectory, root, policy);
1572
1933
  const lockPath = path.join(locksDirectory, `${name}.lock`);
1573
1934
  const deadline = Date.now() + LOCK_WAIT_MS;
1574
- let staleObservedAt: number | undefined;
1575
1935
  while (true) {
1576
1936
  const attemptId = randomUUID();
1577
1937
  const now = Date.now();
@@ -1604,12 +1964,8 @@ export async function acquireManagedLock(
1604
1964
  let descriptorClosed = false;
1605
1965
  const closeDescriptor = (): void => {
1606
1966
  if (descriptorClosed) return;
1607
- try {
1608
- secureFileDescriptor(lockPath, fd, "verify");
1609
- } finally {
1610
- fs.closeSync(fd);
1611
- descriptorClosed = true;
1612
- }
1967
+ fs.closeSync(fd);
1968
+ descriptorClosed = true;
1613
1969
  };
1614
1970
  const assertOwned = (): void => {
1615
1971
  const current = parseLock(lockPath);
@@ -1623,17 +1979,23 @@ export async function acquireManagedLock(
1623
1979
  released ||
1624
1980
  descriptorClosed ||
1625
1981
  !current ||
1982
+ current.released === true ||
1626
1983
  !sameFileIdentity(lockIdentity, named) ||
1627
- current.attemptId !== attemptId ||
1628
- current.leaseExpiresAt < Date.now()
1984
+ current.attemptId !== attemptId
1629
1985
  )
1630
1986
  throw new Error("migration_busy");
1987
+ const now = Date.now();
1988
+ if (current.leaseExpiresAt < now + LOCK_HEARTBEAT_MS) {
1989
+ writeLockDescriptor(fd, {
1990
+ ...record,
1991
+ heartbeatAt: now,
1992
+ leaseExpiresAt: now + LOCK_LEASE_MS,
1993
+ });
1994
+ }
1631
1995
  };
1632
1996
  const heartbeat = setInterval(() => {
1633
1997
  try {
1634
1998
  assertOwned();
1635
- const now = Date.now();
1636
- writeLockDescriptor(fd, { ...record, heartbeatAt: now, leaseExpiresAt: now + LOCK_LEASE_MS });
1637
1999
  } catch {
1638
2000
  /* fencing rejects later publication */
1639
2001
  }
@@ -1646,10 +2008,16 @@ export async function acquireManagedLock(
1646
2008
  clearInterval(heartbeat);
1647
2009
  try {
1648
2010
  assertOwned();
2011
+ secureFileDescriptor(lockPath, fd, "verify");
1649
2012
  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 });
2013
+ // A released record is the only live-process reclaim authority. Expiry alone
2014
+ // never authorizes stealing from a holder whose process is still present.
2015
+ writeLockDescriptor(fd, {
2016
+ ...record,
2017
+ released: true,
2018
+ heartbeatAt: now,
2019
+ leaseExpiresAt: now,
2020
+ });
1653
2021
  fsyncDirectory(locksDirectory);
1654
2022
  } finally {
1655
2023
  released = true;
@@ -1659,24 +2027,27 @@ export async function acquireManagedLock(
1659
2027
  };
1660
2028
  } catch (error) {
1661
2029
  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;
2030
+ const observed = captureLock(lockPath);
2031
+ const owner = observed?.record;
2032
+ const reclaimable =
2033
+ owner?.released === true ||
2034
+ (owner !== undefined && owner.leaseExpiresAt < Date.now() && ownerDefinitelyGone(owner));
2035
+ if (observed && owner && reclaimable) {
2036
+ try {
2037
+ ManagedLockTestHooks.beforeObservedRetirement?.({ path: lockPath, attemptId: owner.attemptId });
2038
+ const removed = exactUnlink(lockPath, {
2039
+ dev: observed.snapshot.identity.dev,
2040
+ ino: observed.snapshot.identity.ino,
2041
+ size: BigInt(observed.snapshot.identity.size),
2042
+ mtimeNs: observed.snapshot.identity.mtimeNs,
2043
+ sha256: observed.snapshot.identity.sha256,
2044
+ quarantineName: `.gjc-lock-${randomUUID()}.stale`,
2045
+ });
2046
+ if (removed.ok || removed.code === "cleanup_pending") fsyncDirectory(locksDirectory);
2047
+ } catch {
2048
+ /* retry owner observation */
1678
2049
  }
1679
- } else staleObservedAt = undefined;
2050
+ }
1680
2051
  if (Date.now() >= deadline) throw new Error("migration_busy");
1681
2052
  await new Promise<void>(resolve => setTimeout(resolve, 50));
1682
2053
  }