agents-can-communicate 0.6.3 → 0.7.1

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 (61) hide show
  1. package/README.md +3 -1
  2. package/bin/entrypoints/hook-output.mjs +12 -0
  3. package/docs/ARCHITECTURE.md +5 -0
  4. package/docs/CAPABILITIES.md +1 -1
  5. package/docs/CLI.md +50 -2
  6. package/docs/CONCEPTS.md +7 -0
  7. package/docs/GETTING_STARTED.md +2 -2
  8. package/docs/PROTOCOL.md +31 -0
  9. package/docs/TROUBLESHOOTING.md +4 -3
  10. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
  11. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  12. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/skills/acc/SKILL.md +8 -0
  13. package/node_modules/@agents-can-communicate/adapter-antigravity/src/install.mjs +2 -1
  14. package/node_modules/@agents-can-communicate/adapter-antigravity/src/native-delivery.mjs +57 -11
  15. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  16. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +8 -0
  17. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  18. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +8 -0
  19. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  20. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +8 -0
  21. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  22. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  23. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +8 -0
  24. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  25. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +8 -0
  26. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  27. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +15 -2
  28. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt-writer.mjs +1 -1
  29. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt.mjs +3 -1
  30. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  31. package/node_modules/@agents-can-communicate/cli/src/args.mjs +6 -0
  32. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +13 -1
  33. package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -1
  34. package/node_modules/@agents-can-communicate/cli/src/main.mjs +33 -8
  35. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  36. package/node_modules/@agents-can-communicate/core/src/attention.mjs +7 -2
  37. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +2 -2
  38. package/node_modules/@agents-can-communicate/core/src/intents.mjs +16 -0
  39. package/node_modules/@agents-can-communicate/core/src/next-turn-delivery.mjs +21 -4
  40. package/node_modules/@agents-can-communicate/core/src/prune.mjs +241 -0
  41. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +66 -10
  42. package/node_modules/@agents-can-communicate/core/src/service.mjs +3 -0
  43. package/node_modules/@agents-can-communicate/core/src/status.mjs +25 -1
  44. package/node_modules/@agents-can-communicate/core/src/sync.mjs +15 -2
  45. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  46. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  47. package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +60 -7
  48. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +62 -27
  49. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  50. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  51. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +5 -3
  52. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  53. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  54. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +26 -7
  55. package/node_modules/@agents-can-communicate/storage-filesystem/src/doomed-directory.mjs +98 -0
  56. package/node_modules/@agents-can-communicate/storage-filesystem/src/event-floor.mjs +52 -0
  57. package/node_modules/@agents-can-communicate/storage-filesystem/src/reclaim.mjs +272 -0
  58. package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +54 -7
  59. package/node_modules/@agents-can-communicate/storage-filesystem/src/stage-sweep.mjs +174 -0
  60. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +97 -5
  61. package/package.json +1 -1
@@ -4,6 +4,7 @@ import { computeAttention } from "./attention.mjs";
4
4
  import { classifySessionPresence } from "./sessions.mjs";
5
5
  import { assertMessageId, messagePage } from "./message-pages.mjs";
6
6
  import { decisionView, isCurrentDecision } from "./decision-state.mjs";
7
+ import { offerFacts } from "./receipts.mjs";
7
8
 
8
9
  const DEFAULT_LIMIT = 100;
9
10
  const CURSOR = /^[0-9]{16}$/;
@@ -38,7 +39,7 @@ export function createSyncService(ports, sessions) {
38
39
  }
39
40
  if (exact) assertMessageId(input.messageId);
40
41
  const snapshot = await store.snapshot(input.workspaceId ?? store.workspaceId,
41
- { kinds: ["message"] });
42
+ { kinds: exact ? ["message", "receipt"] : ["message"] });
42
43
  const messages = snapshot.messages.map(decisionView(snapshot.messages));
43
44
  if (!exact) return messagePage(messages, input,
44
45
  { include: message => input.current !== true || isCurrentDecision(message),
@@ -47,7 +48,15 @@ export function createSyncService(ports, sessions) {
47
48
  if (message === undefined) {
48
49
  throw new AccError(EXIT.DATA, "message is not in this workspace's history");
49
50
  }
50
- return { scope: "history", view: "message", items: [message], nextCursor: null };
51
+ // Beside the message rather than inside it: the router offers `items[0]`
52
+ // to a transport, and a sender asking "did they get it" wants every
53
+ // recipient's answer. `sync --scope full` already shows all of it.
54
+ const receipts = snapshot.receipts
55
+ .filter(receipt => receipt.messageId === message.messageId)
56
+ .sort((left, right) => left.recipientParticipantId.localeCompare(right.recipientParticipantId))
57
+ .map(receipt => ({ recipientParticipantId: receipt.recipientParticipantId,
58
+ state: receipt.state, updatedAt: receipt.updatedAt, offer: offerFacts(receipt) }));
59
+ return { scope: "history", view: "message", items: [message], receipts, nextCursor: null };
51
60
  }
52
61
 
53
62
  async function sync(input = {}) {
@@ -86,6 +95,10 @@ export function createSyncService(ports, sessions) {
86
95
  && snapshot.claims.length === 0;
87
96
  return {
88
97
  cursor: page.cursor,
98
+ // Where the log starts, when it no longer starts at the beginning. A
99
+ // caller whose cursor precedes this was served a short page, and saying
100
+ // so is the difference between a trim and a silent gap.
101
+ trimmedThrough: page.trimmedThrough ?? null,
89
102
  scope: input.scope === "full" ? "full" : "delta",
90
103
  solo,
91
104
  attention,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/delivery-router",
3
- "version": "0.6.3",
3
+ "version": "0.7.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.6.3",
3
+ "version": "0.7.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -3,10 +3,20 @@ import { readInstalledLivePolicyState } from "@agents-can-communicate/installer"
3
3
 
4
4
  import { establishNativeBinding, LIVE_POLICIES, livePolicyFrom } from "./native-binding.mjs";
5
5
 
6
- // Reserve time for turn projection, status and output before optional disk I/O.
6
+ // A cold worker plus the write has to finish inside this window. 250 ms was
7
+ // not enough on a busy CI runner, so the attempt file never appeared and
8
+ // doctor reported no attempt. 1.5 s still abandons a stuck disk, and the
9
+ // extra 500 ms stays available for turn projection, status and output.
10
+ const DIAGNOSTIC_BUDGET_MS = 1_500;
11
+ const DIAGNOSTIC_FLOOR_MS = 250;
12
+ const AFTER_DIAGNOSTIC_MS = 500;
13
+
7
14
  export function nativeDiagnosticDeadline(hookDeadline) {
8
15
  const now = Date.now();
9
- return hookDeadline - now >= 750 ? now + 250 : null;
16
+ const remaining = hookDeadline - now;
17
+ // Below this, even the old quarter-second write would crowd out projection.
18
+ if (remaining < DIAGNOSTIC_FLOOR_MS + AFTER_DIAGNOSTIC_MS) return null;
19
+ return now + Math.min(DIAGNOSTIC_BUDGET_MS, remaining - AFTER_DIAGNOSTIC_MS);
10
20
  }
11
21
 
12
22
  // The diagnostic has its own bounded writer; it never rewrites hook ownership.
@@ -45,12 +55,44 @@ export async function bindNative({ adapter, event, hookBinding, clientVersion, p
45
55
  const HINT_MAX_BYTES = 512;
46
56
  const HINT_MAX_MS = 250;
47
57
 
58
+ function asHint(value) {
59
+ if (typeof value === "string") return { line: value, release: async () => {} };
60
+ if (value !== null && typeof value === "object" && typeof value.line === "string") {
61
+ return { line: value.line,
62
+ release: typeof value.release === "function" ? value.release : async () => {} };
63
+ }
64
+ if (value !== null && typeof value === "object" && typeof value.release === "function") {
65
+ return { line: "", release: value.release };
66
+ }
67
+ return null;
68
+ }
69
+
70
+ function usableHint(line) {
71
+ return line !== "" && !/[\r\n]/.test(line) && Buffer.byteLength(line, "utf8") <= HINT_MAX_BYTES;
72
+ }
73
+
74
+ // An adapter may release with a synchronous function. Calling `.catch` on its
75
+ // return value throws when that return value is undefined, and a throw from
76
+ // the function itself must not become a failed hook.
77
+ export function callRelease(release) {
78
+ if (typeof release !== "function") return Promise.resolve();
79
+ try {
80
+ return Promise.resolve(release()).catch(() => {});
81
+ } catch {
82
+ return Promise.resolve();
83
+ }
84
+ }
85
+
48
86
  /**
49
87
  * One line an adapter asks the agent to act on, when the agent itself can fix
50
88
  * a degraded native binding - Antigravity's relay is started from the agent's
51
89
  * own shell, and nothing else can start it. Bounded and fail-open: a slow or
52
90
  * failing adapter costs the turn nothing, and anything but one short line is
53
91
  * dropped rather than trusted into model context.
92
+ *
93
+ * A string has nothing reserved. `{ line, release }` has reserved an ask; this
94
+ * function calls `release` when it drops the line, including when the adapter
95
+ * answers after the budget. The caller releases a line it does not deliver.
54
96
  */
55
97
  export async function nativeActivationHintFor({ adapter, event, nativeBinding, binding, context,
56
98
  paths, deadline }) {
@@ -58,17 +100,28 @@ export async function nativeActivationHintFor({ adapter, event, nativeBinding, b
58
100
  const budget = Math.min(HINT_MAX_MS, deadline - Date.now() - HINT_MAX_MS);
59
101
  if (budget <= 0) return null;
60
102
  let timer = null;
103
+ let keep = false;
104
+ // The call sits inside then so a synchronous throw becomes a rejection.
105
+ // Promise.resolve(adapter.nativeActivationHint()) evaluates the call first
106
+ // and lets that throw escape the hook.
107
+ const pending = Promise.resolve().then(() => adapter.nativeActivationHint({ event, nativeBinding,
108
+ runtimeDir: paths.root, clientPid: binding?.clientPid, env: context.env })).then(asHint, () => null);
61
109
  try {
62
- const line = await Promise.race([
63
- Promise.resolve(adapter.nativeActivationHint({ event, nativeBinding, runtimeDir: paths.root,
64
- clientPid: binding?.clientPid, env: context.env })),
110
+ const taken = await Promise.race([
111
+ pending,
65
112
  new Promise(resolve => { timer = setTimeout(resolve, budget, null); }),
66
113
  ]);
67
- return typeof line === "string" && line !== "" && !/[\r\n]/.test(line)
68
- && Buffer.byteLength(line, "utf8") <= HINT_MAX_BYTES ? line : null;
114
+ if (taken !== null && usableHint(taken.line)) {
115
+ keep = true;
116
+ return taken;
117
+ }
118
+ return null;
69
119
  } catch {
70
120
  return null;
71
121
  } finally {
72
122
  if (timer !== null) clearTimeout(timer);
123
+ // A late or rejected answer still holds its reservation. Do not wait for
124
+ // it: an adapter that never settles must not hold the hook.
125
+ if (!keep) pending.then(taken => callRelease(taken?.release)).catch(() => {});
73
126
  }
74
127
  }
@@ -15,7 +15,8 @@ import { clearPin, createGitProbe, resolveHookWorkspace, platformDataHome, runti
15
15
 
16
16
  import { resolveClientPid } from "./client-pid.mjs";
17
17
  import { probeClientVersion as defaultProbeClientVersion } from "./client-version.mjs";
18
- import { bindNative, nativeActivationHintFor, nativeDiagnosticDeadline } from "./native-attempt.mjs";
18
+ import { bindNative, callRelease, nativeActivationHintFor, nativeDiagnosticDeadline }
19
+ from "./native-attempt.mjs";
19
20
  import { readProcessTable as defaultReadProcessTable } from "./process-table.mjs";
20
21
  import { withSessionLifecycle } from "./session-lifecycle.mjs";
21
22
  import { appendStartOwner, appendToolOwner, ownerHeader, ownerOnlyOutcome } from "./owner-context.mjs";
@@ -236,7 +237,7 @@ async function openContext({ event, adapterId, dataHome, runtime, env, deadline
236
237
  // `render` is how the projected text reaches this client: before a turn it is
237
238
  // the adapter's context envelope, at the end of one it is a continuation.
238
239
  async function projectTurn({ binding, context, adapter, adapterId,
239
- render = text => adapter.injectOutcome?.(text), activationHint = null }) {
240
+ render = text => adapter.injectOutcome?.(text), activationHint = null, repeats = true }) {
240
241
  const sync = await context.service.sync({ sessionId: binding.accSessionId,
241
242
  cursor: null, scope: "delta" });
242
243
 
@@ -250,11 +251,22 @@ async function projectTurn({ binding, context, adapter, adapterId,
250
251
  // Without this the projector's peer block never runs in production: an
251
252
  // agent sees only the obligation attention line, not the durable body that
252
253
  // may be offered after the stdout transport succeeds.
254
+ // Decided before selection: a repeat is taken out of the reminder lists, so
255
+ // asking for one this turn cannot show is how a message would go quiet.
256
+ // The ceiling a team agreed on in `acc.workspace.json`, or the default when
257
+ // there is no config. Validated by the protocol and, until now, never read:
258
+ // the projector was always called with its own default.
259
+ const effective = effectiveCapabilities(adapter, binding);
260
+ const hasStructuredRenderer = typeof adapter.renderContextResult === "function";
261
+ const canOfferNextTurn = effective.delivery.nextTurn === true && hasStructuredRenderer;
253
262
  const delivery = await context.service.nextTurnDelivery({
254
263
  workspaceId: context.descriptor.id,
255
264
  participantId: mine?.participantId,
256
- exceptSessionId: binding.accSessionId });
257
- const messages = delivery.queuedMessages;
265
+ exceptSessionId: binding.accSessionId,
266
+ repeats: repeats && canOfferNextTurn });
267
+ // New bodies first, so a repeat is what the byte budget drops.
268
+ const messages = [...delivery.queuedMessages, ...delivery.repeatMessages];
269
+ const repeating = new Set(delivery.repeatMessages.map(message => message.messageId));
258
270
 
259
271
  // Only this hook's payload selected the binding. Supply its own pair as
260
272
  // trusted context, outside peer bodies, rather than exporting inheritable
@@ -264,26 +276,24 @@ async function projectTurn({ binding, context, adapter, adapterId,
264
276
  // turn carries - and only when both fit in half the budget, so peer bodies
265
277
  // always keep the rest.
266
278
  const header = ownerHeader(binding, context.workspaceCwd, context.workspaceRef);
267
- const owner = activationHint === null
268
- || byteLength(header) + 1 + byteLength(activationHint) > totalBudget / 2
269
- ? header : `${header}\n${activationHint}`;
279
+ const hintFits = activationHint !== null
280
+ && byteLength(header) + 1 + byteLength(activationHint) <= totalBudget / 2;
281
+ const owner = hintFits ? `${header}\n${activationHint}` : header;
282
+ const withHint = (outcome, attached) => ({ ...outcome,
283
+ activationHintIncluded: attached === true && outcome.stdout !== "" });
270
284
  // A peer can join after this prompt has begun. The current turn must already
271
285
  // have its own arguments when it needs inbox/reply, without reattaching or
272
286
  // waiting for another user prompt. Solo emits identity, not a peer notice.
273
287
  if (sync.solo && messages.length === 0) {
274
- return ownerOnlyOutcome(text => adapter.injectOutcome?.(text), owner, totalBudget);
288
+ return withHint(ownerOnlyOutcome(text => adapter.injectOutcome?.(text), owner, totalBudget),
289
+ hintFits);
275
290
  }
276
291
 
277
- // The ceiling a team agreed on in `acc.workspace.json`, or the default when
278
- // there is no config. Validated by the protocol and, until now, never read:
279
- // the projector was always called with its own default.
280
- const effective = effectiveCapabilities(adapter, binding);
281
- const hasStructuredRenderer = typeof adapter.renderContextResult === "function";
282
- const canOfferNextTurn = effective.delivery.nextTurn === true && hasStructuredRenderer;
283
292
  const projectionInput = { ...sync, messages: canOfferNextTurn ? messages : [],
284
293
  liveOfferedMessageIds: delivery.liveOfferedMessageIds,
285
294
  reminderMessageIds: delivery.reminderMessageIds,
286
295
  roomMessageIds: delivery.roomMessageIds,
296
+ repeatOffers: delivery.repeatOffers,
287
297
  currentParticipantId: mine?.participantId };
288
298
  // Credentials alone cannot replace the command that reaches a queued body.
289
299
  // If both cannot fit, preserve recovery and explain the missing owner pair.
@@ -324,12 +334,13 @@ async function projectTurn({ binding, context, adapter, adapterId,
324
334
  // Own claims make sync non-solo but produce no peer context. They cannot
325
335
  // remove this turn's identity or consume a nonexistent body separator.
326
336
  if (body === "" && messages.length === 0) {
327
- return ownerOnlyOutcome(text => adapter.injectOutcome?.(text), owner, totalBudget);
337
+ return withHint(ownerOnlyOutcome(text => adapter.injectOutcome?.(text), owner, totalBudget),
338
+ hintFits);
328
339
  }
329
340
  const projected = body === "" ? "" : ownerFits ? `${owner}\n${body}` : body;
330
341
  if (projected === "") {
331
- return { stdout: "", stderr: messages.length === 0 ? ""
332
- : [ownerWarning, degradation].filter(Boolean).join("\n") };
342
+ return withHint({ stdout: "", stderr: messages.length === 0 ? ""
343
+ : [ownerWarning, degradation].filter(Boolean).join("\n") }, false);
333
344
  }
334
345
 
335
346
  // The renderer returns ids as metadata, never as text to parse. A peer body
@@ -341,7 +352,8 @@ async function projectTurn({ binding, context, adapter, adapterId,
341
352
  recipientParticipantId: mine.participantId,
342
353
  targetSessionId: binding.accSessionId, targetGeneration: binding.generation,
343
354
  transport: "next-turn", adapterId,
344
- clientVersion: binding.clientVersion }));
355
+ clientVersion: binding.clientVersion,
356
+ ...(repeating.has(message.messageId) ? { repeat: true } : {}) }));
345
357
  // Same again: Kimi Code shows the model a hook's raw stdout, while Gemini
346
358
  // and Claude Code want an envelope and drop a bare string.
347
359
  // The entry point owns the transport boundary. This handler only prepares
@@ -349,9 +361,30 @@ async function projectTurn({ binding, context, adapter, adapterId,
349
361
  // callback proves that the bytes crossed.
350
362
  const outcome = { stdout: "", ...render(projected) };
351
363
  const writableOffers = outcome.stdout === "" ? [] : offerInputs;
352
- return { ...outcome,
364
+ return withHint({ ...outcome,
353
365
  stderr: [outcome.stderr, ownerWarning, degradation].filter(Boolean).join("\n"),
354
- offerInputs: writableOffers };
366
+ offerInputs: writableOffers }, hintFits && ownerFits);
367
+ }
368
+
369
+ // The adapter may already have reserved an ask. That reservation is an ask
370
+ // only when this turn's stdout carries the line. A throw discards the turn,
371
+ // so the reservation goes with it.
372
+ async function projectActivation(input, offered) {
373
+ try {
374
+ const turn = await projectTurn({ ...input, activationHint: offered?.line ?? null });
375
+ const included = turn.activationHintIncluded === true;
376
+ delete turn.activationHintIncluded;
377
+ if (!offered) return turn;
378
+ if (!included) await callRelease(offered.release);
379
+ else if (typeof offered.release === "function") {
380
+ const release = offered.release;
381
+ turn.releaseActivationAsk = () => callRelease(release);
382
+ }
383
+ return turn;
384
+ } catch (error) {
385
+ await callRelease(offered?.release);
386
+ throw error;
387
+ }
355
388
  }
356
389
 
357
390
  const HANDLERS = {
@@ -500,9 +533,9 @@ const HANDLERS = {
500
533
  const started = await HANDLERS.sessionStart(input);
501
534
  const fresh = await loadSessionBinding({ runtimeDir: paths.root,
502
535
  harnessSessionId: event.sessionId });
503
- const activationHint = await nativeActivationHintFor({ adapter, event,
536
+ const offered = await nativeActivationHintFor({ adapter, event,
504
537
  nativeBinding: started.nativeBinding, binding: fresh, context, paths, deadline });
505
- const turn = await projectTurn({ ...input, binding: fresh, activationHint });
538
+ const turn = await projectActivation({ ...input, binding: fresh }, offered);
506
539
  return { ...turn, nativeBinding: started.nativeBinding };
507
540
  }
508
541
  const current = await context.service.locateSession(binding.accSessionId);
@@ -516,9 +549,9 @@ const HANDLERS = {
516
549
  const started = await HANDLERS.sessionStart(input);
517
550
  const fresh = await loadSessionBinding({ runtimeDir: paths.root,
518
551
  harnessSessionId: event.sessionId });
519
- const activationHint = await nativeActivationHintFor({ adapter, event,
552
+ const offered = await nativeActivationHintFor({ adapter, event,
520
553
  nativeBinding: started.nativeBinding, binding: fresh, context, paths, deadline });
521
- const turn = await projectTurn({ ...input, binding: fresh, activationHint });
554
+ const turn = await projectActivation({ ...input, binding: fresh }, offered);
522
555
  return { ...turn, nativeBinding: started.nativeBinding };
523
556
  }
524
557
  // A turn is the clearest sign a session is alive. Never a reason to fail:
@@ -530,9 +563,9 @@ const HANDLERS = {
530
563
  const nativeBinding = await bindNative({ adapter, event, hookBinding: binding,
531
564
  clientVersion: binding.clientVersion, platform: binding.platform, context, paths,
532
565
  deadline });
533
- const activationHint = await nativeActivationHintFor({ adapter, event, nativeBinding,
566
+ const offered = await nativeActivationHintFor({ adapter, event, nativeBinding,
534
567
  binding, context, paths, deadline });
535
- const turn = await projectTurn({ ...input, activationHint });
568
+ const turn = await projectActivation(input, offered);
536
569
  return nativeBinding === undefined ? turn : { ...turn, nativeBinding };
537
570
  },
538
571
 
@@ -557,7 +590,9 @@ const HANDLERS = {
557
590
  const current = await context.service.locateSession(binding.accSessionId);
558
591
  if (current === null || current.record.state !== "open"
559
592
  || current.record.generation !== binding.generation) return {};
560
- const turn = await projectTurn({ ...input,
593
+ // A repeat is a body the model may already have. It rides on the next
594
+ // turn that happens anyway and never buys a model call of its own.
595
+ const turn = await projectTurn({ ...input, repeats: false,
561
596
  render: text => adapter.continueTurnOutcome({ reason: text, payload }) });
562
597
  return (turn.offerInputs ?? []).length === 0 ? {} : turn;
563
598
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.6.3",
3
+ "version": "0.7.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/mcp-server",
3
- "version": "0.6.3",
3
+ "version": "0.7.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -23,15 +23,17 @@ const stringList = description => ({ type: "array", items: { type: "string" }, d
23
23
  export const PUBLIC_TOOLS = Object.freeze([
24
24
  {
25
25
  name: "acc_status",
26
- description: `Read who is here, current intents and claims, and the workspace's real `
27
- + `protection level. ${POLLED}`,
26
+ description: `Read who is here, current intents and claims, the workspace's real `
27
+ + `protection level, and how many messages each participant has not fetched `
28
+ + `(offered is not read). ${POLLED}`,
28
29
  inputSchema: object({}),
29
30
  },
30
31
  {
31
32
  name: "acc_sync",
32
33
  description: `Read coordination state for this workspace: roster, attention items, and `
33
34
  + `events since a cursor. Use scope "history" for bounded message summaries, then `
34
- + `messageId to read one complete historical message without changing receipts. `
35
+ + `messageId to read one complete historical message, with every recipient's `
36
+ + `receipt state, without changing receipts. `
35
37
  + `Scope "full" is an unbounded forensic workspace snapshot. Use acc_inbox for `
36
38
  + `your addressed messages. ${POLLED}`,
37
39
  inputSchema: object({
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/protocol",
3
- "version": "0.6.3",
3
+ "version": "0.7.1",
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.6.3",
3
+ "version": "0.7.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -34,11 +34,11 @@ async function bytesIfPresent(filePath, root, openFile) {
34
34
  }
35
35
  }
36
36
 
37
- function retainedStage(destination, root, tmpDir) {
37
+ function retainedStage(destination, root, stageDir) {
38
38
  const identity = createHash("sha256")
39
39
  .update(path.relative(root, destination))
40
40
  .digest("hex");
41
- return path.join(tmpDir, `${identity}.published`);
41
+ return path.join(stageDir, `${identity}.published`);
42
42
  }
43
43
 
44
44
  async function replaceHandleBytes(handle, bytes) {
@@ -63,11 +63,18 @@ async function replaceHandleBytes(handle, bytes) {
63
63
  *
64
64
  * @returns {Promise<"published" | "already_published">}
65
65
  */
66
- export async function publishAtomic(destination, bytes, { root, tmpDir, replace = false, deadlineAt }) {
66
+ export async function publishAtomic(destination, bytes,
67
+ { root, tmpDir, stageDir, replace = false, deadlineAt, afterAccepted }) {
67
68
  assertPublicationDeadline(deadlineAt);
69
+ // The accepted stage lives apart from the partial a failed publication
70
+ // leaves, so what a file is follows from the directory it was created in
71
+ // rather than from how its name ends. Defaulted from the root the way
72
+ // active-journal.mjs defaults tmpDir, so every caller keeps working.
73
+ stageDir = stageDir ?? path.join(root, "stage");
68
74
  const destinationDir = path.dirname(destination);
69
75
  await Promise.all([
70
76
  ensureManagedDirectory(root, tmpDir),
77
+ ensureManagedDirectory(root, stageDir),
71
78
  ensureManagedDirectory(root, destinationDir),
72
79
  ]);
73
80
  if (!replace) {
@@ -79,8 +86,11 @@ export async function publishAtomic(destination, bytes, { root, tmpDir, replace
79
86
  }
80
87
  }
81
88
 
82
- const stage = retainedStage(destination, root, tmpDir);
83
- const temporary = `${stage}.${process.pid}.${randomUUID()}.tmp`;
89
+ const stage = retainedStage(destination, root, stageDir);
90
+ // The temporary stays in tmpDir: until the bytes are accepted it is a
91
+ // partial, and a partial is exactly what tmp holds.
92
+ const temporary = path.join(tmpDir,
93
+ `${path.basename(stage)}.${process.pid}.${randomUUID()}.tmp`);
84
94
  let handle = await open(temporary, "wx");
85
95
  let stageAcceptedBytes = false;
86
96
  try {
@@ -121,9 +131,18 @@ export async function publishAtomic(destination, bytes, { root, tmpDir, replace
121
131
  } finally {
122
132
  await handle?.close();
123
133
  if (stageAcceptedBytes) {
124
- await assertManagedDirectory(root, tmpDir);
134
+ // `ensure`, not `assert`: the sweep empties this directory by renaming it
135
+ // aside and recreating it, and a publication that began before that swap
136
+ // arrives here to find the name gone. Both apply the same managed-root
137
+ // checks - the only difference is that this one recreates what the sweep
138
+ // has already taken away, instead of failing a write whose bytes are
139
+ // already published.
140
+ // The seam the race test uses, in the manner retainFile already
141
+ // establishes: the window this closes is invisible without one.
142
+ await afterAccepted?.();
143
+ await ensureManagedDirectory(root, stageDir);
125
144
  await rename(temporary, stage);
126
- await syncDirectory(tmpDir);
145
+ await syncDirectory(stageDir);
127
146
  } else {
128
147
  // A crash/error before immutable acceptance keeps its unique partial.
129
148
  // Retention avoids the unsafe parent-check/unlink pathname window, while
@@ -0,0 +1,98 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { open, rename, rm, rmdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { listDirectoryEntries } from "./atomic-json.mjs";
6
+ import { assertManagedDirectory, ensureManagedDirectory } from "./safe-directory.mjs";
7
+
8
+ // The store never unlinks a live name: Node exposes unlink only by pathname, so
9
+ // a checked parent can be replaced between the check and the call. What it does
10
+ // instead is move a doomed entry into a directory this process named with a
11
+ // value no other process knows, and then remove that directory whole. Every
12
+ // reclaimer in the store shares the primitive, so the rule is stated once.
13
+ //
14
+ // The prefix still reads "stage" because a store written by 0.6.3 leaves
15
+ // directories under this exact name when a pass is interrupted, and the resume
16
+ // below finds them by prefix. Renaming it would orphan them.
17
+ const DOOMED = "stage.sweeping-";
18
+
19
+ // Not assertPublicationDeadline: that one throws, and a maintenance pass on the
20
+ // path that opens the store must stop rather than fail the open. What is left
21
+ // is reported as remaining, and the next pass adopts it.
22
+ export const expired = deadlineAt => deadlineAt !== undefined && Date.now() >= deadlineAt;
23
+
24
+ export async function syncDirectory(directory) {
25
+ const handle = await open(directory, "r");
26
+ try {
27
+ await handle.sync();
28
+ } finally {
29
+ await handle.close();
30
+ }
31
+ }
32
+
33
+ export async function doomedDirectories(root) {
34
+ return (await listDirectoryEntries(root, { root }))
35
+ .filter(entry => entry.isDirectory() && entry.name.startsWith(DOOMED))
36
+ .map(entry => path.join(root, entry.name))
37
+ .sort((left, right) => left.localeCompare(right));
38
+ }
39
+
40
+ export function doomedName(root) {
41
+ return path.join(root, `${DOOMED}${randomUUID()}`);
42
+ }
43
+
44
+ export async function detachDoomed(root) {
45
+ const directory = doomedName(root);
46
+ await ensureManagedDirectory(root, directory);
47
+ return directory;
48
+ }
49
+
50
+ /**
51
+ * Move one doomed entry out of the store's live names.
52
+ *
53
+ * The destination name is unique because the sources are not: two records of
54
+ * one kind each keep a marker named for its own sequence, and a journal entry
55
+ * and a stage file can collide on nothing at all. The doomed directory is
56
+ * discarded whole, so the names inside it carry no meaning.
57
+ */
58
+ export async function condemn(filePath, doomed) {
59
+ await rename(filePath, path.join(doomed, `${randomUUID()}.doomed`));
60
+ }
61
+
62
+ /**
63
+ * Remove a doomed directory, one entry at a time, within a budget.
64
+ *
65
+ * @returns {Promise<{ spent: number, drained: boolean }>}
66
+ */
67
+ export async function discard(directory, root, budget, deadlineAt) {
68
+ let spent = 0;
69
+ await assertManagedDirectory(root, directory);
70
+ for (const entry of await listDirectoryEntries(directory, { root })) {
71
+ if (spent >= budget || expired(deadlineAt)) return { spent, drained: false };
72
+ await rm(path.join(directory, entry.name), { recursive: true, force: true });
73
+ spent += 1;
74
+ }
75
+ await rmdir(directory).catch(error => {
76
+ if (error.code !== "ENOENT" && error.code !== "ENOTEMPTY") throw error;
77
+ });
78
+ return { spent, drained: true };
79
+ }
80
+
81
+ /**
82
+ * Finish what an interrupted pass left behind, within a budget.
83
+ *
84
+ * Every pass runs this first, and for a second reason: a pass that stops here
85
+ * never detaches a further directory, so an exhausted budget cannot make them
86
+ * accumulate.
87
+ *
88
+ * @returns {Promise<{ spent: number, drained: boolean }>}
89
+ */
90
+ export async function discardLeftovers(root, budget, deadlineAt) {
91
+ let spent = 0;
92
+ for (const directory of await doomedDirectories(root)) {
93
+ const removed = await discard(directory, root, budget - spent, deadlineAt);
94
+ spent += removed.spent;
95
+ if (!removed.drained) return { spent, drained: false };
96
+ }
97
+ return { spent, drained: true };
98
+ }
@@ -0,0 +1,52 @@
1
+ import path from "node:path";
2
+
3
+ import { AccError, EXIT } from "@agents-can-communicate/protocol";
4
+
5
+ import { encode, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
6
+
7
+ const SEQUENCE = /^[0-9]{16}$/;
8
+
9
+ /**
10
+ * The highest event sequence this store has trimmed away.
11
+ *
12
+ * Trimming the oldest events is safe on its own, because `nextSequence` reads
13
+ * the newest file. An emptied directory is what needs a floor: without one the
14
+ * sequence would restart at 1 and collide with a cursor a peer still holds,
15
+ * and a reader whose cursor precedes the boundary would be served a short page
16
+ * with no way to tell it was short.
17
+ */
18
+ const floorPath = paths => path.join(paths.locks, "retention.json");
19
+
20
+ export function assertSequence(value, field) {
21
+ if (typeof value !== "string" || !SEQUENCE.test(value)) {
22
+ throw new AccError(EXIT.USAGE,
23
+ `${field} is the 16-digit sequence a previous sync returned`, { [field]: value });
24
+ }
25
+ return value;
26
+ }
27
+
28
+ // An unreadable floor is treated as none. The alternative - refusing to serve
29
+ // events because the bookkeeping about their removal is damaged - would take a
30
+ // workspace offline over a file that only ever adds information.
31
+ export async function readEventFloor(paths, root) {
32
+ const found = await readJsonIfPresent(floorPath(paths), root).catch(() => null);
33
+ const value = found?.value?.trimmedThrough;
34
+ return typeof value === "string" && SEQUENCE.test(value) ? value : null;
35
+ }
36
+
37
+ /**
38
+ * Raise the floor, never lower it.
39
+ *
40
+ * Written before a single event file moves. A floor ahead of the trim only
41
+ * reports a boundary for events still present, which costs a reader nothing;
42
+ * a trim ahead of the floor is a silent gap, which is the failure this exists
43
+ * to prevent.
44
+ */
45
+ export async function raiseEventFloor(paths, options, trimmedThrough) {
46
+ assertSequence(trimmedThrough, "trimmedThrough");
47
+ const current = await readEventFloor(paths, options.root);
48
+ if (current !== null && current >= trimmedThrough) return current;
49
+ await publishAtomic(floorPath(paths), encode({ trimmedThrough }),
50
+ { ...options, replace: true });
51
+ return trimmedThrough;
52
+ }