agents-can-communicate 0.1.17 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +76 -138
  2. package/SECURITY.md +31 -0
  3. package/bin/acc-hook.mjs +96 -12
  4. package/bin/acc-mcp.mjs +6 -2
  5. package/bin/acc.mjs +6 -1
  6. package/docs/ADAPTER_AUTHORING.md +172 -0
  7. package/docs/ARCHITECTURE.md +131 -0
  8. package/docs/CAPABILITIES.md +105 -197
  9. package/docs/CLI.md +157 -0
  10. package/docs/CONCEPTS.md +134 -0
  11. package/docs/CONFIGURATION.md +143 -0
  12. package/docs/DESIGN_DECISIONS.md +89 -0
  13. package/docs/GETTING_STARTED.md +145 -0
  14. package/docs/GLOSSARY.md +26 -0
  15. package/docs/MCP.md +94 -0
  16. package/docs/PROTOCOL.md +200 -0
  17. package/docs/RELEASING.md +109 -0
  18. package/docs/SECURITY_MODEL.md +131 -0
  19. package/docs/TROUBLESHOOTING.md +102 -0
  20. package/docs/WHY_ACC.md +61 -0
  21. package/docs/index.md +42 -0
  22. package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +78 -0
  23. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse-Edit.json +19 -0
  24. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse.json +17 -0
  25. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionEnd.json +8 -0
  26. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionStart.json +7 -0
  27. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/UserPromptSubmit.json +9 -0
  28. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +77 -0
  29. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.252.json +19 -0
  30. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +9 -1
  31. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +80 -160
  32. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +15 -5
  33. package/node_modules/@agents-can-communicate/adapter-codex/certification.json +117 -0
  34. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/PreToolUse.json +14 -0
  35. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionEnd.json +7 -0
  36. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionStart.json +9 -0
  37. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/UserPromptSubmit.json +10 -0
  38. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +66 -0
  39. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.0.json +19 -0
  40. package/node_modules/@agents-can-communicate/adapter-codex/package.json +8 -1
  41. package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +1 -1
  42. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +80 -160
  43. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +21 -12
  44. package/node_modules/@agents-can-communicate/adapter-gemini-cli/certification.json +52 -0
  45. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  46. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +80 -160
  47. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeAgent.json +8 -0
  48. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-shell.json +12 -0
  49. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool.json +12 -0
  50. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionEnd.json +8 -0
  51. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionStart.json +8 -0
  52. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/certification-provenance.json +66 -0
  53. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +8 -1
  54. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +10 -4
  55. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +4 -2
  56. package/node_modules/@agents-can-communicate/adapter-grok/certification.json +3 -0
  57. package/node_modules/@agents-can-communicate/adapter-grok/package.json +14 -0
  58. package/node_modules/@agents-can-communicate/adapter-grok/plugin/hooks/hooks.json +61 -0
  59. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +152 -0
  60. package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +61 -0
  61. package/node_modules/@agents-can-communicate/adapter-grok/src/hooks.mjs +127 -0
  62. package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +101 -0
  63. package/node_modules/@agents-can-communicate/adapter-kimi/certification.json +52 -0
  64. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Bash.json +12 -0
  65. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Write.json +12 -0
  66. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionHeartbeat.json +7 -0
  67. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionStart.json +9 -0
  68. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/UserPromptSubmit.json +8 -0
  69. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/certification-provenance.json +66 -0
  70. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +8 -1
  71. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +80 -160
  72. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +10 -4
  73. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  74. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +34 -18
  75. package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +158 -0
  76. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +139 -224
  77. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +7 -1
  78. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +2 -1
  79. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +13 -4
  80. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  81. package/node_modules/@agents-can-communicate/cli/src/args.mjs +13 -29
  82. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +3 -0
  83. package/node_modules/@agents-can-communicate/cli/src/help.mjs +5 -6
  84. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +12 -3
  85. package/node_modules/@agents-can-communicate/cli/src/main.mjs +109 -109
  86. package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +1 -1
  87. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  88. package/node_modules/@agents-can-communicate/core/src/attention.mjs +106 -0
  89. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +276 -0
  90. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +81 -0
  91. package/node_modules/@agents-can-communicate/core/src/finish-retries.mjs +97 -0
  92. package/node_modules/@agents-can-communicate/core/src/inbox.mjs +118 -0
  93. package/node_modules/@agents-can-communicate/core/src/index.mjs +3 -2
  94. package/node_modules/@agents-can-communicate/core/src/intents.mjs +0 -1
  95. package/node_modules/@agents-can-communicate/core/src/ports.mjs +3 -2
  96. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +109 -0
  97. package/node_modules/@agents-can-communicate/core/src/service.mjs +14 -10
  98. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +70 -20
  99. package/node_modules/@agents-can-communicate/core/src/status.mjs +11 -9
  100. package/node_modules/@agents-can-communicate/core/src/sync.mjs +3 -258
  101. package/node_modules/@agents-can-communicate/delivery-router/package.json +12 -0
  102. package/node_modules/@agents-can-communicate/delivery-router/src/index.mjs +1 -0
  103. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +117 -0
  104. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  105. package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +20 -0
  106. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +156 -60
  107. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  108. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +23 -7
  109. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +20 -5
  110. package/node_modules/@agents-can-communicate/installer/src/index.mjs +3 -2
  111. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +108 -12
  112. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +19 -2
  113. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  114. package/node_modules/@agents-can-communicate/mcp-server/src/input-validator.mjs +79 -0
  115. package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +23 -28
  116. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +109 -71
  117. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +74 -93
  118. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  119. package/node_modules/@agents-can-communicate/protocol/src/config.mjs +1 -1
  120. package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +64 -0
  121. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +4 -1
  122. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +49 -90
  123. package/node_modules/@agents-can-communicate/protocol/src/states.mjs +13 -40
  124. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  125. package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +230 -0
  126. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +77 -28
  127. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +1 -1
  128. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +83 -35
  129. package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +112 -0
  130. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +18 -8
  131. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +86 -28
  132. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +121 -27
  133. package/package.json +22 -1
  134. package/node_modules/@agents-can-communicate/core/src/communication.mjs +0 -334
  135. package/node_modules/@agents-can-communicate/core/src/notify.mjs +0 -95
  136. package/node_modules/@agents-can-communicate/core/src/tasks.mjs +0 -244
  137. package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +0 -109
@@ -1,277 +1,192 @@
1
1
  const DEFAULT_BUDGET_BYTES = 6_000;
2
- // Held back from the required lines so the "not shown" note can always be
3
- // written. A projection that silently drops what it could not fit is how an
4
- // agent ends up confidently unaware.
5
- // Enough for the note *and* the command that reads what the note is about. It
6
- // said only that something had been withheld, and nothing anywhere - not the
7
- // skills, not the docs - said how to see it. A turn that reports a thing the
8
- // reader cannot reach is how an agent ends up inventing its own way in.
9
- // Enough for the longest over-budget note - the escalated "message did not fit"
10
- // line, which is longer than the plain "+N not shown" it replaces. Kept small
11
- // enough that the note still fits beside a header at the smallest budgets.
12
- const NOTE_RESERVE = 90;
13
2
  const FENCE = "```";
14
- // A peer cannot close a block it cannot name. The fence carries a marker that
15
- // is stripped from peer content, so forged delimiters stay inside the block.
16
3
  const BLOCK = "acc-peer-message";
17
4
 
18
5
  const bytes = value => Buffer.byteLength(value, "utf8");
19
-
20
- // Written from char codes rather than a literal class: an escaped control
21
- // range in a regex literal is corrupted silently by editors and patches, and a
22
- // corrupted range fails open.
23
6
  const CONTROL_CHARACTERS = new RegExp(
24
7
  `[${String.fromCharCode(0)}-${String.fromCharCode(8)}`
25
8
  + `${String.fromCharCode(11)}-${String.fromCharCode(31)}${String.fromCharCode(127)}]`, "g");
26
9
 
27
- /**
28
- * Render peer-controlled text as displayable data.
29
- *
30
- * Two separate jobs. Control sequences become visible escapes, so a message
31
- * cannot repaint or retitle the human's terminal. Fence markers are stripped,
32
- * so a message cannot break out of its own data block and continue as if ACC
33
- * had written the following lines.
34
- */
35
10
  function escapePeerText(value) {
36
11
  return String(value)
37
12
  .replaceAll(new RegExp(`${FENCE}${BLOCK}`, "g"), `'${FENCE}${BLOCK}`)
38
13
  .replaceAll(FENCE, `'${FENCE}`)
39
- // The labels that frame this block are ACC's words at the start of a line.
40
- // A peer writing one would otherwise produce a second line reading as ACC
41
- // framing a different message - the same break-out the fence rule prevents,
42
- // and neutralised the same way rather than by reflowing the text, which a
43
- // handoff body cannot survive.
44
14
  .replace(/^(subject:|body:)/gm, "'$1")
45
15
  .replace(CONTROL_CHARACTERS,
46
16
  character => `\\u${character.codePointAt(0).toString(16).padStart(4, "0")}`);
47
17
  }
48
18
 
19
+ function oneLine(value) {
20
+ return value.replaceAll("\r\n", "\\n").replaceAll("\n", "\\n").replaceAll("\r", "\\n");
21
+ }
22
+
49
23
  function truncate(line, limit) {
24
+ if (limit <= 0) return "";
50
25
  if (bytes(line) <= limit) return line;
51
26
  let cut = line;
52
27
  while (bytes(`${cut}…`) > limit && cut.length > 0) cut = cut.slice(0, -1);
53
- return `${cut}…`;
28
+ return cut === "" ? "" : `${cut}…`;
54
29
  }
55
30
 
56
- /**
57
- * An attention line an agent can act on without a round trip.
58
- *
59
- * `- [task_unblocked] Tank sinks through mud` says work is waiting and does not
60
- * say which work. The commands that take it - `acc task --take --task <id>` -
61
- * all need the id, and the only other place it appears is `acc sync --json`. An
62
- * agent that is told to act and not told on what improvises, which in this
63
- * project has already meant one hand-editing the store rather than admitting it
64
- * could not name the task.
65
- *
66
- * Every kind carries such an id and every one is the argument to a command:
67
- * a message to `acc ack --message`, a task to `acc task --task`, a claim to
68
- * `acc release --claim`. So they are all shown, not only the ones that happened
69
- * to be noticed first.
70
- */
71
- function attentionLines(attention) {
72
- return attention.map(item => (typeof item.sourceId === "string" && item.sourceId !== ""
73
- ? `- [${item.kind}] ${item.sourceId} ${item.summary}`
74
- : `- [${item.kind}] ${item.summary}`));
31
+ function attentionGroups(attention, { truncatable, liveOfferedMessageIds = new Set() }) {
32
+ return attention.map(item => {
33
+ const breadcrumb = offeredBreadcrumb(item, liveOfferedMessageIds);
34
+ return {
35
+ lines: [breadcrumb ?? (typeof item.sourceId === "string" && item.sourceId !== ""
36
+ ? `- [${item.kind}] ${item.sourceId} ${item.summary}`
37
+ : `- [${item.kind}] ${item.summary}`)],
38
+ kind: "attention",
39
+ sourceId: item.sourceId ?? null,
40
+ truncatable: breadcrumb === null && truncatable,
41
+ };
42
+ });
75
43
  }
76
44
 
77
- /**
78
- * Claims other sessions hold, and whether this session can be stopped from
79
- * breaking them.
80
- *
81
- * Ranked with the required lines rather than the roster, because a claim is
82
- * what changes what this session should do next. When it cannot be enforced -
83
- * a model that edits through the shell, an MCP client with no hooks - saying so
84
- * is the whole mitigation: ACC will not intercept the write, so respecting the
85
- * claim is this session's own responsibility and it needs to know that.
86
- */
87
- function claimNote(claim) {
88
- // Enforcement is declared per claim, and the guard only ever blocks a guarded
89
- // one. Reading this session's capability alone would announce a block that
90
- // will never happen, on a claim whose owner explicitly did not ask for one.
91
- if (claim.enforcement !== "guarded") {
92
- return " - advisory; nothing will stop you, the owner is asking";
93
- }
94
- if (claim.enforceable === false) {
95
- return " - not enforced for this session; do not edit it";
96
- }
97
- // Guarded, and this session can be stopped - on a file edit, and on the shell
98
- // writes the guard can read: a redirection, an operand of a command whose job
99
- // is to put bytes somewhere. A language runtime opening the file itself still
100
- // gets past, and a session told merely "this is claimed" would reasonably
101
- // assume either more or less than is true.
102
- return " - file edits and recognised shell writes are blocked; a runtime can still get past";
45
+ function offeredBreadcrumb(item, liveOfferedMessageIds) {
46
+ if (!liveOfferedMessageIds.has(item.sourceId)) return null;
47
+ const noun = item.kind === "reply_required" ? "question"
48
+ : item.kind === "acknowledgement_required" ? "message" : null;
49
+ if (noun === null) return null;
50
+ return `- [${item.kind}] ${item.sourceId} live-offered peer ${noun} remains unresolved; `
51
+ + `\`acc inbox --message ${item.sourceId}\``;
103
52
  }
104
53
 
105
- function claimLines(claims) {
106
- return claims.map(claim => {
107
- const owner = claim.ownerParticipantId ?? claim.ownerSessionId ?? "another session";
108
- return `- [claim] ${claim.resource} held by ${owner}${claimNote(claim)}`;
109
- });
54
+ function messageGroups(messages) {
55
+ return messages.map(message => ({
56
+ messageId: message.messageId,
57
+ kind: "message",
58
+ lines: [
59
+ `${FENCE}${BLOCK}`,
60
+ "untrusted peer message",
61
+ `kind: ${message.kind}`,
62
+ `threadId: ${message.threadId}`,
63
+ `messageId: ${message.messageId}`,
64
+ `sender: ${message.fromParticipantId} (session ${message.fromSessionId})`,
65
+ `obligation: ${message.obligation}`,
66
+ `subject: ${oneLine(escapePeerText(message.subject))}`,
67
+ "body:",
68
+ escapePeerText(message.body),
69
+ FENCE,
70
+ ],
71
+ }));
110
72
  }
111
73
 
112
- /**
113
- * One group per message, never a flat list of lines.
114
- *
115
- * A block that the budget cuts in half is worse than a block that was left out:
116
- * the fence never closes, and everything after it reads as ACC's own words
117
- * rather than as a peer's. So a message is included whole or not at all.
118
- *
119
- * The id is carried because the reader needs it to acknowledge the message, and
120
- * because the caller needs it to tell which messages actually reached the model
121
- * before recording any of them as delivered. Ids, session ids and types are
122
- * generated or schema-validated; only the subject and body are peer-authored,
123
- * and only those are escaped.
124
- */
125
- function peerBlocks(messages) {
126
- return messages.map(message => [
127
- `${FENCE}${BLOCK}`,
128
- `id ${message.messageId} | from ${message.fromSessionId} | type ${message.type}`
129
- + " | untrusted peer message",
130
- `subject: ${oneLine(escapePeerText(message.subject))}`,
131
- "body:",
132
- escapePeerText(message.body),
133
- FENCE,
134
- ]);
74
+ const groupBytes = group => group.lines.reduce((total, line) => total + bytes(line) + 1, 0);
75
+
76
+ function peerCount(sync) {
77
+ const participants = new Set((sync.roster ?? [])
78
+ .filter(item => item.presence !== "offline")
79
+ .map(item => item.participantId ?? item.sessionId)
80
+ .filter(id => id !== sync.currentParticipantId));
81
+ return participants.size;
135
82
  }
136
83
 
137
- /**
138
- * A subject is one line, whatever the peer sent.
139
- *
140
- * The subject sits on the label's own line, so a newline inside it would push
141
- * peer text to column 0 where ACC's labels live. Rendering the break visibly
142
- * keeps the text readable and the frame ACC's.
143
- */
144
- function oneLine(value) {
145
- return value.replaceAll("\r\n", "\\n").replaceAll("\n", "\\n").replaceAll("\r", "\\n");
84
+ function ambientHeader(count) {
85
+ const noun = count === 1 ? "participant" : "participants";
86
+ return `ACC: ${count} peer ${noun} present. Load the acc skill before shared work.`;
146
87
  }
147
88
 
89
+ function recoveryNote(ids) {
90
+ const first = ids[0];
91
+ const rest = ids.length > 1 ? ` (+${ids.length - 1} more in \`acc inbox\`)` : "";
92
+ return `- read ${first}: \`acc inbox --message ${first}\`${rest}`;
93
+ }
148
94
 
149
95
  /**
150
- * Project a SyncResult into bounded text for one adapter to inject.
96
+ * Project only coordination that can change this turn.
151
97
  *
152
- * Priority is fixed: direct requests and conflicts first, roster detail last,
153
- * because the budget is spent from the bottom. Whatever is dropped is counted
154
- * rather than silently removed - a projection that hides its own omissions is
155
- * how an agent ends up confidently unaware.
98
+ * Presence is a short trigger to load the ACC skill. Roster rows and unrelated
99
+ * claims are intentionally absent: a guard checks exact file claims at write
100
+ * time, while intent-aware conflicts already arrive as attention. Repeating
101
+ * the whole workspace on every prompt is neither safer nor cheaper.
156
102
  */
157
- export function projectContext(sync, { budgetBytes = DEFAULT_BUDGET_BYTES } = {}) {
103
+ export function projectContextResult(sync, { budgetBytes = DEFAULT_BUDGET_BYTES } = {}) {
158
104
  const attention = [...(sync.attention ?? [])]
159
105
  .sort((left, right) => left.priority - right.priority
160
- || left.sourceId.localeCompare(right.sourceId));
106
+ || (left.sourceId ?? "").localeCompare(right.sourceId ?? ""));
107
+ const leadsPeerBodies = item => item.priority <= 2 || item.kind === "claim_conflict";
108
+ const urgent = attention.filter(leadsPeerBodies);
109
+ const informational = attention.filter(item => !leadsPeerBodies(item));
161
110
  const messages = sync.messages ?? [];
162
- // Who is here, which is not the same as who has ever been here. The roster
163
- // keeps closed sessions - `sync` needs them to decide what a cursor has missed
164
- // - and a turn that lists them says "3 session(s)" for two participants, one
165
- // of them shown twice with contradictory presence. Left alone it also grows
166
- // without limit: every session ever opened would take a line out of the
167
- // context budget, crowding out messages actually addressed to the reader.
168
- // Stale stays: a session that crashed holding a claim is very much news.
169
- const roster = (sync.roster ?? []).filter(item => item.presence !== "offline");
170
- const claims = sync.claims ?? [];
171
-
172
- // Every entry is a group that appears whole or not at all. Single-line groups
173
- // may still be truncated - there is no fence in them to leave open. `message`
174
- // is carried so a dropped message can be counted apart from a dropped
175
- // reminder: the two are not the same news.
176
- //
177
- // A peer message sits after "act now" attention (a direct request, an imminent
178
- // conflict: priority <= 2) and ahead of standing reminders. The order is the
179
- // fix for a real starvation: an expired-claim line (priority 6) regenerates
180
- // from state every turn, while a message is delivered once and its receipt
181
- // then stops it appearing - so a reminder that never clears must not keep
182
- // pushing a one-time message into the over-budget overflow, turn after turn,
183
- // where two agents each lost their most important message to it.
184
- const urgent = attention.filter(item => item.priority <= 2);
185
- const info = attention.filter(item => item.priority > 2);
186
- const required = [
187
- ...attentionLines(urgent).map(line => ({ lines: [line], message: false })),
188
- ...peerBlocks(messages).map(block => ({ lines: block, message: true })),
189
- ...attentionLines(info).map(line => ({ lines: [line], message: false })),
190
- ...claimLines(claims).map(line => ({ lines: [line], message: false })),
111
+ const liveOfferedMessageIds = new Set(sync.liveOfferedMessageIds ?? []);
112
+ const groups = [
113
+ ...attentionGroups(urgent, { truncatable: true, liveOfferedMessageIds }),
114
+ ...messageGroups(messages),
115
+ ...attentionGroups(informational, { truncatable: false }),
191
116
  ];
192
- // Solo costs nothing: a lone session pays no visible price, and "no peers" is
193
- // still a cost when injected into every turn. But this is decided after the
194
- // required lines are built, not before - a message already addressed to you,
195
- // or a claim you could break, is not nothing, and returning early swallowed
196
- // exactly the things worth saying to someone working alone.
197
- if (sync.solo === true && required.length === 0) return "";
198
-
199
- // Named by participant, because that is what another agent addresses work to
200
- // - a session id cannot be used with `--to`. The branch says where they are,
201
- // which is how a workspace spanning several worktrees stays legible.
202
- const optional = roster.map(item => {
203
- const who = item.participantId ?? item.sessionId;
204
- const place = item.branch === null || item.branch === undefined
205
- ? ""
206
- : ` on ${item.branch}`;
207
- return `- ${who}${place} (${item.harness}, ${item.presence})`;
208
- });
209
-
210
- const header = `${roster.length} session(s); cursor ${sync.cursor}`;
211
- const lines = [header];
212
- let used = bytes(header);
117
+ const peers = peerCount(sync);
118
+ if (groups.length === 0 && (sync.solo === true || peers === 0)) {
119
+ return { text: "", offeredMessageIds: [], includedAttentionIds: [] };
120
+ }
213
121
 
214
- // Reserved so the note below always fits. Without it the projection could run
215
- // out of room to say that it ran out of room.
216
- const ceiling = budgetBytes - NOTE_RESERVE;
122
+ const fullHeader = groups.length === 0 ? ambientHeader(peers) : "ACC (load the acc skill):";
123
+ const header = truncate(fullHeader, budgetBytes);
124
+ const lines = header === "" ? [] : [header];
125
+ let used = header === "" ? 0 : bytes(header) + 1;
126
+ const included = [];
127
+ const droppedMessages = [];
217
128
  let droppedOther = 0;
218
- let droppedMessages = 0;
219
- const drop = group => { if (group.message) droppedMessages += 1; else droppedOther += 1; };
220
- for (const group of required) {
221
- const block = group.lines;
222
- if (block.length === 1) {
223
- const candidate = truncate(block[0], Math.max(0, ceiling - used - 1));
224
- if (candidate === "" || used + bytes(candidate) + 1 > ceiling) { drop(group); continue; }
225
- lines.push(candidate);
226
- used += bytes(candidate) + 1;
129
+
130
+ for (const group of groups) {
131
+ if (group.lines.length === 1) {
132
+ const remaining = budgetBytes - used;
133
+ const line = group.truncatable
134
+ ? truncate(group.lines[0], remaining)
135
+ : (bytes(group.lines[0]) <= remaining ? group.lines[0] : "");
136
+ if (line !== "" && used + bytes(line) <= budgetBytes) {
137
+ lines.push(line);
138
+ included.push({ group, lineCount: 1 });
139
+ used += bytes(line) + 1;
140
+ } else {
141
+ droppedOther += 1;
142
+ }
227
143
  continue;
228
144
  }
229
- const size = block.reduce((total, line) => total + bytes(line) + 1, 0);
230
- // Skipped rather than stopped at: the groups are ordered by priority, and a
231
- // large message must not hide the shorter ones behind it.
232
- if (used + size > ceiling) { drop(group); continue; }
233
- lines.push(...block);
234
- used += size;
235
- }
236
- // A dropped message is louder than a dropped reminder: "+N not shown" read as
237
- // noise to two agents who each lost their most important message to it, so a
238
- // message that did not fit says so specifically and names the command that
239
- // recovers it. One note either way, escalated when a message is among the loss.
240
- // The note is guarded against the budget, not merely reserved for: at a
241
- // pathologically small budget the header alone can leave less room than the
242
- // note needs, and a projection that overran the very budget it exists to
243
- // respect is the bug this whole function is careful about. If the full note
244
- // will not fit, the shortest imperative that does is still not silence.
245
- const pushNote = note => {
246
- const short = "- ⚠ over budget; `acc sync --scope full --json`";
247
- for (const candidate of [note, short]) {
248
- if (used + bytes(candidate) + 1 <= budgetBytes) {
249
- lines.push(candidate);
250
- used += bytes(candidate) + 1;
251
- return;
252
- }
145
+ const size = groupBytes(group);
146
+ if (used + size <= budgetBytes) {
147
+ lines.push(...group.lines);
148
+ included.push({ group, lineCount: group.lines.length });
149
+ used += size;
150
+ } else {
151
+ droppedMessages.push(group.messageId);
253
152
  }
254
- };
255
- if (droppedMessages > 0) {
256
- // No count of the other drops: `--scope full` recovers everything.
257
- pushNote(`- ⚠ ${droppedMessages} message(s) addressed to you did not fit; `
258
- + "run `acc sync --scope full --json`");
259
- } else if (droppedOther > 0) {
260
- pushNote(`- +${droppedOther} not shown, over budget; read them with `
261
- + "`acc sync --scope full --json`");
262
153
  }
263
154
 
264
- let shown = 0;
265
- for (const line of optional) {
266
- if (used + bytes(line) + 1 > budgetBytes - 16) break;
267
- lines.push(line);
268
- used += bytes(line) + 1;
269
- shown += 1;
270
- }
271
- if (shown < optional.length) {
272
- const note = `- +${optional.length - shown} more`;
155
+ if (droppedMessages.length > 0) {
156
+ let note = recoveryNote(droppedMessages);
157
+ while (used + bytes(note) + 1 > budgetBytes && included.length > 0) {
158
+ const removed = included.pop();
159
+ lines.splice(-removed.lineCount, removed.lineCount);
160
+ used = lines.reduce((total, line) => total + bytes(line) + 1, 0);
161
+ if (removed.group.kind === "message") droppedMessages.push(removed.group.messageId);
162
+ else droppedOther += 1;
163
+ note = recoveryNote([...new Set(droppedMessages)]);
164
+ }
165
+ if (used + bytes(note) + 1 > budgetBytes && lines.length > 0) {
166
+ lines.length = 0;
167
+ used = 0;
168
+ }
169
+ const fitted = recoveryNote([...new Set(droppedMessages)]);
170
+ // An incomplete id or command is not a recovery path. When a deliberately
171
+ // tiny budget cannot hold the shortest truthful instruction, say nothing
172
+ // and leave every omitted receipt queued for a later turn.
173
+ if (used + bytes(fitted) + 1 <= budgetBytes) lines.push(fitted);
174
+ } else if (droppedOther > 0) {
175
+ const note = `- +${droppedOther} actionable item(s) omitted; run \`acc status --json\``;
273
176
  if (used + bytes(note) + 1 <= budgetBytes) lines.push(note);
274
177
  }
275
178
 
276
- return lines.join("\n");
179
+ return {
180
+ text: lines.join("\n"),
181
+ offeredMessageIds: included
182
+ .filter(item => item.group.kind === "message")
183
+ .map(item => item.group.messageId),
184
+ includedAttentionIds: included
185
+ .filter(item => item.group.kind === "attention" && item.group.sourceId !== null)
186
+ .map(item => item.group.sourceId),
187
+ };
188
+ }
189
+
190
+ export function projectContext(sync, options) {
191
+ return projectContextResult(sync, options).text;
277
192
  }
@@ -1,4 +1,4 @@
1
- import { access, chmod, mkdir, readFile, readdir, rm, writeFile }
1
+ import { access, chmod, lstat, mkdir, readFile, readdir, rm, writeFile }
2
2
  from "node:fs/promises";
3
3
  import { existsSync } from "node:fs";
4
4
  import path from "node:path";
@@ -147,6 +147,12 @@ export async function writeHookShim({ dir, adapterId, runner = defaultRunner(),
147
147
  */
148
148
  export async function removeInstalledTree(target, keep = []) {
149
149
  if (keep.includes(target)) return false;
150
+ try {
151
+ await lstat(target);
152
+ } catch (error) {
153
+ if (error.code === "ENOENT") return false;
154
+ throw error;
155
+ }
150
156
  await rm(target, { recursive: true, force: true });
151
157
  return true;
152
158
  }
@@ -1,13 +1,14 @@
1
1
  // Capability contract, context projection, config ownership, and the binding
2
2
  // that survives between two ephemeral hook processes.
3
3
  export { CAPABILITY_SHAPE, assertCapabilities, defineAdapter } from "./capabilities.mjs";
4
+ export { effectiveCapabilities, validateCertification } from "./certification.mjs";
4
5
  export { EVENT_KINDS, NORMALIZED_EVENT_KEYS, normalizedEvent } from "./events.mjs";
5
6
  export { assertRunner, bakeSkillCommand, defaultCli, defaultRunner, removeInstalledTree,
6
7
  runnerExists, writeHookShim }
7
8
  from "./hook-shim.mjs";
8
9
  export { BEGIN, END, removeTomlBlock, renderBlock, stripBlock, tomlString, writeTomlBlock }
9
10
  from "./toml-block.mjs";
10
- export { projectContext } from "./context-projector.mjs";
11
+ export { projectContext, projectContextResult } from "./context-projector.mjs";
11
12
  export { shellWriteTargets } from "./shell-writes.mjs";
12
13
  export { keepOnlyVersion, ownVersion, stampPluginVersion } from "./own-version.mjs";
13
14
  export { editJson, readJson } from "./json-text.mjs";
@@ -18,13 +18,16 @@ const fileFor = (runtimeDir, harnessSessionId) => path.join(runtimeDir, "binding
18
18
  * detach the exact generation instead of opening a second session.
19
19
  *
20
20
  * It lives in the runtime directory, never the project, and carries identity
21
- * only - no prompt, no transcript, no harness state.
21
+ * plus the exact client facts observed at attach time - no prompt, transcript,
22
+ * or harness state.
22
23
  */
23
24
  export async function storeSessionBinding({ runtimeDir, harnessSessionId, accSessionId,
24
- generation }) {
25
+ generation, clientVersion, platform }) {
25
26
  const file = fileFor(runtimeDir, harnessSessionId);
26
27
  await mkdir(path.dirname(file), { recursive: true });
27
28
  const record = { schemaVersion: SCHEMA_VERSION, harnessSessionId, accSessionId, generation };
29
+ if (typeof clientVersion === "string" && clientVersion !== "") record.clientVersion = clientVersion;
30
+ if (typeof platform === "string" && platform !== "") record.platform = platform;
28
31
  const temporary = `${file}.${process.pid}.tmp`;
29
32
  await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, "utf8");
30
33
  // Replace rather than append: re-attaching supersedes the old generation, and
@@ -54,7 +57,10 @@ export async function loadSessionBinding({ runtimeDir, harnessSessionId }) {
54
57
  throw new AccError(EXIT.DATA, "unknown session binding schemaVersion",
55
58
  { file, schemaVersion: record?.schemaVersion });
56
59
  }
57
- return { accSessionId: record.accSessionId, generation: record.generation };
60
+ const binding = { accSessionId: record.accSessionId, generation: record.generation };
61
+ if (typeof record.clientVersion === "string") binding.clientVersion = record.clientVersion;
62
+ if (typeof record.platform === "string") binding.platform = record.platform;
63
+ return binding;
58
64
  }
59
65
 
60
66
  export async function clearSessionBinding({ runtimeDir, harnessSessionId }) {
@@ -89,7 +95,10 @@ export async function listSessionBindings({ runtimeDir }) {
89
95
  .then(JSON.parse).catch(() => null);
90
96
  if (record?.schemaVersion !== SCHEMA_VERSION) continue;
91
97
  bindings.push({ harnessSessionId: record.harnessSessionId,
92
- accSessionId: record.accSessionId, generation: record.generation });
98
+ accSessionId: record.accSessionId, generation: record.generation,
99
+ ...(typeof record.clientVersion === "string"
100
+ ? { clientVersion: record.clientVersion } : {}),
101
+ ...(typeof record.platform === "string" ? { platform: record.platform } : {}) });
93
102
  }
94
103
  return bindings;
95
104
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/cli",
3
- "version": "0.1.17",
3
+ "version": "0.2.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -14,7 +14,7 @@ export const COMMANDS = Object.freeze({
14
14
  detach: { required: ["session", "generation"], optional: [] },
15
15
  sync: { required: [], optional: ["session", "cursor", "limit", "scope"] },
16
16
  work: { required: [], optional: ["session", "generation", "summary", "mode",
17
- "state", "workstream"], repeated: ["hint"], flags: ["clear"] },
17
+ "state"], repeated: ["hint"], flags: ["clear"] },
18
18
  claim: { required: ["resource"],
19
19
  optional: ["session", "generation", "mode", "enforcement", "reason", "lease"] },
20
20
  // Neither is required on its own, because either one names the claim: an id
@@ -24,34 +24,17 @@ export const COMMANDS = Object.freeze({
24
24
  release: { required: [],
25
25
  optional: ["claim", "resource", "session", "generation", "authority", "reason"] },
26
26
  message: { required: ["subject", "body"],
27
- optional: ["session", "generation", "type", "priority", "workstream"],
28
- repeated: ["to"], flags: ["requires-ack"] },
29
- // Asking another agent to do something: one call, because a task nobody was
30
- // told about and a message pointing at no task are each useless.
27
+ optional: ["session", "generation", "type", "obligation", "client-message-id"],
28
+ repeated: ["to"] },
29
+ inbox: { required: [], optional: ["session", "generation", "message"] },
30
+ reply: { required: ["message", "body"],
31
+ optional: ["session", "generation", "subject", "client-message-id"] },
32
+ // Asking another agent to do something as a message with a reply obligation.
31
33
  request: { required: ["to", "title"],
32
- optional: ["session", "generation", "detail", "workstream", "priority"],
33
- repeated: ["depends-on"] },
34
- // Create a task, or act on one with --task. A workstream is optional: small
35
- // requests should not have to invent a project first.
36
- task: { required: [], optional: ["session", "generation", "workstream", "title",
37
- "detail", "assignee", "state", "task", "reason"],
38
- repeated: ["depends-on"], flags: ["take", "decline", "force"] },
39
- // Create one, or take and hand back the coordination of one that exists.
40
- // Creating a workstream raised `coordinator_missing` on every turn from then
41
- // on, and nothing could answer it: the two core operations that do had no
42
- // surface at all.
43
- workstream: { required: [], optional: ["session", "generation", "title", "objective",
44
- "workstream"], flags: ["take", "release"] },
45
- // Messages not tied to a task need a way to be answered too. Without one a
46
- // `requiresAck` message raised an attention item nothing could ever clear.
47
- ack: { required: ["message"], optional: ["session", "generation", "state"] },
48
- // Recording what was settled, so the next session does not reopen it. The
49
- // protocol has described this object from the start and nothing could make
50
- // one: no command, no tool.
51
- decide: { required: ["title", "outcome"],
52
- optional: ["session", "generation", "authority", "workstream", "supersedes"],
53
- repeated: ["decided-by"], flags: ["human"] },
54
- finish: { required: ["goal"], optional: ["session", "generation", "status", "to"],
34
+ optional: ["session", "generation", "detail", "client-message-id"] },
35
+ ack: { required: ["message"], optional: ["session", "generation"] },
36
+ finish: { required: ["goal"], optional: ["session", "generation", "status", "to",
37
+ "client-message-id"],
55
38
  repeated: ["completed", "remaining", "blocker"] },
56
39
  status: { required: [], optional: ["participant"], flags: ["all"] },
57
40
  doctor: { required: [], optional: ["home"], flags: ["repair"] },
@@ -66,7 +49,8 @@ export const COMMANDS = Object.freeze({
66
49
  // `--downgrade` because an older acc first on PATH will otherwise rewire every
67
50
  // client to itself, and the only symptom is a guard behaving like the version
68
51
  // it came from.
69
- install: { required: [], optional: ["adapter", "home"], flags: ["dry-run", "downgrade"] },
52
+ install: { required: [], optional: ["adapter", "home", "delivery"],
53
+ flags: ["dry-run", "downgrade"] },
70
54
  // `--dry-run` on both, because the preview was computed for either action and
71
55
  // only `install` could ask for it. Removal is the side that reaches into a
72
56
  // client's configuration - including a client that has left the machine.
@@ -238,6 +238,9 @@ export async function runDoctor({ options, context, runtime }) {
238
238
  // the store was healthy and nothing else.
239
239
  const text = [`store healthy; ${describePresence(status.counts)}; `
240
240
  + `protection ${status.protection}; ${installed} of ${adapters.length} adapter(s) installed`,
241
+ ...adapters.filter(adapter => (adapter.present || adapter.installed)
242
+ && typeof adapter.deliveryDiagnostic === "string")
243
+ .map(adapter => ` ${adapter.deliveryDiagnostic}`),
241
244
  ...data.remediation.map(line => ` ${line}`),
242
245
  // `0 of 4` is a true line that reads as a broken machine, and on an
243
246
  // MCP-only one it would read that way on every run forever. The server needs
@@ -13,8 +13,8 @@ import { COMMANDS } from "./args.mjs";
13
13
  */
14
14
  const GROUPS = Object.freeze([
15
15
  ["Set up", ["install", "uninstall", "doctor", "config"]],
16
- ["In a session", ["status", "sync", "work", "claim", "release", "ack", "message",
17
- "request", "task", "workstream", "decide", "finish"]],
16
+ ["In a session", ["status", "sync", "work", "claim", "release", "inbox", "reply",
17
+ "ack", "message", "request", "finish"]],
18
18
  ["Driven by adapters, not by people", ["attach", "heartbeat", "detach"]],
19
19
  ["About acc", ["help", "version", "update"]],
20
20
  ]);
@@ -31,10 +31,9 @@ const SUMMARY = Object.freeze({
31
31
  release: "give a claim back",
32
32
  ack: "answer a message that asked for one, so it stops asking",
33
33
  message: "send a typed message to named participants",
34
- request: "ask another agent to do something: the work and the why, in one call",
35
- task: "create work, --take it, or move its --state along",
36
- workstream: "group related work, and steer it with --take / --release",
37
- decide: "record what was settled, so the next session does not reopen it",
34
+ inbox: "read only unresolved messages addressed to this participant",
35
+ reply: "reply to one message and acknowledge it in the same operation",
36
+ request: "ask another agent to do something in a reply-required message",
38
37
  finish: "write the handoff and release what this session held",
39
38
  attach: "open a session; an adapter calls this, not a person",
40
39
  heartbeat: "say the session is still alive",