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
@@ -0,0 +1,41 @@
1
+ // A note is fire-and-forget: shown to the recipient once, no acknowledgement
2
+ // owed, no standing reminder. Agents put decisions and warnings in notes anyway
3
+ // - in one measured session a note carrying a merge decision was lost for three
4
+ // hours - so this is the send-time tell that a note is waiting on a reply and
5
+ // should have gone out as a question (`--requires-ack`) or a decision.
6
+ //
7
+ // A nudge, never a block, and deliberately not a keyword list: keyword lists
8
+ // only fire in the language they were written in, and the agents here write in
9
+ // several. A question mark marks a question in every script, and the warning
10
+ // sign is the one glyph a peer reaches for when something must not be missed.
11
+ const QUESTION = /[??]/u;
12
+ const WARNING = /⚠/u;
13
+
14
+ /**
15
+ * Does this note read like it needs a response from the recipient?
16
+ *
17
+ * Pure and total: a missing subject or body is empty text, not an error, so the
18
+ * caller can hand it a message record without pre-checking its shape.
19
+ */
20
+ export function looksConsequential({ subject = "", body = "" } = {}) {
21
+ const text = `${subject}\n${body}`;
22
+ return QUESTION.test(text) || WARNING.test(text);
23
+ }
24
+
25
+ const NUDGE = "this note reads like it needs a reply, but a note is fire-and-forget: "
26
+ + "the recipient sees it once and owes no response. Re-send with --requires-ack "
27
+ + "for an answer, or record it with `acc decide`.";
28
+
29
+ /**
30
+ * The send-time advisory for a note that looks like it is waiting on a reply,
31
+ * or null when there is nothing to say.
32
+ *
33
+ * Only notes, and only ones the sender did not already mark `requiresAck`: a
34
+ * question already keeps a standing reminder, and nudging it would be noise.
35
+ * A nudge, never a block - the send has already happened; this only tells the
36
+ * sender the shape it chose does not carry the weight the words do.
37
+ */
38
+ export function noteNudge(message) {
39
+ return message?.type === "note" && message?.requiresAck !== true
40
+ && looksConsequential(message ?? {}) ? NUDGE : null;
41
+ }
@@ -23,7 +23,7 @@ const REQUIRED = Object.freeze({
23
23
  // are storage too, so they hang off the store rather than becoming a fourth
24
24
  // port. They are deliberately outside transactions: they append no events and
25
25
  // vanish with their session.
26
- const EPHEMERAL = ["get", "put", "delete", "list"];
26
+ const EPHEMERAL = ["get", "put", "update", "delete", "list"];
27
27
 
28
28
  // Ports are validated at construction rather than at first use. A core that
29
29
  // silently falls back to ambient time or randomness produces tests that pass
@@ -1,6 +1,7 @@
1
1
  import { createClaimService } from "./claims.mjs";
2
2
  import { createCommunicationService } from "./communication.mjs";
3
3
  import { createIntentService } from "./intents.mjs";
4
+ import { createInboxService } from "./inbox.mjs";
4
5
  import { defaultPidIsAlive } from "./pid.mjs";
5
6
  import { assertPorts } from "./ports.mjs";
6
7
  import { createSessionService } from "./sessions.mjs";
@@ -32,6 +33,7 @@ export function createCoordinationService({ store, clock, ids,
32
33
  const tasks = createTaskService(ports, workstreams);
33
34
  const claims = createClaimService(ports, sessions);
34
35
  const communication = createCommunicationService(ports, sessions, claims);
36
+ const inbox = createInboxService(ports, sessions);
35
37
  const sync = createSyncService(ports, sessions);
36
38
  const status = createStatusService(ports, sessions);
37
39
  const guardState = createGuardStateService(ports);
@@ -46,6 +48,7 @@ export function createCoordinationService({ store, clock, ids,
46
48
  ...tasks,
47
49
  ...claims,
48
50
  ...communication,
51
+ ...inbox,
49
52
  ...sync,
50
53
  ...status,
51
54
  guardState,
@@ -179,6 +179,53 @@ export function createSessionService(ports) {
179
179
  return beaten;
180
180
  }
181
181
 
182
+ /**
183
+ * Continue the exact session named by a harness binding.
184
+ *
185
+ * Some clients emit SessionStart again after compacting their model context.
186
+ * The binding is already the continuation token: it names both the session
187
+ * and its generation. Refreshing that record preserves one identity without
188
+ * pretending an unrelated or closed generation is still ours.
189
+ *
190
+ * Returns null when the binding can no longer be resumed, so the hook may
191
+ * open a genuinely new session. No semantic event is appended: compaction is
192
+ * not a second agent arriving.
193
+ */
194
+ async function resumeSession({ sessionId, workspaceId, generation, ...metadata }) {
195
+ const resume = current => {
196
+ if (current === null || current.state !== "open"
197
+ || current.generation !== generation) return null;
198
+ return { ...current,
199
+ pid: metadata.pid ?? null,
200
+ checkoutRoot: metadata.checkoutRoot ?? current.checkoutRoot,
201
+ branch: metadata.branch ?? current.branch,
202
+ enforcement: metadata.enforcement ?? current.enforcement,
203
+ lifecycle: metadata.lifecycle ?? current.lifecycle,
204
+ heartbeatCadenceMs: metadata.heartbeatCadenceMs ?? current.heartbeatCadenceMs,
205
+ heartbeatAt: clock.now(),
206
+ };
207
+ };
208
+
209
+ // The compare and replacement happen under the ephemeral store's writer
210
+ // lock. A close or a replacement generation can win before this update or
211
+ // after it, but can never be overwritten from a record read beforehand.
212
+ const ephemeral = await store.ephemeral.update("session", sessionId, resume);
213
+ if (ephemeral !== null) return ephemeral;
214
+
215
+ // Re-read and validate inside the durable transaction for the same reason.
216
+ // Using generationOf only as the put token is insufficient: it protects
217
+ // the envelope write, not the semantic generation carried by the record.
218
+ const resolvedWorkspace = workspaceId ?? store.workspaceId;
219
+ if (resolvedWorkspace === undefined) return null;
220
+ return store.transaction(async tx => {
221
+ const current = tx.get("session", sessionId);
222
+ const resumed = resume(current);
223
+ if (resumed === null) return null;
224
+ tx.put("session", sessionId, resumed, tx.generationOf("session", sessionId));
225
+ return resumed;
226
+ }, { kinds: ["session"] });
227
+ }
228
+
182
229
  async function closeSession({ sessionId, workspaceId, generation }) {
183
230
  const existing = await locate(sessionId, workspaceId);
184
231
  if (existing === null) throw new AccError(EXIT.CONFLICT, "session is not open", { sessionId });
@@ -222,6 +269,7 @@ export function createSessionService(ports) {
222
269
 
223
270
  return {
224
271
  openSession,
272
+ resumeSession,
225
273
  heartbeatSession,
226
274
  closeSession,
227
275
  locateSession: locate,
@@ -46,6 +46,7 @@ export const ATTENTION_PRIORITY = Object.freeze({
46
46
  request_stalled: 5,
47
47
  claim_expired: 6,
48
48
  claim_contended: 7,
49
+ unread_note: 8,
49
50
  });
50
51
 
51
52
  function directRequests(snapshot, participantId) {
@@ -61,6 +62,40 @@ function directRequests(snapshot, participantId) {
61
62
  return items;
62
63
  }
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` });
95
+ }
96
+ return items;
97
+ }
98
+
64
99
  /**
65
100
  * A claim of yours that has run out.
66
101
  *
@@ -245,6 +280,7 @@ export function computeAttention(snapshot, { session, participantId, now, pidIsA
245
280
  }
246
281
  return [
247
282
  ...directRequests(snapshot, participantId),
283
+ ...unreadNotes(snapshot, participantId),
248
284
  ...claimConflicts(snapshot, session, now),
249
285
  ...claimContended(snapshot, session, now),
250
286
  ...expiredClaims(snapshot, session, now),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -13,6 +13,11 @@ import { createGitProbe, discoverWorkspace, platformDataHome, runtimePaths }
13
13
  import { resolveClientPid } from "./client-pid.mjs";
14
14
  import { readProcessTable as defaultReadProcessTable } from "./process-table.mjs";
15
15
 
16
+ // Kept cohesive above 300 lines because every handler shares one fail-open
17
+ // hook boundary, binding lifecycle, and client-specific outcome contract.
18
+ // Splitting handlers would duplicate that safety boundary and make delivery or
19
+ // guard failures behave differently by event kind.
20
+
16
21
  // A hook runs in front of the user's turn, so it gets a hard ceiling. Better to
17
22
  // let a call through than to make someone's session sit waiting on us.
18
23
  const DEFAULT_BUDGET_MS = 5_000;
@@ -157,7 +162,8 @@ async function openContext({ cwd, dataHome, runtime, env }) {
157
162
  }
158
163
 
159
164
  const HANDLERS = {
160
- async sessionStart({ event, context, adapter, adapterId, paths, readProcessTable }) {
165
+ async sessionStart({ event, context, adapter, adapterId, binding, paths,
166
+ readProcessTable }) {
161
167
  const capabilities = adapter.capabilities ?? {};
162
168
  // Once per session, never per turn. A client that cannot be found yields
163
169
  // null, and the session is then judged by age alone - which is exactly the
@@ -165,6 +171,24 @@ const HANDLERS = {
165
171
  const command = adapter.client?.command ?? null;
166
172
  const pid = command === null ? null
167
173
  : resolveClientPid({ table: await readProcessTable(), from: process.pid, command });
174
+ const metadata = {
175
+ pid,
176
+ enforcement: capabilities.guards?.beforeWrite === true ? "guarded" : "advisory",
177
+ lifecycle: capabilities.lifecycle?.sessionEnd === true ? "managed" : "manual",
178
+ heartbeatCadenceMs: CADENCE_MS,
179
+ checkoutRoot: context.descriptor.git?.worktreeRoot ?? context.descriptor.roots[0],
180
+ branch: context.descriptor.git?.branch ?? null,
181
+ };
182
+ if (binding !== null) {
183
+ const resumed = await context.service.resumeSession({
184
+ sessionId: binding.accSessionId,
185
+ generation: binding.generation,
186
+ ...metadata,
187
+ });
188
+ if (resumed !== null) {
189
+ return { accSessionId: resumed.sessionId, generation: resumed.generation };
190
+ }
191
+ }
168
192
  const session = await context.service.openSession({
169
193
  workspaceId: context.descriptor.id,
170
194
  participantId: participantFor(adapterId, event.sessionId, context.env),
@@ -174,14 +198,9 @@ const HANDLERS = {
174
198
  // Declared from what this adapter proved, not from the fact that it is an
175
199
  // adapter at all. A peer reading the roster can then tell a session whose
176
200
  // writes can be stopped from one whose cannot.
177
- enforcement: capabilities.guards?.beforeWrite === true ? "guarded" : "advisory",
178
- lifecycle: capabilities.lifecycle?.sessionEnd === true ? "managed" : "manual",
179
- heartbeatCadenceMs: CADENCE_MS,
180
201
  // Which checkout this agent is in. One workspace spans every worktree of
181
202
  // a repository, so this is the only thing that distinguishes them.
182
- checkoutRoot: context.descriptor.git?.worktreeRoot ?? context.descriptor.roots[0],
183
- pid,
184
- branch: context.descriptor.git?.branch ?? null,
203
+ ...metadata,
185
204
  descriptor: context.descriptor,
186
205
  });
187
206
  await storeSessionBinding({ runtimeDir: paths.root, harnessSessionId: event.sessionId,
@@ -213,27 +232,11 @@ const HANDLERS = {
213
232
  const sync = await context.service.sync({ sessionId: binding.accSessionId,
214
233
  cursor: null, scope: "delta" });
215
234
 
216
- // Claims held by others, and whether this session can actually be stopped
217
- // from breaking them. For a harness that guards writes this is useful
218
- // warning; for one that cannot - a Codex model editing through the shell,
219
- // an MCP client - it is the only protection there is, so it has to say
220
- // plainly that the responsibility has moved to the session itself.
221
- const status = await context.service.collectStatus({
222
- workspaceId: context.descriptor.id });
223
- const mine = status.participants
235
+ // Sync already carries the roster. Calling collectStatus here used to read
236
+ // the entire materialised store a second time on every prompt merely to
237
+ // rediscover this session's participant id.
238
+ const mine = sync.roster
224
239
  .find(participant => participant.sessionId === binding.accSessionId);
225
- // Two independent facts, and both are needed. `enforceable` is whether ACC
226
- // could stop *this* session at all; `enforcement` is what the claim's owner
227
- // asked for. A guarded session facing an advisory claim is not blocked from
228
- // anything, so reporting either one alone mislabels the other case.
229
- const enforceable = mine?.enforcement === "guarded";
230
- const claims = status.claims
231
- .filter(claim => claim.ownerSessionId !== binding.accSessionId)
232
- .map(claim => ({ resource: claim.resource, enforcement: claim.enforcement,
233
- enforceable,
234
- ownerParticipantId: status.participants
235
- .find(p => p.sessionId === claim.ownerSessionId)?.participantId
236
- ?? claim.ownerSessionId }));
237
240
 
238
241
  // What peers have said to this participant and no model has been shown yet.
239
242
  // Without this the projector's peer block never ran in production: an agent
@@ -254,31 +257,72 @@ const HANDLERS = {
254
257
  // The ceiling a team agreed on in `acc.workspace.json`, or the default when
255
258
  // there is no config. Validated by the protocol and, until now, never read:
256
259
  // the projector was always called with its own default.
257
- const projected = await adapter.renderContext?.({ ...sync, claims, messages },
258
- { budgetBytes: context.descriptor.policy?.contextBudgetBytes }) ?? "";
259
- if (projected === "") return { stdout: "" };
260
+ const tracksDelivery = typeof adapter.renderContextResult === "function";
261
+ const projectionInput = { ...sync, messages: tracksDelivery ? messages : [],
262
+ currentParticipantId: mine?.participantId };
263
+ const projectionOptions = {
264
+ budgetBytes: context.descriptor.policy?.contextBudgetBytes };
265
+ // Delivery is state, not text parsing. Peer-controlled bodies can imitate
266
+ // another message's visible header, so only projector metadata proves
267
+ // which complete groups survived the byte budget. A custom adapter without
268
+ // metadata may still inject text, but cannot advance a receipt from it.
269
+ const projection = !tracksDelivery
270
+ ? { text: await adapter.renderContext?.(projectionInput, projectionOptions) ?? "",
271
+ includedMessageIds: [], includedAttentionIds: [] }
272
+ : await adapter.renderContextResult(projectionInput, projectionOptions);
273
+ const degradation = !tracksDelivery && messages.length > 0
274
+ ? `acc: ${messages.length} pending message(s) withheld because this adapter lacks `
275
+ + `structured delivery metadata; read ${messages[0].messageId} with `
276
+ + `acc inbox --message ${messages[0].messageId}`
277
+ : null;
278
+ const visibleDegradation = degradation === null ? "" : `ACC: ${degradation.slice(5)}`;
279
+ const candidate = [projection.text, visibleDegradation].filter(Boolean).join("\n");
280
+ const projected = Buffer.byteLength(candidate, "utf8")
281
+ <= (projectionOptions.budgetBytes ?? 6_000) ? candidate : projection.text;
282
+ if (projected === "") {
283
+ return degradation === null ? { stdout: "" } : { stdout: "", stderr: degradation };
284
+ }
260
285
 
261
286
  // Only what the model was actually shown is recorded as delivered. The
262
287
  // budget can leave a message out, and a receipt reading `injected` for text
263
288
  // nobody saw is worse than one still reading `queued` - the sender would be
264
289
  // told it landed. A message left behind stays queued and goes out next turn.
265
290
  const failures = [];
291
+ const includedMessages = new Set(projection.includedMessageIds ?? []);
266
292
  for (const message of messages) {
267
- if (!projected.includes(message.messageId)) continue;
293
+ if (!includedMessages.has(message.messageId)) continue;
268
294
  await context.service.markDelivery({ sessionId: binding.accSessionId,
269
295
  generation: binding.generation, messageId: message.messageId,
270
296
  recipientParticipantId: mine.participantId, state: "injected" })
271
297
  .catch(error => failures.push(`${message.messageId}: ${error.message}`));
272
298
  }
299
+ // A note carries no ack obligation, so after its one full showing it leaves
300
+ // a single low-priority `unread_note` breadcrumb. Advancing that receipt
301
+ // injected -> seen the turn the breadcrumb is shown is what makes it
302
+ // one-shot: next turn the note reads `seen`, the breadcrumb stays quiet, and
303
+ // a delivered decision is recoverable without becoming a standing nag - the
304
+ // noise a reader learns to skip. Only what was actually shown is advanced,
305
+ // for the same reason the loop above only records what fit.
306
+ const includedAttention = new Set(projection.includedAttentionIds ?? []);
307
+ for (const item of sync.attention ?? []) {
308
+ if (item.kind !== "unread_note") continue;
309
+ if (!includedAttention.has(item.sourceId)) continue;
310
+ await context.service.markDelivery({ sessionId: binding.accSessionId,
311
+ generation: binding.generation, messageId: item.sourceId,
312
+ recipientParticipantId: mine.participantId, state: "seen" })
313
+ .catch(error => failures.push(`${item.sourceId}: ${error.message}`));
314
+ }
273
315
  // Same again: Kimi Code shows the model a hook's raw stdout, while Gemini
274
316
  // and Claude Code want an envelope and drop a bare string.
275
317
  // Reported rather than swallowed. The context still goes out - losing it
276
318
  // over bookkeeping would be the worse trade - but a receipt that failed to
277
319
  // advance has to be visible somewhere, and stdout belongs to the model.
278
320
  const outcome = { stdout: "", ...adapter.injectOutcome?.(projected) };
279
- if (failures.length === 0) return outcome;
321
+ if (failures.length === 0 && degradation === null) return outcome;
280
322
  return { ...outcome,
281
- stderr: [outcome.stderr, `acc: delivery not recorded for ${failures.join(", ")}`]
323
+ stderr: [outcome.stderr, degradation,
324
+ failures.length === 0 ? null
325
+ : `acc: delivery not recorded for ${failures.join(", ")}`]
282
326
  .filter(Boolean).join("\n") };
283
327
  },
284
328
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": { ".": "./src/index.mjs" },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/mcp-server",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,4 +1,5 @@
1
1
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
2
+ import { noteNudge } from "@agents-can-communicate/core";
2
3
  import { clearSessionBinding, loadSessionBinding, storeSessionBinding }
3
4
  from "@agents-can-communicate/adapter-sdk";
4
5
 
@@ -71,6 +72,22 @@ async function resolveSession(context) {
71
72
  return session;
72
73
  }
73
74
 
75
+ // Kept for clients released before acc_inbox existed. New clients should use
76
+ // the narrow inbox tool, but removing mail from acc_sync would strand deployed
77
+ // clients that only know this response field. Returning a body is delivery, so
78
+ // only those returned messages advance to injected.
79
+ async function syncWithMail(service, owner, context, args) {
80
+ const sync = await service.sync({ ...owner, cursor: args.cursor ?? null,
81
+ scope: args.scope, limit: args.limit });
82
+ const messages = await service.pendingMessages({ workspaceId: context.workspaceId,
83
+ participantId: context.participantId, exceptSessionId: owner.sessionId });
84
+ for (const message of messages) {
85
+ await service.markDelivery({ ...owner, messageId: message.messageId,
86
+ state: "injected" }).catch(() => null);
87
+ }
88
+ return messages.length === 0 ? sync : { ...sync, messages };
89
+ }
90
+
74
91
  /**
75
92
  * A poll is this client's turn.
76
93
  *
@@ -89,25 +106,6 @@ async function resolveSession(context) {
89
106
  * Acknowledgement stays a separate act, because being shown something is not
90
107
  * agreeing to it.
91
108
  */
92
- async function syncWithMail(service, owner, context, args) {
93
- const sync = await service.sync({ ...owner, cursor: args.cursor ?? null,
94
- scope: args.scope, limit: args.limit });
95
- const messages = await service.pendingMessages({
96
- workspaceId: context.workspaceId,
97
- participantId: context.participantId,
98
- exceptSessionId: owner.sessionId });
99
- if (messages.length === 0) return sync;
100
-
101
- for (const message of messages) {
102
- // One failure must not swallow the rest: the client is holding the message
103
- // either way, and a receipt that cannot be written is not a reason to hide
104
- // what a peer said.
105
- await service.markDelivery({ ...owner, messageId: message.messageId,
106
- state: "injected" }).catch(() => null);
107
- }
108
- return { ...sync, messages };
109
- }
110
-
111
109
  async function callTool(name, args, context) {
112
110
  const session = await resolveSession(context);
113
111
  const owner = { sessionId: session.sessionId, generation: session.generation,
@@ -130,11 +128,21 @@ async function callTool(name, args, context) {
130
128
  return service.acquireClaim({ ...owner, resource: args.resource,
131
129
  mode: args.mode ?? "exclusive", enforcement: "advisory",
132
130
  reason: args.reason ?? "unspecified", leaseSeconds: args.leaseSeconds });
133
- case "acc_message":
134
- return service.sendMessage({ ...owner, toParticipantIds: args.to ?? [],
131
+ case "acc_message": {
132
+ const message = await service.sendMessage({ ...owner, toParticipantIds: args.to ?? [],
135
133
  subject: args.subject, body: args.body, type: args.type ?? "note",
136
134
  priority: args.priority, requiresAck: args.requiresAck === true,
137
135
  workstreamId: args.workstreamId ?? null });
136
+ // A note that reads like it wants a reply was sent fire-and-forget; hand
137
+ // the nudge back with the message so the model that sent it can reconsider.
138
+ const advice = noteNudge(message);
139
+ return advice ? { ...message, advice } : message;
140
+ }
141
+ case "acc_inbox":
142
+ return service.readInbox({ ...owner, messageId: args.messageId });
143
+ case "acc_reply":
144
+ return service.replyToMessage({ ...owner, messageId: args.messageId,
145
+ body: args.body, subject: args.subject, type: args.type, priority: args.priority });
138
146
  case "acc_task":
139
147
  if (args.action === "claim") return service.claimTask({ ...owner,
140
148
  taskId: args.taskId, force: args.force === true });
@@ -24,7 +24,8 @@ export const PUBLIC_TOOLS = Object.freeze([
24
24
  name: "acc_sync",
25
25
  description: `Read coordination state for this workspace: roster, attention items, and `
26
26
  + `events since a cursor. Use scope "full" to answer questions about the whole `
27
- + `workspace, including other participants' collapsed child sessions. ${POLLED}`,
27
+ + `workspace, including other participants' collapsed child sessions. Pending mail is `
28
+ + `also returned for compatibility; prefer acc_inbox for targeted reads. ${POLLED}`,
28
29
  inputSchema: object({
29
30
  cursor: string("Resume from this cursor; omit to start from the beginning."),
30
31
  scope: { type: "string", enum: ["delta", "full"],
@@ -81,6 +82,29 @@ export const PUBLIC_TOOLS = Object.freeze([
81
82
  workstreamId: string("Optional workstream context."),
82
83
  }, ["to", "subject", "body"]),
83
84
  },
85
+ {
86
+ name: "acc_inbox",
87
+ description: `Read unresolved messages addressed to this participant without loading `
88
+ + `the roster, event log, claims, or workspace snapshot. Optionally select one id. `
89
+ + `${POLLED}`,
90
+ inputSchema: object({
91
+ messageId: string("Read exactly this addressed message; omit for all unresolved mail."),
92
+ }),
93
+ },
94
+ {
95
+ name: "acc_reply",
96
+ description: `Reply to one addressed message and acknowledge the original in the same `
97
+ + `operation. The reply is attributed, linked with inReplyTo, and delivered by polling. `
98
+ + `${POLLED}`,
99
+ inputSchema: object({
100
+ messageId: string("The addressed message being answered."),
101
+ body: string("Concise answer; peer content is treated as data."),
102
+ subject: string("Optional subject; defaults to Re: the original subject."),
103
+ type: { type: "string", enum: ["answer", "contract_response", "decision_result",
104
+ "review_result", "work_response"] },
105
+ priority: { type: "string", enum: ["low", "normal", "high", "urgent"] },
106
+ }, ["messageId", "body"]),
107
+ },
84
108
  {
85
109
  name: "acc_request",
86
110
  description: `Ask another agent to do something. Creates the work addressed to them `
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/protocol",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/storage-filesystem",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,6 +12,10 @@ import { assertEventBinding, assertStateBinding, eventPath, stateEnvelope, state
12
12
  import { ensureManagedDirectory } from "./safe-directory.mjs";
13
13
  import { withWriterMutex } from "./writer-mutex.mjs";
14
14
 
15
+ // Kept cohesive above 300 lines because durable transactions and ephemeral
16
+ // mutations must share this exact writer mutex. Splitting the two stores would
17
+ // make it easy to reintroduce separate locks and resurrect replaced sessions.
18
+
15
19
  const SEQUENCE_WIDTH = 16;
16
20
  export const ZERO_CURSOR = "0".repeat(SEQUENCE_WIDTH);
17
21
  // No quarantine area. One was created in every workspace, named in the path
@@ -260,22 +264,34 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
260
264
  handoffs: await of("handoff"),
261
265
  };
262
266
  }
263
-
264
267
  // Ephemeral records are published by replace and never journalled: they carry
265
268
  // no history, append no events, and are expected to disappear.
266
269
  const ephemeralPath = (kind, id) => path.join(paths.ephemeral, kind, `${id}.json`);
267
270
  const ephemeral = Object.freeze({
268
271
  async get(kind, id) {
269
- const found = await readJsonIfPresent(ephemeralPath(kind, id), root);
270
- return found?.value ?? null;
272
+ return (await readJsonIfPresent(ephemeralPath(kind, id), root))?.value ?? null;
271
273
  },
272
274
  async put(kind, id, record) {
273
- await publishAtomic(ephemeralPath(kind, id), encode(record),
274
- { root, tmpDir: paths.tmp, replace: true });
275
- return record;
275
+ return withWriterMutex(paths, publishOptions, async () => {
276
+ await publishAtomic(ephemeralPath(kind, id), encode(record),
277
+ { root, tmpDir: paths.tmp, replace: true });
278
+ return record;
279
+ });
280
+ },
281
+ async update(kind, id, updater) {
282
+ return withWriterMutex(paths, publishOptions, async () => {
283
+ const found = await readJsonIfPresent(ephemeralPath(kind, id), root);
284
+ const next = await updater(found?.value ?? null);
285
+ if (next === null) return null;
286
+ validateRecord(kind, next);
287
+ await publishAtomic(ephemeralPath(kind, id), encode(next),
288
+ { root, tmpDir: paths.tmp, replace: true });
289
+ return next;
290
+ });
276
291
  },
277
292
  async delete(kind, id) {
278
- await removeIfPresent(ephemeralPath(kind, id));
293
+ return withWriterMutex(paths, publishOptions,
294
+ async () => removeIfPresent(ephemeralPath(kind, id)));
279
295
  },
280
296
  async list(kind) {
281
297
  const records = [];
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { mkdir, rm } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { performance } from "node:perf_hooks";
4
5
 
5
6
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
6
7
 
@@ -8,7 +9,9 @@ import { encode, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
8
9
  import { ensureManagedDirectory } from "./safe-directory.mjs";
9
10
 
10
11
  const STALE_MS = 60_000;
12
+ const ACQUIRE_TIMEOUT_MS = 2_500;
11
13
  const OWNER = "owner.json";
14
+ const sleepFor = duration => new Promise(resolve => { setTimeout(resolve, duration); });
12
15
 
13
16
  // Directory creation is the atomic primitive: mkdir either creates or fails
14
17
  // with EEXIST, with no window in between. Ported from the reconciled
@@ -85,22 +88,35 @@ async function takeStaleOwnership(directory, root, owner, now, pidIsAlive) {
85
88
 
86
89
  export async function withWriterMutex(paths, options, operation) {
87
90
  const { root, clock, pidIsAlive = defaultPidIsAlive, uuid = randomUUID,
88
- attempts = 50, waitMs = 20, openFile } = options;
91
+ attempts = Number.POSITIVE_INFINITY, waitMs = 20, openFile,
92
+ acquireTimeoutMs = ACQUIRE_TIMEOUT_MS, monotonicNow = () => performance.now(),
93
+ sleep = sleepFor } = options;
89
94
  const directory = path.join(paths.locks, "writer.lock");
95
+ // Wall time can jump while a process waits. A monotonic absolute deadline
96
+ // bounds all owner reads and retries, leaving half the hook's five-second
97
+ // budget for publishing the owner, doing the write, and rendering a result.
98
+ const deadline = monotonicNow() + acquireTimeoutMs;
90
99
  await ensureManagedDirectory(root, paths.locks);
91
100
  const token = uuid();
92
101
 
93
102
  for (let attempt = 0; attempt < attempts; attempt += 1) {
103
+ if (monotonicNow() >= deadline) break;
94
104
  try {
95
105
  await mkdir(directory);
96
106
  } catch (error) {
97
107
  if (error.code !== "EEXIST") throw error;
98
108
  const owner = await readOwner(directory, root, openFile);
99
109
  if (!await takeStaleOwnership(directory, root, owner, clock.now(), pidIsAlive)) {
100
- await new Promise(resolve => { setTimeout(resolve, waitMs); });
110
+ const remaining = deadline - monotonicNow();
111
+ if (remaining <= 0) break;
112
+ await sleep(Math.min(waitMs, remaining));
101
113
  }
102
114
  continue;
103
115
  }
116
+ if (monotonicNow() >= deadline) {
117
+ await rm(directory, { recursive: true, force: true });
118
+ break;
119
+ }
104
120
  const owner = { pid: process.pid, token, acquiredAt: clock.now() };
105
121
  await publishAtomic(path.join(directory, OWNER), encode(owner), { root, tmpDir: paths.tmp });
106
122
  try {