agents-can-communicate 0.3.1 → 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 +28 -7
  2. package/SECURITY.md +1 -1
  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 +41 -7
  17. package/docs/ARCHITECTURE.md +62 -11
  18. package/docs/CAPABILITIES.md +40 -36
  19. package/docs/CLI.md +143 -25
  20. package/docs/CONCEPTS.md +13 -6
  21. package/docs/CONFIGURATION.md +37 -22
  22. package/docs/DESIGN_DECISIONS.md +16 -11
  23. package/docs/GETTING_STARTED.md +37 -7
  24. package/docs/GLOSSARY.md +9 -3
  25. package/docs/HOW_IT_WORKS.md +32 -11
  26. package/docs/MCP.md +91 -24
  27. package/docs/PROTOCOL.md +69 -12
  28. package/docs/RELEASING.md +28 -10
  29. package/docs/SECURITY_MODEL.md +45 -13
  30. package/docs/TROUBLESHOOTING.md +76 -18
  31. package/docs/UPGRADING.md +119 -0
  32. package/docs/WHY_ACC.md +5 -4
  33. package/docs/index.md +8 -0
  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 +106 -5
  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 +106 -5
  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 +106 -5
  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 +106 -5
  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 +106 -5
  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 +22 -4
  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 +9 -25
  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 +1 -1
  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
@@ -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
+ }
@@ -1,7 +1,8 @@
1
- import { AccError, EXIT, SCHEMA_VERSION, assertPortableId, createId, validateRecord }
1
+ import { AccError, EXIT, SCHEMA_VERSION }
2
2
  from "@agents-can-communicate/protocol";
3
3
 
4
- import { ensureMaterialised, isMaterialised, materialise } from "./materialisation.mjs";
4
+ import { ensureMaterialised, isMaterialised } from "./materialisation.mjs";
5
+ import { createSessionOpener } from "./session-opening.mjs";
5
6
 
6
7
  // A hook-only adapter heartbeats only when its harness gives it a turn, so the
7
8
  // staleness window is a multiple of the cadence the session itself declared
@@ -50,70 +51,25 @@ export function classifySessionPresence(session, now, pidIsAlive) {
50
51
  return age > UNKNOWN_EXPIRY_MS ? "offline" : ageBand(session, age);
51
52
  }
52
53
 
53
- const sessionRecord = (input, now, generation) => validateRecord("session", {
54
- schemaVersion: SCHEMA_VERSION,
55
- sessionId: input.sessionId,
56
- participantId: input.participantId,
57
- workspaceId: input.workspaceId,
58
- generation,
59
- harness: input.harness,
60
- state: "open",
61
- parentSessionId: input.parentSessionId ?? null,
62
- checkoutRoot: input.checkoutRoot ?? null,
63
- branch: input.branch ?? null,
64
- pid: input.pid ?? null,
65
- // Both default to the weaker reading. A session that declares nothing is a
66
- // session nothing intercepts - an MCP client, or a CLI user - and claiming
67
- // otherwise would promise enforcement that is not there.
68
- enforcement: input.enforcement ?? "advisory",
69
- lifecycle: input.lifecycle ?? "manual",
70
- heartbeatCadenceMs: input.heartbeatCadenceMs,
71
- startedAt: now,
72
- heartbeatAt: now,
73
- });
74
-
75
- const participantRecord = (input, now) => validateRecord("participant", {
76
- schemaVersion: SCHEMA_VERSION,
77
- participantId: input.participantId,
78
- workspaceId: input.workspaceId,
79
- displayName: input.displayName ?? input.participantId,
80
- kind: input.participantKind ?? "agent",
81
- createdAt: now,
82
- });
83
-
84
54
  export function createSessionService(ports) {
85
55
  const { store, clock, ids, pidIsAlive } = ports;
86
- const workspaceOf = input => input.workspaceId ?? store.workspaceId;
87
56
 
88
57
  async function locate(sessionId, workspaceId) {
89
58
  const ephemeral = await store.ephemeral.get("session", sessionId);
90
- if (ephemeral !== null) return { record: ephemeral, durable: false };
91
- const resolved = workspaceId ?? store.workspaceId;
59
+ const resolved = workspaceId ?? store.workspaceId ?? ephemeral?.workspaceId;
92
60
  if (resolved === undefined) return null;
93
61
  const durable = (await store.snapshot(resolved, { kinds: ["session"] })).sessions
94
62
  .find(session => session.sessionId === sessionId) ?? null;
95
- return durable === null ? null : { record: durable, durable: true };
63
+ if (durable !== null) return { record: durable, durable: true };
64
+ return ephemeral !== null && ephemeral.workspaceId === resolved
65
+ ? { record: ephemeral, durable: false } : null;
96
66
  }
97
67
 
98
- function assertReplaceable(existing, probe) {
99
- if (existing.record.state === "closed") return;
100
- // Presence staleness alone never replaces ownership: an idle-but-open
101
- // session may resume at any moment, and a wrong "gone" verdict there
102
- // self-corrects the moment that session next takes a turn. A wrong
103
- // replacement does not self-correct - it takes the generation, and the
104
- // original session's own heartbeats fail with CONFLICT from then on. So
105
- // only a pid confirmed dead is authority to replace; "we cannot tell" -
106
- // a session with no recorded pid - is never enough, however long the
107
- // silence.
108
- const live = probe ?? (record => (record.pid ?? null) === null
109
- || pidIsAlive(record.pid));
110
- if (live(existing.record)) {
111
- throw new AccError(EXIT.CONFLICT, "the session id is already live",
112
- { sessionId: existing.record.sessionId });
68
+ function assertGeneration(existing, generation, action, workspaceId) {
69
+ if (workspaceId !== undefined && existing.record.workspaceId !== workspaceId) {
70
+ throw new AccError(EXIT.CONFLICT, "session is not open in this workspace",
71
+ { sessionId: existing.record.sessionId, workspaceId });
113
72
  }
114
- }
115
-
116
- function assertGeneration(existing, generation, action) {
117
73
  if (existing.record.generation !== generation) {
118
74
  throw new AccError(EXIT.CONFLICT, `cannot ${action} a replaced session generation`,
119
75
  { sessionId: existing.record.sessionId, expected: generation,
@@ -121,66 +77,28 @@ export function createSessionService(ports) {
121
77
  }
122
78
  }
123
79
 
124
- async function openSession(input) {
125
- const workspaceId = workspaceOf(input);
126
- assertPortableId(workspaceId, "workspace id");
127
- assertPortableId(input.participantId, "participant id");
128
- const sessionId = input.sessionId ?? createId("session");
129
- const existing = await locate(sessionId, workspaceId);
130
- if (existing !== null) assertReplaceable(existing, input.probe);
131
-
132
- const now = clock.now();
133
- const session = sessionRecord({ ...input, workspaceId, sessionId }, now,
134
- ids.next("generation"));
135
- const participant = participantRecord({ ...input, workspaceId }, now);
136
-
137
- if (await isMaterialised(store, workspaceId)) {
138
- await store.transaction(async tx => {
139
- const replaced = tx.get("session", sessionId)?.generation ?? null;
140
- if (tx.get("participant", participant.participantId) === null) {
141
- tx.put("participant", participant.participantId, participant);
142
- }
143
- tx.put("session", sessionId, session, tx.generationOf("session", sessionId));
144
- tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
145
- actorSessionId: sessionId, type: "session.opened", occurredAt: now,
146
- payload: { replaced } });
147
- }, { kinds: ["participant", "session"] });
148
- return session;
149
- }
150
-
151
- await store.ephemeral.put("participant", participant.participantId, participant);
152
- await store.ephemeral.put("session", sessionId, session);
153
- // The approved trigger is the SECOND live session, not the first: a lone
154
- // session must be able to open and close without leaving a trace.
155
- const live = (await store.ephemeral.list("session")).filter(item => item.state === "open");
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)) {
162
- await materialise(ports, { workspaceId, descriptor: input.descriptor,
163
- reason: "second_live_session" });
164
- }
165
- return session;
166
- }
167
-
168
80
  async function heartbeatSession({ sessionId, workspaceId, generation }) {
169
- const existing = await locate(sessionId, workspaceId);
170
- if (existing === null) throw new AccError(EXIT.CONFLICT, "session is not open", { sessionId });
171
- assertGeneration(existing, generation, "heartbeat");
172
- const beaten = { ...existing.record, heartbeatAt: clock.now() };
173
-
81
+ const beat = current => {
82
+ if (current === null) return null;
83
+ assertGeneration({ record: current }, generation, "heartbeat", workspaceId ?? store.workspaceId);
84
+ if (current.state !== "open") {
85
+ throw new AccError(EXIT.CONFLICT, "session is not open", { sessionId });
86
+ }
87
+ return { ...current, heartbeatAt: clock.now() };
88
+ };
174
89
  // Heartbeats never append to the semantic event feed: only open, close, and
175
- // presence transitions surface through cursor sync (spec section 6.4).
176
- if (!existing.durable) {
177
- await store.ephemeral.put("session", sessionId, beaten);
90
+ // presence transitions surface through cursor sync. Read and validate under
91
+ // the writer lock, so a pending heartbeat cannot restore an old generation.
92
+ // Promoted copies may await cleanup; their durable record already owns writes.
93
+ const ephemeral = await store.ephemeral.update("session", sessionId, async current =>
94
+ current !== null && !await isMaterialised(store, current.workspaceId) ? beat(current) : null);
95
+ if (ephemeral !== null) return ephemeral;
96
+ return store.transaction(tx => {
97
+ const beaten = beat(tx.get("session", sessionId));
98
+ if (beaten === null) throw new AccError(EXIT.CONFLICT, "session is not open", { sessionId });
99
+ tx.put("session", sessionId, beaten, tx.generationOf("session", sessionId));
178
100
  return beaten;
179
- }
180
- await store.transaction(async tx =>
181
- tx.put("session", sessionId, beaten, tx.generationOf("session", sessionId)),
182
- { kinds: ["session"] });
183
- return beaten;
101
+ }, { kinds: ["session"] });
184
102
  }
185
103
 
186
104
  /**
@@ -196,9 +114,11 @@ export function createSessionService(ports) {
196
114
  * not a second agent arriving.
197
115
  */
198
116
  async function resumeSession({ sessionId, workspaceId, generation, ...metadata }) {
117
+ const resolvedWorkspace = workspaceId ?? store.workspaceId;
199
118
  const resume = current => {
200
119
  if (current === null || current.state !== "open"
201
- || current.generation !== generation) return null;
120
+ || current.generation !== generation
121
+ || resolvedWorkspace !== undefined && current.workspaceId !== resolvedWorkspace) return null;
202
122
  return { ...current,
203
123
  pid: metadata.pid ?? null,
204
124
  checkoutRoot: metadata.checkoutRoot ?? current.checkoutRoot,
@@ -213,13 +133,13 @@ export function createSessionService(ports) {
213
133
  // The compare and replacement happen under the ephemeral store's writer
214
134
  // lock. A close or a replacement generation can win before this update or
215
135
  // after it, but can never be overwritten from a record read beforehand.
216
- const ephemeral = await store.ephemeral.update("session", sessionId, resume);
136
+ const ephemeral = await store.ephemeral.update("session", sessionId, async current =>
137
+ current !== null && !await isMaterialised(store, current.workspaceId) ? resume(current) : null);
217
138
  if (ephemeral !== null) return ephemeral;
218
139
 
219
140
  // Re-read and validate inside the durable transaction for the same reason.
220
141
  // Using generationOf only as the put token is insufficient: it protects
221
142
  // the envelope write, not the semantic generation carried by the record.
222
- const resolvedWorkspace = workspaceId ?? store.workspaceId;
223
143
  if (resolvedWorkspace === undefined) return null;
224
144
  return store.transaction(async tx => {
225
145
  const current = tx.get("session", sessionId);
@@ -231,27 +151,33 @@ export function createSessionService(ports) {
231
151
  }
232
152
 
233
153
  async function closeSession({ sessionId, workspaceId, generation }) {
234
- const existing = await locate(sessionId, workspaceId);
235
- if (existing === null) throw new AccError(EXIT.CONFLICT, "session is not open", { sessionId });
236
- assertGeneration(existing, generation, "close");
237
- const now = clock.now();
238
- const closed = { ...existing.record, state: "closed", heartbeatAt: now };
239
-
240
- if (!existing.durable) {
241
- // An ephemeral-only workspace vanishes with its sessions: nothing durable
242
- // was written, so nothing has to be cleaned up later.
243
- await store.ephemeral.delete("session", sessionId);
244
- await store.ephemeral.delete("intent", sessionId);
245
- return closed;
154
+ const close = current => {
155
+ assertGeneration({ record: current }, generation, "close", workspaceId ?? store.workspaceId);
156
+ return { ...current, state: "closed", heartbeatAt: clock.now() };
157
+ };
158
+ let ephemeral = null;
159
+ await store.ephemeral.delete("session", sessionId, async current => {
160
+ if (await isMaterialised(store, current.workspaceId)) return false;
161
+ ephemeral = close(current);
162
+ return true;
163
+ });
164
+ if (ephemeral !== null) {
165
+ // Cleanup is a separate locked write; a successor may have appeared since
166
+ // the close. Its intent must survive the old session's delayed cleanup.
167
+ await store.ephemeral.delete("intent", sessionId,
168
+ async () => await store.ephemeral.get("session", sessionId) === null);
169
+ return ephemeral;
246
170
  }
247
-
248
- await store.transaction(async tx => {
171
+ return store.transaction(tx => {
172
+ const current = tx.get("session", sessionId);
173
+ if (current === null) throw new AccError(EXIT.CONFLICT, "session is not open", { sessionId });
174
+ const closed = close(current);
249
175
  tx.put("session", sessionId, closed, tx.generationOf("session", sessionId));
250
176
  tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
251
177
  workspaceId: closed.workspaceId, actorSessionId: sessionId, type: "session.closed",
252
- occurredAt: now, payload: {} });
178
+ occurredAt: closed.heartbeatAt, payload: {} });
179
+ return closed;
253
180
  }, { kinds: ["session"] });
254
- return closed;
255
181
  }
256
182
 
257
183
  async function listLiveSessions({ participantId, workspaceId, now = clock.now() }) {
@@ -269,7 +195,7 @@ export function createSessionService(ports) {
269
195
  }
270
196
 
271
197
  return {
272
- openSession,
198
+ openSession: createSessionOpener(ports, locate),
273
199
  resumeSession,
274
200
  heartbeatSession,
275
201
  closeSession,