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
@@ -81,6 +81,41 @@ export function assertCapabilities(declared = {}, implementation = {}, certifica
81
81
  return freezeCapabilities(resolved);
82
82
  }
83
83
 
84
+ /**
85
+ * The other products whose hooks one adapter serves. Antigravity 2.0 runs the
86
+ * same `~/.gemini/config/hooks.json` as Antigravity CLI, on its own version
87
+ * line, so its evidence is recorded under its own certification name. A
88
+ * variant names a product, not a launch mode: one adapter's hooks, one more
89
+ * version line.
90
+ */
91
+ function validateClientVariants(variants, primary) {
92
+ if (variants === undefined) return [];
93
+ if (!Array.isArray(variants)) usage("client.variants must be an array", { variants });
94
+ const seen = new Set([primary]);
95
+ return variants.map((variant, index) => {
96
+ if (variant === null || typeof variant !== "object" || Array.isArray(variant)) {
97
+ usage(`client.variants entry ${index} must be an object`);
98
+ }
99
+ for (const key of Object.keys(variant)) {
100
+ if (!["certificationName", "displayName"].includes(key)) {
101
+ usage(`client.variants entry ${index} has unknown field ${key}`, { key });
102
+ }
103
+ }
104
+ const { certificationName, displayName } = variant;
105
+ if (typeof certificationName !== "string" || certificationName.trim() === "") {
106
+ usage(`client.variants entry ${index} certificationName must be a non-empty string`);
107
+ }
108
+ if (typeof displayName !== "string" || displayName.trim() === "") {
109
+ usage(`client.variants entry ${index} displayName must be a non-empty string`);
110
+ }
111
+ if (seen.has(certificationName)) {
112
+ usage(`client.variants entry ${index} repeats ${certificationName}`, { certificationName });
113
+ }
114
+ seen.add(certificationName);
115
+ return { certificationName, displayName };
116
+ });
117
+ }
118
+
84
119
  export function defineAdapter(manifest) {
85
120
  if (typeof manifest?.id !== "string") usage("an adapter must declare an id");
86
121
  assertPortableId(manifest.id, "adapter id");
@@ -104,15 +139,25 @@ export function defineAdapter(manifest) {
104
139
  const certification = validateCertification(manifest.certification);
105
140
  const known = new Set(Object.entries(CAPABILITY_SHAPE)
106
141
  .flatMap(([group, names]) => names.map(name => `${group}.${name}`)));
142
+ const primary = manifest.client.certificationName ?? manifest.client.command;
143
+ const clients = new Set([primary, ...validateClientVariants(manifest.client.variants, primary)
144
+ .map(variant => variant.certificationName)]);
107
145
  for (const item of certification.evidence) {
108
146
  if (!known.has(item.capability)) {
109
147
  usage(`unknown certified capability: ${item.capability}`,
110
148
  { capability: item.capability });
111
149
  }
112
- const client = manifest.client.certificationName ?? manifest.client.command;
113
- if (item.client !== client) {
114
- usage(`certification evidence client ${item.client} does not match ${client}`,
115
- { evidenceClient: item.client, client });
150
+ if (!clients.has(item.client)) {
151
+ usage(`certification evidence client ${item.client} does not match ${primary}`,
152
+ { evidenceClient: item.client, client: primary });
153
+ }
154
+ }
155
+ // How the hook runner learns which product and which version a session runs
156
+ // in, from the process that runs it rather than the binary on PATH. Both are
157
+ // optional; an adapter without them is matched by client.command.
158
+ for (const method of ["identifyClientProcess", "clientVersionOf"]) {
159
+ if (manifest[method] !== undefined && typeof manifest[method] !== "function") {
160
+ usage(`optional client method ${method} must be a function`, { id: manifest.id, method });
116
161
  }
117
162
  }
118
163
  // A floor said "judge later versions by this capture". Evidence now does that
@@ -139,7 +184,7 @@ export function defineAdapter(manifest) {
139
184
  if (manifest.nativeDelivery !== undefined) {
140
185
  const client = manifest.client.certificationName ?? manifest.client.command;
141
186
  native.nativeDelivery = validateNativeDeliveryContract(manifest.nativeDelivery,
142
- { certification, client });
187
+ { certification, client, clients: [...clients] });
143
188
  for (const method of NATIVE_METHODS) {
144
189
  if (typeof manifest[method] !== "function") {
145
190
  usage(`a native delivery contract requires ${method}()`, { id: manifest.id, method });
@@ -178,12 +178,21 @@ function falseCapabilities() {
178
178
  * first capture, whose version is returned) or `recorded-failure` (a capture at
179
179
  * the returned version recorded the loss).
180
180
  */
181
- export function capabilityEvidence(adapter, { clientVersion, platform } = {}, capability) {
181
+ export function capabilityEvidence(adapter, { clientVersion, platform, clientName } = {},
182
+ capability) {
182
183
  const [group, name] = capability.split(".");
183
184
  if (adapter.capabilities?.[group]?.[name] !== true) {
184
185
  return { granted: false, reason: "undeclared", version: null };
185
186
  }
186
- const client = adapter.client?.certificationName ?? adapter.client?.command;
187
+ // `clientName` names the product the session runs in when it is one of the
188
+ // adapter's variants. Another product's evidence never stands in for it: its
189
+ // versions count on a different line. A name the adapter does not declare
190
+ // has no evidence at all.
191
+ const primary = adapter.client?.certificationName ?? adapter.client?.command;
192
+ const declared = [primary, ...(adapter.client?.variants ?? [])
193
+ .map(variant => variant?.certificationName)];
194
+ const client = typeof clientName === "string" ? clientName : primary;
195
+ if (!declared.includes(client)) return { granted: false, reason: "unobserved", version: null };
187
196
  const named = (adapter.certification?.evidence ?? [])
188
197
  .filter(item => item.client === client && item.capability === capability);
189
198
  if (named.length === 0) return { granted: false, reason: "unobserved", version: null };
@@ -0,0 +1,149 @@
1
+ // Finding and starting a client binary the way the platform does. POSIX joins
2
+ // the bare name onto each PATH entry; Windows adds an extension from PATHEXT and
3
+ // can start a .cmd or .bat only through cmd.exe.
4
+ import { execFile } from "node:child_process";
5
+ import { constants } from "node:fs";
6
+ import { access, readFile as readText } from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { promisify } from "node:util";
9
+
10
+ const execFileAsync = promisify(execFile);
11
+ const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
12
+
13
+ /** `override` laid over `base`. Windows variable names ignore case, so there an
14
+ * override replaces the base's variable however either spells it. */
15
+ export function mergeEnv(base, override, platform = process.platform) {
16
+ if (platform !== "win32") return { ...base, ...override };
17
+ const replaced = new Set(Object.keys(override ?? {}).map(name => name.toUpperCase()));
18
+ const kept = Object.entries(base ?? {}).filter(([name]) => !replaced.has(name.toUpperCase()));
19
+ return { ...Object.fromEntries(kept), ...override };
20
+ }
21
+
22
+ /** An environment's search path. A copy of Windows' process.env keeps the
23
+ * variable's own spelling, usually `Path`, and a copy is case-sensitive. */
24
+ export const pathOf = env => Object.entries(env ?? {})
25
+ .find(([name]) => name.toUpperCase() === "PATH")?.[1] ?? "";
26
+
27
+ /**
28
+ * The file a bare command runs, found on PATH as the platform finds it.
29
+ * POSIX: the first executable `<dir>/<command>`. Windows: `<dir>/<command><ext>`
30
+ * for each PATHEXT extension in order, a real `.exe` before a `.cmd` shim in
31
+ * the same directory, because Node can run an `.exe` directly.
32
+ */
33
+ export async function resolveExecutable(command, { pathEnv = pathOf(process.env),
34
+ pathExt = process.env.PATHEXT ?? DEFAULT_PATHEXT, platform = process.platform,
35
+ exclude = [], exists = defaultExists } = {}) {
36
+ const flavour = platform === "win32" ? path.win32 : path.posix;
37
+ const delimiter = platform === "win32" ? ";" : ":";
38
+ // Windows paths compare without case; POSIX ones do not.
39
+ const key = directory => (platform === "win32" ? flavour.resolve(directory).toLowerCase()
40
+ : flavour.resolve(directory));
41
+ const excluded = exclude.filter(Boolean).map(key);
42
+ const extensions = platform === "win32" && flavour.extname(command) === ""
43
+ ? orderedExtensions(pathExt) : [""];
44
+ // A path is used as it is, not looked up: the caller already knows the file.
45
+ if (flavour.isAbsolute(command)) {
46
+ for (const extension of extensions) {
47
+ if (await exists(`${command}${extension}`, platform)) return `${command}${extension}`;
48
+ }
49
+ return null;
50
+ }
51
+ for (const directory of String(pathEnv).split(delimiter).filter(Boolean)) {
52
+ if (excluded.includes(key(directory))) continue;
53
+ for (const extension of extensions) {
54
+ const candidate = flavour.join(directory, `${command}${extension}`);
55
+ if (await exists(candidate, platform)) return candidate;
56
+ }
57
+ }
58
+ return null;
59
+ }
60
+
61
+ function orderedExtensions(pathExt) {
62
+ const listed = String(pathExt).split(";").map(item => item.trim().toLowerCase()).filter(Boolean);
63
+ const runnable = listed.filter(item => [".exe", ".com", ".cmd", ".bat"].includes(item));
64
+ return [...new Set([".exe", ...runnable])];
65
+ }
66
+
67
+ async function defaultExists(file, platform) {
68
+ try {
69
+ await access(file, platform === "win32" ? constants.F_OK : constants.X_OK);
70
+ return true;
71
+ } catch { return false; }
72
+ }
73
+
74
+ // cmd.exe expands and splits these even inside quotes; a fixed argument that
75
+ // carries one is refused rather than escaped.
76
+ const CMD_UNSAFE = /["%^&|<>\r\n!]/;
77
+
78
+ /**
79
+ * Run a resolved executable with fixed arguments. Windows cannot start a `.cmd`
80
+ * or `.bat` without cmd.exe (Node refuses with EINVAL since CVE-2024-27980), so
81
+ * those go through `cmd.exe /d /s /c` with a command line ACC builds itself.
82
+ */
83
+ // Not async: the promise execFile returns carries `.child`, and a caller that
84
+ // must close the child's stdin needs it.
85
+ export function runExecutable(file, args, options = {},
86
+ { platform = process.platform, run = execFileAsync, env = process.env,
87
+ killTree = killProcessTree } = {}) {
88
+ if (platform !== "win32" || !/\.(cmd|bat)$/i.test(file)) {
89
+ return run(file, args, { windowsHide: true, ...options });
90
+ }
91
+ for (const word of [file, ...args]) {
92
+ if (CMD_UNSAFE.test(word)) {
93
+ return Promise.reject(Object.assign(new Error("argument unsafe for cmd.exe"), { code: "EINVAL" }));
94
+ }
95
+ }
96
+ const line = [file, ...args].map(word => `"${word}"`).join(" ");
97
+ const comspec = env.ComSpec ?? env.COMSPEC ?? "cmd.exe";
98
+ const { timeout, ...rest } = options;
99
+ const outcome = run(comspec, ["/d", "/s", "/c", `"${line}"`],
100
+ { windowsHide: true, windowsVerbatimArguments: true, ...rest });
101
+ if (!(timeout > 0)) return outcome;
102
+ // cmd.exe starts the batch file's program as a child of its own, and killing
103
+ // cmd.exe leaves that program running with this process's pipes open - which
104
+ // is what execFile waits on after its own timeout. So the timeout is kept
105
+ // here: the whole tree goes, and this side of the pipes is closed.
106
+ const { child } = outcome;
107
+ let timer;
108
+ const expired = new Promise((resolve, reject) => {
109
+ timer = setTimeout(() => {
110
+ // Only a batch still running: once cmd.exe has exited its pid may belong
111
+ // to another process, and /T would end that one and its tree.
112
+ if (child?.exitCode === null && child?.signalCode === null) killTree(child.pid);
113
+ child?.stdout?.destroy();
114
+ child?.stderr?.destroy();
115
+ reject(Object.assign(new Error(`${file} timed out after ${timeout}ms`),
116
+ { code: "ETIMEDOUT", killed: true }));
117
+ }, timeout);
118
+ });
119
+ // Once the timeout has answered, the batch's own failure is expected.
120
+ outcome.catch(() => {});
121
+ const settled = Promise.race([outcome, expired]).finally(() => clearTimeout(timer));
122
+ return Object.assign(settled, { child });
123
+ }
124
+
125
+ // taskkill /T ends a process and everything it started, which is the only way
126
+ // Windows has to reach a grandchild. Best effort: the tree may already be gone.
127
+ function killProcessTree(pid, env = process.env) {
128
+ if (!Number.isInteger(pid) || pid <= 0) return;
129
+ const root = env.SystemRoot ?? env.SYSTEMROOT ?? "C:\\Windows";
130
+ execFile(path.win32.join(root, "System32", "taskkill.exe"), ["/pid", String(pid), "/T", "/F"],
131
+ { windowsHide: true }, () => {});
132
+ }
133
+
134
+ // The file an npm .cmd shim runs: `"%dp0%\<path inside the prefix>"`.
135
+ const SHIM_TARGET = /"%dp0%\\([^"%]+)"/gi;
136
+
137
+ /**
138
+ * The file an npm .cmd shim runs - a native binary or a package script - or
139
+ * null when the file is no such shim. A probe reads that file; the .cmd says
140
+ * nothing about the client behind it.
141
+ */
142
+ export async function npmShimTarget(file, { readFile = readText } = {}) {
143
+ try {
144
+ const targets = [...String(await readFile(file, "utf8")).matchAll(SHIM_TARGET)];
145
+ return targets.length === 1 ? path.win32.join(path.win32.dirname(file), targets[0][1]) : null;
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
@@ -81,8 +81,9 @@ export async function assertRunner(runner) {
81
81
  const PLACEHOLDER = "{{ACC}}";
82
82
 
83
83
  // Shell metacharacters in a path are the caller's problem to survive, not the
84
- // shell's to interpret.
85
- const quote = value => `"${String(value).replace(/(["\\$`])/g, "\\$1")}"`;
84
+ // shell's to interpret. A hook command in the POSIX form quotes its shim the
85
+ // same way, since each client runs it through /bin/sh.
86
+ export const shellQuote = value => `"${String(value).replace(/(["\\$`])/g, "\\$1")}"`;
86
87
 
87
88
  /**
88
89
  * Whether a path is one shell word exactly as written: absolute, and made only
@@ -93,6 +94,43 @@ const quote = value => `"${String(value).replace(/(["\\$`])/g, "\\$1")}"`;
93
94
  export const isShellWord = value => typeof value === "string"
94
95
  && /^\/[A-Za-z0-9._/-]*$/.test(value);
95
96
 
97
+ // Windows runs this where POSIX runs a shell script: no Windows client can be
98
+ // relied on to have `sh`. Same order: the pinned target, loaded into this
99
+ // process when the node running the shim is new enough (a second node start
100
+ // costs more on Windows than the rest of a hook), else started with the pinned
101
+ // node; then a message, and the exit code the caller chose.
102
+ const json = value => JSON.stringify(String(value));
103
+ function nodeShim({ node, target, lead, dataHome, gone, goneExit }) {
104
+ return [
105
+ "#!/usr/bin/env node",
106
+ "// Generated by ACC at install time. The paths are pinned for the reason the",
107
+ "// POSIX shell shim pins them: a hook's environment may carry no PATH, and a",
108
+ "// node version manager moves both the interpreter and the global packages.",
109
+ "import { spawnSync } from \"node:child_process\";",
110
+ "import { existsSync } from \"node:fs\";",
111
+ "import { pathToFileURL } from \"node:url\";",
112
+ "",
113
+ `const ACC_NODE = ${json(node)};`,
114
+ `const ACC_TARGET = ${json(target)};`,
115
+ ...(dataHome === undefined ? [] : [`process.env.ACC_DATA_HOME = ${json(dataHome)};`]),
116
+ `const args = [...${JSON.stringify(lead)}, ...process.argv.slice(2)];`,
117
+ "if (existsSync(ACC_TARGET) && Number(process.versions.node.split(\".\")[0]) >= 24) {",
118
+ " process.argv = [process.execPath, ACC_TARGET, ...args];",
119
+ " await import(pathToFileURL(ACC_TARGET).href);",
120
+ "} else if (existsSync(ACC_NODE) && existsSync(ACC_TARGET)) {",
121
+ " const child = spawnSync(ACC_NODE, [ACC_TARGET, ...args], { stdio: \"inherit\", windowsHide: true });",
122
+ " process.exitCode = child.status ?? 1;",
123
+ "} else {",
124
+ ` process.stderr.write(\`${gone} (node: \${ACC_NODE}).\\n\`);`,
125
+ " process.stderr.write(\"acc: run 'acc install' to wire this client to the acc you have now.\\n\");",
126
+ ` process.exitCode = ${goneExit};`,
127
+ "}",
128
+ "",
129
+ ].join("\n");
130
+ }
131
+
132
+ const nodeShimName = name => name.replace(/\.sh$/, ".mjs");
133
+
96
134
  /**
97
135
  * Write the small shim a client's hook command points at.
98
136
  *
@@ -103,8 +141,15 @@ export const isShellWord = value => typeof value === "string"
103
141
  * install time.
104
142
  */
105
143
  export async function writeHookShim({ dir, adapterId, runner = defaultRunner(),
106
- node = process.execPath, name = "acc-hook.sh", dataHome }) {
144
+ node = process.execPath, name = "acc-hook.sh", dataHome, platform = process.platform }) {
107
145
  await assertRunner(runner);
146
+ if (platform === "win32") {
147
+ const target = path.join(dir, nodeShimName(name));
148
+ await mkdir(dir, { recursive: true });
149
+ await writeFile(target, nodeShim({ node, target: runner, lead: [adapterId], dataHome,
150
+ gone: "acc: the hook runner installed here is gone", goneExit: 0 }));
151
+ return target;
152
+ }
108
153
  const target = path.join(dir, name);
109
154
  await mkdir(dir, { recursive: true });
110
155
  await writeFile(target, [
@@ -118,9 +163,9 @@ export async function writeHookShim({ dir, adapterId, runner = defaultRunner(),
118
163
  "# packages live in, and a shim naming the old ones failed on every event",
119
164
  "# with nothing to read but exit 126 - no presence, no claims, no messages,",
120
165
  "# and nothing anywhere saying why.",
121
- `ACC_NODE=${quote(node)}`,
122
- `ACC_RUNNER=${quote(runner)}`,
123
- ...(dataHome === undefined ? [] : [`export ACC_DATA_HOME=${quote(dataHome)}`]),
166
+ `ACC_NODE=${shellQuote(node)}`,
167
+ `ACC_RUNNER=${shellQuote(runner)}`,
168
+ ...(dataHome === undefined ? [] : [`export ACC_DATA_HOME=${shellQuote(dataHome)}`]),
124
169
  'if [ -x "$ACC_NODE" ] && [ -f "$ACC_RUNNER" ]; then',
125
170
  ` exec "$ACC_NODE" "$ACC_RUNNER" ${adapterId} "$@"`,
126
171
  "fi",
@@ -166,7 +211,14 @@ export async function writeHookShim({ dir, adapterId, runner = defaultRunner(),
166
211
  * an agent told nothing went wrong improvises records by hand.
167
212
  */
168
213
  export async function writeCliShim({ dir, cli = defaultCli(), node = process.execPath,
169
- name = "acc-cli.sh", dataHome }) {
214
+ name = "acc-cli.sh", dataHome, platform = process.platform }) {
215
+ if (platform === "win32") {
216
+ const target = path.join(dir, nodeShimName(name));
217
+ await mkdir(dir, { recursive: true });
218
+ await writeFile(target, nodeShim({ node, target: cli, lead: [], dataHome,
219
+ gone: "acc: the CLI installed here is gone", goneExit: 1 }));
220
+ return target;
221
+ }
170
222
  const target = path.join(dir, name);
171
223
  await mkdir(dir, { recursive: true });
172
224
  await writeFile(target, [
@@ -175,9 +227,9 @@ export async function writeCliShim({ dir, cli = defaultCli(), node = process.exe
175
227
  "# hook shim pins them: a node version manager moves both the interpreter and",
176
228
  "# the directory global packages live in, so the pinned pair is tried first",
177
229
  "# and then asked for.",
178
- `ACC_NODE=${quote(node)}`,
179
- `ACC_CLI=${quote(cli)}`,
180
- ...(dataHome === undefined ? [] : [`export ACC_DATA_HOME=${quote(dataHome)}`]),
230
+ `ACC_NODE=${shellQuote(node)}`,
231
+ `ACC_CLI=${shellQuote(cli)}`,
232
+ ...(dataHome === undefined ? [] : [`export ACC_DATA_HOME=${shellQuote(dataHome)}`]),
181
233
  'if [ -x "$ACC_NODE" ] && [ -f "$ACC_CLI" ]; then',
182
234
  ' exec "$ACC_NODE" "$ACC_CLI" "$@"',
183
235
  "fi",
@@ -196,6 +248,17 @@ export async function writeCliShim({ dir, cli = defaultCli(), node = process.exe
196
248
  return target;
197
249
  }
198
250
 
251
+ /**
252
+ * A Node shim at `file` that runs `target` with the pinned node, for an entry
253
+ * point a Windows shell starts as `node "<file>"`. `gone` is what it prints when
254
+ * neither is there any more, and `goneExit` the exit code it leaves.
255
+ */
256
+ export async function writeNodeShim({ file, target, node = process.execPath, gone, goneExit = 0 }) {
257
+ await mkdir(path.dirname(file), { recursive: true });
258
+ await writeFile(file, nodeShim({ node, target, lead: [], gone, goneExit }));
259
+ return file;
260
+ }
261
+
199
262
  /**
200
263
  * Remove a directory this adapter installed, unless it has been kept back.
201
264
  *
@@ -246,12 +309,16 @@ export const defaultCli = () => ownBinary("acc.mjs");
246
309
  * Antigravity CLI 1.2.12 never matched a command whose program word was quoted,
247
310
  * whatever rule was loaded (issue #214). A path that needs quotes keeps them.
248
311
  */
249
- export async function bakeSkillCommand({ root, cliShim, bareWhenSafe = false }) {
312
+ export async function bakeSkillCommand({ root, cliShim, bareWhenSafe = false,
313
+ platform = process.platform }) {
250
314
  if (typeof cliShim !== "string" || cliShim === "") {
251
315
  throw new AccError(EXIT.DATA, "refusing to bake an empty skill command", { cliShim });
252
316
  }
253
317
  // A managed data home is "Application Support"; an unquoted path splits.
254
- const command = bareWhenSafe && isShellWord(cliShim) ? cliShim : quote(cliShim);
318
+ // Windows names node and the Node shim: `node "C:/…/acc-cli.mjs"` runs the same
319
+ // in PowerShell, Git Bash and cmd, whichever the client gives its model.
320
+ const command = platform === "win32" ? `node "${cliShim.replaceAll("\\", "/")}"`
321
+ : bareWhenSafe && isShellWord(cliShim) ? cliShim : shellQuote(cliShim);
255
322
  const baked = [];
256
323
  const walk = async directory => {
257
324
  for (const entry of await readdir(directory, { withFileTypes: true })) {
@@ -5,12 +5,13 @@ export { capabilityEvidence, compareVersionOrder, effectiveCapabilities, validat
5
5
  versionOrder }
6
6
  from "./certification.mjs";
7
7
  export { NATIVE_ACTIVATION_KINDS, NATIVE_BINDING_MODES, NATIVE_REASON_CODES,
8
- compareStableVersions, evaluateNativeEligibility, evaluateVersionContract, parseStableVersion,
8
+ compareStableVersions, evaluateNativeEligibility, evaluateVersionContract, isLaunchOption,
9
+ parseStableVersion,
9
10
  validateNativeActivationPlan, validateNativeDeliveryContract, validateNativeHandshake }
10
11
  from "./native-delivery.mjs";
11
12
  export { EVENT_KINDS, NORMALIZED_EVENT_KEYS, normalizedEvent } from "./events.mjs";
12
13
  export { assertRunner, bakeSkillCommand, defaultAntigravityRelay, defaultCli, isShellWord,
13
- defaultRunner, removeInstalledTree, runnerExists, writeCliShim, writeHookShim }
14
+ defaultRunner, removeInstalledTree, runnerExists, shellQuote, writeCliShim, writeHookShim, writeNodeShim }
14
15
  from "./hook-shim.mjs";
15
16
  export { BEGIN, END, removeTomlBlock, renderBlock, stripBlock, tomlString, writeTomlBlock }
16
17
  from "./toml-block.mjs";
@@ -28,3 +29,11 @@ export { clearSessionBinding, listSessionBindings, loadSessionBinding,
28
29
 
29
30
  export { clearNativeAttempt, loadNativeAttempt, storeNativeAttempt } from "./native-attempt.mjs";
30
31
  export { channelSocketDirectory } from "./channel-directory.mjs";
32
+ export { readProcessArgs, readProcessTable, readWindowsProcess, splitWindowsCommandLine }
33
+ from "./process-table.mjs";
34
+ export { npmShimTarget, resolveExecutable, runExecutable } from "./executables.mjs";
35
+ export { pathOf } from "./executables.mjs";
36
+ export { windowsHookCommand } from "./windows-command.mjs";
37
+ export { shortPath } from "./windows-command.mjs";
38
+ export { mergeEnv } from "./executables.mjs";
39
+ export { closedTo, isWindowsPlatform, openRegularNoFollow } from "./private-files.mjs";
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { Worker } from "node:worker_threads";
5
5
 
6
- import { NATIVE_REASON_CODES, TIMESTAMP } from "./native-vocabulary.mjs";
6
+ import { NATIVE_REASON_CODES, TIMESTAMP, isLaunchOption } from "./native-vocabulary.mjs";
7
7
 
8
8
  // Optional diagnostic metadata beside the hook-owner file, never an event
9
9
  // history or injected context. Copy only closed values: vendor errors, prompts,
@@ -18,9 +18,13 @@ export function nativeAttemptFrom(value) {
18
18
  || !["bootstrap-environment", "installation-record"].includes(value.policySource)
19
19
  || !["enabled", "off", "missing", "invalid", "unavailable"].includes(value.policyStatus)
20
20
  || !["identified", "unknown"].includes(value.clientProcess)) return null;
21
+ // The option a refusal named is optional and closed to a bare option token;
22
+ // anything else is dropped and the attempt itself kept.
21
23
  return { at: value.at, event: value.event, state: value.state, reasonCode: value.reasonCode,
22
24
  policy: value.policy, policySource: value.policySource, policyStatus: value.policyStatus,
23
- clientProcess: value.clientProcess };
25
+ clientProcess: value.clientProcess,
26
+ ...(value.state !== "active" && isLaunchOption(value.launchOption)
27
+ ? { launchOption: value.launchOption } : {}) };
24
28
  }
25
29
 
26
30
  const keyFor = harnessSessionId => createHash("sha256").update(String(harnessSessionId))
@@ -1,7 +1,7 @@
1
- import { CONTRACT_ID, FINGERPRINT, HANDSHAKE_KEYS, KNOWN_BAD_REASON, NATIVE_BINDING_MODES,
2
- PLANNABLE_ACTIVATION_KINDS, PROBE_KEYS, TIMESTAMP, assertModes, assertReasonCode, closed,
3
- compareStableVersions, deepFreeze, isPlainObject, isText, parseStableVersion, usage }
4
- from "./native-vocabulary.mjs";
1
+ import { CONTRACT_ID, FINGERPRINT, HANDSHAKE_KEYS, HANDSHAKE_OPTIONAL_KEYS, KNOWN_BAD_REASON,
2
+ NATIVE_BINDING_MODES, PLANNABLE_ACTIVATION_KINDS, PROBE_KEYS, TIMESTAMP, assertModes,
3
+ assertReasonCode, closed, compareStableVersions, deepFreeze, isLaunchOption, isPlainObject, isText,
4
+ parseStableVersion, usage } from "./native-vocabulary.mjs";
5
5
  import { compareVersionOrder, versionOrder } from "./certification.mjs";
6
6
 
7
7
  // The native-delivery compatibility contract: a minimum that is a real passing
@@ -17,12 +17,13 @@ import { compareVersionOrder, versionOrder } from "./certification.mjs";
17
17
  // live delivery alone.
18
18
 
19
19
  export { NATIVE_ACTIVATION_KINDS, NATIVE_BINDING_MODES, NATIVE_REASON_CODES,
20
- PLANNABLE_ACTIVATION_KINDS, compareStableVersions, parseStableVersion } from "./native-vocabulary.mjs";
20
+ PLANNABLE_ACTIVATION_KINDS, compareStableVersions, isLaunchOption, parseStableVersion }
21
+ from "./native-vocabulary.mjs";
21
22
  export { validateNativeActivationPlan } from "./native-activation.mjs";
22
23
 
23
24
  const orderedModes = modes => NATIVE_BINDING_MODES.filter(mode => modes.includes(mode));
24
25
 
25
- export function validateNativeDeliveryContract(value, { certification, client }) {
26
+ export function validateNativeDeliveryContract(value, { certification, client, clients = [client] }) {
26
27
  // Consent to live delivery is what `acc install` recorded. Up to 0.7.x a
27
28
  // Claude shell shim could export it instead ("bootstrap-environment"); the
28
29
  // shim is gone, and the installation record is the one source left.
@@ -64,11 +65,19 @@ export function validateNativeDeliveryContract(value, { certification, client })
64
65
  + "is recorded by its evidence, and the anchor names the version alone "
65
66
  + "(docs/design/2026-09-26-native-delivery-across-platforms.md)");
66
67
  }
67
- closed(anchor, ["version", "protocolContract"], "nativeDelivery anchor");
68
+ closed(anchor, ["version", "protocolContract"], "nativeDelivery anchor", ["client"]);
68
69
  if (parseStableVersion(anchor.version) === null) {
69
70
  usage(`nativeDelivery anchor ${index} version must be a stable version`);
70
71
  }
71
- 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) {
72
81
  usage(`nativeDelivery anchor ${index} version ${anchor.version} is below the minimum ${minimum}: `
73
82
  + "the minimum must be the first passing capture");
74
83
  }
@@ -78,7 +87,7 @@ export function validateNativeDeliveryContract(value, { certification, client })
78
87
  // Where the capture was taken is provenance the evidence row keeps; the
79
88
  // anchor is proven by a passing capture of this version on any platform.
80
89
  const proven = (certification?.evidence ?? []).some(item => item.result === "pass"
81
- && item.capability === "delivery.livePush" && item.client === client
90
+ && item.capability === "delivery.livePush" && item.client === owner
82
91
  && item.version === anchor.version);
83
92
  if (!proven) {
84
93
  usage(`nativeDelivery anchor ${anchor.version} has no passing delivery.livePush certification`,
@@ -86,7 +95,18 @@ export function validateNativeDeliveryContract(value, { certification, client })
86
95
  }
87
96
  return { ...anchor };
88
97
  });
89
- 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)) {
90
110
  usage(`nativeDelivery minimum ${minimum} must be the first passing capture: no anchor matches it`);
91
111
  }
92
112
  if (!Array.isArray(value.knownBad)) usage("nativeDelivery.knownBad must be an array");
@@ -143,7 +163,7 @@ function knownBadHit(contract, order) {
143
163
  // validateNativeHandshake below, and the delivery router's own offer check,
144
164
  // which uses this function alone because a response already carries no probe
145
165
  // or handshake shape to check further).
146
- export function evaluateVersionContract(adapter, { clientVersion } = {}) {
166
+ export function evaluateVersionContract(adapter, { clientVersion, protocolContract } = {}) {
147
167
  const contract = adapter?.nativeDelivery;
148
168
  const unsupported = { reasonCode: "native_delivery_unsupported", minimumVersion: null,
149
169
  protocolContract: null };
@@ -160,9 +180,18 @@ export function evaluateVersionContract(adapter, { clientVersion } = {}) {
160
180
  // already answers malformed input this way.
161
181
  const minimumVersion = isText(contract.minimum) ? contract.minimum : null;
162
182
  if (minimumVersion === null) return unsupported;
163
- const anchor = (Array.isArray(contract.anchors) ? contract.anchors : [])
164
- .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));
165
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
+ }
166
195
  const facts = { minimumVersion, protocolContract: anchor.protocolContract };
167
196
  const order = versionOrder(clientVersion);
168
197
  if (order === null) return { ...facts, reasonCode: "version_unavailable" };
@@ -173,6 +202,31 @@ export function evaluateVersionContract(adapter, { clientVersion } = {}) {
173
202
  return { ...facts, reasonCode: null };
174
203
  }
175
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
+
176
230
  function validateNativeProbe(probe) {
177
231
  closed(probe, PROBE_KEYS, "native probe");
178
232
  if (typeof probe.supported !== "boolean") usage("native probe supported must be a boolean");
@@ -198,7 +252,8 @@ export function evaluateNativeEligibility(adapter, { clientVersion, probe }) {
198
252
  // Only the version is read here; the shape is validated where it always was,
199
253
  // so a malformed probe still returns a closed result rather than throwing.
200
254
  const serving = isText(probe?.clientVersion) ? probe.clientVersion : clientVersion;
201
- const rule = evaluateVersionContract(adapter, { clientVersion: serving });
255
+ const rule = evaluateVersionContract(adapter, { clientVersion: serving,
256
+ protocolContract: reportedProtocol(adapter, probe?.protocolContract) });
202
257
  const base = { eligible: false, reasonCode: null, minimumVersion: rule.minimumVersion,
203
258
  protocolContract: rule.protocolContract, modes: [] };
204
259
  const closedResult = reasonCode => deepFreeze({ ...base, reasonCode });
@@ -214,7 +269,7 @@ export function evaluateNativeEligibility(adapter, { clientVersion, probe }) {
214
269
  }
215
270
 
216
271
  function validateNativeHandshakeShape(handshake) {
217
- closed(handshake, HANDSHAKE_KEYS, "native handshake");
272
+ closed(handshake, HANDSHAKE_KEYS, "native handshake", HANDSHAKE_OPTIONAL_KEYS);
218
273
  if (typeof handshake.supported !== "boolean") usage("native handshake supported must be a boolean");
219
274
  if (handshake.clientVersion !== null && !isText(handshake.clientVersion)) {
220
275
  usage("native handshake clientVersion must be a string or null");
@@ -236,6 +291,12 @@ function validateNativeHandshakeShape(handshake) {
236
291
  } else if (handshake.opaqueEndpointRef !== null || handshake.leaseUntil !== null) {
237
292
  usage("an unsupported native handshake carries no endpoint or lease");
238
293
  }
294
+ if (Object.hasOwn(handshake, "launchOption")) {
295
+ if (handshake.supported) usage("only a refused native handshake names a launchOption");
296
+ if (!isLaunchOption(handshake.launchOption)) {
297
+ usage("native handshake launchOption must be a bare command-line option");
298
+ }
299
+ }
239
300
  return handshake;
240
301
  }
241
302
 
@@ -256,7 +317,8 @@ export function validateNativeHandshake(adapter, { clientVersion, handshake }) {
256
317
  // the version is read here; the shape is validated where it always was, so
257
318
  // a malformed handshake still returns a closed result rather than throwing.
258
319
  const serving = isText(handshake?.clientVersion) ? handshake.clientVersion : clientVersion;
259
- const rule = evaluateVersionContract(adapter, { clientVersion: serving });
320
+ const rule = evaluateVersionContract(adapter, { clientVersion: serving,
321
+ protocolContract: reportedProtocol(adapter, handshake?.protocolContract) });
260
322
  const base = { ok: false, reasonCode: null, clientVersion: null,
261
323
  protocolContract: rule.protocolContract, modes: [],
262
324
  opaqueEndpointRef: null, leaseUntil: null };
@@ -264,7 +326,10 @@ export function validateNativeHandshake(adapter, { clientVersion, handshake }) {
264
326
  if (rule.reasonCode !== null) return closedResult(rule.reasonCode);
265
327
  if (handshake === null || handshake === undefined) return closedResult("handshake_failed");
266
328
  const facts = validateNativeHandshakeShape(handshake);
267
- if (facts.supported !== true) return closedResult(facts.reasonCode ?? "handshake_failed");
329
+ if (facts.supported !== true) {
330
+ return deepFreeze({ ...base, reasonCode: facts.reasonCode ?? "handshake_failed",
331
+ ...(Object.hasOwn(facts, "launchOption") ? { launchOption: facts.launchOption } : {}) });
332
+ }
268
333
  if (facts.protocolContract !== rule.protocolContract) return closedResult("protocol_mismatch");
269
334
  const modes = orderedModes(facts.modes);
270
335
  if (!modes.includes("livePush")) return closedResult("handshake_failed");