agents-can-communicate 0.1.17 → 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 (137) hide show
  1. package/README.md +76 -138
  2. package/SECURITY.md +31 -0
  3. package/bin/acc-hook.mjs +96 -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 +105 -197
  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 +80 -160
  32. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +15 -5
  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 +80 -160
  43. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +21 -12
  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 +80 -160
  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 +10 -4
  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 +14 -0
  58. package/node_modules/@agents-can-communicate/adapter-grok/plugin/hooks/hooks.json +61 -0
  59. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +152 -0
  60. package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +61 -0
  61. package/node_modules/@agents-can-communicate/adapter-grok/src/hooks.mjs +127 -0
  62. package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +101 -0
  63. package/node_modules/@agents-can-communicate/adapter-kimi/certification.json +52 -0
  64. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Bash.json +12 -0
  65. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Write.json +12 -0
  66. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionHeartbeat.json +7 -0
  67. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionStart.json +9 -0
  68. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/UserPromptSubmit.json +8 -0
  69. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/certification-provenance.json +66 -0
  70. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +8 -1
  71. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +80 -160
  72. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +10 -4
  73. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  74. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +34 -18
  75. package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +158 -0
  76. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +139 -224
  77. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +7 -1
  78. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +2 -1
  79. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +13 -4
  80. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  81. package/node_modules/@agents-can-communicate/cli/src/args.mjs +13 -29
  82. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +3 -0
  83. package/node_modules/@agents-can-communicate/cli/src/help.mjs +5 -6
  84. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +12 -3
  85. package/node_modules/@agents-can-communicate/cli/src/main.mjs +109 -109
  86. package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +1 -1
  87. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  88. package/node_modules/@agents-can-communicate/core/src/attention.mjs +106 -0
  89. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +276 -0
  90. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +81 -0
  91. package/node_modules/@agents-can-communicate/core/src/finish-retries.mjs +97 -0
  92. package/node_modules/@agents-can-communicate/core/src/inbox.mjs +118 -0
  93. package/node_modules/@agents-can-communicate/core/src/index.mjs +3 -2
  94. package/node_modules/@agents-can-communicate/core/src/intents.mjs +0 -1
  95. package/node_modules/@agents-can-communicate/core/src/ports.mjs +3 -2
  96. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +109 -0
  97. package/node_modules/@agents-can-communicate/core/src/service.mjs +14 -10
  98. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +70 -20
  99. package/node_modules/@agents-can-communicate/core/src/status.mjs +11 -9
  100. package/node_modules/@agents-can-communicate/core/src/sync.mjs +3 -258
  101. package/node_modules/@agents-can-communicate/delivery-router/package.json +12 -0
  102. package/node_modules/@agents-can-communicate/delivery-router/src/index.mjs +1 -0
  103. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +117 -0
  104. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  105. package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +20 -0
  106. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +156 -60
  107. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  108. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +23 -7
  109. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +20 -5
  110. package/node_modules/@agents-can-communicate/installer/src/index.mjs +3 -2
  111. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +108 -12
  112. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +19 -2
  113. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  114. package/node_modules/@agents-can-communicate/mcp-server/src/input-validator.mjs +79 -0
  115. package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +23 -28
  116. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +109 -71
  117. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +74 -93
  118. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  119. package/node_modules/@agents-can-communicate/protocol/src/config.mjs +1 -1
  120. package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +64 -0
  121. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +4 -1
  122. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +49 -90
  123. package/node_modules/@agents-can-communicate/protocol/src/states.mjs +13 -40
  124. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  125. package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +230 -0
  126. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +77 -28
  127. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +1 -1
  128. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +83 -35
  129. package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +112 -0
  130. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +18 -8
  131. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +86 -28
  132. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +121 -27
  133. package/package.json +22 -1
  134. package/node_modules/@agents-can-communicate/core/src/communication.mjs +0 -334
  135. package/node_modules/@agents-can-communicate/core/src/notify.mjs +0 -95
  136. package/node_modules/@agents-can-communicate/core/src/tasks.mjs +0 -244
  137. package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +0 -109
@@ -0,0 +1,117 @@
1
+ import { effectiveCapabilities } from "@agents-can-communicate/adapter-sdk";
2
+
3
+ const PLATFORM = `${process.platform}-${process.arch}`;
4
+ const SAFE_ERRORS = new Set(["ambiguous_recipient_sessions", "delivery_disabled",
5
+ "recipient_busy", "recipient_unavailable", "transport_error", "transport_rejected",
6
+ "unsupported_client_version"]);
7
+ const NAMED_LIVE_TRANSPORTS = new Set(["claude-channel", "codex-app-server"]);
8
+
9
+ const adaptersById = adapters => adapters instanceof Map
10
+ ? adapters
11
+ : new Map((Array.isArray(adapters) ? adapters : Object.values(adapters ?? {}))
12
+ .map(adapter => [adapter.id, adapter]));
13
+
14
+ const permits = (policy, kind) => policy === "all"
15
+ || (policy === "actionable" && ["question", "request", "handoff"].includes(kind));
16
+
17
+ const durable = (recipientParticipantId, errorCode) => ({ recipientParticipantId,
18
+ outcome: "queued", transport: "durable", errorCode });
19
+
20
+ const settled = receipt => ({ recipientParticipantId: receipt.recipientParticipantId,
21
+ outcome: receipt.state, transport: "durable" });
22
+
23
+ function safeTransport(value, opaqueEndpointRef) {
24
+ if (NAMED_LIVE_TRANSPORTS.has(value) && value !== opaqueEndpointRef) return value;
25
+ // Both markers are fixed router vocabulary. The alternate prevents even a
26
+ // coincidental endpoint value equal to the primary redaction from escaping.
27
+ return opaqueEndpointRef === "live-adapter" ? "native-live" : "live-adapter";
28
+ }
29
+
30
+ export function createDeliveryRouter({ service, adapters, clock }) {
31
+ const registry = adaptersById(adapters);
32
+
33
+ async function recordFailure(binding, message, participantId, transport, safeErrorCode) {
34
+ await service.recordOfferFailed({ messageId: message.messageId,
35
+ recipientParticipantId: participantId, targetSessionId: binding.sessionId,
36
+ targetGeneration: binding.generation,
37
+ transport: safeTransport(transport, binding.opaqueEndpointRef), adapterId: binding.adapterId,
38
+ clientVersion: binding.clientVersion, safeErrorCode }).catch(() => null);
39
+ }
40
+
41
+ async function offerTo(message, participantId, now) {
42
+ const receipt = await service.readReceipt({ messageId: message.messageId,
43
+ recipientParticipantId: participantId });
44
+ if (receipt.state !== "queued") return settled(receipt);
45
+ const liveSessions = await service.listLiveSessions({ participantId, now });
46
+ if (liveSessions.length === 0) return durable(participantId, "recipient_unavailable");
47
+ if (liveSessions.length > 1) {
48
+ return durable(participantId, "ambiguous_recipient_sessions");
49
+ }
50
+ const [target] = liveSessions;
51
+ const bindings = (await service.listDeliveryBindings({ participantId, now }))
52
+ .filter(binding => binding.sessionId === target.sessionId
53
+ && binding.generation === target.generation);
54
+ if (bindings.length === 0) return durable(participantId, "recipient_unavailable");
55
+ const permitted = bindings.filter(binding => binding.livePolicy !== "off"
56
+ && permits(binding.livePolicy, message.kind));
57
+ if (permitted.length === 0) return durable(participantId, "delivery_disabled");
58
+ const reachable = permitted.filter(binding => binding.availableModes.includes("livePush"));
59
+ if (reachable.length === 0) return durable(participantId, "recipient_unavailable");
60
+ const certified = reachable.map(binding => ({ binding,
61
+ 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) {
66
+ return durable(participantId, "unsupported_client_version");
67
+ }
68
+ if (certified.length > 1) {
69
+ return durable(participantId, "ambiguous_recipient_sessions");
70
+ }
71
+
72
+ const { binding, adapter } = certified[0];
73
+ let response;
74
+ try {
75
+ response = await adapter.offerMessage({ binding, message });
76
+ } catch {
77
+ await recordFailure(binding, message, participantId, "live-adapter", "transport_error");
78
+ return durable(participantId, "transport_error");
79
+ }
80
+ const transport = safeTransport(response?.transport, binding.opaqueEndpointRef);
81
+ if (response?.accepted !== true) {
82
+ const code = SAFE_ERRORS.has(response?.safeErrorCode)
83
+ ? response.safeErrorCode : "transport_rejected";
84
+ await recordFailure(binding, message, participantId, transport, code);
85
+ return durable(participantId, code);
86
+ }
87
+ if (response.clientVersion !== binding.clientVersion) {
88
+ await recordFailure(binding, message, participantId, transport,
89
+ "unsupported_client_version");
90
+ return durable(participantId, "unsupported_client_version");
91
+ }
92
+ try {
93
+ await service.recordOfferSucceeded({ messageId: message.messageId,
94
+ recipientParticipantId: participantId, targetSessionId: binding.sessionId,
95
+ targetGeneration: binding.generation, transport, adapterId: binding.adapterId,
96
+ clientVersion: binding.clientVersion });
97
+ } catch {
98
+ await recordFailure(binding, message, participantId, transport, "transport_error");
99
+ return durable(participantId, "transport_error");
100
+ }
101
+ return { recipientParticipantId: participantId, outcome: "offered", transport };
102
+ }
103
+
104
+ async function offer(message) {
105
+ if (!Array.isArray(message?.toParticipantIds) || message.toParticipantIds.length === 0) {
106
+ return [];
107
+ }
108
+ const now = clock.now();
109
+ const outcomes = [];
110
+ for (const participantId of message.toParticipantIds) {
111
+ outcomes.push(await offerTo(message, participantId, now));
112
+ }
113
+ return outcomes;
114
+ }
115
+
116
+ return Object.freeze({ offer });
117
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.1.17",
3
+ "version": "0.2.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,20 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ const VERSION = /(?:^|[^0-9A-Za-z])v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)(?:\b|$)/;
4
+
5
+ export function parseClientVersion(output) {
6
+ return typeof output === "string" ? VERSION.exec(output)?.[1] ?? null : null;
7
+ }
8
+
9
+ /** Probe the client binary once when its real session starts. */
10
+ export function probeClientVersion(adapter, { timeoutMs = 1_000 } = {}) {
11
+ return new Promise(resolve => {
12
+ execFile(adapter.client.command, adapter.client.versionArgs ?? ["--version"], {
13
+ timeout: timeoutMs,
14
+ windowsHide: true,
15
+ }, (error, stdout, stderr) => {
16
+ if (error !== null) return resolve(null);
17
+ resolve(parseClientVersion(`${stdout}${stderr}`));
18
+ });
19
+ });
20
+ }
@@ -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,12 +11,40 @@ 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
 
17
+ // Kept cohesive above 300 lines because every handler shares one fail-open
18
+ // hook boundary, binding lifecycle, and client-specific outcome contract.
19
+ // Splitting handlers would duplicate that safety boundary and make delivery or
20
+ // guard failures behave differently by event kind.
21
+
16
22
  // A hook runs in front of the user's turn, so it gets a hard ceiling. Better to
17
23
  // let a call through than to make someone's session sit waiting on us.
18
24
  const DEFAULT_BUDGET_MS = 5_000;
19
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
+
20
48
  // Declared by this process on the session it opens, so peers can tell an idle
21
49
  // session from a dead one. Only one of the four clients fires a heartbeat event,
22
50
  // so the rest refresh here: on every turn, and during a long one whenever the
@@ -157,14 +185,46 @@ async function openContext({ cwd, dataHome, runtime, env }) {
157
185
  }
158
186
 
159
187
  const HANDLERS = {
160
- async sessionStart({ event, context, adapter, adapterId, paths, readProcessTable }) {
161
- const capabilities = adapter.capabilities ?? {};
188
+ async sessionStart({ event, context, adapter, adapterId, binding, paths,
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);
162
201
  // Once per session, never per turn. A client that cannot be found yields
163
202
  // null, and the session is then judged by age alone - which is exactly the
164
203
  // behaviour every session had before this existed.
165
204
  const command = adapter.client?.command ?? null;
166
205
  const pid = command === null ? null
167
206
  : resolveClientPid({ table: await readProcessTable(), from: process.pid, command });
207
+ const metadata = {
208
+ pid,
209
+ enforcement: capabilities.guards?.beforeWrite === true ? "guarded" : "advisory",
210
+ lifecycle: capabilities.lifecycle?.sessionEnd === true ? "managed" : "manual",
211
+ heartbeatCadenceMs: CADENCE_MS,
212
+ checkoutRoot: context.descriptor.git?.worktreeRoot ?? context.descriptor.roots[0],
213
+ branch: context.descriptor.git?.branch ?? null,
214
+ };
215
+ if (binding !== null) {
216
+ const resumed = await context.service.resumeSession({
217
+ sessionId: binding.accSessionId,
218
+ generation: binding.generation,
219
+ ...metadata,
220
+ });
221
+ if (resumed !== null) {
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 };
226
+ }
227
+ }
168
228
  const session = await context.service.openSession({
169
229
  workspaceId: context.descriptor.id,
170
230
  participantId: participantFor(adapterId, event.sessionId, context.env),
@@ -174,19 +234,15 @@ const HANDLERS = {
174
234
  // Declared from what this adapter proved, not from the fact that it is an
175
235
  // adapter at all. A peer reading the roster can then tell a session whose
176
236
  // writes can be stopped from one whose cannot.
177
- enforcement: capabilities.guards?.beforeWrite === true ? "guarded" : "advisory",
178
- lifecycle: capabilities.lifecycle?.sessionEnd === true ? "managed" : "manual",
179
- heartbeatCadenceMs: CADENCE_MS,
180
237
  // Which checkout this agent is in. One workspace spans every worktree of
181
238
  // a repository, so this is the only thing that distinguishes them.
182
- checkoutRoot: context.descriptor.git?.worktreeRoot ?? context.descriptor.roots[0],
183
- pid,
184
- branch: context.descriptor.git?.branch ?? null,
239
+ ...metadata,
185
240
  descriptor: context.descriptor,
186
241
  });
187
242
  await storeSessionBinding({ runtimeDir: paths.root, harnessSessionId: event.sessionId,
188
- accSessionId: session.sessionId, generation: session.generation });
189
- 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 };
190
246
  },
191
247
 
192
248
  async heartbeat({ binding, context }) {
@@ -204,7 +260,7 @@ const HANDLERS = {
204
260
  return {};
205
261
  },
206
262
 
207
- async beforeTurn({ binding, context, adapter }) {
263
+ async beforeTurn({ binding, context, adapter, adapterId }) {
208
264
  if (binding === null) return {};
209
265
  // A turn is the clearest sign a session is alive. Never a reason to fail:
210
266
  // this runs in front of somebody's prompt.
@@ -213,36 +269,21 @@ const HANDLERS = {
213
269
  const sync = await context.service.sync({ sessionId: binding.accSessionId,
214
270
  cursor: null, scope: "delta" });
215
271
 
216
- // Claims held by others, and whether this session can actually be stopped
217
- // from breaking them. For a harness that guards writes this is useful
218
- // warning; for one that cannot - a Codex model editing through the shell,
219
- // an MCP client - it is the only protection there is, so it has to say
220
- // plainly that the responsibility has moved to the session itself.
221
- const status = await context.service.collectStatus({
222
- workspaceId: context.descriptor.id });
223
- const mine = status.participants
272
+ // Sync already carries the roster. Calling collectStatus here used to read
273
+ // the entire materialised store a second time on every prompt merely to
274
+ // rediscover this session's participant id.
275
+ const mine = sync.roster
224
276
  .find(participant => participant.sessionId === binding.accSessionId);
225
- // Two independent facts, and both are needed. `enforceable` is whether ACC
226
- // could stop *this* session at all; `enforcement` is what the claim's owner
227
- // asked for. A guarded session facing an advisory claim is not blocked from
228
- // anything, so reporting either one alone mislabels the other case.
229
- const enforceable = mine?.enforcement === "guarded";
230
- const claims = status.claims
231
- .filter(claim => claim.ownerSessionId !== binding.accSessionId)
232
- .map(claim => ({ resource: claim.resource, enforcement: claim.enforcement,
233
- enforceable,
234
- ownerParticipantId: status.participants
235
- .find(p => p.sessionId === claim.ownerSessionId)?.participantId
236
- ?? claim.ownerSessionId }));
237
277
 
238
278
  // What peers have said to this participant and no model has been shown yet.
239
- // Without this the projector's peer block never ran in production: an agent
240
- // saw only the subject of a message through its attention line, and the
241
- // `injected` delivery state was unreachable.
242
- 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({
243
283
  workspaceId: context.descriptor.id,
244
284
  participantId: mine?.participantId,
245
285
  exceptSessionId: binding.accSessionId });
286
+ const messages = delivery.queuedMessages;
246
287
 
247
288
  // Solo costs nothing: nothing to say means nothing printed, not a banner
248
289
  // announcing that nobody else is here. But something already said to you is
@@ -254,32 +295,62 @@ const HANDLERS = {
254
295
  // The ceiling a team agreed on in `acc.workspace.json`, or the default when
255
296
  // there is no config. Validated by the protocol and, until now, never read:
256
297
  // the projector was always called with its own default.
257
- const projected = await adapter.renderContext?.({ ...sync, claims, messages },
258
- { budgetBytes: context.descriptor.policy?.contextBudgetBytes }) ?? "";
259
- if (projected === "") return { stdout: "" };
260
-
261
- // Only what the model was actually shown is recorded as delivered. The
262
- // budget can leave a message out, and a receipt reading `injected` for text
263
- // nobody saw is worse than one still reading `queued` - the sender would be
264
- // told it landed. A message left behind stays queued and goes out next turn.
265
- const failures = [];
266
- for (const message of messages) {
267
- if (!projected.includes(message.messageId)) continue;
268
- await context.service.markDelivery({ sessionId: binding.accSessionId,
269
- generation: binding.generation, messageId: message.messageId,
270
- recipientParticipantId: mine.participantId, state: "injected" })
271
- .catch(error => failures.push(`${message.messageId}: ${error.message}`));
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,
304
+ currentParticipantId: mine?.participantId };
305
+ const projectionOptions = {
306
+ budgetBytes: context.descriptor.policy?.contextBudgetBytes };
307
+ // Delivery is state, not text parsing. Peer-controlled bodies can imitate
308
+ // another message's visible header, so only projector metadata proves
309
+ // which complete groups survived the byte budget. A custom adapter without
310
+ // metadata may still inject text, but cannot advance a receipt from it.
311
+ const projection = !hasStructuredRenderer
312
+ ? { text: await adapter.renderContext?.(projectionInput, projectionOptions) ?? "",
313
+ offeredMessageIds: [], includedAttentionIds: [] }
314
+ : await adapter.renderContextResult(projectionInput, projectionOptions);
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;
325
+ const visibleDegradation = degradation === null ? "" : `ACC: ${degradation.slice(5)}`;
326
+ const budgetBytes = projectionOptions.budgetBytes ?? 6_000;
327
+ const projected = degradation === null ? projection.text
328
+ : fitDegradation(projection.text, visibleDegradation, messages, budgetBytes);
329
+ if (projected === "") {
330
+ return degradation === null ? { stdout: "" } : { stdout: "", stderr: degradation };
272
331
  }
332
+
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 }));
273
343
  // Same again: Kimi Code shows the model a hook's raw stdout, while Gemini
274
344
  // and Claude Code want an envelope and drop a bare string.
275
- // Reported rather than swallowed. The context still goes out - losing it
276
- // over bookkeeping would be the worse trade - but a receipt that failed to
277
- // 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.
278
348
  const outcome = { stdout: "", ...adapter.injectOutcome?.(projected) };
279
- if (failures.length === 0) return outcome;
349
+ const writableOffers = outcome.stdout === "" ? [] : offerInputs;
350
+ if (degradation === null) return { ...outcome, offerInputs: writableOffers };
280
351
  return { ...outcome,
281
- stderr: [outcome.stderr, `acc: delivery not recorded for ${failures.join(", ")}`]
282
- .filter(Boolean).join("\n") };
352
+ stderr: [outcome.stderr, degradation].filter(Boolean).join("\n"),
353
+ offerInputs: writableOffers };
283
354
  },
284
355
 
285
356
  async beforeTool({ binding, context, event, adapter }) {
@@ -350,8 +421,12 @@ const HANDLERS = {
350
421
  */
351
422
  export async function runHook({ adapterId, payload, adapters, dataHome, env,
352
423
  runtime = defaultRuntime(), budgetMs = DEFAULT_BUDGET_MS,
353
- readProcessTable = defaultReadProcessTable }) {
354
- 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 () => {} };
355
430
  let timer = null;
356
431
  try {
357
432
  const adapter = adapters?.[adapterId];
@@ -366,7 +441,7 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
366
441
  const work = handler === undefined
367
442
  ? Promise.resolve({})
368
443
  : handler({ event, context, adapter, adapterId, binding, paths: context.paths,
369
- readProcessTable });
444
+ readProcessTable, probeClientVersion, platform, deadline });
370
445
 
371
446
  // The loser of a race is not cancelled, so the timer is cleared explicitly:
372
447
  // an outstanding one keeps the process alive long past its answer.
@@ -375,6 +450,24 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
375
450
  });
376
451
  Object.assign(result, await Promise.race([work, budget]));
377
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
+
378
471
  const status = await context.service.collectStatus({
379
472
  workspaceId: context.descriptor.id });
380
473
  result.sessions = status.participants.filter(p => p.presence !== "offline");
@@ -384,6 +477,9 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
384
477
  result.reason = error.message;
385
478
  result.decision = "allow";
386
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 () => {};
387
483
  } finally {
388
484
  if (timer !== null) clearTimeout(timer);
389
485
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.1.17",
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";