agents-can-communicate 0.8.4 → 0.9.0

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 (142) hide show
  1. package/README.md +11 -7
  2. package/bin/entrypoints/acc-antigravity-relay.mjs +2 -4
  3. package/bin/entrypoints/acc-hook.mjs +33 -19
  4. package/bin/entrypoints/hook-output.mjs +14 -5
  5. package/docs/ADAPTER_AUTHORING.md +32 -0
  6. package/docs/ARCHITECTURE.md +7 -4
  7. package/docs/CAPABILITIES.md +7 -6
  8. package/docs/CLI.md +5 -0
  9. package/docs/CONFIGURATION.md +15 -10
  10. package/docs/DESIGN_DECISIONS.md +0 -2
  11. package/docs/GETTING_STARTED.md +39 -6
  12. package/docs/HOW_IT_WORKS.md +13 -8
  13. package/docs/SECURITY_MODEL.md +26 -5
  14. package/docs/TROUBLESHOOTING.md +74 -12
  15. package/docs/UPGRADING.md +50 -5
  16. package/node_modules/@agents-can-communicate/adapter-antigravity/certification.json +78 -0
  17. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/certification-provenance.json +111 -0
  18. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/delivery/antigravity-desktop-2.19.1-product-evidence.json +156 -0
  19. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/delivery/antigravity-desktop-2.19.1-product.json +25 -0
  20. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/desktop-2.19.1.json +141 -0
  21. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +4 -1
  22. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  23. package/node_modules/@agents-can-communicate/adapter-antigravity/src/adapter.mjs +13 -2
  24. package/node_modules/@agents-can-communicate/adapter-antigravity/src/agentapi.mjs +7 -5
  25. package/node_modules/@agents-can-communicate/adapter-antigravity/src/allow-rule.mjs +98 -17
  26. package/node_modules/@agents-can-communicate/adapter-antigravity/src/client-process.mjs +64 -0
  27. package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-allow-rule.mjs +140 -0
  28. package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-delivery.mjs +110 -0
  29. package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-endpoint.mjs +203 -0
  30. package/node_modules/@agents-can-communicate/adapter-antigravity/src/install.mjs +61 -30
  31. package/node_modules/@agents-can-communicate/adapter-antigravity/src/native-delivery.mjs +61 -24
  32. package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay-endpoint.mjs +30 -20
  33. package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay-start.mjs +2 -1
  34. package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay.mjs +20 -12
  35. package/node_modules/@agents-can-communicate/adapter-antigravity/src/relays.mjs +18 -4
  36. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  37. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +6 -0
  38. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-delivery.mjs +50 -37
  39. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +44 -31
  40. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-registry.mjs +99 -14
  41. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-settings.mjs +3 -7
  42. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-socket-directories.mjs +6 -4
  43. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +32 -7
  44. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  45. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +68 -3
  46. package/node_modules/@agents-can-communicate/adapter-codex/src/daemon-layouts.mjs +13 -0
  47. package/node_modules/@agents-can-communicate/adapter-codex/src/embedded-host.mjs +87 -13
  48. package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +16 -6
  49. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +18 -9
  50. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +68 -27
  51. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +66 -26
  52. package/node_modules/@agents-can-communicate/adapter-codex/src/native-endpoint.mjs +39 -16
  53. package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +4 -2
  54. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  55. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  56. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +2 -1
  57. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +15 -9
  58. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  59. package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +25 -14
  60. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  61. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +2 -1
  62. package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +15 -10
  63. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  64. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +50 -5
  65. package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +11 -2
  66. package/node_modules/@agents-can-communicate/adapter-sdk/src/executables.mjs +149 -0
  67. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +79 -12
  68. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +11 -2
  69. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt.mjs +6 -2
  70. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +82 -17
  71. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +9 -2
  72. package/node_modules/@agents-can-communicate/adapter-sdk/src/private-files.mjs +35 -0
  73. package/node_modules/@agents-can-communicate/adapter-sdk/src/process-table.mjs +232 -0
  74. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +8 -1
  75. package/node_modules/@agents-can-communicate/adapter-sdk/src/windows-command.mjs +69 -0
  76. package/node_modules/@agents-can-communicate/adapter-sdk/src/windows-process-chain.wsf +45 -0
  77. package/node_modules/@agents-can-communicate/cli/package.json +2 -1
  78. package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +2 -1
  79. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +60 -4
  80. package/node_modules/@agents-can-communicate/cli/src/hook-support.mjs +9 -0
  81. package/node_modules/@agents-can-communicate/cli/src/hook-workspace.mjs +12 -3
  82. package/node_modules/@agents-can-communicate/cli/src/main.mjs +2 -1
  83. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +3 -2
  84. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +12 -3
  85. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +7 -5
  86. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +7 -7
  87. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +8 -5
  88. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +1 -1
  89. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/mutex.mjs +7 -6
  90. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/portable-fs.mjs +165 -0
  91. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +1 -1
  92. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +18 -8
  93. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +18 -3
  94. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +15 -4
  95. package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +15 -10
  96. package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +3 -2
  97. package/node_modules/@agents-can-communicate/cli/src/update-command.mjs +11 -1
  98. package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +4 -2
  99. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  100. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +7 -0
  101. package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +39 -1
  102. package/node_modules/@agents-can-communicate/core/src/session-opening.mjs +11 -0
  103. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +8 -2
  104. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  105. package/node_modules/@agents-can-communicate/delivery-router/src/native-reason.mjs +6 -2
  106. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +9 -5
  107. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  108. package/node_modules/@agents-can-communicate/hook-runner/src/client-pid.mjs +65 -6
  109. package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +80 -0
  110. package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +7 -2
  111. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +6 -3
  112. package/node_modules/@agents-can-communicate/hook-runner/src/owner-context.mjs +34 -6
  113. package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +3 -50
  114. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +167 -34
  115. package/node_modules/@agents-can-communicate/installer/package.json +3 -2
  116. package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +11 -2
  117. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +13 -3
  118. package/node_modules/@agents-can-communicate/installer/src/hook-support.mjs +5 -0
  119. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +6 -14
  120. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +14 -0
  121. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  122. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  123. package/node_modules/@agents-can-communicate/protocol/src/config.mjs +6 -2
  124. package/node_modules/@agents-can-communicate/protocol/src/resources.mjs +6 -2
  125. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  126. package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +5 -2
  127. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +41 -23
  128. package/node_modules/@agents-can-communicate/storage-filesystem/src/doomed-directory.mjs +4 -12
  129. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +7 -3
  130. package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +1 -0
  131. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +24 -7
  132. package/node_modules/@agents-can-communicate/storage-filesystem/src/portable-fs.mjs +165 -0
  133. package/node_modules/@agents-can-communicate/storage-filesystem/src/publication-path.mjs +8 -0
  134. package/node_modules/@agents-can-communicate/storage-filesystem/src/reclaim.mjs +26 -17
  135. package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +7 -5
  136. package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +7 -16
  137. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +138 -24
  138. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +3 -2
  139. package/node_modules/@agents-can-communicate/storage-filesystem/src/stage-sweep.mjs +9 -6
  140. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +30 -8
  141. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +102 -32
  142. package/package.json +1 -5
@@ -7,11 +7,12 @@ import { AccError, EXIT } from "@agents-can-communicate/protocol";
7
7
  // path is validated segment by segment against the canonical root, so a
8
8
  // symlinked ancestor cannot redirect a read or a publication - neither outside
9
9
  // the store, nor to another directory inside it.
10
- function invalidDirectory(message, directory, root, cause) {
10
+ function invalidDirectory(message, directory, root, cause, resolved) {
11
11
  return new AccError(EXIT.DATA, message, {
12
12
  directory,
13
13
  root,
14
14
  ...(cause === undefined ? {} : { cause }),
15
+ ...(resolved === undefined ? {} : { resolved }),
15
16
  });
16
17
  }
17
18
 
@@ -40,7 +41,7 @@ async function inspectRealDirectory(directory, root, create) {
40
41
  for (let attempt = 0; ; attempt += 1) {
41
42
  let details;
42
43
  try {
43
- details = await lstat(directory);
44
+ details = await lstat(directory, { bigint: true });
44
45
  } catch (error) {
45
46
  if (error.code !== "ENOENT") {
46
47
  throw invalidDirectory("cannot inspect managed directory", directory, root, error.message);
@@ -64,10 +65,46 @@ async function inspectRealDirectory(directory, root, create) {
64
65
  }
65
66
  }
66
67
 
67
- async function inspectManagedDirectory(rootPath, directoryPath, create, { afterInspect } = {}) {
68
+ // A directory validated once is known by its identity: volume and file id, as
69
+ // bigint because an NTFS file id does not fit a double. The same name is
70
+ // checked again with one lstat, which follows every ancestor, so an ancestor
71
+ // replaced by a link lands on another directory and anything but the identity
72
+ // that was validated takes the whole walk again. What is not repeated is
73
+ // finding the same directory under the same name: every record read walked
74
+ // its path from the root twice, about a thousand calls a hook and 130 ms of one
75
+ // on windows-latest. The one case the walk would still refuse is the store's
76
+ // own directory moved elsewhere with a link left at its name - the same data in
77
+ // another place (decided 2026-09-30). The seams bypass it: the race tests
78
+ // exercise the walk itself.
79
+ const VALIDATED = new Map();
80
+ const VALIDATED_LIMIT = 512;
81
+
82
+ async function validatedEarlier(key, directory) {
83
+ const known = VALIDATED.get(key);
84
+ if (known === undefined) return null;
85
+ const current = await lstat(directory, { bigint: true }).catch(() => null);
86
+ if (current !== null && current.isDirectory() && !current.isSymbolicLink()
87
+ && current.dev === known.dev && current.ino === known.ino) return current;
88
+ VALIDATED.delete(key);
89
+ return null;
90
+ }
91
+
92
+ function remember(key, details) {
93
+ if (VALIDATED.size >= VALIDATED_LIMIT) VALIDATED.clear();
94
+ VALIDATED.set(key, { dev: details.dev, ino: details.ino });
95
+ }
96
+
97
+ async function inspectManagedDirectory(rootPath, directoryPath, create, options = {}) {
98
+ const { afterInspect, realpath: resolve = realpath, platform = process.platform } = options;
68
99
  const root = absolutePath(rootPath, "managed root");
69
100
  const directory = absolutePath(directoryPath, "managed directory", root);
70
101
  const relative = relativeWithin(root, directory);
102
+ const key = options.afterInspect === undefined && options.realpath === undefined
103
+ && options.platform === undefined ? `${root}\u0000${directory}` : null;
104
+ if (key !== null) {
105
+ const known = await validatedEarlier(key, directory);
106
+ if (known !== null) return { directory, stat: known };
107
+ }
71
108
  let details = await inspectRealDirectory(root, root, create);
72
109
  const canonicalRoot = await realpath(root);
73
110
  let current = root;
@@ -78,15 +115,33 @@ async function inspectManagedDirectory(rootPath, directoryPath, create, { afterI
78
115
  for (const segment of relative.split(path.sep).filter(Boolean)) {
79
116
  current = path.join(current, segment);
80
117
  let resolved;
81
- for (let attempt = 0; ; attempt += 1) {
82
- details = await inspectRealDirectory(current, root, create);
83
- await afterInspect?.(current);
84
- // A create-mode caller uses this name next. A directory renamed away
85
- // between the check and the resolution leaves realpath nothing to resolve
86
- // (ENOENT), so it is checked and created again, then resolved again; every
87
- // check below applies to whichever attempt settles.
88
- resolved = await resolveSegment(current, create && attempt < VANISHED_RETRIES);
89
- if (resolved !== null) break;
118
+ for (let look = 1; ; look += 1) {
119
+ for (let attempt = 0; ; attempt += 1) {
120
+ details = await inspectRealDirectory(current, root, create);
121
+ await afterInspect?.(current);
122
+ // A create-mode caller uses this name next. A directory renamed away
123
+ // between the check and the resolution leaves realpath nothing to
124
+ // resolve (ENOENT), so it is checked and created again, then resolved
125
+ // again; every check below applies to whichever attempt settles.
126
+ resolved = await resolveSegment(current, create && attempt < VANISHED_RETRIES,
127
+ { resolve, platform, root });
128
+ if (resolved !== null) break;
129
+ }
130
+ if (path.dirname(resolved) === canonicalParent) break;
131
+ // An answer outside the parent says where the directory lives only when
132
+ // that place holds this directory. On windows-latest, a lock renamed
133
+ // aside and back without a pause was once answered with its name under
134
+ // another store's root, which an earlier test had already removed (PR
135
+ // #243). A place holding nothing, or another directory, is asked again;
136
+ // a name that keeps answering that way has changed while it resolved.
137
+ if (await sameDirectoryAt(details, resolved, current, root)) {
138
+ throw invalidDirectory("managed directory escapes the canonical store root", current, root,
139
+ undefined, resolved);
140
+ }
141
+ if (look >= ELSEWHERE_RETRIES) {
142
+ throw invalidDirectory("managed parent directory changed while it resolved", current, root,
143
+ undefined, resolved);
144
+ }
90
145
  }
91
146
  // realpath answers with the name the directory carries *now*, which is not
92
147
  // always the name that was asked for: on Darwin it resolves by opening the
@@ -106,28 +161,86 @@ async function inspectManagedDirectory(rootPath, directoryPath, create, { afterI
106
161
  // swapped in under this name is not. ENOENT on the re-stat means it moved
107
162
  // again between the two calls, which cannot redirect anything: whatever
108
163
  // the caller opens through this path next fails the same way.
109
- if (path.dirname(resolved) !== canonicalParent
110
- || (path.basename(resolved) !== segment
111
- && !await stillTheSameDirectory(details, resolved, current, root))) {
112
- throw invalidDirectory("managed directory escapes the canonical store root", current, root);
164
+ if (path.basename(resolved) !== segment
165
+ && !await stillTheSameDirectory(details, resolved, current, root)) {
166
+ throw invalidDirectory("managed directory escapes the canonical store root", current, root,
167
+ undefined, resolved);
113
168
  }
114
169
  canonicalParent = resolved;
115
170
  }
171
+ if (key !== null) remember(key, details);
116
172
  return { directory, stat: details };
117
173
  }
118
174
 
175
+ // Where NTFS keeps a directory deleted while something still has it open.
176
+ const DELETED = /^(?:\\\\\?\\)?[A-Za-z]:\\\$Extend\\\$Deleted\\/i;
177
+
178
+ // How many times a name answered with a directory leaving it is resolved again.
179
+ const LEAVING_RETRIES = 3;
180
+
181
+ // How many times a name answered with a place that does not hold it is looked
182
+ // at again.
183
+ const ELSEWHERE_RETRIES = 3;
184
+
119
185
  // null when the name went away and the caller may take it again.
120
- async function resolveSegment(current, retry) {
121
- try {
122
- return await realpath(current);
123
- } catch (error) {
124
- if (error.code === "ENOENT" && retry) return null;
125
- throw error;
186
+ //
187
+ // Linux realpath fails ENOENT on a name removed or renamed away while it
188
+ // resolves. Windows, measured on windows-latest, answers that window with
189
+ // EBADF, with EPERM, or with the directory's place in $Extend\$Deleted (above):
190
+ // the handle it took was to a directory leaving the name. The name itself may
191
+ // already hold a new one - `stage` does, right after a sweep - so it is resolved
192
+ // again. A directory there is the answer, no directory there is gone, and a
193
+ // name that keeps answering that way while present fails as it always did:
194
+ // read as gone, a directory would read as holding no records.
195
+ async function resolveSegment(current, retry, { resolve, platform, root }) {
196
+ for (let attempt = 1; ; attempt += 1) {
197
+ let resolved = null;
198
+ let failure = null;
199
+ try {
200
+ resolved = await resolve(current);
201
+ } catch (error) {
202
+ failure = error;
203
+ }
204
+ const leaving = platform === "win32" && (failure === null ? DELETED.test(resolved)
205
+ : ["EBADF", "EPERM"].includes(failure.code));
206
+ if (failure === null && !leaving) return resolved;
207
+ if (failure?.code === "ENOENT") {
208
+ if (retry) return null;
209
+ throw failure;
210
+ }
211
+ if (!leaving) throw failure;
212
+ if (attempt < LEAVING_RETRIES) continue;
213
+ const present = await lstat(current).then(() => true, missing => missing.code !== "ENOENT");
214
+ // Present: refused, never read as gone - its records would read as none.
215
+ // Deleted directories' places, or EBADF - a handle taken to a directory
216
+ // leaving the name - while the name stays, is a directory moving in and out
217
+ // of the name; the writer lock does so for a living. It is refused as
218
+ // changed, which a reader takes as a reason to look again, and never as an
219
+ // escape: nothing left the store. EPERM can be a refusal in earnest and
220
+ // stays what it says.
221
+ if (present) {
222
+ if (failure !== null && failure.code !== "EBADF") throw failure;
223
+ throw invalidDirectory("managed parent directory changed while it resolved", current, root,
224
+ undefined, resolved);
225
+ }
226
+ if (retry) return null;
227
+ throw Object.assign(new Error(`ENOENT: ${current} left its name while it resolved`,
228
+ failure === null ? {} : { cause: failure }), { code: "ENOENT", syscall: "realpath", path: current });
126
229
  }
127
230
  }
128
231
 
232
+ // Whether `resolved` holds the directory `details` describes. A place that
233
+ // holds nothing is not where it lives.
234
+ async function sameDirectoryAt(details, resolved, current, root) {
235
+ const served = await stat(resolved, { bigint: true }).then(found => found, error => {
236
+ if (["ENOENT", "ENOTDIR"].includes(error.code)) return null;
237
+ throw invalidDirectory("cannot inspect managed directory", current, root, error.message);
238
+ });
239
+ return served !== null && served.dev === details.dev && served.ino === details.ino;
240
+ }
241
+
129
242
  async function stillTheSameDirectory(details, resolved, current, root) {
130
- const served = await stat(resolved).then(found => found, error => {
243
+ const served = await stat(resolved, { bigint: true }).then(found => found, error => {
131
244
  if (error.code === "ENOENT") return null;
132
245
  throw invalidDirectory("cannot inspect managed directory", current, root, error.message);
133
246
  });
@@ -135,7 +248,8 @@ async function stillTheSameDirectory(details, resolved, current, root) {
135
248
  }
136
249
 
137
250
  // `afterInspect` is the seam the race tests use: it runs between a segment's
138
- // check and its resolution, the window a concurrent rename lands in.
251
+ // check and its resolution, the window a concurrent rename lands in. `realpath`
252
+ // and `platform` let them put a platform's answer in that window.
139
253
  export async function assertManagedDirectory(rootPath, directoryPath, options) {
140
254
  return inspectManagedDirectory(rootPath, directoryPath, false, options);
141
255
  }
@@ -4,13 +4,14 @@ import path from "node:path";
4
4
 
5
5
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
6
6
 
7
+ import { openNoFollow } from "./portable-fs.mjs";
7
8
  import { assertManagedDirectory } from "./safe-directory.mjs";
8
9
 
9
10
  function sameDirectory(left, right) {
10
11
  return left.dev === right.dev && left.ino === right.ino;
11
12
  }
12
13
 
13
- // The operation runs through the opened handle only after O_NOFOLLOW and the
14
+ // The operation runs through the opened handle only after the no-follow open and the
14
15
  // before/after parent identity check agree. Replacing the pathname after that
15
16
  // point cannot redirect a read or append through the already-open descriptor.
16
17
  export async function withRegularNoFollow(filePath, root, flags, operation, openFile = open) {
@@ -18,7 +19,7 @@ export async function withRegularNoFollow(filePath, root, flags, operation, open
18
19
  const before = await assertManagedDirectory(root, parent);
19
20
  let handle;
20
21
  try {
21
- handle = await openFile(filePath, flags | constants.O_NOFOLLOW);
22
+ handle = await openNoFollow(filePath, flags, { open: openFile });
22
23
  } catch (error) {
23
24
  if (error.code === "ENOENT") throw error;
24
25
  throw new AccError(EXIT.DATA, "cannot safely open regular file",
@@ -1,11 +1,11 @@
1
- import { rename } from "node:fs/promises";
2
1
  import path from "node:path";
3
2
 
4
3
  import { EXIT } from "@agents-can-communicate/protocol";
5
4
 
6
5
  import { encode, listDirectoryEntries, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
7
- import { discard, discardLeftovers, doomedName, expired, syncDirectory }
6
+ import { discard, discardLeftovers, doomedName, expired }
8
7
  from "./doomed-directory.mjs";
8
+ import { renameEntry, syncEntry } from "./portable-fs.mjs";
9
9
  import { reclaimRetired } from "./reclaim.mjs";
10
10
  import { ensureManagedDirectory } from "./safe-directory.mjs";
11
11
 
@@ -30,7 +30,7 @@ async function reclaimLegacy(paths, root, detached, budget, deadlineAt) {
30
30
  for (const entry of await listDirectoryEntries(paths.tmp, { root })) {
31
31
  if (spent >= budget || expired(deadlineAt)) return { spent, drained: false };
32
32
  if (!entry.isFile() || !entry.name.endsWith(".published")) continue;
33
- await rename(path.join(paths.tmp, entry.name), path.join(detached, entry.name));
33
+ await renameEntry(path.join(paths.tmp, entry.name), path.join(detached, entry.name));
34
34
  spent += 1;
35
35
  }
36
36
  return { spent, drained: true };
@@ -69,9 +69,9 @@ export async function sweepAcceptedStages(paths,
69
69
  // the ordinary case rather than an error, and such a store is exactly the one
70
70
  // whose accepted stages are all still in tmp for the reclamation below.
71
71
  await ensureManagedDirectory(root, paths.stage);
72
- await rename(paths.stage, detached);
72
+ await renameEntry(paths.stage, detached);
73
73
  await ensureManagedDirectory(root, paths.stage);
74
- await syncDirectory(root);
74
+ await syncEntry(root, detached);
75
75
 
76
76
  const reclaimed = await reclaimLegacy(paths, root, detached, limit - spent, deadlineAt);
77
77
  spent += reclaimed.spent;
@@ -167,8 +167,11 @@ export async function sweepIfDue(paths,
167
167
  if (result.remaining || expired(deadlineAt)) {
168
168
  return { swept: result.swept, remaining: true };
169
169
  }
170
+ // Without a flush: sweptAt reads a damaged or missing marker as a pass
171
+ // that is due, so a crash costs one more sweep.
170
172
  await publishAtomic(markerPath(paths), encode({ sweptAt: clock.now() }),
171
- { root, tmpDir: paths.tmp, stageDir: paths.stage, replace: true, deadlineAt });
173
+ { root, tmpDir: paths.tmp, stageDir: paths.stage, replace: true, durability: "none",
174
+ deadlineAt });
172
175
  return result;
173
176
  });
174
177
  }
@@ -6,7 +6,8 @@ import { AccError, EXIT, assertPortableId, validateRecord }
6
6
 
7
7
  import { encode, listDirectoryEntries, listJsonFiles, publishAtomic, readJsonIfPresent,
8
8
  retainFile } from "./atomic-json.mjs";
9
- import { initialiseActiveJournal } from "./active-journal.mjs";
9
+ import { initialiseActiveJournal, readActiveJournal } from "./active-journal.mjs";
10
+ import { publicationPath } from "./publication-path.mjs";
10
11
  import { assertPublicationDeadline } from "./deadline.mjs";
11
12
  import { requireStoreIdentity } from "./identity.mjs";
12
13
  import { journalEntry, readJournalCeiling, readOpenJournals, rollForward, writeJournalEntry }
@@ -26,6 +27,11 @@ import { withWriterMutex } from "./writer-mutex.mjs";
26
27
  // make it easy to reintroduce separate locks and resurrect replaced sessions.
27
28
 
28
29
  const SEQUENCE_WIDTH = 16;
30
+ // An ephemeral record describes a live session. A crash of the machine ends
31
+ // every session it describes, and a session whose record comes back as its
32
+ // previous version, or not at all, is recovered by its next hook. Its bytes are
33
+ // still flushed, so no reader finds a torn record.
34
+ const EPHEMERAL = "bytes";
29
35
  export const ZERO_CURSOR = "0".repeat(SEQUENCE_WIDTH);
30
36
  // No quarantine area. One was created in every workspace, named in the path
31
37
  // typedef, and written to by nothing: repair deliberately refuses to move a
@@ -100,7 +106,7 @@ async function nextSequence(paths, root) {
100
106
  }
101
107
 
102
108
  export async function openFilesystemStore({ root, clock, ids, workspaceId, failAt,
103
- deadlineAt: storeDeadline }) {
109
+ deadlineAt: storeDeadline, platform = process.platform }) {
104
110
  assertPublicationDeadline(storeDeadline);
105
111
  const paths = storePaths(root);
106
112
  // The caller owns the root path, so its ancestors are created here. Inside the
@@ -109,9 +115,9 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
109
115
  await mkdir(root, { recursive: true });
110
116
  // Identity is settled before any read or write. Adopting a directory that
111
117
  // already belongs to another workspace is the failure this fails closed on.
112
- await requireStoreIdentity(paths, { workspaceId, clock });
118
+ await requireStoreIdentity(paths, { workspaceId, clock, platform });
113
119
  for (const name of DIRECTORIES) await ensureManagedDirectory(root, paths[name]);
114
- const publishOptions = { root, tmpDir: paths.tmp, clock, failAt };
120
+ const publishOptions = { root, tmpDir: paths.tmp, clock, failAt, platform };
115
121
  await initialiseActiveJournal(paths, publishOptions);
116
122
 
117
123
  // Any journal left behind by a crashed writer is completed before the store
@@ -251,7 +257,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
251
257
  // ceiling in eventsSince has to hide.
252
258
  const publications = [
253
259
  ...events.map(event => ({
254
- path: path.relative(root, eventPath(paths, event.sequence)),
260
+ path: publicationPath(root, eventPath(paths, event.sequence)),
255
261
  bytes: encode(event),
256
262
  replace: false,
257
263
  })),
@@ -259,13 +265,29 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
259
265
  ? stateDeletionPublication(paths, root, entry.kind, entry.id, entry.generation,
260
266
  statePath(paths, entry.kind, entry.id))
261
267
  : {
262
- path: path.relative(root, statePath(paths, entry.kind, entry.id)),
268
+ path: publicationPath(root, statePath(paths, entry.kind, entry.id)),
263
269
  bytes: encode(stateEnvelope(entry.kind, entry.id, entry.generation, entry.record)),
264
270
  replace: true,
265
271
  })),
266
272
  ];
267
273
  if (publications.length === 0) return result;
268
274
 
275
+ // One record replaced and nothing appended - a session's heartbeat, every
276
+ // turn - needs no journal to appear at once: the rename replaces it
277
+ // atomically, and the bytes are flushed before and after it. The journal
278
+ // cost five atomic writes for it, ten flushes on Windows at 8 to 23 ms
279
+ // each (windows-latest). Only while no other transaction is open: an open
280
+ // one would later roll its own bytes over this record, and it refuses a
281
+ // journalled write too, which the path below keeps.
282
+ const [only] = staged.values();
283
+ if (events.length === 0 && staged.size === 1 && only.removed !== true
284
+ && (await readActiveJournal(paths, root)).state === "idle") {
285
+ assertPublicationDeadline(deadlineAt);
286
+ await publishAtomic(statePath(paths, only.kind, only.id), publications[0].bytes,
287
+ { ...publishOptions, replace: true, deadlineAt });
288
+ return result;
289
+ }
290
+
269
291
  // Preparation is still cancellable. The active journal's atomic
270
292
  // publication decides the write; roll-forward must then finish even if
271
293
  // this invocation expires, so its publication options carry no deadline.
@@ -372,7 +394,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
372
394
  validateRecord(kind, record);
373
395
  return withWriterMutex(paths, { ...publishOptions, deadlineAt: storeDeadline }, async () => {
374
396
  await publishAtomic(ephemeralPath(kind, id), encode(record),
375
- { root, tmpDir: paths.tmp, replace: true, deadlineAt: storeDeadline });
397
+ { root, tmpDir: paths.tmp, replace: true, durability: EPHEMERAL, deadlineAt: storeDeadline });
376
398
  // Once record bytes are accepted, finish the same logical publication.
377
399
  await markEphemeral(paths, publishOptions, kind, id, "present");
378
400
  return record;
@@ -386,7 +408,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
386
408
  validateRecord(kind, next);
387
409
  assertPublicationDeadline(deadlineAt);
388
410
  await publishAtomic(ephemeralPath(kind, id), encode(next),
389
- { root, tmpDir: paths.tmp, replace: true, deadlineAt });
411
+ { root, tmpDir: paths.tmp, replace: true, durability: EPHEMERAL, deadlineAt });
390
412
  await markEphemeral(paths, publishOptions, kind, id, "present");
391
413
  return next;
392
414
  });
@@ -1,12 +1,13 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
- import { mkdir, open, rename, rm } from "node:fs/promises";
3
+ import { lstat, mkdir, rmdir } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { performance } from "node:perf_hooks";
6
6
 
7
7
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
8
8
 
9
9
  import { encode, readJsonIfPresent } from "./atomic-json.mjs";
10
+ import { isWindows, removeTree, renameEntry } from "./portable-fs.mjs";
10
11
  import { ensureManagedDirectory } from "./safe-directory.mjs";
11
12
  import { withRegularNoFollow } from "./safe-file.mjs";
12
13
 
@@ -16,8 +17,15 @@ const OWNER = "owner.json";
16
17
  const sleepFor = duration => new Promise(resolve => { setTimeout(resolve, duration); });
17
18
 
18
19
  // A fully prepared directory is the atomic primitive. Publishing the owner
19
- // with the directory means a crash can leave an unused candidate, but never an
20
- // ownerless canonical lock that blocks every later writer.
20
+ // with the directory means a process that dies can leave an unused candidate,
21
+ // but never an ownerless canonical lock that blocks every later writer.
22
+ //
23
+ // Nothing here is flushed. The lock guards live processes, and a crash of the
24
+ // machine leaves none: whatever it left in the lock guards nobody. A flush cost
25
+ // up to 5.8 s on windows-latest beside the suite (2026-10-01), three of them per
26
+ // acquisition on POSIX, and the only thing they bought was an owner record that
27
+ // stays readable through a crash. An unreadable one is reclaimed instead, once
28
+ // it is older than any write takes (takeStaleOwnership).
21
29
  function defaultPidIsAlive(pid) {
22
30
  try {
23
31
  process.kill(pid, 0);
@@ -27,16 +35,7 @@ function defaultPidIsAlive(pid) {
27
35
  }
28
36
  }
29
37
 
30
- async function syncDirectory(directory) {
31
- const handle = await open(directory, "r");
32
- try {
33
- await handle.sync();
34
- } finally {
35
- await handle.close();
36
- }
37
- }
38
-
39
- async function prepareCandidate(paths, root, owner) {
38
+ async function prepareCandidate(paths, root, owner, platform) {
40
39
  const identity = createHash("sha256").update(owner.token).digest("hex");
41
40
  const candidate = path.join(paths.locks, `writer.candidate-${identity}.lock`);
42
41
  await mkdir(candidate);
@@ -44,17 +43,15 @@ async function prepareCandidate(paths, root, owner) {
44
43
  await withRegularNoFollow(path.join(candidate, OWNER), root,
45
44
  constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, async handle => {
46
45
  await handle.writeFile(encode(owner));
47
- await handle.sync();
48
46
  });
49
- await syncDirectory(candidate);
50
47
  return candidate;
51
48
  } catch (error) {
52
- await rm(candidate, { recursive: true, force: true });
49
+ await removeTree(candidate, { platform });
53
50
  throw error;
54
51
  }
55
52
  }
56
53
 
57
- async function releaseCanonical(directory, root, owner, openFile) {
54
+ async function releaseCanonical(directory, root, owner, openFile, { platform, rename }) {
58
55
  const current = await readOwner(directory, root, openFile);
59
56
  if (current?.token !== owner.token) return;
60
57
  // Recursive removal would first unlink owner.json and briefly expose an
@@ -63,14 +60,51 @@ async function releaseCanonical(directory, root, owner, openFile) {
63
60
  const identity = createHash("sha256")
64
61
  .update(JSON.stringify([owner.pid, owner.token, owner.acquiredAt]))
65
62
  .digest("hex");
63
+ if (isWindows(platform)) {
64
+ await releaseByOwnerRecord(directory,
65
+ path.join(path.dirname(directory), `writer.released-${identity}.json`), { platform, rename });
66
+ return;
67
+ }
66
68
  const retired = path.join(path.dirname(directory), `writer.released-${identity}.lock`);
67
69
  try {
68
- await rename(directory, retired);
70
+ // POSIX moves the whole lock aside; Windows released it above, by its
71
+ // owner record, because a reader inside holds the directory in place.
72
+ await renameEntry(directory, retired, { platform, rename });
69
73
  } catch (error) {
70
74
  if (["ENOENT", "EEXIST", "ENOTEMPTY"].includes(error.code)) return;
71
75
  throw error;
72
76
  }
73
- await rm(retired, { recursive: true, force: true });
77
+ await removeTree(retired, { platform });
78
+ }
79
+
80
+ /**
81
+ * Release on Windows, where a directory cannot be renamed while a file inside
82
+ * it is open - and every waiting writer opens owner.json to see who holds the
83
+ * lock. Eight writers polling one lock on windows-latest held the move back
84
+ * until one of them gave up at the acquire timeout.
85
+ *
86
+ * A file can be renamed while it is open (Node opens with FILE_SHARE_DELETE),
87
+ * so the owner record leaves first, in one rename, and the lock is an empty
88
+ * directory from then on. On Windows an empty lock is a released one: the
89
+ * acquiring loop removes it with rmdir, which cannot remove a lock that has an
90
+ * owner in it, and so does this owner. A reader that opened the record before
91
+ * it left sees this owner once more and looks again.
92
+ *
93
+ * Reclaiming a dead owner still moves the whole directory. Node's rename
94
+ * replaces an existing file on Windows, so a late reclaimer moving owner.json
95
+ * could take a successor's record; a directory never lands on another one.
96
+ */
97
+ async function releaseByOwnerRecord(directory, retiredOwner, { platform, rename }) {
98
+ try {
99
+ await renameEntry(path.join(directory, OWNER), retiredOwner, { platform, rename });
100
+ } catch (error) {
101
+ if (error.code === "ENOENT") return;
102
+ throw error;
103
+ }
104
+ // ENOENT: a waiting writer removed it first. ENOTEMPTY: a successor is in.
105
+ // Busy: somebody is looking at it, and the next writer removes it instead.
106
+ await rmdir(directory).catch(() => null);
107
+ await removeTree(retiredOwner, { platform });
74
108
  }
75
109
 
76
110
  /**
@@ -93,14 +127,22 @@ async function releaseCanonical(directory, root, owner, openFile) {
93
127
  * refuses everywhere a parent has no business changing.
94
128
  */
95
129
  async function readOwner(directory, root, openFile) {
130
+ const file = path.join(directory, OWNER);
96
131
  try {
97
- const found = await readJsonIfPresent(path.join(directory, OWNER), root, openFile);
132
+ const found = await readJsonIfPresent(file, root, openFile);
98
133
  return found?.value ?? null;
99
134
  } catch (error) {
100
135
  if (error instanceof AccError && error.code === EXIT.DATA
101
136
  && /parent directory changed/.test(error.message)) {
102
137
  return null;
103
138
  }
139
+ // Bytes a crash kept from the disk: an empty record, or zeros where NTFS
140
+ // kept its length. Its age is all that is left to judge it by.
141
+ if (error instanceof AccError && error.code === EXIT.DATA
142
+ && /^invalid JSON record/.test(error.message)) {
143
+ const stat = await lstat(file).catch(() => null);
144
+ return stat === null ? null : { unreadable: true, modifiedMs: stat.mtimeMs };
145
+ }
104
146
  throw error;
105
147
  }
106
148
  }
@@ -125,25 +167,40 @@ async function readOwner(directory, root, openFile) {
125
167
  * unrelated, and then `pidIsAlive` says yes forever. Sixty seconds is far longer
126
168
  * than any write here takes - the hook budget is five - so a holder that old is
127
169
  * not a writer that is still going.
170
+ *
171
+ * An owner record nobody can read is judged by the same age, on the clock that
172
+ * stamped its file. Only a crash of the machine leaves one: a live writer's
173
+ * record is complete before the lock carries it, and an older ACC that wrote
174
+ * into the lock directly takes microseconds to finish.
128
175
  */
129
- async function takeStaleOwnership(directory, root, owner, now, pidIsAlive) {
176
+ async function takeStaleOwnership(directory, root, owner, now, pidIsAlive,
177
+ { platform, rename, deadlineAt, wallNow }) {
130
178
  if (owner === null) return false;
131
- const age = Date.parse(now) - Date.parse(owner.acquiredAt);
132
- if (pidIsAlive(owner.pid) && !(age > STALE_MS)) return false;
179
+ if (owner.unreadable === true) {
180
+ if (!(wallNow() - owner.modifiedMs > STALE_MS)) return false;
181
+ } else {
182
+ const age = Date.parse(now) - Date.parse(owner.acquiredAt);
183
+ if (pidIsAlive(owner.pid) && !(age > STALE_MS)) return false;
184
+ }
133
185
  // Every contender that observed this owner names the same retained target.
134
186
  // rename() moves the whole lock atomically; exactly one contender can put it
135
187
  // there. The target is deliberately left non-empty. A late contender cannot
136
188
  // rename a successor over it, so a stale pathname observation never becomes
137
189
  // permission to remove the writer that acquired the lock afterwards.
138
190
  const identity = createHash("sha256")
139
- .update(JSON.stringify([owner.pid, owner.token, owner.acquiredAt]))
191
+ .update(JSON.stringify(owner.unreadable === true ? ["unreadable", owner.modifiedMs]
192
+ : [owner.pid, owner.token, owner.acquiredAt]))
140
193
  .digest("hex");
141
194
  const reclaimed = path.join(path.dirname(directory), `writer.reclaimed-${identity}.lock`);
142
195
  try {
143
- await rename(directory, reclaimed);
196
+ await renameEntry(directory, reclaimed, { platform, rename, deadlineAt });
144
197
  return true;
145
198
  } catch (error) {
146
199
  if (["ENOENT", "EEXIST", "ENOTEMPTY"].includes(error.code)) return false;
200
+ // Windows refuses the move while a waiting writer has the owner record
201
+ // open, and it was retried up to this writer's deadline: not reclaimed
202
+ // this time, which the loop reports as the conflict it is.
203
+ if (isWindows(platform) && ["EPERM", "EACCES", "EBUSY"].includes(error.code)) return false;
147
204
  throw error;
148
205
  }
149
206
  }
@@ -152,7 +209,9 @@ export async function withWriterMutex(paths, options, operation) {
152
209
  const { root, clock, pidIsAlive = defaultPidIsAlive, uuid = randomUUID,
153
210
  attempts = Number.POSITIVE_INFINITY, waitMs = 20, openFile,
154
211
  acquireTimeoutMs = ACQUIRE_TIMEOUT_MS, monotonicNow = () => performance.now(),
155
- wallNow = Date.now, deadlineAt, sleep = sleepFor } = options;
212
+ wallNow = Date.now, deadlineAt, sleep = sleepFor, platform = process.platform,
213
+ rename } = options;
214
+ const fsOptions = { platform, rename };
156
215
  const directory = path.join(paths.locks, "writer.lock");
157
216
  // Wall time can jump while a process waits. A monotonic absolute deadline
158
217
  // bounds all owner reads and retries, leaving half the hook's five-second
@@ -164,7 +223,7 @@ export async function withWriterMutex(paths, options, operation) {
164
223
  await ensureManagedDirectory(root, paths.locks);
165
224
  const token = uuid();
166
225
  const owner = { pid: process.pid, token, acquiredAt: clock.now() };
167
- const candidate = await prepareCandidate(paths, root, owner);
226
+ const candidate = await prepareCandidate(paths, root, owner, platform);
168
227
  let ownsCanonical = false;
169
228
 
170
229
  try {
@@ -175,13 +234,24 @@ export async function withWriterMutex(paths, options, operation) {
175
234
  // lock left by the old mkdir-then-publish sequence. A live publisher
176
235
  // either fills it first (so rename fails) or loses its no-replace owner
177
236
  // publication after this complete candidate wins.
178
- await rename(candidate, directory);
237
+ // Windows reports the taken name as EPERM; renameEntry turns that into
238
+ // the EEXIST this loop waits on, and waits out a busy source itself.
239
+ await renameEntry(candidate, directory,
240
+ { ...fsOptions, deadlineAt: wallNow() + Math.max(0, deadline - monotonicNow()) });
179
241
  ownsCanonical = true;
180
- await syncDirectory(paths.locks);
181
242
  } catch (error) {
182
243
  if (!["EEXIST", "ENOTEMPTY"].includes(error.code)) throw error;
183
244
  const current = await readOwner(directory, root, openFile);
184
- if (!await takeStaleOwnership(directory, root, current, clock.now(), pidIsAlive)) {
245
+ // POSIX rename replaces the empty lock an older ACC's mkdir-then-publish
246
+ // left; Windows cannot rename onto any directory, and an owner there
247
+ // releases by leaving an empty one. rmdir removes only an empty one, so
248
+ // the outcome is POSIX's: a publisher still filling it loses its
249
+ // no-replace owner write, and a complete lock is never touched.
250
+ if (current === null && isWindows(platform)
251
+ && await rmdir(directory).then(() => true, () => false)) continue;
252
+ if (!await takeStaleOwnership(directory, root, current, clock.now(), pidIsAlive,
253
+ { ...fsOptions, wallNow,
254
+ deadlineAt: wallNow() + Math.max(0, deadline - monotonicNow()) })) {
185
255
  const remaining = deadline - monotonicNow();
186
256
  if (remaining <= 0) break;
187
257
  await sleep(Math.min(waitMs, remaining));
@@ -193,11 +263,11 @@ export async function withWriterMutex(paths, options, operation) {
193
263
  }
194
264
  } finally {
195
265
  if (ownsCanonical) {
196
- await releaseCanonical(directory, root, owner, openFile);
266
+ await releaseCanonical(directory, root, owner, openFile, fsOptions);
197
267
  } else {
198
268
  const current = await readOwner(candidate, root, openFile);
199
269
  if (current?.token === token) {
200
- await rm(candidate, { recursive: true, force: true });
270
+ await removeTree(candidate, { platform });
201
271
  }
202
272
  }
203
273
  }