agents-can-communicate 0.1.18 → 0.3.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 (155) hide show
  1. package/README.md +87 -69
  2. package/SECURITY.md +31 -0
  3. package/bin/acc-bootstrap.mjs +56 -0
  4. package/bin/acc-claude-channel.mjs +177 -0
  5. package/bin/acc-hook.mjs +94 -12
  6. package/bin/acc-mcp.mjs +6 -2
  7. package/bin/acc.mjs +13 -3
  8. package/docs/ADAPTER_AUTHORING.md +204 -0
  9. package/docs/ARCHITECTURE.md +131 -0
  10. package/docs/CAPABILITIES.md +117 -214
  11. package/docs/CLI.md +164 -0
  12. package/docs/CONCEPTS.md +134 -0
  13. package/docs/CONFIGURATION.md +147 -0
  14. package/docs/DESIGN_DECISIONS.md +89 -0
  15. package/docs/GETTING_STARTED.md +145 -0
  16. package/docs/GLOSSARY.md +26 -0
  17. package/docs/HOW_IT_WORKS.md +277 -0
  18. package/docs/MCP.md +94 -0
  19. package/docs/PROTOCOL.md +200 -0
  20. package/docs/RELEASING.md +115 -0
  21. package/docs/SECURITY_MODEL.md +131 -0
  22. package/docs/TROUBLESHOOTING.md +108 -0
  23. package/docs/WHY_ACC.md +61 -0
  24. package/docs/index.md +44 -0
  25. package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +228 -0
  26. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse-Edit.json +19 -0
  27. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse.json +17 -0
  28. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionEnd.json +8 -0
  29. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionStart.json +7 -0
  30. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/UserPromptSubmit.json +9 -0
  31. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +269 -0
  32. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.252.json +21 -0
  33. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.258.json +23 -0
  34. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.260.json +23 -0
  35. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +13 -2
  36. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.mcp.json +8 -0
  37. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +22 -22
  38. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +45 -5
  39. package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +377 -0
  40. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +27 -7
  41. package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +229 -0
  42. package/node_modules/@agents-can-communicate/adapter-codex/certification.json +150 -0
  43. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/PreToolUse.json +14 -0
  44. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionEnd.json +7 -0
  45. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionStart.json +9 -0
  46. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/UserPromptSubmit.json +10 -0
  47. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +199 -0
  48. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.0.json +21 -0
  49. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-remote-workspace.json +25 -0
  50. package/node_modules/@agents-can-communicate/adapter-codex/package.json +11 -2
  51. package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +1 -1
  52. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +22 -22
  53. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +54 -12
  54. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +121 -0
  55. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +151 -0
  56. package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +192 -0
  57. package/node_modules/@agents-can-communicate/adapter-gemini-cli/certification.json +68 -0
  58. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  59. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +22 -22
  60. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeAgent-0.57.0.json +8 -0
  61. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-0.57.0.json +12 -0
  62. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-shell-0.57.0.json +12 -0
  63. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionEnd-0.57.0.json +8 -0
  64. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionStart-0.57.0.json +8 -0
  65. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/certification-provenance.json +293 -0
  66. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +8 -1
  67. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +31 -13
  68. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +4 -2
  69. package/node_modules/@agents-can-communicate/adapter-grok/certification.json +3 -0
  70. package/node_modules/@agents-can-communicate/adapter-grok/package.json +2 -1
  71. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +22 -22
  72. package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +11 -9
  73. package/node_modules/@agents-can-communicate/adapter-kimi/certification.json +52 -0
  74. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Bash.json +12 -0
  75. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Write.json +12 -0
  76. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionHeartbeat.json +7 -0
  77. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionStart.json +9 -0
  78. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/UserPromptSubmit.json +8 -0
  79. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/certification-provenance.json +66 -0
  80. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +8 -1
  81. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +22 -22
  82. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +7 -3
  83. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  84. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +52 -18
  85. package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +158 -0
  86. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +36 -17
  87. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +9 -1
  88. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +7 -2
  89. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-activation.mjs +76 -0
  90. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +202 -0
  91. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +101 -0
  92. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +28 -4
  93. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  94. package/node_modules/@agents-can-communicate/cli/src/args.mjs +12 -31
  95. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +70 -5
  96. package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -5
  97. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +111 -12
  98. package/node_modules/@agents-can-communicate/cli/src/main.mjs +100 -121
  99. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  100. package/node_modules/@agents-can-communicate/core/src/attention.mjs +106 -0
  101. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +276 -0
  102. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +131 -0
  103. package/node_modules/@agents-can-communicate/core/src/finish-retries.mjs +97 -0
  104. package/node_modules/@agents-can-communicate/core/src/inbox.mjs +91 -107
  105. package/node_modules/@agents-can-communicate/core/src/index.mjs +3 -3
  106. package/node_modules/@agents-can-communicate/core/src/intents.mjs +0 -1
  107. package/node_modules/@agents-can-communicate/core/src/ports.mjs +2 -1
  108. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +109 -0
  109. package/node_modules/@agents-can-communicate/core/src/service.mjs +21 -10
  110. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +22 -20
  111. package/node_modules/@agents-can-communicate/core/src/status.mjs +11 -9
  112. package/node_modules/@agents-can-communicate/core/src/sync.mjs +3 -294
  113. package/node_modules/@agents-can-communicate/delivery-router/package.json +12 -0
  114. package/node_modules/@agents-can-communicate/delivery-router/src/index.mjs +1 -0
  115. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +131 -0
  116. package/node_modules/@agents-can-communicate/hook-runner/package.json +4 -2
  117. package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +20 -0
  118. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +90 -0
  119. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +190 -105
  120. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  121. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +70 -10
  122. package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +144 -0
  123. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +89 -5
  124. package/node_modules/@agents-can-communicate/installer/src/index.mjs +10 -2
  125. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +161 -0
  126. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +112 -12
  127. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +54 -2
  128. package/node_modules/@agents-can-communicate/installer/src/shell-bootstrap.mjs +210 -0
  129. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  130. package/node_modules/@agents-can-communicate/mcp-server/src/input-validator.mjs +79 -0
  131. package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +23 -28
  132. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +102 -72
  133. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +54 -97
  134. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  135. package/node_modules/@agents-can-communicate/protocol/src/config.mjs +1 -1
  136. package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +64 -0
  137. package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +17 -0
  138. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +4 -1
  139. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +64 -90
  140. package/node_modules/@agents-can-communicate/protocol/src/states.mjs +13 -40
  141. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  142. package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +230 -0
  143. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +77 -28
  144. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +1 -1
  145. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +83 -35
  146. package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +112 -0
  147. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +18 -8
  148. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +68 -26
  149. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +113 -35
  150. package/package.json +20 -1
  151. package/node_modules/@agents-can-communicate/core/src/communication.mjs +0 -334
  152. package/node_modules/@agents-can-communicate/core/src/message-signals.mjs +0 -41
  153. package/node_modules/@agents-can-communicate/core/src/notify.mjs +0 -95
  154. package/node_modules/@agents-can-communicate/core/src/tasks.mjs +0 -244
  155. package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +0 -109
@@ -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.3.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,131 @@
1
+ const SAFE_ERRORS = new Set(["ambiguous_recipient_sessions", "delivery_disabled",
2
+ "recipient_busy", "recipient_unavailable", "transport_error", "transport_rejected",
3
+ "unsupported_client_version"]);
4
+ const NAMED_LIVE_TRANSPORTS = new Set(["claude-channel", "codex-app-server"]);
5
+
6
+ const adaptersById = adapters => adapters instanceof Map
7
+ ? adapters
8
+ : new Map((Array.isArray(adapters) ? adapters : Object.values(adapters ?? {}))
9
+ .map(adapter => [adapter.id, adapter]));
10
+
11
+ // Everything that closes or advances a conversation is actionable: a question
12
+ // or request asks for work, an answer or decision resolves it, a handoff
13
+ // transfers it. A note informs and waits for the next turn. Room messages
14
+ // have no recipient and are never offered live.
15
+ const ACTIONABLE = new Set(["question", "request", "answer", "decision", "handoff"]);
16
+ const permits = (policy, kind) => policy === "all"
17
+ || (policy === "actionable" && ACTIONABLE.has(kind));
18
+
19
+ // Compatibility was decided twice already - at bootstrap by the probe and at
20
+ // SessionStart by the generation-bound handshake that published this binding.
21
+ // The router validates binding identity and the adapter's answer; it does not
22
+ // impose a third, exact-version rule that would reject a client the handshake
23
+ // admitted.
24
+ const liveCapable = (adapter, binding) => adapter !== undefined
25
+ && adapter.capabilities?.delivery?.livePush === true
26
+ && adapter.nativeDelivery !== undefined
27
+ && binding.availableModes.includes("livePush");
28
+
29
+ const durable = (recipientParticipantId, errorCode) => ({ recipientParticipantId,
30
+ outcome: "queued", transport: "durable", errorCode });
31
+
32
+ const settled = receipt => ({ recipientParticipantId: receipt.recipientParticipantId,
33
+ outcome: receipt.state, transport: "durable" });
34
+
35
+ function safeTransport(value, opaqueEndpointRef) {
36
+ if (NAMED_LIVE_TRANSPORTS.has(value) && value !== opaqueEndpointRef) return value;
37
+ // Both markers are fixed router vocabulary. The alternate prevents even a
38
+ // coincidental endpoint value equal to the primary redaction from escaping.
39
+ return opaqueEndpointRef === "live-adapter" ? "native-live" : "live-adapter";
40
+ }
41
+
42
+ export function createDeliveryRouter({ service, adapters, clock }) {
43
+ const registry = adaptersById(adapters);
44
+
45
+ async function recordFailure(binding, message, participantId, transport, safeErrorCode) {
46
+ await service.recordOfferFailed({ messageId: message.messageId,
47
+ recipientParticipantId: participantId, targetSessionId: binding.sessionId,
48
+ targetGeneration: binding.generation,
49
+ transport: safeTransport(transport, binding.opaqueEndpointRef), adapterId: binding.adapterId,
50
+ clientVersion: binding.clientVersion, safeErrorCode }).catch(() => null);
51
+ }
52
+
53
+ async function offerTo(message, participantId, now) {
54
+ const receipt = await service.readReceipt({ messageId: message.messageId,
55
+ recipientParticipantId: participantId });
56
+ if (receipt.state !== "queued") return settled(receipt);
57
+ const liveSessions = await service.listLiveSessions({ participantId, now });
58
+ if (liveSessions.length === 0) return durable(participantId, "recipient_unavailable");
59
+ if (liveSessions.length > 1) {
60
+ return durable(participantId, "ambiguous_recipient_sessions");
61
+ }
62
+ const [target] = liveSessions;
63
+ const bindings = (await service.listDeliveryBindings({ participantId, now }))
64
+ .filter(binding => binding.sessionId === target.sessionId
65
+ && binding.generation === target.generation);
66
+ if (bindings.length === 0) return durable(participantId, "recipient_unavailable");
67
+ const permitted = bindings.filter(binding => binding.livePolicy !== "off"
68
+ && permits(binding.livePolicy, message.kind));
69
+ if (permitted.length === 0) return durable(participantId, "delivery_disabled");
70
+ const reachable = permitted.filter(binding => binding.availableModes.includes("livePush"));
71
+ if (reachable.length === 0) return durable(participantId, "recipient_unavailable");
72
+ const capable = reachable.map(binding => ({ binding,
73
+ adapter: registry.get(binding.adapterId) }))
74
+ .filter(({ binding, adapter }) => liveCapable(adapter, binding));
75
+ if (capable.length === 0) {
76
+ return durable(participantId, "unsupported_client_version");
77
+ }
78
+ if (capable.length > 1) {
79
+ return durable(participantId, "ambiguous_recipient_sessions");
80
+ }
81
+
82
+ const { binding, adapter } = capable[0];
83
+ let response;
84
+ try {
85
+ // The store root is this workspace's runtime dir; the adapter resolves its
86
+ // opaque endpoint id under it. Passed as data, never as a leak into core:
87
+ // the router does not read what the adapter does with it.
88
+ response = await adapter.offerMessage({ binding, message,
89
+ runtimeDir: service.store?.root });
90
+ } catch {
91
+ await recordFailure(binding, message, participantId, "live-adapter", "transport_error");
92
+ return durable(participantId, "transport_error");
93
+ }
94
+ const transport = safeTransport(response?.transport, binding.opaqueEndpointRef);
95
+ if (response?.accepted !== true) {
96
+ const code = SAFE_ERRORS.has(response?.safeErrorCode)
97
+ ? response.safeErrorCode : "transport_rejected";
98
+ await recordFailure(binding, message, participantId, transport, code);
99
+ return durable(participantId, code);
100
+ }
101
+ if (response.clientVersion !== binding.clientVersion) {
102
+ await recordFailure(binding, message, participantId, transport,
103
+ "unsupported_client_version");
104
+ return durable(participantId, "unsupported_client_version");
105
+ }
106
+ try {
107
+ await service.recordOfferSucceeded({ messageId: message.messageId,
108
+ recipientParticipantId: participantId, targetSessionId: binding.sessionId,
109
+ targetGeneration: binding.generation, transport, adapterId: binding.adapterId,
110
+ clientVersion: binding.clientVersion });
111
+ } catch {
112
+ await recordFailure(binding, message, participantId, transport, "transport_error");
113
+ return durable(participantId, "transport_error");
114
+ }
115
+ return { recipientParticipantId: participantId, outcome: "offered", transport };
116
+ }
117
+
118
+ async function offer(message) {
119
+ if (!Array.isArray(message?.toParticipantIds) || message.toParticipantIds.length === 0) {
120
+ return [];
121
+ }
122
+ const now = clock.now();
123
+ const outcomes = [];
124
+ for (const participantId of message.toParticipantIds) {
125
+ outcomes.push(await offerTo(message, participantId, now));
126
+ }
127
+ return outcomes;
128
+ }
129
+
130
+ return Object.freeze({ offer });
131
+ }
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.1.18",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
7
- ".": "./src/runner.mjs"
7
+ ".": "./src/runner.mjs",
8
+ "./client-pid": "./src/client-pid.mjs",
9
+ "./process-table": "./src/process-table.mjs"
8
10
  },
9
11
  "files": [
10
12
  "src/"
@@ -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
+ }
@@ -0,0 +1,90 @@
1
+ import { validateNativeHandshake } from "@agents-can-communicate/adapter-sdk";
2
+ import { EXIT } from "@agents-can-communicate/protocol";
3
+
4
+ // The hook side of native delivery: one bounded, fail-open attempt to bind
5
+ // this exact ACC session generation to the vendor session the adapter can
6
+ // reach, then a sanitized binding published for the router. The helper, not
7
+ // the adapter, supplies adapter id, client facts, session id, and generation;
8
+ // the adapter supplies only closed handshake facts. Nothing here throws into
9
+ // the hook path, and no endpoint reference ever leaves through the result.
10
+
11
+ export const LIVE_POLICIES = Object.freeze(["off", "actionable", "all"]);
12
+ export const NATIVE_BINDING_STATES = Object.freeze(["active", "off", "degraded", "unsupported"]);
13
+ const DEFAULT_TIMEOUT_MS = 750;
14
+ const DEFAULT_CADENCE_MS = 60_000;
15
+
16
+ // Reasons the static rule refused: the client will not change within the
17
+ // session, so the state is "unsupported" rather than a retryable "degraded".
18
+ const STATIC_REASONS = new Set(["native_delivery_unsupported", "platform_not_captured",
19
+ "version_unavailable", "prerelease_not_captured", "below_minimum_version",
20
+ "known_bad_version"]);
21
+
22
+ // Only the value a successful owned shell bootstrap exported counts; anything
23
+ // else - missing, blank, differently cased, a number - is off.
24
+ export function livePolicyFrom(env) {
25
+ const value = env?.ACC_NATIVE_DELIVERY_POLICY;
26
+ return LIVE_POLICIES.includes(value) ? value : "off";
27
+ }
28
+
29
+ const isPid = value => Number.isInteger(value) && value > 0;
30
+
31
+ export async function establishNativeBinding({ adapter, event, hookBinding, clientVersion,
32
+ platform, livePolicy, service, runtimeDir, clock,
33
+ timeoutMs = DEFAULT_TIMEOUT_MS, heartbeatCadenceMs = DEFAULT_CADENCE_MS }) {
34
+ const outcome = (state, reasonCode, modes = []) =>
35
+ Object.freeze({ state, reasonCode, modes: Object.freeze([...modes]) });
36
+ const sessionId = hookBinding?.accSessionId;
37
+ const generation = hookBinding?.generation;
38
+ if (typeof sessionId !== "string" || typeof generation !== "string") return outcome("off", null);
39
+ const policy = LIVE_POLICIES.includes(livePolicy) ? livePolicy : "off";
40
+ const clear = () => service.clearDeliveryBinding({ sessionId, generation }).catch(() => null);
41
+ if (policy === "off") {
42
+ await clear();
43
+ return outcome("off", null);
44
+ }
45
+ if (adapter?.nativeDelivery === undefined || typeof adapter.bindNativeSession !== "function") {
46
+ return outcome("unsupported", "native_delivery_unsupported");
47
+ }
48
+ const clientPid = hookBinding.clientPid;
49
+ if (!isPid(clientPid)) {
50
+ await clear();
51
+ return outcome("degraded", "client_process_unknown");
52
+ }
53
+ // Whatever this generation published before is retired first, so a failed
54
+ // re-handshake can never leave yesterday's endpoint reachable.
55
+ await clear();
56
+ let timer = null;
57
+ try {
58
+ const budget = Math.max(1, Math.floor(timeoutMs));
59
+ const handshake = await Promise.race([
60
+ adapter.bindNativeSession({ event, clientPid, clientVersion, runtimeDir, timeoutMs: budget }),
61
+ new Promise((_resolve, reject) => {
62
+ timer = setTimeout(() => reject(Object.assign(new Error("native handshake timed out"),
63
+ { code: "ETIMEDOUT" })), budget);
64
+ }),
65
+ ]);
66
+ const verdict = validateNativeHandshake(adapter, { clientVersion, platform, handshake });
67
+ if (!verdict.ok) {
68
+ return outcome(STATIC_REASONS.has(verdict.reasonCode) ? "unsupported" : "degraded",
69
+ verdict.reasonCode);
70
+ }
71
+ const now = Date.parse(clock.now());
72
+ const lease = Date.parse(verdict.leaseUntil);
73
+ if (!(lease > now)) return outcome("degraded", "handshake_failed");
74
+ const ceiling = now + 2 * heartbeatCadenceMs;
75
+ const leaseUntil = lease > ceiling ? new Date(ceiling).toISOString() : verdict.leaseUntil;
76
+ await service.publishDeliveryBinding({
77
+ sessionId, generation, adapterId: adapter.id, clientVersion,
78
+ availableModes: [...verdict.modes], livePolicy: policy,
79
+ opaqueEndpointRef: verdict.opaqueEndpointRef, leaseUntil,
80
+ });
81
+ return outcome("active", null, verdict.modes);
82
+ } catch (error) {
83
+ await clear();
84
+ const reasonCode = error?.code === "ETIMEDOUT" ? "handshake_timeout"
85
+ : error?.code === EXIT.CONFLICT ? "session_generation_stale" : "handshake_failed";
86
+ return outcome("degraded", reasonCode);
87
+ } finally {
88
+ if (timer !== null) clearTimeout(timer);
89
+ }
90
+ }