agents-can-communicate 0.8.5 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +11 -7
  2. package/bin/entrypoints/acc-antigravity-relay.mjs +2 -4
  3. package/bin/entrypoints/acc-hook.mjs +33 -19
  4. package/bin/entrypoints/hook-output.mjs +14 -5
  5. package/docs/ADAPTER_AUTHORING.md +29 -0
  6. package/docs/CAPABILITIES.md +7 -6
  7. package/docs/CLI.md +5 -0
  8. package/docs/CONFIGURATION.md +41 -10
  9. package/docs/DESIGN_DECISIONS.md +0 -2
  10. package/docs/GETTING_STARTED.md +39 -6
  11. package/docs/HOW_IT_WORKS.md +13 -8
  12. package/docs/SECURITY_MODEL.md +32 -5
  13. package/docs/TROUBLESHOOTING.md +76 -8
  14. package/docs/UPGRADING.md +47 -5
  15. package/node_modules/@agents-can-communicate/adapter-antigravity/certification.json +78 -0
  16. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/certification-provenance.json +111 -0
  17. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/delivery/antigravity-desktop-2.19.1-product-evidence.json +156 -0
  18. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/delivery/antigravity-desktop-2.19.1-product.json +25 -0
  19. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/desktop-2.19.1.json +141 -0
  20. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +4 -1
  21. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  22. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/skills/acc/SKILL.md +3 -0
  23. package/node_modules/@agents-can-communicate/adapter-antigravity/src/adapter.mjs +13 -2
  24. package/node_modules/@agents-can-communicate/adapter-antigravity/src/agentapi.mjs +7 -5
  25. package/node_modules/@agents-can-communicate/adapter-antigravity/src/allow-rule.mjs +98 -17
  26. package/node_modules/@agents-can-communicate/adapter-antigravity/src/client-process.mjs +64 -0
  27. package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-allow-rule.mjs +140 -0
  28. package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-delivery.mjs +110 -0
  29. package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-endpoint.mjs +203 -0
  30. package/node_modules/@agents-can-communicate/adapter-antigravity/src/install.mjs +61 -30
  31. package/node_modules/@agents-can-communicate/adapter-antigravity/src/native-delivery.mjs +61 -24
  32. package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay-endpoint.mjs +30 -20
  33. package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay-start.mjs +2 -1
  34. package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay.mjs +20 -12
  35. package/node_modules/@agents-can-communicate/adapter-antigravity/src/relays.mjs +18 -4
  36. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  37. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +3 -0
  38. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +6 -0
  39. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-delivery.mjs +50 -37
  40. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +44 -31
  41. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-registry.mjs +99 -14
  42. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-settings.mjs +3 -7
  43. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-socket-directories.mjs +6 -4
  44. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +32 -7
  45. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  46. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +3 -0
  47. package/node_modules/@agents-can-communicate/adapter-codex/src/allow-rule.mjs +129 -0
  48. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +68 -3
  49. package/node_modules/@agents-can-communicate/adapter-codex/src/daemon-layouts.mjs +13 -0
  50. package/node_modules/@agents-can-communicate/adapter-codex/src/embedded-host.mjs +42 -11
  51. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +36 -13
  52. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +68 -27
  53. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +36 -18
  54. package/node_modules/@agents-can-communicate/adapter-codex/src/native-endpoint.mjs +39 -16
  55. package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +4 -2
  56. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  57. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +3 -0
  58. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  59. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +2 -1
  60. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +15 -9
  61. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  62. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +3 -0
  63. package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +25 -14
  64. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  65. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +3 -0
  66. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +2 -1
  67. package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +15 -10
  68. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  69. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +50 -5
  70. package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +11 -2
  71. package/node_modules/@agents-can-communicate/adapter-sdk/src/executables.mjs +149 -0
  72. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +79 -12
  73. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +9 -1
  74. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +65 -10
  75. package/node_modules/@agents-can-communicate/adapter-sdk/src/own-version.mjs +27 -1
  76. package/node_modules/@agents-can-communicate/adapter-sdk/src/private-files.mjs +35 -0
  77. package/node_modules/@agents-can-communicate/adapter-sdk/src/process-table.mjs +232 -0
  78. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +8 -1
  79. package/node_modules/@agents-can-communicate/adapter-sdk/src/windows-command.mjs +69 -0
  80. package/node_modules/@agents-can-communicate/adapter-sdk/src/windows-process-chain.wsf +45 -0
  81. package/node_modules/@agents-can-communicate/cli/package.json +2 -1
  82. package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +2 -1
  83. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +60 -4
  84. package/node_modules/@agents-can-communicate/cli/src/hook-support.mjs +9 -0
  85. package/node_modules/@agents-can-communicate/cli/src/hook-workspace.mjs +12 -3
  86. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +3 -2
  87. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +12 -3
  88. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +7 -5
  89. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +7 -7
  90. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +8 -5
  91. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +1 -1
  92. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/mutex.mjs +7 -6
  93. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/portable-fs.mjs +165 -0
  94. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +1 -1
  95. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +18 -8
  96. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +11 -3
  97. package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +15 -10
  98. package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +3 -2
  99. package/node_modules/@agents-can-communicate/cli/src/update-command.mjs +11 -1
  100. package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +4 -2
  101. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  102. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +7 -0
  103. package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +39 -1
  104. package/node_modules/@agents-can-communicate/core/src/session-opening.mjs +11 -0
  105. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +8 -2
  106. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  107. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  108. package/node_modules/@agents-can-communicate/hook-runner/src/client-pid.mjs +65 -6
  109. package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +80 -0
  110. package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +1 -1
  111. package/node_modules/@agents-can-communicate/hook-runner/src/owner-context.mjs +34 -6
  112. package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +3 -50
  113. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +154 -28
  114. package/node_modules/@agents-can-communicate/installer/package.json +3 -2
  115. package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +2 -1
  116. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +13 -3
  117. package/node_modules/@agents-can-communicate/installer/src/hook-support.mjs +5 -0
  118. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +6 -14
  119. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +6 -2
  120. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +14 -0
  121. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  122. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  123. package/node_modules/@agents-can-communicate/protocol/src/config.mjs +6 -2
  124. package/node_modules/@agents-can-communicate/protocol/src/resources.mjs +6 -2
  125. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  126. package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +5 -2
  127. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +41 -23
  128. package/node_modules/@agents-can-communicate/storage-filesystem/src/doomed-directory.mjs +4 -12
  129. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +7 -3
  130. package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +1 -0
  131. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +24 -7
  132. package/node_modules/@agents-can-communicate/storage-filesystem/src/portable-fs.mjs +165 -0
  133. package/node_modules/@agents-can-communicate/storage-filesystem/src/publication-path.mjs +8 -0
  134. package/node_modules/@agents-can-communicate/storage-filesystem/src/reclaim.mjs +26 -17
  135. package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +7 -5
  136. package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +7 -16
  137. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +138 -24
  138. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +3 -2
  139. package/node_modules/@agents-can-communicate/storage-filesystem/src/stage-sweep.mjs +9 -6
  140. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +30 -8
  141. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +102 -32
  142. package/package.json +1 -5
@@ -23,7 +23,7 @@ export { validateNativeActivationPlan } from "./native-activation.mjs";
23
23
 
24
24
  const orderedModes = modes => NATIVE_BINDING_MODES.filter(mode => modes.includes(mode));
25
25
 
26
- export function validateNativeDeliveryContract(value, { certification, client }) {
26
+ export function validateNativeDeliveryContract(value, { certification, client, clients = [client] }) {
27
27
  // Consent to live delivery is what `acc install` recorded. Up to 0.7.x a
28
28
  // Claude shell shim could export it instead ("bootstrap-environment"); the
29
29
  // shim is gone, and the installation record is the one source left.
@@ -65,11 +65,19 @@ export function validateNativeDeliveryContract(value, { certification, client })
65
65
  + "is recorded by its evidence, and the anchor names the version alone "
66
66
  + "(docs/design/2026-09-26-native-delivery-across-platforms.md)");
67
67
  }
68
- closed(anchor, ["version", "protocolContract"], "nativeDelivery anchor");
68
+ closed(anchor, ["version", "protocolContract"], "nativeDelivery anchor", ["client"]);
69
69
  if (parseStableVersion(anchor.version) === null) {
70
70
  usage(`nativeDelivery anchor ${index} version must be a stable version`);
71
71
  }
72
- if (compareStableVersions(anchor.version, minimum) < 0) {
72
+ // An anchor may belong to another product the adapter serves (see
73
+ // client.variants). Its versions count on that product's own line, so the
74
+ // primary minimum says nothing about them.
75
+ const owner = Object.hasOwn(anchor, "client") ? anchor.client : client;
76
+ if (!clients.includes(owner)) {
77
+ usage(`nativeDelivery anchor ${anchor.version} names client ${owner}, which the adapter `
78
+ + "does not declare", { anchor });
79
+ }
80
+ if (owner === client && compareStableVersions(anchor.version, minimum) < 0) {
73
81
  usage(`nativeDelivery anchor ${index} version ${anchor.version} is below the minimum ${minimum}: `
74
82
  + "the minimum must be the first passing capture");
75
83
  }
@@ -79,7 +87,7 @@ export function validateNativeDeliveryContract(value, { certification, client })
79
87
  // Where the capture was taken is provenance the evidence row keeps; the
80
88
  // anchor is proven by a passing capture of this version on any platform.
81
89
  const proven = (certification?.evidence ?? []).some(item => item.result === "pass"
82
- && item.capability === "delivery.livePush" && item.client === client
90
+ && item.capability === "delivery.livePush" && item.client === owner
83
91
  && item.version === anchor.version);
84
92
  if (!proven) {
85
93
  usage(`nativeDelivery anchor ${anchor.version} has no passing delivery.livePush certification`,
@@ -87,7 +95,18 @@ export function validateNativeDeliveryContract(value, { certification, client })
87
95
  }
88
96
  return { ...anchor };
89
97
  });
90
- if (!anchors.some(anchor => compareStableVersions(anchor.version, minimum) === 0)) {
98
+ const owners = new Map();
99
+ for (const anchor of anchors) {
100
+ const owner = anchor.client ?? client;
101
+ const previous = owners.get(anchor.protocolContract);
102
+ if (previous !== undefined && previous !== owner) {
103
+ usage(`nativeDelivery protocol ${anchor.protocolContract} is anchored for both ${previous} `
104
+ + `and ${owner}`, { protocolContract: anchor.protocolContract });
105
+ }
106
+ owners.set(anchor.protocolContract, owner);
107
+ }
108
+ if (!anchors.some(anchor => (anchor.client ?? client) === client
109
+ && compareStableVersions(anchor.version, minimum) === 0)) {
91
110
  usage(`nativeDelivery minimum ${minimum} must be the first passing capture: no anchor matches it`);
92
111
  }
93
112
  if (!Array.isArray(value.knownBad)) usage("nativeDelivery.knownBad must be an array");
@@ -144,7 +163,7 @@ function knownBadHit(contract, order) {
144
163
  // validateNativeHandshake below, and the delivery router's own offer check,
145
164
  // which uses this function alone because a response already carries no probe
146
165
  // or handshake shape to check further).
147
- export function evaluateVersionContract(adapter, { clientVersion } = {}) {
166
+ export function evaluateVersionContract(adapter, { clientVersion, protocolContract } = {}) {
148
167
  const contract = adapter?.nativeDelivery;
149
168
  const unsupported = { reasonCode: "native_delivery_unsupported", minimumVersion: null,
150
169
  protocolContract: null };
@@ -161,9 +180,18 @@ export function evaluateVersionContract(adapter, { clientVersion } = {}) {
161
180
  // already answers malformed input this way.
162
181
  const minimumVersion = isText(contract.minimum) ? contract.minimum : null;
163
182
  if (minimumVersion === null) return unsupported;
164
- const anchor = (Array.isArray(contract.anchors) ? contract.anchors : [])
165
- .find(item => item?.version === minimumVersion);
183
+ const anchors = Array.isArray(contract.anchors) ? contract.anchors : [];
184
+ const primary = adapter.client?.certificationName ?? adapter.client?.command;
185
+ const anchor = anchors.find(item => item?.version === minimumVersion
186
+ && (!isText(item?.client) || item.client === primary));
166
187
  if (anchor === undefined) return unsupported;
188
+ // Another product's protocol has its own floor - the first capture that
189
+ // anchored it - and the denylist, which names the primary product's
190
+ // versions, does not reach it. Asked about no protocol, or the primary one,
191
+ // the rule is the primary minimum as it always was.
192
+ if (isText(protocolContract) && protocolContract !== anchor.protocolContract) {
193
+ return protocolFloor(anchors, protocolContract, clientVersion);
194
+ }
167
195
  const facts = { minimumVersion, protocolContract: anchor.protocolContract };
168
196
  const order = versionOrder(clientVersion);
169
197
  if (order === null) return { ...facts, reasonCode: "version_unavailable" };
@@ -174,6 +202,31 @@ export function evaluateVersionContract(adapter, { clientVersion } = {}) {
174
202
  return { ...facts, reasonCode: null };
175
203
  }
176
204
 
205
+ function protocolFloor(anchors, protocolContract, clientVersion) {
206
+ const own = anchors.filter(item => item?.protocolContract === protocolContract
207
+ && isText(item?.version));
208
+ if (own.length === 0) {
209
+ return { reasonCode: "protocol_mismatch", minimumVersion: null, protocolContract };
210
+ }
211
+ const minimumVersion = own.map(item => item.version)
212
+ .reduce((first, version) => (compareStableVersions(version, first) < 0 ? version : first));
213
+ const facts = { minimumVersion, protocolContract };
214
+ const order = versionOrder(clientVersion);
215
+ if (order === null) return { ...facts, reasonCode: "version_unavailable" };
216
+ if (compareVersionOrder(order, versionOrder(minimumVersion)) < 0) {
217
+ return { ...facts, reasonCode: "below_minimum_version" };
218
+ }
219
+ return { ...facts, reasonCode: null };
220
+ }
221
+
222
+ // The protocol a probe or handshake reports, when it is one an anchor names:
223
+ // that protocol's own floor judges it. Any other value is left to the primary
224
+ // rule, so it still ends as the protocol_mismatch it always was.
225
+ function reportedProtocol(adapter, reported) {
226
+ return isText(reported) && (adapter?.nativeDelivery?.anchors ?? [])
227
+ .some(item => item?.protocolContract === reported) ? reported : undefined;
228
+ }
229
+
177
230
  function validateNativeProbe(probe) {
178
231
  closed(probe, PROBE_KEYS, "native probe");
179
232
  if (typeof probe.supported !== "boolean") usage("native probe supported must be a boolean");
@@ -199,7 +252,8 @@ export function evaluateNativeEligibility(adapter, { clientVersion, probe }) {
199
252
  // Only the version is read here; the shape is validated where it always was,
200
253
  // so a malformed probe still returns a closed result rather than throwing.
201
254
  const serving = isText(probe?.clientVersion) ? probe.clientVersion : clientVersion;
202
- const rule = evaluateVersionContract(adapter, { clientVersion: serving });
255
+ const rule = evaluateVersionContract(adapter, { clientVersion: serving,
256
+ protocolContract: reportedProtocol(adapter, probe?.protocolContract) });
203
257
  const base = { eligible: false, reasonCode: null, minimumVersion: rule.minimumVersion,
204
258
  protocolContract: rule.protocolContract, modes: [] };
205
259
  const closedResult = reasonCode => deepFreeze({ ...base, reasonCode });
@@ -263,7 +317,8 @@ export function validateNativeHandshake(adapter, { clientVersion, handshake }) {
263
317
  // the version is read here; the shape is validated where it always was, so
264
318
  // a malformed handshake still returns a closed result rather than throwing.
265
319
  const serving = isText(handshake?.clientVersion) ? handshake.clientVersion : clientVersion;
266
- const rule = evaluateVersionContract(adapter, { clientVersion: serving });
320
+ const rule = evaluateVersionContract(adapter, { clientVersion: serving,
321
+ protocolContract: reportedProtocol(adapter, handshake?.protocolContract) });
267
322
  const base = { ok: false, reasonCode: null, clientVersion: null,
268
323
  protocolContract: rule.protocolContract, modes: [],
269
324
  opaqueEndpointRef: null, leaseUntil: null };
@@ -75,13 +75,39 @@ export async function stampPluginVersion({ file, version, io }) {
75
75
  * holds every plugin installed from that marketplace, and removing that root
76
76
  * once took a plugin the user had installed themselves - so a sibling here is
77
77
  * an older ACC, and a sibling one level up is somebody else's.
78
+ *
79
+ * A version a running session marks in use stays whatever `keep` says. Claude
80
+ * Code puts `.in_use/<pid>`, named after a running session, into a cached
81
+ * version and drops the file when that session exits (2.1.286). A reinstall
82
+ * after an update names only the active version, so it removed a copy the
83
+ * client had marked (#257). A marker whose process has exited holds nothing; a
84
+ * reused pid at worst keeps a copy until the next install.
78
85
  */
79
- export async function keepVersions({ root, keep, io }) {
86
+ const IN_USE = ".in_use";
87
+ const PID = /^[1-9]\d*$/;
88
+
89
+ // `kill(pid, 0)` delivers nothing; EPERM means the process exists under another user.
90
+ function processAlive(pid) {
91
+ try {
92
+ process.kill(pid, 0);
93
+ return true;
94
+ } catch (error) {
95
+ return error.code === "EPERM";
96
+ }
97
+ }
98
+
99
+ async function markedInUse(directory, io, alive) {
100
+ const markers = await io.readdir(path.join(directory, IN_USE)).catch(() => []);
101
+ return markers.some(name => PID.test(name) && alive(Number(name)));
102
+ }
103
+
104
+ export async function keepVersions({ root, keep, io, alive = processAlive }) {
80
105
  const wanted = new Set(keep.filter(version => typeof version === "string" && version !== ""));
81
106
  const entries = await io.readdir(root, { withFileTypes: true }).catch(() => []);
82
107
  const removed = [];
83
108
  for (const entry of entries) {
84
109
  if (!entry.isDirectory() || wanted.has(entry.name)) continue;
110
+ if (await markedInUse(path.join(root, entry.name), io, alive)) continue;
85
111
  await io.rm(path.join(root, entry.name), { recursive: true, force: true });
86
112
  removed.push(entry.name);
87
113
  }
@@ -0,0 +1,35 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, open } from "node:fs/promises";
3
+
4
+ // Private records an adapter keeps beside a live endpoint: one user's, never
5
+ // reached through a link. POSIX keeps them to the owner with mode bits on each
6
+ // file and directory. Windows has none - Node reports every file as 0o666 - and
7
+ // keeps them private through the profile's ACL, checked for the data home once
8
+ // (docs/design/2026-09-30-native-windows-support.md, section 8).
9
+
10
+ export const isWindowsPlatform = platform => String(platform).startsWith("win32");
11
+
12
+ /** Whether `info` grants none of `mask` to others; always so on Windows. */
13
+ export const closedTo = (info, mask, { platform = process.platform } = {}) =>
14
+ isWindowsPlatform(platform) || (info.mode & mask) === 0;
15
+
16
+ /**
17
+ * Open a regular file without following a link at its name. Windows has no
18
+ * O_NOFOLLOW, so the name is checked first and the handle after the open has to
19
+ * hold the file the name named.
20
+ */
21
+ export async function openRegularNoFollow(file, flags = constants.O_RDONLY,
22
+ { platform = process.platform } = {}) {
23
+ if (!isWindowsPlatform(platform)) {
24
+ return open(file, flags | constants.O_NOFOLLOW | (constants.O_NONBLOCK ?? 0));
25
+ }
26
+ const named = await lstat(file, { bigint: true });
27
+ if (!named.isFile()) throw Object.assign(new Error(`not a regular file: ${file}`), { code: "EINVAL" });
28
+ const handle = await open(file, flags);
29
+ const opened = await handle.stat({ bigint: true }).catch(() => null);
30
+ if (opened?.dev !== named.dev || opened?.ino !== named.ino) {
31
+ await handle.close().catch(() => null);
32
+ throw Object.assign(new Error(`replaced while opening: ${file}`), { code: "EINVAL" });
33
+ }
34
+ return handle;
35
+ }
@@ -0,0 +1,232 @@
1
+ import { execFile } from "node:child_process";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { promisify } from "node:util";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ // A hook runs in front of someone's turn. Reading the table is worth a few
9
+ // hundred milliseconds once per session and nothing at all if it is slow.
10
+ const DEFAULT_TIMEOUT_MS = 1_000;
11
+
12
+ const LINE = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/;
13
+ // A pid, then the whole rest of the line: a command line has spaces anywhere.
14
+ const ARGS_LINE = /^\s*(\d+)\s+(.+?)\s*$/;
15
+ // Command lines are long; measured at 231 KB for 1,137 processes on a desktop
16
+ // Mac, a quarter of execFile's default buffer.
17
+ const ARGS_BUFFER = 16 * 1024 * 1024;
18
+
19
+ // Deep enough for a client that wraps its hook in a shell and a launcher.
20
+ const WINDOWS_HOPS = 8;
21
+
22
+ async function posixTable({ run, timeoutMs }) {
23
+ const options = { timeout: timeoutMs, killSignal: "SIGKILL", windowsHide: true };
24
+ const [tree, commandLines] = await Promise.all([
25
+ run("ps", ["-o", "pid=,ppid=,comm=", "-A"], options).then(({ stdout }) => stdout, () => null),
26
+ run("ps", ["-A", "-o", "pid=,args="], { ...options, maxBuffer: ARGS_BUFFER })
27
+ .then(({ stdout }) => stdout, () => ""),
28
+ ]);
29
+ if (typeof tree !== "string") return new Map();
30
+ const args = new Map();
31
+ for (const line of String(commandLines).split("\n")) {
32
+ const match = ARGS_LINE.exec(line);
33
+ if (match !== null) args.set(Number(match[1]), match[2]);
34
+ }
35
+ const table = new Map();
36
+ for (const line of tree.split("\n")) {
37
+ const match = LINE.exec(line);
38
+ if (match === null) continue;
39
+ const pid = Number(match[1]);
40
+ table.set(pid, { ppid: Number(match[2]), comm: match[3],
41
+ ...(args.has(pid) ? { args: args.get(pid) } : {}) });
42
+ }
43
+ return table;
44
+ }
45
+
46
+ // Windows has no `ps` and no longer ships `wmic`. Win32_Process through CIM
47
+ // answers pid, parent, creation time and command line. A full scan took 1.07 s
48
+ // in Windows PowerShell 5.1 on a windows-latest runner, so the script asks only
49
+ // for the chain it needs, one filtered query per hop. The creation time goes
50
+ // out as a string: a FILETIME is larger than a double holds exactly.
51
+ const chainScript = (from, hops) => `$ErrorActionPreference = 'Stop'
52
+ $next = ${Number(from)}
53
+ $chain = New-Object System.Collections.ArrayList
54
+ for ($hop = 0; $hop -lt ${Number(hops)}; $hop++) {
55
+ $p = Get-CimInstance Win32_Process -Filter "ProcessId=$next" -Property ProcessId,ParentProcessId,CreationDate,Name,CommandLine
56
+ if ($null -eq $p) { break }
57
+ [void]$chain.Add([pscustomobject]@{ pid = [int]$p.ProcessId; ppid = [int]$p.ParentProcessId; name = [string]$p.Name; start = $p.CreationDate.ToFileTimeUtc().ToString(); cmd = [string]$p.CommandLine })
58
+ if ($p.ParentProcessId -eq 0 -or $p.ParentProcessId -eq $next) { break }
59
+ $next = [int]$p.ParentProcessId
60
+ }
61
+ ConvertTo-Json -Compress -InputObject @($chain)`;
62
+
63
+ // PowerShell by absolute path only, so a PATH entry cannot stand in for it.
64
+ // PowerShell 7 is not part of Windows; where it is installed it answers faster
65
+ // (0.37 s against 1.07 s for a full scan, measured), and a missing one fails to
66
+ // spawn at once.
67
+ function powershellCandidates(env) {
68
+ const root = env.SystemRoot ?? env.SYSTEMROOT ?? "C:\\Windows";
69
+ const programs = env.ProgramFiles ?? env.PROGRAMFILES ?? "C:\\Program Files";
70
+ return [path.win32.join(programs, "PowerShell", "7", "pwsh.exe"),
71
+ path.win32.join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")];
72
+ }
73
+
74
+ // Windows Script Host reads the same chain through WMI in a quarter of the
75
+ // time: 145 ms against PowerShell's 580, and 258 against 1,080 with four hooks
76
+ // at once (windows-latest, measured), because it starts no .NET runtime. A
77
+ // machine can switch it off by policy; PowerShell then reads the chain.
78
+ const WMI_CHAIN = fileURLToPath(new URL("./windows-process-chain.wsf", import.meta.url));
79
+
80
+ // WMI's `yyyymmddHHMMSS.ffffff+UUU` - offset in minutes - as a FILETIME string,
81
+ // the form the PowerShell script prints.
82
+ function wmiFileTime(value) {
83
+ const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.(\d{6})([+-])(\d{3})$/.exec(value ?? "");
84
+ if (match === null) return undefined;
85
+ const [, year, month, day, hour, minute, second, micros, sign, offset] = match;
86
+ const local = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour),
87
+ Number(minute), Number(second));
88
+ const utc = local - (sign === "+" ? 1 : -1) * Number(offset) * 60_000;
89
+ return String((BigInt(utc) + 11_644_473_600_000n) * 10_000n + BigInt(micros) * 10n);
90
+ }
91
+
92
+ const chainOf = stdout => {
93
+ const parsed = JSON.parse(stdout);
94
+ return (Array.isArray(parsed) ? parsed : [parsed]).map(hop => (typeof hop?.created === "string"
95
+ ? { ...hop, start: wmiFileTime(hop.created) } : hop));
96
+ };
97
+
98
+ async function windowsChain({ from, hops, run, timeoutMs, env }) {
99
+ const deadline = Date.now() + timeoutMs;
100
+ const options = () => ({ timeout: Math.max(1, deadline - Date.now()), killSignal: "SIGKILL",
101
+ windowsHide: true, maxBuffer: ARGS_BUFFER });
102
+ const root = env.SystemRoot ?? env.SYSTEMROOT ?? "C:\\Windows";
103
+ try {
104
+ const { stdout } = await run(path.win32.join(root, "System32", "cscript.exe"),
105
+ ["//Nologo", WMI_CHAIN, String(Number(from)), String(Number(hops))], options());
106
+ return chainOf(stdout);
107
+ } catch {
108
+ // Script Host is off or failed; PowerShell below.
109
+ }
110
+ const encoded = Buffer.from(chainScript(from, hops), "utf16le").toString("base64");
111
+ for (const shell of powershellCandidates(env)) {
112
+ if (Date.now() >= deadline) break;
113
+ try {
114
+ const { stdout } = await run(shell, ["-NoLogo", "-NoProfile", "-NonInteractive",
115
+ "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded], options());
116
+ return chainOf(stdout);
117
+ } catch {
118
+ // Try the next shell; none left is the empty answer below.
119
+ }
120
+ }
121
+ return [];
122
+ }
123
+
124
+ /**
125
+ * Windows: one process - pid, parent, image name, creation time as a FILETIME
126
+ * string (to the microsecond, as WMI keeps it) and command line - or null when
127
+ * it cannot be read.
128
+ */
129
+ export async function readWindowsProcess(pid, { run = execFileAsync, timeoutMs = DEFAULT_TIMEOUT_MS,
130
+ env = process.env } = {}) {
131
+ if (!Number.isSafeInteger(pid) || pid <= 0) return null;
132
+ const [found] = await windowsChain({ from: pid, hops: 1, run, timeoutMs, env });
133
+ return found?.pid === pid ? found : null;
134
+ }
135
+
136
+ async function windowsTable({ from, run, timeoutMs, env }) {
137
+ const table = new Map();
138
+ let child = null;
139
+ for (const hop of await windowsChain({ from, hops: WINDOWS_HOPS, run, timeoutMs, env })) {
140
+ if (!Number.isSafeInteger(hop?.pid) || typeof hop.name !== "string") break;
141
+ // Windows never re-parents an orphan: its ppid can name a process that
142
+ // died and whose number was reused. A parent created after its child is
143
+ // exactly that, and nothing above it belongs to this chain.
144
+ if (child !== null && !(BigInt(hop.start ?? "0") <= BigInt(child.start ?? "0"))) break;
145
+ table.set(hop.pid, { ppid: hop.ppid, comm: hop.name,
146
+ ...(typeof hop.cmd === "string" && hop.cmd !== "" ? { args: hop.cmd } : {}),
147
+ ...(typeof hop.start === "string" ? { start: hop.start } : {}) });
148
+ child = hop;
149
+ }
150
+ return table;
151
+ }
152
+
153
+ /**
154
+ * Processes on this machine, as pid -> parent, executable and, when it could be
155
+ * read, command line.
156
+ *
157
+ * POSIX reads the whole table with `ps`. Windows reads the chain from `from`
158
+ * upward - the only part a hook asks about - with a creation time for each hop.
159
+ * A failed read is an empty map, never an error: an empty table resolves no
160
+ * client, which is the "nobody knows" answer every caller already handles.
161
+ */
162
+ export async function readProcessTable({ platform = process.platform, from = process.pid,
163
+ run = execFileAsync, timeoutMs = DEFAULT_TIMEOUT_MS, env = process.env } = {}) {
164
+ if (platform === "win32") return windowsTable({ from, run, timeoutMs, env });
165
+ return posixTable({ run, timeoutMs });
166
+ }
167
+
168
+ /**
169
+ * The words one process was started with, or null when they cannot be read.
170
+ *
171
+ * POSIX splits `ps -o args=` on whitespace, as every caller did before. Windows
172
+ * keeps one command-line string per process, split here the way the C runtime
173
+ * splits it.
174
+ */
175
+ export async function readProcessArgs(pid, { platform = process.platform, run = execFileAsync,
176
+ timeoutMs = DEFAULT_TIMEOUT_MS, env = process.env, ps = "ps" } = {}) {
177
+ if (!Number.isSafeInteger(pid) || pid <= 0) return null;
178
+ if (platform === "win32") {
179
+ const [own] = await windowsChain({ from: pid, hops: 1, run, timeoutMs, env });
180
+ return own?.pid === pid && typeof own.cmd === "string" && own.cmd !== ""
181
+ ? splitWindowsCommandLine(own.cmd) : null;
182
+ }
183
+ try {
184
+ const { stdout } = await run(ps, ["-o", "args=", "-p", String(pid)],
185
+ { timeout: timeoutMs, killSignal: "SIGKILL", windowsHide: true });
186
+ const words = stdout.trim().split(/\s+/).filter(Boolean);
187
+ return words.length > 0 ? words : null;
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Split a Windows command line into argv the way CommandLineToArgvW does:
195
+ * whitespace separates words outside quotes; 2n backslashes before a quote are
196
+ * n backslashes and the quote toggles quoting; 2n+1 are n backslashes and a
197
+ * literal quote; backslashes elsewhere are literal.
198
+ */
199
+ export function splitWindowsCommandLine(line) {
200
+ const words = [];
201
+ let word = "";
202
+ let started = false;
203
+ let quoted = false;
204
+ for (let index = 0; index < line.length; index += 1) {
205
+ const char = line[index];
206
+ if (char === "\\") {
207
+ let count = 0;
208
+ while (line[index] === "\\") { count += 1; index += 1; }
209
+ if (line[index] === "\"") {
210
+ word += "\\".repeat(Math.floor(count / 2));
211
+ if (count % 2 === 1) word += "\"";
212
+ else quoted = !quoted;
213
+ } else {
214
+ word += "\\".repeat(count);
215
+ index -= 1;
216
+ }
217
+ started = true;
218
+ } else if (char === "\"") {
219
+ quoted = !quoted;
220
+ started = true;
221
+ } else if ((char === " " || char === "\t") && !quoted) {
222
+ if (started) words.push(word);
223
+ word = "";
224
+ started = false;
225
+ } else {
226
+ word += char;
227
+ started = true;
228
+ }
229
+ }
230
+ if (started) words.push(word);
231
+ return words;
232
+ }
@@ -43,13 +43,17 @@ function assertBindingDeadline(deadlineAt) {
43
43
  }
44
44
 
45
45
  export async function storeSessionBinding({ runtimeDir, harnessSessionId, accSessionId,
46
- generation, clientVersion, platform, clientPid, deadlineAt, storeVersion, runtimeRoot }) {
46
+ generation, clientVersion, clientName, platform, clientPid, deadlineAt, storeVersion, runtimeRoot }) {
47
47
  assertBindingDeadline(deadlineAt);
48
48
  const file = fileFor(runtimeDir, harnessSessionId);
49
49
  const record = { schemaVersion: SCHEMA_VERSION, harnessSessionId, accSessionId, generation };
50
50
  assertIdentity(record);
51
51
  await mkdir(path.dirname(file), { recursive: true });
52
52
  if (typeof clientVersion === "string" && clientVersion !== "") record.clientVersion = clientVersion;
53
+ // The product the session runs in, when it is one of the adapter's variants
54
+ // rather than its own client: its evidence and its version line are its own.
55
+ // A reader that predates the field ignores it.
56
+ if (typeof clientName === "string" && clientName !== "") record.clientName = clientName;
53
57
  if (typeof platform === "string" && platform !== "") record.platform = platform;
54
58
  // The vendor process this session runs in, resolved once at SessionStart.
55
59
  // A native endpoint is matched against it, so a binding without one can
@@ -106,6 +110,7 @@ export async function loadSessionBinding({ runtimeDir, harnessSessionId }) {
106
110
  assertIdentity(record, harnessSessionId);
107
111
  const binding = { accSessionId: record.accSessionId, generation: record.generation };
108
112
  if (typeof record.clientVersion === "string") binding.clientVersion = record.clientVersion;
113
+ if (typeof record.clientName === "string" && record.clientName !== "") binding.clientName = record.clientName;
109
114
  if (typeof record.platform === "string") binding.platform = record.platform;
110
115
  if (isPid(record.clientPid)) binding.clientPid = record.clientPid;
111
116
  return binding;
@@ -147,6 +152,8 @@ export async function listSessionBindings({ runtimeDir }) {
147
152
  accSessionId: record.accSessionId, generation: record.generation,
148
153
  ...(typeof record.clientVersion === "string"
149
154
  ? { clientVersion: record.clientVersion } : {}),
155
+ ...(typeof record.clientName === "string" && record.clientName !== ""
156
+ ? { clientName: record.clientName } : {}),
150
157
  ...(typeof record.platform === "string" ? { platform: record.platform } : {}),
151
158
  ...(isPid(record.clientPid) ? { clientPid: record.clientPid } : {}) });
152
159
  }
@@ -0,0 +1,69 @@
1
+ // A hook command in the form each client's Windows hook runner reads. Measured
2
+ // from the clients' sources and binaries (docs/design/2026-09-30-native-windows-
3
+ // support.md): Codex and Gemini CLI run PowerShell, Grok detects PowerShell,
4
+ // Git Bash or cmd, Kimi Code runs cmd.exe, and Antigravity runs `cmd /c` through
5
+ // Go, which escapes inner quotes as `\"` - a form cmd does not read.
6
+
7
+ import { execFile } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+
10
+ const defaultRun = promisify(execFile);
11
+
12
+ // Characters one of those shells expands or ends a quoted word on. A path that
13
+ // carries one is refused: writing it would run something other than the shim.
14
+ const EXPANDED = /[$`"%]/;
15
+ // What cmd acts on in a word outside quotes, whitespace included.
16
+ const UNQUOTED_UNSAFE = /[\s&|<>^(),;=!]/;
17
+
18
+ const powershellLiteral = value => `'${String(value).replaceAll("'", "''")}'`;
19
+ const forward = value => String(value).replaceAll("\\", "/");
20
+
21
+ /**
22
+ * @param {"powershell"|"portable"|"cmd"|"unquoted"} shell
23
+ * @param {{ node: string, shim: string, args: string[] }} parts
24
+ */
25
+ export function windowsHookCommand(shell, { node, shim, args }) {
26
+ for (const word of [node, shim]) {
27
+ if (EXPANDED.test(word)) {
28
+ throw new Error(`a hook command cannot name ${word}: a Windows shell would expand it`);
29
+ }
30
+ }
31
+ const tail = args.join(" ");
32
+ switch (shell) {
33
+ // `&` runs a quoted program; single quotes take nothing literally but `''`.
34
+ // pwsh -Command reports exit 2 as 1 unless the command exits itself.
35
+ case "powershell":
36
+ return `& ${powershellLiteral(node)} ${powershellLiteral(shim)} ${tail}; exit $LASTEXITCODE`;
37
+ // Node from PATH, which every one of the three shells finds; the shim then
38
+ // prefers the pinned runner. Forward slashes survive bash unquoting.
39
+ case "portable":
40
+ return `node "${forward(shim)}" ${tail}`;
41
+ case "cmd":
42
+ return `"${node}" "${shim}" ${tail}`;
43
+ // The path reaches cmd bare: & | < > ^ ( ) run as cmd's own operators and
44
+ // , ; = split the word the way a space does, so none may be in it.
45
+ case "unquoted":
46
+ if (UNQUOTED_UNSAFE.test(shim)) {
47
+ throw new Error(`a hook command cannot name ${shim} unquoted: it has a space or a character cmd acts on`);
48
+ }
49
+ return `node ${forward(shim)} ${tail}`;
50
+ default:
51
+ throw new Error(`unknown Windows hook shell: ${shell}`);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * The 8.3 form of a path with a space in it, for a hook runner that cannot pass
57
+ * a quoted path (Antigravity CLI). cmd prints it through `%~s`. A volume with 8.3
58
+ * names turned off answers with the long path, which the caller then refuses.
59
+ */
60
+ export async function shortPath(file, { run = defaultRun, env = process.env } = {}) {
61
+ if (!/\s/.test(file)) return file;
62
+ if (/["%^&|<>!]/.test(file)) return file;
63
+ const comspec = env.ComSpec ?? env.COMSPEC ?? "cmd.exe";
64
+ const { stdout } = await run(comspec, ["/d", "/c", `for %I in ("${file}") do @echo %~sI`],
65
+ { windowsHide: true, windowsVerbatimArguments: true, timeout: 5_000 });
66
+ const answer = String(stdout).trim().split(/\r?\n/).at(-1) ?? "";
67
+ return answer === "" ? file : answer;
68
+ }
69
+
@@ -0,0 +1,45 @@
1
+ <?xml version="1.0" ?>
2
+ <!--
3
+ The chain of processes from one pid upward, read through WMI by Windows Script
4
+ Host: cscript //Nologo windows-process-chain.wsf <pid> <hops>. Prints a JSON
5
+ array of { pid, ppid, name, created, cmd }, created in WMI's own date form.
6
+ JScript here predates JSON, so strings are escaped by hand, and everything
7
+ outside printable ASCII as \uXXXX: the output never depends on a code page.
8
+ -->
9
+ <job>
10
+ <script language="JScript"><![CDATA[
11
+ var next = parseInt(WScript.Arguments(0), 10);
12
+ var hops = parseInt(WScript.Arguments(1), 10);
13
+ var wmi = GetObject("winmgmts:{impersonationLevel=impersonate}!\\\\.\\root\\cimv2");
14
+
15
+ function text(value) {
16
+ if (value === null || typeof value === "undefined") return "null";
17
+ var source = String(value);
18
+ var out = "\"";
19
+ for (var index = 0; index < source.length; index++) {
20
+ var code = source.charCodeAt(index);
21
+ if (code === 34) out += "\\\"";
22
+ else if (code === 92) out += "\\\\";
23
+ else if (code < 32 || code > 126) {
24
+ var hex = code.toString(16);
25
+ out += "\\u" + "0000".substr(hex.length) + hex;
26
+ } else out += source.charAt(index);
27
+ }
28
+ return out + "\"";
29
+ }
30
+
31
+ var chain = [];
32
+ for (var hop = 0; hop < hops && next > 0; hop++) {
33
+ var found = new Enumerator(wmi.ExecQuery("SELECT ProcessId,ParentProcessId,Name,CommandLine,"
34
+ + "CreationDate FROM Win32_Process WHERE ProcessId=" + next));
35
+ if (found.atEnd()) break;
36
+ var process = found.item();
37
+ chain.push("{\"pid\":" + process.ProcessId + ",\"ppid\":" + process.ParentProcessId
38
+ + ",\"name\":" + text(process.Name) + ",\"created\":" + text(process.CreationDate)
39
+ + ",\"cmd\":" + text(process.CommandLine) + "}");
40
+ if (process.ParentProcessId === 0 || process.ParentProcessId === next) break;
41
+ next = process.ParentProcessId;
42
+ }
43
+ WScript.StdOut.Write("[" + chain.join(",") + "]");
44
+ ]]></script>
45
+ </job>
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/cli",
3
- "version": "0.8.5",
3
+ "version": "0.9.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.mjs",
8
+ "./hook-support": "./src/hook-support.mjs",
8
9
  "./managed-entry": "./src/managed-runtime/entry.mjs",
9
10
  "./managed-worker": "./src/managed-runtime/worker.mjs"
10
11
  },
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
 
5
5
  import { AccError, CONFIG_FILENAME, EXIT, createId, defaultProjectConfig,
6
6
  validateProjectConfig } from "@agents-can-communicate/protocol";
7
+ import { openNoFollow } from "@agents-can-communicate/storage-filesystem";
7
8
 
8
9
  const configPathIn = cwd => path.join(cwd, CONFIG_FILENAME);
9
10
 
@@ -32,7 +33,7 @@ async function readConfig(cwd) {
32
33
  try {
33
34
  // Same rule as discovery: a config reached through a symlink may point
34
35
  // anywhere, so it is refused rather than followed.
35
- handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
36
+ handle = await openNoFollow(file, constants.O_RDONLY);
36
37
  } catch (error) {
37
38
  if (error.code === "ENOENT") return null;
38
39
  throw new AccError(EXIT.DATA, "cannot safely read the workspace config",