agents-can-communicate 0.1.18 → 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 (134) hide show
  1. package/README.md +78 -70
  2. package/SECURITY.md +31 -0
  3. package/bin/acc-hook.mjs +94 -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 +102 -214
  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 +20 -22
  32. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +12 -4
  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 +20 -22
  43. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +18 -11
  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 +20 -22
  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 +7 -3
  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 +2 -1
  58. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +20 -22
  59. package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +9 -9
  60. package/node_modules/@agents-can-communicate/adapter-kimi/certification.json +52 -0
  61. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Bash.json +12 -0
  62. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Write.json +12 -0
  63. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionHeartbeat.json +7 -0
  64. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionStart.json +9 -0
  65. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/UserPromptSubmit.json +8 -0
  66. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/certification-provenance.json +66 -0
  67. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +8 -1
  68. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +20 -22
  69. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +7 -3
  70. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  71. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +34 -18
  72. package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +158 -0
  73. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +36 -17
  74. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +7 -1
  75. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -0
  76. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +13 -4
  77. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  78. package/node_modules/@agents-can-communicate/cli/src/args.mjs +11 -30
  79. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +3 -0
  80. package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -5
  81. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +9 -2
  82. package/node_modules/@agents-can-communicate/cli/src/main.mjs +100 -121
  83. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  84. package/node_modules/@agents-can-communicate/core/src/attention.mjs +106 -0
  85. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +276 -0
  86. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +81 -0
  87. package/node_modules/@agents-can-communicate/core/src/finish-retries.mjs +97 -0
  88. package/node_modules/@agents-can-communicate/core/src/inbox.mjs +91 -107
  89. package/node_modules/@agents-can-communicate/core/src/index.mjs +3 -3
  90. package/node_modules/@agents-can-communicate/core/src/intents.mjs +0 -1
  91. package/node_modules/@agents-can-communicate/core/src/ports.mjs +2 -1
  92. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +109 -0
  93. package/node_modules/@agents-can-communicate/core/src/service.mjs +11 -10
  94. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +22 -20
  95. package/node_modules/@agents-can-communicate/core/src/status.mjs +11 -9
  96. package/node_modules/@agents-can-communicate/core/src/sync.mjs +3 -294
  97. package/node_modules/@agents-can-communicate/delivery-router/package.json +12 -0
  98. package/node_modules/@agents-can-communicate/delivery-router/src/index.mjs +1 -0
  99. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +117 -0
  100. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  101. package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +20 -0
  102. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +115 -63
  103. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  104. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +23 -7
  105. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +20 -5
  106. package/node_modules/@agents-can-communicate/installer/src/index.mjs +3 -2
  107. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +108 -12
  108. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +19 -2
  109. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  110. package/node_modules/@agents-can-communicate/mcp-server/src/input-validator.mjs +79 -0
  111. package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +23 -28
  112. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +102 -72
  113. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +54 -97
  114. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  115. package/node_modules/@agents-can-communicate/protocol/src/config.mjs +1 -1
  116. package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +64 -0
  117. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +4 -1
  118. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +49 -90
  119. package/node_modules/@agents-can-communicate/protocol/src/states.mjs +13 -40
  120. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  121. package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +230 -0
  122. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +77 -28
  123. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +1 -1
  124. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +83 -35
  125. package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +112 -0
  126. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +18 -8
  127. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +68 -26
  128. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +113 -35
  129. package/package.json +19 -1
  130. package/node_modules/@agents-can-communicate/core/src/communication.mjs +0 -334
  131. package/node_modules/@agents-can-communicate/core/src/message-signals.mjs +0 -41
  132. package/node_modules/@agents-can-communicate/core/src/notify.mjs +0 -95
  133. package/node_modules/@agents-can-communicate/core/src/tasks.mjs +0 -244
  134. package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +0 -109
@@ -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
  }
@@ -243,35 +247,33 @@ export function createSessionService(ports) {
243
247
 
244
248
  await store.transaction(async tx => {
245
249
  tx.put("session", sessionId, closed, tx.generationOf("session", sessionId));
246
- // Work in progress goes back on the table. A task held by a session that
247
- // has gone stayed `in_progress` forever, nobody else could take it, and
248
- // whoever asked for it was never told - a request handed to an agent that
249
- // closed its terminal simply vanished.
250
- for (const task of tx.list("task")) {
251
- if (task.assigneeSessionId !== sessionId) continue;
252
- if (task.state === "done") continue;
253
- const released = { ...task, assigneeSessionId: null, state: "pending" };
254
- tx.put("task", task.taskId, released, tx.generationOf("task", task.taskId));
255
- writeWorkResponse(tx, { task, actor: closed, workspaceId: closed.workspaceId,
256
- now, ids, outcome: "released",
257
- reason: "the session working on this closed before finishing it" });
258
- tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
259
- workspaceId: closed.workspaceId, actorSessionId: sessionId,
260
- type: "task.released", occurredAt: now, payload: { taskId: task.taskId } });
261
- }
262
250
  tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
263
251
  workspaceId: closed.workspaceId, actorSessionId: sessionId, type: "session.closed",
264
252
  occurredAt: now, payload: {} });
265
- // `writeWorkResponse` tells whoever asked, on this same handle.
266
- }, { kinds: ["message", "receipt", "session", "task"] });
253
+ }, { kinds: ["session"] });
267
254
  return closed;
268
255
  }
269
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
+
270
271
  return {
271
272
  openSession,
272
273
  resumeSession,
273
274
  heartbeatSession,
274
275
  closeSession,
276
+ listLiveSessions,
275
277
  locateSession: locate,
276
278
  ensureMaterialised: options => ensureMaterialised(ports, options),
277
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,299 +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
- unread_note: 8,
50
- });
51
-
52
- function directRequests(snapshot, participantId) {
53
- const items = [];
54
- for (const receipt of snapshot.receipts ?? []) {
55
- if (receipt.recipientParticipantId !== participantId) continue;
56
- if (receipt.state === "acknowledged" || receipt.state === "failed") continue;
57
- const message = (snapshot.messages ?? []).find(item => item.messageId === receipt.messageId);
58
- if (message === undefined || !message.requiresAck) continue;
59
- items.push({ kind: "direct_request", priority: ATTENTION_PRIORITY.direct_request,
60
- sourceId: message.messageId, summary: message.subject });
61
- }
62
- return items;
63
- }
64
-
65
- /**
66
- * A note that was delivered once and then left unanswered.
67
- *
68
- * A `note` carries no acknowledgement obligation, so unlike a `requiresAck`
69
- * message it raises no direct_request and, once injected, drops out of the
70
- * inbox for good. Agents put decisions in notes anyway, and one delivered that
71
- * way was missed for three hours because nothing stood behind it. This is the
72
- * single low-priority breadcrumb that keeps a delivered-but-unacknowledged note
73
- * recoverable: it fires only while the receipt reads `injected`, so the runner
74
- * advancing it to `seen` after one showing makes it one-shot rather than a
75
- * standing nag - the very noise a reader learns to skip. A `queued` note is
76
- * about to be shown in full this turn and needs no breadcrumb yet.
77
- *
78
- * Only the recipient's, and named by message id so `acc inbox --message` or
79
- * `acc ack --message` can act on it without a workspace-wide lookup.
80
- */
81
- function unreadNotes(snapshot, participantId) {
82
- const items = [];
83
- for (const receipt of snapshot.receipts ?? []) {
84
- if (receipt.recipientParticipantId !== participantId) continue;
85
- if (receipt.state !== "injected") continue;
86
- const message = (snapshot.messages ?? [])
87
- .find(item => item.messageId === receipt.messageId);
88
- // A requiresAck message already carries a standing direct_request; a second
89
- // line here would be two reminders for one obligation.
90
- if (message === undefined || message.requiresAck) continue;
91
- items.push({ kind: "unread_note", priority: ATTENTION_PRIORITY.unread_note,
92
- sourceId: message.messageId,
93
- summary: `a note you have not acknowledged - \`acc inbox --message `
94
- + `${message.messageId}\` to read it` });
21
+ + "leave it out to start from the beginning", { cursor });
95
22
  }
96
- return items;
97
- }
98
-
99
- /**
100
- * A claim of yours that has run out.
101
- *
102
- * A lease lapses on the clock, and nothing said so. Measured: while it held, a
103
- * peer's write into the file was refused; three seconds later the same write
104
- * went through, and the holder's turn was identical before and after. It went on
105
- * working on a file it believed it had reserved, and everyone else was free to
106
- * change it.
107
- *
108
- * Only for the session that took it, and only while that session is the one
109
- * asking: a lapsed claim is news to its owner and nobody else's business.
110
- * Re-claiming refreshes the lease and clears this; releasing it clears it too.
111
- */
112
- function expiredClaims(snapshot, session, now) {
113
- if (session == null) return [];
114
- return (snapshot.claims ?? [])
115
- .filter(claim => claim.ownerSessionId === session.sessionId
116
- && Date.parse(claim.expiresAt) <= Date.parse(now))
117
- .map(claim => ({ kind: "claim_expired", priority: ATTENTION_PRIORITY.claim_expired,
118
- sourceId: claim.claimId,
119
- summary: `${claim.resource} - your claim has run out, and peers can write to it` }));
120
- }
121
-
122
- function claimConflicts(snapshot, session, now) {
123
- const mine = (snapshot.intents ?? []).find(intent => intent.sessionId === session?.sessionId);
124
- if (mine === undefined) return [];
125
- return (snapshot.claims ?? [])
126
- .filter(claim => claim.ownerSessionId !== session.sessionId
127
- && Date.parse(claim.expiresAt) > Date.parse(now)
128
- && mine.resourceHints.some(hint => overlaps(hint, claim.resource)))
129
- .map(claim => ({ kind: "claim_conflict", priority: ATTENTION_PRIORITY.claim_conflict,
130
- sourceId: claim.claimId,
131
- summary: `${claim.resource} is claimed by ${claim.ownerSessionId}` }));
132
- }
133
-
134
- /**
135
- * A resource I hold that a peer has said they intend to touch.
136
- *
137
- * The mirror of `claimConflicts`. That one reads my own intent and warns me when
138
- * what I mean to touch is already claimed. This one reads a peer's intent and
139
- * warns me, the holder, that someone is heading for what I claimed. Without it
140
- * intent's only wired reader faced inward: it protected the one declaring intent
141
- * and told the claim holder nothing, so a claim was a wall nobody was told they
142
- * were walking into. A claim is advisory - it does not stop the write - so being
143
- * told early is the whole of the protection it offers.
144
- *
145
- * Only my own claims, and never my own intent against them: declaring intent on
146
- * what you already hold is not someone reaching for it. The peer is named by
147
- * participant where the roster knows it, because a session id cannot be used
148
- * with `--to` and an id a reader cannot act on is the trap the projector warns of.
149
- */
150
- function claimContended(snapshot, session, now) {
151
- if (session === null || session === undefined) return [];
152
- const theirs = (snapshot.intents ?? [])
153
- .filter(intent => intent.sessionId !== session.sessionId);
154
- const nameOf = sessionId => (snapshot.sessions ?? [])
155
- .find(item => item.sessionId === sessionId)?.participantId ?? "a peer";
156
- return (snapshot.claims ?? [])
157
- .filter(claim => claim.ownerSessionId === session.sessionId
158
- && Date.parse(claim.expiresAt) > Date.parse(now))
159
- .flatMap(claim => {
160
- const eyeing = theirs.find(intent =>
161
- (intent.resourceHints ?? []).some(hint => overlaps(hint, claim.resource)));
162
- return eyeing === undefined ? [] : [{
163
- kind: "claim_contended", priority: ATTENTION_PRIORITY.claim_contended,
164
- sourceId: claim.claimId,
165
- summary: `${claim.resource} - ${nameOf(eyeing.sessionId)} means to work on what you hold` }];
166
- });
167
- }
168
-
169
- /**
170
- * Work waiting on me.
171
- *
172
- * Addressed by participant, so a request survives the recipient restarting -
173
- * the next session of that agent is told about it. A task already taken by one
174
- * of my sessions matches too, since that session may have been replaced.
175
- *
176
- * Unaddressed tasks are deliberately absent. Anyone may take one, but pushing
177
- * every open task into every turn is how a coordination layer becomes noise.
178
- */
179
- function unblockedTasks(snapshot, session, participantId) {
180
- const mine = task => (task.assigneeParticipantId !== null
181
- && task.assigneeParticipantId === participantId)
182
- || (task.assigneeSessionId !== null && task.assigneeSessionId === session?.sessionId);
183
- return (snapshot.tasks ?? [])
184
- .filter(task => task.state === "pending" && mine(task))
185
- .map(task => ({ kind: "task_unblocked", priority: ATTENTION_PRIORITY.task_unblocked,
186
- sourceId: task.taskId, summary: task.title }));
187
- }
188
-
189
- /**
190
- * Work you asked for that nobody is doing any more.
191
- *
192
- * A task taken by a session that then crashed stayed `in_progress` for good:
193
- * the requester was told nothing and nobody else could take it. Unlike the
194
- * one-shot answers a request produces, this repeats until it is resolved,
195
- * because it stays true until someone picks the work back up.
196
- */
197
- /**
198
- * A question nobody is left to answer.
199
- *
200
- * The task rule below tells a requester when work they asked for is going
201
- * nowhere. A `requiresAck` message had no such rule, and a message is the other
202
- * half of the same act: an agent asked a peer a direct question, the peer's
203
- * session ended without answering, and the asker's next turn was empty. Not
204
- * "still waiting" - empty. Measured, with the only other agent gone and an
205
- * unanswered question standing between them.
206
- *
207
- * The same kind as the task case, because it is the same fact about the world:
208
- * you asked, and there is nobody there.
209
- */
210
- function unansweredQuestions(snapshot, participantId, onlineParticipants) {
211
- const items = [];
212
- for (const receipt of snapshot.receipts ?? []) {
213
- if (receipt.state === "acknowledged" || receipt.state === "failed") continue;
214
- const message = (snapshot.messages ?? [])
215
- .find(item => item.messageId === receipt.messageId);
216
- if (message === undefined || !message.requiresAck) continue;
217
- if (message.fromParticipantId !== participantId) continue;
218
- // Not answered yet by someone who is here is ordinary waiting, and saying so
219
- // every turn would be noise the reader learns to skip.
220
- if (onlineParticipants.has(receipt.recipientParticipantId)) continue;
221
- items.push({ kind: "request_stalled", priority: ATTENTION_PRIORITY.request_stalled,
222
- sourceId: message.messageId,
223
- summary: `${message.subject} - ${receipt.recipientParticipantId} is not here to answer` });
224
- }
225
- return items;
226
- }
227
-
228
- function stalledRequests(snapshot, participantId, now, pidIsAlive) {
229
- // Classified once per session and reused below, for the same reason
230
- // collectStatus takes one reading: classifySessionPresence calls pidIsAlive,
231
- // a real process.kill(pid, 0) syscall for a session with a recorded pid, so
232
- // it is not pure given `now` any more. A second classifying pass here could
233
- // disagree with the first - a client exiting between them would leave `live`
234
- // saying "online" while a freshly-computed `onlineParticipants` had already
235
- // dropped it, inside one attention computation.
236
- const live = new Map((snapshot.sessions ?? [])
237
- .map(session => [session.sessionId, classifySessionPresence(session, now, pidIsAlive)]));
238
- const onlineParticipants = new Set((snapshot.sessions ?? [])
239
- .filter(session => live.get(session.sessionId) === "online")
240
- .map(session => session.participantId));
241
- const goingNowhere = task => {
242
- // Taken by someone who has gone quiet.
243
- if (task.state === "in_progress") {
244
- return task.assigneeSessionId !== null
245
- && live.get(task.assigneeSessionId) !== "online";
246
- }
247
- // Or waiting on an agent that is not here - including one that closed and
248
- // never came back, which leaves the work addressed to nobody at all.
249
- return task.state === "pending" && task.assigneeParticipantId !== null
250
- && !onlineParticipants.has(task.assigneeParticipantId);
251
- };
252
- return [
253
- ...(snapshot.tasks ?? [])
254
- .filter(task => task.requestedByParticipantId === participantId
255
- && goingNowhere(task))
256
- .map(task => ({ kind: "request_stalled", priority: ATTENTION_PRIORITY.request_stalled,
257
- sourceId: task.taskId,
258
- summary: `${task.title} - nobody is working on it` })),
259
- ...unansweredQuestions(snapshot, participantId, onlineParticipants),
260
- ];
261
- }
262
-
263
- function coordinatorGaps(snapshot) {
264
- return (snapshot.workstreams ?? [])
265
- .filter(workstream => workstream.state === "open"
266
- && workstream.coordinatorSessionId === null)
267
- .map(workstream => ({ kind: "coordinator_missing",
268
- priority: ATTENTION_PRIORITY.coordinator_missing,
269
- sourceId: workstream.workstreamId, summary: workstream.title }));
270
- }
271
-
272
- export function computeAttention(snapshot, { session, participantId, now, pidIsAlive }) {
273
- // Required unconditionally, not only when there happen to be sessions to
274
- // classify: `stalledRequests` reaches `classifySessionPresence` only inside a
275
- // map/filter over `snapshot.sessions`, so an empty or absent roster let a
276
- // missing probe through with nothing to trip over it - the same silent pass
277
- // the classifier's own required parameter exists to close, one layer up.
278
- if (typeof pidIsAlive !== "function") {
279
- throw new AccError(EXIT.USAGE, "computeAttention requires a pidIsAlive probe", {});
280
- }
281
- return [
282
- ...directRequests(snapshot, participantId),
283
- ...unreadNotes(snapshot, participantId),
284
- ...claimConflicts(snapshot, session, now),
285
- ...claimContended(snapshot, session, now),
286
- ...expiredClaims(snapshot, session, now),
287
- ...unblockedTasks(snapshot, session, participantId),
288
- ...coordinatorGaps(snapshot),
289
- ...stalledRequests(snapshot, participantId, now, pidIsAlive),
290
- ].sort((left, right) => left.priority - right.priority
291
- || left.sourceId.localeCompare(right.sourceId));
292
23
  }
293
24
 
294
25
  export function createSyncService(ports, sessions) {
295
26
  const { store, clock, pidIsAlive } = ports;
296
27
 
297
- /**
298
- * Any session may request the full Workspace scope. Peer equality is a
299
- * knowledge property: no session receives a reduced
300
- * view because of its role. The bounded delta is only the ambient default.
301
- */
302
28
  async function sync(input = {}) {
303
- // A cursor that is not a cursor answered "nothing new", every time, for as
304
- // long as it was held. `eventsSince` compares sequences as strings, so
305
- // `not-a-cursor` sorts after every event there has ever been - and an
306
- // adapter holding a corrupt one, or an agent that invented one, saw a quiet
307
- // workspace rather than a mistake. `"0000000000000001; DROP"` was quietly
308
- // taken as the sequence it starts with.
309
29
  if (input.cursor != null) assertCursor(input.cursor);
310
30
  assertScope(input.scope);
311
31
  const workspaceId = input.workspaceId ?? store.workspaceId;
312
32
  const now = clock.now();
313
33
  const located = input.sessionId === undefined
314
- ? null
315
- : await sessions.locateSession(input.sessionId, workspaceId);
34
+ ? null : await sessions.locateSession(input.sessionId, workspaceId);
316
35
  const session = located?.record ?? null;
317
-
318
36
  const durable = await store.snapshot(workspaceId);
319
- // A workspace that has not materialised still has a truthful roster: its
320
- // sessions live in the ephemeral area. Reading only the durable snapshot
321
- // would make a lone session invisible to itself, and would disagree with
322
- // what `status` reports from the same state.
323
37
  const snapshot = durable.workspace !== null
324
38
  ? durable
325
39
  : { ...durable,
@@ -329,7 +43,6 @@ export function createSyncService(ports, sessions) {
329
43
  input.limit ?? DEFAULT_LIMIT);
330
44
  const attention = computeAttention(snapshot, { session,
331
45
  participantId: session?.participantId ?? input.participantId, now, pidIsAlive });
332
-
333
46
  const roster = snapshot.sessions.map(item => ({
334
47
  sessionId: item.sessionId,
335
48
  participantId: item.participantId,
@@ -338,14 +51,10 @@ export function createSyncService(ports, sessions) {
338
51
  branch: item.branch ?? null,
339
52
  presence: classifySessionPresence(item, now, pidIsAlive),
340
53
  }));
341
-
342
- // Solo zero-overhead: one live session, no claims and
343
- // no attention means an empty result, not a "nothing to report" banner.
344
54
  const peers = roster.filter(item => item.sessionId !== session?.sessionId
345
55
  && item.presence !== "offline");
346
56
  const solo = peers.length === 0 && attention.length === 0
347
57
  && snapshot.claims.length === 0;
348
-
349
58
  return {
350
59
  cursor: page.cursor,
351
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";
@@ -0,0 +1,117 @@
1
+ import { effectiveCapabilities } from "@agents-can-communicate/adapter-sdk";
2
+
3
+ const PLATFORM = `${process.platform}-${process.arch}`;
4
+ const SAFE_ERRORS = new Set(["ambiguous_recipient_sessions", "delivery_disabled",
5
+ "recipient_busy", "recipient_unavailable", "transport_error", "transport_rejected",
6
+ "unsupported_client_version"]);
7
+ const NAMED_LIVE_TRANSPORTS = new Set(["claude-channel", "codex-app-server"]);
8
+
9
+ const adaptersById = adapters => adapters instanceof Map
10
+ ? adapters
11
+ : new Map((Array.isArray(adapters) ? adapters : Object.values(adapters ?? {}))
12
+ .map(adapter => [adapter.id, adapter]));
13
+
14
+ const permits = (policy, kind) => policy === "all"
15
+ || (policy === "actionable" && ["question", "request", "handoff"].includes(kind));
16
+
17
+ const durable = (recipientParticipantId, errorCode) => ({ recipientParticipantId,
18
+ outcome: "queued", transport: "durable", errorCode });
19
+
20
+ const settled = receipt => ({ recipientParticipantId: receipt.recipientParticipantId,
21
+ outcome: receipt.state, transport: "durable" });
22
+
23
+ function safeTransport(value, opaqueEndpointRef) {
24
+ if (NAMED_LIVE_TRANSPORTS.has(value) && value !== opaqueEndpointRef) return value;
25
+ // Both markers are fixed router vocabulary. The alternate prevents even a
26
+ // coincidental endpoint value equal to the primary redaction from escaping.
27
+ return opaqueEndpointRef === "live-adapter" ? "native-live" : "live-adapter";
28
+ }
29
+
30
+ export function createDeliveryRouter({ service, adapters, clock }) {
31
+ const registry = adaptersById(adapters);
32
+
33
+ async function recordFailure(binding, message, participantId, transport, safeErrorCode) {
34
+ await service.recordOfferFailed({ messageId: message.messageId,
35
+ recipientParticipantId: participantId, targetSessionId: binding.sessionId,
36
+ targetGeneration: binding.generation,
37
+ transport: safeTransport(transport, binding.opaqueEndpointRef), adapterId: binding.adapterId,
38
+ clientVersion: binding.clientVersion, safeErrorCode }).catch(() => null);
39
+ }
40
+
41
+ async function offerTo(message, participantId, now) {
42
+ const receipt = await service.readReceipt({ messageId: message.messageId,
43
+ recipientParticipantId: participantId });
44
+ if (receipt.state !== "queued") return settled(receipt);
45
+ const liveSessions = await service.listLiveSessions({ participantId, now });
46
+ if (liveSessions.length === 0) return durable(participantId, "recipient_unavailable");
47
+ if (liveSessions.length > 1) {
48
+ return durable(participantId, "ambiguous_recipient_sessions");
49
+ }
50
+ const [target] = liveSessions;
51
+ const bindings = (await service.listDeliveryBindings({ participantId, now }))
52
+ .filter(binding => binding.sessionId === target.sessionId
53
+ && binding.generation === target.generation);
54
+ if (bindings.length === 0) return durable(participantId, "recipient_unavailable");
55
+ const permitted = bindings.filter(binding => binding.livePolicy !== "off"
56
+ && permits(binding.livePolicy, message.kind));
57
+ if (permitted.length === 0) return durable(participantId, "delivery_disabled");
58
+ const reachable = permitted.filter(binding => binding.availableModes.includes("livePush"));
59
+ if (reachable.length === 0) return durable(participantId, "recipient_unavailable");
60
+ const certified = reachable.map(binding => ({ binding,
61
+ adapter: registry.get(binding.adapterId) }))
62
+ .filter(({ binding, adapter }) => adapter !== undefined
63
+ && effectiveCapabilities(adapter,
64
+ { clientVersion: binding.clientVersion, platform: PLATFORM }).delivery.livePush);
65
+ if (certified.length === 0) {
66
+ return durable(participantId, "unsupported_client_version");
67
+ }
68
+ if (certified.length > 1) {
69
+ return durable(participantId, "ambiguous_recipient_sessions");
70
+ }
71
+
72
+ const { binding, adapter } = certified[0];
73
+ let response;
74
+ try {
75
+ response = await adapter.offerMessage({ binding, message });
76
+ } catch {
77
+ await recordFailure(binding, message, participantId, "live-adapter", "transport_error");
78
+ return durable(participantId, "transport_error");
79
+ }
80
+ const transport = safeTransport(response?.transport, binding.opaqueEndpointRef);
81
+ if (response?.accepted !== true) {
82
+ const code = SAFE_ERRORS.has(response?.safeErrorCode)
83
+ ? response.safeErrorCode : "transport_rejected";
84
+ await recordFailure(binding, message, participantId, transport, code);
85
+ return durable(participantId, code);
86
+ }
87
+ if (response.clientVersion !== binding.clientVersion) {
88
+ await recordFailure(binding, message, participantId, transport,
89
+ "unsupported_client_version");
90
+ return durable(participantId, "unsupported_client_version");
91
+ }
92
+ try {
93
+ await service.recordOfferSucceeded({ messageId: message.messageId,
94
+ recipientParticipantId: participantId, targetSessionId: binding.sessionId,
95
+ targetGeneration: binding.generation, transport, adapterId: binding.adapterId,
96
+ clientVersion: binding.clientVersion });
97
+ } catch {
98
+ await recordFailure(binding, message, participantId, transport, "transport_error");
99
+ return durable(participantId, "transport_error");
100
+ }
101
+ return { recipientParticipantId: participantId, outcome: "offered", transport };
102
+ }
103
+
104
+ async function offer(message) {
105
+ if (!Array.isArray(message?.toParticipantIds) || message.toParticipantIds.length === 0) {
106
+ return [];
107
+ }
108
+ const now = clock.now();
109
+ const outcomes = [];
110
+ for (const participantId of message.toParticipantIds) {
111
+ outcomes.push(await offerTo(message, participantId, now));
112
+ }
113
+ return outcomes;
114
+ }
115
+
116
+ return Object.freeze({ offer });
117
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.1.18",
3
+ "version": "0.2.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,20 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ const VERSION = /(?:^|[^0-9A-Za-z])v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)(?:\b|$)/;
4
+
5
+ export function parseClientVersion(output) {
6
+ return typeof output === "string" ? VERSION.exec(output)?.[1] ?? null : null;
7
+ }
8
+
9
+ /** Probe the client binary once when its real session starts. */
10
+ export function probeClientVersion(adapter, { timeoutMs = 1_000 } = {}) {
11
+ return new Promise(resolve => {
12
+ execFile(adapter.client.command, adapter.client.versionArgs ?? ["--version"], {
13
+ timeout: timeoutMs,
14
+ windowsHide: true,
15
+ }, (error, stdout, stderr) => {
16
+ if (error !== null) return resolve(null);
17
+ resolve(parseClientVersion(`${stdout}${stderr}`));
18
+ });
19
+ });
20
+ }