agents-can-communicate 0.1.18 → 0.3.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 (155) hide show
  1. package/README.md +87 -69
  2. package/SECURITY.md +31 -0
  3. package/bin/acc-bootstrap.mjs +56 -0
  4. package/bin/acc-claude-channel.mjs +177 -0
  5. package/bin/acc-hook.mjs +94 -12
  6. package/bin/acc-mcp.mjs +6 -2
  7. package/bin/acc.mjs +13 -3
  8. package/docs/ADAPTER_AUTHORING.md +204 -0
  9. package/docs/ARCHITECTURE.md +131 -0
  10. package/docs/CAPABILITIES.md +117 -214
  11. package/docs/CLI.md +164 -0
  12. package/docs/CONCEPTS.md +134 -0
  13. package/docs/CONFIGURATION.md +147 -0
  14. package/docs/DESIGN_DECISIONS.md +89 -0
  15. package/docs/GETTING_STARTED.md +145 -0
  16. package/docs/GLOSSARY.md +26 -0
  17. package/docs/HOW_IT_WORKS.md +277 -0
  18. package/docs/MCP.md +94 -0
  19. package/docs/PROTOCOL.md +200 -0
  20. package/docs/RELEASING.md +115 -0
  21. package/docs/SECURITY_MODEL.md +131 -0
  22. package/docs/TROUBLESHOOTING.md +108 -0
  23. package/docs/WHY_ACC.md +61 -0
  24. package/docs/index.md +44 -0
  25. package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +228 -0
  26. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse-Edit.json +19 -0
  27. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse.json +17 -0
  28. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionEnd.json +8 -0
  29. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionStart.json +7 -0
  30. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/UserPromptSubmit.json +9 -0
  31. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +269 -0
  32. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.252.json +21 -0
  33. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.258.json +23 -0
  34. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.260.json +23 -0
  35. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +13 -2
  36. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.mcp.json +8 -0
  37. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +22 -22
  38. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +45 -5
  39. package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +377 -0
  40. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +27 -7
  41. package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +229 -0
  42. package/node_modules/@agents-can-communicate/adapter-codex/certification.json +150 -0
  43. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/PreToolUse.json +14 -0
  44. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionEnd.json +7 -0
  45. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionStart.json +9 -0
  46. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/UserPromptSubmit.json +10 -0
  47. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +199 -0
  48. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.0.json +21 -0
  49. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-remote-workspace.json +25 -0
  50. package/node_modules/@agents-can-communicate/adapter-codex/package.json +11 -2
  51. package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +1 -1
  52. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +22 -22
  53. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +54 -12
  54. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +121 -0
  55. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +151 -0
  56. package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +192 -0
  57. package/node_modules/@agents-can-communicate/adapter-gemini-cli/certification.json +68 -0
  58. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  59. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +22 -22
  60. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeAgent-0.57.0.json +8 -0
  61. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-0.57.0.json +12 -0
  62. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-shell-0.57.0.json +12 -0
  63. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionEnd-0.57.0.json +8 -0
  64. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionStart-0.57.0.json +8 -0
  65. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/certification-provenance.json +293 -0
  66. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +8 -1
  67. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +31 -13
  68. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +4 -2
  69. package/node_modules/@agents-can-communicate/adapter-grok/certification.json +3 -0
  70. package/node_modules/@agents-can-communicate/adapter-grok/package.json +2 -1
  71. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +22 -22
  72. package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +11 -9
  73. package/node_modules/@agents-can-communicate/adapter-kimi/certification.json +52 -0
  74. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Bash.json +12 -0
  75. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Write.json +12 -0
  76. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionHeartbeat.json +7 -0
  77. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionStart.json +9 -0
  78. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/UserPromptSubmit.json +8 -0
  79. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/certification-provenance.json +66 -0
  80. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +8 -1
  81. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +22 -22
  82. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +7 -3
  83. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  84. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +52 -18
  85. package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +158 -0
  86. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +36 -17
  87. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +9 -1
  88. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +7 -2
  89. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-activation.mjs +76 -0
  90. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +202 -0
  91. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +101 -0
  92. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +28 -4
  93. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  94. package/node_modules/@agents-can-communicate/cli/src/args.mjs +12 -31
  95. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +70 -5
  96. package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -5
  97. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +111 -12
  98. package/node_modules/@agents-can-communicate/cli/src/main.mjs +100 -121
  99. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  100. package/node_modules/@agents-can-communicate/core/src/attention.mjs +106 -0
  101. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +276 -0
  102. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +131 -0
  103. package/node_modules/@agents-can-communicate/core/src/finish-retries.mjs +97 -0
  104. package/node_modules/@agents-can-communicate/core/src/inbox.mjs +91 -107
  105. package/node_modules/@agents-can-communicate/core/src/index.mjs +3 -3
  106. package/node_modules/@agents-can-communicate/core/src/intents.mjs +0 -1
  107. package/node_modules/@agents-can-communicate/core/src/ports.mjs +2 -1
  108. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +109 -0
  109. package/node_modules/@agents-can-communicate/core/src/service.mjs +21 -10
  110. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +22 -20
  111. package/node_modules/@agents-can-communicate/core/src/status.mjs +11 -9
  112. package/node_modules/@agents-can-communicate/core/src/sync.mjs +3 -294
  113. package/node_modules/@agents-can-communicate/delivery-router/package.json +12 -0
  114. package/node_modules/@agents-can-communicate/delivery-router/src/index.mjs +1 -0
  115. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +131 -0
  116. package/node_modules/@agents-can-communicate/hook-runner/package.json +4 -2
  117. package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +20 -0
  118. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +90 -0
  119. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +190 -105
  120. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  121. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +70 -10
  122. package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +144 -0
  123. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +89 -5
  124. package/node_modules/@agents-can-communicate/installer/src/index.mjs +10 -2
  125. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +161 -0
  126. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +112 -12
  127. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +54 -2
  128. package/node_modules/@agents-can-communicate/installer/src/shell-bootstrap.mjs +210 -0
  129. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  130. package/node_modules/@agents-can-communicate/mcp-server/src/input-validator.mjs +79 -0
  131. package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +23 -28
  132. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +102 -72
  133. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +54 -97
  134. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  135. package/node_modules/@agents-can-communicate/protocol/src/config.mjs +1 -1
  136. package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +64 -0
  137. package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +17 -0
  138. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +4 -1
  139. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +64 -90
  140. package/node_modules/@agents-can-communicate/protocol/src/states.mjs +13 -40
  141. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  142. package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +230 -0
  143. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +77 -28
  144. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +1 -1
  145. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +83 -35
  146. package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +112 -0
  147. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +18 -8
  148. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +68 -26
  149. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +113 -35
  150. package/package.json +20 -1
  151. package/node_modules/@agents-can-communicate/core/src/communication.mjs +0 -334
  152. package/node_modules/@agents-can-communicate/core/src/message-signals.mjs +0 -41
  153. package/node_modules/@agents-can-communicate/core/src/notify.mjs +0 -95
  154. package/node_modules/@agents-can-communicate/core/src/tasks.mjs +0 -244
  155. package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +0 -109
@@ -2,7 +2,7 @@ import { createHash, randomBytes } from "node:crypto";
2
2
  import { realpath } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
- import { clearSessionBinding, loadSessionBinding, storeSessionBinding }
5
+ import { clearSessionBinding, effectiveCapabilities, loadSessionBinding, storeSessionBinding }
6
6
  from "@agents-can-communicate/adapter-sdk";
7
7
  import { createCoordinationService } from "@agents-can-communicate/core";
8
8
  import { createId } from "@agents-can-communicate/protocol";
@@ -11,6 +11,8 @@ import { createGitProbe, discoverWorkspace, platformDataHome, runtimePaths }
11
11
  from "@agents-can-communicate/cli";
12
12
 
13
13
  import { resolveClientPid } from "./client-pid.mjs";
14
+ import { probeClientVersion as defaultProbeClientVersion } from "./client-version.mjs";
15
+ import { establishNativeBinding, livePolicyFrom } from "./native-binding.mjs";
14
16
  import { readProcessTable as defaultReadProcessTable } from "./process-table.mjs";
15
17
 
16
18
  // Kept cohesive above 300 lines because every handler shares one fail-open
@@ -22,6 +24,28 @@ import { readProcessTable as defaultReadProcessTable } from "./process-table.mjs
22
24
  // let a call through than to make someone's session sit waiting on us.
23
25
  const DEFAULT_BUDGET_MS = 5_000;
24
26
 
27
+ const byteLength = value => Buffer.byteLength(value, "utf8");
28
+
29
+ function compactInboxRecovery(messages) {
30
+ const first = messages[0].messageId;
31
+ const rest = messages.length > 1 ? ` (+${messages.length - 1} more in \`acc inbox\`)` : "";
32
+ return `ACC: read pending peer message: \`acc inbox --message ${first}\`${rest}`;
33
+ }
34
+
35
+ function fitDegradation(projection, visibleDegradation, messages, budgetBytes) {
36
+ const full = [projection, visibleDegradation].filter(Boolean).join("\n");
37
+ if (byteLength(full) <= budgetBytes) return full;
38
+ const recovery = compactInboxRecovery(messages);
39
+ const exactRecovery = compactInboxRecovery(messages.slice(0, 1));
40
+ const withRecovery = [projection, recovery].filter(Boolean).join("\n");
41
+ if (byteLength(withRecovery) <= budgetBytes) return withRecovery;
42
+ const withExactRecovery = [projection, exactRecovery].filter(Boolean).join("\n");
43
+ if (byteLength(withExactRecovery) <= budgetBytes) return withExactRecovery;
44
+ if (byteLength(recovery) <= budgetBytes) return recovery;
45
+ if (byteLength(exactRecovery) <= budgetBytes) return exactRecovery;
46
+ return projection;
47
+ }
48
+
25
49
  // Declared by this process on the session it opens, so peers can tell an idle
26
50
  // session from a dead one. Only one of the four clients fires a heartbeat event,
27
51
  // so the rest refresh here: on every turn, and during a long one whenever the
@@ -161,16 +185,130 @@ async function openContext({ cwd, dataHome, runtime, env }) {
161
185
  service: createCoordinationService({ store, clock: runtime.clock, ids: runtime.ids }) };
162
186
  }
163
187
 
188
+ // What this turn is shown and which complete groups it may commit as
189
+ // offered. Runs after the heartbeat and the bounded native retry.
190
+ async function projectTurn({ binding, context, adapter, adapterId }) {
191
+ const sync = await context.service.sync({ sessionId: binding.accSessionId,
192
+ cursor: null, scope: "delta" });
193
+
194
+ // Sync already carries the roster. Calling collectStatus here used to read
195
+ // the entire materialised store a second time on every prompt merely to
196
+ // rediscover this session's participant id.
197
+ const mine = sync.roster
198
+ .find(participant => participant.sessionId === binding.accSessionId);
199
+
200
+ // What peers have said to this participant and no model has been shown yet.
201
+ // Without this the projector's peer block never runs in production: an
202
+ // agent sees only the obligation attention line, not the durable body that
203
+ // may be offered after the stdout transport succeeds.
204
+ const delivery = await context.service.nextTurnDelivery({
205
+ workspaceId: context.descriptor.id,
206
+ participantId: mine?.participantId,
207
+ exceptSessionId: binding.accSessionId });
208
+ const messages = delivery.queuedMessages;
209
+
210
+ // Solo costs nothing: nothing to say means nothing printed, not a banner
211
+ // announcing that nobody else is here. But something already said to you is
212
+ // not nothing - the check used to run before the inbox was read, so the
213
+ // answer to your own request vanished the moment the agent working on it
214
+ // closed and left you as the only session.
215
+ if (sync.solo && messages.length === 0) return { stdout: "" };
216
+
217
+ // The ceiling a team agreed on in `acc.workspace.json`, or the default when
218
+ // there is no config. Validated by the protocol and, until now, never read:
219
+ // the projector was always called with its own default.
220
+ const effective = effectiveCapabilities(adapter, binding);
221
+ const hasStructuredRenderer = typeof adapter.renderContextResult === "function";
222
+ const canOfferNextTurn = effective.delivery.nextTurn === true && hasStructuredRenderer;
223
+ const projectionInput = { ...sync, messages: canOfferNextTurn ? messages : [],
224
+ liveOfferedMessageIds: delivery.liveOfferedMessageIds,
225
+ roomMessageIds: delivery.roomMessageIds,
226
+ currentParticipantId: mine?.participantId };
227
+ const projectionOptions = {
228
+ budgetBytes: context.descriptor.policy?.contextBudgetBytes };
229
+ // Delivery is state, not text parsing. Peer-controlled bodies can imitate
230
+ // another message's visible header, so only projector metadata proves
231
+ // which complete groups survived the byte budget. A custom adapter without
232
+ // metadata may still inject text, but cannot advance a receipt from it.
233
+ const projection = !hasStructuredRenderer
234
+ ? { text: await adapter.renderContext?.(projectionInput, projectionOptions) ?? "",
235
+ offeredMessageIds: [], includedAttentionIds: [] }
236
+ : await adapter.renderContextResult(projectionInput, projectionOptions);
237
+ const clientFactsKnown = typeof binding.clientVersion === "string"
238
+ && typeof binding.platform === "string";
239
+ const reason = !effective.delivery.nextTurn
240
+ ? clientFactsKnown
241
+ ? `client ${binding.clientVersion} on ${binding.platform} is not certified for nextTurn`
242
+ : "the client version or platform is unknown"
243
+ : !hasStructuredRenderer ? "this adapter lacks structured delivery metadata" : null;
244
+ const degradation = reason !== null && messages.length > 0
245
+ ? `acc: ${messages.length} pending message(s) withheld because ${reason}; read `
246
+ + `${messages[0].messageId} with acc inbox --message ${messages[0].messageId}` : null;
247
+ const visibleDegradation = degradation === null ? "" : `ACC: ${degradation.slice(5)}`;
248
+ const budgetBytes = projectionOptions.budgetBytes ?? 6_000;
249
+ const projected = degradation === null ? projection.text
250
+ : fitDegradation(projection.text, visibleDegradation, messages, budgetBytes);
251
+ if (projected === "") {
252
+ return degradation === null ? { stdout: "" } : { stdout: "", stderr: degradation };
253
+ }
254
+
255
+ // The renderer returns ids as metadata, never as text to parse. A peer body
256
+ // can imitate every visible label, so only a complete group selected by the
257
+ // projector is eligible for the post-write offer commit.
258
+ const offered = new Set(canOfferNextTurn ? projection.offeredMessageIds ?? [] : []);
259
+ const offerInputs = messages.filter(message => offered.has(message.messageId))
260
+ .map(message => ({ messageId: message.messageId,
261
+ recipientParticipantId: mine.participantId,
262
+ targetSessionId: binding.accSessionId, targetGeneration: binding.generation,
263
+ transport: "next-turn", adapterId,
264
+ clientVersion: binding.clientVersion }));
265
+ // Same again: Kimi Code shows the model a hook's raw stdout, while Gemini
266
+ // and Claude Code want an envelope and drop a bare string.
267
+ // The entry point owns the transport boundary. This handler only prepares
268
+ // offer inputs; recording them here would claim delivery before stdout's
269
+ // callback proves that the bytes crossed.
270
+ const outcome = { stdout: "", ...adapter.injectOutcome?.(projected) };
271
+ const writableOffers = outcome.stdout === "" ? [] : offerInputs;
272
+ if (degradation === null) return { ...outcome, offerInputs: writableOffers };
273
+ return { ...outcome,
274
+ stderr: [outcome.stderr, degradation].filter(Boolean).join("\n"),
275
+ offerInputs: writableOffers };
276
+ }
277
+
278
+ // One bounded, fail-open native handshake for this exact session generation.
279
+ // The policy comes only from the environment an owned shell bootstrap
280
+ // exported; an ordinary launch has none and stays durable.
281
+ async function bindNative({ adapter, event, hookBinding, clientVersion, platform, context, paths,
282
+ deadline }) {
283
+ return establishNativeBinding({ adapter, event, hookBinding, clientVersion, platform,
284
+ livePolicy: livePolicyFrom(context.env), service: context.service, runtimeDir: paths.root,
285
+ clock: context.service.clock,
286
+ timeoutMs: Math.max(1, Math.min(750, deadline - Date.now())) });
287
+ }
288
+
164
289
  const HANDLERS = {
165
290
  async sessionStart({ event, context, adapter, adapterId, binding, paths,
166
- readProcessTable }) {
167
- const capabilities = adapter.capabilities ?? {};
291
+ readProcessTable, probeClientVersion, platform, deadline }) {
292
+ // A repeated start refreshes the client's version/platform. Remove the old
293
+ // certified facts before any probe, PID lookup, resume, or open can fail;
294
+ // keep only the generation identity needed for a successful resume.
295
+ if (binding !== null) {
296
+ await storeSessionBinding({ runtimeDir: paths.root, harnessSessionId: event.sessionId,
297
+ accSessionId: binding.accSessionId, generation: binding.generation });
298
+ }
299
+ const clientVersion = await probeClientVersion(adapter,
300
+ { timeoutMs: Math.max(1, Math.min(1_000, deadline - Date.now())) });
301
+ const clientFacts = { clientVersion, platform };
302
+ const capabilities = effectiveCapabilities(adapter, clientFacts);
168
303
  // Once per session, never per turn. A client that cannot be found yields
169
304
  // null, and the session is then judged by age alone - which is exactly the
170
305
  // behaviour every session had before this existed.
171
306
  const command = adapter.client?.command ?? null;
172
307
  const pid = command === null ? null
173
308
  : resolveClientPid({ table: await readProcessTable(), from: process.pid, command });
309
+ const clientPid = Number.isInteger(pid) && pid > 0 ? pid : undefined;
310
+ const native = hookBinding => bindNative({ adapter, event, hookBinding, ...clientFacts,
311
+ context, paths, deadline });
174
312
  const metadata = {
175
313
  pid,
176
314
  enforcement: capabilities.guards?.beforeWrite === true ? "guarded" : "advisory",
@@ -186,7 +324,12 @@ const HANDLERS = {
186
324
  ...metadata,
187
325
  });
188
326
  if (resumed !== null) {
189
- return { accSessionId: resumed.sessionId, generation: resumed.generation };
327
+ const hookBinding = { accSessionId: resumed.sessionId, generation: resumed.generation,
328
+ ...clientFacts, clientPid };
329
+ await storeSessionBinding({ runtimeDir: paths.root, harnessSessionId: event.sessionId,
330
+ ...hookBinding });
331
+ return { accSessionId: resumed.sessionId, generation: resumed.generation,
332
+ ...clientFacts, capabilities, nativeBinding: await native(hookBinding) };
190
333
  }
191
334
  }
192
335
  const session = await context.service.openSession({
@@ -203,9 +346,12 @@ const HANDLERS = {
203
346
  ...metadata,
204
347
  descriptor: context.descriptor,
205
348
  });
349
+ const hookBinding = { accSessionId: session.sessionId, generation: session.generation,
350
+ ...clientFacts, clientPid };
206
351
  await storeSessionBinding({ runtimeDir: paths.root, harnessSessionId: event.sessionId,
207
- accSessionId: session.sessionId, generation: session.generation });
208
- return { accSessionId: session.sessionId, generation: session.generation };
352
+ ...hookBinding });
353
+ return { accSessionId: session.sessionId, generation: session.generation,
354
+ ...clientFacts, capabilities, nativeBinding: await native(hookBinding) };
209
355
  },
210
356
 
211
357
  async heartbeat({ binding, context }) {
@@ -223,107 +369,21 @@ const HANDLERS = {
223
369
  return {};
224
370
  },
225
371
 
226
- async beforeTurn({ binding, context, adapter }) {
372
+ async beforeTurn(input) {
373
+ const { binding, context, adapter, event, paths, deadline } = input;
227
374
  if (binding === null) return {};
228
375
  // A turn is the clearest sign a session is alive. Never a reason to fail:
229
376
  // this runs in front of somebody's prompt.
230
377
  await context.service.heartbeatSession({ sessionId: binding.accSessionId,
231
378
  generation: binding.generation }).catch(() => null);
232
- const sync = await context.service.sync({ sessionId: binding.accSessionId,
233
- cursor: null, scope: "delta" });
234
-
235
- // Sync already carries the roster. Calling collectStatus here used to read
236
- // the entire materialised store a second time on every prompt merely to
237
- // rediscover this session's participant id.
238
- const mine = sync.roster
239
- .find(participant => participant.sessionId === binding.accSessionId);
240
-
241
- // What peers have said to this participant and no model has been shown yet.
242
- // Without this the projector's peer block never ran in production: an agent
243
- // saw only the subject of a message through its attention line, and the
244
- // `injected` delivery state was unreachable.
245
- const messages = await context.service.pendingMessages({
246
- workspaceId: context.descriptor.id,
247
- participantId: mine?.participantId,
248
- exceptSessionId: binding.accSessionId });
249
-
250
- // Solo costs nothing: nothing to say means nothing printed, not a banner
251
- // announcing that nobody else is here. But something already said to you is
252
- // not nothing - the check used to run before the inbox was read, so the
253
- // answer to your own request vanished the moment the agent working on it
254
- // closed and left you as the only session.
255
- if (sync.solo && messages.length === 0) return { stdout: "" };
256
-
257
- // The ceiling a team agreed on in `acc.workspace.json`, or the default when
258
- // there is no config. Validated by the protocol and, until now, never read:
259
- // the projector was always called with its own default.
260
- const tracksDelivery = typeof adapter.renderContextResult === "function";
261
- const projectionInput = { ...sync, messages: tracksDelivery ? messages : [],
262
- currentParticipantId: mine?.participantId };
263
- const projectionOptions = {
264
- budgetBytes: context.descriptor.policy?.contextBudgetBytes };
265
- // Delivery is state, not text parsing. Peer-controlled bodies can imitate
266
- // another message's visible header, so only projector metadata proves
267
- // which complete groups survived the byte budget. A custom adapter without
268
- // metadata may still inject text, but cannot advance a receipt from it.
269
- const projection = !tracksDelivery
270
- ? { text: await adapter.renderContext?.(projectionInput, projectionOptions) ?? "",
271
- includedMessageIds: [], includedAttentionIds: [] }
272
- : await adapter.renderContextResult(projectionInput, projectionOptions);
273
- const degradation = !tracksDelivery && messages.length > 0
274
- ? `acc: ${messages.length} pending message(s) withheld because this adapter lacks `
275
- + `structured delivery metadata; read ${messages[0].messageId} with `
276
- + `acc inbox --message ${messages[0].messageId}`
277
- : null;
278
- const visibleDegradation = degradation === null ? "" : `ACC: ${degradation.slice(5)}`;
279
- const candidate = [projection.text, visibleDegradation].filter(Boolean).join("\n");
280
- const projected = Buffer.byteLength(candidate, "utf8")
281
- <= (projectionOptions.budgetBytes ?? 6_000) ? candidate : projection.text;
282
- if (projected === "") {
283
- return degradation === null ? { stdout: "" } : { stdout: "", stderr: degradation };
284
- }
285
-
286
- // Only what the model was actually shown is recorded as delivered. The
287
- // budget can leave a message out, and a receipt reading `injected` for text
288
- // nobody saw is worse than one still reading `queued` - the sender would be
289
- // told it landed. A message left behind stays queued and goes out next turn.
290
- const failures = [];
291
- const includedMessages = new Set(projection.includedMessageIds ?? []);
292
- for (const message of messages) {
293
- if (!includedMessages.has(message.messageId)) continue;
294
- await context.service.markDelivery({ sessionId: binding.accSessionId,
295
- generation: binding.generation, messageId: message.messageId,
296
- recipientParticipantId: mine.participantId, state: "injected" })
297
- .catch(error => failures.push(`${message.messageId}: ${error.message}`));
298
- }
299
- // A note carries no ack obligation, so after its one full showing it leaves
300
- // a single low-priority `unread_note` breadcrumb. Advancing that receipt
301
- // injected -> seen the turn the breadcrumb is shown is what makes it
302
- // one-shot: next turn the note reads `seen`, the breadcrumb stays quiet, and
303
- // a delivered decision is recoverable without becoming a standing nag - the
304
- // noise a reader learns to skip. Only what was actually shown is advanced,
305
- // for the same reason the loop above only records what fit.
306
- const includedAttention = new Set(projection.includedAttentionIds ?? []);
307
- for (const item of sync.attention ?? []) {
308
- if (item.kind !== "unread_note") continue;
309
- if (!includedAttention.has(item.sourceId)) continue;
310
- await context.service.markDelivery({ sessionId: binding.accSessionId,
311
- generation: binding.generation, messageId: item.sourceId,
312
- recipientParticipantId: mine.participantId, state: "seen" })
313
- .catch(error => failures.push(`${item.sourceId}: ${error.message}`));
314
- }
315
- // Same again: Kimi Code shows the model a hook's raw stdout, while Gemini
316
- // and Claude Code want an envelope and drop a bare string.
317
- // Reported rather than swallowed. The context still goes out - losing it
318
- // over bookkeeping would be the worse trade - but a receipt that failed to
319
- // advance has to be visible somewhere, and stdout belongs to the model.
320
- const outcome = { stdout: "", ...adapter.injectOutcome?.(projected) };
321
- if (failures.length === 0 && degradation === null) return outcome;
322
- return { ...outcome,
323
- stderr: [outcome.stderr, degradation,
324
- failures.length === 0 ? null
325
- : `acc: delivery not recorded for ${failures.join(", ")}`]
326
- .filter(Boolean).join("\n") };
379
+ // A native transport that became ready only after SessionStart is picked
380
+ // up here and a live lease is renewed: bounded, fail-open, never on a guard.
381
+ const nativeBinding = livePolicyFrom(context.env) === "off" ? undefined
382
+ : await bindNative({ adapter, event, hookBinding: binding,
383
+ clientVersion: binding.clientVersion, platform: binding.platform, context, paths,
384
+ deadline });
385
+ const turn = await projectTurn(input);
386
+ return nativeBinding === undefined ? turn : { ...turn, nativeBinding };
327
387
  },
328
388
 
329
389
  async beforeTool({ binding, context, event, adapter }) {
@@ -394,8 +454,12 @@ const HANDLERS = {
394
454
  */
395
455
  export async function runHook({ adapterId, payload, adapters, dataHome, env,
396
456
  runtime = defaultRuntime(), budgetMs = DEFAULT_BUDGET_MS,
397
- readProcessTable = defaultReadProcessTable }) {
398
- const result = { stdout: "", exitCode: 0, decision: "allow", sessions: [] };
457
+ readProcessTable = defaultReadProcessTable,
458
+ probeClientVersion = defaultProbeClientVersion,
459
+ platform = `${process.platform}-${process.arch}` }) {
460
+ const deadline = Date.now() + budgetMs;
461
+ const result = { stdout: "", exitCode: 0, decision: "allow", sessions: [], deadlineAt: deadline,
462
+ commitOffers: async () => {} };
399
463
  let timer = null;
400
464
  try {
401
465
  const adapter = adapters?.[adapterId];
@@ -410,7 +474,7 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
410
474
  const work = handler === undefined
411
475
  ? Promise.resolve({})
412
476
  : handler({ event, context, adapter, adapterId, binding, paths: context.paths,
413
- readProcessTable });
477
+ readProcessTable, probeClientVersion, platform, deadline });
414
478
 
415
479
  // The loser of a race is not cancelled, so the timer is cleared explicitly:
416
480
  // an outstanding one keeps the process alive long past its answer.
@@ -419,6 +483,24 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
419
483
  });
420
484
  Object.assign(result, await Promise.race([work, budget]));
421
485
 
486
+ const offerInputs = result.offerInputs ?? [];
487
+ let commitPromise = null;
488
+ result.commitOffers = () => {
489
+ if (commitPromise !== null) return commitPromise;
490
+ commitPromise = (async () => {
491
+ for (const input of offerInputs) {
492
+ const remaining = deadline - Date.now();
493
+ if (remaining <= 0) throw new Error("hook budget exhausted before offer commit");
494
+ // The durable transaction owns deadline cancellation. Racing it here
495
+ // would only reject the public promise while the losing writer kept
496
+ // waiting and could publish later.
497
+ await context.service.recordOfferSucceeded({ ...input, deadlineAt: deadline });
498
+ }
499
+ })();
500
+ return commitPromise;
501
+ };
502
+ delete result.offerInputs;
503
+
422
504
  const status = await context.service.collectStatus({
423
505
  workspaceId: context.descriptor.id });
424
506
  result.sessions = status.participants.filter(p => p.presence !== "offline");
@@ -428,6 +510,9 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
428
510
  result.reason = error.message;
429
511
  result.decision = "allow";
430
512
  result.stdout = "";
513
+ // A later failure may happen after a turn prepared offer inputs. Once the
514
+ // fail-open path withdraws stdout, no transport boundary remains to commit.
515
+ result.commitOffers = async () => {};
431
516
  } finally {
432
517
  if (timer !== null) clearTimeout(timer);
433
518
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.1.18",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": { ".": "./src/index.mjs" },
@@ -1,4 +1,6 @@
1
- import { recordInstall, removeOwned } from "./ownership.mjs";
1
+ import { applyNativeActivation, deactivateNative } from "./native-activation.mjs";
2
+ import { finalizeRemoval, missingArtifactParents, recordInstall,
3
+ removeEmptyOwnedDirectories, removeOwnedArtifacts } from "./ownership.mjs";
2
4
 
3
5
  /**
4
6
  * Carry out a plan, one adapter at a time.
@@ -13,7 +15,7 @@ import { recordInstall, removeOwned } from "./ownership.mjs";
13
15
  * three that work, plus the name of the one that did not.
14
16
  */
15
17
  export async function applyPlan({ plan, adapters, context, dataHome, dryRun = false,
16
- accVersion = null }) {
18
+ accVersion = null, activation = {} }) {
17
19
  const byId = new Map(adapters.map(adapter => [adapter.id, adapter]));
18
20
  const results = { action: plan.action, dryRun, operations: [], skipped: plan.skipped,
19
21
  failed: [] };
@@ -30,27 +32,66 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
30
32
 
31
33
  try {
32
34
  if (plan.action === "install") {
33
- const outcome = await adapter.install(context);
35
+ const createdDirectories = await missingArtifactParents({ home: context.home,
36
+ artifacts: operation.artifacts });
37
+ const installContext = { ...context,
38
+ requestedLivePolicy: operation.livePolicy ?? "off",
39
+ livePolicy: operation.effectiveLivePolicy ?? "off" };
40
+ const outcome = await adapter.install(installContext);
41
+ // A consented activation is applied after the adapter's own wiring, in
42
+ // a fixed order; an explicit off takes a recorded one back first so the
43
+ // record written below describes the machine as it now is.
44
+ const notes = [];
45
+ if (operation.deactivation !== undefined) {
46
+ const report = await deactivateNative({ nativeActivation: operation.deactivation,
47
+ ...activation });
48
+ notes.push(...describeTeardown(report));
49
+ }
50
+ let native = null;
51
+ let appendedRcBlock = false;
52
+ if (operation.nativeActivation !== undefined) {
53
+ const applied = await applyNativeActivation({ adapter,
54
+ activation: operation.nativeActivation, dataHome, ...activation });
55
+ native = applied.nativeActivation;
56
+ appendedRcBlock = applied.appendedRcBlock;
57
+ }
34
58
  // Recorded after the write, so a record never claims an install that
35
59
  // did not happen. The reverse order would leave uninstall trying to
36
60
  // remove files nothing created.
37
61
  await recordInstall({ dataHome, adapterId: adapter.id,
38
62
  version: operation.clientVersion ?? null, accVersion,
39
- artifacts: operation.artifacts });
40
- results.operations.push({ ...operation, applied: true,
41
- changes: outcome.changes ?? [], diagnostics: outcome.diagnostics ?? [] });
63
+ artifacts: operation.artifacts, createdDirectories, nativeActivation: native });
64
+ results.operations.push({ ...operation, applied: true, appendedRcBlock,
65
+ changes: outcome.changes ?? [], diagnostics: [
66
+ ...(operation.deliveryDiagnostic === undefined
67
+ ? [] : [operation.deliveryDiagnostic]),
68
+ ...(outcome.diagnostics ?? []),
69
+ ...notes,
70
+ ] });
42
71
  } else {
43
- // Ownership first: it decides what may be deleted, and the adapter's own
44
- // uninstall then unpicks the entries it added to files the user owns.
45
- const owned = await removeOwned({ dataHome, adapterId: adapter.id });
72
+ // Keep the record until every cleanup step succeeds. It is both the
73
+ // authority for deletion and the only durable recipe a retry has when
74
+ // the client or one of ACC's own artifacts is already gone.
75
+ const notes = [];
76
+ if (operation.deactivation !== undefined) {
77
+ const report = await deactivateNative({ nativeActivation: operation.deactivation,
78
+ ...activation });
79
+ notes.push(...describeTeardown(report));
80
+ }
81
+ const owned = await removeOwnedArtifacts({ dataHome, adapterId: adapter.id });
46
82
  // What ownership held back is passed on, because the adapter would
47
83
  // otherwise remove its own layout unconditionally and undo the decision.
48
84
  // The case that matters: someone put their own work inside a directory
49
85
  // ACC created, and a recognised path is not a reason to delete it.
50
86
  const outcome = await adapter.uninstall({ ...context, keep: owned.kept });
87
+ const directories = await removeEmptyOwnedDirectories({ home: context.home,
88
+ directories: owned.createdDirectories });
89
+ await finalizeRemoval({ dataHome, adapterId: adapter.id });
51
90
  results.operations.push({ ...operation, applied: true,
52
91
  changes: outcome.changes ?? [], removed: owned.removed, kept: owned.kept,
53
- diagnostics: outcome.diagnostics ?? [] });
92
+ removedDirectories: directories.removed, keptDirectories: directories.kept,
93
+ missingDirectories: directories.missing,
94
+ diagnostics: [...(outcome.diagnostics ?? []), ...notes] });
54
95
  }
55
96
  } catch (error) {
56
97
  results.failed.push({ adapterId: operation.adapterId, error: error.message });
@@ -58,3 +99,22 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
58
99
  }
59
100
  return results;
60
101
  }
102
+
103
+ // What a deactivation actually did, said truthfully: a retained service is
104
+ // named as retained, a modified PATH block as kept.
105
+ function describeTeardown(report) {
106
+ const lines = [];
107
+ if (report.shell !== null) {
108
+ for (const file of report.shell.removedShims) lines.push(`removed shim ${file}`);
109
+ for (const file of report.shell.keptShims) lines.push(`kept shim ${file} - changed since ACC wrote it`);
110
+ if (report.shell.rcBlock === "removed") lines.push("removed the ACC PATH block");
111
+ if (report.shell.rcBlock === "modified") lines.push("kept the ACC PATH block - changed since ACC wrote it");
112
+ if (report.shell.rcBlock === "kept") lines.push("kept the ACC PATH block - another ACC shim still uses it");
113
+ }
114
+ for (const service of report.services) {
115
+ lines.push(service.outcome === "stopped" ? `stopped the ${service.serviceId} service`
116
+ : `retained the ${service.serviceId} service (${service.outcome === "retained_pre_existing"
117
+ ? "it existed before ACC" : "no vendor teardown exists"})`);
118
+ }
119
+ return lines;
120
+ }
@@ -0,0 +1,144 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { createReadStream } from "node:fs";
4
+ import { mkdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+
7
+ import { evaluateNativeEligibility } from "@agents-can-communicate/adapter-sdk";
8
+
9
+ // The launch-time check behind an owned shell shim. It answers one closed
10
+ // question - may this exact executable receive native delivery? - from the
11
+ // static minimum, a read-only adapter probe, and a keyed cache under ACC's own
12
+ // data home. It never throws: a shim that cannot decide launches the vendor
13
+ // command untouched, and this module is what makes "cannot decide" cheap.
14
+ //
15
+ // The cache key is the executable's identity - resolved path, symlink target,
16
+ // inode, size, mtime, and full sha256 - so an upgrade or replacement is a miss
17
+ // and an unchanged binary skips the version spawn and the probe. A cached
18
+ // failure is short-lived; a repaired client is never disabled for long.
19
+
20
+ export const BOOTSTRAP_CACHE_SCHEMA = 1;
21
+ export const SUPPORTED_TTL_MS = 6 * 60 * 60 * 1_000;
22
+ export const FAILED_TTL_MS = 5 * 60 * 1_000;
23
+ const DEFAULT_TIMEOUT_MS = 750;
24
+ const VERSION = /(?:^|[^0-9A-Za-z])v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)(?:\b|$)/;
25
+
26
+ export const cachePathFor = (dataHome, adapterId) =>
27
+ path.join(dataHome, "acc", "native-bootstrap", `${adapterId}.json`);
28
+
29
+ function sha256File(file) {
30
+ return new Promise((resolve, reject) => {
31
+ const hash = createHash("sha256");
32
+ createReadStream(file).on("data", chunk => hash.update(chunk))
33
+ .on("error", reject).on("end", () => resolve(`sha256:${hash.digest("hex")}`));
34
+ });
35
+ }
36
+
37
+ async function executableIdentity(realExecutable) {
38
+ const target = await realpath(realExecutable);
39
+ const facts = await stat(target);
40
+ if (!facts.isFile()) throw new Error("not a file");
41
+ return { path: realExecutable, target, inode: facts.ino, size: facts.size,
42
+ mtimeMs: Math.floor(facts.mtimeMs), executableFingerprint: await sha256File(target) };
43
+ }
44
+
45
+ function withTimeout(work, ms, label) {
46
+ let timer = null;
47
+ const deadline = new Promise((_resolve, reject) => {
48
+ timer = setTimeout(() => reject(Object.assign(new Error(`${label} timed out`),
49
+ { code: "ETIMEDOUT" })), ms);
50
+ });
51
+ return Promise.race([work, deadline]).finally(() => clearTimeout(timer));
52
+ }
53
+
54
+ // Read-only: the client's own version command, nothing else.
55
+ export function defaultReadVersion({ realExecutable, versionArgs, timeoutMs }) {
56
+ return new Promise(resolve => {
57
+ execFile(realExecutable, versionArgs, { timeout: timeoutMs, windowsHide: true },
58
+ (error, stdout, stderr) => {
59
+ if (error !== null) return resolve(null);
60
+ resolve(VERSION.exec(`${stdout}${stderr}`)?.[1] ?? null);
61
+ });
62
+ });
63
+ }
64
+
65
+ const sameIdentity = (left, right) => left !== undefined && right !== undefined
66
+ && ["path", "target", "inode", "size", "mtimeMs", "executableFingerprint"]
67
+ .every(key => left[key] === right[key]);
68
+
69
+ async function loadCache(file) {
70
+ try {
71
+ const record = JSON.parse(await readFile(file, "utf8"));
72
+ return record?.schemaVersion === BOOTSTRAP_CACHE_SCHEMA ? record : null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ async function saveCache(file, record) {
79
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
80
+ const temporary = `${file}.${process.pid}.tmp`;
81
+ await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
82
+ await rename(temporary, file);
83
+ }
84
+
85
+ const closedOutcome = (supported, reasonCode) => Object.freeze({ supported, reasonCode });
86
+
87
+ export async function checkNativeBootstrap({ adapter, realExecutable, platform, dataHome,
88
+ timeoutMs = DEFAULT_TIMEOUT_MS, clock = { now: () => new Date().toISOString() },
89
+ readVersion = defaultReadVersion }) {
90
+ try {
91
+ if (adapter?.nativeDelivery === undefined || typeof adapter.probeNativeDelivery !== "function") {
92
+ return closedOutcome(false, "native_delivery_unsupported");
93
+ }
94
+ const identity = await executableIdentity(realExecutable);
95
+ const file = cachePathFor(dataHome, adapter.id);
96
+ const now = Date.parse(clock.now());
97
+ const cached = await loadCache(file);
98
+ if (cached !== null && cached.adapterId === adapter.id && cached.platform === platform
99
+ && sameIdentity(cached.identity, identity) && Date.parse(cached.expiresAt) > now) {
100
+ return closedOutcome(cached.supported, cached.reasonCode);
101
+ }
102
+
103
+ const clientVersion = await withTimeout(readVersion({ realExecutable: identity.target,
104
+ versionArgs: adapter.client?.versionArgs ?? ["--version"], timeoutMs }), timeoutMs,
105
+ "version probe").catch(() => null);
106
+ let probe = null;
107
+ let reasonCode = null;
108
+ if (clientVersion !== null) {
109
+ try {
110
+ probe = await withTimeout(adapter.probeNativeDelivery({ realExecutable: identity.target,
111
+ timeoutMs }), timeoutMs, "native probe");
112
+ } catch (error) {
113
+ probe = null;
114
+ reasonCode = error?.code === "ETIMEDOUT" ? "probe_timeout" : "feature_probe_failed";
115
+ }
116
+ }
117
+ let eligibility;
118
+ try {
119
+ eligibility = evaluateNativeEligibility(adapter, { clientVersion, platform, probe });
120
+ } catch {
121
+ // A malformed probe is an adapter bug, and still not a reason to change
122
+ // the user's launch: it fails closed like any other probe failure.
123
+ eligibility = { eligible: false, reasonCode: "feature_probe_failed" };
124
+ }
125
+ const supported = eligibility.eligible === true;
126
+ // A probe that timed out or threw is reported as such rather than as the
127
+ // generic verdict the missing probe would otherwise produce.
128
+ const outcomeReason = supported ? null : (reasonCode ?? eligibility.reasonCode
129
+ ?? "feature_probe_failed");
130
+ // Only closed facts are stored: identity, versions, the eligibility verdict.
131
+ // Never command output, never a message body.
132
+ await saveCache(file, {
133
+ schemaVersion: BOOTSTRAP_CACHE_SCHEMA, adapterId: adapter.id, platform, identity,
134
+ clientVersion, supported, reasonCode: outcomeReason,
135
+ protocolContract: supported ? eligibility.protocolContract : null,
136
+ modes: supported ? [...eligibility.modes] : [],
137
+ checkedAt: new Date(now).toISOString(),
138
+ expiresAt: new Date(now + (supported ? SUPPORTED_TTL_MS : FAILED_TTL_MS)).toISOString(),
139
+ }).catch(() => null);
140
+ return closedOutcome(supported, outcomeReason);
141
+ } catch {
142
+ return closedOutcome(false, "feature_probe_failed");
143
+ }
144
+ }