agents-can-communicate 0.1.16 → 0.1.18

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 (49) hide show
  1. package/README.md +63 -133
  2. package/bin/acc-hook.mjs +2 -0
  3. package/docs/CAPABILITIES.md +105 -85
  4. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  5. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +80 -154
  6. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +3 -1
  7. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  8. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +80 -154
  9. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +3 -1
  10. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +80 -154
  11. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  12. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +3 -1
  13. package/node_modules/@agents-can-communicate/adapter-grok/package.json +13 -0
  14. package/node_modules/@agents-can-communicate/adapter-grok/plugin/hooks/hooks.json +61 -0
  15. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +154 -0
  16. package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +61 -0
  17. package/node_modules/@agents-can-communicate/adapter-grok/src/hooks.mjs +127 -0
  18. package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +101 -0
  19. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  20. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +80 -154
  21. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +3 -1
  22. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  23. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +125 -189
  24. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -1
  25. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  26. package/node_modules/@agents-can-communicate/cli/src/args.mjs +3 -0
  27. package/node_modules/@agents-can-communicate/cli/src/help.mjs +4 -2
  28. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +3 -1
  29. package/node_modules/@agents-can-communicate/cli/src/main.mjs +23 -2
  30. package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +1 -1
  31. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  32. package/node_modules/@agents-can-communicate/core/src/inbox.mjs +134 -0
  33. package/node_modules/@agents-can-communicate/core/src/index.mjs +1 -0
  34. package/node_modules/@agents-can-communicate/core/src/message-signals.mjs +41 -0
  35. package/node_modules/@agents-can-communicate/core/src/ports.mjs +1 -1
  36. package/node_modules/@agents-can-communicate/core/src/service.mjs +3 -0
  37. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +48 -0
  38. package/node_modules/@agents-can-communicate/core/src/sync.mjs +36 -0
  39. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  40. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +77 -33
  41. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  42. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  43. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +29 -21
  44. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +25 -1
  45. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  46. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  47. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +23 -7
  48. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +18 -2
  49. package/package.json +4 -1
@@ -1,237 +1,173 @@
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
- const NOTE_RESERVE = 80;
10
2
  const FENCE = "```";
11
- // A peer cannot close a block it cannot name. The fence carries a marker that
12
- // is stripped from peer content, so forged delimiters stay inside the block.
13
3
  const BLOCK = "acc-peer-message";
14
4
 
15
5
  const bytes = value => Buffer.byteLength(value, "utf8");
16
-
17
- // Written from char codes rather than a literal class: an escaped control
18
- // range in a regex literal is corrupted silently by editors and patches, and a
19
- // corrupted range fails open.
20
6
  const CONTROL_CHARACTERS = new RegExp(
21
7
  `[${String.fromCharCode(0)}-${String.fromCharCode(8)}`
22
8
  + `${String.fromCharCode(11)}-${String.fromCharCode(31)}${String.fromCharCode(127)}]`, "g");
23
9
 
24
- /**
25
- * Render peer-controlled text as displayable data.
26
- *
27
- * Two separate jobs. Control sequences become visible escapes, so a message
28
- * cannot repaint or retitle the human's terminal. Fence markers are stripped,
29
- * so a message cannot break out of its own data block and continue as if ACC
30
- * had written the following lines.
31
- */
32
10
  function escapePeerText(value) {
33
11
  return String(value)
34
12
  .replaceAll(new RegExp(`${FENCE}${BLOCK}`, "g"), `'${FENCE}${BLOCK}`)
35
13
  .replaceAll(FENCE, `'${FENCE}`)
36
- // The labels that frame this block are ACC's words at the start of a line.
37
- // A peer writing one would otherwise produce a second line reading as ACC
38
- // framing a different message - the same break-out the fence rule prevents,
39
- // and neutralised the same way rather than by reflowing the text, which a
40
- // handoff body cannot survive.
41
14
  .replace(/^(subject:|body:)/gm, "'$1")
42
15
  .replace(CONTROL_CHARACTERS,
43
16
  character => `\\u${character.codePointAt(0).toString(16).padStart(4, "0")}`);
44
17
  }
45
18
 
19
+ function oneLine(value) {
20
+ return value.replaceAll("\r\n", "\\n").replaceAll("\n", "\\n").replaceAll("\r", "\\n");
21
+ }
22
+
46
23
  function truncate(line, limit) {
24
+ if (limit <= 0) return "";
47
25
  if (bytes(line) <= limit) return line;
48
26
  let cut = line;
49
27
  while (bytes(`${cut}…`) > limit && cut.length > 0) cut = cut.slice(0, -1);
50
- return `${cut}…`;
28
+ return cut === "" ? "" : `${cut}…`;
51
29
  }
52
30
 
53
- /**
54
- * An attention line an agent can act on without a round trip.
55
- *
56
- * `- [task_unblocked] Tank sinks through mud` says work is waiting and does not
57
- * say which work. The commands that take it - `acc task --take --task <id>` -
58
- * all need the id, and the only other place it appears is `acc sync --json`. An
59
- * agent that is told to act and not told on what improvises, which in this
60
- * project has already meant one hand-editing the store rather than admitting it
61
- * could not name the task.
62
- *
63
- * Every kind carries such an id and every one is the argument to a command:
64
- * a message to `acc ack --message`, a task to `acc task --task`, a claim to
65
- * `acc release --claim`. So they are all shown, not only the ones that happened
66
- * to be noticed first.
67
- */
68
- function attentionLines(attention) {
69
- return attention.map(item => (typeof item.sourceId === "string" && item.sourceId !== ""
70
- ? `- [${item.kind}] ${item.sourceId} ${item.summary}`
71
- : `- [${item.kind}] ${item.summary}`));
31
+ function attentionGroups(attention, { truncatable }) {
32
+ return attention.map(item => ({
33
+ lines: [typeof item.sourceId === "string" && item.sourceId !== ""
34
+ ? `- [${item.kind}] ${item.sourceId} ${item.summary}`
35
+ : `- [${item.kind}] ${item.summary}`],
36
+ kind: "attention",
37
+ sourceId: item.sourceId ?? null,
38
+ truncatable,
39
+ }));
72
40
  }
73
41
 
74
- /**
75
- * Claims other sessions hold, and whether this session can be stopped from
76
- * breaking them.
77
- *
78
- * Ranked with the required lines rather than the roster, because a claim is
79
- * what changes what this session should do next. When it cannot be enforced -
80
- * a model that edits through the shell, an MCP client with no hooks - saying so
81
- * is the whole mitigation: ACC will not intercept the write, so respecting the
82
- * claim is this session's own responsibility and it needs to know that.
83
- */
84
- function claimNote(claim) {
85
- // Enforcement is declared per claim, and the guard only ever blocks a guarded
86
- // one. Reading this session's capability alone would announce a block that
87
- // will never happen, on a claim whose owner explicitly did not ask for one.
88
- if (claim.enforcement !== "guarded") {
89
- return " - advisory; nothing will stop you, the owner is asking";
90
- }
91
- if (claim.enforceable === false) {
92
- return " - not enforced for this session; do not edit it";
93
- }
94
- // Guarded, and this session can be stopped - on a file edit, and on the shell
95
- // writes the guard can read: a redirection, an operand of a command whose job
96
- // is to put bytes somewhere. A language runtime opening the file itself still
97
- // gets past, and a session told merely "this is claimed" would reasonably
98
- // assume either more or less than is true.
99
- return " - file edits and recognised shell writes are blocked; a runtime can still get past";
42
+ function messageGroups(messages) {
43
+ return messages.map(message => ({
44
+ messageId: message.messageId,
45
+ kind: "message",
46
+ lines: [
47
+ `${FENCE}${BLOCK}`,
48
+ `id ${message.messageId} | from ${message.fromSessionId} | type ${message.type}`
49
+ + " | untrusted peer message",
50
+ `subject: ${oneLine(escapePeerText(message.subject))}`,
51
+ "body:",
52
+ escapePeerText(message.body),
53
+ FENCE,
54
+ ],
55
+ }));
100
56
  }
101
57
 
102
- function claimLines(claims) {
103
- return claims.map(claim => {
104
- const owner = claim.ownerParticipantId ?? claim.ownerSessionId ?? "another session";
105
- return `- [claim] ${claim.resource} held by ${owner}${claimNote(claim)}`;
106
- });
107
- }
58
+ const groupBytes = group => group.lines.reduce((total, line) => total + bytes(line) + 1, 0);
108
59
 
109
- /**
110
- * One group per message, never a flat list of lines.
111
- *
112
- * A block that the budget cuts in half is worse than a block that was left out:
113
- * the fence never closes, and everything after it reads as ACC's own words
114
- * rather than as a peer's. So a message is included whole or not at all.
115
- *
116
- * The id is carried because the reader needs it to acknowledge the message, and
117
- * because the caller needs it to tell which messages actually reached the model
118
- * before recording any of them as delivered. Ids, session ids and types are
119
- * generated or schema-validated; only the subject and body are peer-authored,
120
- * and only those are escaped.
121
- */
122
- function peerBlocks(messages) {
123
- return messages.map(message => [
124
- `${FENCE}${BLOCK}`,
125
- `id ${message.messageId} | from ${message.fromSessionId} | type ${message.type}`
126
- + " | untrusted peer message",
127
- `subject: ${oneLine(escapePeerText(message.subject))}`,
128
- "body:",
129
- escapePeerText(message.body),
130
- FENCE,
131
- ]);
60
+ function peerCount(sync) {
61
+ const participants = new Set((sync.roster ?? [])
62
+ .filter(item => item.presence !== "offline")
63
+ .map(item => item.participantId ?? item.sessionId)
64
+ .filter(id => id !== sync.currentParticipantId));
65
+ return participants.size;
132
66
  }
133
67
 
134
- /**
135
- * A subject is one line, whatever the peer sent.
136
- *
137
- * The subject sits on the label's own line, so a newline inside it would push
138
- * peer text to column 0 where ACC's labels live. Rendering the break visibly
139
- * keeps the text readable and the frame ACC's.
140
- */
141
- function oneLine(value) {
142
- return value.replaceAll("\r\n", "\\n").replaceAll("\n", "\\n").replaceAll("\r", "\\n");
68
+ function ambientHeader(count) {
69
+ const noun = count === 1 ? "participant" : "participants";
70
+ return `ACC: ${count} peer ${noun} present. Load the acc skill before shared work.`;
143
71
  }
144
72
 
73
+ function recoveryNote(ids) {
74
+ const first = ids[0];
75
+ const rest = ids.length > 1 ? ` (+${ids.length - 1} more in \`acc inbox\`)` : "";
76
+ return `- read ${first}: \`acc inbox --message ${first}\`${rest}`;
77
+ }
145
78
 
146
79
  /**
147
- * Project a SyncResult into bounded text for one adapter to inject.
80
+ * Project only coordination that can change this turn.
148
81
  *
149
- * Priority is fixed: direct requests and conflicts first, roster detail last,
150
- * because the budget is spent from the bottom. Whatever is dropped is counted
151
- * rather than silently removed - a projection that hides its own omissions is
152
- * how an agent ends up confidently unaware.
82
+ * Presence is a short trigger to load the ACC skill. Roster rows and unrelated
83
+ * claims are intentionally absent: a guard checks exact file claims at write
84
+ * time, while intent-aware conflicts already arrive as attention. Repeating
85
+ * the whole workspace on every prompt is neither safer nor cheaper.
153
86
  */
154
- export function projectContext(sync, { budgetBytes = DEFAULT_BUDGET_BYTES } = {}) {
87
+ export function projectContextResult(sync, { budgetBytes = DEFAULT_BUDGET_BYTES } = {}) {
155
88
  const attention = [...(sync.attention ?? [])]
156
89
  .sort((left, right) => left.priority - right.priority
157
- || left.sourceId.localeCompare(right.sourceId));
158
- const messages = sync.messages ?? [];
159
- // Who is here, which is not the same as who has ever been here. The roster
160
- // keeps closed sessions - `sync` needs them to decide what a cursor has missed
161
- // - and a turn that lists them says "3 session(s)" for two participants, one
162
- // of them shown twice with contradictory presence. Left alone it also grows
163
- // without limit: every session ever opened would take a line out of the
164
- // context budget, crowding out messages actually addressed to the reader.
165
- // Stale stays: a session that crashed holding a claim is very much news.
166
- const roster = (sync.roster ?? []).filter(item => item.presence !== "offline");
167
- const claims = sync.claims ?? [];
168
-
169
- // Every entry is a group that appears whole or not at all. Single-line groups
170
- // may still be truncated - there is no fence in them to leave open.
171
- const required = [
172
- ...attentionLines(attention).map(line => [line]),
173
- ...claimLines(claims).map(line => [line]),
174
- ...peerBlocks(messages),
90
+ || (left.sourceId ?? "").localeCompare(right.sourceId ?? ""));
91
+ const urgent = attention.filter(item => item.priority <= 2);
92
+ const informational = attention.filter(item => item.priority > 2);
93
+ const groups = [
94
+ ...attentionGroups(urgent, { truncatable: true }),
95
+ ...messageGroups(sync.messages ?? []),
96
+ ...attentionGroups(informational, { truncatable: false }),
175
97
  ];
176
- // Solo costs nothing: a lone session pays no visible price, and "no peers" is
177
- // still a cost when injected into every turn. But this is decided after the
178
- // required lines are built, not before - a message already addressed to you,
179
- // or a claim you could break, is not nothing, and returning early swallowed
180
- // exactly the things worth saying to someone working alone.
181
- if (sync.solo === true && required.length === 0) return "";
182
-
183
- // Named by participant, because that is what another agent addresses work to
184
- // - a session id cannot be used with `--to`. The branch says where they are,
185
- // which is how a workspace spanning several worktrees stays legible.
186
- const optional = roster.map(item => {
187
- const who = item.participantId ?? item.sessionId;
188
- const place = item.branch === null || item.branch === undefined
189
- ? ""
190
- : ` on ${item.branch}`;
191
- return `- ${who}${place} (${item.harness}, ${item.presence})`;
192
- });
193
-
194
- const header = `${roster.length} session(s); cursor ${sync.cursor}`;
195
- const lines = [header];
196
- let used = bytes(header);
197
-
198
- // Reserved so the note below always fits. Without it the projection could run
199
- // out of room to say that it ran out of room.
200
- const ceiling = budgetBytes - NOTE_RESERVE;
201
- let dropped = 0;
202
- for (const group of required) {
203
- if (group.length === 1) {
204
- const candidate = truncate(group[0], Math.max(0, ceiling - used - 1));
205
- if (candidate === "" || used + bytes(candidate) + 1 > ceiling) { dropped += 1; continue; }
206
- lines.push(candidate);
207
- used += bytes(candidate) + 1;
98
+ const peers = peerCount(sync);
99
+ if (groups.length === 0 && (sync.solo === true || peers === 0)) {
100
+ return { text: "", includedMessageIds: [], includedAttentionIds: [] };
101
+ }
102
+
103
+ const fullHeader = groups.length === 0 ? ambientHeader(peers) : "ACC (load the acc skill):";
104
+ const header = truncate(fullHeader, budgetBytes);
105
+ const lines = header === "" ? [] : [header];
106
+ let used = header === "" ? 0 : bytes(header) + 1;
107
+ const included = [];
108
+ const droppedMessages = [];
109
+ let droppedOther = 0;
110
+
111
+ for (const group of groups) {
112
+ if (group.lines.length === 1) {
113
+ const remaining = budgetBytes - used;
114
+ const line = group.truncatable
115
+ ? truncate(group.lines[0], remaining)
116
+ : (bytes(group.lines[0]) <= remaining ? group.lines[0] : "");
117
+ if (line !== "" && used + bytes(line) <= budgetBytes) {
118
+ lines.push(line);
119
+ included.push({ group, lineCount: 1 });
120
+ used += bytes(line) + 1;
121
+ } else {
122
+ droppedOther += 1;
123
+ }
208
124
  continue;
209
125
  }
210
- const size = group.reduce((total, line) => total + bytes(line) + 1, 0);
211
- // Skipped rather than stopped at: the groups are ordered by priority, and a
212
- // large message must not hide the shorter ones behind it.
213
- if (used + size > ceiling) { dropped += 1; continue; }
214
- lines.push(...group);
215
- used += size;
216
- }
217
- if (dropped > 0) {
218
- const note = `- +${dropped} not shown, over budget; read them with `
219
- + "`acc sync --scope full --json`";
220
- lines.push(note);
221
- used += bytes(note) + 1;
126
+ const size = groupBytes(group);
127
+ if (used + size <= budgetBytes) {
128
+ lines.push(...group.lines);
129
+ included.push({ group, lineCount: group.lines.length });
130
+ used += size;
131
+ } else {
132
+ droppedMessages.push(group.messageId);
133
+ }
222
134
  }
223
135
 
224
- let shown = 0;
225
- for (const line of optional) {
226
- if (used + bytes(line) + 1 > budgetBytes - 16) break;
227
- lines.push(line);
228
- used += bytes(line) + 1;
229
- shown += 1;
230
- }
231
- if (shown < optional.length) {
232
- const note = `- +${optional.length - shown} more`;
136
+ if (droppedMessages.length > 0) {
137
+ let note = recoveryNote(droppedMessages);
138
+ while (used + bytes(note) + 1 > budgetBytes && included.length > 0) {
139
+ const removed = included.pop();
140
+ lines.splice(-removed.lineCount, removed.lineCount);
141
+ used = lines.reduce((total, line) => total + bytes(line) + 1, 0);
142
+ if (removed.group.kind === "message") droppedMessages.push(removed.group.messageId);
143
+ else droppedOther += 1;
144
+ note = recoveryNote([...new Set(droppedMessages)]);
145
+ }
146
+ if (used + bytes(note) + 1 > budgetBytes && lines.length > 0) {
147
+ lines.length = 0;
148
+ used = 0;
149
+ }
150
+ const fitted = recoveryNote([...new Set(droppedMessages)]);
151
+ // An incomplete id or command is not a recovery path. When a deliberately
152
+ // tiny budget cannot hold the shortest truthful instruction, say nothing
153
+ // and leave every omitted receipt queued for a later turn.
154
+ if (used + bytes(fitted) + 1 <= budgetBytes) lines.push(fitted);
155
+ } else if (droppedOther > 0) {
156
+ const note = `- +${droppedOther} actionable item(s) omitted; run \`acc status --json\``;
233
157
  if (used + bytes(note) + 1 <= budgetBytes) lines.push(note);
234
158
  }
235
159
 
236
- return lines.join("\n");
160
+ return {
161
+ text: lines.join("\n"),
162
+ includedMessageIds: included
163
+ .filter(item => item.group.kind === "message")
164
+ .map(item => item.group.messageId),
165
+ includedAttentionIds: included
166
+ .filter(item => item.group.kind === "attention" && item.group.sourceId !== null)
167
+ .map(item => item.group.sourceId),
168
+ };
169
+ }
170
+
171
+ export function projectContext(sync, options) {
172
+ return projectContextResult(sync, options).text;
237
173
  }
@@ -7,7 +7,7 @@ export { assertRunner, bakeSkillCommand, defaultCli, defaultRunner, removeInstal
7
7
  from "./hook-shim.mjs";
8
8
  export { BEGIN, END, removeTomlBlock, renderBlock, stripBlock, tomlString, writeTomlBlock }
9
9
  from "./toml-block.mjs";
10
- export { projectContext } from "./context-projector.mjs";
10
+ export { projectContext, projectContextResult } from "./context-projector.mjs";
11
11
  export { shellWriteTargets } from "./shell-writes.mjs";
12
12
  export { keepOnlyVersion, ownVersion, stampPluginVersion } from "./own-version.mjs";
13
13
  export { editJson, readJson } from "./json-text.mjs";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/cli",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -26,6 +26,9 @@ export const COMMANDS = Object.freeze({
26
26
  message: { required: ["subject", "body"],
27
27
  optional: ["session", "generation", "type", "priority", "workstream"],
28
28
  repeated: ["to"], flags: ["requires-ack"] },
29
+ inbox: { required: [], optional: ["session", "generation", "message"] },
30
+ reply: { required: ["message", "body"],
31
+ optional: ["session", "generation", "subject", "type", "priority"] },
29
32
  // Asking another agent to do something: one call, because a task nobody was
30
33
  // told about and a message pointing at no task are each useless.
31
34
  request: { required: ["to", "title"],
@@ -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", "task", "workstream", "decide", "finish"]],
18
18
  ["Driven by adapters, not by people", ["attach", "heartbeat", "detach"]],
19
19
  ["About acc", ["help", "version", "update"]],
20
20
  ]);
@@ -31,6 +31,8 @@ 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
+ inbox: "read only unresolved messages addressed to this participant",
35
+ reply: "reply to one message and acknowledge it in the same operation",
34
36
  request: "ask another agent to do something: the work and the why, in one call",
35
37
  task: "create work, --take it, or move its --state along",
36
38
  workstream: "group related work, and steer it with --take / --release",
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { createClaudeCodeAdapter } from "@agents-can-communicate/adapter-claude-code";
5
5
  import { createCodexAdapter } from "@agents-can-communicate/adapter-codex";
6
6
  import { createGeminiCliAdapter } from "@agents-can-communicate/adapter-gemini-cli";
7
+ import { createGrokAdapter } from "@agents-can-communicate/adapter-grok";
7
8
  import { createKimiAdapter } from "@agents-can-communicate/adapter-kimi";
8
9
  import { applyPlan, detectInstallation, loadOwnership, planInstallation }
9
10
  from "@agents-can-communicate/installer";
@@ -23,6 +24,7 @@ export const clientContext = (home, stateRoot) => ({
23
24
  agentsHome: home,
24
25
  codexHome: path.join(home, ".codex"),
25
26
  kimiHome: path.join(home, ".kimi-code"),
27
+ grokHome: path.join(home, ".grok"),
26
28
  // Where ACC keeps its own state, for the client that has to be told. Codex
27
29
  // sandboxes the commands a model runs to the workspace, and ACC's state is
28
30
  // outside every workspace on purpose - so an agent there could read the
@@ -32,7 +34,7 @@ export const clientContext = (home, stateRoot) => ({
32
34
  });
33
35
 
34
36
  export const ALL_ADAPTERS = () => [createClaudeCodeAdapter(), createCodexAdapter(),
35
- createGeminiCliAdapter(), createKimiAdapter()];
37
+ createGeminiCliAdapter(), createGrokAdapter(), createKimiAdapter()];
36
38
 
37
39
  /**
38
40
  * How long to wait for a client to say its version.
@@ -1,6 +1,6 @@
1
1
  import { AccError, CONFIG_FILENAME, EXIT, failure, ok }
2
2
  from "@agents-can-communicate/protocol";
3
- import { createCoordinationService } from "@agents-can-communicate/core";
3
+ import { createCoordinationService, noteNudge } from "@agents-can-communicate/core";
4
4
  import { openFilesystemStore } from "@agents-can-communicate/storage-filesystem";
5
5
 
6
6
  import { parseArgs, positiveNumber } from "./args.mjs";
@@ -199,7 +199,28 @@ const HANDLERS = Object.freeze({
199
199
  type: options.type ?? "note", subject: options.subject, body: options.body,
200
200
  priority: options.priority, workstreamId: options.workstream ?? null,
201
201
  requiresAck: options.requiresAck === true, descriptor: context.descriptor });
202
- return { data: message, text: `sent ${message.messageId}` };
202
+ // A note that reads like it wants a reply was sent with no ack obligation
203
+ // and no standing reminder; say so once, without blocking the send. Carried
204
+ // in `data` so an adapter reading `--json` sees it, and appended to `text`
205
+ // for a person - the send already succeeded either way.
206
+ const advice = noteNudge(message);
207
+ return { data: advice ? { ...message, advice } : message,
208
+ text: advice ? `sent ${message.messageId}\n${advice}` : `sent ${message.messageId}` };
209
+ },
210
+
211
+ inbox: async ({ options, context }) => {
212
+ const messages = await context.service.readInbox({ sessionId: options.session,
213
+ generation: options.generation, messageId: options.message });
214
+ return { data: messages, text: messages.length === 0
215
+ ? "inbox empty" : JSON.stringify(messages, null, 2) };
216
+ },
217
+
218
+ reply: async ({ options, context }) => {
219
+ const result = await context.service.replyToMessage({ sessionId: options.session,
220
+ generation: options.generation, messageId: options.message, body: options.body,
221
+ subject: options.subject, type: options.type, priority: options.priority });
222
+ return { data: result,
223
+ text: `replied ${result.reply.messageId}; acknowledged ${options.message}` };
203
224
  },
204
225
 
205
226
  /**
@@ -20,7 +20,7 @@ import { AccError, EXIT } from "@agents-can-communicate/protocol";
20
20
  * answers that from something the caller demonstrably has.
21
21
  */
22
22
  const NEEDS_OWNER = Object.freeze(new Set(["work", "claim", "release", "message",
23
- "request", "ack", "workstream", "task", "finish", "decide"]));
23
+ "inbox", "reply", "request", "ack", "workstream", "task", "finish", "decide"]));
24
24
 
25
25
  /**
26
26
  * Reads that are answers about *you*.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,134 @@
1
+ import { AccError, EXIT, SCHEMA_VERSION, advanceDelivery, createId, validateRecord }
2
+ from "@agents-can-communicate/protocol";
3
+
4
+ const receiptId = (messageId, recipient) => `${messageId}--${recipient}`;
5
+ const unresolved = (message, receipt) => receipt.state === "recorded"
6
+ || receipt.state === "queued"
7
+ || (message.requiresAck && (receipt.state === "injected" || receipt.state === "seen"));
8
+
9
+ /** Narrow, recipient-owned message reads and replies. */
10
+ export function createInboxService(ports, sessions) {
11
+ const { store, clock, ids } = ports;
12
+
13
+ async function requireOpen(input, action) {
14
+ const existing = await sessions.locateSession(input.sessionId, input.workspaceId);
15
+ if (existing === null || existing.record.state !== "open"
16
+ || existing.record.generation !== input.generation) {
17
+ throw new AccError(EXIT.CONFLICT, `cannot ${action} from this session generation`,
18
+ { sessionId: input.sessionId });
19
+ }
20
+ return existing.record;
21
+ }
22
+
23
+ async function readInbox(input) {
24
+ const session = await requireOpen(input, "read the inbox");
25
+ const snapshot = await store.snapshot(session.workspaceId,
26
+ { kinds: ["message", "receipt"] });
27
+ const messages = new Map(snapshot.messages.map(message => [message.messageId, message]));
28
+ let addressed = snapshot.receipts
29
+ .filter(receipt => receipt.recipientParticipantId === session.participantId)
30
+ .map(receipt => ({ message: messages.get(receipt.messageId), receipt }))
31
+ .filter(item => item.message !== undefined);
32
+ if (input.messageId !== undefined) {
33
+ addressed = addressed.filter(item => item.message.messageId === input.messageId
34
+ && item.receipt.state !== "acknowledged" && item.receipt.state !== "failed");
35
+ if (addressed.length === 0) {
36
+ throw new AccError(EXIT.CONFLICT,
37
+ "that message is not recoverable by this participant",
38
+ { messageId: input.messageId, participantId: session.participantId });
39
+ }
40
+ } else {
41
+ addressed = addressed.filter(item => unresolved(item.message, item.receipt));
42
+ }
43
+ addressed.sort((left, right) => left.message.sentAt.localeCompare(right.message.sentAt)
44
+ || left.message.messageId.localeCompare(right.message.messageId));
45
+ if (addressed.length === 0) return [];
46
+
47
+ const now = clock.now();
48
+ const shown = [];
49
+ await store.transaction(async tx => {
50
+ for (const item of addressed) {
51
+ const id = receiptId(item.message.messageId, session.participantId);
52
+ const current = tx.get("receipt", id);
53
+ const receipt = { ...current, state: advanceDelivery(current.state, "seen"),
54
+ updatedAt: now };
55
+ tx.put("receipt", id, receipt, tx.generationOf("receipt", id));
56
+ tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
57
+ workspaceId: session.workspaceId, actorSessionId: session.sessionId,
58
+ type: "message.seen", occurredAt: now,
59
+ payload: { messageId: item.message.messageId,
60
+ recipientParticipantId: session.participantId } });
61
+ shown.push({ message: item.message, receipt });
62
+ }
63
+ }, { kinds: ["receipt"] });
64
+ return shown;
65
+ }
66
+
67
+ async function replyToMessage(input) {
68
+ const session = await requireOpen(input, "reply to a message");
69
+ const now = clock.now();
70
+ const replyId = createId("message");
71
+ let result = null;
72
+ await store.transaction(async tx => {
73
+ const original = tx.get("message", input.messageId);
74
+ const ownReceiptId = receiptId(input.messageId, session.participantId);
75
+ const originalReceipt = tx.get("receipt", ownReceiptId);
76
+ if (original === null || originalReceipt === null
77
+ || !original.toParticipantIds.includes(session.participantId)) {
78
+ throw new AccError(EXIT.CONFLICT,
79
+ "that message is not addressed to this participant",
80
+ { messageId: input.messageId, participantId: session.participantId });
81
+ }
82
+ if (originalReceipt.state === "acknowledged" || originalReceipt.state === "failed") {
83
+ throw new AccError(EXIT.CONFLICT, "that message is already resolved",
84
+ { messageId: input.messageId, state: originalReceipt.state });
85
+ }
86
+ const reply = validateRecord("message", {
87
+ schemaVersion: SCHEMA_VERSION,
88
+ messageId: replyId,
89
+ workspaceId: session.workspaceId,
90
+ fromSessionId: session.sessionId,
91
+ fromParticipantId: session.participantId,
92
+ toParticipantIds: [original.fromParticipantId],
93
+ type: input.type ?? "answer",
94
+ subject: input.subject ?? `Re: ${original.subject}`,
95
+ body: input.body,
96
+ priority: input.priority ?? "normal",
97
+ workstreamId: original.workstreamId,
98
+ taskId: original.taskId,
99
+ inReplyTo: original.messageId,
100
+ requiresAck: false,
101
+ artifacts: input.artifacts ?? [],
102
+ sentAt: now,
103
+ });
104
+ const acknowledged = { ...originalReceipt,
105
+ state: advanceDelivery(originalReceipt.state, "acknowledged"), updatedAt: now };
106
+ const replyReceipt = validateRecord("receipt", {
107
+ schemaVersion: SCHEMA_VERSION,
108
+ messageId: replyId,
109
+ workspaceId: session.workspaceId,
110
+ recipientParticipantId: original.fromParticipantId,
111
+ state: "queued",
112
+ updatedAt: now,
113
+ });
114
+ tx.put("message", replyId, reply);
115
+ tx.put("receipt", receiptId(replyId, original.fromParticipantId), replyReceipt);
116
+ tx.put("receipt", ownReceiptId, acknowledged,
117
+ tx.generationOf("receipt", ownReceiptId));
118
+ tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
119
+ workspaceId: session.workspaceId, actorSessionId: session.sessionId,
120
+ type: "message.sent", occurredAt: now,
121
+ payload: { messageId: replyId, type: reply.type,
122
+ recipients: reply.toParticipantIds } });
123
+ tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
124
+ workspaceId: session.workspaceId, actorSessionId: session.sessionId,
125
+ type: "message.acknowledged", occurredAt: now,
126
+ payload: { messageId: original.messageId,
127
+ recipientParticipantId: session.participantId } });
128
+ result = { reply, receipt: acknowledged };
129
+ }, { kinds: ["message", "receipt"] });
130
+ return result;
131
+ }
132
+
133
+ return { readInbox, replyToMessage };
134
+ }
@@ -4,5 +4,6 @@ export { assertPorts } from "./ports.mjs";
4
4
  export { classifySessionPresence } from "./sessions.mjs";
5
5
  export { defaultPidIsAlive } from "./pid.mjs";
6
6
  export { ATTENTION_PRIORITY, computeAttention } from "./sync.mjs";
7
+ export { looksConsequential, noteNudge } from "./message-signals.mjs";
7
8
  export { overlaps } from "./claims.mjs";
8
9
  export { wouldCycle } from "./tasks.mjs";