agents-can-communicate 0.1.18 → 0.2.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 (134) hide show
  1. package/README.md +78 -70
  2. package/SECURITY.md +31 -0
  3. package/bin/acc-hook.mjs +94 -12
  4. package/bin/acc-mcp.mjs +6 -2
  5. package/bin/acc.mjs +6 -1
  6. package/docs/ADAPTER_AUTHORING.md +172 -0
  7. package/docs/ARCHITECTURE.md +131 -0
  8. package/docs/CAPABILITIES.md +102 -214
  9. package/docs/CLI.md +157 -0
  10. package/docs/CONCEPTS.md +134 -0
  11. package/docs/CONFIGURATION.md +143 -0
  12. package/docs/DESIGN_DECISIONS.md +89 -0
  13. package/docs/GETTING_STARTED.md +145 -0
  14. package/docs/GLOSSARY.md +26 -0
  15. package/docs/MCP.md +94 -0
  16. package/docs/PROTOCOL.md +200 -0
  17. package/docs/RELEASING.md +109 -0
  18. package/docs/SECURITY_MODEL.md +131 -0
  19. package/docs/TROUBLESHOOTING.md +102 -0
  20. package/docs/WHY_ACC.md +61 -0
  21. package/docs/index.md +42 -0
  22. package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +78 -0
  23. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse-Edit.json +19 -0
  24. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse.json +17 -0
  25. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionEnd.json +8 -0
  26. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionStart.json +7 -0
  27. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/UserPromptSubmit.json +9 -0
  28. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +77 -0
  29. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.252.json +19 -0
  30. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +9 -1
  31. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +20 -22
  32. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +12 -4
  33. package/node_modules/@agents-can-communicate/adapter-codex/certification.json +117 -0
  34. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/PreToolUse.json +14 -0
  35. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionEnd.json +7 -0
  36. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionStart.json +9 -0
  37. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/UserPromptSubmit.json +10 -0
  38. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +66 -0
  39. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.0.json +19 -0
  40. package/node_modules/@agents-can-communicate/adapter-codex/package.json +8 -1
  41. package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +1 -1
  42. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +20 -22
  43. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +18 -11
  44. package/node_modules/@agents-can-communicate/adapter-gemini-cli/certification.json +52 -0
  45. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  46. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +20 -22
  47. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeAgent.json +8 -0
  48. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-shell.json +12 -0
  49. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool.json +12 -0
  50. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionEnd.json +8 -0
  51. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionStart.json +8 -0
  52. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/certification-provenance.json +66 -0
  53. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +8 -1
  54. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +7 -3
  55. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +4 -2
  56. package/node_modules/@agents-can-communicate/adapter-grok/certification.json +3 -0
  57. package/node_modules/@agents-can-communicate/adapter-grok/package.json +2 -1
  58. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +20 -22
  59. package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +9 -9
  60. package/node_modules/@agents-can-communicate/adapter-kimi/certification.json +52 -0
  61. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Bash.json +12 -0
  62. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Write.json +12 -0
  63. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionHeartbeat.json +7 -0
  64. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionStart.json +9 -0
  65. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/UserPromptSubmit.json +8 -0
  66. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/certification-provenance.json +66 -0
  67. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +8 -1
  68. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +20 -22
  69. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +7 -3
  70. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  71. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +34 -18
  72. package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +158 -0
  73. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +36 -17
  74. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +7 -1
  75. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -0
  76. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +13 -4
  77. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  78. package/node_modules/@agents-can-communicate/cli/src/args.mjs +11 -30
  79. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +3 -0
  80. package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -5
  81. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +9 -2
  82. package/node_modules/@agents-can-communicate/cli/src/main.mjs +100 -121
  83. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  84. package/node_modules/@agents-can-communicate/core/src/attention.mjs +106 -0
  85. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +276 -0
  86. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +81 -0
  87. package/node_modules/@agents-can-communicate/core/src/finish-retries.mjs +97 -0
  88. package/node_modules/@agents-can-communicate/core/src/inbox.mjs +91 -107
  89. package/node_modules/@agents-can-communicate/core/src/index.mjs +3 -3
  90. package/node_modules/@agents-can-communicate/core/src/intents.mjs +0 -1
  91. package/node_modules/@agents-can-communicate/core/src/ports.mjs +2 -1
  92. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +109 -0
  93. package/node_modules/@agents-can-communicate/core/src/service.mjs +11 -10
  94. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +22 -20
  95. package/node_modules/@agents-can-communicate/core/src/status.mjs +11 -9
  96. package/node_modules/@agents-can-communicate/core/src/sync.mjs +3 -294
  97. package/node_modules/@agents-can-communicate/delivery-router/package.json +12 -0
  98. package/node_modules/@agents-can-communicate/delivery-router/src/index.mjs +1 -0
  99. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +117 -0
  100. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  101. package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +20 -0
  102. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +115 -63
  103. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  104. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +23 -7
  105. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +20 -5
  106. package/node_modules/@agents-can-communicate/installer/src/index.mjs +3 -2
  107. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +108 -12
  108. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +19 -2
  109. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  110. package/node_modules/@agents-can-communicate/mcp-server/src/input-validator.mjs +79 -0
  111. package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +23 -28
  112. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +102 -72
  113. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +54 -97
  114. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  115. package/node_modules/@agents-can-communicate/protocol/src/config.mjs +1 -1
  116. package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +64 -0
  117. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +4 -1
  118. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +49 -90
  119. package/node_modules/@agents-can-communicate/protocol/src/states.mjs +13 -40
  120. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  121. package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +230 -0
  122. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +77 -28
  123. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +1 -1
  124. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +83 -35
  125. package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +112 -0
  126. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +18 -8
  127. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +68 -26
  128. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +113 -35
  129. package/package.json +19 -1
  130. package/node_modules/@agents-can-communicate/core/src/communication.mjs +0 -334
  131. package/node_modules/@agents-can-communicate/core/src/message-signals.mjs +0 -41
  132. package/node_modules/@agents-can-communicate/core/src/notify.mjs +0 -95
  133. package/node_modules/@agents-can-communicate/core/src/tasks.mjs +0 -244
  134. package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +0 -109
@@ -2,7 +2,7 @@ import { createHash, randomBytes } from "node:crypto";
2
2
  import { realpath } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
- import { clearSessionBinding, loadSessionBinding, storeSessionBinding }
5
+ import { clearSessionBinding, effectiveCapabilities, loadSessionBinding, storeSessionBinding }
6
6
  from "@agents-can-communicate/adapter-sdk";
7
7
  import { createCoordinationService } from "@agents-can-communicate/core";
8
8
  import { createId } from "@agents-can-communicate/protocol";
@@ -11,6 +11,7 @@ import { createGitProbe, discoverWorkspace, platformDataHome, runtimePaths }
11
11
  from "@agents-can-communicate/cli";
12
12
 
13
13
  import { resolveClientPid } from "./client-pid.mjs";
14
+ import { probeClientVersion as defaultProbeClientVersion } from "./client-version.mjs";
14
15
  import { readProcessTable as defaultReadProcessTable } from "./process-table.mjs";
15
16
 
16
17
  // Kept cohesive above 300 lines because every handler shares one fail-open
@@ -22,6 +23,28 @@ import { readProcessTable as defaultReadProcessTable } from "./process-table.mjs
22
23
  // let a call through than to make someone's session sit waiting on us.
23
24
  const DEFAULT_BUDGET_MS = 5_000;
24
25
 
26
+ const byteLength = value => Buffer.byteLength(value, "utf8");
27
+
28
+ function compactInboxRecovery(messages) {
29
+ const first = messages[0].messageId;
30
+ const rest = messages.length > 1 ? ` (+${messages.length - 1} more in \`acc inbox\`)` : "";
31
+ return `ACC: read pending peer message: \`acc inbox --message ${first}\`${rest}`;
32
+ }
33
+
34
+ function fitDegradation(projection, visibleDegradation, messages, budgetBytes) {
35
+ const full = [projection, visibleDegradation].filter(Boolean).join("\n");
36
+ if (byteLength(full) <= budgetBytes) return full;
37
+ const recovery = compactInboxRecovery(messages);
38
+ const exactRecovery = compactInboxRecovery(messages.slice(0, 1));
39
+ const withRecovery = [projection, recovery].filter(Boolean).join("\n");
40
+ if (byteLength(withRecovery) <= budgetBytes) return withRecovery;
41
+ const withExactRecovery = [projection, exactRecovery].filter(Boolean).join("\n");
42
+ if (byteLength(withExactRecovery) <= budgetBytes) return withExactRecovery;
43
+ if (byteLength(recovery) <= budgetBytes) return recovery;
44
+ if (byteLength(exactRecovery) <= budgetBytes) return exactRecovery;
45
+ return projection;
46
+ }
47
+
25
48
  // Declared by this process on the session it opens, so peers can tell an idle
26
49
  // session from a dead one. Only one of the four clients fires a heartbeat event,
27
50
  // so the rest refresh here: on every turn, and during a long one whenever the
@@ -163,8 +186,18 @@ async function openContext({ cwd, dataHome, runtime, env }) {
163
186
 
164
187
  const HANDLERS = {
165
188
  async sessionStart({ event, context, adapter, adapterId, binding, paths,
166
- readProcessTable }) {
167
- const capabilities = adapter.capabilities ?? {};
189
+ readProcessTable, probeClientVersion, platform, deadline }) {
190
+ // A repeated start refreshes the client's version/platform. Remove the old
191
+ // certified facts before any probe, PID lookup, resume, or open can fail;
192
+ // keep only the generation identity needed for a successful resume.
193
+ if (binding !== null) {
194
+ await storeSessionBinding({ runtimeDir: paths.root, harnessSessionId: event.sessionId,
195
+ accSessionId: binding.accSessionId, generation: binding.generation });
196
+ }
197
+ const clientVersion = await probeClientVersion(adapter,
198
+ { timeoutMs: Math.max(1, Math.min(1_000, deadline - Date.now())) });
199
+ const clientFacts = { clientVersion, platform };
200
+ const capabilities = effectiveCapabilities(adapter, clientFacts);
168
201
  // Once per session, never per turn. A client that cannot be found yields
169
202
  // null, and the session is then judged by age alone - which is exactly the
170
203
  // behaviour every session had before this existed.
@@ -186,7 +219,10 @@ const HANDLERS = {
186
219
  ...metadata,
187
220
  });
188
221
  if (resumed !== null) {
189
- return { accSessionId: resumed.sessionId, generation: resumed.generation };
222
+ await storeSessionBinding({ runtimeDir: paths.root, harnessSessionId: event.sessionId,
223
+ accSessionId: resumed.sessionId, generation: resumed.generation, ...clientFacts });
224
+ return { accSessionId: resumed.sessionId, generation: resumed.generation,
225
+ ...clientFacts, capabilities };
190
226
  }
191
227
  }
192
228
  const session = await context.service.openSession({
@@ -204,8 +240,9 @@ const HANDLERS = {
204
240
  descriptor: context.descriptor,
205
241
  });
206
242
  await storeSessionBinding({ runtimeDir: paths.root, harnessSessionId: event.sessionId,
207
- accSessionId: session.sessionId, generation: session.generation });
208
- return { accSessionId: session.sessionId, generation: session.generation };
243
+ accSessionId: session.sessionId, generation: session.generation, ...clientFacts });
244
+ return { accSessionId: session.sessionId, generation: session.generation,
245
+ ...clientFacts, capabilities };
209
246
  },
210
247
 
211
248
  async heartbeat({ binding, context }) {
@@ -223,7 +260,7 @@ const HANDLERS = {
223
260
  return {};
224
261
  },
225
262
 
226
- async beforeTurn({ binding, context, adapter }) {
263
+ async beforeTurn({ binding, context, adapter, adapterId }) {
227
264
  if (binding === null) return {};
228
265
  // A turn is the clearest sign a session is alive. Never a reason to fail:
229
266
  // this runs in front of somebody's prompt.
@@ -239,13 +276,14 @@ const HANDLERS = {
239
276
  .find(participant => participant.sessionId === binding.accSessionId);
240
277
 
241
278
  // What peers have said to this participant and no model has been shown yet.
242
- // Without this the projector's peer block never ran in production: an agent
243
- // saw only the subject of a message through its attention line, and the
244
- // `injected` delivery state was unreachable.
245
- const messages = await context.service.pendingMessages({
279
+ // Without this the projector's peer block never runs in production: an
280
+ // agent sees only the obligation attention line, not the durable body that
281
+ // may be offered after the stdout transport succeeds.
282
+ const delivery = await context.service.nextTurnDelivery({
246
283
  workspaceId: context.descriptor.id,
247
284
  participantId: mine?.participantId,
248
285
  exceptSessionId: binding.accSessionId });
286
+ const messages = delivery.queuedMessages;
249
287
 
250
288
  // Solo costs nothing: nothing to say means nothing printed, not a banner
251
289
  // announcing that nobody else is here. But something already said to you is
@@ -257,8 +295,12 @@ const HANDLERS = {
257
295
  // The ceiling a team agreed on in `acc.workspace.json`, or the default when
258
296
  // there is no config. Validated by the protocol and, until now, never read:
259
297
  // the projector was always called with its own default.
260
- const tracksDelivery = typeof adapter.renderContextResult === "function";
261
- const projectionInput = { ...sync, messages: tracksDelivery ? messages : [],
298
+ const effective = effectiveCapabilities(adapter, binding);
299
+ const hasStructuredRenderer = typeof adapter.renderContextResult === "function";
300
+ const canOfferNextTurn = effective.delivery.nextTurn === true && hasStructuredRenderer;
301
+ const projectionInput = { ...sync, messages: canOfferNextTurn ? messages : [],
302
+ liveOfferedMessageIds: delivery.liveOfferedMessageIds,
303
+ roomMessageIds: delivery.roomMessageIds,
262
304
  currentParticipantId: mine?.participantId };
263
305
  const projectionOptions = {
264
306
  budgetBytes: context.descriptor.policy?.contextBudgetBytes };
@@ -266,64 +308,49 @@ const HANDLERS = {
266
308
  // another message's visible header, so only projector metadata proves
267
309
  // which complete groups survived the byte budget. A custom adapter without
268
310
  // metadata may still inject text, but cannot advance a receipt from it.
269
- const projection = !tracksDelivery
311
+ const projection = !hasStructuredRenderer
270
312
  ? { text: await adapter.renderContext?.(projectionInput, projectionOptions) ?? "",
271
- includedMessageIds: [], includedAttentionIds: [] }
313
+ offeredMessageIds: [], includedAttentionIds: [] }
272
314
  : await adapter.renderContextResult(projectionInput, projectionOptions);
273
- const degradation = !tracksDelivery && messages.length > 0
274
- ? `acc: ${messages.length} pending message(s) withheld because this adapter lacks `
275
- + `structured delivery metadata; read ${messages[0].messageId} with `
276
- + `acc inbox --message ${messages[0].messageId}`
277
- : null;
315
+ const clientFactsKnown = typeof binding.clientVersion === "string"
316
+ && typeof binding.platform === "string";
317
+ const reason = !effective.delivery.nextTurn
318
+ ? clientFactsKnown
319
+ ? `client ${binding.clientVersion} on ${binding.platform} is not certified for nextTurn`
320
+ : "the client version or platform is unknown"
321
+ : !hasStructuredRenderer ? "this adapter lacks structured delivery metadata" : null;
322
+ const degradation = reason !== null && messages.length > 0
323
+ ? `acc: ${messages.length} pending message(s) withheld because ${reason}; read `
324
+ + `${messages[0].messageId} with acc inbox --message ${messages[0].messageId}` : null;
278
325
  const visibleDegradation = degradation === null ? "" : `ACC: ${degradation.slice(5)}`;
279
- const candidate = [projection.text, visibleDegradation].filter(Boolean).join("\n");
280
- const projected = Buffer.byteLength(candidate, "utf8")
281
- <= (projectionOptions.budgetBytes ?? 6_000) ? candidate : projection.text;
326
+ const budgetBytes = projectionOptions.budgetBytes ?? 6_000;
327
+ const projected = degradation === null ? projection.text
328
+ : fitDegradation(projection.text, visibleDegradation, messages, budgetBytes);
282
329
  if (projected === "") {
283
330
  return degradation === null ? { stdout: "" } : { stdout: "", stderr: degradation };
284
331
  }
285
332
 
286
- // Only what the model was actually shown is recorded as delivered. The
287
- // budget can leave a message out, and a receipt reading `injected` for text
288
- // nobody saw is worse than one still reading `queued` - the sender would be
289
- // told it landed. A message left behind stays queued and goes out next turn.
290
- const failures = [];
291
- const includedMessages = new Set(projection.includedMessageIds ?? []);
292
- for (const message of messages) {
293
- if (!includedMessages.has(message.messageId)) continue;
294
- await context.service.markDelivery({ sessionId: binding.accSessionId,
295
- generation: binding.generation, messageId: message.messageId,
296
- recipientParticipantId: mine.participantId, state: "injected" })
297
- .catch(error => failures.push(`${message.messageId}: ${error.message}`));
298
- }
299
- // A note carries no ack obligation, so after its one full showing it leaves
300
- // a single low-priority `unread_note` breadcrumb. Advancing that receipt
301
- // injected -> seen the turn the breadcrumb is shown is what makes it
302
- // one-shot: next turn the note reads `seen`, the breadcrumb stays quiet, and
303
- // a delivered decision is recoverable without becoming a standing nag - the
304
- // noise a reader learns to skip. Only what was actually shown is advanced,
305
- // for the same reason the loop above only records what fit.
306
- const includedAttention = new Set(projection.includedAttentionIds ?? []);
307
- for (const item of sync.attention ?? []) {
308
- if (item.kind !== "unread_note") continue;
309
- if (!includedAttention.has(item.sourceId)) continue;
310
- await context.service.markDelivery({ sessionId: binding.accSessionId,
311
- generation: binding.generation, messageId: item.sourceId,
312
- recipientParticipantId: mine.participantId, state: "seen" })
313
- .catch(error => failures.push(`${item.sourceId}: ${error.message}`));
314
- }
333
+ // The renderer returns ids as metadata, never as text to parse. A peer body
334
+ // can imitate every visible label, so only a complete group selected by the
335
+ // projector is eligible for the post-write offer commit.
336
+ const offered = new Set(canOfferNextTurn ? projection.offeredMessageIds ?? [] : []);
337
+ const offerInputs = messages.filter(message => offered.has(message.messageId))
338
+ .map(message => ({ messageId: message.messageId,
339
+ recipientParticipantId: mine.participantId,
340
+ targetSessionId: binding.accSessionId, targetGeneration: binding.generation,
341
+ transport: "next-turn", adapterId,
342
+ clientVersion: binding.clientVersion }));
315
343
  // Same again: Kimi Code shows the model a hook's raw stdout, while Gemini
316
344
  // and Claude Code want an envelope and drop a bare string.
317
- // Reported rather than swallowed. The context still goes out - losing it
318
- // over bookkeeping would be the worse trade - but a receipt that failed to
319
- // advance has to be visible somewhere, and stdout belongs to the model.
345
+ // The entry point owns the transport boundary. This handler only prepares
346
+ // offer inputs; recording them here would claim delivery before stdout's
347
+ // callback proves that the bytes crossed.
320
348
  const outcome = { stdout: "", ...adapter.injectOutcome?.(projected) };
321
- if (failures.length === 0 && degradation === null) return outcome;
349
+ const writableOffers = outcome.stdout === "" ? [] : offerInputs;
350
+ if (degradation === null) return { ...outcome, offerInputs: writableOffers };
322
351
  return { ...outcome,
323
- stderr: [outcome.stderr, degradation,
324
- failures.length === 0 ? null
325
- : `acc: delivery not recorded for ${failures.join(", ")}`]
326
- .filter(Boolean).join("\n") };
352
+ stderr: [outcome.stderr, degradation].filter(Boolean).join("\n"),
353
+ offerInputs: writableOffers };
327
354
  },
328
355
 
329
356
  async beforeTool({ binding, context, event, adapter }) {
@@ -394,8 +421,12 @@ const HANDLERS = {
394
421
  */
395
422
  export async function runHook({ adapterId, payload, adapters, dataHome, env,
396
423
  runtime = defaultRuntime(), budgetMs = DEFAULT_BUDGET_MS,
397
- readProcessTable = defaultReadProcessTable }) {
398
- const result = { stdout: "", exitCode: 0, decision: "allow", sessions: [] };
424
+ readProcessTable = defaultReadProcessTable,
425
+ probeClientVersion = defaultProbeClientVersion,
426
+ platform = `${process.platform}-${process.arch}` }) {
427
+ const deadline = Date.now() + budgetMs;
428
+ const result = { stdout: "", exitCode: 0, decision: "allow", sessions: [], deadlineAt: deadline,
429
+ commitOffers: async () => {} };
399
430
  let timer = null;
400
431
  try {
401
432
  const adapter = adapters?.[adapterId];
@@ -410,7 +441,7 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
410
441
  const work = handler === undefined
411
442
  ? Promise.resolve({})
412
443
  : handler({ event, context, adapter, adapterId, binding, paths: context.paths,
413
- readProcessTable });
444
+ readProcessTable, probeClientVersion, platform, deadline });
414
445
 
415
446
  // The loser of a race is not cancelled, so the timer is cleared explicitly:
416
447
  // an outstanding one keeps the process alive long past its answer.
@@ -419,6 +450,24 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
419
450
  });
420
451
  Object.assign(result, await Promise.race([work, budget]));
421
452
 
453
+ const offerInputs = result.offerInputs ?? [];
454
+ let commitPromise = null;
455
+ result.commitOffers = () => {
456
+ if (commitPromise !== null) return commitPromise;
457
+ commitPromise = (async () => {
458
+ for (const input of offerInputs) {
459
+ const remaining = deadline - Date.now();
460
+ if (remaining <= 0) throw new Error("hook budget exhausted before offer commit");
461
+ // The durable transaction owns deadline cancellation. Racing it here
462
+ // would only reject the public promise while the losing writer kept
463
+ // waiting and could publish later.
464
+ await context.service.recordOfferSucceeded({ ...input, deadlineAt: deadline });
465
+ }
466
+ })();
467
+ return commitPromise;
468
+ };
469
+ delete result.offerInputs;
470
+
422
471
  const status = await context.service.collectStatus({
423
472
  workspaceId: context.descriptor.id });
424
473
  result.sessions = status.participants.filter(p => p.presence !== "offline");
@@ -428,6 +477,9 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
428
477
  result.reason = error.message;
429
478
  result.decision = "allow";
430
479
  result.stdout = "";
480
+ // A later failure may happen after a turn prepared offer inputs. Once the
481
+ // fail-open path withdraws stdout, no transport boundary remains to commit.
482
+ result.commitOffers = async () => {};
431
483
  } finally {
432
484
  if (timer !== null) clearTimeout(timer);
433
485
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.1.18",
3
+ "version": "0.2.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": { ".": "./src/index.mjs" },
@@ -1,4 +1,5 @@
1
- import { recordInstall, removeOwned } from "./ownership.mjs";
1
+ import { finalizeRemoval, missingArtifactParents, recordInstall,
2
+ removeEmptyOwnedDirectories, removeOwnedArtifacts } from "./ownership.mjs";
2
3
 
3
4
  /**
4
5
  * Carry out a plan, one adapter at a time.
@@ -30,26 +31,41 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
30
31
 
31
32
  try {
32
33
  if (plan.action === "install") {
33
- const outcome = await adapter.install(context);
34
+ const createdDirectories = await missingArtifactParents({ home: context.home,
35
+ artifacts: operation.artifacts });
36
+ const installContext = { ...context,
37
+ requestedLivePolicy: operation.livePolicy ?? "off",
38
+ livePolicy: operation.effectiveLivePolicy ?? "off" };
39
+ const outcome = await adapter.install(installContext);
34
40
  // Recorded after the write, so a record never claims an install that
35
41
  // did not happen. The reverse order would leave uninstall trying to
36
42
  // remove files nothing created.
37
43
  await recordInstall({ dataHome, adapterId: adapter.id,
38
44
  version: operation.clientVersion ?? null, accVersion,
39
- artifacts: operation.artifacts });
45
+ artifacts: operation.artifacts, createdDirectories });
40
46
  results.operations.push({ ...operation, applied: true,
41
- changes: outcome.changes ?? [], diagnostics: outcome.diagnostics ?? [] });
47
+ changes: outcome.changes ?? [], diagnostics: [
48
+ ...(operation.deliveryDiagnostic === undefined
49
+ ? [] : [operation.deliveryDiagnostic]),
50
+ ...(outcome.diagnostics ?? []),
51
+ ] });
42
52
  } else {
43
- // Ownership first: it decides what may be deleted, and the adapter's own
44
- // uninstall then unpicks the entries it added to files the user owns.
45
- const owned = await removeOwned({ dataHome, adapterId: adapter.id });
53
+ // Keep the record until every cleanup step succeeds. It is both the
54
+ // authority for deletion and the only durable recipe a retry has when
55
+ // the client or one of ACC's own artifacts is already gone.
56
+ const owned = await removeOwnedArtifacts({ dataHome, adapterId: adapter.id });
46
57
  // What ownership held back is passed on, because the adapter would
47
58
  // otherwise remove its own layout unconditionally and undo the decision.
48
59
  // The case that matters: someone put their own work inside a directory
49
60
  // ACC created, and a recognised path is not a reason to delete it.
50
61
  const outcome = await adapter.uninstall({ ...context, keep: owned.kept });
62
+ const directories = await removeEmptyOwnedDirectories({ home: context.home,
63
+ directories: owned.createdDirectories });
64
+ await finalizeRemoval({ dataHome, adapterId: adapter.id });
51
65
  results.operations.push({ ...operation, applied: true,
52
66
  changes: outcome.changes ?? [], removed: owned.removed, kept: owned.kept,
67
+ removedDirectories: directories.removed, keptDirectories: directories.kept,
68
+ missingDirectories: directories.missing,
53
69
  diagnostics: outcome.diagnostics ?? [] });
54
70
  }
55
71
  } catch (error) {
@@ -1,6 +1,8 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
 
4
+ import { effectiveCapabilities } from "@agents-can-communicate/adapter-sdk";
5
+
4
6
  const run = promisify(execFile);
5
7
 
6
8
  const DEFAULT_PROBE_TIMEOUT_MS = 3_000;
@@ -9,7 +11,7 @@ const DEFAULT_PROBE_TIMEOUT_MS = 3_000;
9
11
  // "0.36.1", a banner with the number somewhere inside. The number is extracted
10
12
  // where it can be, and the raw line is kept either way - "present, version
11
13
  // unreadable" is a real state and hiding it would make the client look absent.
12
- const VERSION = /\b(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\b/;
14
+ const VERSION = /(?:^|[^0-9A-Za-z])v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)(?:\b|$)/;
13
15
 
14
16
  /**
15
17
  * Ask the operating system what a client reports as its version.
@@ -36,7 +38,8 @@ const withTimeout = (work, ms, label) => new Promise((resolve, reject) => {
36
38
  * letting it throw would hide the other three behind it.
37
39
  */
38
40
  export async function detectInstallation({ adapters, context, probe = spawnProbe,
39
- probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS }) {
41
+ probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
42
+ platform = `${process.platform}-${process.arch}` }) {
40
43
  const entries = await Promise.all([...adapters]
41
44
  // Ordered by id so two runs can be diffed, and so a plan built from this is
42
45
  // deterministic rather than dependent on registry order.
@@ -44,8 +47,8 @@ export async function detectInstallation({ adapters, context, probe = spawnProbe
44
47
  .map(async adapter => {
45
48
  const entry = { adapterId: adapter.id, displayName: adapter.displayName,
46
49
  present: false, version: null, versionOutput: null, installed: false,
47
- diagnostics: [], needsAction: [], capabilities: adapter.capabilities ?? {},
48
- error: null };
50
+ diagnostics: [], needsAction: [], capabilities: effectiveCapabilities(adapter),
51
+ deliveryDiagnostic: null, error: null };
49
52
 
50
53
  try {
51
54
  const output = await withTimeout(
@@ -62,10 +65,12 @@ export async function detectInstallation({ adapters, context, probe = spawnProbe
62
65
  } catch (error) {
63
66
  entry.error = error.message;
64
67
  }
68
+ entry.capabilities = effectiveCapabilities(adapter,
69
+ { clientVersion: entry.version, platform });
65
70
 
66
71
  try {
67
72
  const detected = await adapter.detect(context);
68
- entry.diagnostics = detected.diagnostics ?? [];
73
+ entry.diagnostics = [...(detected.diagnostics ?? [])];
69
74
  // What a person has to do, as opposed to what is true. Adapters that
70
75
  // have nothing to ask for say nothing.
71
76
  entry.needsAction = detected.needsAction ?? [];
@@ -77,6 +82,16 @@ export async function detectInstallation({ adapters, context, probe = spawnProbe
77
82
  } catch (error) {
78
83
  entry.error = entry.error ?? error.message;
79
84
  }
85
+ if (entry.capabilities?.delivery?.livePush !== true
86
+ && typeof adapter.deliveryFallback?.diagnostic === "string") {
87
+ const nextTurnDowngraded = adapter.capabilities?.delivery?.nextTurn === true
88
+ && entry.capabilities?.delivery?.nextTurn !== true;
89
+ entry.deliveryDiagnostic = nextTurnDowngraded
90
+ ? `${adapter.displayName} ${entry.version ?? "unknown version"} has no certified `
91
+ + `next-turn delivery on ${platform}; ${adapter.deliveryFallback.diagnostic}`
92
+ : adapter.deliveryFallback.diagnostic;
93
+ entry.diagnostics.push(entry.deliveryDiagnostic);
94
+ }
80
95
  return entry;
81
96
  }));
82
97
  return entries;
@@ -2,5 +2,6 @@
2
2
  export { detectInstallation, spawnProbe } from "./detect.mjs";
3
3
  export { planInstallation } from "./plan.mjs";
4
4
  export { applyPlan } from "./apply.mjs";
5
- export { fingerprint, loadOwnership, recordInstall, removeOwned, treeFingerprint,
6
- verifyOwned } from "./ownership.mjs";
5
+ export { finalizeRemoval, fingerprint, loadOwnership, missingArtifactParents, recordInstall,
6
+ removeEmptyOwnedDirectories, removeOwned, removeOwnedArtifacts, treeFingerprint, verifyOwned }
7
+ from "./ownership.mjs";
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { lstat, mkdir, readdir, readFile, rename, rm, rmdir, writeFile }
3
+ from "node:fs/promises";
3
4
  import path from "node:path";
4
5
 
5
6
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
@@ -109,7 +110,7 @@ async function saveOwnership({ dataHome, record }) {
109
110
  * runtime, and leaves the bundle inside the client exactly where it was.
110
111
  */
111
112
  export async function recordInstall({ dataHome, adapterId, version, accVersion = null,
112
- artifacts }) {
113
+ artifacts, createdDirectories = [] }) {
113
114
  const stamped = await Promise.all(artifacts.map(async artifact => ({
114
115
  path: artifact.path,
115
116
  kind: artifact.kind ?? "file",
@@ -118,14 +119,99 @@ export async function recordInstall({ dataHome, adapterId, version, accVersion =
118
119
  sha256: artifact.kind === "merge" ? null : await fingerprintFor(artifact),
119
120
  })));
120
121
  const record = await loadOwnership({ dataHome });
122
+ const previous = record.installs.find(install => install.adapterId === adapterId);
123
+ const directories = [...new Set([
124
+ ...(previous?.createdDirectories ?? []), ...createdDirectories,
125
+ ])].sort((left, right) => left.split(path.sep).length - right.split(path.sep).length
126
+ || left.localeCompare(right));
121
127
  await saveOwnership({ dataHome, record: { schemaVersion: SCHEMA_VERSION,
122
128
  installs: [...record.installs.filter(install => install.adapterId !== adapterId),
123
- { adapterId, version, accVersion, artifacts: stamped }] } });
129
+ { adapterId, version, accVersion, artifacts: stamped,
130
+ ...(directories.length === 0 ? {} : { createdDirectories: directories }) }] } });
124
131
  }
125
132
 
126
133
  const installFor = (record, adapterId) =>
127
134
  record.installs.find(install => install.adapterId === adapterId) ?? null;
128
135
 
136
+ const inside = (home, candidate) => {
137
+ const relative = path.relative(home, candidate);
138
+ return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`)
139
+ && !path.isAbsolute(relative);
140
+ };
141
+
142
+ async function hasSymlinkAncestor(home, candidate) {
143
+ let current = candidate;
144
+ while (inside(home, current)) {
145
+ const stat = await lstat(current).catch(error => {
146
+ if (error.code === "ENOENT") return null;
147
+ throw error;
148
+ });
149
+ if (stat?.isSymbolicLink()) return true;
150
+ current = path.dirname(current);
151
+ }
152
+ return false;
153
+ }
154
+
155
+ /** Return planned artifact parents that do not yet exist under the client home. */
156
+ export async function missingArtifactParents({ home, artifacts }) {
157
+ if (typeof home !== "string") return [];
158
+ const root = path.resolve(home);
159
+ const missing = new Set();
160
+ for (const artifact of artifacts) {
161
+ let directory = path.dirname(path.resolve(artifact.path));
162
+ while (inside(root, directory)) {
163
+ try {
164
+ await lstat(directory);
165
+ break;
166
+ } catch (error) {
167
+ if (error.code !== "ENOENT") throw error;
168
+ missing.add(directory);
169
+ directory = path.dirname(directory);
170
+ }
171
+ }
172
+ }
173
+ return [...missing].sort((left, right) =>
174
+ left.split(path.sep).length - right.split(path.sep).length || left.localeCompare(right));
175
+ }
176
+
177
+ /** Remove recorded parents deepest-first, but only while each remains an empty directory. */
178
+ export async function removeEmptyOwnedDirectories({ home, directories = [] }) {
179
+ const result = { removed: [], kept: [], missing: [] };
180
+ const root = typeof home === "string" ? path.resolve(home) : null;
181
+ const ordered = [...new Set(directories)].sort((left, right) =>
182
+ right.split(path.sep).length - left.split(path.sep).length || left.localeCompare(right));
183
+ for (const directory of ordered) {
184
+ if (root === null || !inside(root, path.resolve(directory))) {
185
+ result.kept.push(directory);
186
+ continue;
187
+ }
188
+ let stat;
189
+ try {
190
+ stat = await lstat(directory);
191
+ } catch (error) {
192
+ if (error.code === "ENOENT") { result.missing.push(directory); continue; }
193
+ throw error;
194
+ }
195
+ if (!stat.isDirectory() || stat.isSymbolicLink()
196
+ || await hasSymlinkAncestor(root, path.dirname(directory))) {
197
+ result.kept.push(directory);
198
+ continue;
199
+ }
200
+ try {
201
+ await rmdir(directory);
202
+ result.removed.push(directory);
203
+ } catch (error) {
204
+ if (["ENOTEMPTY", "EEXIST"].includes(error.code)) {
205
+ result.kept.push(directory);
206
+ continue;
207
+ }
208
+ if (error.code === "ENOENT") { result.missing.push(directory); continue; }
209
+ throw error;
210
+ }
211
+ }
212
+ return result;
213
+ }
214
+
129
215
  /** Compare what was written against what is there now. Read-only. */
130
216
  export async function verifyOwned({ dataHome, adapterId }) {
131
217
  const install = installFor(await loadOwnership({ dataHome }), adapterId);
@@ -141,17 +227,12 @@ export async function verifyOwned({ dataHome, adapterId }) {
141
227
  return result;
142
228
  }
143
229
 
144
- /**
145
- * Remove the files this adapter's install wrote, and only those.
146
- *
147
- * A modified file is kept and reported. A merge artifact is never deleted at
148
- * all: the user owns that file and ACC owns some entries inside it, which is the
149
- * adapter's own uninstall to unpick because it knows the format.
150
- */
151
- export async function removeOwned({ dataHome, adapterId }) {
230
+ /** Remove owned artifacts while retaining the record as retry authority. */
231
+ export async function removeOwnedArtifacts({ dataHome, adapterId }) {
152
232
  const record = await loadOwnership({ dataHome });
153
233
  const install = installFor(record, adapterId);
154
- const result = { adapterId, removed: [], kept: [], missing: [], delegated: [] };
234
+ const result = { adapterId, removed: [], kept: [], missing: [], delegated: [],
235
+ createdDirectories: install?.createdDirectories ?? [] };
155
236
  if (install === null) return result;
156
237
 
157
238
  for (const artifact of install.artifacts) {
@@ -163,7 +244,22 @@ export async function removeOwned({ dataHome, adapterId }) {
163
244
  result.removed.push(artifact.path);
164
245
  }
165
246
 
247
+ return result;
248
+ }
249
+
250
+ /** Forget one install only after every adapter-owned cleanup step succeeded. */
251
+ export async function finalizeRemoval({ dataHome, adapterId }) {
252
+ const record = await loadOwnership({ dataHome });
253
+ if (installFor(record, adapterId) === null) return false;
254
+
166
255
  await saveOwnership({ dataHome, record: { schemaVersion: SCHEMA_VERSION,
167
256
  installs: record.installs.filter(entry => entry.adapterId !== adapterId) } });
257
+ return true;
258
+ }
259
+
260
+ /** Remove and finalize for callers that perform no delegated adapter cleanup. */
261
+ export async function removeOwned(options) {
262
+ const result = await removeOwnedArtifacts(options);
263
+ await finalizeRemoval(options);
168
264
  return result;
169
265
  }
@@ -8,10 +8,14 @@ import { AccError, EXIT } from "@agents-can-communicate/protocol";
8
8
  * it previews is a decoration, and the operator would find out only afterwards.
9
9
  */
10
10
  export function planInstallation({ adapters, detected, context, action = "install",
11
- recorded = [], accVersion = null, allowDowngrade = false, requested = [] }) {
11
+ recorded = [], accVersion = null, allowDowngrade = false, requested = [],
12
+ delivery = "off" }) {
12
13
  if (!["install", "uninstall"].includes(action)) {
13
14
  throw new AccError(EXIT.USAGE, `unknown installation action: ${action}`, { action });
14
15
  }
16
+ if (!["off", "actionable", "all"].includes(delivery)) {
17
+ throw new AccError(EXIT.USAGE, `unknown delivery policy: ${delivery}`, { delivery });
18
+ }
15
19
  const byId = new Map(adapters.map(adapter => [adapter.id, adapter]));
16
20
  // What ACC recorded writing, by client. For an uninstall this is the
17
21
  // authority rather than detection: the record is the only account of what was
@@ -82,7 +86,16 @@ export function planInstallation({ adapters, detected, context, action = "instal
82
86
  // From the record when the client is gone, because that is what was written
83
87
  // and so what will be removed. Asking the adapter instead would describe an
84
88
  // install for a machine this one no longer is.
85
- const artifacts = (record?.artifacts ?? adapter.planInstall(context))
89
+ const liveDeliverySupported = entry.capabilities?.delivery?.livePush === true;
90
+ const effectiveLivePolicy = liveDeliverySupported ? delivery : "off";
91
+ const deliveryDiagnostic = action === "install" && delivery !== "off"
92
+ && !liveDeliverySupported
93
+ ? entry.deliveryDiagnostic ?? adapter.deliveryFallback?.diagnostic
94
+ ?? `${adapter.displayName ?? adapter.id} has no certified live delivery for this client; durable fallback remains active`
95
+ : null;
96
+ const installContext = { ...context, requestedLivePolicy: delivery,
97
+ livePolicy: effectiveLivePolicy };
98
+ const artifacts = (record?.artifacts ?? adapter.planInstall(installContext))
86
99
  .map(artifact => ({ path: artifact.path, kind: artifact.kind ?? "file" }))
87
100
  .sort((a, b) => a.path.localeCompare(b.path));
88
101
 
@@ -96,12 +109,16 @@ export function planInstallation({ adapters, detected, context, action = "instal
96
109
  // to be inferred from a client version that is null.
97
110
  clientPresent: entry.present === true,
98
111
  alreadyInstalled: entry.installed === true,
112
+ livePolicy: delivery,
113
+ effectiveLivePolicy,
114
+ ...(deliveryDiagnostic === null ? {} : { deliveryDiagnostic }),
99
115
  artifacts,
100
116
  // Said in the operator's terms, not in paths: which files ACC creates
101
117
  // outright and which belong to the user and are only edited.
102
118
  summary: [
103
119
  ...(entry.present ? [] : [`${adapter.displayName ?? adapter.id} is no longer on `
104
120
  + "this machine; removing what ACC recorded writing"]),
121
+ ...(deliveryDiagnostic === null ? [] : [deliveryDiagnostic]),
105
122
  ...artifacts.filter(a => a.kind === "tree")
106
123
  .map(a => `${action === "install" ? "create" : "remove"} ${a.path}`),
107
124
  ...artifacts.filter(a => a.kind === "merge")