agents-can-communicate 0.1.18 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/README.md +78 -70
  2. package/SECURITY.md +31 -0
  3. package/bin/acc-hook.mjs +94 -12
  4. package/bin/acc-mcp.mjs +6 -2
  5. package/bin/acc.mjs +6 -1
  6. package/docs/ADAPTER_AUTHORING.md +172 -0
  7. package/docs/ARCHITECTURE.md +131 -0
  8. package/docs/CAPABILITIES.md +102 -214
  9. package/docs/CLI.md +157 -0
  10. package/docs/CONCEPTS.md +134 -0
  11. package/docs/CONFIGURATION.md +143 -0
  12. package/docs/DESIGN_DECISIONS.md +89 -0
  13. package/docs/GETTING_STARTED.md +145 -0
  14. package/docs/GLOSSARY.md +26 -0
  15. package/docs/MCP.md +94 -0
  16. package/docs/PROTOCOL.md +200 -0
  17. package/docs/RELEASING.md +109 -0
  18. package/docs/SECURITY_MODEL.md +131 -0
  19. package/docs/TROUBLESHOOTING.md +102 -0
  20. package/docs/WHY_ACC.md +61 -0
  21. package/docs/index.md +42 -0
  22. package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +78 -0
  23. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse-Edit.json +19 -0
  24. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse.json +17 -0
  25. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionEnd.json +8 -0
  26. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionStart.json +7 -0
  27. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/UserPromptSubmit.json +9 -0
  28. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +77 -0
  29. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.252.json +19 -0
  30. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +9 -1
  31. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +20 -22
  32. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +12 -4
  33. package/node_modules/@agents-can-communicate/adapter-codex/certification.json +117 -0
  34. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/PreToolUse.json +14 -0
  35. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionEnd.json +7 -0
  36. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionStart.json +9 -0
  37. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/UserPromptSubmit.json +10 -0
  38. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +66 -0
  39. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.0.json +19 -0
  40. package/node_modules/@agents-can-communicate/adapter-codex/package.json +8 -1
  41. package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +1 -1
  42. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +20 -22
  43. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +18 -11
  44. package/node_modules/@agents-can-communicate/adapter-gemini-cli/certification.json +52 -0
  45. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  46. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +20 -22
  47. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeAgent.json +8 -0
  48. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-shell.json +12 -0
  49. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool.json +12 -0
  50. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionEnd.json +8 -0
  51. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionStart.json +8 -0
  52. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/certification-provenance.json +66 -0
  53. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +8 -1
  54. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +7 -3
  55. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +4 -2
  56. package/node_modules/@agents-can-communicate/adapter-grok/certification.json +3 -0
  57. package/node_modules/@agents-can-communicate/adapter-grok/package.json +2 -1
  58. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +20 -22
  59. package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +9 -9
  60. package/node_modules/@agents-can-communicate/adapter-kimi/certification.json +52 -0
  61. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Bash.json +12 -0
  62. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Write.json +12 -0
  63. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionHeartbeat.json +7 -0
  64. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionStart.json +9 -0
  65. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/UserPromptSubmit.json +8 -0
  66. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/certification-provenance.json +66 -0
  67. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +8 -1
  68. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +20 -22
  69. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +7 -3
  70. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  71. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +34 -18
  72. package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +158 -0
  73. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +36 -17
  74. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +7 -1
  75. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -0
  76. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +13 -4
  77. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  78. package/node_modules/@agents-can-communicate/cli/src/args.mjs +11 -30
  79. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +3 -0
  80. package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -5
  81. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +9 -2
  82. package/node_modules/@agents-can-communicate/cli/src/main.mjs +100 -121
  83. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  84. package/node_modules/@agents-can-communicate/core/src/attention.mjs +106 -0
  85. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +276 -0
  86. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +81 -0
  87. package/node_modules/@agents-can-communicate/core/src/finish-retries.mjs +97 -0
  88. package/node_modules/@agents-can-communicate/core/src/inbox.mjs +91 -107
  89. package/node_modules/@agents-can-communicate/core/src/index.mjs +3 -3
  90. package/node_modules/@agents-can-communicate/core/src/intents.mjs +0 -1
  91. package/node_modules/@agents-can-communicate/core/src/ports.mjs +2 -1
  92. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +109 -0
  93. package/node_modules/@agents-can-communicate/core/src/service.mjs +11 -10
  94. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +22 -20
  95. package/node_modules/@agents-can-communicate/core/src/status.mjs +11 -9
  96. package/node_modules/@agents-can-communicate/core/src/sync.mjs +3 -294
  97. package/node_modules/@agents-can-communicate/delivery-router/package.json +12 -0
  98. package/node_modules/@agents-can-communicate/delivery-router/src/index.mjs +1 -0
  99. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +117 -0
  100. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  101. package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +20 -0
  102. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +115 -63
  103. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  104. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +23 -7
  105. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +20 -5
  106. package/node_modules/@agents-can-communicate/installer/src/index.mjs +3 -2
  107. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +108 -12
  108. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +19 -2
  109. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  110. package/node_modules/@agents-can-communicate/mcp-server/src/input-validator.mjs +79 -0
  111. package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +23 -28
  112. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +102 -72
  113. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +54 -97
  114. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  115. package/node_modules/@agents-can-communicate/protocol/src/config.mjs +1 -1
  116. package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +64 -0
  117. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +4 -1
  118. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +49 -90
  119. package/node_modules/@agents-can-communicate/protocol/src/states.mjs +13 -40
  120. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  121. package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +230 -0
  122. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +77 -28
  123. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +1 -1
  124. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +83 -35
  125. package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +112 -0
  126. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +18 -8
  127. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +68 -26
  128. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +113 -35
  129. package/package.json +19 -1
  130. package/node_modules/@agents-can-communicate/core/src/communication.mjs +0 -334
  131. package/node_modules/@agents-can-communicate/core/src/message-signals.mjs +0 -41
  132. package/node_modules/@agents-can-communicate/core/src/notify.mjs +0 -95
  133. package/node_modules/@agents-can-communicate/core/src/tasks.mjs +0 -244
  134. package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +0 -109
@@ -1,14 +1,19 @@
1
1
  import { mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- import { AccError, EXIT, validateRecord } from "@agents-can-communicate/protocol";
4
+ import { AccError, EXIT, assertPortableId, validateRecord }
5
+ from "@agents-can-communicate/protocol";
5
6
 
6
7
  import { encode, listDirectoryEntries, listJsonFiles, publishAtomic, readJsonIfPresent,
7
- removeIfPresent } from "./atomic-json.mjs";
8
+ retainFile } from "./atomic-json.mjs";
9
+ import { initialiseActiveJournal } from "./active-journal.mjs";
8
10
  import { requireStoreIdentity } from "./identity.mjs";
9
- import { journalEntry, readOpenJournals, rollForward, writeJournalEntry } from "./journal.mjs";
11
+ import { journalEntry, readJournalCeiling, readOpenJournals, rollForward, writeJournalEntry }
12
+ from "./journal.mjs";
10
13
  import { assertEventBinding, assertStateBinding, eventPath, stateEnvelope, statePath }
11
14
  from "./record-id.mjs";
15
+ import { ephemeralIsDeleted, markEphemeral, stateDeletionPublication,
16
+ stateGenerationIsDeleted } from "./retention.mjs";
12
17
  import { ensureManagedDirectory } from "./safe-directory.mjs";
13
18
  import { withWriterMutex } from "./writer-mutex.mjs";
14
19
 
@@ -23,10 +28,17 @@ export const ZERO_CURSOR = "0".repeat(SEQUENCE_WIDTH);
23
28
  // corrupt record, so nothing ever had a reason to put one aside. An empty
24
29
  // directory that reads as a feature is the same mistake as an attention kind
25
30
  // with no rule behind it. If quarantining is ever built, it comes back with it.
26
- const DIRECTORIES = ["state", "events", "journal", "locks", "ephemeral", "tmp"];
31
+ const DIRECTORIES = ["state", "events", "journal", "locks", "ephemeral", "retained", "tmp"];
27
32
 
28
33
  const pad = value => String(value).padStart(SEQUENCE_WIDTH, "0");
29
34
 
35
+ function assertBeforePublication(deadlineAt) {
36
+ if (deadlineAt !== undefined && Date.now() >= deadlineAt) {
37
+ throw new AccError(EXIT.CONFLICT,
38
+ "transaction deadline expired before durable publication", {});
39
+ }
40
+ }
41
+
30
42
  export function storePaths(root) {
31
43
  return Object.freeze(Object.fromEntries([["root", root],
32
44
  ...DIRECTORIES.map(name => [name, path.join(root, name)])]));
@@ -37,8 +49,12 @@ async function listState(paths, root, kind) {
37
49
  for (const filePath of await listJsonFiles(path.join(paths.state, kind), { root })) {
38
50
  const found = await readJsonIfPresent(filePath, root);
39
51
  if (found === null) continue;
40
- envelopes.push(assertStateBinding(found.value, kind,
41
- path.basename(filePath, ".json"), filePath));
52
+ const envelope = assertStateBinding(found.value, kind,
53
+ path.basename(filePath, ".json"), filePath);
54
+ if (await stateGenerationIsDeleted(paths, root, kind, envelope.id,
55
+ envelope.generation)) continue;
56
+ validateRecord(kind, envelope.record);
57
+ envelopes.push(envelope);
42
58
  }
43
59
  return envelopes;
44
60
  }
@@ -67,11 +83,12 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
67
83
  // root, containment rules apply and each level is created individually so a
68
84
  // symlinked ancestor cannot be created past.
69
85
  await mkdir(root, { recursive: true });
70
- for (const name of DIRECTORIES) await ensureManagedDirectory(root, paths[name]);
71
86
  // Identity is settled before any read or write. Adopting a directory that
72
87
  // already belongs to another workspace is the failure this fails closed on.
73
88
  await requireStoreIdentity(paths, { workspaceId, clock });
89
+ for (const name of DIRECTORIES) await ensureManagedDirectory(root, paths[name]);
74
90
  const publishOptions = { root, tmpDir: paths.tmp, clock, failAt };
91
+ await initialiseActiveJournal(paths, publishOptions);
75
92
 
76
93
  // Any journal left behind by a crashed writer is completed before the store
77
94
  // serves a single read, so callers never observe a half-published
@@ -107,7 +124,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
107
124
  * these transactions make reads as "nothing conflicts" when it finds nothing.
108
125
  * Declaring nothing reads everything, which is what this always did.
109
126
  */
110
- async function transaction(callback, { kinds } = {}) {
127
+ async function transaction(callback, { kinds, deadlineAt } = {}) {
111
128
  const wanted = kinds === undefined ? null : new Set(kinds);
112
129
  const declared = kind => {
113
130
  if (wanted !== null && !wanted.has(kind)) {
@@ -117,7 +134,8 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
117
134
  }
118
135
  return kind;
119
136
  };
120
- return withWriterMutex(paths, publishOptions, async () => {
137
+ return withWriterMutex(paths, { ...publishOptions, deadlineAt }, async () => {
138
+ assertBeforePublication(deadlineAt);
121
139
  // Reads are loaded once per transaction so get, list, and the generation
122
140
  // that put() compares against all describe the same instant.
123
141
  const loaded = await loadAllState(paths, root, wanted);
@@ -171,7 +189,9 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
171
189
  throw new AccError(EXIT.CONFLICT, `${kind} ${id} changed under this transaction`,
172
190
  { kind, id, expectedGeneration, actualGeneration: actual });
173
191
  }
174
- staged.set(key, { kind, id, removed: true });
192
+ const persisted = loaded.get(key);
193
+ if (persisted === undefined) staged.delete(key);
194
+ else staged.set(key, { kind, id, generation: persisted.generation, removed: true });
175
195
  },
176
196
  append(event) {
177
197
  const stamped = { ...event, sequence: pad(sequence) };
@@ -194,7 +214,8 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
194
214
  replace: false,
195
215
  })),
196
216
  ...[...staged.values()].map(entry => (entry.removed === true
197
- ? { path: path.relative(root, statePath(paths, entry.kind, entry.id)), remove: true }
217
+ ? stateDeletionPublication(paths, root, entry.kind, entry.id, entry.generation,
218
+ statePath(paths, entry.kind, entry.id))
198
219
  : {
199
220
  path: path.relative(root, statePath(paths, entry.kind, entry.id)),
200
221
  bytes: encode(stateEnvelope(entry.kind, entry.id, entry.generation, entry.record)),
@@ -203,6 +224,11 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
203
224
  ];
204
225
  if (publications.length === 0) return result;
205
226
 
227
+ // Cancellation is safe up to this point: nothing durable has decided the
228
+ // transaction. Once the journal write starts, recovery must finish it and
229
+ // the caller waits for that atomic outcome instead of reporting a false
230
+ // timeout while publication continues in the background.
231
+ assertBeforePublication(deadlineAt);
206
232
  const entry = journalEntry(ids.next("transaction"), firstSequence, publications,
207
233
  clock.now());
208
234
  await writeJournalEntry(paths, publishOptions, entry);
@@ -217,7 +243,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
217
243
  // An open journal marks a transaction that is decided but not fully
218
244
  // published. Bounding the page below its first sequence is what keeps a
219
245
  // partially published transaction invisible to every reader.
220
- const ceiling = (await readOpenJournals(paths, root)).at(0)?.firstSequence ?? null;
246
+ const ceiling = await readJournalCeiling(paths, root);
221
247
  const events = [];
222
248
  for (const filePath of await listJsonFiles(paths.events, { root })) {
223
249
  const sequence = path.basename(filePath, ".json");
@@ -225,7 +251,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
225
251
  if (ceiling !== null && sequence >= ceiling) break;
226
252
  const found = await readJsonIfPresent(filePath, root);
227
253
  if (found === null) continue;
228
- const event = assertEventBinding(found.value, filePath);
254
+ const event = validateRecord("event", assertEventBinding(found.value, filePath));
229
255
  if (event.workspaceId !== workspace) continue;
230
256
  events.push(event);
231
257
  if (events.length === limit) break;
@@ -255,49 +281,65 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
255
281
  participants: await of("participant"),
256
282
  sessions: await of("session"),
257
283
  intents: await of("intent"),
258
- workstreams: await of("workstream"),
259
- tasks: await of("task"),
260
284
  claims: await of("claim"),
261
285
  messages: await of("message"),
262
286
  receipts: await of("receipt"),
263
- decisions: await of("decision"),
264
- handoffs: await of("handoff"),
265
287
  };
266
288
  }
267
289
  // Ephemeral records are published by replace and never journalled: they carry
268
- // no history, append no events, and are expected to disappear.
269
- const ephemeralPath = (kind, id) => path.join(paths.ephemeral, kind, `${id}.json`);
290
+ // no durable history and append no events. Deletion is represented by a
291
+ // retained marker because Node cannot unlink safely through a directory fd.
292
+ const ephemeralDirectory = kind => {
293
+ assertPortableId(kind, "ephemeral record kind");
294
+ return path.join(paths.ephemeral, kind);
295
+ };
296
+ const ephemeralPath = (kind, id) => {
297
+ assertPortableId(id, "ephemeral record id");
298
+ return path.join(ephemeralDirectory(kind), `${id}.json`);
299
+ };
300
+ const readEphemeral = async (kind, id) => {
301
+ const found = await readJsonIfPresent(ephemeralPath(kind, id), root);
302
+ if (found === null || await ephemeralIsDeleted(paths, root, kind, id)) return null;
303
+ return validateRecord(kind, found.value);
304
+ };
270
305
  const ephemeral = Object.freeze({
271
306
  async get(kind, id) {
272
- return (await readJsonIfPresent(ephemeralPath(kind, id), root))?.value ?? null;
307
+ return readEphemeral(kind, id);
273
308
  },
274
309
  async put(kind, id, record) {
310
+ validateRecord(kind, record);
275
311
  return withWriterMutex(paths, publishOptions, async () => {
276
312
  await publishAtomic(ephemeralPath(kind, id), encode(record),
277
313
  { root, tmpDir: paths.tmp, replace: true });
314
+ await markEphemeral(paths, publishOptions, kind, id, "present");
278
315
  return record;
279
316
  });
280
317
  },
281
318
  async update(kind, id, updater) {
282
319
  return withWriterMutex(paths, publishOptions, async () => {
283
- const found = await readJsonIfPresent(ephemeralPath(kind, id), root);
284
- const next = await updater(found?.value ?? null);
320
+ const next = await updater(await readEphemeral(kind, id));
285
321
  if (next === null) return null;
286
322
  validateRecord(kind, next);
287
323
  await publishAtomic(ephemeralPath(kind, id), encode(next),
288
324
  { root, tmpDir: paths.tmp, replace: true });
325
+ await markEphemeral(paths, publishOptions, kind, id, "present");
289
326
  return next;
290
327
  });
291
328
  },
292
329
  async delete(kind, id) {
293
- return withWriterMutex(paths, publishOptions,
294
- async () => removeIfPresent(ephemeralPath(kind, id)));
330
+ return withWriterMutex(paths, publishOptions, async () => {
331
+ if (await readEphemeral(kind, id) === null) return null;
332
+ await retainFile(ephemeralPath(kind, id), { root });
333
+ await markEphemeral(paths, publishOptions, kind, id, "deleted");
334
+ return null;
335
+ });
295
336
  },
296
337
  async list(kind) {
297
338
  const records = [];
298
- for (const filePath of await listJsonFiles(path.join(paths.ephemeral, kind), { root })) {
339
+ for (const filePath of await listJsonFiles(ephemeralDirectory(kind), { root })) {
299
340
  const found = await readJsonIfPresent(filePath, root);
300
- if (found !== null) records.push(found.value);
341
+ if (found !== null && !await ephemeralIsDeleted(paths, root, kind,
342
+ path.basename(filePath, ".json"))) records.push(validateRecord(kind, found.value));
301
343
  }
302
344
  return records;
303
345
  },
@@ -1,21 +1,23 @@
1
- import { randomUUID } from "node:crypto";
2
- import { mkdir, rm } from "node:fs/promises";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { mkdir, open, rename, rm } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { performance } from "node:perf_hooks";
5
6
 
6
7
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
7
8
 
8
- import { encode, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
9
+ import { encode, readJsonIfPresent } from "./atomic-json.mjs";
9
10
  import { ensureManagedDirectory } from "./safe-directory.mjs";
11
+ import { withRegularNoFollow } from "./safe-file.mjs";
10
12
 
11
13
  const STALE_MS = 60_000;
12
14
  const ACQUIRE_TIMEOUT_MS = 2_500;
13
15
  const OWNER = "owner.json";
14
16
  const sleepFor = duration => new Promise(resolve => { setTimeout(resolve, duration); });
15
17
 
16
- // Directory creation is the atomic primitive: mkdir either creates or fails
17
- // with EEXIST, with no window in between. Ported from the reconciled
18
- // prototype's repair mutex and reused here as the per-workspace writer lock.
18
+ // A fully prepared directory is the atomic primitive. Publishing the owner
19
+ // with the directory means a crash can leave an unused candidate, but never an
20
+ // ownerless canonical lock that blocks every later writer.
19
21
  function defaultPidIsAlive(pid) {
20
22
  try {
21
23
  process.kill(pid, 0);
@@ -25,13 +27,59 @@ function defaultPidIsAlive(pid) {
25
27
  }
26
28
  }
27
29
 
30
+ async function syncDirectory(directory) {
31
+ const handle = await open(directory, "r");
32
+ try {
33
+ await handle.sync();
34
+ } finally {
35
+ await handle.close();
36
+ }
37
+ }
38
+
39
+ async function prepareCandidate(paths, root, owner) {
40
+ const identity = createHash("sha256").update(owner.token).digest("hex");
41
+ const candidate = path.join(paths.locks, `writer.candidate-${identity}.lock`);
42
+ await mkdir(candidate);
43
+ try {
44
+ await withRegularNoFollow(path.join(candidate, OWNER), root,
45
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, async handle => {
46
+ await handle.writeFile(encode(owner));
47
+ await handle.sync();
48
+ });
49
+ await syncDirectory(candidate);
50
+ return candidate;
51
+ } catch (error) {
52
+ await rm(candidate, { recursive: true, force: true });
53
+ throw error;
54
+ }
55
+ }
56
+
57
+ async function releaseCanonical(directory, root, owner, openFile) {
58
+ const current = await readOwner(directory, root, openFile);
59
+ if (current?.token !== owner.token) return;
60
+ // Recursive removal would first unlink owner.json and briefly expose an
61
+ // empty canonical directory. Move the complete lock aside atomically so a
62
+ // successor can only arrive after this owner has left the canonical name.
63
+ const identity = createHash("sha256")
64
+ .update(JSON.stringify([owner.pid, owner.token, owner.acquiredAt]))
65
+ .digest("hex");
66
+ const retired = path.join(path.dirname(directory), `writer.released-${identity}.lock`);
67
+ try {
68
+ await rename(directory, retired);
69
+ } catch (error) {
70
+ if (["ENOENT", "EEXIST", "ENOTEMPTY"].includes(error.code)) return;
71
+ throw error;
72
+ }
73
+ await rm(retired, { recursive: true, force: true });
74
+ }
75
+
28
76
  /**
29
77
  * Who holds the lock, or nothing if it moved while we looked.
30
78
  *
31
79
  * Reads inside the store refuse a parent directory whose identity changed
32
80
  * between the check and the open - the defence against a directory being
33
81
  * swapped under a read. This lock is the one directory whose entire life is
34
- * being created and removed: `mkdir` grants it, `rm` releases it, so its inode
82
+ * being published and retired: `rename` grants and releases it, so its inode
35
83
  * changes every time it passes from one process to the next. Reading its owner
36
84
  * through the strict path meant a contended lock raised
37
85
  * "record parent directory changed while opening" and the whole command failed -
@@ -82,49 +130,79 @@ async function takeStaleOwnership(directory, root, owner, now, pidIsAlive) {
82
130
  if (owner === null) return false;
83
131
  const age = Date.parse(now) - Date.parse(owner.acquiredAt);
84
132
  if (pidIsAlive(owner.pid) && !(age > STALE_MS)) return false;
85
- await rm(directory, { recursive: true, force: true });
86
- return true;
133
+ // Every contender that observed this owner names the same retained target.
134
+ // rename() moves the whole lock atomically; exactly one contender can put it
135
+ // there. The target is deliberately left non-empty. A late contender cannot
136
+ // rename a successor over it, so a stale pathname observation never becomes
137
+ // permission to remove the writer that acquired the lock afterwards.
138
+ const identity = createHash("sha256")
139
+ .update(JSON.stringify([owner.pid, owner.token, owner.acquiredAt]))
140
+ .digest("hex");
141
+ const reclaimed = path.join(path.dirname(directory), `writer.reclaimed-${identity}.lock`);
142
+ try {
143
+ await rename(directory, reclaimed);
144
+ return true;
145
+ } catch (error) {
146
+ if (["ENOENT", "EEXIST", "ENOTEMPTY"].includes(error.code)) return false;
147
+ throw error;
148
+ }
87
149
  }
88
150
 
89
151
  export async function withWriterMutex(paths, options, operation) {
90
152
  const { root, clock, pidIsAlive = defaultPidIsAlive, uuid = randomUUID,
91
153
  attempts = Number.POSITIVE_INFINITY, waitMs = 20, openFile,
92
154
  acquireTimeoutMs = ACQUIRE_TIMEOUT_MS, monotonicNow = () => performance.now(),
93
- sleep = sleepFor } = options;
155
+ wallNow = Date.now, deadlineAt, sleep = sleepFor } = options;
94
156
  const directory = path.join(paths.locks, "writer.lock");
95
157
  // Wall time can jump while a process waits. A monotonic absolute deadline
96
158
  // bounds all owner reads and retries, leaving half the hook's five-second
97
159
  // budget for publishing the owner, doing the write, and rendering a result.
98
- const deadline = monotonicNow() + acquireTimeoutMs;
160
+ const started = monotonicNow();
161
+ const callerBudget = deadlineAt === undefined
162
+ ? Number.POSITIVE_INFINITY : Math.max(0, deadlineAt - wallNow());
163
+ const deadline = started + Math.min(acquireTimeoutMs, callerBudget);
99
164
  await ensureManagedDirectory(root, paths.locks);
100
165
  const token = uuid();
166
+ const owner = { pid: process.pid, token, acquiredAt: clock.now() };
167
+ const candidate = await prepareCandidate(paths, root, owner);
168
+ let ownsCanonical = false;
101
169
 
102
- for (let attempt = 0; attempt < attempts; attempt += 1) {
103
- if (monotonicNow() >= deadline) break;
104
- try {
105
- await mkdir(directory);
106
- } catch (error) {
107
- if (error.code !== "EEXIST") throw error;
108
- const owner = await readOwner(directory, root, openFile);
109
- if (!await takeStaleOwnership(directory, root, owner, clock.now(), pidIsAlive)) {
110
- const remaining = deadline - monotonicNow();
111
- if (remaining <= 0) break;
112
- await sleep(Math.min(waitMs, remaining));
170
+ try {
171
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
172
+ if (monotonicNow() >= deadline) break;
173
+ try {
174
+ // POSIX rename replaces an empty directory atomically. That recovers a
175
+ // lock left by the old mkdir-then-publish sequence. A live publisher
176
+ // either fills it first (so rename fails) or loses its no-replace owner
177
+ // publication after this complete candidate wins.
178
+ await rename(candidate, directory);
179
+ ownsCanonical = true;
180
+ await syncDirectory(paths.locks);
181
+ } catch (error) {
182
+ if (!["EEXIST", "ENOTEMPTY"].includes(error.code)) throw error;
183
+ const current = await readOwner(directory, root, openFile);
184
+ if (!await takeStaleOwnership(directory, root, current, clock.now(), pidIsAlive)) {
185
+ const remaining = deadline - monotonicNow();
186
+ if (remaining <= 0) break;
187
+ await sleep(Math.min(waitMs, remaining));
188
+ }
189
+ continue;
113
190
  }
114
- continue;
115
- }
116
- if (monotonicNow() >= deadline) {
117
- await rm(directory, { recursive: true, force: true });
118
- break;
119
- }
120
- const owner = { pid: process.pid, token, acquiredAt: clock.now() };
121
- await publishAtomic(path.join(directory, OWNER), encode(owner), { root, tmpDir: paths.tmp });
122
- try {
191
+ if (monotonicNow() >= deadline) break;
123
192
  return await operation();
124
- } finally {
125
- const current = await readOwner(directory, root, openFile);
126
- if (current?.token === token) await rm(directory, { recursive: true, force: true });
193
+ }
194
+ } finally {
195
+ if (ownsCanonical) {
196
+ await releaseCanonical(directory, root, owner, openFile);
197
+ } else {
198
+ const current = await readOwner(candidate, root, openFile);
199
+ if (current?.token === token) {
200
+ await rm(candidate, { recursive: true, force: true });
201
+ }
127
202
  }
128
203
  }
129
- throw new AccError(EXIT.CONFLICT, "another writer holds the store lock", { directory });
204
+ const reason = deadlineAt !== undefined && wallNow() >= deadlineAt
205
+ ? "transaction deadline expired while waiting for the store lock"
206
+ : "another writer holds the store lock";
207
+ throw new AccError(EXIT.CONFLICT, reason, { directory });
130
208
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.1.18",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Local-first coordination for independently opened AI agent sessions.",
6
6
  "keywords": [
@@ -45,7 +45,23 @@
45
45
  },
46
46
  "files": [
47
47
  "bin/",
48
+ "docs/ADAPTER_AUTHORING.md",
49
+ "docs/ARCHITECTURE.md",
48
50
  "docs/CAPABILITIES.md",
51
+ "docs/CLI.md",
52
+ "docs/CONCEPTS.md",
53
+ "docs/CONFIGURATION.md",
54
+ "docs/DESIGN_DECISIONS.md",
55
+ "docs/GETTING_STARTED.md",
56
+ "docs/GLOSSARY.md",
57
+ "docs/MCP.md",
58
+ "docs/PROTOCOL.md",
59
+ "docs/RELEASING.md",
60
+ "docs/SECURITY_MODEL.md",
61
+ "docs/TROUBLESHOOTING.md",
62
+ "docs/WHY_ACC.md",
63
+ "docs/index.md",
64
+ "SECURITY.md",
49
65
  "README.md",
50
66
  "LICENSE"
51
67
  ],
@@ -58,6 +74,7 @@
58
74
  "@agents-can-communicate/adapter-sdk": "*",
59
75
  "@agents-can-communicate/cli": "*",
60
76
  "@agents-can-communicate/core": "*",
77
+ "@agents-can-communicate/delivery-router": "*",
61
78
  "@agents-can-communicate/hook-runner": "*",
62
79
  "@agents-can-communicate/installer": "*",
63
80
  "@agents-can-communicate/mcp-server": "*",
@@ -73,6 +90,7 @@
73
90
  "@agents-can-communicate/adapter-sdk",
74
91
  "@agents-can-communicate/cli",
75
92
  "@agents-can-communicate/core",
93
+ "@agents-can-communicate/delivery-router",
76
94
  "@agents-can-communicate/hook-runner",
77
95
  "@agents-can-communicate/installer",
78
96
  "@agents-can-communicate/mcp-server",