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,276 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isDeepStrictEqual } from "node:util";
3
+
4
+ import { AccError, EXIT, SCHEMA_VERSION, validateRecord }
5
+ from "@agents-can-communicate/protocol";
6
+
7
+ import { projectReleasedClaim, reconstructFinishRetry } from "./finish-retries.mjs";
8
+ import { ensureMaterialised } from "./materialisation.mjs";
9
+
10
+ export const receiptId = (messageId, participantId) => `receipt_${createHash("sha256")
11
+ .update(JSON.stringify([messageId, participantId])).digest("base64url")}`;
12
+
13
+ const logicalContent = message => ({
14
+ toParticipantIds: message.toParticipantIds,
15
+ kind: message.kind,
16
+ obligation: message.obligation,
17
+ subject: message.subject,
18
+ body: message.body,
19
+ inReplyTo: message.inReplyTo,
20
+ artifacts: message.artifacts,
21
+ handoff: message.handoff,
22
+ });
23
+
24
+ const normalizedContent = input => logicalContent({
25
+ toParticipantIds: input.toParticipantIds ?? [],
26
+ kind: input.kind,
27
+ obligation: input.obligation,
28
+ subject: input.subject,
29
+ body: input.body,
30
+ inReplyTo: input.inReplyTo ?? null,
31
+ artifacts: input.artifacts ?? [],
32
+ handoff: input.handoff ?? null,
33
+ });
34
+
35
+ function existingMessage(tx, session, input) {
36
+ const existing = tx.list("message", message =>
37
+ message.workspaceId === session.workspaceId
38
+ && message.fromParticipantId === session.participantId
39
+ && message.clientMessageId === input.clientMessageId).at(0);
40
+ if (existing !== undefined
41
+ && !isDeepStrictEqual(logicalContent(existing), normalizedContent(input))) {
42
+ throw new AccError(EXIT.CONFLICT,
43
+ "clientMessageId was already used with different message content",
44
+ { clientMessageId: input.clientMessageId, messageId: existing.messageId });
45
+ }
46
+ return existing;
47
+ }
48
+
49
+ function assertCurrentSession(tx, session, action) {
50
+ const current = tx.get("session", session.sessionId);
51
+ if (current === null || current.state !== "open"
52
+ || current.generation !== session.generation) {
53
+ throw new AccError(EXIT.CONFLICT, `cannot ${action} from this session generation`,
54
+ { sessionId: session.sessionId });
55
+ }
56
+ }
57
+
58
+ function addressedRecipients(tx, message, session) {
59
+ if (message.toParticipantIds.length === 0) {
60
+ return [...new Set(tx.list("session", item => item.state === "open"
61
+ && item.participantId !== session.participantId)
62
+ .map(item => item.participantId))].sort();
63
+ }
64
+ const unique = [...new Set(message.toParticipantIds)];
65
+ if (unique.length !== message.toParticipantIds.length) {
66
+ throw new AccError(EXIT.USAGE, "a participant may be addressed only once", {});
67
+ }
68
+ const known = new Set(tx.list("participant").map(item => item.participantId));
69
+ const strangers = unique.filter(participantId => !known.has(participantId));
70
+ if (strangers.length > 0) {
71
+ throw new AccError(EXIT.DATA,
72
+ `no participant here is called ${strangers.join(", ")}. `
73
+ + `This workspace has: ${[...known].sort().join(", ") || "nobody else yet"}`,
74
+ { strangers, known: [...known].sort() });
75
+ }
76
+ return unique;
77
+ }
78
+
79
+ function threadFor(tx, messageId, inReplyTo) {
80
+ if (inReplyTo === null) return messageId;
81
+ const parent = tx.get("message", inReplyTo);
82
+ if (parent === null) {
83
+ throw new AccError(EXIT.DATA, "the message being replied to does not exist",
84
+ { inReplyTo });
85
+ }
86
+ return parent.threadId;
87
+ }
88
+
89
+ export function recordMessageInTransaction({ tx, session, input, now, messageId, ids,
90
+ action = "send a message" }) {
91
+ assertCurrentSession(tx, session, action);
92
+ const existing = existingMessage(tx, session, input);
93
+ if (existing !== undefined) {
94
+ return { message: existing, recipientParticipantIds: [], created: false };
95
+ }
96
+
97
+ const inReplyTo = input.inReplyTo ?? null;
98
+ const message = validateRecord("message", {
99
+ schemaVersion: SCHEMA_VERSION,
100
+ messageId,
101
+ threadId: threadFor(tx, messageId, inReplyTo),
102
+ clientMessageId: input.clientMessageId,
103
+ workspaceId: session.workspaceId,
104
+ fromParticipantId: session.participantId,
105
+ fromSessionId: session.sessionId,
106
+ toParticipantIds: input.toParticipantIds ?? [],
107
+ kind: input.kind,
108
+ obligation: input.obligation,
109
+ subject: input.subject,
110
+ body: input.body,
111
+ inReplyTo,
112
+ artifacts: input.artifacts ?? [],
113
+ handoff: input.handoff ?? null,
114
+ sentAt: now,
115
+ });
116
+ const recipientParticipantIds = addressedRecipients(tx, message, session);
117
+ tx.put("message", messageId, message);
118
+ for (const participantId of recipientParticipantIds) {
119
+ tx.put("receipt", receiptId(messageId, participantId), validateRecord("receipt", {
120
+ schemaVersion: SCHEMA_VERSION,
121
+ messageId,
122
+ workspaceId: session.workspaceId,
123
+ recipientParticipantId: participantId,
124
+ state: "queued",
125
+ updatedAt: now,
126
+ }));
127
+ }
128
+ tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
129
+ workspaceId: session.workspaceId, actorSessionId: session.sessionId,
130
+ type: "message.recorded", occurredAt: now,
131
+ payload: { messageId, threadId: message.threadId, recipientParticipantIds } });
132
+ return { message, recipientParticipantIds, created: true };
133
+ }
134
+
135
+ function handoffBody(goal, handoff) {
136
+ const section = (label, items) => items.length === 0
137
+ ? [] : [`${label}:`, ...items.map(item => `- ${item}`)];
138
+ return [goal, handoff.status,
139
+ ...section("completed", handoff.completed),
140
+ ...section("remaining", handoff.remaining),
141
+ ...section("blockers", handoff.blockers)].join("\n");
142
+ }
143
+
144
+ export function createConversationService(ports, sessions) {
145
+ const { store, clock, ids } = ports;
146
+
147
+ async function requireSession(input, action, { open = true } = {}) {
148
+ const located = await sessions.locateSession(input.sessionId, input.workspaceId);
149
+ if (located === null || located.record.generation !== input.generation
150
+ || (open && located.record.state !== "open")) {
151
+ throw new AccError(EXIT.CONFLICT, `cannot ${action} from this session generation`,
152
+ { sessionId: input.sessionId });
153
+ }
154
+ return located.record;
155
+ }
156
+
157
+ async function sendMessage(input) {
158
+ const session = await requireSession(input, "send a message");
159
+ await ensureMaterialised(ports, { workspaceId: session.workspaceId,
160
+ descriptor: input.descriptor, reason: "durable_object" });
161
+ return store.transaction(tx => recordMessageInTransaction({ tx, session, input,
162
+ now: clock.now(), messageId: ids.next("message"), ids }).message,
163
+ { kinds: ["participant", "session", "message", "receipt"] });
164
+ }
165
+
166
+ async function finishSession(input) {
167
+ const located = await sessions.locateSession(input.sessionId, input.workspaceId);
168
+ if (located === null || located.record.generation !== input.generation) {
169
+ throw new AccError(EXIT.CONFLICT, "cannot finish from this session generation",
170
+ { sessionId: input.sessionId });
171
+ }
172
+ const session = located.record;
173
+ await ensureMaterialised(ports, { workspaceId: session.workspaceId,
174
+ descriptor: input.descriptor, reason: "durable_object" });
175
+ const handoff = {
176
+ status: input.status ?? "partial",
177
+ completed: input.completed ?? [],
178
+ remaining: input.remaining ?? [],
179
+ blockers: input.blockers ?? [],
180
+ verification: input.verification ?? [],
181
+ };
182
+ const messageInput = {
183
+ clientMessageId: input.clientMessageId,
184
+ toParticipantIds: input.toParticipantId === undefined ? [] : [input.toParticipantId],
185
+ kind: "handoff",
186
+ obligation: input.toParticipantId === undefined ? "none" : "acknowledge",
187
+ subject: input.goal,
188
+ body: handoffBody(input.goal, handoff),
189
+ inReplyTo: null,
190
+ artifacts: input.artifacts ?? [],
191
+ handoff,
192
+ };
193
+ const now = clock.now();
194
+ const outcome = await store.transaction(tx => {
195
+ const current = tx.get("session", session.sessionId);
196
+ if (current === null || current.generation !== session.generation) {
197
+ throw new AccError(EXIT.CONFLICT, "cannot finish from this session generation",
198
+ { sessionId: session.sessionId });
199
+ }
200
+ const existing = existingMessage(tx, session, messageInput);
201
+ if (existing !== undefined) {
202
+ if (existing.fromSessionId !== session.sessionId) {
203
+ throw new AccError(EXIT.CONFLICT,
204
+ "clientMessageId belongs to a different finish generation",
205
+ { clientMessageId: input.clientMessageId, messageId: existing.messageId });
206
+ }
207
+ return { retry: true, message: existing, session: current };
208
+ }
209
+ if (current.state !== "open") {
210
+ throw new AccError(EXIT.CONFLICT, "cannot finish from this session generation",
211
+ { sessionId: session.sessionId });
212
+ }
213
+ const releasedClaims = tx.list("claim",
214
+ claim => claim.ownerSessionId === session.sessionId);
215
+ const recorded = recordMessageInTransaction({ tx, session, now,
216
+ messageId: ids.next("message"), ids, action: "finish",
217
+ input: messageInput });
218
+ for (const claim of releasedClaims) {
219
+ tx.remove("claim", claim.claimId, tx.generationOf("claim", claim.claimId));
220
+ tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
221
+ workspaceId: session.workspaceId, actorSessionId: session.sessionId,
222
+ type: "claim.released", occurredAt: now,
223
+ payload: { claimId: claim.claimId, authority: null, reason: null,
224
+ replacedGeneration: claim.generation } });
225
+ }
226
+ const closed = { ...current, state: "closed", heartbeatAt: now };
227
+ tx.put("session", session.sessionId, closed,
228
+ tx.generationOf("session", session.sessionId));
229
+ tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
230
+ workspaceId: session.workspaceId, actorSessionId: session.sessionId,
231
+ type: "session.closed", occurredAt: now,
232
+ payload: { cause: "finish", messageId: recorded.message.messageId,
233
+ sessionGeneration: session.generation,
234
+ releasedClaimIds: releasedClaims.map(claim => claim.claimId) } });
235
+ return { retry: false, message: recorded.message,
236
+ releasedClaims: releasedClaims.map(projectReleasedClaim), session: closed };
237
+ }, { kinds: ["participant", "session", "claim", "message", "receipt"] });
238
+ if (!outcome.retry) {
239
+ return { message: outcome.message, releasedClaims: outcome.releasedClaims,
240
+ session: outcome.session };
241
+ }
242
+ const releasedClaims = await reconstructFinishRetry({ store,
243
+ workspaceId: session.workspaceId, session: outcome.session, message: outcome.message });
244
+ return { message: outcome.message, releasedClaims, session: outcome.session };
245
+ }
246
+
247
+ async function pendingMessages(input = {}) {
248
+ return (await nextTurnDelivery(input)).queuedMessages;
249
+ }
250
+
251
+ async function nextTurnDelivery(input = {}) {
252
+ if (typeof input.participantId !== "string" || input.participantId === "") {
253
+ return { queuedMessages: [], liveOfferedMessageIds: [], roomMessageIds: [] };
254
+ }
255
+ const snapshot = await store.snapshot(input.workspaceId ?? store.workspaceId,
256
+ { kinds: ["message", "receipt"] });
257
+ const receiptByMessage = new Map(snapshot.receipts
258
+ .filter(item => item.recipientParticipantId === input.participantId)
259
+ .map(item => [item.messageId, item]));
260
+ const eligible = snapshot.messages.filter(message => receiptByMessage.has(message.messageId)
261
+ && message.fromSessionId !== input.exceptSessionId)
262
+ .sort((left, right) => left.sentAt.localeCompare(right.sentAt)
263
+ || left.messageId.localeCompare(right.messageId));
264
+ return {
265
+ queuedMessages: eligible.filter(message =>
266
+ receiptByMessage.get(message.messageId).state === "queued"),
267
+ liveOfferedMessageIds: eligible.filter(message => message.toParticipantIds.length > 0
268
+ && receiptByMessage.get(message.messageId).state === "offered")
269
+ .map(message => message.messageId),
270
+ roomMessageIds: eligible.filter(message => message.toParticipantIds.length === 0)
271
+ .map(message => message.messageId),
272
+ };
273
+ }
274
+
275
+ return { sendMessage, finishSession, pendingMessages, nextTurnDelivery };
276
+ }
@@ -0,0 +1,81 @@
1
+ import { AccError, EXIT, SCHEMA_VERSION, validateRecord }
2
+ from "@agents-can-communicate/protocol";
3
+
4
+ function conflict(sessionId) {
5
+ return new AccError(EXIT.CONFLICT,
6
+ "cannot publish a delivery binding for anything but this open session generation",
7
+ { sessionId });
8
+ }
9
+
10
+ export function createDeliveryBindingService(ports, sessions) {
11
+ const { store } = ports;
12
+
13
+ async function currentBindings(now) {
14
+ const bindings = await store.ephemeral.list("deliveryBinding");
15
+ const current = [];
16
+ for (const binding of bindings) {
17
+ const located = await sessions.locateSession(binding.sessionId);
18
+ if (located?.record.state !== "open"
19
+ || located.record.generation !== binding.generation) continue;
20
+ current.push({ binding, participantId: located.record.participantId,
21
+ reachable: Date.parse(binding.leaseUntil) > Date.parse(now) });
22
+ }
23
+ return current.sort((left, right) =>
24
+ left.binding.sessionId.localeCompare(right.binding.sessionId));
25
+ }
26
+
27
+ async function publishDeliveryBinding(input) {
28
+ const located = await sessions.locateSession(input.sessionId);
29
+ if (located?.record.state !== "open" || located.record.generation !== input.generation) {
30
+ throw conflict(input.sessionId);
31
+ }
32
+ let binding;
33
+ try {
34
+ binding = validateRecord("deliveryBinding", {
35
+ schemaVersion: SCHEMA_VERSION,
36
+ sessionId: input.sessionId,
37
+ generation: input.generation,
38
+ adapterId: input.adapterId,
39
+ clientVersion: input.clientVersion,
40
+ availableModes: Array.isArray(input.availableModes)
41
+ ? [...input.availableModes] : input.availableModes,
42
+ livePolicy: input.livePolicy,
43
+ opaqueEndpointRef: input.opaqueEndpointRef,
44
+ leaseUntil: input.leaseUntil,
45
+ });
46
+ } catch (error) {
47
+ if (error?.details?.field === "opaqueEndpointRef") {
48
+ throw new AccError(error.code, error.message, { field: "opaqueEndpointRef" });
49
+ }
50
+ throw error;
51
+ }
52
+ await store.ephemeral.update("deliveryBinding", binding.sessionId, async () => {
53
+ // This check runs while the ephemeral key's writer lock is held. A stale
54
+ // publisher that passed the earlier check cannot replace a successor's
55
+ // endpoint after that successor becomes current.
56
+ const current = await sessions.locateSession(input.sessionId);
57
+ if (current?.record.state !== "open"
58
+ || current.record.generation !== input.generation) {
59
+ throw conflict(input.sessionId);
60
+ }
61
+ return binding;
62
+ });
63
+ const stillCurrent = await sessions.locateSession(input.sessionId);
64
+ if (stillCurrent?.record.state !== "open"
65
+ || stillCurrent.record.generation !== input.generation) {
66
+ // Do not delete here: a successor generation may have published between
67
+ // our put and this recheck. A stale record is filtered on every read and
68
+ // is safe; deleting a successor's endpoint is not.
69
+ throw conflict(input.sessionId);
70
+ }
71
+ return binding;
72
+ }
73
+
74
+ async function listDeliveryBindings({ participantId, now }) {
75
+ return (await currentBindings(now))
76
+ .filter(item => item.participantId === participantId && item.reachable)
77
+ .map(item => item.binding);
78
+ }
79
+
80
+ return { publishDeliveryBinding, listDeliveryBindings, currentBindings };
81
+ }
@@ -0,0 +1,97 @@
1
+ import { AccError, EXIT } from "@agents-can-communicate/protocol";
2
+
3
+ const PAGE_SIZE = 256;
4
+
5
+ export const projectReleasedClaim = claim => ({
6
+ claimId: claim.claimId,
7
+ resource: claim.resource,
8
+ mode: claim.mode,
9
+ });
10
+
11
+ function conflict(message) {
12
+ return new AccError(EXIT.CONFLICT,
13
+ "clientMessageId belongs to a different finish generation",
14
+ { clientMessageId: message.clientMessageId, messageId: message.messageId });
15
+ }
16
+
17
+ async function allEvents(store, workspaceId) {
18
+ const events = [];
19
+ let cursor = null;
20
+ while (true) {
21
+ const page = await store.eventsSince(workspaceId, cursor, PAGE_SIZE);
22
+ if (page.events.length === 0) return events;
23
+ events.push(...page.events);
24
+ cursor = page.cursor;
25
+ }
26
+ }
27
+
28
+ function claimProjection(events, releaseIndex) {
29
+ const release = events[releaseIndex];
30
+ const claimId = release.payload.claimId;
31
+ const facts = events.slice(0, releaseIndex + 1).findLast(event =>
32
+ (event.type === "claim.acquired" || event.type === "claim.renewed")
33
+ && event.payload.claimId === claimId
34
+ && typeof event.payload.resource === "string");
35
+ return facts === undefined ? null : {
36
+ claimId,
37
+ resource: facts.payload.resource,
38
+ mode: facts.payload.mode,
39
+ };
40
+ }
41
+
42
+ function correlatedReleaseIndexes(events, recordedIndex, closedIndex, session, message) {
43
+ const close = events[closedIndex];
44
+ const payload = close.payload;
45
+ const keys = Object.keys(payload).sort();
46
+ const expectedKeys = ["cause", "messageId", "releasedClaimIds", "sessionGeneration"];
47
+ if (keys.length !== expectedKeys.length
48
+ || !keys.every((key, index) => key === expectedKeys[index])
49
+ || payload.cause !== "finish"
50
+ || payload.messageId !== message.messageId
51
+ || payload.sessionGeneration !== session.generation
52
+ || !Array.isArray(payload.releasedClaimIds)
53
+ || payload.releasedClaimIds.some(claimId => typeof claimId !== "string")
54
+ || new Set(payload.releasedClaimIds).size !== payload.releasedClaimIds.length) {
55
+ throw conflict(message);
56
+ }
57
+
58
+ const between = events.slice(recordedIndex + 1, closedIndex);
59
+ if (between.length !== payload.releasedClaimIds.length
60
+ || between.some(event => event.type !== "claim.released"
61
+ || event.actorSessionId !== session.sessionId
62
+ || event.payload.authority !== null
63
+ || event.payload.reason !== null)) {
64
+ throw conflict(message);
65
+ }
66
+ const actualIds = between.map(event => event.payload.claimId);
67
+ if (actualIds.some((claimId, index) => claimId !== payload.releasedClaimIds[index])) {
68
+ throw conflict(message);
69
+ }
70
+ return actualIds.map((claimId, offset) => recordedIndex + offset + 1);
71
+ }
72
+
73
+ export async function reconstructFinishRetry({ store, workspaceId, session, message }) {
74
+ const events = await allEvents(store, workspaceId);
75
+ const recordedIndex = events.findIndex(event => event.type === "message.recorded"
76
+ && event.payload.messageId === message.messageId
77
+ && event.actorSessionId === session.sessionId);
78
+ if (session.state !== "closed" || recordedIndex === -1) throw conflict(message);
79
+
80
+ const laterOpen = events.slice(recordedIndex + 1).some(event =>
81
+ event.type === "session.opened" && event.actorSessionId === session.sessionId);
82
+ if (laterOpen) throw conflict(message);
83
+
84
+ const closedOffset = events.slice(recordedIndex + 1).findIndex(event =>
85
+ event.type === "session.closed" && event.actorSessionId === session.sessionId);
86
+ if (closedOffset === -1) throw conflict(message);
87
+ const closedIndex = recordedIndex + closedOffset + 1;
88
+ const releaseIndexes = correlatedReleaseIndexes(events, recordedIndex, closedIndex,
89
+ session, message);
90
+ const releasedClaims = releaseIndexes.map(index => claimProjection(events, index));
91
+ if (releasedClaims.includes(null)) throw conflict(message);
92
+ const snapshot = await store.snapshot(workspaceId, { kinds: ["claim"] });
93
+ if (snapshot.claims.some(claim => claim.ownerSessionId === session.sessionId)) {
94
+ throw conflict(message);
95
+ }
96
+ return releasedClaims;
97
+ }
@@ -0,0 +1,118 @@
1
+ import { AccError, EXIT, SCHEMA_VERSION, advanceReceipt }
2
+ from "@agents-can-communicate/protocol";
3
+
4
+ import { receiptId, recordMessageInTransaction } from "./conversations.mjs";
5
+
6
+ const listable = (message, receipt) => receipt.state === "queued" || receipt.state === "offered"
7
+ || (receipt.state === "retrieved" && message.obligation !== "none");
8
+
9
+ export function createInboxService(ports, sessions) {
10
+ const { store, clock, ids } = ports;
11
+
12
+ async function requireOpen(input, action) {
13
+ const located = await sessions.locateSession(input.sessionId, input.workspaceId);
14
+ if (located === null || located.record.state !== "open"
15
+ || located.record.generation !== input.generation) {
16
+ throw new AccError(EXIT.CONFLICT, `cannot ${action} from this session generation`,
17
+ { sessionId: input.sessionId });
18
+ }
19
+ return located.record;
20
+ }
21
+
22
+ function requireOwnedReceipt(tx, session, messageId) {
23
+ const message = tx.get("message", messageId);
24
+ const id = receiptId(messageId, session.participantId);
25
+ const receipt = tx.get("receipt", id);
26
+ if (message === null || receipt === null) {
27
+ throw new AccError(EXIT.CONFLICT,
28
+ "that message is not addressed to this participant",
29
+ { messageId, participantId: session.participantId });
30
+ }
31
+ return { id, message, receipt };
32
+ }
33
+
34
+ function advanceOwned(tx, session, messageId, state, now) {
35
+ const owned = requireOwnedReceipt(tx, session, messageId);
36
+ const nextState = advanceReceipt(owned.receipt.state, state);
37
+ if (nextState === owned.receipt.state) {
38
+ return { message: owned.message, receipt: owned.receipt };
39
+ }
40
+ const receipt = { ...owned.receipt, state: nextState, updatedAt: now };
41
+ tx.put("receipt", owned.id, receipt, tx.generationOf("receipt", owned.id));
42
+ tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
43
+ workspaceId: session.workspaceId, actorSessionId: session.sessionId,
44
+ type: `message.${state}`, occurredAt: now,
45
+ payload: { messageId, recipientParticipantId: session.participantId } });
46
+ return { message: owned.message, receipt };
47
+ }
48
+
49
+ async function readInbox(input) {
50
+ const session = await requireOpen(input, "read the inbox");
51
+ const now = clock.now();
52
+ return store.transaction(tx => {
53
+ const messages = new Map(tx.list("message").map(item => [item.messageId, item]));
54
+ let selected = tx.list("receipt", receipt =>
55
+ receipt.recipientParticipantId === session.participantId)
56
+ .filter(receipt => messages.has(receipt.messageId));
57
+ if (input.messageId === undefined) {
58
+ selected = selected.filter(receipt => listable(messages.get(receipt.messageId), receipt));
59
+ } else {
60
+ selected = selected.filter(receipt => receipt.messageId === input.messageId
61
+ && receipt.state !== "acknowledged");
62
+ if (selected.length === 0) {
63
+ throw new AccError(EXIT.CONFLICT,
64
+ "that message is not recoverable by this participant",
65
+ { messageId: input.messageId, participantId: session.participantId });
66
+ }
67
+ }
68
+ selected.sort((left, right) => {
69
+ const a = messages.get(left.messageId);
70
+ const b = messages.get(right.messageId);
71
+ return a.sentAt.localeCompare(b.sentAt) || a.messageId.localeCompare(b.messageId);
72
+ });
73
+ return selected.map(receipt => advanceOwned(tx, session, receipt.messageId,
74
+ "retrieved", now));
75
+ }, { kinds: ["message", "receipt"] });
76
+ }
77
+
78
+ async function acknowledgeMessage(input) {
79
+ const session = await requireOpen(input, "acknowledge a message");
80
+ const now = clock.now();
81
+ return store.transaction(tx => advanceOwned(tx, session, input.messageId,
82
+ "acknowledged", now).receipt,
83
+ { kinds: ["message", "receipt"] });
84
+ }
85
+
86
+ async function replyToMessage(input) {
87
+ const session = await requireOpen(input, "reply to a message");
88
+ const now = clock.now();
89
+ const replyId = ids.next("message");
90
+ return store.transaction(tx => {
91
+ const original = requireOwnedReceipt(tx, session, input.messageId);
92
+ const matchingReply = tx.list("message", message =>
93
+ message.workspaceId === session.workspaceId
94
+ && message.fromParticipantId === session.participantId
95
+ && message.clientMessageId === input.clientMessageId).at(0);
96
+ if (original.receipt.state === "acknowledged" && matchingReply === undefined) {
97
+ throw new AccError(EXIT.CONFLICT, "that message is already resolved",
98
+ { messageId: input.messageId });
99
+ }
100
+ const recorded = recordMessageInTransaction({ tx, session, now, messageId: replyId, ids,
101
+ action: "reply to a message", input: {
102
+ clientMessageId: input.clientMessageId,
103
+ toParticipantIds: [original.message.fromParticipantId],
104
+ kind: "answer",
105
+ obligation: "none",
106
+ subject: input.subject ?? `Re: ${original.message.subject}`,
107
+ body: input.body,
108
+ inReplyTo: original.message.messageId,
109
+ artifacts: input.artifacts ?? [],
110
+ handoff: null,
111
+ } });
112
+ const receipt = advanceOwned(tx, session, input.messageId, "acknowledged", now).receipt;
113
+ return { reply: recorded.message, receipt };
114
+ }, { kinds: ["participant", "session", "message", "receipt"] });
115
+ }
116
+
117
+ return { readInbox, replyToMessage, acknowledgeMessage };
118
+ }
@@ -3,6 +3,7 @@ export { createCoordinationService } from "./service.mjs";
3
3
  export { assertPorts } from "./ports.mjs";
4
4
  export { classifySessionPresence } from "./sessions.mjs";
5
5
  export { defaultPidIsAlive } from "./pid.mjs";
6
- export { ATTENTION_PRIORITY, computeAttention } from "./sync.mjs";
6
+ export { ATTENTION_PRIORITY, computeAttention } from "./attention.mjs";
7
+ export { SAFE_OFFER_ERROR_CODES } from "./receipts.mjs";
8
+ export { createDeliveryBindingService } from "./delivery-bindings.mjs";
7
9
  export { overlaps } from "./claims.mjs";
8
- export { wouldCycle } from "./tasks.mjs";
@@ -35,7 +35,6 @@ export function createIntentService(ports, sessions) {
35
35
  summary: input.summary,
36
36
  mode: input.mode,
37
37
  resourceHints: input.resourceHints ?? [],
38
- workstreamId: input.workstreamId ?? null,
39
38
  state: input.state ?? "active",
40
39
  updatedAt: now,
41
40
  });
@@ -8,7 +8,8 @@ import { AccError, EXIT } from "@agents-can-communicate/protocol";
8
8
  * list(kind: string, predicate?: (record: object) => boolean): object[],
9
9
  * put(kind: string, id: string, record: object, expectedGeneration?: string | null): string,
10
10
  * append(event: object): object }} CoordinationTransaction
11
- * @typedef {{ transaction(callback: (tx: CoordinationTransaction) => unknown): Promise<unknown>,
11
+ * @typedef {{ transaction(callback: (tx: CoordinationTransaction) => unknown,
12
+ * options?: { kinds?: string[], deadlineAt?: number }): Promise<unknown>,
12
13
  * eventsSince(workspaceId: string, cursor: string | null, limit: number): Promise<object>,
13
14
  * snapshot(workspaceId: string): Promise<object> }} CoordinationStore
14
15
  */
@@ -23,7 +24,7 @@ const REQUIRED = Object.freeze({
23
24
  // are storage too, so they hang off the store rather than becoming a fourth
24
25
  // port. They are deliberately outside transactions: they append no events and
25
26
  // vanish with their session.
26
- const EPHEMERAL = ["get", "put", "delete", "list"];
27
+ const EPHEMERAL = ["get", "put", "update", "delete", "list"];
27
28
 
28
29
  // Ports are validated at construction rather than at first use. A core that
29
30
  // silently falls back to ambient time or randomness produces tests that pass