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
@@ -38,10 +38,19 @@ export async function fetchRelease({ pin = null, env = process.env, get = fetch
38
38
  return { version: body.version, integrity, registry: registry.href };
39
39
  }
40
40
 
41
- async function npmCli(env) {
42
- const directories = [path.dirname(process.execPath), ...(env.PATH ?? "").split(path.delimiter)];
41
+ /** npm's own script, beside this node first. POSIX's `npm` links to it; on
42
+ * Windows that name is a shell script, and node would run it as JavaScript. */
43
+ export async function npmCli(env, { platform = process.platform, execPath = process.execPath,
44
+ realpath: resolve = realpath } = {}) {
45
+ const flavour = platform === "win32" ? path.win32 : path.posix;
46
+ // A copy of Windows' environment keeps the variable's own spelling, `Path`.
47
+ const search = Object.entries(env).find(([name]) => name.toUpperCase() === "PATH")?.[1] ?? "";
48
+ const directories = [flavour.dirname(execPath), ...search.split(flavour.delimiter)];
43
49
  for (const directory of directories.filter(Boolean)) {
44
- try { return await realpath(path.join(directory, "npm")); } catch { /* Try the next installed npm. */ }
50
+ const candidate = platform === "win32"
51
+ ? flavour.join(directory, "node_modules", "npm", "bin", "npm-cli.js")
52
+ : flavour.join(directory, "npm");
53
+ try { return await resolve(candidate); } catch { /* Try the next installed npm. */ }
45
54
  }
46
55
  throw new Error("npm is unavailable; the working ACC runtime was kept");
47
56
  }
@@ -17,12 +17,14 @@ export const RETIRED_ENTRY_KINDS = Object.freeze(["acc-bootstrap", "acc-claude-c
17
17
 
18
18
  // Bootstrap resolution deliberately needs only Node built-ins: no selected
19
19
  // generation is imported until admission and its actual PID lease are durable.
20
+ // The same rule as platformDataHome, which this module may not import.
20
21
  export function managerLocation({ env = process.env, platform = process.platform } = {}) {
21
- const data = env.ACC_DATA_HOME || (platform === "win32" ? env.APPDATA
22
- : platform === "darwin" ? env.HOME && path.join(env.HOME, "Library", "Application Support")
23
- : env.XDG_DATA_HOME || env.HOME && path.join(env.HOME, ".local", "share"));
24
- if (typeof data !== "string" || !path.isAbsolute(data)) throw new Error("cannot resolve ACC data home");
25
- return path.join(data, "acc", "runtime");
22
+ const flavour = platform === "win32" ? path.win32 : path.posix;
23
+ const data = env.ACC_DATA_HOME || (platform === "win32" ? env.LOCALAPPDATA
24
+ : platform === "darwin" ? env.HOME && flavour.join(env.HOME, "Library", "Application Support")
25
+ : env.XDG_DATA_HOME || env.HOME && flavour.join(env.HOME, ".local", "share"));
26
+ if (typeof data !== "string" || !flavour.isAbsolute(data)) throw new Error("cannot resolve ACC data home");
27
+ return flavour.join(data, "acc", "runtime");
26
28
  }
27
29
 
28
30
  export function invokedDirectly(url) {
@@ -1,9 +1,11 @@
1
- import { lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm }
1
+ import { lstat, mkdir, mkdtemp, open, readFile, readdir, realpath }
2
2
  from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
5
  import { collectFile, fingerprint } from "./generation-files.mjs";
6
+ import { removeTree, renameEntry } from "./portable-fs.mjs";
6
7
  import { attachStagingTemp, holdStagedGeneration, releaseStagingHold } from "./staging.mjs";
8
+ import { syncDirectory } from "./state.mjs";
7
9
 
8
10
  const NAME = "agents-can-communicate";
9
11
  const STABLE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
@@ -144,19 +146,17 @@ export async function stageOwnGeneration({ packageRoot, managerRoot }) {
144
146
  } finally { await handle.close(); }
145
147
  }
146
148
  for (const directory of [...directories].sort((a, b) => b.length - a.length)) {
147
- const handle = await open(directory, "r");
148
- try { await handle.sync(); } finally { await handle.close(); }
149
+ await syncDirectory(directory);
149
150
  }
150
151
  try {
151
- await rename(staging, target);
152
+ await renameEntry(staging, target);
152
153
  } catch (error) {
153
154
  if (!["EEXIST", "ENOTEMPTY"].includes(error.code) || !await existingMatches(target, files)) throw error;
154
155
  }
155
- const handle = await open(generations, "r");
156
- try { await handle.sync(); } finally { await handle.close(); }
156
+ await syncDirectory(generations);
157
157
  return { ...result, hold };
158
158
  } finally {
159
- await rm(staging, { recursive: true, force: true });
159
+ await removeTree(staging);
160
160
  }
161
161
  } catch (error) {
162
162
  await releaseStagingHold(hold);
@@ -1,11 +1,12 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { lstat, mkdtemp, open, readFile, rename, rm } from "node:fs/promises";
2
+ import { lstat, mkdtemp, open, readFile, rm } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { ENTRY_KINDS, RETIRED_ENTRY_KINDS } from "./entry.mjs";
5
+ import { removeTree, renameEntry, renameReplacing } from "./portable-fs.mjs";
5
6
  import { managedDirectory, syncDirectory } from "./state.mjs";
6
7
 
7
8
  const MODULES = ["entry.mjs", "command-prefix.mjs", "state.mjs", "generation-files.mjs",
8
- "mutex.mjs", "leases.mjs", "schedule.mjs", "policy.mjs"];
9
+ "mutex.mjs", "leases.mjs", "schedule.mjs", "policy.mjs", "portable-fs.mjs"];
9
10
  async function durableFile(file, bytes, mode = 0o600) {
10
11
  const handle = await open(file, "wx", mode);
11
12
  try { await handle.writeFile(bytes); await handle.sync(); }
@@ -34,7 +35,7 @@ export async function writeLaunchers(root, packageRoot) {
34
35
  try {
35
36
  for (const [name, bytes] of modules) await durableFile(path.join(staging, name), bytes);
36
37
  await syncDirectory(staging);
37
- try { await rename(staging, directory); }
38
+ try { await renameEntry(staging, directory); }
38
39
  catch (error) { if (!["EEXIST", "ENOTEMPTY"].includes(error.code)) throw error; }
39
40
  await managedDirectory(directory);
40
41
  for (const [name, bytes] of modules) {
@@ -43,7 +44,7 @@ export async function writeLaunchers(root, packageRoot) {
43
44
  || !(await readFile(file)).equals(bytes)) throw new Error("managed launcher integrity changed");
44
45
  }
45
46
  await syncDirectory(parent);
46
- } finally { await rm(staging, { recursive: true, force: true }); }
47
+ } finally { await removeTree(staging); }
47
48
  const bin = path.join(root, "bin");
48
49
  await managedDirectory(bin, { create: true });
49
50
  for (const kind of ENTRY_KINDS) {
@@ -52,7 +53,9 @@ export async function writeLaunchers(root, packageRoot) {
52
53
  const temporary = path.join(bin, `.${randomUUID()}.tmp`);
53
54
  try {
54
55
  await durableFile(temporary, bytes, 0o700);
55
- await rename(temporary, path.join(bin, `${kind}.mjs`));
56
+ // A launcher starting right now may still hold this file open; Windows
57
+ // refuses the replacement until it lets go.
58
+ await renameReplacing(temporary, path.join(bin, `${kind}.mjs`));
56
59
  } finally { await rm(temporary, { force: true }); }
57
60
  }
58
61
  // A 0.7.x `claude` shim or plugin copy that still reaches for one of these
@@ -75,7 +75,7 @@ export async function spawnMaintenance({ root, job, packageRoot, env }) {
75
75
  throw new Error("maintenance worker implementation is unavailable");
76
76
  }
77
77
  const child = spawn(process.execPath, [path.join(packageRoot, "bin", "acc-maintenance-worker.mjs"), root, job.id],
78
- { env, cwd: root, detached: true, stdio: "ignore" });
78
+ { env, cwd: root, detached: true, windowsHide: true, stdio: "ignore" });
79
79
  await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
80
80
  child.unref();
81
81
  return child.pid;
@@ -1,7 +1,8 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { lstat, mkdir, readdir, rename, rm } from "node:fs/promises";
2
+ import { lstat, mkdir, readdir } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { performance } from "node:perf_hooks";
5
+ import { removeTree, renameEntry } from "./portable-fs.mjs";
5
6
  import { canonicalManagerRoot, managedDirectory, readManagedJson, syncDirectory, writeManagedJson } from "./state.mjs";
6
7
 
7
8
  export function defaultPidIsAlive(pid) {
@@ -49,7 +50,7 @@ async function readOwner(directory) {
49
50
  }
50
51
 
51
52
  async function retire(directory, target) {
52
- try { await rename(directory, target); return true; }
53
+ try { await renameEntry(directory, target); return true; }
53
54
  catch (error) {
54
55
  if (["ENOENT", "EEXIST", "ENOTEMPTY"].includes(error.code)) return false;
55
56
  throw error;
@@ -81,10 +82,10 @@ async function compactQuiescentLocks(root, pidIsAlive) {
81
82
  const tombstone = path.join(root, name);
82
83
  const owner = await readOwner(tombstone);
83
84
  if (owner && name === `manager.reclaimed-${identity(owner)}.lock`) {
84
- await rm(tombstone, { recursive: true, force: true });
85
+ await removeTree(tombstone);
85
86
  }
86
87
  }
87
- for (const candidate of dead) await rm(candidate, { recursive: true, force: true });
88
+ for (const candidate of dead) await removeTree(candidate);
88
89
  await syncDirectory(root);
89
90
  } catch {
90
91
  // Maintenance is best effort. Unreadable/malformed preparation keeps the
@@ -118,7 +119,7 @@ export async function withManagerLock(root, operation, { timeoutMs = 1000, pidIs
118
119
  }
119
120
  } else {
120
121
  try {
121
- await rename(candidate, directory);
122
+ await renameEntry(candidate, directory);
122
123
  owned = true;
123
124
  await syncDirectory(root);
124
125
  await compactQuiescentLocks(root, pidIsAlive);
@@ -140,6 +141,6 @@ export async function withManagerLock(root, operation, { timeoutMs = 1000, pidIs
140
141
  const released = path.join(root, `manager.reclaimed-${identity(owner)}.lock`);
141
142
  if (await retire(directory, released)) await syncDirectory(root);
142
143
  }
143
- } else await rm(candidate, { recursive: true, force: true });
144
+ } else await removeTree(candidate);
144
145
  }
145
146
  }
@@ -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
+ }
@@ -32,7 +32,7 @@ export async function scheduleWorker(root, control, { env = process.env } = {})
32
32
  }
33
33
  const child = spawn(process.execPath,
34
34
  [path.join(control.active.root, "bin", "acc-update-worker.mjs"), root], {
35
- env, detached: true, stdio: "ignore", cwd: root,
35
+ env, detached: true, windowsHide: true, stdio: "ignore", cwd: root,
36
36
  });
37
37
  child.on("error", () => {});
38
38
  child.unref();
@@ -1,8 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
- import { lstat, mkdir, open, realpath, rename, rm } from "node:fs/promises";
3
+ import { lstat, mkdir, open, realpath, rm } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { collectFile, fingerprint } from "./generation-files.mjs";
6
+ import { isWindows, openNoFollow, renameReplacing, syncEntry } from "./portable-fs.mjs";
6
7
 
7
8
  /** Resolve existing ancestors without creating an uninitialized manager. */
8
9
  export async function canonicalManagerRoot(root) {
@@ -26,8 +27,10 @@ export async function managedDirectory(directory, { create = false } = {}) {
26
27
  try {
27
28
  const info = await lstat(directory);
28
29
  if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("managed directory is not a regular directory");
29
- if (create) {
30
- const handle = await open(directory, constants.O_RDONLY | constants.O_NOFOLLOW);
30
+ // Mode bits are POSIX privacy. Windows refuses fchmod on a directory
31
+ // handle, and privacy there is the profile ACL the directory inherits.
32
+ if (create && !isWindows()) {
33
+ const handle = await openNoFollow(directory, constants.O_RDONLY);
31
34
  try { await handle.chmod(0o700); } finally { await handle.close(); }
32
35
  }
33
36
  return true;
@@ -37,7 +40,11 @@ export async function managedDirectory(directory, { create = false } = {}) {
37
40
  }
38
41
  }
39
42
 
43
+ /** Make removals and directory renames in `directory` durable. Windows refuses a
44
+ * flush on a directory handle; a record written by writeManagedJson is flushed
45
+ * itself there, and a removal that a power cut undoes only keeps a hold longer. */
40
46
  export async function syncDirectory(directory) {
47
+ if (isWindows()) return;
41
48
  const handle = await open(directory, constants.O_RDONLY | constants.O_NOFOLLOW);
42
49
  try { await handle.sync(); } finally { await handle.close(); }
43
50
  }
@@ -46,7 +53,7 @@ export async function syncDirectory(directory) {
46
53
  export async function readManagedJson(file) {
47
54
  let handle;
48
55
  try {
49
- handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
56
+ handle = await openNoFollow(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
50
57
  if (!(await handle.stat()).isFile()) throw new Error("managed record must be a regular file");
51
58
  return JSON.parse(await handle.readFile("utf8"));
52
59
  } catch (error) {
@@ -55,17 +62,20 @@ export async function readManagedJson(file) {
55
62
  } finally { await handle?.close(); }
56
63
  }
57
64
 
58
- export async function writeManagedJson(file, value) {
65
+ // `flushName: false` leaves the name to the next flush, for a record whose loss
66
+ // on a crash is recovered by writing it again. The bytes are always flushed.
67
+ export async function writeManagedJson(file, value, { flushName = true } = {}) {
59
68
  const temporary = path.join(path.dirname(file), `.record-${randomUUID()}.tmp`);
60
69
  let handle;
61
70
  try {
62
- handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
71
+ handle = await openNoFollow(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
72
+ { mode: 0o600 });
63
73
  await handle.writeFile(`${JSON.stringify(value)}\n`);
64
74
  await handle.sync();
65
75
  await handle.close();
66
76
  handle = null;
67
- await rename(temporary, file);
68
- await syncDirectory(path.dirname(file));
77
+ await renameReplacing(temporary, file);
78
+ if (flushName) await syncEntry(path.dirname(file), file);
69
79
  } finally {
70
80
  await handle?.close();
71
81
  await rm(temporary, { force: true });
@@ -2,6 +2,7 @@ import { realpath } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
4
  import { activatePending, reclaimGenerations } from "./activation.mjs";
5
+ import { listRuntimeHolds } from "./leases.mjs";
5
6
  import { downloadRelease, fetchRelease, newerVersion } from "./download.mjs";
6
7
  import { withManagerLock } from "./mutex.mjs";
7
8
  import { readControl, writeControl, writeManagedJson } from "./state.mjs";
@@ -82,10 +83,24 @@ export async function reclaimAsActive(root, generationRoot) {
82
83
  const control = await readControl(root);
83
84
  if (control === null || await realpath(generationRoot) !== await realpath(control.active.root)) return;
84
85
  const result = await reclaimGenerations({ root });
85
- // A postponed pass is recorded as unfinished, so the scheduler tries again
86
- // (hourly) even with automatic updates off.
86
+ // The process that activated this generation still runs from the one it
87
+ // replaced, and its own lease keeps that generation through this pass -
88
+ // measured in the 0.8.5 upgrade preflight. That pass is unfinished, so the
89
+ // hourly retry removes the old generation once the process has exited. A
90
+ // worker started at once would race every command right after an update.
91
+ const active = await realpath(control.active.root);
92
+ const own = await listRuntimeHolds(root).then(async holds => {
93
+ for (const hold of holds) {
94
+ if (hold.pid === process.pid && await realpath(hold.runtime.root).catch(() => null) !== active) {
95
+ return true;
96
+ }
97
+ }
98
+ return false;
99
+ }, () => false);
100
+ // A postponed pass is recorded as unfinished too, so the scheduler tries
101
+ // again (hourly) even with automatic updates off.
87
102
  await writeManagedJson(path.join(root, RECLAIM_MARKER), { schemaVersion: 1,
88
- activeRoot: control.active.root, complete: result?.postponed !== true,
103
+ activeRoot: control.active.root, complete: result?.postponed !== true && !own,
89
104
  attemptedAt: new Date().toISOString() });
90
105
  } catch { /* Best effort; the next pass reclaims again. */ }
91
106
  }
@@ -73,7 +73,12 @@ export async function updateNativeSessions(adapters, { service, status, root, no
73
73
  : binding === undefined ? "unbound"
74
74
  : delivery === null ? (leased ? "active" : "degraded")
75
75
  : !delivery.deliverable ? "degraded" : leased ? "active" : "idle";
76
+ // A session in another product the adapter serves - Antigravity 2.0
77
+ // beside Antigravity CLI - is named by that product.
78
+ const product = (byAdapterId.get(entry.adapterId)?.client?.variants ?? [])
79
+ .find(variant => variant?.certificationName === owner?.clientName);
76
80
  native.sessions.push({ sessionId: peer.sessionId, participantId: peer.participantId,
81
+ ...(product === undefined ? {} : { displayName: product.displayName }),
77
82
  presence: peer.presence, runtime, clientVersion: binding?.clientVersion ?? null, delivery,
78
83
  lastAttempt: owner === null ? null : await loadNativeAttempt({ runtimeDir: root, ...owner }) });
79
84
  }
@@ -104,9 +109,12 @@ const failures = {
104
109
  session_generation_stale: "the session was replaced during the handshake",
105
110
  workspace_identity_unavailable: "the bound client session is no longer in this workspace; "
106
111
  + "start a new client session here",
107
- // It stays embedded until it closes; a later service never adopts it.
112
+ // It stays embedded until it closes; a later service never adopts it. The
113
+ // Codex app runs every chat this way (openai/codex#41014), so a new chat
114
+ // there is embedded too; one started in a terminal is not.
108
115
  client_session_embedded: "the client runs this session on its own embedded service, which "
109
- + "peers cannot reach; start a new client session while its local delivery service runs",
116
+ + "peers cannot reach; start a new client session in a terminal while its local delivery "
117
+ + "service runs (the Codex app runs every chat embedded)",
110
118
  };
111
119
 
112
120
  function describeAttempt(attempt) {
@@ -120,7 +128,10 @@ function describeAttempt(attempt) {
120
128
  : attempt.policyStatus === "unavailable" ? `${source} delivery policy could not be read`
121
129
  : `${source} delivery policy was off`;
122
130
  } else if (attempt.state === "active") detail = "local handshake succeeded";
123
- else detail = failures[attempt.reasonCode] ?? describeNativeReason(attempt.reasonCode);
131
+ else if (attempt.reasonCode === "client_session_embedded" && attempt.launchOption) {
132
+ detail = `${describeNativeReason(attempt.reasonCode, { launchOption: attempt.launchOption })}; `
133
+ + `start a new client session without ${attempt.launchOption}`;
134
+ } else detail = failures[attempt.reasonCode] ?? describeNativeReason(attempt.reasonCode);
124
135
  return `last attempt ${attempt.at}: ${detail}`;
125
136
  }
126
137
 
@@ -146,7 +157,7 @@ const RUNTIME_TEXT = { active: "local transport active", idle: "idle, lease laps
146
157
 
147
158
  export function nativeSessionLines(adapters) {
148
159
  return adapters.flatMap(entry => (entry.nativeDelivery.sessions ?? []).map(session =>
149
- ` ${entry.displayName} session ${session.participantId} (${session.sessionId}): `
160
+ ` ${session.displayName ?? entry.displayName} session ${session.participantId} (${session.sessionId}): `
150
161
  + `${RUNTIME_TEXT[session.runtime] ?? session.runtime}; `
151
162
  + [describeDelivery(session), session.runtime === "idle" ? "the next send refreshes it" : null,
152
163
  describeAttempt(session.lastAttempt)].filter(part => part !== null).join("; ")));
@@ -28,15 +28,17 @@ const usage = (message, details) => {
28
28
  throw new AccError(EXIT.USAGE, message, details);
29
29
  };
30
30
 
31
+ // Pids, locks and pipe names belong to one machine, so state and cache live in
32
+ // local app data. A roaming profile would carry them to another machine, where
33
+ // every recorded pid names something else; there is no roaming fallback.
31
34
  function windowsPaths(env) {
32
- if (!set(env.APPDATA)) {
33
- usage("cannot resolve the Windows application data directory");
35
+ if (!set(env.LOCALAPPDATA)) {
36
+ usage("cannot resolve the Windows local application data directory");
34
37
  }
35
38
  return {
36
- data: env.APPDATA,
37
- config: env.APPDATA,
38
- // Roaming is the honest fallback: a cache that roams is wasteful, not wrong.
39
- cache: set(env.LOCALAPPDATA) ? env.LOCALAPPDATA : env.APPDATA,
39
+ data: env.LOCALAPPDATA,
40
+ config: set(env.APPDATA) ? env.APPDATA : env.LOCALAPPDATA,
41
+ cache: env.LOCALAPPDATA,
40
42
  };
41
43
  }
42
44
 
@@ -45,18 +47,21 @@ function requireHome(env) {
45
47
  return env;
46
48
  }
47
49
 
50
+ // POSIX paths are built with path.posix whatever the host: the resolution is a
51
+ // pure function of platform and environment, and a Windows host would turn
52
+ // every forward slash of a POSIX home into a backslash.
48
53
  function macosPaths(env) {
49
- const support = path.join(env.HOME, "Library", "Application Support");
54
+ const support = path.posix.join(env.HOME, "Library", "Application Support");
50
55
  // XDG variables are deliberately ignored here. They are common on a machine
51
56
  // that also runs Linux tooling, and letting one relocate macOS state would
52
57
  // move a user's sessions the day they install something unrelated.
53
58
  return { data: support, config: support,
54
- cache: path.join(env.HOME, "Library", "Caches") };
59
+ cache: path.posix.join(env.HOME, "Library", "Caches") };
55
60
  }
56
61
 
57
62
  function xdgPaths(env) {
58
- const fallback = { data: path.join(env.HOME, ".local", "share"),
59
- config: path.join(env.HOME, ".config"), cache: path.join(env.HOME, ".cache") };
63
+ const fallback = { data: path.posix.join(env.HOME, ".local", "share"),
64
+ config: path.posix.join(env.HOME, ".config"), cache: path.posix.join(env.HOME, ".cache") };
60
65
  return Object.fromEntries(AREAS.map(area =>
61
66
  [area, set(env[XDG[area]]) ? env[XDG[area]] : fallback[area]]));
62
67
  }
@@ -54,9 +54,10 @@ export function platformDataHome({ platform = process.platform, env = process.en
54
54
  if (typeof env.ACC_DATA_HOME === "string" && env.ACC_DATA_HOME.length > 0) {
55
55
  return env.ACC_DATA_HOME;
56
56
  }
57
+ // Machine-local: see windowsPaths in platform-paths.mjs.
57
58
  if (platform === "win32") {
58
- if (typeof env.APPDATA === "string" && env.APPDATA.length > 0) return env.APPDATA;
59
- throw new AccError(EXIT.USAGE, "cannot resolve the Windows application data directory");
59
+ if (typeof env.LOCALAPPDATA === "string" && env.LOCALAPPDATA.length > 0) return env.LOCALAPPDATA;
60
+ throw new AccError(EXIT.USAGE, "cannot resolve the Windows local application data directory");
60
61
  }
61
62
  if (typeof env.XDG_DATA_HOME === "string" && env.XDG_DATA_HOME.length > 0
62
63
  && platform !== "darwin") {
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
3
  import { promisify } from "node:util";
4
4
 
5
+ import { pathOf, resolveExecutable, runExecutable } from "@agents-can-communicate/adapter-sdk";
5
6
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
6
7
 
7
8
  import { platformPaths } from "./platform-paths.mjs";
@@ -11,6 +12,15 @@ import { runManagedUpdate, validateUpdateOptions } from "./managed-runtime/comma
11
12
 
12
13
  const execFileAsync = promisify(execFile);
13
14
 
15
+ // npm and acc are .cmd shims on Windows: found through PATHEXT, and started
16
+ // through cmd.exe. POSIX runs them by name as before.
17
+ async function runStep(command, argv, env) {
18
+ if (process.platform !== "win32") return execFileAsync(command, argv, { env });
19
+ const file = await resolveExecutable(command, { pathEnv: pathOf(env) });
20
+ if (file === null) throw new Error(`${command} is not on PATH`);
21
+ return runExecutable(file, argv, { env });
22
+ }
23
+
14
24
  /**
15
25
  * The two commands an upgrade takes, and why it is two.
16
26
  *
@@ -76,7 +86,7 @@ export async function runUpdateCommand({ options, runtime }) {
76
86
  ...steps.map(spell), "", "or run: acc update"].join("\n") };
77
87
  }
78
88
 
79
- const spawn = runtime.spawn ?? ((command, argv) => execFileAsync(command, argv, { env }));
89
+ const spawn = runtime.spawn ?? ((command, argv) => runStep(command, argv, env));
80
90
  const done = [];
81
91
  let installation = { stdout: "", stderr: "" };
82
92
  for (const [command, argv] of steps) {
@@ -1,10 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
- import { open, realpath } from "node:fs/promises";
3
+ import { realpath } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
 
6
6
  import { AccError, CONFIG_FILENAME, EXIT, validateProjectConfig }
7
7
  from "@agents-can-communicate/protocol";
8
+ import { openNoFollow } from "@agents-can-communicate/storage-filesystem";
8
9
 
9
10
  /**
10
11
  * @typedef {{ id: string, roots: string[], source: "config" | "git" | "directory",
@@ -17,7 +18,8 @@ const stableId = value =>
17
18
  async function readConfigNoFollow(configPath) {
18
19
  let handle;
19
20
  try {
20
- handle = await open(configPath, constants.O_RDONLY | constants.O_NOFOLLOW);
21
+ // Windows has no O_NOFOLLOW; openNoFollow keeps the refusal there too.
22
+ handle = await openNoFollow(configPath, constants.O_RDONLY);
21
23
  } catch (error) {
22
24
  if (error.code === "ENOENT") return null;
23
25
  // ELOOP is what O_NOFOLLOW reports for a symlink. A config reached through