agents-can-communicate 0.1.17 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +76 -138
  2. package/SECURITY.md +31 -0
  3. package/bin/acc-hook.mjs +96 -12
  4. package/bin/acc-mcp.mjs +6 -2
  5. package/bin/acc.mjs +6 -1
  6. package/docs/ADAPTER_AUTHORING.md +172 -0
  7. package/docs/ARCHITECTURE.md +131 -0
  8. package/docs/CAPABILITIES.md +105 -197
  9. package/docs/CLI.md +157 -0
  10. package/docs/CONCEPTS.md +134 -0
  11. package/docs/CONFIGURATION.md +143 -0
  12. package/docs/DESIGN_DECISIONS.md +89 -0
  13. package/docs/GETTING_STARTED.md +145 -0
  14. package/docs/GLOSSARY.md +26 -0
  15. package/docs/MCP.md +94 -0
  16. package/docs/PROTOCOL.md +200 -0
  17. package/docs/RELEASING.md +109 -0
  18. package/docs/SECURITY_MODEL.md +131 -0
  19. package/docs/TROUBLESHOOTING.md +102 -0
  20. package/docs/WHY_ACC.md +61 -0
  21. package/docs/index.md +42 -0
  22. package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +78 -0
  23. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse-Edit.json +19 -0
  24. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse.json +17 -0
  25. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionEnd.json +8 -0
  26. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionStart.json +7 -0
  27. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/UserPromptSubmit.json +9 -0
  28. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +77 -0
  29. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.252.json +19 -0
  30. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +9 -1
  31. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +80 -160
  32. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +15 -5
  33. package/node_modules/@agents-can-communicate/adapter-codex/certification.json +117 -0
  34. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/PreToolUse.json +14 -0
  35. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionEnd.json +7 -0
  36. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionStart.json +9 -0
  37. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/UserPromptSubmit.json +10 -0
  38. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +66 -0
  39. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.0.json +19 -0
  40. package/node_modules/@agents-can-communicate/adapter-codex/package.json +8 -1
  41. package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +1 -1
  42. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +80 -160
  43. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +21 -12
  44. package/node_modules/@agents-can-communicate/adapter-gemini-cli/certification.json +52 -0
  45. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  46. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +80 -160
  47. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeAgent.json +8 -0
  48. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-shell.json +12 -0
  49. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool.json +12 -0
  50. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionEnd.json +8 -0
  51. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionStart.json +8 -0
  52. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/certification-provenance.json +66 -0
  53. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +8 -1
  54. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +10 -4
  55. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +4 -2
  56. package/node_modules/@agents-can-communicate/adapter-grok/certification.json +3 -0
  57. package/node_modules/@agents-can-communicate/adapter-grok/package.json +14 -0
  58. package/node_modules/@agents-can-communicate/adapter-grok/plugin/hooks/hooks.json +61 -0
  59. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +152 -0
  60. package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +61 -0
  61. package/node_modules/@agents-can-communicate/adapter-grok/src/hooks.mjs +127 -0
  62. package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +101 -0
  63. package/node_modules/@agents-can-communicate/adapter-kimi/certification.json +52 -0
  64. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Bash.json +12 -0
  65. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Write.json +12 -0
  66. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionHeartbeat.json +7 -0
  67. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionStart.json +9 -0
  68. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/UserPromptSubmit.json +8 -0
  69. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/certification-provenance.json +66 -0
  70. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +8 -1
  71. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +80 -160
  72. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +10 -4
  73. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  74. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +34 -18
  75. package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +158 -0
  76. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +139 -224
  77. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +7 -1
  78. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +2 -1
  79. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +13 -4
  80. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  81. package/node_modules/@agents-can-communicate/cli/src/args.mjs +13 -29
  82. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +3 -0
  83. package/node_modules/@agents-can-communicate/cli/src/help.mjs +5 -6
  84. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +12 -3
  85. package/node_modules/@agents-can-communicate/cli/src/main.mjs +109 -109
  86. package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +1 -1
  87. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  88. package/node_modules/@agents-can-communicate/core/src/attention.mjs +106 -0
  89. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +276 -0
  90. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +81 -0
  91. package/node_modules/@agents-can-communicate/core/src/finish-retries.mjs +97 -0
  92. package/node_modules/@agents-can-communicate/core/src/inbox.mjs +118 -0
  93. package/node_modules/@agents-can-communicate/core/src/index.mjs +3 -2
  94. package/node_modules/@agents-can-communicate/core/src/intents.mjs +0 -1
  95. package/node_modules/@agents-can-communicate/core/src/ports.mjs +3 -2
  96. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +109 -0
  97. package/node_modules/@agents-can-communicate/core/src/service.mjs +14 -10
  98. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +70 -20
  99. package/node_modules/@agents-can-communicate/core/src/status.mjs +11 -9
  100. package/node_modules/@agents-can-communicate/core/src/sync.mjs +3 -258
  101. package/node_modules/@agents-can-communicate/delivery-router/package.json +12 -0
  102. package/node_modules/@agents-can-communicate/delivery-router/src/index.mjs +1 -0
  103. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +117 -0
  104. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  105. package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +20 -0
  106. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +156 -60
  107. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  108. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +23 -7
  109. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +20 -5
  110. package/node_modules/@agents-can-communicate/installer/src/index.mjs +3 -2
  111. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +108 -12
  112. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +19 -2
  113. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  114. package/node_modules/@agents-can-communicate/mcp-server/src/input-validator.mjs +79 -0
  115. package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +23 -28
  116. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +109 -71
  117. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +74 -93
  118. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  119. package/node_modules/@agents-can-communicate/protocol/src/config.mjs +1 -1
  120. package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +64 -0
  121. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +4 -1
  122. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +49 -90
  123. package/node_modules/@agents-can-communicate/protocol/src/states.mjs +13 -40
  124. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  125. package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +230 -0
  126. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +77 -28
  127. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +1 -1
  128. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +83 -35
  129. package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +112 -0
  130. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +18 -8
  131. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +86 -28
  132. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +121 -27
  133. package/package.json +22 -1
  134. package/node_modules/@agents-can-communicate/core/src/communication.mjs +0 -334
  135. package/node_modules/@agents-can-communicate/core/src/notify.mjs +0 -95
  136. package/node_modules/@agents-can-communicate/core/src/tasks.mjs +0 -244
  137. package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +0 -109
@@ -10,16 +10,15 @@ function sameDirectory(left, right) {
10
10
  return left.dev === right.dev && left.ino === right.ino;
11
11
  }
12
12
 
13
- // O_NOFOLLOW plus a before/after identity check on the parent: the bytes
14
- // returned come from the handle that was opened, not from whatever the path
15
- // resolves to afterwards. The injected opener is the seam the crash-window and
16
- // race tests use, and stays the final argument.
17
- export async function readRegularNoFollow(filePath, root, openFile = open) {
13
+ // The operation runs through the opened handle only after O_NOFOLLOW and the
14
+ // before/after parent identity check agree. Replacing the pathname after that
15
+ // point cannot redirect a read or append through the already-open descriptor.
16
+ export async function withRegularNoFollow(filePath, root, flags, operation, openFile = open) {
18
17
  const parent = path.dirname(filePath);
19
18
  const before = await assertManagedDirectory(root, parent);
20
19
  let handle;
21
20
  try {
22
- handle = await openFile(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
21
+ handle = await openFile(filePath, flags | constants.O_NOFOLLOW);
23
22
  } catch (error) {
24
23
  if (error.code === "ENOENT") throw error;
25
24
  throw new AccError(EXIT.DATA, "cannot safely open regular file",
@@ -35,16 +34,27 @@ export async function readRegularNoFollow(filePath, root, openFile = open) {
35
34
  throw new AccError(EXIT.DATA, "record parent directory changed while opening",
36
35
  { filePath, root });
37
36
  }
38
- return await handle.readFile();
37
+ return await operation(handle, stat);
39
38
  } catch (error) {
40
39
  if (error instanceof AccError || error.code === "ENOENT") throw error;
41
- throw new AccError(EXIT.DATA, "cannot safely read regular file",
40
+ throw new AccError(EXIT.DATA, "cannot safely access regular file",
42
41
  { filePath, cause: error.message });
43
42
  } finally {
44
43
  await handle.close();
45
44
  }
46
45
  }
47
46
 
47
+ export async function readRegularNoFollow(filePath, root, openFile = open) {
48
+ try {
49
+ return await withRegularNoFollow(filePath, root, constants.O_RDONLY,
50
+ handle => handle.readFile(), openFile);
51
+ } catch (error) {
52
+ if (error instanceof AccError || error.code === "ENOENT") throw error;
53
+ throw new AccError(EXIT.DATA, "cannot safely read regular file",
54
+ { filePath, cause: error.message });
55
+ }
56
+ }
57
+
48
58
  export async function readJsonNoFollow(filePath, root, openFile = open) {
49
59
  const bytes = await readRegularNoFollow(filePath, root, openFile);
50
60
  try {
@@ -1,17 +1,26 @@
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
 
20
+ // Kept cohesive above 300 lines because durable transactions and ephemeral
21
+ // mutations must share this exact writer mutex. Splitting the two stores would
22
+ // make it easy to reintroduce separate locks and resurrect replaced sessions.
23
+
15
24
  const SEQUENCE_WIDTH = 16;
16
25
  export const ZERO_CURSOR = "0".repeat(SEQUENCE_WIDTH);
17
26
  // No quarantine area. One was created in every workspace, named in the path
@@ -19,10 +28,17 @@ export const ZERO_CURSOR = "0".repeat(SEQUENCE_WIDTH);
19
28
  // corrupt record, so nothing ever had a reason to put one aside. An empty
20
29
  // directory that reads as a feature is the same mistake as an attention kind
21
30
  // with no rule behind it. If quarantining is ever built, it comes back with it.
22
- const DIRECTORIES = ["state", "events", "journal", "locks", "ephemeral", "tmp"];
31
+ const DIRECTORIES = ["state", "events", "journal", "locks", "ephemeral", "retained", "tmp"];
23
32
 
24
33
  const pad = value => String(value).padStart(SEQUENCE_WIDTH, "0");
25
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
+
26
42
  export function storePaths(root) {
27
43
  return Object.freeze(Object.fromEntries([["root", root],
28
44
  ...DIRECTORIES.map(name => [name, path.join(root, name)])]));
@@ -33,8 +49,12 @@ async function listState(paths, root, kind) {
33
49
  for (const filePath of await listJsonFiles(path.join(paths.state, kind), { root })) {
34
50
  const found = await readJsonIfPresent(filePath, root);
35
51
  if (found === null) continue;
36
- envelopes.push(assertStateBinding(found.value, kind,
37
- 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);
38
58
  }
39
59
  return envelopes;
40
60
  }
@@ -63,11 +83,12 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
63
83
  // root, containment rules apply and each level is created individually so a
64
84
  // symlinked ancestor cannot be created past.
65
85
  await mkdir(root, { recursive: true });
66
- for (const name of DIRECTORIES) await ensureManagedDirectory(root, paths[name]);
67
86
  // Identity is settled before any read or write. Adopting a directory that
68
87
  // already belongs to another workspace is the failure this fails closed on.
69
88
  await requireStoreIdentity(paths, { workspaceId, clock });
89
+ for (const name of DIRECTORIES) await ensureManagedDirectory(root, paths[name]);
70
90
  const publishOptions = { root, tmpDir: paths.tmp, clock, failAt };
91
+ await initialiseActiveJournal(paths, publishOptions);
71
92
 
72
93
  // Any journal left behind by a crashed writer is completed before the store
73
94
  // serves a single read, so callers never observe a half-published
@@ -103,7 +124,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
103
124
  * these transactions make reads as "nothing conflicts" when it finds nothing.
104
125
  * Declaring nothing reads everything, which is what this always did.
105
126
  */
106
- async function transaction(callback, { kinds } = {}) {
127
+ async function transaction(callback, { kinds, deadlineAt } = {}) {
107
128
  const wanted = kinds === undefined ? null : new Set(kinds);
108
129
  const declared = kind => {
109
130
  if (wanted !== null && !wanted.has(kind)) {
@@ -113,7 +134,8 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
113
134
  }
114
135
  return kind;
115
136
  };
116
- return withWriterMutex(paths, publishOptions, async () => {
137
+ return withWriterMutex(paths, { ...publishOptions, deadlineAt }, async () => {
138
+ assertBeforePublication(deadlineAt);
117
139
  // Reads are loaded once per transaction so get, list, and the generation
118
140
  // that put() compares against all describe the same instant.
119
141
  const loaded = await loadAllState(paths, root, wanted);
@@ -167,7 +189,9 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
167
189
  throw new AccError(EXIT.CONFLICT, `${kind} ${id} changed under this transaction`,
168
190
  { kind, id, expectedGeneration, actualGeneration: actual });
169
191
  }
170
- 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 });
171
195
  },
172
196
  append(event) {
173
197
  const stamped = { ...event, sequence: pad(sequence) };
@@ -190,7 +214,8 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
190
214
  replace: false,
191
215
  })),
192
216
  ...[...staged.values()].map(entry => (entry.removed === true
193
- ? { 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))
194
219
  : {
195
220
  path: path.relative(root, statePath(paths, entry.kind, entry.id)),
196
221
  bytes: encode(stateEnvelope(entry.kind, entry.id, entry.generation, entry.record)),
@@ -199,6 +224,11 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
199
224
  ];
200
225
  if (publications.length === 0) return result;
201
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);
202
232
  const entry = journalEntry(ids.next("transaction"), firstSequence, publications,
203
233
  clock.now());
204
234
  await writeJournalEntry(paths, publishOptions, entry);
@@ -213,7 +243,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
213
243
  // An open journal marks a transaction that is decided but not fully
214
244
  // published. Bounding the page below its first sequence is what keeps a
215
245
  // partially published transaction invisible to every reader.
216
- const ceiling = (await readOpenJournals(paths, root)).at(0)?.firstSequence ?? null;
246
+ const ceiling = await readJournalCeiling(paths, root);
217
247
  const events = [];
218
248
  for (const filePath of await listJsonFiles(paths.events, { root })) {
219
249
  const sequence = path.basename(filePath, ".json");
@@ -221,7 +251,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
221
251
  if (ceiling !== null && sequence >= ceiling) break;
222
252
  const found = await readJsonIfPresent(filePath, root);
223
253
  if (found === null) continue;
224
- const event = assertEventBinding(found.value, filePath);
254
+ const event = validateRecord("event", assertEventBinding(found.value, filePath));
225
255
  if (event.workspaceId !== workspace) continue;
226
256
  events.push(event);
227
257
  if (events.length === limit) break;
@@ -251,37 +281,65 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
251
281
  participants: await of("participant"),
252
282
  sessions: await of("session"),
253
283
  intents: await of("intent"),
254
- workstreams: await of("workstream"),
255
- tasks: await of("task"),
256
284
  claims: await of("claim"),
257
285
  messages: await of("message"),
258
286
  receipts: await of("receipt"),
259
- decisions: await of("decision"),
260
- handoffs: await of("handoff"),
261
287
  };
262
288
  }
263
-
264
289
  // Ephemeral records are published by replace and never journalled: they carry
265
- // no history, append no events, and are expected to disappear.
266
- 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
+ };
267
305
  const ephemeral = Object.freeze({
268
306
  async get(kind, id) {
269
- const found = await readJsonIfPresent(ephemeralPath(kind, id), root);
270
- return found?.value ?? null;
307
+ return readEphemeral(kind, id);
271
308
  },
272
309
  async put(kind, id, record) {
273
- await publishAtomic(ephemeralPath(kind, id), encode(record),
274
- { root, tmpDir: paths.tmp, replace: true });
275
- return record;
310
+ validateRecord(kind, record);
311
+ return withWriterMutex(paths, publishOptions, async () => {
312
+ await publishAtomic(ephemeralPath(kind, id), encode(record),
313
+ { root, tmpDir: paths.tmp, replace: true });
314
+ await markEphemeral(paths, publishOptions, kind, id, "present");
315
+ return record;
316
+ });
317
+ },
318
+ async update(kind, id, updater) {
319
+ return withWriterMutex(paths, publishOptions, async () => {
320
+ const next = await updater(await readEphemeral(kind, id));
321
+ if (next === null) return null;
322
+ validateRecord(kind, next);
323
+ await publishAtomic(ephemeralPath(kind, id), encode(next),
324
+ { root, tmpDir: paths.tmp, replace: true });
325
+ await markEphemeral(paths, publishOptions, kind, id, "present");
326
+ return next;
327
+ });
276
328
  },
277
329
  async delete(kind, id) {
278
- await 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
+ });
279
336
  },
280
337
  async list(kind) {
281
338
  const records = [];
282
- for (const filePath of await listJsonFiles(path.join(paths.ephemeral, kind), { root })) {
339
+ for (const filePath of await listJsonFiles(ephemeralDirectory(kind), { root })) {
283
340
  const found = await readJsonIfPresent(filePath, root);
284
- 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));
285
343
  }
286
344
  return records;
287
345
  },
@@ -1,18 +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";
5
+ import { performance } from "node:perf_hooks";
4
6
 
5
7
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
6
8
 
7
- import { encode, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
9
+ import { encode, readJsonIfPresent } from "./atomic-json.mjs";
8
10
  import { ensureManagedDirectory } from "./safe-directory.mjs";
11
+ import { withRegularNoFollow } from "./safe-file.mjs";
9
12
 
10
13
  const STALE_MS = 60_000;
14
+ const ACQUIRE_TIMEOUT_MS = 2_500;
11
15
  const OWNER = "owner.json";
16
+ const sleepFor = duration => new Promise(resolve => { setTimeout(resolve, duration); });
12
17
 
13
- // Directory creation is the atomic primitive: mkdir either creates or fails
14
- // with EEXIST, with no window in between. Ported from the reconciled
15
- // 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.
16
21
  function defaultPidIsAlive(pid) {
17
22
  try {
18
23
  process.kill(pid, 0);
@@ -22,13 +27,59 @@ function defaultPidIsAlive(pid) {
22
27
  }
23
28
  }
24
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
+
25
76
  /**
26
77
  * Who holds the lock, or nothing if it moved while we looked.
27
78
  *
28
79
  * Reads inside the store refuse a parent directory whose identity changed
29
80
  * between the check and the open - the defence against a directory being
30
81
  * swapped under a read. This lock is the one directory whose entire life is
31
- * 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
32
83
  * changes every time it passes from one process to the next. Reading its owner
33
84
  * through the strict path meant a contended lock raised
34
85
  * "record parent directory changed while opening" and the whole command failed -
@@ -79,36 +130,79 @@ async function takeStaleOwnership(directory, root, owner, now, pidIsAlive) {
79
130
  if (owner === null) return false;
80
131
  const age = Date.parse(now) - Date.parse(owner.acquiredAt);
81
132
  if (pidIsAlive(owner.pid) && !(age > STALE_MS)) return false;
82
- await rm(directory, { recursive: true, force: true });
83
- 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
+ }
84
149
  }
85
150
 
86
151
  export async function withWriterMutex(paths, options, operation) {
87
152
  const { root, clock, pidIsAlive = defaultPidIsAlive, uuid = randomUUID,
88
- attempts = 50, waitMs = 20, openFile } = options;
153
+ attempts = Number.POSITIVE_INFINITY, waitMs = 20, openFile,
154
+ acquireTimeoutMs = ACQUIRE_TIMEOUT_MS, monotonicNow = () => performance.now(),
155
+ wallNow = Date.now, deadlineAt, sleep = sleepFor } = options;
89
156
  const directory = path.join(paths.locks, "writer.lock");
157
+ // Wall time can jump while a process waits. A monotonic absolute deadline
158
+ // bounds all owner reads and retries, leaving half the hook's five-second
159
+ // budget for publishing the owner, doing the write, and rendering a result.
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);
90
164
  await ensureManagedDirectory(root, paths.locks);
91
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;
92
169
 
93
- for (let attempt = 0; attempt < attempts; attempt += 1) {
94
- try {
95
- await mkdir(directory);
96
- } catch (error) {
97
- if (error.code !== "EEXIST") throw error;
98
- const owner = await readOwner(directory, root, openFile);
99
- if (!await takeStaleOwnership(directory, root, owner, clock.now(), pidIsAlive)) {
100
- await new Promise(resolve => { setTimeout(resolve, waitMs); });
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;
101
190
  }
102
- continue;
103
- }
104
- const owner = { pid: process.pid, token, acquiredAt: clock.now() };
105
- await publishAtomic(path.join(directory, OWNER), encode(owner), { root, tmpDir: paths.tmp });
106
- try {
191
+ if (monotonicNow() >= deadline) break;
107
192
  return await operation();
108
- } finally {
109
- const current = await readOwner(directory, root, openFile);
110
- 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
+ }
111
202
  }
112
203
  }
113
- 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 });
114
208
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.1.17",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Local-first coordination for independently opened AI agent sessions.",
6
6
  "keywords": [
@@ -14,6 +14,7 @@
14
14
  "claude-code",
15
15
  "gemini-cli",
16
16
  "kimi",
17
+ "grok",
17
18
  "multi-agent",
18
19
  "cli"
19
20
  ],
@@ -44,7 +45,23 @@
44
45
  },
45
46
  "files": [
46
47
  "bin/",
48
+ "docs/ADAPTER_AUTHORING.md",
49
+ "docs/ARCHITECTURE.md",
47
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",
48
65
  "README.md",
49
66
  "LICENSE"
50
67
  ],
@@ -52,10 +69,12 @@
52
69
  "@agents-can-communicate/adapter-claude-code": "*",
53
70
  "@agents-can-communicate/adapter-codex": "*",
54
71
  "@agents-can-communicate/adapter-gemini-cli": "*",
72
+ "@agents-can-communicate/adapter-grok": "*",
55
73
  "@agents-can-communicate/adapter-kimi": "*",
56
74
  "@agents-can-communicate/adapter-sdk": "*",
57
75
  "@agents-can-communicate/cli": "*",
58
76
  "@agents-can-communicate/core": "*",
77
+ "@agents-can-communicate/delivery-router": "*",
59
78
  "@agents-can-communicate/hook-runner": "*",
60
79
  "@agents-can-communicate/installer": "*",
61
80
  "@agents-can-communicate/mcp-server": "*",
@@ -66,10 +85,12 @@
66
85
  "@agents-can-communicate/adapter-claude-code",
67
86
  "@agents-can-communicate/adapter-codex",
68
87
  "@agents-can-communicate/adapter-gemini-cli",
88
+ "@agents-can-communicate/adapter-grok",
69
89
  "@agents-can-communicate/adapter-kimi",
70
90
  "@agents-can-communicate/adapter-sdk",
71
91
  "@agents-can-communicate/cli",
72
92
  "@agents-can-communicate/core",
93
+ "@agents-can-communicate/delivery-router",
73
94
  "@agents-can-communicate/hook-runner",
74
95
  "@agents-can-communicate/installer",
75
96
  "@agents-can-communicate/mcp-server",