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,109 @@
1
+ import { AccError, EXIT, SCHEMA_VERSION, advanceReceipt }
2
+ from "@agents-can-communicate/protocol";
3
+
4
+ import { receiptId } from "./conversations.mjs";
5
+
6
+ export const SAFE_OFFER_ERROR_CODES = Object.freeze([
7
+ "ambiguous_recipient_sessions",
8
+ "delivery_disabled",
9
+ "recipient_busy",
10
+ "recipient_unavailable",
11
+ "transport_error",
12
+ "transport_rejected",
13
+ "unsupported_client_version",
14
+ ]);
15
+
16
+ function missingReceipt(messageId, recipientParticipantId) {
17
+ return new AccError(EXIT.CONFLICT, "no receipt exists for that recipient",
18
+ { messageId, recipientParticipantId });
19
+ }
20
+
21
+ function requireReceipt(tx, messageId, recipientParticipantId) {
22
+ const receipt = tx.get("receipt", receiptId(messageId, recipientParticipantId));
23
+ if (receipt === null) throw missingReceipt(messageId, recipientParticipantId);
24
+ const message = tx.get("message", messageId);
25
+ if (message === null) throw missingReceipt(messageId, recipientParticipantId);
26
+ return { message, receipt };
27
+ }
28
+
29
+ function requireOfferableReceipt(tx, input, { allowRoomNextTurn }) {
30
+ const found = requireReceipt(tx, input.messageId, input.recipientParticipantId);
31
+ if (found.message.toParticipantIds.length === 0
32
+ && (!allowRoomNextTurn || input.transport !== "next-turn")) {
33
+ throw new AccError(EXIT.CONFLICT, "room messages are not eligible for live offers",
34
+ { messageId: input.messageId, recipientParticipantId: input.recipientParticipantId });
35
+ }
36
+ return found.receipt;
37
+ }
38
+
39
+ function requireTarget(tx, input, { mustBeOpen }) {
40
+ const target = tx.get("session", input.targetSessionId);
41
+ if (target === null || (mustBeOpen && target.state !== "open")
42
+ || target.participantId !== input.recipientParticipantId
43
+ || target.generation !== input.targetGeneration) {
44
+ throw new AccError(EXIT.CONFLICT,
45
+ "the offer target is not this recipient's session generation",
46
+ { targetSessionId: input.targetSessionId,
47
+ recipientParticipantId: input.recipientParticipantId });
48
+ }
49
+ return target;
50
+ }
51
+
52
+ export function createReceiptService(ports) {
53
+ const { store, clock, ids } = ports;
54
+
55
+ async function readReceipt(input) {
56
+ return store.transaction(tx => requireReceipt(tx, input.messageId,
57
+ input.recipientParticipantId).receipt, { kinds: ["message", "receipt"] });
58
+ }
59
+
60
+ async function recordOfferSucceeded(input) {
61
+ const now = clock.now();
62
+ return store.transaction(tx => {
63
+ const id = receiptId(input.messageId, input.recipientParticipantId);
64
+ const receipt = requireOfferableReceipt(tx, input, { allowRoomNextTurn: true });
65
+ const target = requireTarget(tx, input, { mustBeOpen: true });
66
+ // A retrieval or acknowledgement may win after a transport accepted but
67
+ // before this transaction acquired the writer. That stronger truth must
68
+ // remain in place. The same rule makes a repeated successful commit a
69
+ // read-only operation rather than another success event.
70
+ if (receipt.state !== "queued") return receipt;
71
+ const offered = { ...receipt, state: advanceReceipt(receipt.state, "offered"),
72
+ updatedAt: now };
73
+ tx.put("receipt", id, offered, tx.generationOf("receipt", id));
74
+ tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
75
+ workspaceId: receipt.workspaceId, actorSessionId: target.sessionId,
76
+ type: "message.offer_succeeded", occurredAt: now,
77
+ payload: { messageId: input.messageId,
78
+ recipientParticipantId: input.recipientParticipantId,
79
+ targetSessionId: input.targetSessionId,
80
+ targetGeneration: input.targetGeneration,
81
+ transport: input.transport, adapterId: input.adapterId,
82
+ clientVersion: input.clientVersion } });
83
+ return offered;
84
+ }, { kinds: ["session", "message", "receipt"], deadlineAt: input.deadlineAt });
85
+ }
86
+
87
+ async function recordOfferFailed(input) {
88
+ if (!SAFE_OFFER_ERROR_CODES.includes(input.safeErrorCode)) {
89
+ throw new AccError(EXIT.DATA, "safeErrorCode is not in the closed offer error set",
90
+ { safeErrorCode: input.safeErrorCode });
91
+ }
92
+ const now = clock.now();
93
+ return store.transaction(tx => {
94
+ const receipt = requireOfferableReceipt(tx, input, { allowRoomNextTurn: false });
95
+ const target = requireTarget(tx, input, { mustBeOpen: false });
96
+ return tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
97
+ workspaceId: receipt.workspaceId, actorSessionId: target.sessionId,
98
+ type: "message.offer_failed", occurredAt: now,
99
+ payload: { messageId: input.messageId,
100
+ recipientParticipantId: input.recipientParticipantId,
101
+ targetSessionId: target.sessionId,
102
+ targetGeneration: target.generation,
103
+ transport: input.transport, adapterId: input.adapterId,
104
+ clientVersion: input.clientVersion, safeErrorCode: input.safeErrorCode } });
105
+ }, { kinds: ["session", "message", "receipt"] });
106
+ }
107
+
108
+ return { readReceipt, recordOfferSucceeded, recordOfferFailed };
109
+ }
@@ -1,13 +1,14 @@
1
1
  import { createClaimService } from "./claims.mjs";
2
- import { createCommunicationService } from "./communication.mjs";
2
+ import { createConversationService } from "./conversations.mjs";
3
+ import { createDeliveryBindingService } from "./delivery-bindings.mjs";
3
4
  import { createIntentService } from "./intents.mjs";
5
+ import { createInboxService } from "./inbox.mjs";
4
6
  import { defaultPidIsAlive } from "./pid.mjs";
5
7
  import { assertPorts } from "./ports.mjs";
8
+ import { createReceiptService } from "./receipts.mjs";
6
9
  import { createSessionService } from "./sessions.mjs";
7
10
  import { createGuardStateService, createStatusService } from "./status.mjs";
8
11
  import { createSyncService } from "./sync.mjs";
9
- import { createTaskService } from "./tasks.mjs";
10
- import { createWorkstreamService } from "./workstreams.mjs";
11
12
 
12
13
  /**
13
14
  * Composition root for the domain services. Everything time- or
@@ -28,12 +29,13 @@ export function createCoordinationService({ store, clock, ids,
28
29
  const ports = assertPorts({ store, clock, ids, pidIsAlive });
29
30
  const sessions = createSessionService(ports);
30
31
  const intents = createIntentService(ports, sessions);
31
- const workstreams = createWorkstreamService(ports, sessions);
32
- const tasks = createTaskService(ports, workstreams);
33
32
  const claims = createClaimService(ports, sessions);
34
- const communication = createCommunicationService(ports, sessions, claims);
33
+ const conversations = createConversationService(ports, sessions);
34
+ const deliveryBindings = createDeliveryBindingService(ports, sessions);
35
+ const inbox = createInboxService(ports, sessions);
36
+ const receipts = createReceiptService(ports);
35
37
  const sync = createSyncService(ports, sessions);
36
- const status = createStatusService(ports, sessions);
38
+ const status = createStatusService(ports, sessions, deliveryBindings);
37
39
  const guardState = createGuardStateService(ports);
38
40
  return Object.freeze({
39
41
  store,
@@ -42,10 +44,12 @@ export function createCoordinationService({ store, clock, ids,
42
44
  policies: Object.freeze({ ...policies }),
43
45
  ...sessions,
44
46
  ...intents,
45
- ...workstreams,
46
- ...tasks,
47
47
  ...claims,
48
- ...communication,
48
+ ...conversations,
49
+ publishDeliveryBinding: deliveryBindings.publishDeliveryBinding,
50
+ listDeliveryBindings: deliveryBindings.listDeliveryBindings,
51
+ ...inbox,
52
+ ...receipts,
49
53
  ...sync,
50
54
  ...status,
51
55
  guardState,
@@ -2,7 +2,6 @@ import { AccError, EXIT, SCHEMA_VERSION, assertPortableId, createId, validateRec
2
2
  from "@agents-can-communicate/protocol";
3
3
 
4
4
  import { ensureMaterialised, isMaterialised, materialise } from "./materialisation.mjs";
5
- import { writeWorkResponse } from "./notify.mjs";
6
5
 
7
6
  // A hook-only adapter heartbeats only when its harness gives it a turn, so the
8
7
  // staleness window is a multiple of the cadence the session itself declared
@@ -154,7 +153,12 @@ export function createSessionService(ports) {
154
153
  // The approved trigger is the SECOND live session, not the first: a lone
155
154
  // session must be able to open and close without leaving a trace.
156
155
  const live = (await store.ephemeral.list("session")).filter(item => item.state === "open");
157
- if (live.length > 1) {
156
+ // Recheck durable state only after our ephemeral writes. Another process
157
+ // may have staged the old ephemeral set after our first precheck, committed
158
+ // materialisation, and retired that set before these puts completed. In
159
+ // that case this attach is the only ephemeral session left, but it still
160
+ // has to run the idempotent promotion pass into the now-durable workspace.
161
+ if (live.length > 1 || await isMaterialised(store, workspaceId)) {
158
162
  await materialise(ports, { workspaceId, descriptor: input.descriptor,
159
163
  reason: "second_live_session" });
160
164
  }
@@ -179,6 +183,53 @@ export function createSessionService(ports) {
179
183
  return beaten;
180
184
  }
181
185
 
186
+ /**
187
+ * Continue the exact session named by a harness binding.
188
+ *
189
+ * Some clients emit SessionStart again after compacting their model context.
190
+ * The binding is already the continuation token: it names both the session
191
+ * and its generation. Refreshing that record preserves one identity without
192
+ * pretending an unrelated or closed generation is still ours.
193
+ *
194
+ * Returns null when the binding can no longer be resumed, so the hook may
195
+ * open a genuinely new session. No semantic event is appended: compaction is
196
+ * not a second agent arriving.
197
+ */
198
+ async function resumeSession({ sessionId, workspaceId, generation, ...metadata }) {
199
+ const resume = current => {
200
+ if (current === null || current.state !== "open"
201
+ || current.generation !== generation) return null;
202
+ return { ...current,
203
+ pid: metadata.pid ?? null,
204
+ checkoutRoot: metadata.checkoutRoot ?? current.checkoutRoot,
205
+ branch: metadata.branch ?? current.branch,
206
+ enforcement: metadata.enforcement ?? current.enforcement,
207
+ lifecycle: metadata.lifecycle ?? current.lifecycle,
208
+ heartbeatCadenceMs: metadata.heartbeatCadenceMs ?? current.heartbeatCadenceMs,
209
+ heartbeatAt: clock.now(),
210
+ };
211
+ };
212
+
213
+ // The compare and replacement happen under the ephemeral store's writer
214
+ // lock. A close or a replacement generation can win before this update or
215
+ // after it, but can never be overwritten from a record read beforehand.
216
+ const ephemeral = await store.ephemeral.update("session", sessionId, resume);
217
+ if (ephemeral !== null) return ephemeral;
218
+
219
+ // Re-read and validate inside the durable transaction for the same reason.
220
+ // Using generationOf only as the put token is insufficient: it protects
221
+ // the envelope write, not the semantic generation carried by the record.
222
+ const resolvedWorkspace = workspaceId ?? store.workspaceId;
223
+ if (resolvedWorkspace === undefined) return null;
224
+ return store.transaction(async tx => {
225
+ const current = tx.get("session", sessionId);
226
+ const resumed = resume(current);
227
+ if (resumed === null) return null;
228
+ tx.put("session", sessionId, resumed, tx.generationOf("session", sessionId));
229
+ return resumed;
230
+ }, { kinds: ["session"] });
231
+ }
232
+
182
233
  async function closeSession({ sessionId, workspaceId, generation }) {
183
234
  const existing = await locate(sessionId, workspaceId);
184
235
  if (existing === null) throw new AccError(EXIT.CONFLICT, "session is not open", { sessionId });
@@ -196,34 +247,33 @@ export function createSessionService(ports) {
196
247
 
197
248
  await store.transaction(async tx => {
198
249
  tx.put("session", sessionId, closed, tx.generationOf("session", sessionId));
199
- // Work in progress goes back on the table. A task held by a session that
200
- // has gone stayed `in_progress` forever, nobody else could take it, and
201
- // whoever asked for it was never told - a request handed to an agent that
202
- // closed its terminal simply vanished.
203
- for (const task of tx.list("task")) {
204
- if (task.assigneeSessionId !== sessionId) continue;
205
- if (task.state === "done") continue;
206
- const released = { ...task, assigneeSessionId: null, state: "pending" };
207
- tx.put("task", task.taskId, released, tx.generationOf("task", task.taskId));
208
- writeWorkResponse(tx, { task, actor: closed, workspaceId: closed.workspaceId,
209
- now, ids, outcome: "released",
210
- reason: "the session working on this closed before finishing it" });
211
- tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
212
- workspaceId: closed.workspaceId, actorSessionId: sessionId,
213
- type: "task.released", occurredAt: now, payload: { taskId: task.taskId } });
214
- }
215
250
  tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
216
251
  workspaceId: closed.workspaceId, actorSessionId: sessionId, type: "session.closed",
217
252
  occurredAt: now, payload: {} });
218
- // `writeWorkResponse` tells whoever asked, on this same handle.
219
- }, { kinds: ["message", "receipt", "session", "task"] });
253
+ }, { kinds: ["session"] });
220
254
  return closed;
221
255
  }
222
256
 
257
+ async function listLiveSessions({ participantId, workspaceId, now = clock.now() }) {
258
+ const resolved = workspaceId ?? store.workspaceId;
259
+ const snapshot = resolved === undefined ? null
260
+ : await store.snapshot(resolved, { kinds: ["workspace", "session"] });
261
+ const records = snapshot?.workspace === null
262
+ ? await store.ephemeral.list("session")
263
+ : snapshot?.sessions ?? await store.ephemeral.list("session");
264
+ return records
265
+ .filter(session => session.participantId === participantId
266
+ && classifySessionPresence(session, now, pidIsAlive) !== "offline")
267
+ .sort((left, right) => left.sessionId.localeCompare(right.sessionId))
268
+ .map(session => ({ sessionId: session.sessionId, generation: session.generation }));
269
+ }
270
+
223
271
  return {
224
272
  openSession,
273
+ resumeSession,
225
274
  heartbeatSession,
226
275
  closeSession,
276
+ listLiveSessions,
227
277
  locateSession: locate,
228
278
  ensureMaterialised: options => ensureMaterialised(ports, options),
229
279
  };
@@ -1,5 +1,5 @@
1
1
  import { classifySessionPresence } from "./sessions.mjs";
2
- import { computeAttention } from "./sync.mjs";
2
+ import { computeAttention } from "./attention.mjs";
3
3
 
4
4
  /**
5
5
  * Protection level, reported from what is actually enforceable.
@@ -57,7 +57,7 @@ export function createGuardStateService(ports) {
57
57
  };
58
58
  }
59
59
 
60
- export function createStatusService(ports, sessions) {
60
+ export function createStatusService(ports, sessions, deliveryBindings) {
61
61
  const { store, clock, pidIsAlive } = ports;
62
62
 
63
63
  async function collectStatus(input = {}) {
@@ -85,6 +85,7 @@ export function createStatusService(ports, sessions) {
85
85
  const live = classified.filter(item => item.presence !== "offline");
86
86
  const claims = snapshot.claims
87
87
  .filter(claim => Date.parse(claim.expiresAt) > Date.parse(now));
88
+ const currentBindings = await deliveryBindings.currentBindings(now);
88
89
 
89
90
  return {
90
91
  workspaceId,
@@ -109,12 +110,6 @@ export function createStatusService(ports, sessions) {
109
110
  presence,
110
111
  intent: intents.find(intent => intent.sessionId === session.sessionId)?.summary ?? null,
111
112
  })),
112
- workstreams: snapshot.workstreams.map(workstream => ({
113
- workstreamId: workstream.workstreamId,
114
- title: workstream.title,
115
- state: workstream.state,
116
- coordinatorSessionId: workstream.coordinatorSessionId,
117
- })),
118
113
  // The owner is named twice on purpose. Every command that reaches a peer
119
114
  // takes a participant id, so a claim that gave only a session id sent the
120
115
  // reader back through the roster to answer "who is holding this, and how
@@ -133,13 +128,20 @@ export function createStatusService(ports, sessions) {
133
128
  .find(session => session.sessionId === claim.ownerSessionId)?.participantId ?? null,
134
129
  expiresAt: claim.expiresAt,
135
130
  })),
131
+ deliveryBindings: currentBindings.map(({ binding, reachable }) => ({
132
+ adapterId: binding.adapterId,
133
+ clientVersion: binding.clientVersion,
134
+ availableModes: [...binding.availableModes],
135
+ livePolicy: binding.livePolicy,
136
+ reachable,
137
+ leaseUntil: binding.leaseUntil,
138
+ })),
136
139
  attention: computeAttention(snapshot, { session: null,
137
140
  participantId: input.participantId, now, pidIsAlive }),
138
141
  counts: {
139
142
  live: live.length,
140
143
  stale: live.filter(item => item.presence === "stale").length,
141
144
  claims: claims.length,
142
- tasks: snapshot.tasks.length,
143
145
  messages: snapshot.messages.length,
144
146
  },
145
147
  };
@@ -1,19 +1,10 @@
1
1
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
2
2
 
3
+ import { computeAttention } from "./attention.mjs";
3
4
  import { classifySessionPresence } from "./sessions.mjs";
4
- import { overlaps } from "./claims.mjs";
5
5
 
6
6
  const DEFAULT_LIMIT = 100;
7
-
8
- // The shape every event carries, and the only thing a caller may ask to resume
9
- // from. `null` means the beginning, which is what a session with no cursor yet
10
- // has.
11
7
  const CURSOR = /^[0-9]{16}$/;
12
-
13
- // The two a caller may ask for. An unknown one used to become `delta`, so
14
- // `--scope ful` answered the one question the full scope exists for - "show me
15
- // everything, I cannot see the rest of the system" - with a delta carrying no
16
- // snapshot at all, and the agent concluded there was nothing to see.
17
8
  const SCOPES = Object.freeze(["delta", "full"]);
18
9
 
19
10
  function assertScope(scope) {
@@ -27,263 +18,22 @@ function assertCursor(cursor) {
27
18
  if (typeof cursor !== "string" || !CURSOR.test(cursor)) {
28
19
  throw new AccError(EXIT.USAGE,
29
20
  "a cursor is the 16-digit sequence a previous sync returned; "
30
- + "leave it out to start from the beginning",
31
- { cursor });
32
- }
33
- }
34
-
35
- // Attention is computed from explicit rules, never from a hidden classifier.
36
- // Lower priority sorts first.
37
- //
38
- // Exported so a test can prove every kind listed here is reachable. A fifth
39
- // entry once sat here with no rule behind it, which read as a feature in review
40
- // and produced nothing at runtime.
41
- export const ATTENTION_PRIORITY = Object.freeze({
42
- direct_request: 1,
43
- claim_conflict: 2,
44
- task_unblocked: 3,
45
- coordinator_missing: 4,
46
- request_stalled: 5,
47
- claim_expired: 6,
48
- claim_contended: 7,
49
- });
50
-
51
- function directRequests(snapshot, participantId) {
52
- const items = [];
53
- for (const receipt of snapshot.receipts ?? []) {
54
- if (receipt.recipientParticipantId !== participantId) continue;
55
- if (receipt.state === "acknowledged" || receipt.state === "failed") continue;
56
- const message = (snapshot.messages ?? []).find(item => item.messageId === receipt.messageId);
57
- if (message === undefined || !message.requiresAck) continue;
58
- items.push({ kind: "direct_request", priority: ATTENTION_PRIORITY.direct_request,
59
- sourceId: message.messageId, summary: message.subject });
60
- }
61
- return items;
62
- }
63
-
64
- /**
65
- * A claim of yours that has run out.
66
- *
67
- * A lease lapses on the clock, and nothing said so. Measured: while it held, a
68
- * peer's write into the file was refused; three seconds later the same write
69
- * went through, and the holder's turn was identical before and after. It went on
70
- * working on a file it believed it had reserved, and everyone else was free to
71
- * change it.
72
- *
73
- * Only for the session that took it, and only while that session is the one
74
- * asking: a lapsed claim is news to its owner and nobody else's business.
75
- * Re-claiming refreshes the lease and clears this; releasing it clears it too.
76
- */
77
- function expiredClaims(snapshot, session, now) {
78
- if (session == null) return [];
79
- return (snapshot.claims ?? [])
80
- .filter(claim => claim.ownerSessionId === session.sessionId
81
- && Date.parse(claim.expiresAt) <= Date.parse(now))
82
- .map(claim => ({ kind: "claim_expired", priority: ATTENTION_PRIORITY.claim_expired,
83
- sourceId: claim.claimId,
84
- summary: `${claim.resource} - your claim has run out, and peers can write to it` }));
85
- }
86
-
87
- function claimConflicts(snapshot, session, now) {
88
- const mine = (snapshot.intents ?? []).find(intent => intent.sessionId === session?.sessionId);
89
- if (mine === undefined) return [];
90
- return (snapshot.claims ?? [])
91
- .filter(claim => claim.ownerSessionId !== session.sessionId
92
- && Date.parse(claim.expiresAt) > Date.parse(now)
93
- && mine.resourceHints.some(hint => overlaps(hint, claim.resource)))
94
- .map(claim => ({ kind: "claim_conflict", priority: ATTENTION_PRIORITY.claim_conflict,
95
- sourceId: claim.claimId,
96
- summary: `${claim.resource} is claimed by ${claim.ownerSessionId}` }));
97
- }
98
-
99
- /**
100
- * A resource I hold that a peer has said they intend to touch.
101
- *
102
- * The mirror of `claimConflicts`. That one reads my own intent and warns me when
103
- * what I mean to touch is already claimed. This one reads a peer's intent and
104
- * warns me, the holder, that someone is heading for what I claimed. Without it
105
- * intent's only wired reader faced inward: it protected the one declaring intent
106
- * and told the claim holder nothing, so a claim was a wall nobody was told they
107
- * were walking into. A claim is advisory - it does not stop the write - so being
108
- * told early is the whole of the protection it offers.
109
- *
110
- * Only my own claims, and never my own intent against them: declaring intent on
111
- * what you already hold is not someone reaching for it. The peer is named by
112
- * participant where the roster knows it, because a session id cannot be used
113
- * with `--to` and an id a reader cannot act on is the trap the projector warns of.
114
- */
115
- function claimContended(snapshot, session, now) {
116
- if (session === null || session === undefined) return [];
117
- const theirs = (snapshot.intents ?? [])
118
- .filter(intent => intent.sessionId !== session.sessionId);
119
- const nameOf = sessionId => (snapshot.sessions ?? [])
120
- .find(item => item.sessionId === sessionId)?.participantId ?? "a peer";
121
- return (snapshot.claims ?? [])
122
- .filter(claim => claim.ownerSessionId === session.sessionId
123
- && Date.parse(claim.expiresAt) > Date.parse(now))
124
- .flatMap(claim => {
125
- const eyeing = theirs.find(intent =>
126
- (intent.resourceHints ?? []).some(hint => overlaps(hint, claim.resource)));
127
- return eyeing === undefined ? [] : [{
128
- kind: "claim_contended", priority: ATTENTION_PRIORITY.claim_contended,
129
- sourceId: claim.claimId,
130
- summary: `${claim.resource} - ${nameOf(eyeing.sessionId)} means to work on what you hold` }];
131
- });
132
- }
133
-
134
- /**
135
- * Work waiting on me.
136
- *
137
- * Addressed by participant, so a request survives the recipient restarting -
138
- * the next session of that agent is told about it. A task already taken by one
139
- * of my sessions matches too, since that session may have been replaced.
140
- *
141
- * Unaddressed tasks are deliberately absent. Anyone may take one, but pushing
142
- * every open task into every turn is how a coordination layer becomes noise.
143
- */
144
- function unblockedTasks(snapshot, session, participantId) {
145
- const mine = task => (task.assigneeParticipantId !== null
146
- && task.assigneeParticipantId === participantId)
147
- || (task.assigneeSessionId !== null && task.assigneeSessionId === session?.sessionId);
148
- return (snapshot.tasks ?? [])
149
- .filter(task => task.state === "pending" && mine(task))
150
- .map(task => ({ kind: "task_unblocked", priority: ATTENTION_PRIORITY.task_unblocked,
151
- sourceId: task.taskId, summary: task.title }));
152
- }
153
-
154
- /**
155
- * Work you asked for that nobody is doing any more.
156
- *
157
- * A task taken by a session that then crashed stayed `in_progress` for good:
158
- * the requester was told nothing and nobody else could take it. Unlike the
159
- * one-shot answers a request produces, this repeats until it is resolved,
160
- * because it stays true until someone picks the work back up.
161
- */
162
- /**
163
- * A question nobody is left to answer.
164
- *
165
- * The task rule below tells a requester when work they asked for is going
166
- * nowhere. A `requiresAck` message had no such rule, and a message is the other
167
- * half of the same act: an agent asked a peer a direct question, the peer's
168
- * session ended without answering, and the asker's next turn was empty. Not
169
- * "still waiting" - empty. Measured, with the only other agent gone and an
170
- * unanswered question standing between them.
171
- *
172
- * The same kind as the task case, because it is the same fact about the world:
173
- * you asked, and there is nobody there.
174
- */
175
- function unansweredQuestions(snapshot, participantId, onlineParticipants) {
176
- const items = [];
177
- for (const receipt of snapshot.receipts ?? []) {
178
- if (receipt.state === "acknowledged" || receipt.state === "failed") continue;
179
- const message = (snapshot.messages ?? [])
180
- .find(item => item.messageId === receipt.messageId);
181
- if (message === undefined || !message.requiresAck) continue;
182
- if (message.fromParticipantId !== participantId) continue;
183
- // Not answered yet by someone who is here is ordinary waiting, and saying so
184
- // every turn would be noise the reader learns to skip.
185
- if (onlineParticipants.has(receipt.recipientParticipantId)) continue;
186
- items.push({ kind: "request_stalled", priority: ATTENTION_PRIORITY.request_stalled,
187
- sourceId: message.messageId,
188
- summary: `${message.subject} - ${receipt.recipientParticipantId} is not here to answer` });
189
- }
190
- return items;
191
- }
192
-
193
- function stalledRequests(snapshot, participantId, now, pidIsAlive) {
194
- // Classified once per session and reused below, for the same reason
195
- // collectStatus takes one reading: classifySessionPresence calls pidIsAlive,
196
- // a real process.kill(pid, 0) syscall for a session with a recorded pid, so
197
- // it is not pure given `now` any more. A second classifying pass here could
198
- // disagree with the first - a client exiting between them would leave `live`
199
- // saying "online" while a freshly-computed `onlineParticipants` had already
200
- // dropped it, inside one attention computation.
201
- const live = new Map((snapshot.sessions ?? [])
202
- .map(session => [session.sessionId, classifySessionPresence(session, now, pidIsAlive)]));
203
- const onlineParticipants = new Set((snapshot.sessions ?? [])
204
- .filter(session => live.get(session.sessionId) === "online")
205
- .map(session => session.participantId));
206
- const goingNowhere = task => {
207
- // Taken by someone who has gone quiet.
208
- if (task.state === "in_progress") {
209
- return task.assigneeSessionId !== null
210
- && live.get(task.assigneeSessionId) !== "online";
211
- }
212
- // Or waiting on an agent that is not here - including one that closed and
213
- // never came back, which leaves the work addressed to nobody at all.
214
- return task.state === "pending" && task.assigneeParticipantId !== null
215
- && !onlineParticipants.has(task.assigneeParticipantId);
216
- };
217
- return [
218
- ...(snapshot.tasks ?? [])
219
- .filter(task => task.requestedByParticipantId === participantId
220
- && goingNowhere(task))
221
- .map(task => ({ kind: "request_stalled", priority: ATTENTION_PRIORITY.request_stalled,
222
- sourceId: task.taskId,
223
- summary: `${task.title} - nobody is working on it` })),
224
- ...unansweredQuestions(snapshot, participantId, onlineParticipants),
225
- ];
226
- }
227
-
228
- function coordinatorGaps(snapshot) {
229
- return (snapshot.workstreams ?? [])
230
- .filter(workstream => workstream.state === "open"
231
- && workstream.coordinatorSessionId === null)
232
- .map(workstream => ({ kind: "coordinator_missing",
233
- priority: ATTENTION_PRIORITY.coordinator_missing,
234
- sourceId: workstream.workstreamId, summary: workstream.title }));
235
- }
236
-
237
- export function computeAttention(snapshot, { session, participantId, now, pidIsAlive }) {
238
- // Required unconditionally, not only when there happen to be sessions to
239
- // classify: `stalledRequests` reaches `classifySessionPresence` only inside a
240
- // map/filter over `snapshot.sessions`, so an empty or absent roster let a
241
- // missing probe through with nothing to trip over it - the same silent pass
242
- // the classifier's own required parameter exists to close, one layer up.
243
- if (typeof pidIsAlive !== "function") {
244
- throw new AccError(EXIT.USAGE, "computeAttention requires a pidIsAlive probe", {});
21
+ + "leave it out to start from the beginning", { cursor });
245
22
  }
246
- return [
247
- ...directRequests(snapshot, participantId),
248
- ...claimConflicts(snapshot, session, now),
249
- ...claimContended(snapshot, session, now),
250
- ...expiredClaims(snapshot, session, now),
251
- ...unblockedTasks(snapshot, session, participantId),
252
- ...coordinatorGaps(snapshot),
253
- ...stalledRequests(snapshot, participantId, now, pidIsAlive),
254
- ].sort((left, right) => left.priority - right.priority
255
- || left.sourceId.localeCompare(right.sourceId));
256
23
  }
257
24
 
258
25
  export function createSyncService(ports, sessions) {
259
26
  const { store, clock, pidIsAlive } = ports;
260
27
 
261
- /**
262
- * Any session may request the full Workspace scope. Peer equality is a
263
- * knowledge property: no session receives a reduced
264
- * view because of its role. The bounded delta is only the ambient default.
265
- */
266
28
  async function sync(input = {}) {
267
- // A cursor that is not a cursor answered "nothing new", every time, for as
268
- // long as it was held. `eventsSince` compares sequences as strings, so
269
- // `not-a-cursor` sorts after every event there has ever been - and an
270
- // adapter holding a corrupt one, or an agent that invented one, saw a quiet
271
- // workspace rather than a mistake. `"0000000000000001; DROP"` was quietly
272
- // taken as the sequence it starts with.
273
29
  if (input.cursor != null) assertCursor(input.cursor);
274
30
  assertScope(input.scope);
275
31
  const workspaceId = input.workspaceId ?? store.workspaceId;
276
32
  const now = clock.now();
277
33
  const located = input.sessionId === undefined
278
- ? null
279
- : await sessions.locateSession(input.sessionId, workspaceId);
34
+ ? null : await sessions.locateSession(input.sessionId, workspaceId);
280
35
  const session = located?.record ?? null;
281
-
282
36
  const durable = await store.snapshot(workspaceId);
283
- // A workspace that has not materialised still has a truthful roster: its
284
- // sessions live in the ephemeral area. Reading only the durable snapshot
285
- // would make a lone session invisible to itself, and would disagree with
286
- // what `status` reports from the same state.
287
37
  const snapshot = durable.workspace !== null
288
38
  ? durable
289
39
  : { ...durable,
@@ -293,7 +43,6 @@ export function createSyncService(ports, sessions) {
293
43
  input.limit ?? DEFAULT_LIMIT);
294
44
  const attention = computeAttention(snapshot, { session,
295
45
  participantId: session?.participantId ?? input.participantId, now, pidIsAlive });
296
-
297
46
  const roster = snapshot.sessions.map(item => ({
298
47
  sessionId: item.sessionId,
299
48
  participantId: item.participantId,
@@ -302,14 +51,10 @@ export function createSyncService(ports, sessions) {
302
51
  branch: item.branch ?? null,
303
52
  presence: classifySessionPresence(item, now, pidIsAlive),
304
53
  }));
305
-
306
- // Solo zero-overhead: one live session, no claims and
307
- // no attention means an empty result, not a "nothing to report" banner.
308
54
  const peers = roster.filter(item => item.sessionId !== session?.sessionId
309
55
  && item.presence !== "offline");
310
56
  const solo = peers.length === 0 && attention.length === 0
311
57
  && snapshot.claims.length === 0;
312
-
313
58
  return {
314
59
  cursor: page.cursor,
315
60
  scope: input.scope === "full" ? "full" : "delta",
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@agents-can-communicate/delivery-router",
3
+ "version": "0.2.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.mjs"
8
+ },
9
+ "files": [
10
+ "src/"
11
+ ]
12
+ }
@@ -0,0 +1 @@
1
+ export { createDeliveryRouter } from "./router.mjs";