agents-can-communicate 0.1.18 → 0.3.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 (155) hide show
  1. package/README.md +87 -69
  2. package/SECURITY.md +31 -0
  3. package/bin/acc-bootstrap.mjs +56 -0
  4. package/bin/acc-claude-channel.mjs +177 -0
  5. package/bin/acc-hook.mjs +94 -12
  6. package/bin/acc-mcp.mjs +6 -2
  7. package/bin/acc.mjs +13 -3
  8. package/docs/ADAPTER_AUTHORING.md +204 -0
  9. package/docs/ARCHITECTURE.md +131 -0
  10. package/docs/CAPABILITIES.md +117 -214
  11. package/docs/CLI.md +164 -0
  12. package/docs/CONCEPTS.md +134 -0
  13. package/docs/CONFIGURATION.md +147 -0
  14. package/docs/DESIGN_DECISIONS.md +89 -0
  15. package/docs/GETTING_STARTED.md +145 -0
  16. package/docs/GLOSSARY.md +26 -0
  17. package/docs/HOW_IT_WORKS.md +277 -0
  18. package/docs/MCP.md +94 -0
  19. package/docs/PROTOCOL.md +200 -0
  20. package/docs/RELEASING.md +115 -0
  21. package/docs/SECURITY_MODEL.md +131 -0
  22. package/docs/TROUBLESHOOTING.md +108 -0
  23. package/docs/WHY_ACC.md +61 -0
  24. package/docs/index.md +44 -0
  25. package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +228 -0
  26. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse-Edit.json +19 -0
  27. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse.json +17 -0
  28. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionEnd.json +8 -0
  29. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionStart.json +7 -0
  30. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/UserPromptSubmit.json +9 -0
  31. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +269 -0
  32. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.252.json +21 -0
  33. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.258.json +23 -0
  34. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.260.json +23 -0
  35. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +13 -2
  36. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.mcp.json +8 -0
  37. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +22 -22
  38. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +45 -5
  39. package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +377 -0
  40. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +27 -7
  41. package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +229 -0
  42. package/node_modules/@agents-can-communicate/adapter-codex/certification.json +150 -0
  43. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/PreToolUse.json +14 -0
  44. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionEnd.json +7 -0
  45. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionStart.json +9 -0
  46. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/UserPromptSubmit.json +10 -0
  47. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +199 -0
  48. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.0.json +21 -0
  49. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-remote-workspace.json +25 -0
  50. package/node_modules/@agents-can-communicate/adapter-codex/package.json +11 -2
  51. package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +1 -1
  52. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +22 -22
  53. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +54 -12
  54. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +121 -0
  55. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +151 -0
  56. package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +192 -0
  57. package/node_modules/@agents-can-communicate/adapter-gemini-cli/certification.json +68 -0
  58. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  59. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +22 -22
  60. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeAgent-0.57.0.json +8 -0
  61. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-0.57.0.json +12 -0
  62. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-shell-0.57.0.json +12 -0
  63. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionEnd-0.57.0.json +8 -0
  64. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionStart-0.57.0.json +8 -0
  65. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/certification-provenance.json +293 -0
  66. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +8 -1
  67. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +31 -13
  68. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +4 -2
  69. package/node_modules/@agents-can-communicate/adapter-grok/certification.json +3 -0
  70. package/node_modules/@agents-can-communicate/adapter-grok/package.json +2 -1
  71. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +22 -22
  72. package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +11 -9
  73. package/node_modules/@agents-can-communicate/adapter-kimi/certification.json +52 -0
  74. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Bash.json +12 -0
  75. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Write.json +12 -0
  76. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionHeartbeat.json +7 -0
  77. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionStart.json +9 -0
  78. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/UserPromptSubmit.json +8 -0
  79. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/certification-provenance.json +66 -0
  80. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +8 -1
  81. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +22 -22
  82. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +7 -3
  83. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  84. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +52 -18
  85. package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +158 -0
  86. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +36 -17
  87. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +9 -1
  88. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +7 -2
  89. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-activation.mjs +76 -0
  90. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +202 -0
  91. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +101 -0
  92. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +28 -4
  93. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  94. package/node_modules/@agents-can-communicate/cli/src/args.mjs +12 -31
  95. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +70 -5
  96. package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -5
  97. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +111 -12
  98. package/node_modules/@agents-can-communicate/cli/src/main.mjs +100 -121
  99. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  100. package/node_modules/@agents-can-communicate/core/src/attention.mjs +106 -0
  101. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +276 -0
  102. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +131 -0
  103. package/node_modules/@agents-can-communicate/core/src/finish-retries.mjs +97 -0
  104. package/node_modules/@agents-can-communicate/core/src/inbox.mjs +91 -107
  105. package/node_modules/@agents-can-communicate/core/src/index.mjs +3 -3
  106. package/node_modules/@agents-can-communicate/core/src/intents.mjs +0 -1
  107. package/node_modules/@agents-can-communicate/core/src/ports.mjs +2 -1
  108. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +109 -0
  109. package/node_modules/@agents-can-communicate/core/src/service.mjs +21 -10
  110. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +22 -20
  111. package/node_modules/@agents-can-communicate/core/src/status.mjs +11 -9
  112. package/node_modules/@agents-can-communicate/core/src/sync.mjs +3 -294
  113. package/node_modules/@agents-can-communicate/delivery-router/package.json +12 -0
  114. package/node_modules/@agents-can-communicate/delivery-router/src/index.mjs +1 -0
  115. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +131 -0
  116. package/node_modules/@agents-can-communicate/hook-runner/package.json +4 -2
  117. package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +20 -0
  118. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +90 -0
  119. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +190 -105
  120. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  121. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +70 -10
  122. package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +144 -0
  123. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +89 -5
  124. package/node_modules/@agents-can-communicate/installer/src/index.mjs +10 -2
  125. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +161 -0
  126. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +112 -12
  127. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +54 -2
  128. package/node_modules/@agents-can-communicate/installer/src/shell-bootstrap.mjs +210 -0
  129. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  130. package/node_modules/@agents-can-communicate/mcp-server/src/input-validator.mjs +79 -0
  131. package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +23 -28
  132. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +102 -72
  133. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +54 -97
  134. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  135. package/node_modules/@agents-can-communicate/protocol/src/config.mjs +1 -1
  136. package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +64 -0
  137. package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +17 -0
  138. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +4 -1
  139. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +64 -90
  140. package/node_modules/@agents-can-communicate/protocol/src/states.mjs +13 -40
  141. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  142. package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +230 -0
  143. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +77 -28
  144. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +1 -1
  145. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +83 -35
  146. package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +112 -0
  147. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +18 -8
  148. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +68 -26
  149. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +113 -35
  150. package/package.json +20 -1
  151. package/node_modules/@agents-can-communicate/core/src/communication.mjs +0 -334
  152. package/node_modules/@agents-can-communicate/core/src/message-signals.mjs +0 -41
  153. package/node_modules/@agents-can-communicate/core/src/notify.mjs +0 -95
  154. package/node_modules/@agents-can-communicate/core/src/tasks.mjs +0 -244
  155. package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +0 -109
@@ -1,55 +1,28 @@
1
1
  import { AccError, EXIT } from "./errors.mjs";
2
2
 
3
- // recorded -> queued -> injected -> seen -> acknowledged, with failed branching
4
- // off before the message was ever exposed. States are monotonic: one
5
- // recipient's receipt can only move forwards, and never rewrites another
6
- // recipient's state.
7
- export const DELIVERY_STATES = Object.freeze(
8
- ["recorded", "queued", "injected", "seen", "acknowledged", "failed"]);
3
+ export const RECEIPT_STATES = Object.freeze(
4
+ ["queued", "offered", "retrieved", "acknowledged"]);
9
5
 
10
- const DELIVERY_NEXT = Object.freeze({
11
- recorded: ["queued", "injected", "seen", "acknowledged", "failed"],
12
- queued: ["injected", "seen", "acknowledged", "failed"],
13
- injected: ["seen", "acknowledged"],
14
- seen: ["acknowledged"],
6
+ const RECEIPT_NEXT = Object.freeze({
7
+ queued: ["offered", "retrieved", "acknowledged"],
8
+ offered: ["retrieved", "acknowledged"],
9
+ retrieved: ["acknowledged"],
15
10
  acknowledged: [],
16
- failed: [],
17
11
  });
18
12
 
19
- export const TASK_STATES = Object.freeze(
20
- ["pending", "in_progress", "review", "done", "blocked"]);
21
-
22
- const TASK_NEXT = Object.freeze({
23
- pending: ["in_progress", "blocked"],
24
- in_progress: ["review", "done", "blocked"],
25
- review: ["in_progress", "done", "blocked"],
26
- blocked: ["pending", "in_progress"],
27
- done: [],
28
- });
29
-
30
- function step(machine, allowed, label, current, next) {
31
- if (!machine.includes(current)) {
32
- throw new AccError(EXIT.DATA, `unknown ${label} state: ${String(current)}`,
13
+ export function advanceReceipt(current, next) {
14
+ if (!RECEIPT_STATES.includes(current)) {
15
+ throw new AccError(EXIT.DATA, `unknown receipt state: ${String(current)}`,
33
16
  { current, next });
34
17
  }
35
- if (!machine.includes(next)) {
36
- throw new AccError(EXIT.DATA, `unknown ${label} state: ${String(next)}`,
18
+ if (!RECEIPT_STATES.includes(next)) {
19
+ throw new AccError(EXIT.DATA, `unknown receipt state: ${String(next)}`,
37
20
  { current, next });
38
21
  }
39
- // Re-declaring the current state is idempotent. Adapters retry at safe
40
- // points, and a repeated receipt is not a protocol violation.
41
22
  if (current === next) return next;
42
- if (!allowed[current].includes(next)) {
23
+ if (!RECEIPT_NEXT[current].includes(next)) {
43
24
  throw new AccError(EXIT.CONFLICT,
44
- `illegal ${label} transition from ${current} to ${next}`, { current, next });
25
+ `illegal receipt transition from ${current} to ${next}`, { current, next });
45
26
  }
46
27
  return next;
47
28
  }
48
-
49
- export function advanceDelivery(current, next) {
50
- return step(DELIVERY_STATES, DELIVERY_NEXT, "delivery", current, next);
51
- }
52
-
53
- export function transitionTask(current, next) {
54
- return step(TASK_STATES, TASK_NEXT, "task", current, next);
55
- }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/storage-filesystem",
3
- "version": "0.1.18",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,230 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protocol";
6
+
7
+ import { encode, publishAtomic } from "./atomic-json.mjs";
8
+ import { withRegularNoFollow } from "./safe-file.mjs";
9
+
10
+ const ACTIVE_JOURNAL_VERSION = 2;
11
+ const AUTHORITY_BYTES_LIMIT = 1024;
12
+ const GENERATION_WIDTH = 16;
13
+ const READ_ATTEMPTS = 4;
14
+
15
+ const data = (message, details) => {
16
+ throw new AccError(EXIT.DATA, message, details);
17
+ };
18
+
19
+ export function activeJournalPath(paths, slot) {
20
+ if (slot !== 0 && slot !== 1) data("invalid active journal slot", { slot });
21
+ return path.join(paths.journal, `active.${slot}`);
22
+ }
23
+
24
+ function exactKeys(value, expected, filePath) {
25
+ if (value === null || typeof value !== "object" || Array.isArray(value)
26
+ || Object.keys(value).sort().join("\0") !== [...expected].sort().join("\0")) {
27
+ data("active journal record is not closed", { filePath });
28
+ }
29
+ }
30
+
31
+ function checksum(record) {
32
+ return createHash("sha256").update(JSON.stringify(record)).digest("hex");
33
+ }
34
+
35
+ function encodeAuthority(record) {
36
+ return encode({ activeJournalChecksum: checksum(record), record });
37
+ }
38
+
39
+ function validateActiveRecord(record, filePath) {
40
+ const base = ["activeJournalVersion", "generation", "previousChecksum", "state"];
41
+ if (record?.state === "idle") exactKeys(record, base, filePath);
42
+ else if (record?.state === "open") {
43
+ exactKeys(record, [...base, "transactionId", "firstSequence"], filePath);
44
+ } else data("invalid active journal state", { filePath, state: record?.state });
45
+
46
+ if (record.activeJournalVersion !== ACTIVE_JOURNAL_VERSION) {
47
+ data("unknown active journal version", { filePath,
48
+ activeJournalVersion: record.activeJournalVersion });
49
+ }
50
+ if (typeof record.generation !== "string"
51
+ || !/^\d{16}$/.test(record.generation)) {
52
+ data("invalid active journal generation", { filePath, generation: record.generation });
53
+ }
54
+ const generation = BigInt(record.generation);
55
+ if (generation === 0n) {
56
+ if (record.state !== "idle" || record.previousChecksum !== null) {
57
+ data("invalid active journal genesis", { filePath });
58
+ }
59
+ } else if (typeof record.previousChecksum !== "string"
60
+ || !/^[0-9a-f]{64}$/.test(record.previousChecksum)) {
61
+ data("invalid active journal previous checksum", { filePath });
62
+ }
63
+ if (record.state === "open") {
64
+ assertPortableId(record.transactionId, "transaction id");
65
+ if (typeof record.firstSequence !== "string"
66
+ || !/^\d{16}$/.test(record.firstSequence)) {
67
+ data("invalid active journal first sequence", { filePath,
68
+ firstSequence: record.firstSequence });
69
+ }
70
+ }
71
+ return generation;
72
+ }
73
+
74
+ function decodeAuthority(bytes, filePath, slot) {
75
+ let envelope;
76
+ try {
77
+ envelope = JSON.parse(bytes.toString("utf8"));
78
+ } catch (error) {
79
+ data("invalid active journal authority", { filePath, cause: error.message });
80
+ }
81
+ exactKeys(envelope, ["activeJournalChecksum", "record"], filePath);
82
+ const generation = validateActiveRecord(envelope.record, filePath);
83
+ if (Number(generation % 2n) !== slot) {
84
+ data("active journal generation chain is invalid", { filePath,
85
+ generation: envelope.record.generation, slot });
86
+ }
87
+ if (typeof envelope.activeJournalChecksum !== "string"
88
+ || envelope.activeJournalChecksum !== checksum(envelope.record)) {
89
+ data("active journal checksum mismatch", { filePath });
90
+ }
91
+ if (!bytes.equals(encodeAuthority(envelope.record))) {
92
+ data("active journal authority is not canonical", { filePath });
93
+ }
94
+ return { checksum: envelope.activeJournalChecksum, generation,
95
+ record: envelope.record, slot };
96
+ }
97
+
98
+ async function readSlotBytes(paths, root, slot, openFile) {
99
+ const filePath = activeJournalPath(paths, slot);
100
+ try {
101
+ return await withRegularNoFollow(filePath, root, constants.O_RDONLY,
102
+ async (handle, stat) => {
103
+ if (stat.size < 1 || stat.size > AUTHORITY_BYTES_LIMIT) {
104
+ data("invalid active journal authority size", { filePath, size: stat.size });
105
+ }
106
+ return handle.readFile();
107
+ }, openFile);
108
+ } catch (error) {
109
+ if (error.code === "ENOENT") return null;
110
+ throw error;
111
+ }
112
+ }
113
+
114
+ function sameBytes(left, right) {
115
+ if (left === null || right === null) return left === right;
116
+ return left.equals(right);
117
+ }
118
+
119
+ // Slot zero is read twice. Because writers alternate slots and generations
120
+ // never repeat, equal reads prove that the pair existed at one instant even if
121
+ // another process published between descriptor opens.
122
+ async function readStableSlots(paths, root, openFile) {
123
+ for (let attempt = 0; attempt < READ_ATTEMPTS; attempt += 1) {
124
+ const slot0 = await readSlotBytes(paths, root, 0, openFile);
125
+ const slot1 = await readSlotBytes(paths, root, 1, openFile);
126
+ const slot0Confirmation = await readSlotBytes(paths, root, 0, openFile);
127
+ if (sameBytes(slot0, slot0Confirmation)) return [slot0, slot1];
128
+ }
129
+ throw new AccError(EXIT.CONFLICT, "active journal changed while being read", {});
130
+ }
131
+
132
+ function validateTransition(current, previous, filePath) {
133
+ const expected = previous.state === "idle" ? "open" : "idle";
134
+ if (current.state !== expected) {
135
+ data("invalid active journal transition", { filePath,
136
+ previous: previous.state, current: current.state });
137
+ }
138
+ }
139
+
140
+ function compareGenerations(left, right) {
141
+ if (left.generation < right.generation) return -1;
142
+ if (left.generation > right.generation) return 1;
143
+ return 0;
144
+ }
145
+
146
+ function selectAuthority(paths, bytes) {
147
+ // Decode every present slot before selection. Falling back from a corrupt
148
+ // latest slot to an older valid peer would turn corruption into rollback.
149
+ const found = bytes.map((value, slot) => value === null
150
+ ? null
151
+ : decodeAuthority(value, activeJournalPath(paths, slot), slot)).filter(Boolean);
152
+ if (found.length === 0) return null;
153
+ if (found.length === 1) {
154
+ const [only] = found;
155
+ if (only.slot === 0 && only.generation === 0n) return only;
156
+ data("active journal authority peer is missing", { slot: only.slot,
157
+ generation: only.record.generation });
158
+ }
159
+ found.sort(compareGenerations);
160
+ const [previous, current] = found;
161
+ if (current.generation !== previous.generation + 1n) {
162
+ data("active journal generation chain is invalid", {
163
+ previous: previous.record.generation, current: current.record.generation });
164
+ }
165
+ if (current.record.previousChecksum !== previous.checksum) {
166
+ data("active journal checksum chain is invalid", { slot: current.slot });
167
+ }
168
+ validateTransition(current.record, previous.record,
169
+ activeJournalPath(paths, current.slot));
170
+ return current;
171
+ }
172
+
173
+ async function readCurrent(paths, root, openFile) {
174
+ return selectAuthority(paths, await readStableSlots(paths, root, openFile));
175
+ }
176
+
177
+ const genesis = () => ({ activeJournalVersion: ACTIVE_JOURNAL_VERSION,
178
+ generation: "0".repeat(GENERATION_WIDTH), previousChecksum: null, state: "idle" });
179
+
180
+ export async function initialiseActiveJournal(paths, options) {
181
+ const current = await readCurrent(paths, options.root);
182
+ if (current !== null) return current.record;
183
+ try {
184
+ await publishAtomic(activeJournalPath(paths, 0), encodeAuthority(genesis()), options);
185
+ } catch (error) {
186
+ if (error.code !== EXIT.CONFLICT) throw error;
187
+ }
188
+ return readActiveJournal(paths, options.root);
189
+ }
190
+
191
+ export async function readActiveJournal(paths, root, openFile) {
192
+ const current = await readCurrent(paths, root, openFile);
193
+ if (current === null) data("active journal has no authority", { journal: paths.journal });
194
+ return current.record;
195
+ }
196
+
197
+ function nextGeneration(current) {
198
+ const next = current.generation + 1n;
199
+ const generation = next.toString().padStart(GENERATION_WIDTH, "0");
200
+ if (generation.length !== GENERATION_WIDTH) {
201
+ data("active journal generation is exhausted", { current: current.record.generation });
202
+ }
203
+ return generation;
204
+ }
205
+
206
+ async function appendTransition(paths, options, fields, expected) {
207
+ const current = await readCurrent(paths, options.root, options.openFile);
208
+ if (current === null || !expected(current.record)) {
209
+ throw new AccError(EXIT.CONFLICT, "active journal transition conflicts",
210
+ { current: current?.record ?? null, next: fields });
211
+ }
212
+ const record = { activeJournalVersion: ACTIVE_JOURNAL_VERSION,
213
+ generation: nextGeneration(current), previousChecksum: current.checksum, ...fields };
214
+ validateActiveRecord(record, activeJournalPath(paths, 1 - current.slot));
215
+ await publishAtomic(activeJournalPath(paths, 1 - current.slot), encodeAuthority(record),
216
+ { ...options, tmpDir: options.tmpDir ?? paths.tmp ?? path.join(options.root, "tmp"),
217
+ replace: true });
218
+ return record;
219
+ }
220
+
221
+ export function activateJournal(paths, options, entry) {
222
+ return appendTransition(paths, options, { state: "open",
223
+ transactionId: entry.transactionId, firstSequence: entry.firstSequence },
224
+ current => current.state === "idle");
225
+ }
226
+
227
+ export function idleJournal(paths, options, transactionId) {
228
+ return appendTransition(paths, options, { state: "idle" },
229
+ current => current.state === "open" && current.transactionId === transactionId);
230
+ }
@@ -1,5 +1,5 @@
1
- import { randomUUID } from "node:crypto";
2
- import { link, open, readdir, rename, unlink } from "node:fs/promises";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { link, open, readdir, rename } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
5
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
@@ -16,14 +16,6 @@ async function syncDirectory(directory) {
16
16
  }
17
17
  }
18
18
 
19
- async function unlinkIfPresent(filePath) {
20
- try {
21
- await unlink(filePath);
22
- } catch (error) {
23
- if (error.code !== "ENOENT") throw error;
24
- }
25
- }
26
-
27
19
  export function encode(value) {
28
20
  const serialised = JSON.stringify(value, null, 2);
29
21
  if (serialised === undefined) {
@@ -41,6 +33,23 @@ async function bytesIfPresent(filePath, root, openFile) {
41
33
  }
42
34
  }
43
35
 
36
+ function retainedStage(destination, root, tmpDir) {
37
+ const identity = createHash("sha256")
38
+ .update(path.relative(root, destination))
39
+ .digest("hex");
40
+ return path.join(tmpDir, `${identity}.published`);
41
+ }
42
+
43
+ async function replaceHandleBytes(handle, bytes) {
44
+ await handle.truncate(0);
45
+ let offset = 0;
46
+ while (offset < bytes.length) {
47
+ const { bytesWritten } = await handle.write(bytes, offset, bytes.length - offset, offset);
48
+ offset += bytesWritten;
49
+ }
50
+ await handle.sync();
51
+ }
52
+
44
53
  /**
45
54
  * Publish bytes atomically.
46
55
  *
@@ -59,31 +68,64 @@ export async function publishAtomic(destination, bytes, { root, tmpDir, replace
59
68
  ensureManagedDirectory(root, tmpDir),
60
69
  ensureManagedDirectory(root, destinationDir),
61
70
  ]);
62
- const temporary = path.join(tmpDir, `${path.basename(destination)}.${process.pid}.${randomUUID()}.tmp`);
63
- const handle = await open(temporary, "wx");
71
+ if (!replace) {
72
+ const existing = await bytesIfPresent(destination, root);
73
+ if (existing !== null) {
74
+ if (existing.equals(bytes)) return "already_published";
75
+ throw new AccError(EXIT.CONFLICT, "record already published with different bytes",
76
+ { destination });
77
+ }
78
+ }
79
+
80
+ const stage = retainedStage(destination, root, tmpDir);
81
+ const temporary = `${stage}.${process.pid}.${randomUUID()}.tmp`;
82
+ let handle = await open(temporary, "wx");
83
+ let stageAcceptedBytes = false;
64
84
  try {
65
85
  await handle.writeFile(bytes);
66
86
  await handle.sync();
67
- } finally {
68
- await handle.close();
69
- }
70
- try {
71
87
  if (replace) {
88
+ await handle.close();
89
+ handle = null;
72
90
  await rename(temporary, destination);
73
91
  await syncDirectory(destinationDir);
74
92
  return "published";
75
93
  }
76
- await link(temporary, destination);
77
- await syncDirectory(destinationDir);
78
- return "published";
79
- } catch (error) {
80
- if (error.code !== "EEXIST") throw error;
81
- const existing = await bytesIfPresent(destination, root);
82
- if (existing !== null && existing.equals(bytes)) return "already_published";
83
- throw new AccError(EXIT.CONFLICT, "record already published with different bytes",
84
- { destination });
94
+
95
+ try {
96
+ await link(temporary, destination);
97
+ stageAcceptedBytes = true;
98
+ await syncDirectory(destinationDir);
99
+ return "published";
100
+ } catch (error) {
101
+ if (error.code !== "EEXIST") throw error;
102
+ const existing = await bytesIfPresent(destination, root);
103
+ if (existing !== null && existing.equals(bytes)) {
104
+ stageAcceptedBytes = true;
105
+ return "already_published";
106
+ }
107
+ if (existing !== null) {
108
+ // This caller lost a different-payload race. Keep no rejected bytes:
109
+ // rewrite its still-open private inode with the accepted destination
110
+ // before consolidating every contender onto one retained stage name.
111
+ await replaceHandleBytes(handle, existing);
112
+ stageAcceptedBytes = true;
113
+ }
114
+ throw new AccError(EXIT.CONFLICT, "record already published with different bytes",
115
+ { destination });
116
+ }
85
117
  } finally {
86
- await unlinkIfPresent(temporary);
118
+ await handle?.close();
119
+ if (stageAcceptedBytes) {
120
+ await assertManagedDirectory(root, tmpDir);
121
+ await rename(temporary, stage);
122
+ await syncDirectory(tmpDir);
123
+ } else {
124
+ // A crash/error before immutable acceptance keeps its unique partial.
125
+ // Retention avoids the unsafe parent-check/unlink pathname window, while
126
+ // the deterministic accepted stage bounds all ordinary retries.
127
+ await retainFile(temporary, { root });
128
+ }
87
129
  }
88
130
  }
89
131
 
@@ -132,6 +174,13 @@ export async function listJsonFiles(dirPath, options) {
132
174
  .sort((left, right) => left.localeCompare(right));
133
175
  }
134
176
 
135
- export async function removeIfPresent(filePath) {
136
- await unlinkIfPresent(filePath);
177
+ // Node exposes unlink only by pathname: directory handles cannot be passed to
178
+ // unlinkat, so checking a parent and then unlinking still lets an adversary
179
+ // replace that parent in between. Retention is the safe primitive. Callers
180
+ // publish an append-only logical marker after this validation and never unlink
181
+ // the retained path. The callback is the deterministic race seam.
182
+ export async function retainFile(filePath, { root, afterValidation } = {}) {
183
+ await assertManagedDirectory(root, path.dirname(filePath));
184
+ await afterValidation?.();
185
+ return "retained";
137
186
  }
@@ -4,7 +4,7 @@ import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protoc
4
4
 
5
5
  import { encode, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
6
6
 
7
- export const STORE_VERSION = 2;
7
+ export const STORE_VERSION = 6;
8
8
 
9
9
  export function identityPath(paths) {
10
10
  return path.join(paths.root, "protocol.json");
@@ -1,21 +1,51 @@
1
1
  import path from "node:path";
2
2
 
3
- import { AccError, EXIT } from "@agents-can-communicate/protocol";
3
+ import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protocol";
4
4
 
5
- import { encode, listJsonFiles, publishAtomic, readJsonIfPresent, removeIfPresent }
5
+ import { activateJournal, idleJournal, readActiveJournal } from "./active-journal.mjs";
6
+ import { encode, publishAtomic, readJsonIfPresent, retainFile }
6
7
  from "./atomic-json.mjs";
8
+ import { completeJournal } from "./retention.mjs";
7
9
 
8
- export const JOURNAL_VERSION = 1;
10
+ export const JOURNAL_VERSION = 2;
9
11
 
10
- // A journal entry is written only after the transaction callback has succeeded
11
- // and every byte is known. Its existence therefore means "this transaction was
12
- // decided", which is what makes roll-forward - rather than rollback - the
13
- // correct recovery. Roll-forward is idempotent because publication is
14
- // no-replace and identical bytes are accepted as already published.
12
+ // A journal entry is prepared only after the transaction callback has
13
+ // succeeded and every byte is known. The subsequent synced active-log record
14
+ // is the commit point: an orphan prepared journal is not a decided
15
+ // transaction. Roll-forward is idempotent because identical immutable bytes
16
+ // are accepted and materialised state carries generations.
15
17
  export function journalPath(paths, transactionId) {
18
+ assertPortableId(transactionId, "transaction id");
16
19
  return path.join(paths.journal, `${transactionId}.json`);
17
20
  }
18
21
 
22
+ function publicationDestination(root, publicationPath) {
23
+ if (typeof publicationPath !== "string" || publicationPath === ""
24
+ || path.isAbsolute(publicationPath) || path.win32.isAbsolute(publicationPath)) {
25
+ throw new AccError(EXIT.DATA, "journal publication path must be relative",
26
+ { publicationPath });
27
+ }
28
+ const segments = publicationPath.split(/[\\/]/);
29
+ if (segments.some(segment => segment === "" || segment === "." || segment === "..")) {
30
+ throw new AccError(EXIT.DATA, "journal publication path is not managed",
31
+ { publicationPath });
32
+ }
33
+ const durablePublication = segments[0] === "events" || segments[0] === "state";
34
+ const stateRetention = segments[0] === "retained" && segments[1] === "state";
35
+ if (!(durablePublication || stateRetention)) {
36
+ throw new AccError(EXIT.DATA, "journal publication path is not managed",
37
+ { publicationPath });
38
+ }
39
+ const destination = path.resolve(root, ...segments);
40
+ const relative = path.relative(root, destination);
41
+ if (relative === "" || path.isAbsolute(relative) || relative === ".."
42
+ || relative.startsWith(`..${path.sep}`)) {
43
+ throw new AccError(EXIT.DATA, "journal publication path escapes the store root",
44
+ { publicationPath, root });
45
+ }
46
+ return destination;
47
+ }
48
+
19
49
  export function journalEntry(transactionId, firstSequence, publications, startedAt) {
20
50
  return {
21
51
  journalVersion: JOURNAL_VERSION,
@@ -24,41 +54,61 @@ export function journalEntry(transactionId, firstSequence, publications, started
24
54
  startedAt,
25
55
  publications: publications.map(item => ({
26
56
  path: item.path,
27
- // A removal is journalled like any other publication, so a crash between
28
- // two deletions replays to the same end state rather than a partial one.
29
- bytes: item.remove === true ? null : item.bytes.toString("base64"),
57
+ bytes: item.bytes.toString("base64"),
30
58
  replace: item.replace === true,
31
- remove: item.remove === true,
59
+ retainedPath: item.retainedPath ?? null,
32
60
  })),
33
61
  };
34
62
  }
35
63
 
36
64
  export async function writeJournalEntry(paths, options, entry) {
37
65
  await publishAtomic(journalPath(paths, entry.transactionId), encode(entry), options);
66
+ await options.failAt?.("after-journal-prepared");
67
+ await activateJournal(paths, options, entry);
38
68
  return entry;
39
69
  }
40
70
 
41
- export async function retireJournalEntry(paths, transactionId) {
42
- await removeIfPresent(journalPath(paths, transactionId));
71
+ export async function retireJournalEntry(paths, options, transactionId) {
72
+ await retainFile(journalPath(paths, transactionId), { root: paths.root });
73
+ await completeJournal(paths, options, transactionId);
74
+ await options.failAt?.("before-journal-idle");
75
+ await idleJournal(paths, options, transactionId);
43
76
  }
44
77
 
45
78
  export async function readOpenJournals(paths, root) {
46
- const files = await listJsonFiles(paths.journal, { root });
47
- const entries = [];
48
- for (const file of files) {
49
- const found = await readJsonIfPresent(file, root);
50
- if (found === null) continue;
51
- const entry = found.value;
52
- if (entry?.journalVersion !== JOURNAL_VERSION) {
53
- throw new AccError(EXIT.DATA, "unknown journal version", { file,
54
- journalVersion: entry?.journalVersion });
55
- }
56
- if (path.basename(file, ".json") !== entry.transactionId) {
57
- throw new AccError(EXIT.DATA, "journal entry does not match its filename", { file });
79
+ const active = await readActiveJournal(paths, root);
80
+ if (active.state === "idle") return [];
81
+ const file = journalPath(paths, active.transactionId);
82
+ const found = await readJsonIfPresent(file, root);
83
+ if (found === null) {
84
+ throw new AccError(EXIT.DATA, "active journal entry is missing", { file });
85
+ }
86
+ const entry = found.value;
87
+ if (entry?.journalVersion !== JOURNAL_VERSION) {
88
+ throw new AccError(EXIT.DATA, "unknown journal version", { file,
89
+ journalVersion: entry?.journalVersion });
90
+ }
91
+ if (path.basename(file, ".json") !== entry.transactionId
92
+ || entry.transactionId !== active.transactionId
93
+ || entry.firstSequence !== active.firstSequence) {
94
+ throw new AccError(EXIT.DATA, "journal entry does not match its active record", { file });
95
+ }
96
+ assertPortableId(entry.transactionId, "transaction id");
97
+ if (!Array.isArray(entry.publications)) {
98
+ throw new AccError(EXIT.DATA, "journal publications must be an array", { file });
99
+ }
100
+ for (const publication of entry.publications) {
101
+ publicationDestination(root, publication?.path);
102
+ if (publication?.retainedPath !== null) {
103
+ publicationDestination(root, publication?.retainedPath);
58
104
  }
59
- entries.push(entry);
60
105
  }
61
- return entries.sort((left, right) => left.firstSequence.localeCompare(right.firstSequence));
106
+ return [entry];
107
+ }
108
+
109
+ export async function readJournalCeiling(paths, root) {
110
+ const active = await readActiveJournal(paths, root);
111
+ return active.state === "open" ? active.firstSequence : null;
62
112
  }
63
113
 
64
114
  // Publishing every listed file and then retiring the entry. Already-published
@@ -67,12 +117,10 @@ export async function readOpenJournals(paths, root) {
67
117
  export async function rollForward(paths, options, entry) {
68
118
  const published = [];
69
119
  for (const publication of entry.publications) {
70
- const destination = path.resolve(options.root, publication.path);
71
- if (publication.remove === true) {
72
- await removeIfPresent(destination);
73
- published.push(publication.path);
74
- await options.failAt?.(`after:${publication.path}`);
75
- continue;
120
+ const destination = publicationDestination(options.root, publication.path);
121
+ if (publication.retainedPath !== null) {
122
+ await retainFile(publicationDestination(options.root, publication.retainedPath),
123
+ { root: options.root });
76
124
  }
77
125
  const bytes = Buffer.from(publication.bytes, "base64");
78
126
  const outcome = await publishAtomic(destination, bytes,
@@ -82,6 +130,6 @@ export async function rollForward(paths, options, entry) {
82
130
  // same decided transaction.
83
131
  await options.failAt?.(`after:${publication.path}`);
84
132
  }
85
- await retireJournalEntry(paths, entry.transactionId);
133
+ await retireJournalEntry(paths, options, entry.transactionId);
86
134
  return published;
87
135
  }