agents-can-communicate 0.3.0 → 0.4.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 (157) hide show
  1. package/README.md +112 -91
  2. package/SECURITY.md +8 -5
  3. package/bin/acc-bootstrap.mjs +5 -54
  4. package/bin/acc-claude-channel.mjs +8 -175
  5. package/bin/acc-hook.mjs +4 -133
  6. package/bin/acc-mcp.mjs +4 -59
  7. package/bin/acc-update-worker.mjs +8 -0
  8. package/bin/acc.mjs +5 -39
  9. package/bin/entrypoints/acc-bootstrap.mjs +47 -0
  10. package/bin/entrypoints/acc-claude-channel.mjs +149 -0
  11. package/bin/entrypoints/acc-hook.mjs +58 -0
  12. package/bin/entrypoints/acc-mcp.mjs +70 -0
  13. package/bin/entrypoints/acc.mjs +51 -0
  14. package/bin/entrypoints/claude-channel-stdio.mjs +29 -0
  15. package/bin/entrypoints/hook-output.mjs +73 -0
  16. package/docs/ADAPTER_AUTHORING.md +62 -24
  17. package/docs/ARCHITECTURE.md +79 -20
  18. package/docs/CAPABILITIES.md +52 -30
  19. package/docs/CLI.md +164 -35
  20. package/docs/CONCEPTS.md +97 -93
  21. package/docs/CONFIGURATION.md +66 -45
  22. package/docs/DESIGN_DECISIONS.md +24 -15
  23. package/docs/GETTING_STARTED.md +124 -118
  24. package/docs/GLOSSARY.md +12 -6
  25. package/docs/HOW_IT_WORKS.md +136 -229
  26. package/docs/MCP.md +98 -31
  27. package/docs/PROTOCOL.md +76 -14
  28. package/docs/RELEASING.md +128 -86
  29. package/docs/SECURITY_MODEL.md +52 -15
  30. package/docs/TROUBLESHOOTING.md +88 -18
  31. package/docs/UPGRADING.md +119 -0
  32. package/docs/WHY_ACC.md +19 -16
  33. package/docs/index.md +40 -24
  34. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  35. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +112 -9
  36. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +6 -6
  37. package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +2 -1
  38. package/node_modules/@agents-can-communicate/adapter-codex/certification.json +38 -13
  39. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +220 -0
  40. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-current-binding-product-evidence.json +1549 -0
  41. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-current-binding-product.json +27 -0
  42. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-product-evidence.json +1549 -0
  43. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-product.json +27 -0
  44. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-transport-evidence.json +259 -0
  45. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-transport.json +21 -0
  46. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-current-binding-product-evidence.json +1549 -0
  47. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-current-binding-product.json +27 -0
  48. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-product-evidence.json +1549 -0
  49. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-product.json +27 -0
  50. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-transport-evidence.json +259 -0
  51. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-transport.json +21 -0
  52. package/node_modules/@agents-can-communicate/adapter-codex/package.json +14 -2
  53. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +111 -8
  54. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +26 -44
  55. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +70 -17
  56. package/node_modules/@agents-can-communicate/adapter-codex/src/config-block.mjs +231 -0
  57. package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +1 -1
  58. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +71 -63
  59. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +107 -103
  60. package/node_modules/@agents-can-communicate/adapter-codex/src/native-endpoint.mjs +96 -0
  61. package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +3 -2
  62. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  63. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +111 -8
  64. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  65. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +2 -2
  66. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  67. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +111 -8
  68. package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +2 -2
  69. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  70. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +111 -8
  71. package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +1 -1
  72. package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +2 -2
  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 +5 -0
  75. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +26 -4
  76. package/node_modules/@agents-can-communicate/adapter-sdk/src/decision-text.mjs +19 -0
  77. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +5 -3
  78. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -0
  79. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +9 -2
  80. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +2 -6
  81. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +29 -9
  82. package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-block.mjs +8 -6
  83. package/node_modules/@agents-can-communicate/cli/package.json +4 -2
  84. package/node_modules/@agents-can-communicate/cli/src/args.mjs +49 -22
  85. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +11 -4
  86. package/node_modules/@agents-can-communicate/cli/src/git-probe.mjs +9 -3
  87. package/node_modules/@agents-can-communicate/cli/src/help.mjs +109 -14
  88. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +66 -18
  89. package/node_modules/@agents-can-communicate/cli/src/main.mjs +35 -12
  90. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +110 -0
  91. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/command.mjs +63 -0
  92. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +71 -0
  93. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +110 -0
  94. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +167 -0
  95. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/install.mjs +73 -0
  96. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +60 -0
  97. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/leases.mjs +60 -0
  98. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/location.mjs +55 -0
  99. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/mutex.mjs +145 -0
  100. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/policy.mjs +4 -0
  101. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +40 -0
  102. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +21 -0
  103. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +122 -0
  104. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +65 -0
  105. package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +33 -108
  106. package/node_modules/@agents-can-communicate/cli/src/update-command.mjs +25 -7
  107. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  108. package/node_modules/@agents-can-communicate/core/src/attention.mjs +3 -0
  109. package/node_modules/@agents-can-communicate/core/src/claims.mjs +13 -8
  110. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +48 -24
  111. package/node_modules/@agents-can-communicate/core/src/decision-changes.mjs +36 -0
  112. package/node_modules/@agents-can-communicate/core/src/decision-state.mjs +42 -0
  113. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +11 -4
  114. package/node_modules/@agents-can-communicate/core/src/inbox.mjs +48 -17
  115. package/node_modules/@agents-can-communicate/core/src/intents.mjs +35 -15
  116. package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +7 -5
  117. package/node_modules/@agents-can-communicate/core/src/message-pages.mjs +75 -0
  118. package/node_modules/@agents-can-communicate/core/src/next-turn-delivery.mjs +31 -0
  119. package/node_modules/@agents-can-communicate/core/src/session-opening.mjs +128 -0
  120. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +58 -132
  121. package/node_modules/@agents-can-communicate/core/src/sync.mjs +31 -2
  122. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  123. package/node_modules/@agents-can-communicate/delivery-router/src/refresh-binding.mjs +38 -0
  124. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +86 -10
  125. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  126. package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +1 -0
  127. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +6 -4
  128. package/node_modules/@agents-can-communicate/hook-runner/src/native-retirement.mjs +48 -0
  129. package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +1 -1
  130. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +175 -67
  131. package/node_modules/@agents-can-communicate/hook-runner/src/session-lifecycle.mjs +13 -0
  132. package/node_modules/@agents-can-communicate/installer/package.json +4 -2
  133. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +17 -2
  134. package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +11 -5
  135. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +1 -1
  136. package/node_modules/@agents-can-communicate/installer/src/index.mjs +1 -0
  137. package/node_modules/@agents-can-communicate/installer/src/live-policy.mjs +21 -0
  138. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +20 -6
  139. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +10 -4
  140. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +3 -3
  141. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  142. package/node_modules/@agents-can-communicate/mcp-server/src/protocol.mjs +85 -0
  143. package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +3 -2
  144. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +59 -57
  145. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +47 -33
  146. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  147. package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +3 -0
  148. package/node_modules/@agents-can-communicate/protocol/src/decision-changes.mjs +15 -0
  149. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +3 -1
  150. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +14 -5
  151. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  152. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +5 -1
  153. package/node_modules/@agents-can-communicate/storage-filesystem/src/deadline.mjs +7 -0
  154. package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +2 -0
  155. package/node_modules/@agents-can-communicate/storage-filesystem/src/session-read.mjs +25 -0
  156. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +28 -25
  157. package/package.json +2 -1
@@ -0,0 +1,36 @@
1
+ import { AccError, EXIT, assertDecisionChange } from "@agents-can-communicate/protocol";
2
+
3
+ // Resolve inherited recipients before retry comparison. Target messages and
4
+ // their recipient sets are immutable, so receipt advancement cannot alter a retry.
5
+ export function prepareDecisionChange(tx, input, session) {
6
+ if (input.decisionChange !== undefined) {
7
+ throw new AccError(EXIT.USAGE, "send a decision change using supersedes or withdraws");
8
+ }
9
+ if (input.supersedes === undefined && input.withdraws === undefined) return input;
10
+ if (input.kind !== "decision" || (input.supersedes !== undefined && input.withdraws !== undefined)) {
11
+ throw new AccError(EXIT.USAGE, "only decisions accept either supersedes or withdraws, never both");
12
+ }
13
+ const decisionChange = assertDecisionChange({
14
+ action: input.supersedes === undefined ? "withdraw" : "replace",
15
+ messageIds: input.supersedes ?? input.withdraws,
16
+ });
17
+ decisionChange.messageIds = [...decisionChange.messageIds].sort();
18
+ const targets = decisionChange.messageIds.map(messageId => {
19
+ const message = tx.get("message", messageId);
20
+ if (message?.kind !== "decision" || message.workspaceId !== session.workspaceId) {
21
+ throw new AccError(EXIT.DATA, "a decision change must reference existing decisions in this workspace",
22
+ { messageId });
23
+ }
24
+ return message;
25
+ });
26
+ if (new Set(input.toParticipantIds).size !== input.toParticipantIds.length) {
27
+ throw new AccError(EXIT.USAGE, "a participant may be addressed only once");
28
+ }
29
+ const targetIds = new Set(decisionChange.messageIds);
30
+ const inherited = [...targets.map(message => message.fromParticipantId),
31
+ ...tx.list("receipt", receipt => targetIds.has(receipt.messageId))
32
+ .map(receipt => receipt.recipientParticipantId)]
33
+ .filter(participantId => participantId !== session.participantId);
34
+ return { ...input, decisionChange,
35
+ toParticipantIds: [...new Set([...input.toParticipantIds, ...inherited])].sort() };
36
+ }
@@ -0,0 +1,42 @@
1
+ // Lifecycle is a read model of explicit peer assertions, never a timestamp
2
+ // winner or a consensus verdict. Iterative traversal avoids recursion limits.
3
+ export function decisionView(messages) {
4
+ const decisions = new Map(messages.filter(m => m.kind === "decision").map(m => [m.messageId, m]));
5
+ const neighbours = new Map([...decisions.keys()].map(id => [id, new Set()]));
6
+ const replaced = new Set();
7
+ for (const message of decisions.values()) {
8
+ for (const target of message.decisionChange?.messageIds ?? []) {
9
+ if (!decisions.has(target)) continue;
10
+ neighbours.get(target).add(message.messageId);
11
+ neighbours.get(message.messageId).add(target);
12
+ replaced.add(target);
13
+ }
14
+ }
15
+ const statuses = new Map();
16
+ for (const start of decisions.keys()) {
17
+ if (statuses.has(start)) continue;
18
+ const group = new Set([start]), pending = [start];
19
+ while (pending.length > 0) {
20
+ for (const id of neighbours.get(pending.pop())) {
21
+ if (!group.has(id)) { group.add(id); pending.push(id); }
22
+ }
23
+ }
24
+ const heads = [...group].filter(id => !replaced.has(id));
25
+ const hasDecision = heads.some(id => decisions.get(id).decisionChange?.action !== "withdraw");
26
+ const roots = [...group].filter(id => decisions.get(id).decisionChange === undefined);
27
+ const groupId = (roots.length > 0 ? roots : [...group]).sort()[0];
28
+ for (const id of group) {
29
+ const isHead = !replaced.has(id);
30
+ statuses.set(id, {
31
+ state: isHead ? (decisions.get(id).decisionChange?.action === "withdraw" ? "withdrawn" : "current")
32
+ : (hasDecision ? "superseded" : "withdrawn"),
33
+ isHead, conflicted: heads.length !== 1, headCount: heads.length,
34
+ currentMessageId: heads.length === 1 ? heads[0] : null, groupId,
35
+ });
36
+ }
37
+ }
38
+ return message => statuses.has(message.messageId)
39
+ ? { ...message, decisionStatus: statuses.get(message.messageId) } : message;
40
+ }
41
+
42
+ export const isCurrentDecision = message => message.decisionStatus?.isHead !== false;
@@ -81,18 +81,24 @@ export function createDeliveryBindingService(ports, sessions) {
81
81
  // delete() takes the same mutex, so this is the one atomic form: a stale
82
82
  // generation cannot retire a successor's endpoint, and an absent or
83
83
  // already-retired binding stays as it is.
84
+ // A caller that captured an endpoint may also scope retirement to that exact
85
+ // address. This protects a same-generation re-handshake that publishes while
86
+ // an older bounded cleanup call is still waiting to enter the writer lock.
84
87
  //
85
88
  // The retirement is recorded as its own fact rather than as an expired lease.
86
89
  // Expiry and retirement used to be the same edit, so a channel still renewing
87
90
  // its endpoint could extend a binding the session had already given up.
88
- async function clearDeliveryBinding({ sessionId, generation }) {
91
+ async function clearDeliveryBinding(input) {
92
+ const { sessionId, generation } = input;
93
+ const endpointScoped = Object.hasOwn(input, "opaqueEndpointRef");
89
94
  await store.ephemeral.update("deliveryBinding", sessionId, async current => {
90
95
  if (current === null || current === undefined) return null;
91
96
  if (current.generation !== generation) throw conflict(sessionId);
97
+ if (endpointScoped && current.opaqueEndpointRef !== input.opaqueEndpointRef) return null;
92
98
  if (current.retiredAt !== null && current.retiredAt !== undefined) return null;
93
99
  const now = clock.now();
94
100
  return { ...current, leaseUntil: now, retiredAt: now };
95
- });
101
+ }, { deadlineAt: input.deadlineAt });
96
102
  }
97
103
 
98
104
  /**
@@ -120,9 +126,10 @@ export function createDeliveryBindingService(ports, sessions) {
120
126
  });
121
127
  }
122
128
 
123
- async function listDeliveryBindings({ participantId, now }) {
129
+ async function listDeliveryBindings({ participantId, now, includeExpired = false }) {
124
130
  return (await currentBindings(now))
125
- .filter(item => item.participantId === participantId && item.reachable)
131
+ .filter(item => item.participantId === participantId
132
+ && (includeExpired || item.reachable))
126
133
  .map(item => item.binding);
127
134
  }
128
135
 
@@ -2,6 +2,8 @@ import { AccError, EXIT, SCHEMA_VERSION, advanceReceipt }
2
2
  from "@agents-can-communicate/protocol";
3
3
 
4
4
  import { receiptId, recordMessageInTransaction } from "./conversations.mjs";
5
+ import { messagePage, messageSummary } from "./message-pages.mjs";
6
+ import { decisionView, isCurrentDecision } from "./decision-state.mjs";
5
7
 
6
8
  const listable = (message, receipt) => receipt.state === "queued" || receipt.state === "offered"
7
9
  || (receipt.state === "retrieved" && message.obligation !== "none");
@@ -9,14 +11,19 @@ const listable = (message, receipt) => receipt.state === "queued" || receipt.sta
9
11
  export function createInboxService(ports, sessions) {
10
12
  const { store, clock, ids } = ports;
11
13
 
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) {
14
+ async function requireOpen(input, action, tx) {
15
+ // A solo inbox may have no durable session yet. Inside a transaction the
16
+ // same writer lock also protects this ephemeral read from replacement.
17
+ const current = tx === undefined
18
+ ? (await sessions.locateSession(input.sessionId, input.workspaceId))?.record
19
+ : tx.get("session", input.sessionId)
20
+ ?? await store.ephemeral.get("session", input.sessionId);
21
+ if (current == null || current.state !== "open"
22
+ || current.generation !== input.generation) {
16
23
  throw new AccError(EXIT.CONFLICT, `cannot ${action} from this session generation`,
17
24
  { sessionId: input.sessionId });
18
25
  }
19
- return located.record;
26
+ return current;
20
27
  }
21
28
 
22
29
  function requireOwnedReceipt(tx, session, messageId) {
@@ -46,19 +53,38 @@ export function createInboxService(ports, sessions) {
46
53
  return { message: owned.message, receipt };
47
54
  }
48
55
 
56
+ async function listInbox(input) {
57
+ const session = await requireOpen(input, "list the inbox");
58
+ return store.transaction(async tx => {
59
+ await requireOpen(input, "list the inbox", tx);
60
+ const receipts = new Map(tx.list("receipt", receipt =>
61
+ receipt.recipientParticipantId === session.participantId)
62
+ .map(receipt => [receipt.messageId, receipt]));
63
+ const all = tx.list("message"), view = decisionView(all);
64
+ const messages = all.filter(message => receipts.has(message.messageId)).map(view);
65
+ return messagePage(messages, input, {
66
+ include: message => isCurrentDecision(message) && listable(message, receipts.get(message.messageId)),
67
+ project: message => ({ message: messageSummary(message),
68
+ receipt: receipts.get(message.messageId) }),
69
+ });
70
+ }, { kinds: ["session", "message", "receipt"] });
71
+ }
72
+
49
73
  async function readInbox(input) {
50
74
  const session = await requireOpen(input, "read the inbox");
51
75
  const now = clock.now();
52
- return store.transaction(tx => {
53
- const messages = new Map(tx.list("message").map(item => [item.messageId, item]));
76
+ return store.transaction(async tx => {
77
+ await requireOpen(input, "read the inbox", tx);
78
+ const all = tx.list("message"), view = decisionView(all);
79
+ const messages = new Map(all.map(item => [item.messageId, view(item)]));
54
80
  let selected = tx.list("receipt", receipt =>
55
81
  receipt.recipientParticipantId === session.participantId)
56
82
  .filter(receipt => messages.has(receipt.messageId));
57
83
  if (input.messageId === undefined) {
58
- selected = selected.filter(receipt => listable(messages.get(receipt.messageId), receipt));
84
+ selected = selected.filter(receipt => listable(messages.get(receipt.messageId), receipt)
85
+ && isCurrentDecision(messages.get(receipt.messageId)));
59
86
  } else {
60
- selected = selected.filter(receipt => receipt.messageId === input.messageId
61
- && receipt.state !== "acknowledged");
87
+ selected = selected.filter(receipt => receipt.messageId === input.messageId);
62
88
  if (selected.length === 0) {
63
89
  throw new AccError(EXIT.CONFLICT,
64
90
  "that message is not recoverable by this participant",
@@ -70,17 +96,22 @@ export function createInboxService(ports, sessions) {
70
96
  const b = messages.get(right.messageId);
71
97
  return a.sentAt.localeCompare(b.sentAt) || a.messageId.localeCompare(b.messageId);
72
98
  });
73
- return selected.map(receipt => advanceOwned(tx, session, receipt.messageId,
74
- "retrieved", now));
75
- }, { kinds: ["message", "receipt"] });
99
+ // An exact read can inspect a resolved message. Inspection must neither
100
+ // move its receipt backward nor refresh its timestamp or emit an event.
101
+ return selected.map(receipt => receipt.state === "acknowledged"
102
+ ? { message: messages.get(receipt.messageId), receipt }
103
+ : { ...advanceOwned(tx, session, receipt.messageId, "retrieved", now),
104
+ message: messages.get(receipt.messageId) });
105
+ }, { kinds: ["session", "message", "receipt"] });
76
106
  }
77
107
 
78
108
  async function acknowledgeMessage(input) {
79
109
  const session = await requireOpen(input, "acknowledge a message");
80
110
  const now = clock.now();
81
- return store.transaction(tx => advanceOwned(tx, session, input.messageId,
82
- "acknowledged", now).receipt,
83
- { kinds: ["message", "receipt"] });
111
+ return store.transaction(async tx => {
112
+ await requireOpen(input, "acknowledge a message", tx);
113
+ return advanceOwned(tx, session, input.messageId, "acknowledged", now).receipt;
114
+ }, { kinds: ["session", "message", "receipt"] });
84
115
  }
85
116
 
86
117
  async function replyToMessage(input) {
@@ -114,5 +145,5 @@ export function createInboxService(ports, sessions) {
114
145
  }, { kinds: ["participant", "session", "message", "receipt"] });
115
146
  }
116
147
 
117
- return { readInbox, replyToMessage, acknowledgeMessage };
148
+ return { listInbox, readInbox, replyToMessage, acknowledgeMessage };
118
149
  }
@@ -9,24 +9,26 @@ import { isMaterialised } from "./materialisation.mjs";
9
9
  export function createIntentService(ports, sessions) {
10
10
  const { store, clock, ids } = ports;
11
11
 
12
- async function requireOwner(sessionId, workspaceId, generation) {
13
- const existing = await sessions.locateSession(sessionId, workspaceId);
14
- if (existing === null) {
12
+ async function requireOwner(sessionId, workspaceId, generation, tx) {
13
+ const current = tx === undefined
14
+ ? (await sessions.locateSession(sessionId, workspaceId))?.record
15
+ : tx.get("session", sessionId);
16
+ if (current == null) {
15
17
  throw new AccError(EXIT.CONFLICT, "session is not open", { sessionId });
16
18
  }
17
- if (existing.record.generation !== generation) {
19
+ if (current.generation !== generation) {
18
20
  throw new AccError(EXIT.CONFLICT, "intent belongs to a replaced session generation",
19
- { sessionId, expected: generation, actual: existing.record.generation });
21
+ { sessionId, expected: generation, actual: current.generation });
20
22
  }
21
- if (existing.record.state !== "open") {
23
+ if (current.state !== "open") {
22
24
  throw new AccError(EXIT.CONFLICT, "a closed session cannot publish intent", { sessionId });
23
25
  }
24
- return existing;
26
+ return current;
25
27
  }
26
28
 
27
29
  async function setIntent(input) {
28
30
  const owner = await requireOwner(input.sessionId, input.workspaceId, input.generation);
29
- const workspaceId = owner.record.workspaceId;
31
+ const workspaceId = owner.workspaceId;
30
32
  const now = clock.now();
31
33
  const intent = validateRecord("intent", {
32
34
  schemaVersion: SCHEMA_VERSION,
@@ -40,34 +42,52 @@ export function createIntentService(ports, sessions) {
40
42
  });
41
43
 
42
44
  if (!await isMaterialised(store, workspaceId)) {
43
- await store.ephemeral.put("intent", input.sessionId, intent);
44
- return intent;
45
+ const written = await store.ephemeral.update("intent", input.sessionId, async () => {
46
+ // Promotion may have won since the branch above. Never publish a new
47
+ // ephemeral copy that durable readers cannot see or cleanup could erase.
48
+ if (await isMaterialised(store, workspaceId)) return null;
49
+ await requireOwner(input.sessionId, workspaceId, input.generation);
50
+ return intent;
51
+ });
52
+ if (written !== null) return written;
45
53
  }
46
54
  await store.transaction(async tx => {
55
+ await requireOwner(input.sessionId, workspaceId, input.generation, tx);
47
56
  tx.put("intent", input.sessionId, intent, tx.generationOf("intent", input.sessionId));
48
57
  tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
49
58
  actorSessionId: input.sessionId, type: "intent.published", occurredAt: now,
50
59
  payload: { mode: intent.mode, state: intent.state } });
51
- }, { kinds: ["intent"] });
60
+ }, { kinds: ["session", "intent"] });
52
61
  return intent;
53
62
  }
54
63
 
55
64
  async function clearIntent(input) {
56
65
  const owner = await requireOwner(input.sessionId, input.workspaceId, input.generation);
57
- const workspaceId = owner.record.workspaceId;
66
+ const workspaceId = owner.workspaceId;
58
67
  if (!await isMaterialised(store, workspaceId)) {
59
- await store.ephemeral.delete("intent", input.sessionId);
60
- return;
68
+ let cleared = false;
69
+ await store.ephemeral.delete("intent", input.sessionId, async () => {
70
+ if (await isMaterialised(store, workspaceId)) return false;
71
+ await requireOwner(input.sessionId, workspaceId, input.generation);
72
+ cleared = true;
73
+ return true;
74
+ });
75
+ if (cleared) return;
76
+ // A missing intent bypasses the delete guard. A close may have removed
77
+ // it, or promotion may have moved it to durable storage while we waited.
78
+ await requireOwner(input.sessionId, workspaceId, input.generation);
79
+ if (!await isMaterialised(store, workspaceId)) return;
61
80
  }
62
81
  const now = clock.now();
63
82
  await store.transaction(async tx => {
83
+ await requireOwner(input.sessionId, workspaceId, input.generation, tx);
64
84
  const current = tx.get("intent", input.sessionId);
65
85
  if (current === null) return;
66
86
  tx.put("intent", input.sessionId, { ...current, state: "done", updatedAt: now },
67
87
  tx.generationOf("intent", input.sessionId));
68
88
  tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
69
89
  actorSessionId: input.sessionId, type: "intent.cleared", occurredAt: now, payload: {} });
70
- }, { kinds: ["intent"] });
90
+ }, { kinds: ["session", "intent"] });
71
91
  }
72
92
 
73
93
  return { setIntent, clearIntent };
@@ -24,13 +24,15 @@ export async function isMaterialised(store, workspaceId) {
24
24
  export async function materialise({ store, clock, ids }, { workspaceId, descriptor, reason }) {
25
25
  const now = clock.now();
26
26
  const staged = [];
27
- for (const entry of EPHEMERAL_KINDS) {
28
- staged.push({ ...entry, records: await store.ephemeral.list(entry.kind) });
29
- }
30
- const actorSessionId = staged.find(entry => entry.kind === "session")?.records.at(-1)?.sessionId
31
- ?? "session_bootstrap";
32
27
 
33
28
  await store.transaction(async tx => {
29
+ // Ephemeral writers use this same mutex. Reading before taking it could
30
+ // resurrect a closed owner or replace an intent with an older snapshot.
31
+ for (const entry of EPHEMERAL_KINDS) {
32
+ staged.push({ ...entry, records: await store.ephemeral.list(entry.kind) });
33
+ }
34
+ const actorSessionId = staged.find(entry => entry.kind === "session")?.records.at(-1)?.sessionId
35
+ ?? "session_bootstrap";
34
36
  // Asked again inside the lock. `ensureMaterialised` asks before taking it,
35
37
  // which is a check-then-act across processes: agents starting together in a
36
38
  // workspace neither had opened - the ordinary way two agents start - all saw
@@ -0,0 +1,75 @@
1
+ import { AccError, EXIT, MESSAGE_KINDS, assertPortableId }
2
+ from "@agents-can-communicate/protocol";
3
+
4
+ const DEFAULT_LIMIT = 20;
5
+ const PAGE_BYTES = 12_000;
6
+ const usage = message => { throw new AccError(EXIT.USAGE, message); };
7
+ const newestFirst = (a, b) => b.sentAt.localeCompare(a.sentAt)
8
+ || b.messageId.localeCompare(a.messageId);
9
+
10
+ export function assertMessageId(value, label = "message id") {
11
+ try { assertPortableId(value, label); }
12
+ catch { usage(`${label} must be a complete message id returned by ACC`); }
13
+ }
14
+
15
+ function shortSubject(subject) {
16
+ if (Buffer.byteLength(subject) <= 160) return subject;
17
+ let text = "";
18
+ for (const character of subject) {
19
+ if (Buffer.byteLength(text + character + "…") > 160) break;
20
+ text += character;
21
+ }
22
+ return `${text}…`;
23
+ }
24
+
25
+ export function messageSummary(message) {
26
+ // An allowlist keeps artifacts, handoff details, and future peer payload
27
+ // fields out of discovery. A summary is not a retrieved message body.
28
+ return {
29
+ messageId: message.messageId, threadId: message.threadId,
30
+ fromParticipantId: message.fromParticipantId, fromSessionId: message.fromSessionId,
31
+ kind: message.kind, obligation: message.obligation,
32
+ subject: shortSubject(message.subject), sentAt: message.sentAt,
33
+ inReplyTo: message.inReplyTo, bodyBytes: Buffer.byteLength(message.body),
34
+ artifactCount: message.artifacts.length, trust: "untrusted peer content",
35
+ ...(message.decisionStatus === undefined ? {} : { decisionStatus: message.decisionStatus }),
36
+ };
37
+ }
38
+
39
+ export function messagePage(messages, input, { include = () => true,
40
+ project = messageSummary, metadata = {} } = {}) {
41
+ const limit = input.limit === undefined ? DEFAULT_LIMIT : input.limit;
42
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
43
+ usage("message list limit must be an integer from 1 to 500");
44
+ }
45
+ if (input.kind !== undefined && !MESSAGE_KINDS.includes(input.kind)) {
46
+ usage(`message type is one of ${MESSAGE_KINDS.join(", ")}`);
47
+ }
48
+ let anchor = null;
49
+ if (input.cursor != null) {
50
+ assertMessageId(input.cursor, "message cursor");
51
+ anchor = messages.find(message => message.messageId === input.cursor);
52
+ if (anchor === undefined || (input.kind !== undefined && anchor.kind !== input.kind)) {
53
+ usage("message cursor is not in this message list; start again without a cursor");
54
+ }
55
+ }
56
+ // Resolve the anchor before filtering pending state. Reading or resolving
57
+ // that message between pages must not shift the next page as an offset would.
58
+ const selected = messages.filter(message => include(message)
59
+ && (input.kind === undefined || message.kind === input.kind)
60
+ && (anchor === null || newestFirst(message, anchor) > 0)).sort(newestFirst);
61
+ const items = [];
62
+ for (const message of selected.slice(0, limit)) {
63
+ const item = project(message);
64
+ // Reserve a complete continuation id even when count, rather than bytes,
65
+ // eventually ends the page. Never slice a summary or a recovery id in half.
66
+ const candidate = { ...metadata, items: [...items, item], nextCursor: message.messageId };
67
+ if (Buffer.byteLength(JSON.stringify(candidate, null, 2)) > PAGE_BYTES) break;
68
+ items.push(item);
69
+ }
70
+ if (selected.length > 0 && items.length === 0) {
71
+ throw new AccError(EXIT.DATA, "one message summary exceeds the page budget");
72
+ }
73
+ return { ...metadata, items, nextCursor: items.length < selected.length
74
+ ? selected[items.length - 1].messageId : null };
75
+ }
@@ -0,0 +1,31 @@
1
+ export async function nextTurnDelivery(store, input = {}) {
2
+ if (typeof input.participantId !== "string" || input.participantId === "") {
3
+ return { queuedMessages: [], liveOfferedMessageIds: [], reminderMessageIds: [],
4
+ roomMessageIds: [] };
5
+ }
6
+ const snapshot = await store.snapshot(input.workspaceId ?? store.workspaceId,
7
+ { kinds: ["message", "receipt"] });
8
+ const receiptByMessage = new Map(snapshot.receipts
9
+ .filter(item => item.recipientParticipantId === input.participantId)
10
+ .map(item => [item.messageId, item]));
11
+ const eligible = snapshot.messages.map(decisionView(snapshot.messages))
12
+ .filter(message => isCurrentDecision(message) && receiptByMessage.has(message.messageId)
13
+ && message.fromSessionId !== input.exceptSessionId)
14
+ .sort((left, right) => left.sentAt.localeCompare(right.sentAt)
15
+ || left.messageId.localeCompare(right.messageId));
16
+ return {
17
+ queuedMessages: eligible.filter(message =>
18
+ receiptByMessage.get(message.messageId).state === "queued"),
19
+ liveOfferedMessageIds: eligible.filter(message => message.toParticipantIds.length > 0
20
+ && receiptByMessage.get(message.messageId).state === "offered")
21
+ .map(message => message.messageId),
22
+ // A missing body is not evidence of delivery: a degraded adapter may have
23
+ // withheld it. Only this recipient's durable receipt permits a reminder.
24
+ reminderMessageIds: eligible.filter(message => message.obligation !== "none"
25
+ && ["offered", "retrieved"].includes(receiptByMessage.get(message.messageId).state))
26
+ .map(message => message.messageId),
27
+ roomMessageIds: eligible.filter(message => message.toParticipantIds.length === 0)
28
+ .map(message => message.messageId),
29
+ };
30
+ }
31
+ import { decisionView, isCurrentDecision } from "./decision-state.mjs";
@@ -0,0 +1,128 @@
1
+ import { AccError, EXIT, SCHEMA_VERSION, assertPortableId, createId, validateRecord }
2
+ from "@agents-can-communicate/protocol";
3
+
4
+ import { isMaterialised, materialise } from "./materialisation.mjs";
5
+
6
+ const sessionRecord = (input, now, generation) => validateRecord("session", {
7
+ schemaVersion: SCHEMA_VERSION,
8
+ sessionId: input.sessionId,
9
+ participantId: input.participantId,
10
+ workspaceId: input.workspaceId,
11
+ generation,
12
+ harness: input.harness,
13
+ state: "open",
14
+ parentSessionId: input.parentSessionId ?? null,
15
+ checkoutRoot: input.checkoutRoot ?? null,
16
+ branch: input.branch ?? null,
17
+ pid: input.pid ?? null,
18
+ // Both default to the weaker reading. A session that declares nothing is a
19
+ // session nothing intercepts - an MCP client, or a CLI user - and claiming
20
+ // otherwise would promise enforcement that is not there.
21
+ enforcement: input.enforcement ?? "advisory",
22
+ lifecycle: input.lifecycle ?? "manual",
23
+ heartbeatCadenceMs: input.heartbeatCadenceMs,
24
+ startedAt: now,
25
+ heartbeatAt: now,
26
+ });
27
+
28
+ const participantRecord = (input, now) => validateRecord("participant", {
29
+ schemaVersion: SCHEMA_VERSION,
30
+ participantId: input.participantId,
31
+ workspaceId: input.workspaceId,
32
+ displayName: input.displayName ?? input.participantId,
33
+ kind: input.participantKind ?? "agent",
34
+ createdAt: now,
35
+ });
36
+
37
+ // Opening owns acquisition in both stores. Lifecycle updates remain in sessions.mjs.
38
+ export function createSessionOpener(ports, locate) {
39
+ const { store, clock, ids, pidIsAlive } = ports;
40
+
41
+ function assertReplaceable(existing, probe) {
42
+ if (existing.record.state === "closed") return;
43
+ // Presence staleness alone never replaces ownership: an idle-but-open
44
+ // session may resume at any moment, and a wrong "gone" verdict there
45
+ // self-corrects the moment that session next takes a turn. A wrong
46
+ // replacement does not self-correct - it takes the generation, and the
47
+ // original session's own heartbeats fail with CONFLICT from then on. So
48
+ // only a pid confirmed dead is authority to replace; "we cannot tell" -
49
+ // a session with no recorded pid - is never enough, however long the
50
+ // silence.
51
+ const live = probe ?? (record => (record.pid ?? null) === null
52
+ || pidIsAlive(record.pid));
53
+ if (live(existing.record)) {
54
+ throw new AccError(EXIT.CONFLICT, "the session id is already live",
55
+ { sessionId: existing.record.sessionId });
56
+ }
57
+ }
58
+
59
+ async function openSession(input) {
60
+ const workspaceId = input.workspaceId ?? store.workspaceId;
61
+ assertPortableId(workspaceId, "workspace id");
62
+ if (store.workspaceId !== undefined && workspaceId !== store.workspaceId) {
63
+ throw new AccError(EXIT.CONFLICT, "cannot open a session in a different workspace",
64
+ { workspaceId, storeWorkspaceId: store.workspaceId });
65
+ }
66
+ assertPortableId(input.participantId, "participant id");
67
+ const sessionId = input.sessionId ?? createId("session");
68
+ // A caller may persist a fresh owner pair before creation. Such a reserved
69
+ // generation is insert-only: it must never replace an existing session.
70
+ const assertAvailable = current => {
71
+ if (current === null) return;
72
+ if (input.generation !== undefined) {
73
+ throw new AccError(EXIT.CONFLICT, "a reserved generation requires a new session id",
74
+ { sessionId });
75
+ }
76
+ assertReplaceable({ record: current }, input.probe);
77
+ };
78
+ const existing = await locate(sessionId, workspaceId);
79
+ assertAvailable(existing?.record ?? null);
80
+
81
+ const now = clock.now();
82
+ const session = sessionRecord({ ...input, workspaceId, sessionId }, now,
83
+ input.generation === undefined ? ids.next("generation") : input.generation);
84
+ const participant = participantRecord({ ...input, workspaceId }, now);
85
+
86
+ const openDurable = async () => {
87
+ await store.transaction(async tx => {
88
+ const current = tx.get("session", sessionId);
89
+ assertAvailable(current);
90
+ const replaced = current?.generation ?? null;
91
+ if (tx.get("participant", participant.participantId) === null) {
92
+ tx.put("participant", participant.participantId, participant);
93
+ }
94
+ tx.put("session", sessionId, session, tx.generationOf("session", sessionId));
95
+ tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
96
+ actorSessionId: sessionId, type: "session.opened", occurredAt: now,
97
+ payload: { replaced } });
98
+ }, { kinds: ["participant", "session"] });
99
+ return session;
100
+ };
101
+ if (await isMaterialised(store, workspaceId)) return openDurable();
102
+
103
+ // Preparing a participant must not rename one that already exists, or
104
+ // recreate an ephemeral copy after promotion has taken ownership.
105
+ await store.ephemeral.update("participant", participant.participantId, async current =>
106
+ await isMaterialised(store, workspaceId) ? null : current ?? participant);
107
+ const opened = await store.ephemeral.update("session", sessionId, async current => {
108
+ // Check even when no copy remains: promotion may have retired it while
109
+ // this opener waited. Only the durable transaction may acquire that id now.
110
+ if (await isMaterialised(store, workspaceId)) return null;
111
+ assertAvailable(current);
112
+ return session;
113
+ });
114
+ if (opened === null) return openDurable();
115
+ // The approved trigger is the SECOND live session, not the first: a lone
116
+ // session must be able to open and close without leaving a trace.
117
+ const live = (await store.ephemeral.list("session")).filter(item => item.state === "open");
118
+ // Another opener may have promoted us after acquisition; completing the
119
+ // idempotent pass also retires any copies that are still pending cleanup.
120
+ if (live.length > 1 || await isMaterialised(store, workspaceId)) {
121
+ await materialise(ports, { workspaceId, descriptor: input.descriptor,
122
+ reason: "second_live_session" });
123
+ }
124
+ return session;
125
+ }
126
+
127
+ return openSession;
128
+ }