agents-can-communicate 0.2.0 → 0.3.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 (96) hide show
  1. package/README.md +91 -81
  2. package/SECURITY.md +7 -4
  3. package/bin/acc-bootstrap.mjs +56 -0
  4. package/bin/acc-claude-channel.mjs +177 -0
  5. package/bin/acc.mjs +7 -2
  6. package/docs/ADAPTER_AUTHORING.md +57 -21
  7. package/docs/ARCHITECTURE.md +23 -15
  8. package/docs/CAPABILITIES.md +51 -18
  9. package/docs/CLI.md +31 -13
  10. package/docs/CONCEPTS.md +91 -94
  11. package/docs/CONFIGURATION.md +33 -23
  12. package/docs/DESIGN_DECISIONS.md +8 -4
  13. package/docs/GETTING_STARTED.md +76 -100
  14. package/docs/GLOSSARY.md +4 -4
  15. package/docs/HOW_IT_WORKS.md +163 -0
  16. package/docs/MCP.md +8 -8
  17. package/docs/PROTOCOL.md +13 -8
  18. package/docs/RELEASING.md +112 -82
  19. package/docs/SECURITY_MODEL.md +13 -8
  20. package/docs/TROUBLESHOOTING.md +30 -12
  21. package/docs/WHY_ACC.md +18 -16
  22. package/docs/index.md +26 -16
  23. package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +196 -46
  24. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +237 -45
  25. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.252.json +3 -1
  26. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.258.json +23 -0
  27. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.260.json +23 -0
  28. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +5 -2
  29. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.mcp.json +8 -0
  30. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +10 -6
  31. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +34 -2
  32. package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +377 -0
  33. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +27 -7
  34. package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +229 -0
  35. package/node_modules/@agents-can-communicate/adapter-codex/certification.json +38 -5
  36. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +171 -38
  37. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.0.json +3 -1
  38. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-remote-workspace.json +25 -0
  39. package/node_modules/@agents-can-communicate/adapter-codex/package.json +4 -2
  40. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +9 -5
  41. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +42 -7
  42. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +121 -0
  43. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +151 -0
  44. package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +192 -0
  45. package/node_modules/@agents-can-communicate/adapter-gemini-cli/certification.json +44 -28
  46. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  47. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +9 -5
  48. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{BeforeAgent.json → BeforeAgent-0.57.0.json} +2 -2
  49. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{BeforeTool.json → BeforeTool-0.57.0.json} +2 -2
  50. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{BeforeTool-shell.json → BeforeTool-shell-0.57.0.json} +2 -2
  51. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{SessionEnd.json → SessionEnd-0.57.0.json} +2 -2
  52. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/{SessionStart.json → SessionStart-0.57.0.json} +2 -2
  53. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/certification-provenance.json +266 -39
  54. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +6 -6
  55. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +25 -11
  56. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  57. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +9 -5
  58. package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +3 -1
  59. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  60. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +9 -5
  61. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  62. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +18 -0
  63. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +2 -0
  64. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +6 -2
  65. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-activation.mjs +76 -0
  66. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +202 -0
  67. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +101 -0
  68. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +17 -2
  69. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  70. package/node_modules/@agents-can-communicate/cli/src/args.mjs +2 -2
  71. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +67 -5
  72. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +133 -11
  73. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  74. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +41 -1
  75. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +52 -2
  76. package/node_modules/@agents-can-communicate/core/src/service.mjs +10 -0
  77. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  78. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +26 -12
  79. package/node_modules/@agents-can-communicate/hook-runner/package.json +4 -2
  80. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +90 -0
  81. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +123 -90
  82. package/node_modules/@agents-can-communicate/installer/package.json +4 -2
  83. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +48 -4
  84. package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +144 -0
  85. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +72 -3
  86. package/node_modules/@agents-can-communicate/installer/src/index.mjs +7 -0
  87. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +161 -0
  88. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +6 -2
  89. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +41 -6
  90. package/node_modules/@agents-can-communicate/installer/src/shell-bootstrap.mjs +210 -0
  91. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  92. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  93. package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +17 -0
  94. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +18 -3
  95. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  96. 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, 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,111 @@ 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
+ /**
196
+ * One default-No question per eligible client, asked only after detection has
197
+ * finished and only when a person is at both ends of the terminal.
198
+ *
199
+ * Written for someone who has never heard of this project. It used to open with
200
+ * "Enable native live delivery" over a list of internal artefact names, which
201
+ * told a first-time reader neither what they would gain nor what it would cost -
202
+ * and left out the part that matters most: saying yes makes their own `claude`
203
+ * start with the vendor's `--dangerously-load-development-channels` flag. A
204
+ * consent prompt that hides the loudest consequence is not consent.
205
+ *
206
+ * So the order is the reader's, not the installer's: what changes for them,
207
+ * what it costs, what it touches, and that "no" costs them nothing.
208
+ */
209
+ function questionFor(entry, context, { onlyEligible = false } = {}) {
210
+ const bootstrap = entry.nativeDelivery.activationPlan.mechanisms
211
+ .find(mechanism => mechanism.kind === "shell-bootstrap") ?? null;
212
+ // The presence of a flag is what makes this worth asking about at all: it is
213
+ // why the client will warn at every start from now on. Which flag it is, and
214
+ // that a shim adds it, are answers to a question nobody is asking here - the
215
+ // shim `exec`s and is gone, so nothing runs "through" acc afterwards either.
216
+ const flags = (bootstrap?.prefixArgs ?? []).filter(argument => argument.startsWith("--"));
217
+ void context;
218
+ // Why the default is No, and why only one client is being asked about, are
219
+ // the two questions the bare prompt provokes and never answered: a `[y/N]`
220
+ // reads as "not recommended" on its own, and a single question among four
221
+ // installed clients reads as an omission. Both have the same short answer.
222
+ const aside = ["experimental", ...(onlyEligible ? [`${entry.displayName} only`] : [])]
223
+ .join("; ");
224
+ return [
225
+ `Let idle agents answer each other while you are away? (${aside})`,
226
+ ...(flags.length === 0 || bootstrap === null
227
+ ? [" Yes: they reply without waiting for you."]
228
+ : [` Yes: they reply without waiting for you, and ${entry.displayName} asks you`,
229
+ " to allow development channels every time it starts."]),
230
+ " No: messages still arrive, at the session's next turn.",
231
+ ].join("\n");
232
+ }
233
+
234
+ /**
235
+ * Which live policy each selected client gets.
236
+ *
237
+ * An explicit --delivery applies uniformly and never prompts. Otherwise a
238
+ * recorded opt-in is kept as it was consented to, a fresh or never-activated
239
+ * client is off unless a person answers yes here, and a dry run or a
240
+ * non-interactive stdin/stdout makes no interactive choice at all.
241
+ */
242
+ export async function decideDelivery({ options, detected, recorded, runtime, dryRun, context }) {
243
+ const explicit = options.delivery;
244
+ if (explicit !== undefined && !LIVE_POLICIES.includes(explicit)) {
245
+ throw new AccError(EXIT.USAGE, `unknown delivery policy: ${explicit}`, { delivery: explicit });
246
+ }
247
+ const recordedById = new Map(recorded.map(install => [install.adapterId, install]));
248
+ // Whether this client is the only one that could be asked at all. When it is,
249
+ // the question says so, because otherwise being asked about one of four
250
+ // installed clients looks like the other three were forgotten.
251
+ const onlyEligible = detected
252
+ .filter(candidate => candidate.nativeDelivery?.state === "eligible").length === 1;
253
+ const deliveryByAdapter = {};
254
+ const asked = [];
255
+ let withheld = 0;
256
+ for (const entry of detected) {
257
+ const eligible = entry.nativeDelivery?.state === "eligible";
258
+ const previous = livePolicyOf(recordedById.get(entry.adapterId));
259
+ if (explicit !== undefined) {
260
+ deliveryByAdapter[entry.adapterId] = explicit;
261
+ } else if (previous !== "off") {
262
+ deliveryByAdapter[entry.adapterId] = previous;
263
+ } else if (!eligible || dryRun || !isInteractive(runtime)) {
264
+ deliveryByAdapter[entry.adapterId] = "off";
265
+ if (eligible) withheld += 1;
266
+ } else {
267
+ const yes = await runtime.confirm(questionFor(entry, context, { onlyEligible }),
268
+ { input: runtime.input, output: runtime.output }) === true;
269
+ deliveryByAdapter[entry.adapterId] = yes ? "actionable" : "off";
270
+ asked.push(entry.adapterId);
271
+ }
272
+ }
273
+ const notes = explicit === undefined && dryRun && withheld > 0
274
+ ? ["interactive choices were not made: this preview keeps native delivery off for "
275
+ + `${withheld} eligible client(s) without a recorded opt-in`]
276
+ : [];
277
+ return { deliveryByAdapter, asked, notes };
278
+ }
279
+
280
+ // Said once, after the first PATH block is written: a running shell and a
281
+ // running client know nothing about it.
282
+ function reloadAdvice(operations) {
283
+ const appended = operations.filter(operation => operation.appendedRcBlock === true);
284
+ if (appended.length === 0) return [];
285
+ const rc = appended[0].nativeActivation?.rcFile ?? "your shell rc file";
286
+ return ["", `native delivery is wired for new sessions: open a new terminal (or reload ${rc}), `
287
+ + "then start the client normally; a session already running keeps durable delivery"];
288
+ }
289
+
177
290
  export async function runInstallCommand({ options, runtime, action = "install" }) {
178
291
  const adapters = selectAdapters(options.adapter);
179
292
  const home = options.home ?? runtime.env?.HOME ?? homedir();
180
293
  const { data: dataHome } = platformPaths({ platform: runtime.platform,
181
294
  env: runtime.env ?? {} });
182
- const context = clientContext(home, path.join(dataHome, "acc"));
295
+ const context = clientContext(home, path.join(dataHome, "acc"),
296
+ { shell: shellOf(runtime.env), env: runtime.env ?? {} });
183
297
 
184
298
  const detected = await detectInstallation({ adapters, context,
185
299
  probeTimeoutMs: probeTimeout(runtime.env) });
@@ -205,20 +319,28 @@ export async function runInstallCommand({ options, runtime, action = "install" }
205
319
  const requested = options.adapter === undefined
206
320
  ? []
207
321
  : (Array.isArray(options.adapter) ? options.adapter : [options.adapter]);
322
+ // Decided only after detection, from the operator's explicit answer, the
323
+ // recorded consent, or a default of off; never from inference.
324
+ const decided = action === "install"
325
+ ? await decideDelivery({ options, detected, recorded, runtime, dryRun, context })
326
+ : { deliveryByAdapter: {}, asked: [], notes: [] };
208
327
  const plan = planInstallation({ adapters, detected, context, action, recorded,
209
328
  accVersion, allowDowngrade: options.downgrade === true, requested,
210
- delivery: options.delivery ?? "off" });
329
+ deliveryByAdapter: decided.deliveryByAdapter });
211
330
  const result = await applyPlan({ plan, adapters, context, dataHome, dryRun, accVersion });
212
331
 
213
332
  const acted = actedOn(result);
214
333
  if (dryRun) {
215
- return { data: { ...result, plan, dataHome },
334
+ return { data: { ...result, plan, dataHome, deliveryByAdapter: decided.deliveryByAdapter },
216
335
  text: [`would ${action}:`, ...plan.operations.flatMap(operation => operation.summary),
217
- ...plan.skipped.map(entry => `skip ${entry.adapterId}: ${entry.reason}`)].join("\n") };
336
+ ...plan.skipped.map(entry => `skip ${entry.adapterId}: ${entry.reason}`),
337
+ ...decided.notes].join("\n") };
218
338
  }
219
339
 
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 }) };
340
+ return { data: { ...result, plan, dataHome, deliveryByAdapter: decided.deliveryByAdapter,
341
+ asked: decided.asked },
342
+ text: [describeOutcome({ action, acted, failed: result.failed,
343
+ skipped: plan.skipped, operations: result.operations, home }),
344
+ ...reloadAdvice(result.operations)].join("\n"),
345
+ error: failureOf({ action, acted, failed: result.failed }) };
224
346
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -55,6 +55,41 @@ function assertCurrentSession(tx, session, action) {
55
55
  }
56
56
  }
57
57
 
58
+ /**
59
+ * Turn what the sender typed into participant ids that exist.
60
+ *
61
+ * An exact participant id is always taken as written. A name that matches no
62
+ * participant is tried as a client name - `codex`, `claude_code` - because that
63
+ * is what an agent knows about its peers: the roster it is handed names the
64
+ * client, and asking it to first look up `codex-fbqX8o` is a lookup it
65
+ * routinely skips. Every shipped example taught this form while nothing
66
+ * implemented it, so an agent following its own skill was refused on its first
67
+ * attempt to reach anyone.
68
+ *
69
+ * One live session of that client resolves. Two refuse and name them, the way
70
+ * `--session` already refuses to choose between two sessions of one client:
71
+ * guessing is how a question is answered by a session that never had the
72
+ * context. The sender's own client is excluded, or the one client an agent is
73
+ * certain is running becomes the easiest thing to address by accident.
74
+ */
75
+ function resolveAddressees(tx, requested, session) {
76
+ const known = new Set(tx.list("participant").map(item => item.participantId));
77
+ return requested.map(name => {
78
+ if (known.has(name)) return name;
79
+ const peers = [...new Set(tx.list("session", item => item.state === "open"
80
+ && item.harness === name && item.participantId !== session.participantId)
81
+ .map(item => item.participantId))].sort();
82
+ if (peers.length === 1) return peers[0];
83
+ if (peers.length > 1) {
84
+ throw new AccError(EXIT.USAGE,
85
+ `could not tell which of ${peers.length} ${name} sessions you meant: `
86
+ + `${peers.join(", ")}. Name one of them, which \`acc status --json\` lists.`,
87
+ { client: name, candidates: peers });
88
+ }
89
+ return name;
90
+ });
91
+ }
92
+
58
93
  function addressedRecipients(tx, message, session) {
59
94
  if (message.toParticipantIds.length === 0) {
60
95
  return [...new Set(tx.list("session", item => item.state === "open"
@@ -86,9 +121,14 @@ function threadFor(tx, messageId, inReplyTo) {
86
121
  return parent.threadId;
87
122
  }
88
123
 
89
- export function recordMessageInTransaction({ tx, session, input, now, messageId, ids,
124
+ export function recordMessageInTransaction({ tx, session, input: raw, now, messageId, ids,
90
125
  action = "send a message" }) {
91
126
  assertCurrentSession(tx, session, action);
127
+ // Resolved before anything else reads the recipients, so the stored message
128
+ // names real participants and a retry compares against the same content. A
129
+ // record that kept the client name would address nobody when it is read back.
130
+ const input = { ...raw,
131
+ toParticipantIds: resolveAddressees(tx, raw.toParticipantIds ?? [], session) };
92
132
  const existing = existingMessage(tx, session, input);
93
133
  if (existing !== undefined) {
94
134
  return { message: existing, recipientParticipantIds: [], created: false };
@@ -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.1",
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.1",
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
+ }