agents-can-communicate 0.8.5 → 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 (133) 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 +15 -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 +26 -5
  13. package/docs/TROUBLESHOOTING.md +63 -8
  14. package/docs/UPGRADING.md +33 -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/src/adapter.mjs +13 -2
  23. package/node_modules/@agents-can-communicate/adapter-antigravity/src/agentapi.mjs +7 -5
  24. package/node_modules/@agents-can-communicate/adapter-antigravity/src/allow-rule.mjs +98 -17
  25. package/node_modules/@agents-can-communicate/adapter-antigravity/src/client-process.mjs +64 -0
  26. package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-allow-rule.mjs +140 -0
  27. package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-delivery.mjs +110 -0
  28. package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-endpoint.mjs +203 -0
  29. package/node_modules/@agents-can-communicate/adapter-antigravity/src/install.mjs +61 -30
  30. package/node_modules/@agents-can-communicate/adapter-antigravity/src/native-delivery.mjs +61 -24
  31. package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay-endpoint.mjs +30 -20
  32. package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay-start.mjs +2 -1
  33. package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay.mjs +20 -12
  34. package/node_modules/@agents-can-communicate/adapter-antigravity/src/relays.mjs +18 -4
  35. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  36. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +6 -0
  37. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-delivery.mjs +50 -37
  38. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +44 -31
  39. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-registry.mjs +99 -14
  40. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-settings.mjs +3 -7
  41. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-socket-directories.mjs +6 -4
  42. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +32 -7
  43. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  44. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +68 -3
  45. package/node_modules/@agents-can-communicate/adapter-codex/src/daemon-layouts.mjs +13 -0
  46. package/node_modules/@agents-can-communicate/adapter-codex/src/embedded-host.mjs +42 -11
  47. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +18 -9
  48. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +68 -27
  49. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +36 -18
  50. package/node_modules/@agents-can-communicate/adapter-codex/src/native-endpoint.mjs +39 -16
  51. package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +4 -2
  52. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  53. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  54. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +2 -1
  55. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +15 -9
  56. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  57. package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +25 -14
  58. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  59. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +2 -1
  60. package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +15 -10
  61. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  62. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +50 -5
  63. package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +11 -2
  64. package/node_modules/@agents-can-communicate/adapter-sdk/src/executables.mjs +149 -0
  65. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +79 -12
  66. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +9 -1
  67. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +65 -10
  68. package/node_modules/@agents-can-communicate/adapter-sdk/src/private-files.mjs +35 -0
  69. package/node_modules/@agents-can-communicate/adapter-sdk/src/process-table.mjs +232 -0
  70. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +8 -1
  71. package/node_modules/@agents-can-communicate/adapter-sdk/src/windows-command.mjs +69 -0
  72. package/node_modules/@agents-can-communicate/adapter-sdk/src/windows-process-chain.wsf +45 -0
  73. package/node_modules/@agents-can-communicate/cli/package.json +2 -1
  74. package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +2 -1
  75. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +60 -4
  76. package/node_modules/@agents-can-communicate/cli/src/hook-support.mjs +9 -0
  77. package/node_modules/@agents-can-communicate/cli/src/hook-workspace.mjs +12 -3
  78. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +3 -2
  79. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +12 -3
  80. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +7 -5
  81. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +7 -7
  82. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +8 -5
  83. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +1 -1
  84. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/mutex.mjs +7 -6
  85. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/portable-fs.mjs +165 -0
  86. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +1 -1
  87. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +18 -8
  88. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +11 -3
  89. package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +15 -10
  90. package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +3 -2
  91. package/node_modules/@agents-can-communicate/cli/src/update-command.mjs +11 -1
  92. package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +4 -2
  93. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  94. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +7 -0
  95. package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +39 -1
  96. package/node_modules/@agents-can-communicate/core/src/session-opening.mjs +11 -0
  97. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +8 -2
  98. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  99. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  100. package/node_modules/@agents-can-communicate/hook-runner/src/client-pid.mjs +65 -6
  101. package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +80 -0
  102. package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +1 -1
  103. package/node_modules/@agents-can-communicate/hook-runner/src/owner-context.mjs +34 -6
  104. package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +3 -50
  105. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +154 -28
  106. package/node_modules/@agents-can-communicate/installer/package.json +3 -2
  107. package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +2 -1
  108. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +13 -3
  109. package/node_modules/@agents-can-communicate/installer/src/hook-support.mjs +5 -0
  110. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +6 -14
  111. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +14 -0
  112. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  113. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  114. package/node_modules/@agents-can-communicate/protocol/src/config.mjs +6 -2
  115. package/node_modules/@agents-can-communicate/protocol/src/resources.mjs +6 -2
  116. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  117. package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +5 -2
  118. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +41 -23
  119. package/node_modules/@agents-can-communicate/storage-filesystem/src/doomed-directory.mjs +4 -12
  120. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +7 -3
  121. package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +1 -0
  122. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +24 -7
  123. package/node_modules/@agents-can-communicate/storage-filesystem/src/portable-fs.mjs +165 -0
  124. package/node_modules/@agents-can-communicate/storage-filesystem/src/publication-path.mjs +8 -0
  125. package/node_modules/@agents-can-communicate/storage-filesystem/src/reclaim.mjs +26 -17
  126. package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +7 -5
  127. package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +7 -16
  128. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +138 -24
  129. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +3 -2
  130. package/node_modules/@agents-can-communicate/storage-filesystem/src/stage-sweep.mjs +9 -6
  131. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +30 -8
  132. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +102 -32
  133. package/package.json +1 -5
@@ -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 });
@@ -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) {
@@ -149,7 +157,7 @@ const RUNTIME_TEXT = { active: "local transport active", idle: "idle, lease laps
149
157
 
150
158
  export function nativeSessionLines(adapters) {
151
159
  return adapters.flatMap(entry => (entry.nativeDelivery.sessions ?? []).map(session =>
152
- ` ${entry.displayName} session ${session.participantId} (${session.sessionId}): `
160
+ ` ${session.displayName ?? entry.displayName} session ${session.participantId} (${session.sessionId}): `
153
161
  + `${RUNTIME_TEXT[session.runtime] ?? session.runtime}; `
154
162
  + [describeDelivery(session), session.runtime === "idle" ? "the next send refreshes it" : null,
155
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.8.5",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -91,6 +91,13 @@ export function createDeliveryBindingService(ports, sessions) {
91
91
  async function clearDeliveryBinding(input) {
92
92
  const { sessionId, generation } = input;
93
93
  const endpointScoped = Object.hasOwn(input, "opaqueEndpointRef");
94
+ // Every hook clears when live delivery is not running, and on Windows that
95
+ // is every hook. Absent, or retired for this generation, is a state the
96
+ // update below leaves exactly as it is, so it is read first without the
97
+ // writer lock; a record read this way is as current as one read under it.
98
+ const seen = await store.ephemeral.get("deliveryBinding", sessionId);
99
+ if (seen === null || seen === undefined) return;
100
+ if (seen.generation === generation && seen.retiredAt !== null && seen.retiredAt !== undefined) return;
94
101
  await store.ephemeral.update("deliveryBinding", sessionId, async current => {
95
102
  if (current === null || current === undefined) return null;
96
103
  if (current.generation !== generation) throw conflict(sessionId);
@@ -21,9 +21,19 @@ export async function isMaterialised(store, workspaceId) {
21
21
  return (await store.snapshot(workspaceId, { kinds: ["workspace"] })).workspace !== null;
22
22
  }
23
23
 
24
- export async function materialise({ store, clock, ids }, { workspaceId, descriptor, reason }) {
24
+ /**
25
+ * `opening` is a second live session that opens here, in the transaction that
26
+ * materialises, instead of first writing an ephemeral copy of itself to be
27
+ * copied and retired a moment later. It opens only while the session it joins
28
+ * is still live under the lock; otherwise it would be a lone session, which
29
+ * leaves no durable trace, and nothing is written: the result is false and the
30
+ * caller opens it ephemerally.
31
+ */
32
+ export async function materialise({ store, clock, ids },
33
+ { workspaceId, descriptor, reason, opening = null }) {
25
34
  const now = clock.now();
26
35
  const staged = [];
36
+ let joined = true;
27
37
 
28
38
  await store.transaction(async tx => {
29
39
  // Ephemeral writers use this same mutex. Reading before taking it could
@@ -31,6 +41,13 @@ export async function materialise({ store, clock, ids }, { workspaceId, descript
31
41
  for (const entry of EPHEMERAL_KINDS) {
32
42
  staged.push({ ...entry, records: await store.ephemeral.list(entry.kind) });
33
43
  }
44
+ if (opening !== null && tx.get("workspace", workspaceId) === null
45
+ && !staged.find(entry => entry.kind === "session").records.some(record =>
46
+ record.state === "open" && record.sessionId !== opening.session.sessionId)) {
47
+ joined = false;
48
+ staged.length = 0;
49
+ return;
50
+ }
34
51
  const actorSessionId = staged.find(entry => entry.kind === "session")?.records.at(-1)?.sessionId
35
52
  ?? "session_bootstrap";
36
53
  // Asked again inside the lock. `ensureMaterialised` asks before taking it,
@@ -53,8 +70,14 @@ export async function materialise({ store, clock, ids }, { workspaceId, descript
53
70
  payload: { reason } });
54
71
  }
55
72
 
73
+ // The opening session's own ephemeral copy - left by a process that died
74
+ // mid-open - is what the opening replaces, not a session to promote first:
75
+ // promoting it would record the session opening twice.
76
+ const own = opening === null ? null : staged.find(entry => entry.kind === "session")
77
+ .records.find(record => record.sessionId === opening.session.sessionId) ?? null;
56
78
  for (const entry of staged) {
57
79
  for (const record of entry.records) {
80
+ if (record === own) continue;
58
81
  // Whoever got here first may have promoted this record already, and
59
82
  // promoting is a copy: the ephemeral and durable shapes are the same,
60
83
  // so the one already there is the one this would write.
@@ -66,11 +89,25 @@ export async function materialise({ store, clock, ids }, { workspaceId, descript
66
89
  payload: { sessionId: record.sessionId } });
67
90
  }
68
91
  }
92
+
93
+ if (opening !== null) {
94
+ const { participant, session } = opening;
95
+ const current = tx.get("session", session.sessionId) ?? own;
96
+ opening.assertAvailable(current);
97
+ if (tx.get("participant", participant.participantId) === null) {
98
+ tx.put("participant", participant.participantId, participant);
99
+ }
100
+ tx.put("session", session.sessionId, session, tx.generationOf("session", session.sessionId));
101
+ tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
102
+ actorSessionId: session.sessionId, type: "session.opened", occurredAt: now,
103
+ payload: { replaced: current?.generation ?? null } });
104
+ }
69
105
  // The promoted kinds are named by the loop above, not written literally in
70
106
  // the body, so they are derived rather than repeated: a new ephemeral kind
71
107
  // added to that list is read here without anyone remembering to say so.
72
108
  }, { kinds: ["workspace", ...EPHEMERAL_KINDS.map(entry => entry.kind)] });
73
109
 
110
+ if (!joined) return false;
74
111
  // The ephemeral copies are retired only after the durable transaction
75
112
  // committed, so a crash in between leaves a recoverable duplicate rather than
76
113
  // a hole.
@@ -79,6 +116,7 @@ export async function materialise({ store, clock, ids }, { workspaceId, descript
79
116
  await store.ephemeral.delete(entry.kind, record[entry.key]);
80
117
  }
81
118
  }
119
+ return true;
82
120
  }
83
121
 
84
122
  export async function ensureMaterialised(ports, { workspaceId, descriptor, reason }) {