agents-can-communicate 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/README.md +95 -52
  2. package/SECURITY.md +1 -1
  3. package/bin/acc-bootstrap.mjs +5 -54
  4. package/bin/acc-claude-channel.mjs +8 -175
  5. package/bin/acc-hook.mjs +4 -133
  6. package/bin/acc-mcp.mjs +4 -59
  7. package/bin/acc-update-worker.mjs +8 -0
  8. package/bin/acc.mjs +5 -39
  9. package/bin/entrypoints/acc-bootstrap.mjs +47 -0
  10. package/bin/entrypoints/acc-claude-channel.mjs +149 -0
  11. package/bin/entrypoints/acc-hook.mjs +58 -0
  12. package/bin/entrypoints/acc-mcp.mjs +70 -0
  13. package/bin/entrypoints/acc.mjs +51 -0
  14. package/bin/entrypoints/claude-channel-stdio.mjs +29 -0
  15. package/bin/entrypoints/hook-output.mjs +77 -0
  16. package/docs/ADAPTER_AUTHORING.md +52 -8
  17. package/docs/ARCHITECTURE.md +64 -11
  18. package/docs/CAPABILITIES.md +41 -37
  19. package/docs/CLI.md +145 -25
  20. package/docs/CONCEPTS.md +13 -6
  21. package/docs/CONFIGURATION.md +37 -22
  22. package/docs/DESIGN_DECISIONS.md +16 -11
  23. package/docs/GETTING_STARTED.md +41 -7
  24. package/docs/GLOSSARY.md +9 -3
  25. package/docs/HOW_IT_WORKS.md +32 -11
  26. package/docs/MCP.md +91 -24
  27. package/docs/PROTOCOL.md +69 -12
  28. package/docs/RELEASING.md +28 -10
  29. package/docs/SECURITY_MODEL.md +47 -13
  30. package/docs/TROUBLESHOOTING.md +102 -20
  31. package/docs/UPGRADING.md +154 -0
  32. package/docs/WHY_ACC.md +5 -4
  33. package/docs/index.md +8 -0
  34. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  35. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +106 -5
  36. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +6 -6
  37. package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +2 -1
  38. package/node_modules/@agents-can-communicate/adapter-codex/certification.json +38 -13
  39. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +220 -0
  40. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-current-binding-product-evidence.json +1549 -0
  41. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-current-binding-product.json +27 -0
  42. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-product-evidence.json +1549 -0
  43. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-product.json +27 -0
  44. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-transport-evidence.json +259 -0
  45. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-transport.json +21 -0
  46. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-current-binding-product-evidence.json +1549 -0
  47. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-current-binding-product.json +27 -0
  48. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-product-evidence.json +1549 -0
  49. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-product.json +27 -0
  50. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-transport-evidence.json +259 -0
  51. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-transport.json +21 -0
  52. package/node_modules/@agents-can-communicate/adapter-codex/package.json +14 -2
  53. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +106 -5
  54. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +26 -44
  55. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +70 -17
  56. package/node_modules/@agents-can-communicate/adapter-codex/src/config-block.mjs +231 -0
  57. package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +1 -1
  58. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +71 -63
  59. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +107 -103
  60. package/node_modules/@agents-can-communicate/adapter-codex/src/native-endpoint.mjs +96 -0
  61. package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +3 -2
  62. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  63. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +106 -5
  64. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  65. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +2 -2
  66. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  67. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +122 -8
  68. package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +5 -3
  69. package/node_modules/@agents-can-communicate/adapter-grok/src/hooks.mjs +9 -0
  70. package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +2 -2
  71. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  72. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +106 -5
  73. package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +1 -1
  74. package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +2 -2
  75. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  76. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +5 -0
  77. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +26 -4
  78. package/node_modules/@agents-can-communicate/adapter-sdk/src/decision-text.mjs +19 -0
  79. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +5 -3
  80. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -0
  81. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +9 -2
  82. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +2 -6
  83. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +29 -9
  84. package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-block.mjs +8 -6
  85. package/node_modules/@agents-can-communicate/cli/package.json +4 -2
  86. package/node_modules/@agents-can-communicate/cli/src/args.mjs +49 -22
  87. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +11 -4
  88. package/node_modules/@agents-can-communicate/cli/src/git-probe.mjs +9 -3
  89. package/node_modules/@agents-can-communicate/cli/src/help.mjs +109 -14
  90. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +24 -5
  91. package/node_modules/@agents-can-communicate/cli/src/main.mjs +35 -12
  92. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +110 -0
  93. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/command.mjs +64 -0
  94. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +71 -0
  95. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +110 -0
  96. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +167 -0
  97. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/install.mjs +78 -0
  98. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +60 -0
  99. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/leases.mjs +60 -0
  100. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/location.mjs +55 -0
  101. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/mutex.mjs +145 -0
  102. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/policy.mjs +4 -0
  103. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +40 -0
  104. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +21 -0
  105. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +123 -0
  106. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +65 -0
  107. package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +1 -1
  108. package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +33 -108
  109. package/node_modules/@agents-can-communicate/cli/src/update-command.mjs +25 -7
  110. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  111. package/node_modules/@agents-can-communicate/core/src/attention.mjs +3 -0
  112. package/node_modules/@agents-can-communicate/core/src/claims.mjs +13 -8
  113. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +9 -25
  114. package/node_modules/@agents-can-communicate/core/src/decision-changes.mjs +36 -0
  115. package/node_modules/@agents-can-communicate/core/src/decision-state.mjs +42 -0
  116. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +11 -4
  117. package/node_modules/@agents-can-communicate/core/src/inbox.mjs +48 -17
  118. package/node_modules/@agents-can-communicate/core/src/intents.mjs +35 -15
  119. package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +7 -5
  120. package/node_modules/@agents-can-communicate/core/src/message-pages.mjs +75 -0
  121. package/node_modules/@agents-can-communicate/core/src/next-turn-delivery.mjs +31 -0
  122. package/node_modules/@agents-can-communicate/core/src/session-opening.mjs +128 -0
  123. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +58 -132
  124. package/node_modules/@agents-can-communicate/core/src/sync.mjs +31 -2
  125. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  126. package/node_modules/@agents-can-communicate/delivery-router/src/refresh-binding.mjs +38 -0
  127. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +86 -10
  128. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  129. package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +1 -0
  130. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +6 -4
  131. package/node_modules/@agents-can-communicate/hook-runner/src/native-retirement.mjs +48 -0
  132. package/node_modules/@agents-can-communicate/hook-runner/src/owner-context.mjs +30 -0
  133. package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +1 -1
  134. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +172 -67
  135. package/node_modules/@agents-can-communicate/hook-runner/src/session-lifecycle.mjs +13 -0
  136. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  137. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +17 -2
  138. package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +11 -5
  139. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +1 -1
  140. package/node_modules/@agents-can-communicate/installer/src/index.mjs +1 -0
  141. package/node_modules/@agents-can-communicate/installer/src/live-policy.mjs +21 -0
  142. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +20 -6
  143. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +10 -4
  144. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +3 -3
  145. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  146. package/node_modules/@agents-can-communicate/mcp-server/src/protocol.mjs +85 -0
  147. package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +3 -2
  148. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +59 -57
  149. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +47 -33
  150. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  151. package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +3 -0
  152. package/node_modules/@agents-can-communicate/protocol/src/decision-changes.mjs +15 -0
  153. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +3 -1
  154. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +14 -5
  155. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  156. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +5 -1
  157. package/node_modules/@agents-can-communicate/storage-filesystem/src/deadline.mjs +7 -0
  158. package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +2 -0
  159. package/node_modules/@agents-can-communicate/storage-filesystem/src/session-read.mjs +25 -0
  160. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +28 -25
  161. package/package.json +2 -1
@@ -0,0 +1,30 @@
1
+ import { assertPortableId } from "@agents-can-communicate/protocol";
2
+
3
+ export const ownerHeader = binding => "ACC CLI (append): --session "
4
+ + assertPortableId(binding.accSessionId, "sessionId") + " --generation "
5
+ + assertPortableId(binding.generation, "generation");
6
+
7
+ export function ownerOnlyOutcome(inject, owner, budgetBytes) {
8
+ if (Buffer.byteLength(owner, "utf8") > budgetBytes) {
9
+ return { stdout: "", stderr: "acc: context budget cannot fit owner arguments; increase contextBudgetBytes" };
10
+ }
11
+ return { stdout: "", ...inject(owner) };
12
+ }
13
+
14
+ // This is ownership context only. No peer projection, receipt, new session or
15
+ // inherited environment belongs here. A client may deliver the line after the
16
+ // tool has closed its owner; owned CLI operations must still validate the pair.
17
+ export async function appendToolOwner(result, { event, binding, context, adapter }) {
18
+ if (event.kind !== "beforeTool" || result.decision !== "allow" || binding === null
19
+ || typeof adapter.injectToolOwnerOutcome !== "function") return result;
20
+ const current = await context.service.locateSession(binding.accSessionId, context.descriptor.id);
21
+ if (current?.record.state !== "open" || current.record.generation !== binding.generation) return result;
22
+
23
+ const owner = ownerHeader(binding);
24
+ const injected = adapter.injectToolOwnerOutcome({ owner, tool: event.tool });
25
+ if (injected === null) return result;
26
+ const fitted = ownerOnlyOutcome(() => injected, owner,
27
+ context.descriptor.policy?.contextBudgetBytes ?? 6_000);
28
+ return { ...result, stdout: fitted.stdout,
29
+ stderr: [result.stderr, fitted.stderr].filter(Boolean).join("\n") };
30
+ }
@@ -20,7 +20,7 @@ export async function readProcessTable({ run: exec = run,
20
20
  timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
21
21
  try {
22
22
  const { stdout } = await exec("ps", ["-o", "pid=,ppid=,comm=", "-A"],
23
- { timeout: timeoutMs });
23
+ { timeout: timeoutMs, killSignal: "SIGKILL" });
24
24
  const table = new Map();
25
25
  for (const line of stdout.split("\n")) {
26
26
  const match = LINE.exec(line);
@@ -1,3 +1,4 @@
1
+ import { retireNativeBinding } from "./native-retirement.mjs";
1
2
  import { createHash, randomBytes } from "node:crypto";
2
3
  import { realpath } from "node:fs/promises";
3
4
  import path from "node:path";
@@ -5,7 +6,8 @@ import path from "node:path";
5
6
  import { clearSessionBinding, effectiveCapabilities, loadSessionBinding, storeSessionBinding }
6
7
  from "@agents-can-communicate/adapter-sdk";
7
8
  import { createCoordinationService } from "@agents-can-communicate/core";
8
- import { createId } from "@agents-can-communicate/protocol";
9
+ import { readInstalledLivePolicy } from "@agents-can-communicate/installer";
10
+ import { AccError, createId } from "@agents-can-communicate/protocol";
9
11
  import { openFilesystemStore } from "@agents-can-communicate/storage-filesystem";
10
12
  import { createGitProbe, discoverWorkspace, platformDataHome, runtimePaths }
11
13
  from "@agents-can-communicate/cli";
@@ -14,6 +16,8 @@ import { resolveClientPid } from "./client-pid.mjs";
14
16
  import { probeClientVersion as defaultProbeClientVersion } from "./client-version.mjs";
15
17
  import { establishNativeBinding, livePolicyFrom } from "./native-binding.mjs";
16
18
  import { readProcessTable as defaultReadProcessTable } from "./process-table.mjs";
19
+ import { withSessionLifecycle } from "./session-lifecycle.mjs";
20
+ import { appendToolOwner, ownerHeader, ownerOnlyOutcome } from "./owner-context.mjs";
17
21
 
18
22
  // Kept cohesive above 300 lines because every handler shares one fail-open
19
23
  // hook boundary, binding lifecycle, and client-specific outcome contract.
@@ -24,6 +28,10 @@ import { readProcessTable as defaultReadProcessTable } from "./process-table.mjs
24
28
  // let a call through than to make someone's session sit waiting on us.
25
29
  const DEFAULT_BUDGET_MS = 5_000;
26
30
 
31
+ function assertHookBudget(deadline, message = "hook deadline expired") {
32
+ if (Date.now() >= deadline) throw new Error(message);
33
+ }
34
+
27
35
  const byteLength = value => Buffer.byteLength(value, "utf8");
28
36
 
29
37
  function compactInboxRecovery(messages) {
@@ -171,17 +179,20 @@ export function participantFor(adapterId, harnessSessionId, env = {}) {
171
179
  return `${adapterId}-${suffix}`;
172
180
  }
173
181
 
174
- async function openContext({ cwd, dataHome, runtime, env }) {
182
+ async function openContext({ cwd, dataHome, runtime, env, deadline }) {
183
+ assertHookBudget(deadline);
175
184
  const descriptor = await discoverWorkspace({ cwd, env: env ?? {},
176
- gitProbe: createGitProbe() });
185
+ gitProbe: createGitProbe({ deadlineAt: deadline }) });
186
+ assertHookBudget(deadline);
187
+ const resolvedDataHome = dataHome ?? platformDataHome({ env: env ?? {} });
177
188
  const paths = runtimePaths({
178
- dataHome: dataHome ?? platformDataHome({ env: env ?? {} }),
189
+ dataHome: resolvedDataHome,
179
190
  workspaceId: descriptor.id,
180
191
  workspaceRoots: descriptor.roots,
181
192
  });
182
193
  const store = await openFilesystemStore({ root: paths.root, clock: runtime.clock,
183
- ids: runtime.ids, workspaceId: descriptor.id });
184
- return { descriptor, paths, env: env ?? {}, realpath: runtime.realpath ?? realpath,
194
+ ids: runtime.ids, workspaceId: descriptor.id, deadlineAt: deadline });
195
+ return { descriptor, paths, dataHome: resolvedDataHome, env: env ?? {}, realpath: runtime.realpath ?? realpath,
185
196
  service: createCoordinationService({ store, clock: runtime.clock, ids: runtime.ids }) };
186
197
  }
187
198
 
@@ -207,12 +218,17 @@ async function projectTurn({ binding, context, adapter, adapterId }) {
207
218
  exceptSessionId: binding.accSessionId });
208
219
  const messages = delivery.queuedMessages;
209
220
 
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: "" };
221
+ // Only this hook's payload selected the binding. Supply its own pair as
222
+ // trusted context, outside peer bodies, rather than exporting inheritable
223
+ // credentials or teaching the CLI to guess from a public roster.
224
+ const owner = ownerHeader(binding);
225
+ const totalBudget = context.descriptor.policy?.contextBudgetBytes ?? 6_000;
226
+ // A peer can join after this prompt has begun. The current turn must already
227
+ // have its own arguments when it needs inbox/reply, without reattaching or
228
+ // waiting for another user prompt. Solo emits identity, not a peer notice.
229
+ if (sync.solo && messages.length === 0) {
230
+ return ownerOnlyOutcome(text => adapter.injectOutcome?.(text), owner, totalBudget);
231
+ }
216
232
 
217
233
  // The ceiling a team agreed on in `acc.workspace.json`, or the default when
218
234
  // there is no config. Validated by the protocol and, until now, never read:
@@ -222,10 +238,18 @@ async function projectTurn({ binding, context, adapter, adapterId }) {
222
238
  const canOfferNextTurn = effective.delivery.nextTurn === true && hasStructuredRenderer;
223
239
  const projectionInput = { ...sync, messages: canOfferNextTurn ? messages : [],
224
240
  liveOfferedMessageIds: delivery.liveOfferedMessageIds,
241
+ reminderMessageIds: delivery.reminderMessageIds,
225
242
  roomMessageIds: delivery.roomMessageIds,
226
243
  currentParticipantId: mine?.participantId };
244
+ // Credentials alone cannot replace the command that reaches a queued body.
245
+ // If both cannot fit, preserve recovery and explain the missing owner pair.
246
+ const minimumBodyBytes = messages.length === 0 ? 1
247
+ : byteLength(compactInboxRecovery(messages.slice(0, 1)));
248
+ const ownerFits = byteLength(owner) + 1 + minimumBodyBytes <= totalBudget;
249
+ const ownerWarning = ownerFits ? null
250
+ : "acc: context budget cannot fit owner arguments with coordination context; increase contextBudgetBytes";
227
251
  const projectionOptions = {
228
- budgetBytes: context.descriptor.policy?.contextBudgetBytes };
252
+ budgetBytes: ownerFits ? totalBudget - byteLength(owner) - 1 : totalBudget };
229
253
  // Delivery is state, not text parsing. Peer-controlled bodies can imitate
230
254
  // another message's visible header, so only projector metadata proves
231
255
  // which complete groups survived the byte budget. A custom adapter without
@@ -246,10 +270,17 @@ async function projectTurn({ binding, context, adapter, adapterId }) {
246
270
  + `${messages[0].messageId} with acc inbox --message ${messages[0].messageId}` : null;
247
271
  const visibleDegradation = degradation === null ? "" : `ACC: ${degradation.slice(5)}`;
248
272
  const budgetBytes = projectionOptions.budgetBytes ?? 6_000;
249
- const projected = degradation === null ? projection.text
273
+ const body = degradation === null ? projection.text
250
274
  : fitDegradation(projection.text, visibleDegradation, messages, budgetBytes);
275
+ // Own claims make sync non-solo but produce no peer context. They cannot
276
+ // remove this turn's identity or consume a nonexistent body separator.
277
+ if (body === "" && messages.length === 0) {
278
+ return ownerOnlyOutcome(text => adapter.injectOutcome?.(text), owner, totalBudget);
279
+ }
280
+ const projected = body === "" ? "" : ownerFits ? `${owner}\n${body}` : body;
251
281
  if (projected === "") {
252
- return degradation === null ? { stdout: "" } : { stdout: "", stderr: degradation };
282
+ return { stdout: "", stderr: messages.length === 0 ? ""
283
+ : [ownerWarning, degradation].filter(Boolean).join("\n") };
253
284
  }
254
285
 
255
286
  // The renderer returns ids as metadata, never as text to parse. A peer body
@@ -269,20 +300,24 @@ async function projectTurn({ binding, context, adapter, adapterId }) {
269
300
  // callback proves that the bytes crossed.
270
301
  const outcome = { stdout: "", ...adapter.injectOutcome?.(projected) };
271
302
  const writableOffers = outcome.stdout === "" ? [] : offerInputs;
272
- if (degradation === null) return { ...outcome, offerInputs: writableOffers };
273
303
  return { ...outcome,
274
- stderr: [outcome.stderr, degradation].filter(Boolean).join("\n"),
304
+ stderr: [outcome.stderr, ownerWarning, degradation].filter(Boolean).join("\n"),
275
305
  offerInputs: writableOffers };
276
306
  }
277
307
 
278
308
  // 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.
309
+ // New adapters read durable consent from their installation record; legacy
310
+ // adapters keep the environment exported by an owned shell bootstrap.
281
311
  async function bindNative({ adapter, event, hookBinding, clientVersion, platform, context, paths,
282
312
  deadline }) {
313
+ assertHookBudget(deadline);
314
+ const livePolicy = adapter?.nativeDelivery?.policySource === "installation-record"
315
+ ? await readInstalledLivePolicy({ dataHome: context.dataHome, adapterId: adapter.id })
316
+ : livePolicyFrom(context.env);
317
+ assertHookBudget(deadline);
283
318
  return establishNativeBinding({ adapter, event, hookBinding, clientVersion, platform,
284
- livePolicy: livePolicyFrom(context.env), service: context.service, runtimeDir: paths.root,
285
- clock: context.service.clock,
319
+ livePolicy, service: context.service, runtimeDir: paths.root,
320
+ clock: context.service.clock, env: context.env,
286
321
  timeoutMs: Math.max(1, Math.min(750, deadline - Date.now())) });
287
322
  }
288
323
 
@@ -293,11 +328,12 @@ const HANDLERS = {
293
328
  // certified facts before any probe, PID lookup, resume, or open can fail;
294
329
  // keep only the generation identity needed for a successful resume.
295
330
  if (binding !== null) {
296
- await storeSessionBinding({ runtimeDir: paths.root, harnessSessionId: event.sessionId,
331
+ await storeSessionBinding({ runtimeDir: paths.root, deadlineAt: deadline, harnessSessionId: event.sessionId,
297
332
  accSessionId: binding.accSessionId, generation: binding.generation });
298
333
  }
299
334
  const clientVersion = await probeClientVersion(adapter,
300
335
  { timeoutMs: Math.max(1, Math.min(1_000, deadline - Date.now())) });
336
+ assertHookBudget(deadline);
301
337
  const clientFacts = { clientVersion, platform };
302
338
  const capabilities = effectiveCapabilities(adapter, clientFacts);
303
339
  // Once per session, never per turn. A client that cannot be found yields
@@ -305,7 +341,9 @@ const HANDLERS = {
305
341
  // behaviour every session had before this existed.
306
342
  const command = adapter.client?.command ?? null;
307
343
  const pid = command === null ? null
308
- : resolveClientPid({ table: await readProcessTable(), from: process.pid, command });
344
+ : resolveClientPid({ table: await readProcessTable({
345
+ timeoutMs: Math.max(1, Math.min(1_000, deadline - Date.now())) }), from: process.pid, command });
346
+ assertHookBudget(deadline);
309
347
  const clientPid = Number.isInteger(pid) && pid > 0 ? pid : undefined;
310
348
  const native = hookBinding => bindNative({ adapter, event, hookBinding, ...clientFacts,
311
349
  context, paths, deadline });
@@ -317,6 +355,16 @@ const HANDLERS = {
317
355
  checkoutRoot: context.descriptor.git?.worktreeRoot ?? context.descriptor.roots[0],
318
356
  branch: context.descriptor.git?.branch ?? null,
319
357
  };
358
+ if (event.kind === "beforeTurn" && binding !== null) {
359
+ // A real prompt may continue after finish closed this native owner's
360
+ // record. Recheck after the probes: a CLI replacement can run outside
361
+ // the native lifecycle lock. Never adopt that replacement's identity.
362
+ const previous = await context.service.locateSession(binding.accSessionId);
363
+ if (previous?.record.state !== "closed"
364
+ || previous.record.generation !== binding.generation) {
365
+ throw new Error("the completed hook owner changed during turn registration");
366
+ }
367
+ }
320
368
  if (binding !== null) {
321
369
  const resumed = await context.service.resumeSession({
322
370
  sessionId: binding.accSessionId,
@@ -326,13 +374,21 @@ const HANDLERS = {
326
374
  if (resumed !== null) {
327
375
  const hookBinding = { accSessionId: resumed.sessionId, generation: resumed.generation,
328
376
  ...clientFacts, clientPid };
329
- await storeSessionBinding({ runtimeDir: paths.root, harnessSessionId: event.sessionId,
377
+ await storeSessionBinding({ runtimeDir: paths.root, deadlineAt: deadline, harnessSessionId: event.sessionId,
330
378
  ...hookBinding });
331
379
  return { accSessionId: resumed.sessionId, generation: resumed.generation,
332
380
  ...clientFacts, capabilities, nativeBinding: await native(hookBinding) };
333
381
  }
334
382
  }
383
+ // Persist ownership before creating its session. If the hook dies after
384
+ // creation, the next start resumes this exact pair. If creation never
385
+ // happened (or it was closed), a retry allocates a NEW pair, never revives it.
386
+ const opening = { sessionId: context.service.ids.next("session"),
387
+ generation: context.service.ids.next("generation") };
388
+ await storeSessionBinding({ runtimeDir: paths.root, deadlineAt: deadline, harnessSessionId: event.sessionId,
389
+ accSessionId: opening.sessionId, generation: opening.generation });
335
390
  const session = await context.service.openSession({
391
+ ...opening,
336
392
  workspaceId: context.descriptor.id,
337
393
  participantId: participantFor(adapterId, event.sessionId, context.env),
338
394
  displayName: participantFor(adapterId, event.sessionId, context.env),
@@ -348,7 +404,7 @@ const HANDLERS = {
348
404
  });
349
405
  const hookBinding = { accSessionId: session.sessionId, generation: session.generation,
350
406
  ...clientFacts, clientPid };
351
- await storeSessionBinding({ runtimeDir: paths.root, harnessSessionId: event.sessionId,
407
+ await storeSessionBinding({ runtimeDir: paths.root, deadlineAt: deadline, harnessSessionId: event.sessionId,
352
408
  ...hookBinding });
353
409
  return { accSessionId: session.sessionId, generation: session.generation,
354
410
  ...clientFacts, capabilities, nativeBinding: await native(hookBinding) };
@@ -361,27 +417,59 @@ const HANDLERS = {
361
417
  return {};
362
418
  },
363
419
 
364
- async sessionEnd({ binding, context, event, paths }) {
420
+ async sessionEnd({ binding, context, event, paths, adapter, deadline }) {
365
421
  if (binding === null) return {};
366
- await context.service.closeSession({ sessionId: binding.accSessionId,
367
- generation: binding.generation });
368
- await clearSessionBinding({ runtimeDir: paths.root, harnessSessionId: event.sessionId });
422
+ await retireNativeBinding({ adapter, service: context.service, sessionId: binding.accSessionId,
423
+ generation: binding.generation, runtimeDir: paths.root,
424
+ timeoutMs: Math.max(1, Math.min(200, deadline - Date.now())) });
425
+ const owner = { sessionId: binding.accSessionId, generation: binding.generation };
426
+ const current = await context.service.locateSession(binding.accSessionId);
427
+ if (current?.record.generation === binding.generation && current.record.state === "open") {
428
+ await context.service.closeSession(owner);
429
+ } else {
430
+ // Close may have committed before this hook died. Retire only the old
431
+ // endpoint and binding; do not close a replacement or repeat a close event.
432
+ await context.service.clearDeliveryBinding(owner);
433
+ }
434
+ await clearSessionBinding({ runtimeDir: paths.root, deadlineAt: deadline, harnessSessionId: event.sessionId });
369
435
  return {};
370
436
  },
371
437
 
372
438
  async beforeTurn(input) {
373
439
  const { binding, context, adapter, event, paths, deadline } = input;
374
- if (binding === null) return {};
440
+ if (binding === null) {
441
+ // SessionStart can be skipped when an automatic runtime refresh races
442
+ // process admission. A genuine prompt is the next safe proof that this
443
+ // native conversation exists, and it is already inside the same
444
+ // lifecycle mutex as startup. Reuse that opening path so publication,
445
+ // crash recovery, client facts, and the native handshake stay atomic.
446
+ const started = await HANDLERS.sessionStart(input);
447
+ const fresh = await loadSessionBinding({ runtimeDir: paths.root,
448
+ harnessSessionId: event.sessionId });
449
+ const turn = await projectTurn({ ...input, binding: fresh });
450
+ return { ...turn, nativeBinding: started.nativeBinding };
451
+ }
452
+ const current = await context.service.locateSession(binding.accSessionId);
453
+ if (current?.record.state === "closed" && current.record.generation === binding.generation) {
454
+ // finish ends an ACC incarnation, not the native conversation. Only a
455
+ // genuine new user turn can start another one; tool hooks cannot. Reuse
456
+ // the crash-safe opening path, retaining its full published client facts
457
+ // and its single native handshake rather than binding a second time.
458
+ const started = await HANDLERS.sessionStart(input);
459
+ const fresh = await loadSessionBinding({ runtimeDir: paths.root,
460
+ harnessSessionId: event.sessionId });
461
+ const turn = await projectTurn({ ...input, binding: fresh });
462
+ return { ...turn, nativeBinding: started.nativeBinding };
463
+ }
375
464
  // A turn is the clearest sign a session is alive. Never a reason to fail:
376
465
  // this runs in front of somebody's prompt.
377
466
  await context.service.heartbeatSession({ sessionId: binding.accSessionId,
378
- generation: binding.generation }).catch(() => null);
467
+ generation: binding.generation });
379
468
  // A native transport that became ready only after SessionStart is picked
380
469
  // 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 });
470
+ const nativeBinding = await bindNative({ adapter, event, hookBinding: binding,
471
+ clientVersion: binding.clientVersion, platform: binding.platform, context, paths,
472
+ deadline });
385
473
  const turn = await projectTurn(input);
386
474
  return nativeBinding === undefined ? turn : { ...turn, nativeBinding };
387
475
  },
@@ -458,42 +546,49 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
458
546
  probeClientVersion = defaultProbeClientVersion,
459
547
  platform = `${process.platform}-${process.arch}` }) {
460
548
  const deadline = Date.now() + budgetMs;
461
- const result = { stdout: "", exitCode: 0, decision: "allow", sessions: [], deadlineAt: deadline,
549
+ const fallback = { stdout: "", exitCode: 0, decision: "allow", sessions: [], deadlineAt: deadline,
462
550
  commitOffers: async () => {} };
463
- let timer = null;
464
- try {
551
+ const execute = async () => {
552
+ assertHookBudget(deadline);
465
553
  const adapter = adapters?.[adapterId];
466
554
  if (adapter === undefined) throw new Error(`no adapter named ${adapterId}`);
467
555
 
468
556
  const event = await adapter.normalizeHook(payload);
469
- const context = await openContext({ cwd: event.cwd, dataHome, runtime, env });
470
- const binding = await loadSessionBinding({ runtimeDir: context.paths.root,
471
- harnessSessionId: event.sessionId }).catch(() => null);
472
-
557
+ const context = await openContext({ cwd: event.cwd, dataHome, runtime, env, deadline });
473
558
  const handler = HANDLERS[event.kind];
474
- const work = handler === undefined
475
- ? Promise.resolve({})
476
- : handler({ event, context, adapter, adapterId, binding, paths: context.paths,
559
+ const lifecycle = ["sessionStart", "sessionEnd", "beforeTurn"].includes(event.kind);
560
+ const invoke = async () => {
561
+ assertHookBudget(deadline);
562
+ if (lifecycle) {
563
+ // A previous holder may have died after journalling its session while
564
+ // this process waited. Recover that write before interpreting its binding.
565
+ const store = await openFilesystemStore({ root: context.paths.root, clock: runtime.clock,
566
+ ids: runtime.ids, workspaceId: context.descriptor.id, deadlineAt: deadline });
567
+ context.service = createCoordinationService({ store, clock: runtime.clock, ids: runtime.ids });
568
+ }
569
+ const binding = await loadSessionBinding({ runtimeDir: context.paths.root,
570
+ harnessSessionId: event.sessionId });
571
+ assertHookBudget(deadline);
572
+ const result = handler === undefined ? {} : await handler({ event, context, adapter, adapterId,
573
+ binding, paths: context.paths,
477
574
  readProcessTable, probeClientVersion, platform, deadline });
478
-
479
- // The loser of a race is not cancelled, so the timer is cleared explicitly:
480
- // an outstanding one keeps the process alive long past its answer.
481
- const budget = new Promise(resolve => {
482
- timer = setTimeout(() => resolve({ timedOut: true }), budgetMs);
483
- });
484
- Object.assign(result, await Promise.race([work, budget]));
485
-
575
+ return appendToolOwner(result, { event, binding, context, adapter });
576
+ };
577
+ const work = lifecycle
578
+ ? withSessionLifecycle({ root: context.paths.root, sessionId: event.sessionId,
579
+ clock: runtime.clock, deadlineAt: deadline }, invoke)
580
+ : invoke();
581
+ // Keep the late continuation's result private. Once the budget wins, it
582
+ // cannot change the fail-open answer or restore withdrawn output/offers.
583
+ const result = { ...fallback, ...await work };
584
+ assertHookBudget(deadline);
486
585
  const offerInputs = result.offerInputs ?? [];
487
586
  let commitPromise = null;
488
587
  result.commitOffers = () => {
489
588
  if (commitPromise !== null) return commitPromise;
490
589
  commitPromise = (async () => {
491
590
  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.
591
+ assertHookBudget(deadline, "hook budget exhausted before offer commit");
497
592
  await context.service.recordOfferSucceeded({ ...input, deadlineAt: deadline });
498
593
  }
499
594
  })();
@@ -503,18 +598,28 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
503
598
 
504
599
  const status = await context.service.collectStatus({
505
600
  workspaceId: context.descriptor.id });
601
+ assertHookBudget(deadline);
506
602
  result.sessions = status.participants.filter(p => p.presence !== "offline");
507
603
  result.service = context.service;
604
+ return result;
605
+ };
606
+
607
+ let timer;
608
+ try {
609
+ // One budget covers normalization, discovery, recovery, handling and the
610
+ // final status read. Store/binding deadlines fence undecided writes; an
611
+ // activated journal still completes under its writer lock after expiry.
612
+ const budget = new Promise(resolve => {
613
+ timer = setTimeout(() => resolve({ ...fallback, timedOut: true }),
614
+ Math.max(0, deadline - Date.now()));
615
+ });
616
+ return await Promise.race([execute(), budget]);
508
617
  } catch (error) {
509
- result.failed = true;
510
- result.reason = error.message;
511
- result.decision = "allow";
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 () => {};
618
+ return { ...fallback, failed: true, reason: error.message,
619
+ ...(error instanceof AccError && error.details?.reasonCode === "workspace_contains_runtime"
620
+ ? { failureCode: "workspace_contains_runtime" } : {}),
621
+ ...(Date.now() >= deadline ? { timedOut: true } : {}) };
516
622
  } finally {
517
- if (timer !== null) clearTimeout(timer);
623
+ clearTimeout(timer);
518
624
  }
519
- return result;
520
625
  }
@@ -0,0 +1,13 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+
4
+ import { withWriterMutex } from "@agents-can-communicate/storage-filesystem";
5
+
6
+ // Binding ownership spans load, resume/open/close, and publication. The store's
7
+ // writer lock protects each record, but cannot protect this whole hook sequence.
8
+ // Use the same crash-safe mutex in a separate directory to avoid nested locks.
9
+ export function withSessionLifecycle({ root, sessionId, clock, deadlineAt }, operation) {
10
+ const key = createHash("sha256").update(String(sessionId)).digest("hex");
11
+ return withWriterMutex({ locks: path.join(root, "lifecycle-locks", key) },
12
+ { root, clock, deadlineAt }, operation);
13
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -42,10 +42,12 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
42
42
  // a fixed order; an explicit off takes a recorded one back first so the
43
43
  // record written below describes the machine as it now is.
44
44
  const notes = [];
45
+ let retainedMechanisms = [];
45
46
  if (operation.deactivation !== undefined) {
46
47
  const report = await deactivateNative({ nativeActivation: operation.deactivation,
47
48
  ...activation });
48
49
  notes.push(...describeTeardown(report));
50
+ retainedMechanisms = report.retainedMechanisms;
49
51
  }
50
52
  let native = null;
51
53
  let appendedRcBlock = false;
@@ -55,13 +57,19 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
55
57
  native = applied.nativeActivation;
56
58
  appendedRcBlock = applied.appendedRcBlock;
57
59
  }
60
+ if (retainedMechanisms.length > 0) {
61
+ native = { ...(native ?? operation.deactivation),
62
+ mechanisms: [...(native?.mechanisms ?? []), ...retainedMechanisms] };
63
+ }
58
64
  // Recorded after the write, so a record never claims an install that
59
65
  // did not happen. The reverse order would leave uninstall trying to
60
66
  // remove files nothing created.
61
67
  await recordInstall({ dataHome, adapterId: adapter.id,
62
68
  version: operation.clientVersion ?? null, accVersion,
63
- artifacts: operation.artifacts, createdDirectories, nativeActivation: native });
69
+ artifacts: operation.artifacts, createdDirectories,
70
+ deliveryPolicy: operation.livePolicy, nativeActivation: native });
64
71
  results.operations.push({ ...operation, applied: true, appendedRcBlock,
72
+ needsAction: outcome.needsAction ?? [],
65
73
  changes: outcome.changes ?? [], diagnostics: [
66
74
  ...(operation.deliveryDiagnostic === undefined
67
75
  ? [] : [operation.deliveryDiagnostic]),
@@ -69,14 +77,19 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
69
77
  ...notes,
70
78
  ] });
71
79
  } else {
80
+ // Config ownership can veto removal. Ask before teardown or recorded
81
+ // artifact deletion; an adapter-local check would run after those writes.
82
+ await adapter.preflightUninstall?.(context);
72
83
  // Keep the record until every cleanup step succeeds. It is both the
73
84
  // authority for deletion and the only durable recipe a retry has when
74
85
  // the client or one of ACC's own artifacts is already gone.
75
86
  const notes = [];
87
+ let retainedMechanisms = [];
76
88
  if (operation.deactivation !== undefined) {
77
89
  const report = await deactivateNative({ nativeActivation: operation.deactivation,
78
90
  ...activation });
79
91
  notes.push(...describeTeardown(report));
92
+ retainedMechanisms = report.retainedMechanisms;
80
93
  }
81
94
  const owned = await removeOwnedArtifacts({ dataHome, adapterId: adapter.id });
82
95
  // What ownership held back is passed on, because the adapter would
@@ -86,7 +99,9 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
86
99
  const outcome = await adapter.uninstall({ ...context, keep: owned.kept });
87
100
  const directories = await removeEmptyOwnedDirectories({ home: context.home,
88
101
  directories: owned.createdDirectories });
89
- await finalizeRemoval({ dataHome, adapterId: adapter.id });
102
+ await finalizeRemoval({ dataHome, adapterId: adapter.id,
103
+ retainedNativeActivation: retainedMechanisms.length === 0 ? null
104
+ : { ...operation.deactivation, mechanisms: retainedMechanisms } });
90
105
  results.operations.push({ ...operation, applied: true,
91
106
  changes: outcome.changes ?? [], removed: owned.removed, kept: owned.kept,
92
107
  removedDirectories: directories.removed, keptDirectories: directories.kept,
@@ -4,7 +4,7 @@ import { createReadStream } from "node:fs";
4
4
  import { mkdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
 
7
- import { evaluateNativeEligibility } from "@agents-can-communicate/adapter-sdk";
7
+ import { evaluateNativeEligibility, ownVersion } from "@agents-can-communicate/adapter-sdk";
8
8
 
9
9
  // The launch-time check behind an owned shell shim. It answers one closed
10
10
  // question - may this exact executable receive native delivery? - from the
@@ -13,9 +13,10 @@ import { evaluateNativeEligibility } from "@agents-can-communicate/adapter-sdk";
13
13
  // command untouched, and this module is what makes "cannot decide" cheap.
14
14
  //
15
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.
16
+ // inode, size, mtime, and full sha256 - plus platform and this ACC runtime's
17
+ // version. A vendor upgrade, replacement, or changed certification contract is
18
+ // therefore a miss. A cached failure is short-lived; a repaired client is
19
+ // never disabled for long.
19
20
 
20
21
  export const BOOTSTRAP_CACHE_SCHEMA = 1;
21
22
  export const SUPPORTED_TTL_MS = 6 * 60 * 60 * 1_000;
@@ -86,7 +87,7 @@ const closedOutcome = (supported, reasonCode) => Object.freeze({ supported, reas
86
87
 
87
88
  export async function checkNativeBootstrap({ adapter, realExecutable, platform, dataHome,
88
89
  timeoutMs = DEFAULT_TIMEOUT_MS, clock = { now: () => new Date().toISOString() },
89
- readVersion = defaultReadVersion }) {
90
+ readVersion = defaultReadVersion, accVersion }) {
90
91
  try {
91
92
  if (adapter?.nativeDelivery === undefined || typeof adapter.probeNativeDelivery !== "function") {
92
93
  return closedOutcome(false, "native_delivery_unsupported");
@@ -94,8 +95,12 @@ export async function checkNativeBootstrap({ adapter, realExecutable, platform,
94
95
  const identity = await executableIdentity(realExecutable);
95
96
  const file = cachePathFor(dataHome, adapter.id);
96
97
  const now = Date.parse(clock.now());
98
+ const runtimeVersion = accVersion === undefined
99
+ ? await ownVersion(import.meta.url) : accVersion;
100
+ const runtimeVersionKnown = typeof runtimeVersion === "string" && runtimeVersion !== "";
97
101
  const cached = await loadCache(file);
98
102
  if (cached !== null && cached.adapterId === adapter.id && cached.platform === platform
103
+ && runtimeVersionKnown && cached.accVersion === runtimeVersion
99
104
  && sameIdentity(cached.identity, identity) && Date.parse(cached.expiresAt) > now) {
100
105
  return closedOutcome(cached.supported, cached.reasonCode);
101
106
  }
@@ -131,6 +136,7 @@ export async function checkNativeBootstrap({ adapter, realExecutable, platform,
131
136
  // Never command output, never a message body.
132
137
  await saveCache(file, {
133
138
  schemaVersion: BOOTSTRAP_CACHE_SCHEMA, adapterId: adapter.id, platform, identity,
139
+ accVersion: runtimeVersionKnown ? runtimeVersion : null,
134
140
  clientVersion, supported, reasonCode: outcomeReason,
135
141
  protocolContract: supported ? eligibility.protocolContract : null,
136
142
  modes: supported ? [...eligibility.modes] : [],
@@ -66,7 +66,7 @@ async function detectNative(adapter, entry, { context, platform, probeTimeoutMs,
66
66
  const facts = { realExecutable, probe: null, eligibility: null };
67
67
  try {
68
68
  facts.probe = await withTimeout(Promise.resolve(adapter.probeNativeDelivery({
69
- realExecutable, timeoutMs: probeTimeoutMs })), probeTimeoutMs,
69
+ realExecutable, timeoutMs: probeTimeoutMs, env: context?.env })), probeTimeoutMs,
70
70
  `${adapter.id} native probe`);
71
71
  } catch {
72
72
  facts.probe = null;
@@ -12,3 +12,4 @@ export { BLOCK_BEGIN, BLOCK_END, SHIM_MARKER, SHIM_POLICIES, SUPPORTED_SHELLS,
12
12
  shellLiteral, uninstallShellBootstrap, validateShimEntry } from "./shell-bootstrap.mjs";
13
13
  export { LIVE_POLICIES, describeActivation, describeDeactivation, livePolicyOf, rcFileFor,
14
14
  resolveExecutable, shellOf, shimDirFor } from "./native-activation.mjs";
15
+ export { readInstalledLivePolicy } from "./live-policy.mjs";
@@ -0,0 +1,21 @@
1
+ import { loadOwnership } from "./ownership.mjs";
2
+
3
+ export const LIVE_POLICIES = Object.freeze(["off", "actionable", "all"]);
4
+
5
+ export const livePolicyOf = install => {
6
+ if (Object.hasOwn(install ?? {}, "deliveryPolicy")) {
7
+ return LIVE_POLICIES.includes(install.deliveryPolicy) ? install.deliveryPolicy : "off";
8
+ }
9
+ return LIVE_POLICIES.includes(install?.nativeActivation?.livePolicy)
10
+ ? install.nativeActivation.livePolicy : "off";
11
+ };
12
+
13
+ export async function readInstalledLivePolicy({ dataHome, adapterId }) {
14
+ try {
15
+ const record = await loadOwnership({ dataHome });
16
+ const install = record.installs.find(entry => entry.adapterId === adapterId);
17
+ return livePolicyOf(install);
18
+ } catch {
19
+ return "off";
20
+ }
21
+ }