agents-can-communicate 0.8.5 → 0.9.1

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 +29 -0
  6. package/docs/CAPABILITIES.md +7 -6
  7. package/docs/CLI.md +5 -0
  8. package/docs/CONFIGURATION.md +41 -10
  9. package/docs/DESIGN_DECISIONS.md +0 -2
  10. package/docs/GETTING_STARTED.md +39 -6
  11. package/docs/HOW_IT_WORKS.md +13 -8
  12. package/docs/SECURITY_MODEL.md +32 -5
  13. package/docs/TROUBLESHOOTING.md +76 -8
  14. package/docs/UPGRADING.md +47 -5
  15. package/node_modules/@agents-can-communicate/adapter-antigravity/certification.json +78 -0
  16. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/certification-provenance.json +111 -0
  17. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/delivery/antigravity-desktop-2.19.1-product-evidence.json +156 -0
  18. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/delivery/antigravity-desktop-2.19.1-product.json +25 -0
  19. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/desktop-2.19.1.json +141 -0
  20. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +4 -1
  21. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  22. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/skills/acc/SKILL.md +3 -0
  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/plugin/skills/acc/SKILL.md +3 -0
  38. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +6 -0
  39. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-delivery.mjs +50 -37
  40. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +44 -31
  41. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-registry.mjs +99 -14
  42. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-settings.mjs +3 -7
  43. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-socket-directories.mjs +6 -4
  44. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +32 -7
  45. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  46. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +3 -0
  47. package/node_modules/@agents-can-communicate/adapter-codex/src/allow-rule.mjs +129 -0
  48. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +68 -3
  49. package/node_modules/@agents-can-communicate/adapter-codex/src/daemon-layouts.mjs +13 -0
  50. package/node_modules/@agents-can-communicate/adapter-codex/src/embedded-host.mjs +42 -11
  51. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +36 -13
  52. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +68 -27
  53. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +36 -18
  54. package/node_modules/@agents-can-communicate/adapter-codex/src/native-endpoint.mjs +39 -16
  55. package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +4 -2
  56. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  57. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +3 -0
  58. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  59. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +2 -1
  60. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +15 -9
  61. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  62. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +3 -0
  63. package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +25 -14
  64. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  65. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +3 -0
  66. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +2 -1
  67. package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +15 -10
  68. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  69. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +50 -5
  70. package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +11 -2
  71. package/node_modules/@agents-can-communicate/adapter-sdk/src/executables.mjs +149 -0
  72. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +79 -12
  73. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +9 -1
  74. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +65 -10
  75. package/node_modules/@agents-can-communicate/adapter-sdk/src/own-version.mjs +27 -1
  76. package/node_modules/@agents-can-communicate/adapter-sdk/src/private-files.mjs +35 -0
  77. package/node_modules/@agents-can-communicate/adapter-sdk/src/process-table.mjs +232 -0
  78. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +8 -1
  79. package/node_modules/@agents-can-communicate/adapter-sdk/src/windows-command.mjs +69 -0
  80. package/node_modules/@agents-can-communicate/adapter-sdk/src/windows-process-chain.wsf +45 -0
  81. package/node_modules/@agents-can-communicate/cli/package.json +2 -1
  82. package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +2 -1
  83. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +60 -4
  84. package/node_modules/@agents-can-communicate/cli/src/hook-support.mjs +9 -0
  85. package/node_modules/@agents-can-communicate/cli/src/hook-workspace.mjs +12 -3
  86. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +3 -2
  87. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +12 -3
  88. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +7 -5
  89. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +7 -7
  90. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +8 -5
  91. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +1 -1
  92. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/mutex.mjs +7 -6
  93. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/portable-fs.mjs +165 -0
  94. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +1 -1
  95. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +18 -8
  96. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +11 -3
  97. package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +15 -10
  98. package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +3 -2
  99. package/node_modules/@agents-can-communicate/cli/src/update-command.mjs +11 -1
  100. package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +4 -2
  101. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  102. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +7 -0
  103. package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +39 -1
  104. package/node_modules/@agents-can-communicate/core/src/session-opening.mjs +11 -0
  105. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +8 -2
  106. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  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 +1 -1
  111. package/node_modules/@agents-can-communicate/hook-runner/src/owner-context.mjs +34 -6
  112. package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +3 -50
  113. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +154 -28
  114. package/node_modules/@agents-can-communicate/installer/package.json +3 -2
  115. package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +2 -1
  116. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +13 -3
  117. package/node_modules/@agents-can-communicate/installer/src/hook-support.mjs +5 -0
  118. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +6 -14
  119. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +6 -2
  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
@@ -1,22 +1,14 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { link, open, readdir, rename } from "node:fs/promises";
2
+ import { link, open, readdir } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
5
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
6
6
 
7
7
  import { assertPublicationDeadline } from "./deadline.mjs";
8
+ import { isWindows, renameReplacing, syncEntry } from "./portable-fs.mjs";
8
9
  import { assertManagedDirectory, ensureManagedDirectory } from "./safe-directory.mjs";
9
10
  import { readRegularNoFollow } from "./safe-file.mjs";
10
11
 
11
- async function syncDirectory(directory) {
12
- const handle = await open(directory, "r");
13
- try {
14
- await handle.sync();
15
- } finally {
16
- await handle.close();
17
- }
18
- }
19
-
20
12
  export function encode(value) {
21
13
  const serialised = JSON.stringify(value, null, 2);
22
14
  if (serialised === undefined) {
@@ -41,6 +33,20 @@ function retainedStage(destination, root, stageDir) {
41
33
  return path.join(stageDir, `${identity}.published`);
42
34
  }
43
35
 
36
+ const DURABILITY = new Set(["full", "bytes", "none"]);
37
+
38
+ /**
39
+ * The durability of a publication that another flush follows.
40
+ *
41
+ * On Windows flushing a file commits NTFS's metadata journal up to that file's
42
+ * last change, and that journal is one sequence: the next flush commits this
43
+ * rename too, so only the bytes are flushed here. A crash before that flush
44
+ * leaves the previous version, or nothing, and nothing written after it can be
45
+ * durable without it (docs/design/2026-09-30-native-windows-support.md, "What
46
+ * is flushed"). POSIX syncs the name's own directory.
47
+ */
48
+ export const nameCommittedLater = platform => (isWindows(platform) ? "bytes" : "full");
49
+
44
50
  async function replaceHandleBytes(handle, bytes) {
45
51
  await handle.truncate(0);
46
52
  let offset = 0;
@@ -61,11 +67,22 @@ async function replaceHandleBytes(handle, bytes) {
61
67
  * state is mutable by design, which is what generations exist for, so it is
62
68
  * published by rename(). Conflating the two makes every state update fail.
63
69
  *
70
+ * `durability` is what a crash of the machine may cost the record, since every
71
+ * flush waits for the whole disk - on windows-latest from 15 ms to 5.8 s:
72
+ * - "full" (the default): nothing; its bytes are flushed before they take the
73
+ * name, and the name is flushed before this returns;
74
+ * - "bytes": the name, so a reader finds the previous version and never torn
75
+ * bytes - for a record whose loss its owner recovers from;
76
+ * - "none": everything - for a record whose reader treats damage as absence.
77
+ *
64
78
  * @returns {Promise<"published" | "already_published">}
65
79
  */
66
80
  export async function publishAtomic(destination, bytes,
67
- { root, tmpDir, stageDir, replace = false, deadlineAt, afterAccepted, afterStageEnsured,
68
- afterStageRenamed }) {
81
+ { root, tmpDir, stageDir, replace = false, durability = "full", deadlineAt, afterAccepted,
82
+ afterStageEnsured, afterStageRenamed, sync = syncEntry }) {
83
+ if (!DURABILITY.has(durability)) {
84
+ throw new AccError(EXIT.USAGE, "unknown publication durability", { durability });
85
+ }
69
86
  assertPublicationDeadline(deadlineAt);
70
87
  // The accepted stage lives apart from the partial a failed publication
71
88
  // leaves, so what a file is follows from the directory it was created in
@@ -101,13 +118,16 @@ export async function publishAtomic(destination, bytes,
101
118
  let stageAcceptedBytes = false;
102
119
  try {
103
120
  await handle.writeFile(bytes);
104
- await handle.sync();
121
+ if (durability !== "none") await handle.sync();
105
122
  if (replace) {
106
123
  await handle.close();
107
124
  handle = null;
108
125
  assertPublicationDeadline(deadlineAt);
109
- await rename(temporary, destination);
110
- await syncDirectory(destinationDir);
126
+ await renameReplacing(temporary, destination, { deadlineAt });
127
+ // The bytes are at their name now: the write is decided and visible, and
128
+ // its flush is bounded by its own wait, never by the caller's deadline,
129
+ // which would report a published record as failed.
130
+ if (durability === "full") await sync(destinationDir, destination);
111
131
  return "published";
112
132
  }
113
133
 
@@ -115,7 +135,7 @@ export async function publishAtomic(destination, bytes,
115
135
  assertPublicationDeadline(deadlineAt);
116
136
  await link(temporary, destination);
117
137
  stageAcceptedBytes = true;
118
- await syncDirectory(destinationDir);
138
+ if (durability === "full") await sync(destinationDir, destination);
119
139
  return "published";
120
140
  } catch (error) {
121
141
  if (error.code !== "EEXIST") throw error;
@@ -160,9 +180,10 @@ export async function publishAtomic(destination, bytes,
160
180
  // The sweep can take `stage` between any two of these steps: it renames the
161
181
  // directory aside, and a store still opening publishes here without the writer
162
182
  // mutex the sweep holds (CI on #217). The bytes are already published, so the
163
- // retained copy goes to whichever directory carries the name when it moves, and
164
- // a copy the sweep took along with the directory needs no sync here - the sweep
165
- // discards that directory. The two callbacks are seams for the race tests.
183
+ // retained copy goes to whichever directory carries the name when it moves. The
184
+ // copy is never flushed: it is never read, and the sweep discards it. A crash
185
+ // that loses its move leaves the temporary in tmp, which is what tmp holds. The
186
+ // two callbacks are seams for the race tests.
166
187
  const STAGE_RETRIES = 4;
167
188
 
168
189
  async function retainAcceptedStage({ root, stageDir, temporary, stage, afterStageEnsured,
@@ -171,16 +192,13 @@ async function retainAcceptedStage({ root, stageDir, temporary, stage, afterStag
171
192
  await ensureManagedDirectory(root, stageDir);
172
193
  await afterStageEnsured?.();
173
194
  try {
174
- await rename(temporary, stage);
195
+ await renameReplacing(temporary, stage);
175
196
  break;
176
197
  } catch (error) {
177
198
  if (error.code !== "ENOENT" || attempt >= STAGE_RETRIES) throw error;
178
199
  }
179
200
  }
180
201
  await afterStageRenamed?.();
181
- await syncDirectory(stageDir).catch(error => {
182
- if (error.code !== "ENOENT") throw error;
183
- });
184
202
  }
185
203
 
186
204
  // The opener is the seam the race tests use, and stays last so callers that do
@@ -1,8 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { open, rename, rm, rmdir } from "node:fs/promises";
2
+ import { rmdir } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
5
  import { listDirectoryEntries } from "./atomic-json.mjs";
6
+ import { removeTree, renameEntry } from "./portable-fs.mjs";
6
7
  import { assertManagedDirectory, ensureManagedDirectory } from "./safe-directory.mjs";
7
8
 
8
9
  // The store never unlinks a live name: Node exposes unlink only by pathname, so
@@ -21,15 +22,6 @@ const DOOMED = "stage.sweeping-";
21
22
  // is reported as remaining, and the next pass adopts it.
22
23
  export const expired = deadlineAt => deadlineAt !== undefined && Date.now() >= deadlineAt;
23
24
 
24
- export async function syncDirectory(directory) {
25
- const handle = await open(directory, "r");
26
- try {
27
- await handle.sync();
28
- } finally {
29
- await handle.close();
30
- }
31
- }
32
-
33
25
  export async function doomedDirectories(root) {
34
26
  return (await listDirectoryEntries(root, { root }))
35
27
  .filter(entry => entry.isDirectory() && entry.name.startsWith(DOOMED))
@@ -56,7 +48,7 @@ export async function detachDoomed(root) {
56
48
  * discarded whole, so the names inside it carry no meaning.
57
49
  */
58
50
  export async function condemn(filePath, doomed) {
59
- await rename(filePath, path.join(doomed, `${randomUUID()}.doomed`));
51
+ await renameEntry(filePath, path.join(doomed, `${randomUUID()}.doomed`));
60
52
  }
61
53
 
62
54
  /**
@@ -69,7 +61,7 @@ export async function discard(directory, root, budget, deadlineAt) {
69
61
  await assertManagedDirectory(root, directory);
70
62
  for (const entry of await listDirectoryEntries(directory, { root })) {
71
63
  if (spent >= budget || expired(deadlineAt)) return { spent, drained: false };
72
- await rm(path.join(directory, entry.name), { recursive: true, force: true });
64
+ await removeTree(path.join(directory, entry.name));
73
65
  spent += 1;
74
66
  }
75
67
  await rmdir(directory).catch(error => {
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
 
3
3
  import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protocol";
4
4
 
5
- import { encode, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
5
+ import { encode, nameCommittedLater, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
6
6
 
7
7
  export const STORE_VERSION = 6;
8
8
 
@@ -29,7 +29,8 @@ function assertIdentity(record, workspaceId, filePath) {
29
29
  * foreign store is the failure the reconciled prototype fails closed on, and
30
30
  * the same rule applies here.
31
31
  */
32
- export async function requireStoreIdentity(paths, { workspaceId, clock, create = true }) {
32
+ export async function requireStoreIdentity(paths, { workspaceId, clock, create = true,
33
+ platform }) {
33
34
  assertPortableId(workspaceId, "workspace id");
34
35
  const filePath = identityPath(paths);
35
36
  const found = await readJsonIfPresent(filePath, paths.root);
@@ -39,7 +40,10 @@ export async function requireStoreIdentity(paths, { workspaceId, clock, create =
39
40
  }
40
41
  const record = { storeVersion: STORE_VERSION, workspaceId, initialisedAt: clock.now() };
41
42
  try {
42
- await publishAtomic(filePath, encode(record), { root: paths.root, tmpDir: paths.tmp });
43
+ // The journal's first pointer, published next, commits this name on
44
+ // Windows; a crash before it leaves a directory the next open initialises.
45
+ await publishAtomic(filePath, encode(record), { root: paths.root, tmpDir: paths.tmp,
46
+ durability: nameCommittedLater(platform) });
43
47
  } catch (error) {
44
48
  // Losing this race is not a failure. Two agents starting together in a
45
49
  // workspace neither has opened before is the ordinary case, and the two
@@ -4,3 +4,4 @@ export { diagnoseFilesystemStore, repairFilesystemStore } from "./recovery.mjs";
4
4
  export { readStoreIdentity, requireStoreIdentity, STORE_VERSION } from "./identity.mjs";
5
5
  export { withWriterMutex } from "./writer-mutex.mjs";
6
6
  export { readSessionRecord } from "./session-read.mjs";
7
+ export { openNoFollow } from "./portable-fs.mjs";
@@ -3,12 +3,27 @@ import path from "node:path";
3
3
  import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protocol";
4
4
 
5
5
  import { activateJournal, idleJournal, readActiveJournal } from "./active-journal.mjs";
6
- import { encode, publishAtomic, readJsonIfPresent, retainFile }
6
+ import { encode, nameCommittedLater, publishAtomic, readJsonIfPresent, retainFile }
7
7
  from "./atomic-json.mjs";
8
- import { completeJournal } from "./retention.mjs";
9
8
 
10
9
  export const JOURNAL_VERSION = 2;
11
10
 
11
+ // What a publication inside a transaction leaves to the flush after it.
12
+ //
13
+ // The entry's name is made durable by the pointer that opens it, which is
14
+ // published beside it in journal/: POSIX syncs that directory, and on Windows
15
+ // flushing a file commits NTFS's metadata journal up to that file's last change
16
+ // (docs/design/2026-09-30-native-windows-support.md, "What is flushed"). That
17
+ // journal is one sequence, so on Windows every rename logged before a flush is
18
+ // committed with it: each record's own flush commits the rename before it, and
19
+ // the pointer going idle commits the last record's. Only bytes are flushed
20
+ // there. A crash that loses the idle pointer's rename leaves the transaction
21
+ // open, and nothing written after it can be durable without that rename, so
22
+ // rolling it forward again changes nothing. A flush took 15 ms at the median and
23
+ // up to 5.8 s on windows-latest beside the suite (2026-10-01).
24
+ const ENTRY = "bytes";
25
+ const linked = nameCommittedLater;
26
+
12
27
  // A journal entry is prepared only after the transaction callback has
13
28
  // succeeded and every byte is known. The subsequent synced active-log record
14
29
  // is the commit point: an orphan prepared journal is not a decided
@@ -62,17 +77,19 @@ export function journalEntry(transactionId, firstSequence, publications, started
62
77
  }
63
78
 
64
79
  export async function writeJournalEntry(paths, options, entry) {
65
- await publishAtomic(journalPath(paths, entry.transactionId), encode(entry), options);
80
+ await publishAtomic(journalPath(paths, entry.transactionId), encode(entry),
81
+ { ...options, durability: ENTRY });
66
82
  await options.failAt?.("after-journal-prepared");
67
- await activateJournal(paths, options, entry);
83
+ await activateJournal(paths, { ...options, durability: linked(options.platform) }, entry);
68
84
  return entry;
69
85
  }
70
86
 
87
+ // The entry stays where it is; the reclaimer retires every entry the pointer
88
+ // does not name.
71
89
  export async function retireJournalEntry(paths, options, transactionId) {
72
90
  await retainFile(journalPath(paths, transactionId), { root: paths.root });
73
- await completeJournal(paths, options, transactionId);
74
91
  await options.failAt?.("before-journal-idle");
75
- await idleJournal(paths, options, transactionId);
92
+ await idleJournal(paths, { ...options, durability: linked(options.platform) }, transactionId);
76
93
  }
77
94
 
78
95
  export async function readOpenJournals(paths, root) {
@@ -124,7 +141,7 @@ export async function rollForward(paths, options, entry) {
124
141
  }
125
142
  const bytes = Buffer.from(publication.bytes, "base64");
126
143
  const outcome = await publishAtomic(destination, bytes,
127
- { ...options, replace: publication.replace === true });
144
+ { ...options, replace: publication.replace === true, durability: linked(options.platform) });
128
145
  if (outcome === "published") published.push(publication.path);
129
146
  // Seam for the crash-window tests: abort between two publications of the
130
147
  // same decided transaction.
@@ -0,0 +1,165 @@
1
+ // Filesystem calls whose meaning differs on Windows, each measured on a
2
+ // windows-latest runner (docs/design/2026-09-30-native-windows-support.md).
3
+ // POSIX keeps the single call it always made; the Windows branch keeps the rule
4
+ // the caller relies on. Node built-ins only: the managed runtime's launchers
5
+ // carry a byte-identical copy of this file, and they may import nothing else.
6
+ import { constants } from "node:fs";
7
+ import * as fs from "node:fs/promises";
8
+
9
+ const BUSY = new Set(["EPERM", "EACCES", "EBUSY"]);
10
+ // Long enough for a reader, an antivirus scan or an indexer to let go; short
11
+ // enough that a hook's budget is spent on the write, not on waiting.
12
+ const DEFAULT_WAIT_MS = 2_000;
13
+ const BACKOFF_MS = [5, 10, 20, 40, 80];
14
+ const STEADY_MS = 100;
15
+
16
+ const sleepFor = duration => new Promise(resolve => { setTimeout(resolve, duration); });
17
+
18
+ export const isWindows = (platform = process.platform) => platform === "win32";
19
+
20
+ async function retrying(operation, retry, { deadlineAt, sleep = sleepFor }) {
21
+ const deadline = deadlineAt ?? Date.now() + DEFAULT_WAIT_MS;
22
+ for (let attempt = 0; ; attempt += 1) {
23
+ try {
24
+ return await operation();
25
+ } catch (error) {
26
+ const remaining = deadline - Date.now();
27
+ if (remaining <= 0 || !await retry(error)) throw error;
28
+ await sleep(Math.min(BACKOFF_MS[attempt] ?? STEADY_MS, remaining));
29
+ }
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Rename a file over an existing one.
35
+ *
36
+ * Windows refuses the rename while any handle is open on the target - an ACC
37
+ * reader, an antivirus scan, an indexer - whatever sharing that handle allows.
38
+ * The handle is always short-lived, so the rename is retried until the caller's
39
+ * deadline rather than failing a write whose bytes are ready.
40
+ */
41
+ export async function renameReplacing(from, to, { platform = process.platform,
42
+ rename = fs.rename, deadlineAt, sleep } = {}) {
43
+ if (!isWindows(platform)) return rename(from, to);
44
+ return retrying(() => rename(from, to), error => BUSY.has(error.code), { deadlineAt, sleep });
45
+ }
46
+
47
+ /**
48
+ * Rename a directory, or move any entry to a name nobody else uses.
49
+ *
50
+ * POSIX reports an occupied name as EEXIST or ENOTEMPTY, and callers treat that
51
+ * as "somebody else got there first". Windows reports it as EPERM, the same code
52
+ * it uses when a file inside the source is open. The target tells them apart:
53
+ * if it exists the answer is EEXIST, as on POSIX; if not, the source was busy
54
+ * and the rename is retried.
55
+ */
56
+ export async function renameEntry(from, to, { platform = process.platform,
57
+ rename = fs.rename, lstat = fs.lstat, deadlineAt, sleep } = {}) {
58
+ if (!isWindows(platform)) return rename(from, to);
59
+ return retrying(async () => {
60
+ try {
61
+ return await rename(from, to);
62
+ } catch (error) {
63
+ if (!BUSY.has(error.code)) throw error;
64
+ const occupied = await lstat(to).then(() => true, missing => {
65
+ if (missing.code === "ENOENT") return false;
66
+ throw missing;
67
+ });
68
+ if (occupied) {
69
+ throw Object.assign(new Error(`EEXIST: file already exists, rename '${from}' -> '${to}'`),
70
+ { code: "EEXIST", syscall: "rename", path: from, dest: to });
71
+ }
72
+ throw error;
73
+ }
74
+ }, error => BUSY.has(error.code), { deadlineAt, sleep });
75
+ }
76
+
77
+ const gone = (file, lstat) => lstat(file).then(() => false,
78
+ error => error.code === "ENOENT");
79
+
80
+ const refusedLink = file => Object.assign(
81
+ new Error(`ELOOP: too many symbolic links encountered, open '${file}'`),
82
+ { code: "ELOOP", syscall: "open", path: file });
83
+
84
+ // How many times a name that keeps changing hands is opened afresh.
85
+ const SWAPS = 3;
86
+
87
+ /**
88
+ * Open a file without following a symlink at its final component.
89
+ *
90
+ * Windows has no O_NOFOLLOW: the flag is undefined there and an open through a
91
+ * file symlink succeeds. The rule is kept by checking the name first, refusing a
92
+ * symlink or junction (Node reports both as symbolic links), and after the open
93
+ * checking the name again: it must be no link, and name the very file the handle
94
+ * holds. A name another process replaced in between - a lock changing hands, a
95
+ * record renamed over - then names what was opened, as POSIX's O_NOFOLLOW open
96
+ * would have taken it; a name that moved on again is opened afresh, a few times.
97
+ */
98
+ export async function openNoFollow(file, flags, { mode, platform = process.platform,
99
+ open = fs.open, lstat = fs.lstat, deadlineAt, sleep } = {}) {
100
+ if (!isWindows(platform)) return open(file, flags | constants.O_NOFOLLOW, mode);
101
+ for (let attempt = 1; ; attempt += 1) {
102
+ const before = await lstat(file, { bigint: true }).catch(error => {
103
+ if (error.code === "ENOENT") return null;
104
+ throw error;
105
+ });
106
+ if (before?.isSymbolicLink()) throw refusedLink(file);
107
+ // A scanner that opened the file without read sharing refuses every reader
108
+ // with EBUSY until it lets go.
109
+ const handle = await retrying(() => open(file, flags, mode), error => error.code === "EBUSY",
110
+ { deadlineAt, sleep }).catch(async error => {
111
+ // A name another process is deleting, or a lock changing hands around
112
+ // it, refuses the open with EPERM, and the name is gone right after
113
+ // (measured on windows-latest, for a name present before the open and
114
+ // for one absent before it): that is ENOENT, as Linux says it. A name
115
+ // that is still there keeps its EPERM, and so does a create on a name
116
+ // that was free, which is a refused create.
117
+ const refusedCreate = before === null && (flags & constants.O_CREAT) !== 0;
118
+ if (error.code !== "EPERM" || refusedCreate || !await gone(file, lstat)) throw error;
119
+ throw Object.assign(new Error(`ENOENT: no such file or directory, open '${file}'`,
120
+ { cause: error }), { code: "ENOENT", syscall: "open", path: file });
121
+ });
122
+ try {
123
+ const opened = await handle.stat({ bigint: true });
124
+ const named = await lstat(file, { bigint: true }).catch(error => {
125
+ if (error.code === "ENOENT") return null;
126
+ throw error;
127
+ });
128
+ if (named?.isSymbolicLink()) throw refusedLink(file);
129
+ if (named !== null && named.dev === opened.dev && named.ino === opened.ino) return handle;
130
+ if (attempt >= SWAPS) throw refusedLink(file);
131
+ } catch (error) {
132
+ await handle.close();
133
+ throw error;
134
+ }
135
+ await handle.close();
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Make a rename or link that produced `entry` inside `directory` durable.
141
+ *
142
+ * POSIX syncs the directory. Windows refuses a flush on a directory handle, and
143
+ * NTFS journals the rename as metadata: flushing the renamed file commits that
144
+ * journal up to the file's last change, which includes the rename. A directory
145
+ * entry (a lock taken by renaming a directory) guards live processes only and is
146
+ * not flushed.
147
+ */
148
+ export async function syncEntry(directory, entry, { platform = process.platform,
149
+ open = fs.open, lstat = fs.lstat, deadlineAt, sleep } = {}) {
150
+ if (!isWindows(platform)) {
151
+ const handle = await open(directory, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
152
+ try { await handle.sync(); } finally { await handle.close(); }
153
+ return;
154
+ }
155
+ if ((await lstat(entry)).isDirectory()) return;
156
+ const handle = await retrying(() => open(entry, "r+"), error => error.code === "EBUSY",
157
+ { deadlineAt, sleep });
158
+ try { await handle.sync(); } finally { await handle.close(); }
159
+ }
160
+
161
+ /** Remove a tree; Windows retries a file another process still holds. */
162
+ export async function removeTree(target, { platform = process.platform, rm = fs.rm } = {}) {
163
+ await rm(target, { recursive: true, force: true,
164
+ maxRetries: isWindows(platform) ? 10 : 0, retryDelay: 50 });
165
+ }
@@ -0,0 +1,8 @@
1
+ import path from "node:path";
2
+
3
+ // A journal entry names each file it publishes relative to the store root, with
4
+ // forward slashes on every platform. path.relative gives backslashes on Windows,
5
+ // and an entry would then read differently depending on where it was written.
6
+ export function publicationPath(root, file, pathApi = path) {
7
+ return pathApi.relative(root, file).split(pathApi.sep).join("/");
8
+ }
@@ -17,6 +17,13 @@ const jsonNames = async (directory, root) =>
17
17
  .filter(entry => entry.isFile() && entry.name.endsWith(".json"))
18
18
  .map(entry => entry.name);
19
19
 
20
+ // A store that never had an older ACC write a completion marker has no
21
+ // directory for them.
22
+ const jsonNamesIfPresent = (directory, root) => jsonNames(directory, root).catch(error => {
23
+ if (error.code === "ENOENT") return [];
24
+ throw error;
25
+ });
26
+
20
27
  /**
21
28
  * Which transaction the store is in the middle of, or null when it cannot tell.
22
29
  *
@@ -40,16 +47,19 @@ async function activeTransaction(paths, root) {
40
47
  }
41
48
 
42
49
  /**
43
- * Condemn every journal entry that has been retired, with its completion marker.
50
+ * Condemn every journal entry but the one the pointer names open.
44
51
  *
45
- * `retireJournalEntry` calls `retainFile`, which by construction never unlinks,
46
- * so a completed entry was kept forever; `completeJournal` then wrote a marker
47
- * that nothing reads. Both halves go together: a marker outliving its entry
48
- * records the completion of a file nothing can find.
52
+ * Every pass runs under the writer lock, as every transaction does, so none is
53
+ * between preparing its entry and opening it: an entry the pointer does not
54
+ * name either finished or was never decided, and recovery reads only the one it
55
+ * names. That entry stays even when a marker says it completed - an older ACC
56
+ * wrote its marker before the pointer went idle, and a crash between the two
57
+ * leaves exactly that. A completion marker is all an older ACC knew to judge
58
+ * by; this one writes none, and the old ones leave with or without their entry.
49
59
  *
50
- * The marker directory is listed once rather than read per entry. A workspace
51
- * in daily use reaches thousands of retired transactions, and one open each
52
- * would be the cost this pass exists to remove.
60
+ * Both directories are listed once rather than read per entry. A workspace in
61
+ * daily use reaches thousands of retired transactions, and one open each would
62
+ * be the cost this pass exists to remove.
53
63
  */
54
64
  async function condemnRetiredJournals(paths, root, doomed, budget, deadlineAt) {
55
65
  let spent = 0;
@@ -57,16 +67,15 @@ async function condemnRetiredJournals(paths, root, doomed, budget, deadlineAt) {
57
67
  if (!active.known) return { spent, drained: true };
58
68
 
59
69
  const markerDirectory = path.join(paths.retained, "journal");
60
- const completed = new Set(await jsonNames(markerDirectory, root));
61
- for (const name of await jsonNames(paths.journal, root)) {
62
- if (spent + 2 > budget || expired(deadlineAt)) return { spent, drained: false };
63
- if (!completed.has(name)) continue;
64
- // The marker is written before the pointer goes idle, so a crash between
65
- // the two leaves a completed marker on the entry recovery still owns.
70
+ const markers = new Set(await jsonNamesIfPresent(markerDirectory, root));
71
+ const entries = new Set(await jsonNames(paths.journal, root));
72
+ for (const name of new Set([...entries, ...markers])) {
66
73
  if (path.basename(name, ".json") === active.transactionId) continue;
67
- await condemn(path.join(paths.journal, name), doomed);
68
- await condemn(path.join(markerDirectory, name), doomed);
69
- spent += 2;
74
+ const found = [...entries.has(name) ? [path.join(paths.journal, name)] : [],
75
+ ...markers.has(name) ? [path.join(markerDirectory, name)] : []];
76
+ if (spent + found.length > budget || expired(deadlineAt)) return { spent, drained: false };
77
+ for (const file of found) await condemn(file, doomed);
78
+ spent += found.length;
70
79
  }
71
80
  return { spent, drained: true };
72
81
  }
@@ -5,6 +5,7 @@ import { AccError, EXIT, validateRecord } from "@agents-can-communicate/protocol
5
5
  import { listDirectoryEntries, listJsonFiles, readJsonIfPresent } from "./atomic-json.mjs";
6
6
  import { readEventFloor } from "./event-floor.mjs";
7
7
  import { readStoreIdentity } from "./identity.mjs";
8
+ import { readActiveJournal } from "./active-journal.mjs";
8
9
  import { readOpenJournals, rollForward } from "./journal.mjs";
9
10
  import { assertEventBinding, assertStateBinding } from "./record-id.mjs";
10
11
  import { reclaimRetired } from "./reclaim.mjs";
@@ -45,12 +46,13 @@ async function countStaging(paths, root) {
45
46
  const staged = (await entries(paths.stage)).filter(entry => entry.isFile()
46
47
  && accepted(entry.name)).length;
47
48
  const inTmp = await entries(paths.tmp);
48
- // A retired journal entry is one the store keeps and never reads again, so
49
- // counting them is what tells an operator the automatic pass has work left.
50
- const completed = new Set((await entries(path.join(paths.retained, "journal")))
51
- .filter(entry => entry.isFile()).map(entry => entry.name));
49
+ // A retired journal entry is one the store keeps and never reads again - any
50
+ // the pointer does not name open - so counting them is what tells an operator
51
+ // the automatic pass has work left.
52
+ const open = await readActiveJournal(paths, root)
53
+ .then(active => (active.state === "open" ? `${active.transactionId}.json` : null), () => null);
52
54
  const retired = (await entries(paths.journal))
53
- .filter(entry => entry.isFile() && completed.has(entry.name)).length;
55
+ .filter(entry => entry.isFile() && entry.name.endsWith(".json") && entry.name !== open).length;
54
56
  return {
55
57
  staged: staged + inTmp.filter(entry => accepted(entry.name)).length,
56
58
  partials: inTmp.filter(entry => entry.name.endsWith(".tmp")).length,
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protocol";
4
4
 
5
5
  import { encode, listJsonFiles, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
6
+ import { publicationPath } from "./publication-path.mjs";
6
7
 
7
8
  const RETENTION_VERSION = 1;
8
9
  const SEQUENCE_WIDTH = 16;
@@ -40,8 +41,8 @@ function stateMarker(paths, kind, id, generation) {
40
41
 
41
42
  export function stateDeletionPublication(paths, root, kind, id, generation, retainedPath) {
42
43
  const marker = stateMarker(paths, kind, id, generation);
43
- return { path: path.relative(root, marker.filePath), bytes: encode(marker.record),
44
- replace: false, retainedPath: path.relative(root, retainedPath) };
44
+ return { path: publicationPath(root, marker.filePath), bytes: encode(marker.record),
45
+ replace: false, retainedPath: publicationPath(root, retainedPath) };
45
46
  }
46
47
 
47
48
  export async function stateGenerationIsDeleted(paths, root, kind, id, generation) {
@@ -99,8 +100,10 @@ export async function markEphemeral(paths, options, kind, id, state) {
99
100
  const previous = await latestEphemeralMarker(paths, options.root, kind, id);
100
101
  // A marker that repeats the current state changes nothing, and every renewal
101
102
  // of a live record used to append one. History now grows only with a real
102
- // change: published after a deletion, or deleted.
103
- if (previous?.state === state) return previous;
103
+ // change: published after a deletion, or deleted. A record with no marker is
104
+ // present, so its first publication is no change either - it cost every new
105
+ // session an atomic write, three flushes on Windows, per ephemeral record.
106
+ if (previous?.state === state || (previous === null && state === "present")) return previous;
104
107
  const sequence = pad(previous === null ? 1n : BigInt(previous.sequence) + 1n);
105
108
  const record = { retentionVersion: RETENTION_VERSION, area: "ephemeral", kind, id,
106
109
  sequence, state };
@@ -108,15 +111,3 @@ export async function markEphemeral(paths, options, kind, id, state) {
108
111
  await publishAtomic(filePath, encode(record), options);
109
112
  return record;
110
113
  }
111
-
112
- function completionMarker(paths, transactionId) {
113
- assertPortableId(transactionId, "transaction id");
114
- const record = { retentionVersion: RETENTION_VERSION, transactionId };
115
- return { record, filePath: path.join(paths.retained, "journal", `${transactionId}.json`) };
116
- }
117
-
118
- export async function completeJournal(paths, options, transactionId) {
119
- const marker = completionMarker(paths, transactionId);
120
- await publishAtomic(marker.filePath, encode(marker.record), options);
121
- return marker.record;
122
- }