agents-can-communicate 0.3.1 → 0.4.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 (157) hide show
  1. package/README.md +28 -7
  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 +73 -0
  16. package/docs/ADAPTER_AUTHORING.md +41 -7
  17. package/docs/ARCHITECTURE.md +62 -11
  18. package/docs/CAPABILITIES.md +40 -36
  19. package/docs/CLI.md +143 -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 +37 -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 +45 -13
  30. package/docs/TROUBLESHOOTING.md +76 -18
  31. package/docs/UPGRADING.md +119 -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 +106 -5
  68. package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +2 -2
  69. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  70. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +106 -5
  71. package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +1 -1
  72. package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +2 -2
  73. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  74. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +5 -0
  75. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +26 -4
  76. package/node_modules/@agents-can-communicate/adapter-sdk/src/decision-text.mjs +19 -0
  77. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +5 -3
  78. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -0
  79. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +9 -2
  80. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +2 -6
  81. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +29 -9
  82. package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-block.mjs +8 -6
  83. package/node_modules/@agents-can-communicate/cli/package.json +4 -2
  84. package/node_modules/@agents-can-communicate/cli/src/args.mjs +49 -22
  85. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +11 -4
  86. package/node_modules/@agents-can-communicate/cli/src/git-probe.mjs +9 -3
  87. package/node_modules/@agents-can-communicate/cli/src/help.mjs +109 -14
  88. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +22 -4
  89. package/node_modules/@agents-can-communicate/cli/src/main.mjs +35 -12
  90. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +110 -0
  91. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/command.mjs +63 -0
  92. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +71 -0
  93. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +110 -0
  94. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +167 -0
  95. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/install.mjs +73 -0
  96. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +60 -0
  97. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/leases.mjs +60 -0
  98. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/location.mjs +55 -0
  99. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/mutex.mjs +145 -0
  100. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/policy.mjs +4 -0
  101. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +40 -0
  102. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +21 -0
  103. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +122 -0
  104. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +65 -0
  105. package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +33 -108
  106. package/node_modules/@agents-can-communicate/cli/src/update-command.mjs +25 -7
  107. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  108. package/node_modules/@agents-can-communicate/core/src/attention.mjs +3 -0
  109. package/node_modules/@agents-can-communicate/core/src/claims.mjs +13 -8
  110. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +9 -25
  111. package/node_modules/@agents-can-communicate/core/src/decision-changes.mjs +36 -0
  112. package/node_modules/@agents-can-communicate/core/src/decision-state.mjs +42 -0
  113. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +11 -4
  114. package/node_modules/@agents-can-communicate/core/src/inbox.mjs +48 -17
  115. package/node_modules/@agents-can-communicate/core/src/intents.mjs +35 -15
  116. package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +7 -5
  117. package/node_modules/@agents-can-communicate/core/src/message-pages.mjs +75 -0
  118. package/node_modules/@agents-can-communicate/core/src/next-turn-delivery.mjs +31 -0
  119. package/node_modules/@agents-can-communicate/core/src/session-opening.mjs +128 -0
  120. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +58 -132
  121. package/node_modules/@agents-can-communicate/core/src/sync.mjs +31 -2
  122. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  123. package/node_modules/@agents-can-communicate/delivery-router/src/refresh-binding.mjs +38 -0
  124. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +86 -10
  125. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  126. package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +1 -0
  127. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +6 -4
  128. package/node_modules/@agents-can-communicate/hook-runner/src/native-retirement.mjs +48 -0
  129. package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +1 -1
  130. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +175 -67
  131. package/node_modules/@agents-can-communicate/hook-runner/src/session-lifecycle.mjs +13 -0
  132. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  133. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +17 -2
  134. package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +11 -5
  135. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +1 -1
  136. package/node_modules/@agents-can-communicate/installer/src/index.mjs +1 -0
  137. package/node_modules/@agents-can-communicate/installer/src/live-policy.mjs +21 -0
  138. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +20 -6
  139. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +10 -4
  140. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +3 -3
  141. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  142. package/node_modules/@agents-can-communicate/mcp-server/src/protocol.mjs +85 -0
  143. package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +3 -2
  144. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +59 -57
  145. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +47 -33
  146. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  147. package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +3 -0
  148. package/node_modules/@agents-can-communicate/protocol/src/decision-changes.mjs +15 -0
  149. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +3 -1
  150. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +14 -5
  151. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  152. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +5 -1
  153. package/node_modules/@agents-can-communicate/storage-filesystem/src/deadline.mjs +7 -0
  154. package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +2 -0
  155. package/node_modules/@agents-can-communicate/storage-filesystem/src/session-read.mjs +25 -0
  156. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +28 -25
  157. package/package.json +2 -1
@@ -2,10 +2,12 @@ import { AccError, EXIT } from "@agents-can-communicate/protocol";
2
2
 
3
3
  import { computeAttention } from "./attention.mjs";
4
4
  import { classifySessionPresence } from "./sessions.mjs";
5
+ import { assertMessageId, messagePage } from "./message-pages.mjs";
6
+ import { decisionView, isCurrentDecision } from "./decision-state.mjs";
5
7
 
6
8
  const DEFAULT_LIMIT = 100;
7
9
  const CURSOR = /^[0-9]{16}$/;
8
- const SCOPES = Object.freeze(["delta", "full"]);
10
+ const SCOPES = Object.freeze(["delta", "full", "history"]);
9
11
 
10
12
  function assertScope(scope) {
11
13
  if (scope != null && !SCOPES.includes(scope)) {
@@ -25,9 +27,36 @@ function assertCursor(cursor) {
25
27
  export function createSyncService(ports, sessions) {
26
28
  const { store, clock, pidIsAlive } = ports;
27
29
 
30
+ async function history(input) {
31
+ const exact = input.messageId !== undefined;
32
+ if (input.current !== undefined && (typeof input.current !== "boolean"
33
+ || input.kind !== "decision" || exact)) {
34
+ throw new AccError(EXIT.USAGE, "current requires a decision history list, without message id");
35
+ }
36
+ if (exact && (input.cursor != null || input.limit !== undefined || input.kind !== undefined)) {
37
+ throw new AccError(EXIT.USAGE, "an exact history read cannot use cursor, limit, or type");
38
+ }
39
+ if (exact) assertMessageId(input.messageId);
40
+ const snapshot = await store.snapshot(input.workspaceId ?? store.workspaceId,
41
+ { kinds: ["message"] });
42
+ const messages = snapshot.messages.map(decisionView(snapshot.messages));
43
+ if (!exact) return messagePage(messages, input,
44
+ { include: message => input.current !== true || isCurrentDecision(message),
45
+ metadata: { scope: "history", view: "summary" } });
46
+ const message = messages.find(item => item.messageId === input.messageId);
47
+ if (message === undefined) {
48
+ throw new AccError(EXIT.DATA, "message is not in this workspace's history");
49
+ }
50
+ return { scope: "history", view: "message", items: [message], nextCursor: null };
51
+ }
52
+
28
53
  async function sync(input = {}) {
29
- if (input.cursor != null) assertCursor(input.cursor);
30
54
  assertScope(input.scope);
55
+ if (input.scope === "history") return history(input);
56
+ if (input.messageId !== undefined || input.kind !== undefined || input.current !== undefined) {
57
+ throw new AccError(EXIT.USAGE, "message, type and current require history scope");
58
+ }
59
+ if (input.cursor != null) assertCursor(input.cursor);
31
60
  const workspaceId = input.workspaceId ?? store.workspaceId;
32
61
  const now = clock.now();
33
62
  const located = input.sessionId === undefined
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/delivery-router",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,38 @@
1
+ import { validateNativeHandshake } from "@agents-can-communicate/adapter-sdk";
2
+
3
+ const MAX_REFRESH_LEASE_MS = 120_000;
4
+
5
+ function sameBinding(current, expected, leaseUntil) {
6
+ return current !== null && current !== undefined
7
+ && (current.retiredAt === null || current.retiredAt === undefined)
8
+ && current.sessionId === expected.sessionId
9
+ && current.generation === expected.generation
10
+ && current.adapterId === expected.adapterId
11
+ && current.clientVersion === expected.clientVersion
12
+ && current.opaqueEndpointRef === expected.opaqueEndpointRef
13
+ && current.leaseUntil === leaseUntil;
14
+ }
15
+
16
+ export async function refreshExpiredBinding({ service, adapter, binding, runtimeDir,
17
+ platform, clock, timeoutMs = 750 }) {
18
+ if (typeof adapter?.refreshNativeSession !== "function") return false;
19
+ const now = Date.parse(clock.now());
20
+ if (!Number.isFinite(now)) return false;
21
+ try {
22
+ const handshake = await adapter.refreshNativeSession({ binding, runtimeDir, timeoutMs });
23
+ const validated = validateNativeHandshake(adapter, {
24
+ clientVersion: binding.clientVersion, platform, handshake,
25
+ });
26
+ if (!validated.ok || validated.opaqueEndpointRef !== binding.opaqueEndpointRef) return false;
27
+ const requestedLease = Date.parse(validated.leaseUntil);
28
+ if (!Number.isFinite(requestedLease) || requestedLease <= now) return false;
29
+ const leaseUntil = new Date(Math.min(requestedLease,
30
+ now + MAX_REFRESH_LEASE_MS)).toISOString();
31
+ await service.refreshDeliveryBinding({ sessionId: binding.sessionId,
32
+ generation: binding.generation, leaseUntil });
33
+ const current = await service.store?.ephemeral?.get("deliveryBinding", binding.sessionId);
34
+ return sameBinding(current, binding, leaseUntil);
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
@@ -1,3 +1,5 @@
1
+ import { refreshExpiredBinding } from "./refresh-binding.mjs";
2
+
1
3
  const SAFE_ERRORS = new Set(["ambiguous_recipient_sessions", "delivery_disabled",
2
4
  "recipient_busy", "recipient_unavailable", "transport_error", "transport_rejected",
3
5
  "unsupported_client_version"]);
@@ -13,6 +15,7 @@ const adaptersById = adapters => adapters instanceof Map
13
15
  // transfers it. A note informs and waits for the next turn. Room messages
14
16
  // have no recipient and are never offered live.
15
17
  const ACTIONABLE = new Set(["question", "request", "answer", "decision", "handoff"]);
18
+ const LIVE_POLICIES = new Set(["off", "actionable", "all"]);
16
19
  const permits = (policy, kind) => policy === "all"
17
20
  || (policy === "actionable" && ACTIONABLE.has(kind));
18
21
 
@@ -39,9 +42,22 @@ function safeTransport(value, opaqueEndpointRef) {
39
42
  return opaqueEndpointRef === "live-adapter" ? "native-live" : "live-adapter";
40
43
  }
41
44
 
42
- export function createDeliveryRouter({ service, adapters, clock }) {
45
+ export function createDeliveryRouter({ service, adapters, clock, platform, readLivePolicy }) {
43
46
  const registry = adaptersById(adapters);
44
47
 
48
+ async function policyFor(adapter, binding) {
49
+ if (adapter?.nativeDelivery?.policySource !== "installation-record") {
50
+ return LIVE_POLICIES.has(binding.livePolicy) ? binding.livePolicy : "off";
51
+ }
52
+ if (typeof readLivePolicy !== "function") return "off";
53
+ try {
54
+ const policy = await readLivePolicy({ adapter, binding });
55
+ return LIVE_POLICIES.has(policy) ? policy : "off";
56
+ } catch {
57
+ return "off";
58
+ }
59
+ }
60
+
45
61
  async function recordFailure(binding, message, participantId, transport, safeErrorCode) {
46
62
  await service.recordOfferFailed({ messageId: message.messageId,
47
63
  recipientParticipantId: participantId, targetSessionId: binding.sessionId,
@@ -54,24 +70,29 @@ export function createDeliveryRouter({ service, adapters, clock }) {
54
70
  const receipt = await service.readReceipt({ messageId: message.messageId,
55
71
  recipientParticipantId: participantId });
56
72
  if (receipt.state !== "queued") return settled(receipt);
73
+ // Core keeps its existing 24-hour presence hard expiry. Refreshing an
74
+ // endpoint lease never sends a hook heartbeat: a daemon is reachability,
75
+ // not evidence that this particular session thread is still present.
57
76
  const liveSessions = await service.listLiveSessions({ participantId, now });
58
77
  if (liveSessions.length === 0) return durable(participantId, "recipient_unavailable");
59
78
  if (liveSessions.length > 1) {
60
79
  return durable(participantId, "ambiguous_recipient_sessions");
61
80
  }
62
81
  const [target] = liveSessions;
63
- const bindings = (await service.listDeliveryBindings({ participantId, now }))
82
+ const bindings = (await service.listDeliveryBindings({
83
+ participantId, now, includeExpired: true }))
64
84
  .filter(binding => binding.sessionId === target.sessionId
65
85
  && binding.generation === target.generation);
66
86
  if (bindings.length === 0) return durable(participantId, "recipient_unavailable");
67
- const permitted = bindings.filter(binding => binding.livePolicy !== "off"
68
- && permits(binding.livePolicy, message.kind));
87
+ const evaluated = await Promise.all(bindings.map(async binding => {
88
+ const adapter = registry.get(binding.adapterId);
89
+ return { binding, adapter, policy: await policyFor(adapter, binding) };
90
+ }));
91
+ const permitted = evaluated.filter(({ policy }) => permits(policy, message.kind));
69
92
  if (permitted.length === 0) return durable(participantId, "delivery_disabled");
70
- const reachable = permitted.filter(binding => binding.availableModes.includes("livePush"));
93
+ const reachable = permitted.filter(({ binding }) => binding.availableModes.includes("livePush"));
71
94
  if (reachable.length === 0) return durable(participantId, "recipient_unavailable");
72
- const capable = reachable.map(binding => ({ binding,
73
- adapter: registry.get(binding.adapterId) }))
74
- .filter(({ binding, adapter }) => liveCapable(adapter, binding));
95
+ const capable = reachable.filter(({ binding, adapter }) => liveCapable(adapter, binding));
75
96
  if (capable.length === 0) {
76
97
  return durable(participantId, "unsupported_client_version");
77
98
  }
@@ -79,13 +100,61 @@ export function createDeliveryRouter({ service, adapters, clock }) {
79
100
  return durable(participantId, "ambiguous_recipient_sessions");
80
101
  }
81
102
 
82
- const { binding, adapter } = capable[0];
103
+ let { binding } = capable[0];
104
+ const { adapter } = capable[0];
105
+ if (Date.parse(binding.leaseUntil) <= Date.parse(now)) {
106
+ const refreshed = await refreshExpiredBinding({ service, adapter, binding,
107
+ runtimeDir: service.store?.root, platform, clock });
108
+ if (!refreshed) return durable(participantId, "recipient_unavailable");
109
+ const current = (await service.listDeliveryBindings({
110
+ participantId, now: clock.now() })).filter(item => item.sessionId === binding.sessionId
111
+ && item.generation === binding.generation
112
+ && item.adapterId === binding.adapterId
113
+ && item.clientVersion === binding.clientVersion
114
+ && item.opaqueEndpointRef === binding.opaqueEndpointRef);
115
+ if (current.length !== 1) return durable(participantId, "recipient_unavailable");
116
+ [binding] = current;
117
+ }
118
+ const currentPolicy = await policyFor(adapter, binding);
119
+ if (!permits(currentPolicy, message.kind)) {
120
+ return durable(participantId, "delivery_disabled");
121
+ }
122
+ // A refresh RPC or policy read can overlap another session opening. The
123
+ // receiver must still be the sole live session when we hand off to transport.
124
+ const currentSessions = await service.listLiveSessions({ participantId, now: clock.now() });
125
+ if (currentSessions.length > 1) return durable(participantId, "ambiguous_recipient_sessions");
126
+ if (currentSessions.length !== 1 || currentSessions[0].sessionId !== binding.sessionId
127
+ || currentSessions[0].generation !== binding.generation) {
128
+ return durable(participantId, "recipient_unavailable");
129
+ }
130
+ // Every recipient gets a fresh decision snapshot after async lookup. A
131
+ // previous recipient may have replaced it while processing their offer.
132
+ // A send already in flight cannot be recalled by a later peer assertion.
133
+ if (message.kind === "decision") {
134
+ message = (await service.sync({ scope: "history", messageId: message.messageId })).items[0];
135
+ if (!message.decisionStatus.isHead) return settled(await service.readReceipt({
136
+ messageId: message.messageId, recipientParticipantId: participantId }));
137
+ }
138
+ // Consent and session reads can outlive retirement or a re-handshake in
139
+ // the same generation. Core filters retired, expired and stale-generation
140
+ // bindings; require the selected identity and live mode immediately before
141
+ // transport, with no further awaited policy/session work in between.
142
+ const currentBindings = (await service.listDeliveryBindings({
143
+ participantId, now: clock.now() })).filter(item => item.sessionId === binding.sessionId
144
+ && item.generation === binding.generation
145
+ && item.adapterId === binding.adapterId
146
+ && item.clientVersion === binding.clientVersion
147
+ && item.opaqueEndpointRef === binding.opaqueEndpointRef
148
+ && item.availableModes.includes("livePush")
149
+ && Date.parse(item.leaseUntil) > Date.parse(clock.now()));
150
+ if (currentBindings.length !== 1) return durable(participantId, "recipient_unavailable");
151
+ const currentBinding = { ...currentBindings[0], livePolicy: currentPolicy };
83
152
  let response;
84
153
  try {
85
154
  // The store root is this workspace's runtime dir; the adapter resolves its
86
155
  // opaque endpoint id under it. Passed as data, never as a leak into core:
87
156
  // the router does not read what the adapter does with it.
88
- response = await adapter.offerMessage({ binding, message,
157
+ response = await adapter.offerMessage({ binding: currentBinding, message,
89
158
  runtimeDir: service.store?.root });
90
159
  } catch {
91
160
  await recordFailure(binding, message, participantId, "live-adapter", "transport_error");
@@ -119,6 +188,13 @@ export function createDeliveryRouter({ service, adapters, clock }) {
119
188
  if (!Array.isArray(message?.toParticipantIds) || message.toParticipantIds.length === 0) {
120
189
  return [];
121
190
  }
191
+ if (message.kind === "decision") {
192
+ message = (await service.sync({ scope: "history", messageId: message.messageId })).items[0];
193
+ if (!message.decisionStatus.isHead) {
194
+ return Promise.all(message.toParticipantIds.map(async recipientParticipantId => settled(
195
+ await service.readReceipt({ messageId: message.messageId, recipientParticipantId }))));
196
+ }
197
+ }
122
198
  const now = clock.now();
123
199
  const outcomes = [];
124
200
  for (const participantId of message.toParticipantIds) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,6 +11,7 @@ export function probeClientVersion(adapter, { timeoutMs = 1_000 } = {}) {
11
11
  return new Promise(resolve => {
12
12
  execFile(adapter.client.command, adapter.client.versionArgs ?? ["--version"], {
13
13
  timeout: timeoutMs,
14
+ killSignal: "SIGKILL",
14
15
  windowsHide: true,
15
16
  }, (error, stdout, stderr) => {
16
17
  if (error !== null) return resolve(null);
@@ -1,5 +1,6 @@
1
1
  import { validateNativeHandshake } from "@agents-can-communicate/adapter-sdk";
2
2
  import { EXIT } from "@agents-can-communicate/protocol";
3
+ import { retireNativeBinding } from "./native-retirement.mjs";
3
4
 
4
5
  // The hook side of native delivery: one bounded, fail-open attempt to bind
5
6
  // this exact ACC session generation to the vendor session the adapter can
@@ -29,7 +30,7 @@ export function livePolicyFrom(env) {
29
30
  const isPid = value => Number.isInteger(value) && value > 0;
30
31
 
31
32
  export async function establishNativeBinding({ adapter, event, hookBinding, clientVersion,
32
- platform, livePolicy, service, runtimeDir, clock,
33
+ platform, livePolicy, service, runtimeDir, clock, env,
33
34
  timeoutMs = DEFAULT_TIMEOUT_MS, heartbeatCadenceMs = DEFAULT_CADENCE_MS }) {
34
35
  const outcome = (state, reasonCode, modes = []) =>
35
36
  Object.freeze({ state, reasonCode, modes: Object.freeze([...modes]) });
@@ -37,7 +38,7 @@ export async function establishNativeBinding({ adapter, event, hookBinding, clie
37
38
  const generation = hookBinding?.generation;
38
39
  if (typeof sessionId !== "string" || typeof generation !== "string") return outcome("off", null);
39
40
  const policy = LIVE_POLICIES.includes(livePolicy) ? livePolicy : "off";
40
- const clear = () => service.clearDeliveryBinding({ sessionId, generation }).catch(() => null);
41
+ const clear = () => retireNativeBinding({ adapter, service, sessionId, generation, runtimeDir, timeoutMs });
41
42
  if (policy === "off") {
42
43
  await clear();
43
44
  return outcome("off", null);
@@ -52,12 +53,13 @@ export async function establishNativeBinding({ adapter, event, hookBinding, clie
52
53
  }
53
54
  // Whatever this generation published before is retired first, so a failed
54
55
  // re-handshake can never leave yesterday's endpoint reachable.
55
- await clear();
56
+ if (!await clear()) return outcome("degraded", "handshake_failed");
56
57
  let timer = null;
57
58
  try {
58
59
  const budget = Math.max(1, Math.floor(timeoutMs));
59
60
  const handshake = await Promise.race([
60
- adapter.bindNativeSession({ event, clientPid, clientVersion, runtimeDir, timeoutMs: budget }),
61
+ adapter.bindNativeSession({ event, clientPid, clientVersion, runtimeDir,
62
+ timeoutMs: budget, env }),
61
63
  new Promise((_resolve, reject) => {
62
64
  timer = setTimeout(() => reject(Object.assign(new Error("native handshake timed out"),
63
65
  { code: "ETIMEDOUT" })), budget);
@@ -0,0 +1,48 @@
1
+ // Retire the core fact first. Adapter cleanup is bounded and acts only on the
2
+ // endpoint whose retirement was observed; an expired registration is retained
3
+ // for on-demand verification, and a successor's endpoint is never touched.
4
+ async function attempt(run, deadlineAt) {
5
+ const timeoutMs = Math.max(0, deadlineAt - Date.now());
6
+ if (timeoutMs === 0) return null;
7
+ let timer;
8
+ try {
9
+ return await Promise.race([Promise.resolve().then(run), new Promise(resolve => {
10
+ timer = setTimeout(() => resolve(null), timeoutMs);
11
+ })]);
12
+ } catch { return null; }
13
+ finally { clearTimeout(timer); }
14
+ }
15
+
16
+ export async function retireNativeBinding({ adapter, service, sessionId, generation,
17
+ runtimeDir, timeoutMs = 200 }) {
18
+ const deadlineAt = Date.now() + Math.max(1, Math.floor(timeoutMs));
19
+ const cleanup = typeof adapter?.retireNativeSession === "function";
20
+ const read = () => service.store.ephemeral.get("deliveryBinding", sessionId);
21
+ const clear = opaqueEndpointRef => opaqueEndpointRef === undefined
22
+ ? service.clearDeliveryBinding({ sessionId, generation, deadlineAt })
23
+ : service.clearDeliveryBinding({ sessionId, generation, opaqueEndpointRef, deadlineAt });
24
+ if (!cleanup) {
25
+ return await attempt(async () => {
26
+ await clear(undefined);
27
+ return true;
28
+ }, deadlineAt) === true;
29
+ }
30
+ // Observation is metadata, not the state transition. A hung read leaves half
31
+ // the shared retirement window for the exact core clear; a cheap read spends
32
+ // only the time it actually takes.
33
+ const observationDeadline = Date.now() + Math.max(1,
34
+ Math.floor(Math.max(0, deadlineAt - Date.now()) / 2));
35
+ const observed = await attempt(async () => ({ binding: await read() }), observationDeadline);
36
+ const prior = observed?.binding;
37
+ const cleared = await attempt(async () => {
38
+ await clear(prior?.opaqueEndpointRef ?? null);
39
+ return true;
40
+ }, deadlineAt) === true;
41
+ if (!cleared || observed === null) return false;
42
+ if (prior?.generation !== generation) return true;
43
+ const after = await attempt(read, deadlineAt);
44
+ if (after?.generation !== generation || after.retiredAt == null
45
+ || after.opaqueEndpointRef !== prior.opaqueEndpointRef) return true;
46
+ await attempt(() => adapter.retireNativeSession({ binding: prior, runtimeDir }), deadlineAt);
47
+ return true;
48
+ }
@@ -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);