agents-can-communicate 0.2.0 → 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 (86) hide show
  1. package/README.md +17 -7
  2. package/bin/acc-bootstrap.mjs +56 -0
  3. package/bin/acc-claude-channel.mjs +177 -0
  4. package/bin/acc.mjs +7 -2
  5. package/docs/ADAPTER_AUTHORING.md +34 -2
  6. package/docs/CAPABILITIES.md +25 -10
  7. package/docs/CLI.md +10 -3
  8. package/docs/CONFIGURATION.md +4 -0
  9. package/docs/HOW_IT_WORKS.md +277 -0
  10. package/docs/PROTOCOL.md +1 -1
  11. package/docs/RELEASING.md +7 -1
  12. package/docs/TROUBLESHOOTING.md +7 -1
  13. package/docs/index.md +3 -1
  14. package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +196 -46
  15. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +237 -45
  16. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.252.json +3 -1
  17. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.258.json +23 -0
  18. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.260.json +23 -0
  19. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +5 -2
  20. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.mcp.json +8 -0
  21. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +4 -2
  22. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +34 -2
  23. package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +377 -0
  24. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +27 -7
  25. package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +229 -0
  26. package/node_modules/@agents-can-communicate/adapter-codex/certification.json +38 -5
  27. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +171 -38
  28. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.0.json +3 -1
  29. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-remote-workspace.json +25 -0
  30. package/node_modules/@agents-can-communicate/adapter-codex/package.json +4 -2
  31. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +4 -2
  32. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +42 -7
  33. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +121 -0
  34. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +151 -0
  35. package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +192 -0
  36. package/node_modules/@agents-can-communicate/adapter-gemini-cli/certification.json +44 -28
  37. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  38. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +4 -2
  39. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{BeforeAgent.json → BeforeAgent-0.57.0.json} +2 -2
  40. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{BeforeTool.json → BeforeTool-0.57.0.json} +2 -2
  41. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{BeforeTool-shell.json → BeforeTool-shell-0.57.0.json} +2 -2
  42. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{SessionEnd.json → SessionEnd-0.57.0.json} +2 -2
  43. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{SessionStart.json → SessionStart-0.57.0.json} +2 -2
  44. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/certification-provenance.json +266 -39
  45. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +6 -6
  46. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +25 -11
  47. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  48. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +4 -2
  49. package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +3 -1
  50. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  51. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +4 -2
  52. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  53. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +18 -0
  54. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +2 -0
  55. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +6 -2
  56. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-activation.mjs +76 -0
  57. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +202 -0
  58. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +101 -0
  59. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +17 -2
  60. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  61. package/node_modules/@agents-can-communicate/cli/src/args.mjs +2 -2
  62. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +67 -5
  63. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +103 -11
  64. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  65. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +52 -2
  66. package/node_modules/@agents-can-communicate/core/src/service.mjs +10 -0
  67. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  68. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +26 -12
  69. package/node_modules/@agents-can-communicate/hook-runner/package.json +4 -2
  70. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +90 -0
  71. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +123 -90
  72. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  73. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +48 -4
  74. package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +144 -0
  75. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +72 -3
  76. package/node_modules/@agents-can-communicate/installer/src/index.mjs +7 -0
  77. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +161 -0
  78. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +6 -2
  79. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +41 -6
  80. package/node_modules/@agents-can-communicate/installer/src/shell-bootstrap.mjs +210 -0
  81. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  82. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  83. package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +17 -0
  84. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +18 -3
  85. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  86. package/package.json +2 -1
@@ -6,7 +6,8 @@ import { createCodexAdapter } from "@agents-can-communicate/adapter-codex";
6
6
  import { createGeminiCliAdapter } from "@agents-can-communicate/adapter-gemini-cli";
7
7
  import { createGrokAdapter } from "@agents-can-communicate/adapter-grok";
8
8
  import { createKimiAdapter } from "@agents-can-communicate/adapter-kimi";
9
- import { applyPlan, detectInstallation, loadOwnership, planInstallation }
9
+ import { LIVE_POLICIES, applyPlan, describeActivation, detectInstallation, livePolicyOf,
10
+ loadOwnership, planInstallation, rcFileFor, shellOf, shimDirFor }
10
11
  from "@agents-can-communicate/installer";
11
12
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
12
13
 
@@ -18,13 +19,18 @@ import { platformPaths } from "./platform-paths.mjs";
18
19
  // Each client keeps its own directory under the user's home, and an adapter
19
20
  // pointed at the home itself writes beside them rather than inside them. That
20
21
  // install reports success and the client never reads a byte of it.
21
- export const clientContext = (home, stateRoot) => ({
22
+ export const clientContext = (home, stateRoot, { shell = null, env = {} } = {}) => ({
22
23
  home,
23
24
  configDir: path.join(home, ".claude"),
24
25
  agentsHome: home,
25
26
  codexHome: path.join(home, ".codex"),
26
27
  kimiHome: path.join(home, ".kimi-code"),
27
28
  grokHome: path.join(home, ".grok"),
29
+ // The user's login shell and PATH, for the optional native shell bootstrap:
30
+ // which rc file could carry an ACC PATH block and which real executable a
31
+ // shim would exec. Detection reads them; nothing here writes.
32
+ shell,
33
+ env,
28
34
  // Where ACC keeps its own state, for the client that has to be told. Codex
29
35
  // sandboxes the commands a model runs to the workspace, and ACC's state is
30
36
  // outside every workspace on purpose - so an agent there could read the
@@ -108,11 +114,20 @@ export function describeChanges(operation, home) {
108
114
  return [...artifacts.filter(artifact => artifact.kind !== "merge")
109
115
  .map(artifact => ` created ${shorten(artifact.path, home)}`), ...edited];
110
116
  }
117
+ // An uninstall's merge edits come from the plan - every file ACC has ever
118
+ // written into - while everything else on this list is decided by the run.
119
+ // Printed unconditionally, a second uninstall told the operator it had edited
120
+ // six of their configuration files whose bytes it had not touched. `changes`
121
+ // is what the adapter actually took out, and is already what the packed
122
+ // verifier calls a no-op, so the report now follows the same definition. It
123
+ // is still the plan's list rather than a per-file one: this says whether
124
+ // anything was edited, not which of several files it was.
125
+ const editedIfAnything = (operation.changes ?? []).length === 0 ? [] : edited;
111
126
  return [
112
127
  ...(operation.removed ?? []).map(file => ` removed ${shorten(file, home)}`),
113
128
  ...(operation.removedDirectories ?? [])
114
129
  .map(file => ` removed ${shorten(file, home)}`),
115
- ...edited,
130
+ ...editedIfAnything,
116
131
  ...(operation.kept ?? [])
117
132
  .map(file => ` kept ${shorten(file, home)} - changed since ACC wrote it`),
118
133
  ];
@@ -174,12 +189,81 @@ export function actedOn(result) {
174
189
  + (operation.changes?.length ?? 0) > 0)).length;
175
190
  }
176
191
 
192
+ const isInteractive = runtime => typeof runtime.isInteractive === "function"
193
+ && runtime.isInteractive() === true;
194
+
195
+ // One default-No question per eligible client, asked only after detection has
196
+ // finished and only when a person is at both ends of the terminal. It names
197
+ // the client, the mechanism, and the files or service it would touch, and says
198
+ // when the choice takes effect.
199
+ function questionFor(entry, context) {
200
+ const activation = { livePolicy: "actionable", shell: context.shell,
201
+ rcFile: rcFileFor(context.home, context.shell), shimDir: shimDirFor(context.stateRoot),
202
+ mechanisms: entry.nativeDelivery.activationPlan.mechanisms };
203
+ return [`Enable native live delivery for ${entry.displayName} ${entry.version}?`,
204
+ ...describeActivation(activation).map(line => ` ${line}`),
205
+ " applies to newly started sessions; a new PATH block needs a new or reloaded shell"]
206
+ .join("\n");
207
+ }
208
+
209
+ /**
210
+ * Which live policy each selected client gets.
211
+ *
212
+ * An explicit --delivery applies uniformly and never prompts. Otherwise a
213
+ * recorded opt-in is kept as it was consented to, a fresh or never-activated
214
+ * client is off unless a person answers yes here, and a dry run or a
215
+ * non-interactive stdin/stdout makes no interactive choice at all.
216
+ */
217
+ export async function decideDelivery({ options, detected, recorded, runtime, dryRun, context }) {
218
+ const explicit = options.delivery;
219
+ if (explicit !== undefined && !LIVE_POLICIES.includes(explicit)) {
220
+ throw new AccError(EXIT.USAGE, `unknown delivery policy: ${explicit}`, { delivery: explicit });
221
+ }
222
+ const recordedById = new Map(recorded.map(install => [install.adapterId, install]));
223
+ const deliveryByAdapter = {};
224
+ const asked = [];
225
+ let withheld = 0;
226
+ for (const entry of detected) {
227
+ const eligible = entry.nativeDelivery?.state === "eligible";
228
+ const previous = livePolicyOf(recordedById.get(entry.adapterId));
229
+ if (explicit !== undefined) {
230
+ deliveryByAdapter[entry.adapterId] = explicit;
231
+ } else if (previous !== "off") {
232
+ deliveryByAdapter[entry.adapterId] = previous;
233
+ } else if (!eligible || dryRun || !isInteractive(runtime)) {
234
+ deliveryByAdapter[entry.adapterId] = "off";
235
+ if (eligible) withheld += 1;
236
+ } else {
237
+ const yes = await runtime.confirm(questionFor(entry, context),
238
+ { input: runtime.input, output: runtime.output }) === true;
239
+ deliveryByAdapter[entry.adapterId] = yes ? "actionable" : "off";
240
+ asked.push(entry.adapterId);
241
+ }
242
+ }
243
+ const notes = explicit === undefined && dryRun && withheld > 0
244
+ ? ["interactive choices were not made: this preview keeps native delivery off for "
245
+ + `${withheld} eligible client(s) without a recorded opt-in`]
246
+ : [];
247
+ return { deliveryByAdapter, asked, notes };
248
+ }
249
+
250
+ // Said once, after the first PATH block is written: a running shell and a
251
+ // running client know nothing about it.
252
+ function reloadAdvice(operations) {
253
+ const appended = operations.filter(operation => operation.appendedRcBlock === true);
254
+ if (appended.length === 0) return [];
255
+ const rc = appended[0].nativeActivation?.rcFile ?? "your shell rc file";
256
+ return ["", `native delivery is wired for new sessions: open a new terminal (or reload ${rc}), `
257
+ + "then start the client normally; a session already running keeps durable delivery"];
258
+ }
259
+
177
260
  export async function runInstallCommand({ options, runtime, action = "install" }) {
178
261
  const adapters = selectAdapters(options.adapter);
179
262
  const home = options.home ?? runtime.env?.HOME ?? homedir();
180
263
  const { data: dataHome } = platformPaths({ platform: runtime.platform,
181
264
  env: runtime.env ?? {} });
182
- const context = clientContext(home, path.join(dataHome, "acc"));
265
+ const context = clientContext(home, path.join(dataHome, "acc"),
266
+ { shell: shellOf(runtime.env), env: runtime.env ?? {} });
183
267
 
184
268
  const detected = await detectInstallation({ adapters, context,
185
269
  probeTimeoutMs: probeTimeout(runtime.env) });
@@ -205,20 +289,28 @@ export async function runInstallCommand({ options, runtime, action = "install" }
205
289
  const requested = options.adapter === undefined
206
290
  ? []
207
291
  : (Array.isArray(options.adapter) ? options.adapter : [options.adapter]);
292
+ // Decided only after detection, from the operator's explicit answer, the
293
+ // recorded consent, or a default of off; never from inference.
294
+ const decided = action === "install"
295
+ ? await decideDelivery({ options, detected, recorded, runtime, dryRun, context })
296
+ : { deliveryByAdapter: {}, asked: [], notes: [] };
208
297
  const plan = planInstallation({ adapters, detected, context, action, recorded,
209
298
  accVersion, allowDowngrade: options.downgrade === true, requested,
210
- delivery: options.delivery ?? "off" });
299
+ deliveryByAdapter: decided.deliveryByAdapter });
211
300
  const result = await applyPlan({ plan, adapters, context, dataHome, dryRun, accVersion });
212
301
 
213
302
  const acted = actedOn(result);
214
303
  if (dryRun) {
215
- return { data: { ...result, plan, dataHome },
304
+ return { data: { ...result, plan, dataHome, deliveryByAdapter: decided.deliveryByAdapter },
216
305
  text: [`would ${action}:`, ...plan.operations.flatMap(operation => operation.summary),
217
- ...plan.skipped.map(entry => `skip ${entry.adapterId}: ${entry.reason}`)].join("\n") };
306
+ ...plan.skipped.map(entry => `skip ${entry.adapterId}: ${entry.reason}`),
307
+ ...decided.notes].join("\n") };
218
308
  }
219
309
 
220
- return { data: { ...result, plan, dataHome },
221
- text: describeOutcome({ action, acted, failed: result.failed,
222
- skipped: plan.skipped, operations: result.operations, home }),
223
- error: failureOf({ action, acted, failed: result.failed }) };
310
+ return { data: { ...result, plan, dataHome, deliveryByAdapter: decided.deliveryByAdapter,
311
+ asked: decided.asked },
312
+ text: [describeOutcome({ action, acted, failed: result.failed,
313
+ skipped: plan.skipped, operations: result.operations, home }),
314
+ ...reloadAdvice(result.operations)].join("\n"),
315
+ error: failureOf({ action, acted, failed: result.failed }) };
224
316
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,7 +8,7 @@ function conflict(sessionId) {
8
8
  }
9
9
 
10
10
  export function createDeliveryBindingService(ports, sessions) {
11
- const { store } = ports;
11
+ const { store, clock } = ports;
12
12
 
13
13
  async function currentBindings(now) {
14
14
  const bindings = await store.ephemeral.list("deliveryBinding");
@@ -17,6 +17,10 @@ export function createDeliveryBindingService(ports, sessions) {
17
17
  const located = await sessions.locateSession(binding.sessionId);
18
18
  if (located?.record.state !== "open"
19
19
  || located.record.generation !== binding.generation) continue;
20
+ // A retirement is final. It is kept as a record rather than deleted so a
21
+ // stale generation cannot race a successor's publication, and it must
22
+ // never be read back as a live endpoint.
23
+ if (binding.retiredAt !== null && binding.retiredAt !== undefined) continue;
20
24
  current.push({ binding, participantId: located.record.participantId,
21
25
  reachable: Date.parse(binding.leaseUntil) > Date.parse(now) });
22
26
  }
@@ -42,6 +46,7 @@ export function createDeliveryBindingService(ports, sessions) {
42
46
  livePolicy: input.livePolicy,
43
47
  opaqueEndpointRef: input.opaqueEndpointRef,
44
48
  leaseUntil: input.leaseUntil,
49
+ retiredAt: null,
45
50
  });
46
51
  } catch (error) {
47
52
  if (error?.details?.field === "opaqueEndpointRef") {
@@ -71,11 +76,56 @@ export function createDeliveryBindingService(ports, sessions) {
71
76
  return binding;
72
77
  }
73
78
 
79
+ // Clearing is an in-place retirement under the same writer lock as
80
+ // publication. The store's update contract has no deletion sentinel and its
81
+ // delete() takes the same mutex, so this is the one atomic form: a stale
82
+ // generation cannot retire a successor's endpoint, and an absent or
83
+ // already-retired binding stays as it is.
84
+ //
85
+ // The retirement is recorded as its own fact rather than as an expired lease.
86
+ // Expiry and retirement used to be the same edit, so a channel still renewing
87
+ // its endpoint could extend a binding the session had already given up.
88
+ async function clearDeliveryBinding({ sessionId, generation }) {
89
+ await store.ephemeral.update("deliveryBinding", sessionId, async current => {
90
+ if (current === null || current === undefined) return null;
91
+ if (current.generation !== generation) throw conflict(sessionId);
92
+ if (current.retiredAt !== null && current.retiredAt !== undefined) return null;
93
+ const now = clock.now();
94
+ return { ...current, leaseUntil: now, retiredAt: now };
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Extend the lease on a binding whose endpoint is still being served.
100
+ *
101
+ * Only the process holding the endpoint knows it is alive, and for a client
102
+ * with no heartbeat nothing else refreshes this record: measured on a real
103
+ * Claude 2.1.259 session, the channel kept its registration renewed while
104
+ * this lease went stale about a minute after the last turn, and the router
105
+ * stopped offering to a session that was still serving.
106
+ *
107
+ * Narrow on purpose. It moves the lease and nothing else, refuses anything
108
+ * but the current open generation, and will not revive a retired binding.
109
+ */
110
+ async function refreshDeliveryBinding({ sessionId, generation, leaseUntil }) {
111
+ const located = await sessions.locateSession(sessionId);
112
+ if (located?.record.state !== "open" || located.record.generation !== generation) {
113
+ throw conflict(sessionId);
114
+ }
115
+ await store.ephemeral.update("deliveryBinding", sessionId, async current => {
116
+ if (current === null || current === undefined) return null;
117
+ if (current.generation !== generation) throw conflict(sessionId);
118
+ if (current.retiredAt !== null && current.retiredAt !== undefined) return null;
119
+ return validateRecord("deliveryBinding", { ...current, leaseUntil });
120
+ });
121
+ }
122
+
74
123
  async function listDeliveryBindings({ participantId, now }) {
75
124
  return (await currentBindings(now))
76
125
  .filter(item => item.participantId === participantId && item.reachable)
77
126
  .map(item => item.binding);
78
127
  }
79
128
 
80
- return { publishDeliveryBinding, listDeliveryBindings, currentBindings };
129
+ return { publishDeliveryBinding, clearDeliveryBinding, refreshDeliveryBinding,
130
+ listDeliveryBindings, currentBindings };
81
131
  }
@@ -43,11 +43,21 @@ export function createCoordinationService({ store, clock, ids,
43
43
  ids,
44
44
  policies: Object.freeze({ ...policies }),
45
45
  ...sessions,
46
+ // Closing a session also retires its live endpoint under the same
47
+ // generation, so nothing can be offered into a session that has left.
48
+ closeSession: async input => {
49
+ const closed = await sessions.closeSession(input);
50
+ await deliveryBindings.clearDeliveryBinding({ sessionId: input.sessionId,
51
+ generation: input.generation });
52
+ return closed;
53
+ },
46
54
  ...intents,
47
55
  ...claims,
48
56
  ...conversations,
49
57
  publishDeliveryBinding: deliveryBindings.publishDeliveryBinding,
58
+ clearDeliveryBinding: deliveryBindings.clearDeliveryBinding,
50
59
  listDeliveryBindings: deliveryBindings.listDeliveryBindings,
60
+ refreshDeliveryBinding: deliveryBindings.refreshDeliveryBinding,
51
61
  ...inbox,
52
62
  ...receipts,
53
63
  ...sync,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/delivery-router",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,3 @@
1
- import { effectiveCapabilities } from "@agents-can-communicate/adapter-sdk";
2
-
3
- const PLATFORM = `${process.platform}-${process.arch}`;
4
1
  const SAFE_ERRORS = new Set(["ambiguous_recipient_sessions", "delivery_disabled",
5
2
  "recipient_busy", "recipient_unavailable", "transport_error", "transport_rejected",
6
3
  "unsupported_client_version"]);
@@ -11,8 +8,23 @@ const adaptersById = adapters => adapters instanceof Map
11
8
  : new Map((Array.isArray(adapters) ? adapters : Object.values(adapters ?? {}))
12
9
  .map(adapter => [adapter.id, adapter]));
13
10
 
11
+ // Everything that closes or advances a conversation is actionable: a question
12
+ // or request asks for work, an answer or decision resolves it, a handoff
13
+ // transfers it. A note informs and waits for the next turn. Room messages
14
+ // have no recipient and are never offered live.
15
+ const ACTIONABLE = new Set(["question", "request", "answer", "decision", "handoff"]);
14
16
  const permits = (policy, kind) => policy === "all"
15
- || (policy === "actionable" && ["question", "request", "handoff"].includes(kind));
17
+ || (policy === "actionable" && ACTIONABLE.has(kind));
18
+
19
+ // Compatibility was decided twice already - at bootstrap by the probe and at
20
+ // SessionStart by the generation-bound handshake that published this binding.
21
+ // The router validates binding identity and the adapter's answer; it does not
22
+ // impose a third, exact-version rule that would reject a client the handshake
23
+ // admitted.
24
+ const liveCapable = (adapter, binding) => adapter !== undefined
25
+ && adapter.capabilities?.delivery?.livePush === true
26
+ && adapter.nativeDelivery !== undefined
27
+ && binding.availableModes.includes("livePush");
16
28
 
17
29
  const durable = (recipientParticipantId, errorCode) => ({ recipientParticipantId,
18
30
  outcome: "queued", transport: "durable", errorCode });
@@ -57,22 +69,24 @@ export function createDeliveryRouter({ service, adapters, clock }) {
57
69
  if (permitted.length === 0) return durable(participantId, "delivery_disabled");
58
70
  const reachable = permitted.filter(binding => binding.availableModes.includes("livePush"));
59
71
  if (reachable.length === 0) return durable(participantId, "recipient_unavailable");
60
- const certified = reachable.map(binding => ({ binding,
72
+ const capable = reachable.map(binding => ({ binding,
61
73
  adapter: registry.get(binding.adapterId) }))
62
- .filter(({ binding, adapter }) => adapter !== undefined
63
- && effectiveCapabilities(adapter,
64
- { clientVersion: binding.clientVersion, platform: PLATFORM }).delivery.livePush);
65
- if (certified.length === 0) {
74
+ .filter(({ binding, adapter }) => liveCapable(adapter, binding));
75
+ if (capable.length === 0) {
66
76
  return durable(participantId, "unsupported_client_version");
67
77
  }
68
- if (certified.length > 1) {
78
+ if (capable.length > 1) {
69
79
  return durable(participantId, "ambiguous_recipient_sessions");
70
80
  }
71
81
 
72
- const { binding, adapter } = certified[0];
82
+ const { binding, adapter } = capable[0];
73
83
  let response;
74
84
  try {
75
- response = await adapter.offerMessage({ binding, message });
85
+ // The store root is this workspace's runtime dir; the adapter resolves its
86
+ // opaque endpoint id under it. Passed as data, never as a leak into core:
87
+ // the router does not read what the adapter does with it.
88
+ response = await adapter.offerMessage({ binding, message,
89
+ runtimeDir: service.store?.root });
76
90
  } catch {
77
91
  await recordFailure(binding, message, participantId, "live-adapter", "transport_error");
78
92
  return durable(participantId, "transport_error");
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
7
- ".": "./src/runner.mjs"
7
+ ".": "./src/runner.mjs",
8
+ "./client-pid": "./src/client-pid.mjs",
9
+ "./process-table": "./src/process-table.mjs"
8
10
  },
9
11
  "files": [
10
12
  "src/"
@@ -0,0 +1,90 @@
1
+ import { validateNativeHandshake } from "@agents-can-communicate/adapter-sdk";
2
+ import { EXIT } from "@agents-can-communicate/protocol";
3
+
4
+ // The hook side of native delivery: one bounded, fail-open attempt to bind
5
+ // this exact ACC session generation to the vendor session the adapter can
6
+ // reach, then a sanitized binding published for the router. The helper, not
7
+ // the adapter, supplies adapter id, client facts, session id, and generation;
8
+ // the adapter supplies only closed handshake facts. Nothing here throws into
9
+ // the hook path, and no endpoint reference ever leaves through the result.
10
+
11
+ export const LIVE_POLICIES = Object.freeze(["off", "actionable", "all"]);
12
+ export const NATIVE_BINDING_STATES = Object.freeze(["active", "off", "degraded", "unsupported"]);
13
+ const DEFAULT_TIMEOUT_MS = 750;
14
+ const DEFAULT_CADENCE_MS = 60_000;
15
+
16
+ // Reasons the static rule refused: the client will not change within the
17
+ // session, so the state is "unsupported" rather than a retryable "degraded".
18
+ const STATIC_REASONS = new Set(["native_delivery_unsupported", "platform_not_captured",
19
+ "version_unavailable", "prerelease_not_captured", "below_minimum_version",
20
+ "known_bad_version"]);
21
+
22
+ // Only the value a successful owned shell bootstrap exported counts; anything
23
+ // else - missing, blank, differently cased, a number - is off.
24
+ export function livePolicyFrom(env) {
25
+ const value = env?.ACC_NATIVE_DELIVERY_POLICY;
26
+ return LIVE_POLICIES.includes(value) ? value : "off";
27
+ }
28
+
29
+ const isPid = value => Number.isInteger(value) && value > 0;
30
+
31
+ export async function establishNativeBinding({ adapter, event, hookBinding, clientVersion,
32
+ platform, livePolicy, service, runtimeDir, clock,
33
+ timeoutMs = DEFAULT_TIMEOUT_MS, heartbeatCadenceMs = DEFAULT_CADENCE_MS }) {
34
+ const outcome = (state, reasonCode, modes = []) =>
35
+ Object.freeze({ state, reasonCode, modes: Object.freeze([...modes]) });
36
+ const sessionId = hookBinding?.accSessionId;
37
+ const generation = hookBinding?.generation;
38
+ if (typeof sessionId !== "string" || typeof generation !== "string") return outcome("off", null);
39
+ const policy = LIVE_POLICIES.includes(livePolicy) ? livePolicy : "off";
40
+ const clear = () => service.clearDeliveryBinding({ sessionId, generation }).catch(() => null);
41
+ if (policy === "off") {
42
+ await clear();
43
+ return outcome("off", null);
44
+ }
45
+ if (adapter?.nativeDelivery === undefined || typeof adapter.bindNativeSession !== "function") {
46
+ return outcome("unsupported", "native_delivery_unsupported");
47
+ }
48
+ const clientPid = hookBinding.clientPid;
49
+ if (!isPid(clientPid)) {
50
+ await clear();
51
+ return outcome("degraded", "client_process_unknown");
52
+ }
53
+ // Whatever this generation published before is retired first, so a failed
54
+ // re-handshake can never leave yesterday's endpoint reachable.
55
+ await clear();
56
+ let timer = null;
57
+ try {
58
+ const budget = Math.max(1, Math.floor(timeoutMs));
59
+ const handshake = await Promise.race([
60
+ adapter.bindNativeSession({ event, clientPid, clientVersion, runtimeDir, timeoutMs: budget }),
61
+ new Promise((_resolve, reject) => {
62
+ timer = setTimeout(() => reject(Object.assign(new Error("native handshake timed out"),
63
+ { code: "ETIMEDOUT" })), budget);
64
+ }),
65
+ ]);
66
+ const verdict = validateNativeHandshake(adapter, { clientVersion, platform, handshake });
67
+ if (!verdict.ok) {
68
+ return outcome(STATIC_REASONS.has(verdict.reasonCode) ? "unsupported" : "degraded",
69
+ verdict.reasonCode);
70
+ }
71
+ const now = Date.parse(clock.now());
72
+ const lease = Date.parse(verdict.leaseUntil);
73
+ if (!(lease > now)) return outcome("degraded", "handshake_failed");
74
+ const ceiling = now + 2 * heartbeatCadenceMs;
75
+ const leaseUntil = lease > ceiling ? new Date(ceiling).toISOString() : verdict.leaseUntil;
76
+ await service.publishDeliveryBinding({
77
+ sessionId, generation, adapterId: adapter.id, clientVersion,
78
+ availableModes: [...verdict.modes], livePolicy: policy,
79
+ opaqueEndpointRef: verdict.opaqueEndpointRef, leaseUntil,
80
+ });
81
+ return outcome("active", null, verdict.modes);
82
+ } catch (error) {
83
+ await clear();
84
+ const reasonCode = error?.code === "ETIMEDOUT" ? "handshake_timeout"
85
+ : error?.code === EXIT.CONFLICT ? "session_generation_stale" : "handshake_failed";
86
+ return outcome("degraded", reasonCode);
87
+ } finally {
88
+ if (timer !== null) clearTimeout(timer);
89
+ }
90
+ }