agents-can-communicate 0.8.4 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +11 -7
  2. package/bin/entrypoints/acc-antigravity-relay.mjs +2 -4
  3. package/bin/entrypoints/acc-hook.mjs +33 -19
  4. package/bin/entrypoints/hook-output.mjs +14 -5
  5. package/docs/ADAPTER_AUTHORING.md +32 -0
  6. package/docs/ARCHITECTURE.md +7 -4
  7. package/docs/CAPABILITIES.md +7 -6
  8. package/docs/CLI.md +5 -0
  9. package/docs/CONFIGURATION.md +15 -10
  10. package/docs/DESIGN_DECISIONS.md +0 -2
  11. package/docs/GETTING_STARTED.md +39 -6
  12. package/docs/HOW_IT_WORKS.md +13 -8
  13. package/docs/SECURITY_MODEL.md +26 -5
  14. package/docs/TROUBLESHOOTING.md +74 -12
  15. package/docs/UPGRADING.md +50 -5
  16. package/node_modules/@agents-can-communicate/adapter-antigravity/certification.json +78 -0
  17. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/certification-provenance.json +111 -0
  18. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/delivery/antigravity-desktop-2.19.1-product-evidence.json +156 -0
  19. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/delivery/antigravity-desktop-2.19.1-product.json +25 -0
  20. package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/desktop-2.19.1.json +141 -0
  21. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +4 -1
  22. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  23. package/node_modules/@agents-can-communicate/adapter-antigravity/src/adapter.mjs +13 -2
  24. package/node_modules/@agents-can-communicate/adapter-antigravity/src/agentapi.mjs +7 -5
  25. package/node_modules/@agents-can-communicate/adapter-antigravity/src/allow-rule.mjs +98 -17
  26. package/node_modules/@agents-can-communicate/adapter-antigravity/src/client-process.mjs +64 -0
  27. package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-allow-rule.mjs +140 -0
  28. package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-delivery.mjs +110 -0
  29. package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-endpoint.mjs +203 -0
  30. package/node_modules/@agents-can-communicate/adapter-antigravity/src/install.mjs +61 -30
  31. package/node_modules/@agents-can-communicate/adapter-antigravity/src/native-delivery.mjs +61 -24
  32. package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay-endpoint.mjs +30 -20
  33. package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay-start.mjs +2 -1
  34. package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay.mjs +20 -12
  35. package/node_modules/@agents-can-communicate/adapter-antigravity/src/relays.mjs +18 -4
  36. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  37. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +6 -0
  38. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-delivery.mjs +50 -37
  39. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +44 -31
  40. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-registry.mjs +99 -14
  41. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-settings.mjs +3 -7
  42. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-socket-directories.mjs +6 -4
  43. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +32 -7
  44. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  45. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +68 -3
  46. package/node_modules/@agents-can-communicate/adapter-codex/src/daemon-layouts.mjs +13 -0
  47. package/node_modules/@agents-can-communicate/adapter-codex/src/embedded-host.mjs +87 -13
  48. package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +16 -6
  49. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +18 -9
  50. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +68 -27
  51. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +66 -26
  52. package/node_modules/@agents-can-communicate/adapter-codex/src/native-endpoint.mjs +39 -16
  53. package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +4 -2
  54. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  55. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  56. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +2 -1
  57. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +15 -9
  58. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  59. package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +25 -14
  60. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  61. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +2 -1
  62. package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +15 -10
  63. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  64. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +50 -5
  65. package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +11 -2
  66. package/node_modules/@agents-can-communicate/adapter-sdk/src/executables.mjs +149 -0
  67. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +79 -12
  68. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +11 -2
  69. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt.mjs +6 -2
  70. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +82 -17
  71. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +9 -2
  72. package/node_modules/@agents-can-communicate/adapter-sdk/src/private-files.mjs +35 -0
  73. package/node_modules/@agents-can-communicate/adapter-sdk/src/process-table.mjs +232 -0
  74. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +8 -1
  75. package/node_modules/@agents-can-communicate/adapter-sdk/src/windows-command.mjs +69 -0
  76. package/node_modules/@agents-can-communicate/adapter-sdk/src/windows-process-chain.wsf +45 -0
  77. package/node_modules/@agents-can-communicate/cli/package.json +2 -1
  78. package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +2 -1
  79. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +60 -4
  80. package/node_modules/@agents-can-communicate/cli/src/hook-support.mjs +9 -0
  81. package/node_modules/@agents-can-communicate/cli/src/hook-workspace.mjs +12 -3
  82. package/node_modules/@agents-can-communicate/cli/src/main.mjs +2 -1
  83. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +3 -2
  84. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +12 -3
  85. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +7 -5
  86. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +7 -7
  87. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +8 -5
  88. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +1 -1
  89. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/mutex.mjs +7 -6
  90. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/portable-fs.mjs +165 -0
  91. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +1 -1
  92. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +18 -8
  93. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +18 -3
  94. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +15 -4
  95. package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +15 -10
  96. package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +3 -2
  97. package/node_modules/@agents-can-communicate/cli/src/update-command.mjs +11 -1
  98. package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +4 -2
  99. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  100. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +7 -0
  101. package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +39 -1
  102. package/node_modules/@agents-can-communicate/core/src/session-opening.mjs +11 -0
  103. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +8 -2
  104. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  105. package/node_modules/@agents-can-communicate/delivery-router/src/native-reason.mjs +6 -2
  106. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +9 -5
  107. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  108. package/node_modules/@agents-can-communicate/hook-runner/src/client-pid.mjs +65 -6
  109. package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +80 -0
  110. package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +7 -2
  111. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +6 -3
  112. package/node_modules/@agents-can-communicate/hook-runner/src/owner-context.mjs +34 -6
  113. package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +3 -50
  114. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +167 -34
  115. package/node_modules/@agents-can-communicate/installer/package.json +3 -2
  116. package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +11 -2
  117. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +13 -3
  118. package/node_modules/@agents-can-communicate/installer/src/hook-support.mjs +5 -0
  119. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +6 -14
  120. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +14 -0
  121. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  122. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  123. package/node_modules/@agents-can-communicate/protocol/src/config.mjs +6 -2
  124. package/node_modules/@agents-can-communicate/protocol/src/resources.mjs +6 -2
  125. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  126. package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +5 -2
  127. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +41 -23
  128. package/node_modules/@agents-can-communicate/storage-filesystem/src/doomed-directory.mjs +4 -12
  129. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +7 -3
  130. package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +1 -0
  131. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +24 -7
  132. package/node_modules/@agents-can-communicate/storage-filesystem/src/portable-fs.mjs +165 -0
  133. package/node_modules/@agents-can-communicate/storage-filesystem/src/publication-path.mjs +8 -0
  134. package/node_modules/@agents-can-communicate/storage-filesystem/src/reclaim.mjs +26 -17
  135. package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +7 -5
  136. package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +7 -16
  137. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +138 -24
  138. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +3 -2
  139. package/node_modules/@agents-can-communicate/storage-filesystem/src/stage-sweep.mjs +9 -6
  140. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +30 -8
  141. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +102 -32
  142. package/package.json +1 -5
@@ -50,6 +50,13 @@ export const PROBE_KEYS = ["supported", "clientVersion", "protocolContract", "ex
50
50
  "modes", "reasonCode"];
51
51
  export const HANDSHAKE_KEYS = ["supported", "clientVersion", "protocolContract", "modes",
52
52
  "opaqueEndpointRef", "leaseUntil", "reasonCode"];
53
+ // A refused handshake may name the client launch option that caused it, such
54
+ // as Codex's `--search`. A bare option token, never its value: a value can be
55
+ // anything a user typed.
56
+ export const HANDSHAKE_OPTIONAL_KEYS = ["launchOption"];
57
+ export const LAUNCH_OPTION = /^-(?:[a-z]|-[a-z][a-z0-9]*(?:-[a-z0-9]+)*)$/;
58
+ export const isLaunchOption = value => typeof value === "string" && value.length <= 40
59
+ && LAUNCH_OPTION.test(value);
53
60
 
54
61
  export function usage(message, details = {}) {
55
62
  throw new AccError(EXIT.USAGE, message, details);
@@ -58,10 +65,10 @@ export function usage(message, details = {}) {
58
65
  export const isPlainObject = value => value !== null && typeof value === "object" && !Array.isArray(value);
59
66
  export const isText = value => typeof value === "string" && value !== "";
60
67
 
61
- export function closed(value, keys, label) {
68
+ export function closed(value, keys, label, optional = []) {
62
69
  if (!isPlainObject(value)) usage(`${label} must be an object`);
63
70
  for (const key of Object.keys(value)) {
64
- if (!keys.includes(key)) usage(`unknown ${label} field ${key}`, { key });
71
+ if (!keys.includes(key) && !optional.includes(key)) usage(`unknown ${label} field ${key}`, { key });
65
72
  }
66
73
  for (const key of keys) {
67
74
  if (!Object.hasOwn(value, key)) usage(`${label} requires ${key}`, { key });
@@ -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.4",
3
+ "version": "0.9.0",
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",
@@ -50,13 +50,66 @@ import { diagnoseFilesystemStore, repairFilesystemStore }
50
50
  *
51
51
  * Null for anything unreadable. "Might be old" on every run is not a diagnosis.
52
52
  */
53
+ /**
54
+ * The package directory of the runner a shim or config names, or null.
55
+ *
56
+ * ACC writes the runner in double quotes in every form: a shell shim
57
+ * (`ACC_RUNNER="..."`), the Node shim Windows gets (a JSON string) and Kimi's
58
+ * config (a TOML string holding a command, whose own quotes are cmd's on
59
+ * Windows and TOML's elsewhere). Each quoted string is read the way a shell
60
+ * reads one: a backslash escapes only a quote, a backslash, $ or a backtick,
61
+ * which is all the shim escapes and all a path needs undone in JSON or TOML,
62
+ * so an unescaped C:\Users\... in a shell script stays that path. A string
63
+ * holding quotes is a command. A Windows path in it is cmd's, which has no
64
+ * escape character, so it is read as it stands and a UNC path keeps its leading
65
+ * \\; other words are read the shell's way, since Kimi's POSIX command escapes a
66
+ * quote in a path; then, when no word names the runner, the string is read
67
+ * whole, since a POSIX path may hold a quote.
68
+ * An apostrophe, as in C:\Users\O'Neil, is part of a path. An unquoted path is
69
+ * read to the whitespace before it.
70
+ */
71
+ const RUNNER = /[\\/]agents-can-communicate(?=[\\/]bin[\\/]acc-hook\.mjs)/;
72
+ const absoluteRoot = value => {
73
+ const match = RUNNER.exec(value);
74
+ if (match === null) return null;
75
+ const root = value.slice(0, match.index + match[0].length);
76
+ return /^(?:[A-Za-z]:)?[\\/]/.test(root) ? root : null;
77
+ };
78
+
79
+ const quotedStrings = text => [...text.matchAll(/"((?:[^"\\]|\\[\s\S])*)"/g)]
80
+ .map(([, quoted]) => quoted.replace(/\\(["\\$`])/g, "$1"));
81
+
82
+ const commandRoot = command => {
83
+ for (const [, word] of command.matchAll(/"([^"]*)"/g)) {
84
+ const root = /^(?:[A-Za-z]:\\|\\\\)/.test(word) ? absoluteRoot(word) : null;
85
+ if (root !== null) return root;
86
+ }
87
+ for (const word of quotedStrings(command)) {
88
+ const root = absoluteRoot(word);
89
+ if (root !== null) return root;
90
+ }
91
+ return absoluteRoot(command);
92
+ };
93
+
94
+ export function runnerRoot(text) {
95
+ for (const value of quotedStrings(String(text))) {
96
+ const root = value.includes("\"") ? commandRoot(value) : absoluteRoot(value);
97
+ if (root !== null) return root;
98
+ }
99
+ const flat = String(text);
100
+ const match = RUNNER.exec(flat);
101
+ if (match === null) return null;
102
+ const start = flat.slice(0, match.index).search(/\S+$/);
103
+ return start < 0 ? null : absoluteRoot(flat.slice(start));
104
+ }
105
+
53
106
  export async function wiredVersion(shimPath) {
54
107
  if (typeof shimPath !== "string" || shimPath === "") return null;
55
108
  const text = await readFile(shimPath, "utf8").catch(() => null);
56
109
  if (text === null) return null;
57
- const runner = /["']?(\/[^"'\s]*\/agents-can-communicate)\/bin\/acc-hook\.mjs["']?/.exec(text);
58
- if (runner === null) return null;
59
- const manifest = await readFile(path.join(runner[1], "package.json"), "utf8")
110
+ const root = runnerRoot(text);
111
+ if (root === null) return null;
112
+ const manifest = await readFile(path.join(root, "package.json"), "utf8")
60
113
  .catch(() => null);
61
114
  if (manifest === null) return null;
62
115
  try {
@@ -103,7 +156,10 @@ async function findShims(root, depth) {
103
156
  for (const entry of entries) {
104
157
  const target = path.join(root, entry.name);
105
158
  if (entry.isDirectory()) found.push(...await findShims(target, depth - 1));
106
- else if (entry.name.endsWith(".sh")) found.push(target);
159
+ // The shell shim on POSIX, the Node shim on Windows.
160
+ else if (/^acc-(?:hook|cli)\.(?:sh|mjs)$/.test(entry.name) || entry.name.endsWith(".sh")) {
161
+ found.push(target);
162
+ }
107
163
  }
108
164
  return found;
109
165
  }
@@ -0,0 +1,9 @@
1
+ // What a hook needs from this package, without the rest of it. A hook starts a
2
+ // process on every turn, and the package index loads the whole command line and
3
+ // the installer with it: modules a hook never runs, about a millisecond each on
4
+ // windows-latest.
5
+ export { resolveHookWorkspace } from "./hook-workspace.mjs";
6
+ export { createGitProbe } from "./git-probe.mjs";
7
+ export { platformDataHome, runtimePaths } from "./runtime-paths.mjs";
8
+ export { clearPin, hookEntrypointFor, resolvePinnedGeneration, writePin }
9
+ from "./managed-runtime/pins.mjs";
@@ -12,7 +12,7 @@ import { runtimePaths } from "./runtime-paths.mjs";
12
12
  // this survives client exit: resuming the same native conversation keeps its
13
13
  // room. Adapter + native id distinguish independent conversations.
14
14
  export async function resolveHookWorkspace({ adapterId, event, room, cwd, dataHome, env = {},
15
- gitProbe, clock, deadlineAt }) {
15
+ gitProbe, clock, deadlineAt, platform = process.platform }) {
16
16
  const identityKey = (adapter, native) => createHash("sha256")
17
17
  .update(JSON.stringify([adapter, native])).digest("hex");
18
18
  if (room !== undefined && !/^[a-f0-9]{64}$/.test(room)) {
@@ -72,7 +72,13 @@ export async function resolveHookWorkspace({ adapterId, event, room, cwd, dataHo
72
72
  let current = descriptor;
73
73
  if (descriptor.source === "git" && path.resolve(event.cwd) !== workspaceCwd) {
74
74
  const git = await gitProbe({ cwd: event.cwd }).catch(() => null);
75
- if (git !== null && await realpath(git.commonDir) === descriptor.git.commonDir) {
75
+ // Both sides canonical: Git spells the directory the way the cwd did
76
+ // (through a symlink, or `C:/…` on Windows), and the room kept that
77
+ // spelling.
78
+ const same = git !== null && await Promise.all([git.commonDir, descriptor.git.commonDir]
79
+ .map(directory => realpath(directory).catch(() => null)))
80
+ .then(([here, room]) => here !== null && here === room);
81
+ if (same) {
76
82
  current = { ...descriptor, git };
77
83
  }
78
84
  }
@@ -97,13 +103,16 @@ export async function resolveHookWorkspace({ adapterId, event, room, cwd, dataHo
97
103
  const raced = await read();
98
104
  if (raced !== null) return discover(raced);
99
105
  deadline();
106
+ // On Windows the session this hook opens next flushes, and that commits
107
+ // this name with NTFS's journal; a crash before it leaves the room to be
108
+ // chosen again by the session's next hook.
100
109
  await writeManagedJson(file, { schemaVersion: 1, adapterId,
101
110
  nativeSessionId: event.sessionId, cwd: context.workspaceCwd,
102
111
  workspaceId: context.descriptor.id, source: context.descriptor.source,
103
112
  ...(context.descriptor.source === "git" ? { git: {
104
113
  commonDir: await realpath(context.descriptor.git.commonDir),
105
114
  worktreeRoot: await realpath(context.descriptor.git.worktreeRoot),
106
- } } : {}) });
115
+ } } : {}) }, { flushName: platform !== "win32" });
107
116
  return { ...context, workspaceRef: `acc://${key}` };
108
117
  });
109
118
  }
@@ -164,7 +164,8 @@ export function recordedText(message, delivery) {
164
164
  ? "live offer blocked by sender permissions; message remains queued; run acc doctor in the sending session"
165
165
  : item.errorCode === "no_live_transport"
166
166
  ? `${item.recipientParticipantId} has no live transport`
167
- + `${item.nativeReasonCode ? ` (${describeNativeReason(item.nativeReasonCode)})` : ""}`
167
+ + `${item.nativeReasonCode ? ` (${describeNativeReason(item.nativeReasonCode,
168
+ { launchOption: item.nativeLaunchOption })})` : ""}`
168
169
  + "; the message waits in its inbox"
169
170
  : item.errorCode === "recipient_offline" && item.endReason === "clear"
170
171
  ? `${item.recipientParticipantId}'s conversation was cleared (/clear); the message waits `
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { readdir, rm } from "node:fs/promises";
2
+ import { readdir } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { assertPortableId } from "@agents-can-communicate/protocol";
@@ -8,6 +8,7 @@ import { listRuntimeHolds } from "./leases.mjs";
8
8
  import { confirmedDead, defaultPidIsAlive, withManagerLock } from "./mutex.mjs";
9
9
  import { reapPins } from "./pins.mjs";
10
10
  import { reapStagingHolds } from "./staging.mjs";
11
+ import { removeTree } from "./portable-fs.mjs";
11
12
  import { canonicalManagerRoot, managedDirectory, readControl, readManagedJson, syncDirectory, writeControl } from "./state.mjs";
12
13
 
13
14
  /** The staged generation states its own contract in its manifest, so the
@@ -250,7 +251,7 @@ export async function reclaimGenerations({ root, active = null, pidIsAlive = def
250
251
  if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
251
252
  const directory = path.join(generations, entry.name);
252
253
  if (referenced.has(directory)) continue;
253
- await rm(directory, { recursive: true, force: true });
254
+ await removeTree(directory);
254
255
  removed.push(entry.name);
255
256
  }
256
257
  if (removed.length) await syncDirectory(generations);