agents-can-communicate 0.1.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 (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +176 -0
  3. package/bin/acc-hook.mjs +53 -0
  4. package/bin/acc-mcp.mjs +46 -0
  5. package/bin/acc.mjs +24 -0
  6. package/docs/CAPABILITIES.md +153 -0
  7. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +13 -0
  8. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.claude-plugin/plugin.json +5 -0
  9. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/hooks/hooks.json +56 -0
  10. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +217 -0
  11. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +64 -0
  12. package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +106 -0
  13. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +278 -0
  14. package/node_modules/@agents-can-communicate/adapter-codex/package.json +13 -0
  15. package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +17 -0
  16. package/node_modules/@agents-can-communicate/adapter-codex/plugin/hooks.json +55 -0
  17. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +217 -0
  18. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +81 -0
  19. package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +133 -0
  20. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +235 -0
  21. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +6 -0
  22. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/hooks/hooks.json +69 -0
  23. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +217 -0
  24. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +13 -0
  25. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +74 -0
  26. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/hooks.mjs +109 -0
  27. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +149 -0
  28. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +13 -0
  29. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/.kimi-plugin/plugin.json +9 -0
  30. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +217 -0
  31. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +73 -0
  32. package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +125 -0
  33. package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +216 -0
  34. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +12 -0
  35. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +101 -0
  36. package/node_modules/@agents-can-communicate/adapter-sdk/src/config-merge.mjs +215 -0
  37. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +217 -0
  38. package/node_modules/@agents-can-communicate/adapter-sdk/src/events.mjs +55 -0
  39. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +148 -0
  40. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +16 -0
  41. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +95 -0
  42. package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-block.mjs +71 -0
  43. package/node_modules/@agents-can-communicate/cli/package.json +12 -0
  44. package/node_modules/@agents-can-communicate/cli/src/args.mjs +163 -0
  45. package/node_modules/@agents-can-communicate/cli/src/claim-spelling.mjs +79 -0
  46. package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +149 -0
  47. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +85 -0
  48. package/node_modules/@agents-can-communicate/cli/src/git-probe.mjs +41 -0
  49. package/node_modules/@agents-can-communicate/cli/src/help.mjs +78 -0
  50. package/node_modules/@agents-can-communicate/cli/src/index.mjs +10 -0
  51. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +107 -0
  52. package/node_modules/@agents-can-communicate/cli/src/main.mjs +344 -0
  53. package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +109 -0
  54. package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +58 -0
  55. package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +126 -0
  56. package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +151 -0
  57. package/node_modules/@agents-can-communicate/core/package.json +12 -0
  58. package/node_modules/@agents-can-communicate/core/src/claims.mjs +180 -0
  59. package/node_modules/@agents-can-communicate/core/src/communication.mjs +334 -0
  60. package/node_modules/@agents-can-communicate/core/src/index.mjs +7 -0
  61. package/node_modules/@agents-can-communicate/core/src/intents.mjs +75 -0
  62. package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +86 -0
  63. package/node_modules/@agents-can-communicate/core/src/notify.mjs +95 -0
  64. package/node_modules/@agents-can-communicate/core/src/participants.mjs +48 -0
  65. package/node_modules/@agents-can-communicate/core/src/ports.mjs +56 -0
  66. package/node_modules/@agents-can-communicate/core/src/service.mjs +44 -0
  67. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +190 -0
  68. package/node_modules/@agents-can-communicate/core/src/status.mjs +132 -0
  69. package/node_modules/@agents-can-communicate/core/src/sync.mjs +273 -0
  70. package/node_modules/@agents-can-communicate/core/src/tasks.mjs +238 -0
  71. package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +109 -0
  72. package/node_modules/@agents-can-communicate/hook-runner/package.json +12 -0
  73. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +379 -0
  74. package/node_modules/@agents-can-communicate/installer/package.json +10 -0
  75. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +58 -0
  76. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +79 -0
  77. package/node_modules/@agents-can-communicate/installer/src/index.mjs +6 -0
  78. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +162 -0
  79. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +62 -0
  80. package/node_modules/@agents-can-communicate/mcp-server/package.json +12 -0
  81. package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +58 -0
  82. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +250 -0
  83. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +201 -0
  84. package/node_modules/@agents-can-communicate/protocol/package.json +12 -0
  85. package/node_modules/@agents-can-communicate/protocol/src/config.mjs +151 -0
  86. package/node_modules/@agents-can-communicate/protocol/src/envelopes.mjs +25 -0
  87. package/node_modules/@agents-can-communicate/protocol/src/errors.mjs +30 -0
  88. package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +103 -0
  89. package/node_modules/@agents-can-communicate/protocol/src/ids.mjs +25 -0
  90. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +9 -0
  91. package/node_modules/@agents-can-communicate/protocol/src/resources.mjs +74 -0
  92. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +175 -0
  93. package/node_modules/@agents-can-communicate/protocol/src/states.mjs +55 -0
  94. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +12 -0
  95. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +135 -0
  96. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +67 -0
  97. package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +4 -0
  98. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +87 -0
  99. package/node_modules/@agents-can-communicate/storage-filesystem/src/record-id.mjs +44 -0
  100. package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +114 -0
  101. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +80 -0
  102. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +55 -0
  103. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +292 -0
  104. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +87 -0
  105. package/package.json +81 -0
@@ -0,0 +1,175 @@
1
+ import { AccError, EXIT } from "./errors.mjs";
2
+ import { flag, id, invalid, listOf, nullable, oneOf, plainObject, positiveInteger,
3
+ resourceUri, sequence, text, timestamp } from "./fields.mjs";
4
+
5
+ export const SCHEMA_VERSION = 1;
6
+
7
+ const line = text();
8
+ const prose = text({ max: 4000, multiline: true });
9
+ const summary = text({ max: 280 });
10
+ const artifactKind = oneOf("file", "git", "url", "report", "image", "data");
11
+
12
+ /** @typedef {{ sequence: string, eventId: string, workspaceId: string,
13
+ * actorSessionId: string, type: string, occurredAt: string, payload: object }} AccEvent */
14
+ /** @typedef {{ cursor: string, events: AccEvent[] }} EventPage */
15
+ /** @typedef {{ kind: string, priority: number, sourceId: string, summary: string }} AttentionItem */
16
+ /** @typedef {{ workspace: object, participants: object[], sessions: object[],
17
+ * intents: object[], workstreams: object[], tasks: object[], claims: object[] }} WorkspaceSnapshot */
18
+
19
+ // Field names stay mappable to the A2A Agent Card, Task, Message, and Artifact
20
+ // concepts (spec section 11) without importing any A2A transport.
21
+ const artifactRef = (value, field) => {
22
+ plainObject(value, field);
23
+ const known = new Set(["kind", "uri", "description", "sha256"]);
24
+ for (const key of Object.keys(value)) {
25
+ if (!known.has(key)) invalid(`${field}.${key}`, "is not a known artifact field", value[key]);
26
+ }
27
+ artifactKind(value.kind, `${field}.kind`);
28
+ resourceUri(value.uri, `${field}.uri`);
29
+ line(value.description, `${field}.description`);
30
+ if (value.sha256 !== undefined && !/^[a-f0-9]{64}$/.test(value.sha256)) {
31
+ invalid(`${field}.sha256`, "must be a lowercase SHA-256 digest", value.sha256);
32
+ }
33
+ return value;
34
+ };
35
+
36
+ // Every event ACC itself appends. Closed on purpose: `type` used to be free
37
+ // text, so a record written by hand validated cleanly and `acc doctor` called
38
+ // the store healthy. That is not theoretical - a session that could not run the
39
+ // CLI wrote its own events, inventing `task.completed`, and the store reported
40
+ // no problem.
41
+ const EVENT_TYPES = Object.freeze([
42
+ "workspace.materialised",
43
+ "session.opened", "session.closed",
44
+ "intent.published", "intent.cleared",
45
+ "workstream.created", "workstream.coordinator_acquired",
46
+ "workstream.coordinator_released",
47
+ "task.created", "task.claimed", "task.transitioned", "task.unblocked",
48
+ "task.declined", "task.released",
49
+ "claim.acquired", "claim.released", "claim.renewed", "claim.force_released",
50
+ "message.sent", "decision.recorded", "handoff.created",
51
+ // Delivery transitions and request outcomes are templated from their state,
52
+ // so the set has to carry each one they can produce.
53
+ ...["recorded", "queued", "injected", "seen", "acknowledged", "failed"]
54
+ .map(state => `message.${state}`),
55
+ ...["accepted", "declined", "review", "done", "released"]
56
+ .map(outcome => `work.${outcome}`),
57
+ "work.requested",
58
+ ]);
59
+
60
+ const eventType = oneOf(...EVENT_TYPES);
61
+
62
+ const RECORDS = Object.freeze({
63
+ workspace: { workspaceId: id, displayName: line, source: oneOf("config", "git", "directory"),
64
+ roots: listOf(line), createdAt: timestamp },
65
+
66
+ participant: { participantId: id, workspaceId: id, displayName: line,
67
+ kind: oneOf("agent", "human"), createdAt: timestamp },
68
+
69
+ // `enforcement` and `lifecycle` are what this session's harness can actually
70
+ // do, declared at attach. The harness name does not imply them: the same
71
+ // client guards or does not depending on its model and its approval mode, and
72
+ // a peer deciding whether to rely on a claim needs the answer, not the brand.
73
+ // A workspace spans every worktree of one repository, so the workspace id
74
+ // cannot say which checkout a session is sitting in. Recorded at attach from
75
+ // what discovery already resolved: without it nobody can tell which worktrees
76
+ // have an owner, and asking cannot answer for the agents that are not running
77
+ // - which are exactly the ones a clean-up is looking for.
78
+ session: { sessionId: id, participantId: id, workspaceId: id, generation: id,
79
+ harness: line, state: oneOf("open", "closed"), parentSessionId: nullable(id),
80
+ checkoutRoot: nullable(line), branch: nullable(line),
81
+ enforcement: oneOf("guarded", "advisory"), lifecycle: oneOf("managed", "manual"),
82
+ heartbeatCadenceMs: positiveInteger, startedAt: timestamp, heartbeatAt: timestamp },
83
+
84
+ intent: { sessionId: id, workspaceId: id, summary,
85
+ mode: oneOf("observe", "explore", "edit", "review", "coordinate", "wait"),
86
+ resourceHints: listOf(resourceUri), workstreamId: nullable(id),
87
+ state: oneOf("active", "blocked", "waiting", "done"), updatedAt: timestamp },
88
+
89
+ workstream: { workstreamId: id, workspaceId: id, title: line, objective: prose,
90
+ coordinatorSessionId: nullable(id),
91
+ state: oneOf("open", "paused", "complete", "cancelled"), createdAt: timestamp },
92
+
93
+ // Two assignees, deliberately. `assigneeParticipantId` is who the work is
94
+ // for and survives that agent restarting; `assigneeSessionId` is the exact
95
+ // session doing it right now and dies with the process. Asking one field to
96
+ // be both would either lose the request when a terminal closes or claim a
97
+ // dead session is still working.
98
+ task: { taskId: id, workstreamId: nullable(id), workspaceId: id, title: line,
99
+ state: oneOf("pending", "in_progress", "review", "done", "blocked"),
100
+ assigneeParticipantId: nullable(id), assigneeSessionId: nullable(id),
101
+ // Who asked. Without it nothing could tell the requester that their work
102
+ // was accepted, declined or finished - the task knew who it was for and
103
+ // had no idea who was waiting on it.
104
+ requestedByParticipantId: nullable(id),
105
+ dependsOn: listOf(id), acceptance: listOf(line), detail: nullable(prose),
106
+ createdAt: timestamp },
107
+
108
+ claim: { claimId: id, workspaceId: id, ownerSessionId: id, resource: resourceUri,
109
+ mode: oneOf("shared", "exclusive"), enforcement: oneOf("advisory", "guarded"),
110
+ reason: line, acquiredAt: timestamp, expiresAt: timestamp, generation: id },
111
+
112
+ // `fromParticipantId` beside the session: a session ends, and the one fact
113
+ // that has to outlive it is who was speaking. Resolving the sender by looking
114
+ // its session up meant the record could never be retired, and an agent whose
115
+ // client had restarted stopped being told about its own unanswered question.
116
+ message: { messageId: id, workspaceId: id, fromSessionId: id,
117
+ fromParticipantId: id, toParticipantIds: listOf(id),
118
+ type: oneOf("note", "question", "answer", "contract_request", "contract_response",
119
+ "decision_proposal", "decision_result", "blocker", "review_request",
120
+ "review_result", "handoff", "work_request", "work_response"),
121
+ subject: line, body: prose, priority: oneOf("low", "normal", "high", "urgent"),
122
+ workstreamId: nullable(id), taskId: nullable(id), inReplyTo: nullable(id),
123
+ requiresAck: flag, artifacts: listOf(artifactRef), sentAt: timestamp },
124
+
125
+ receipt: { messageId: id, workspaceId: id, recipientParticipantId: id,
126
+ state: oneOf("recorded", "queued", "injected", "seen", "acknowledged", "failed"),
127
+ updatedAt: timestamp },
128
+
129
+ decision: { decisionId: id, workspaceId: id, workstreamId: nullable(id), title: line,
130
+ outcome: prose, authority: oneOf("human", "workstream", "policy"),
131
+ decidedBy: listOf(id), evidence: listOf(artifactRef), supersedes: nullable(id),
132
+ decidedAt: timestamp },
133
+
134
+ artifact: { kind: artifactKind, uri: resourceUri, description: line },
135
+
136
+ handoff: { handoffId: id, workspaceId: id, fromSessionId: id, toParticipantId: nullable(id),
137
+ goal: line, status: oneOf("complete", "partial", "blocked"), completed: listOf(line),
138
+ remaining: listOf(line), blockers: listOf(line), claimsToRelease: listOf(resourceUri),
139
+ verification: listOf(artifactRef), artifacts: listOf(artifactRef), createdAt: timestamp },
140
+
141
+ event: { sequence, eventId: id, workspaceId: id, actorSessionId: id, type: eventType,
142
+ occurredAt: timestamp, payload: plainObject },
143
+ });
144
+
145
+ export const RECORD_KINDS = Object.freeze(Object.keys(RECORDS));
146
+
147
+ export function validateRecord(kind, value) {
148
+ const fields = RECORDS[kind];
149
+ if (fields === undefined) {
150
+ throw new AccError(EXIT.DATA, `unknown record kind: ${kind}`, { kind });
151
+ }
152
+ plainObject(value, kind);
153
+ if (value.schemaVersion !== SCHEMA_VERSION) {
154
+ throw new AccError(EXIT.DATA,
155
+ `${kind} has an unknown schemaVersion: ${String(value.schemaVersion)}`,
156
+ { kind, schemaVersion: value.schemaVersion });
157
+ }
158
+ for (const key of Object.keys(value)) {
159
+ if (key === "schemaVersion" || key === "extensions") continue;
160
+ if (!Object.hasOwn(fields, key)) {
161
+ invalid(`${kind}.${key}`, "is not a known field", value[key]);
162
+ }
163
+ }
164
+ for (const [field, check] of Object.entries(fields)) {
165
+ if (!Object.hasOwn(value, field)) {
166
+ throw new AccError(EXIT.DATA, `${kind} requires ${field}`, { kind, field });
167
+ }
168
+ check(value[field], field);
169
+ }
170
+ // Forward-compatible metadata is tolerated only inside a named container, so
171
+ // an older reader can round-trip a newer writer's record without guessing
172
+ // which unknown top-level keys are safe.
173
+ if (Object.hasOwn(value, "extensions")) plainObject(value.extensions, "extensions");
174
+ return value;
175
+ }
@@ -0,0 +1,55 @@
1
+ import { AccError, EXIT } from "./errors.mjs";
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"]);
9
+
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"],
15
+ acknowledged: [],
16
+ failed: [],
17
+ });
18
+
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)}`,
33
+ { current, next });
34
+ }
35
+ if (!machine.includes(next)) {
36
+ throw new AccError(EXIT.DATA, `unknown ${label} state: ${String(next)}`,
37
+ { current, next });
38
+ }
39
+ // Re-declaring the current state is idempotent. Adapters retry at safe
40
+ // points, and a repeated receipt is not a protocol violation.
41
+ if (current === next) return next;
42
+ if (!allowed[current].includes(next)) {
43
+ throw new AccError(EXIT.CONFLICT,
44
+ `illegal ${label} transition from ${current} to ${next}`, { current, next });
45
+ }
46
+ return next;
47
+ }
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
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@agents-can-communicate/storage-filesystem",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.mjs"
8
+ },
9
+ "files": [
10
+ "src/"
11
+ ]
12
+ }
@@ -0,0 +1,135 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { link, open, readdir, rename, unlink } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { AccError, EXIT } from "@agents-can-communicate/protocol";
6
+
7
+ import { assertManagedDirectory, ensureManagedDirectory } from "./safe-directory.mjs";
8
+ import { readRegularNoFollow } from "./safe-file.mjs";
9
+
10
+ async function syncDirectory(directory) {
11
+ const handle = await open(directory, "r");
12
+ try {
13
+ await handle.sync();
14
+ } finally {
15
+ await handle.close();
16
+ }
17
+ }
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
+ export function encode(value) {
28
+ const serialised = JSON.stringify(value, null, 2);
29
+ if (serialised === undefined) {
30
+ throw new AccError(EXIT.DATA, "record is not JSON serializable", { value: typeof value });
31
+ }
32
+ return Buffer.from(`${serialised}\n`, "utf8");
33
+ }
34
+
35
+ async function bytesIfPresent(filePath, root) {
36
+ try {
37
+ return await readRegularNoFollow(filePath, root);
38
+ } catch (error) {
39
+ if (error.code === "ENOENT") return null;
40
+ throw error;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Publish bytes atomically.
46
+ *
47
+ * Two modes, and the distinction matters: immutable evidence - events, journal
48
+ * entries, audits - is published by link(), so overwriting is impossible by
49
+ * construction rather than by a check that could race, and re-publishing
50
+ * identical bytes is idempotent while different bytes fail closed. Materialised
51
+ * state is mutable by design, which is what generations exist for, so it is
52
+ * published by rename(). Conflating the two makes every state update fail.
53
+ *
54
+ * @returns {Promise<"published" | "already_published">}
55
+ */
56
+ export async function publishAtomic(destination, bytes, { root, tmpDir, replace = false }) {
57
+ const destinationDir = path.dirname(destination);
58
+ await Promise.all([
59
+ ensureManagedDirectory(root, tmpDir),
60
+ ensureManagedDirectory(root, destinationDir),
61
+ ]);
62
+ const temporary = path.join(tmpDir, `${path.basename(destination)}.${process.pid}.${randomUUID()}.tmp`);
63
+ const handle = await open(temporary, "wx");
64
+ try {
65
+ await handle.writeFile(bytes);
66
+ await handle.sync();
67
+ } finally {
68
+ await handle.close();
69
+ }
70
+ try {
71
+ if (replace) {
72
+ await rename(temporary, destination);
73
+ await syncDirectory(destinationDir);
74
+ return "published";
75
+ }
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 });
85
+ } finally {
86
+ await unlinkIfPresent(temporary);
87
+ }
88
+ }
89
+
90
+ export async function readJsonIfPresent(filePath, root) {
91
+ const bytes = await bytesIfPresent(filePath, root);
92
+ if (bytes === null) return null;
93
+ try {
94
+ return { value: JSON.parse(bytes.toString("utf8")), bytes };
95
+ } catch (error) {
96
+ // Named in the message, not only in the details: human mode prints the
97
+ // message alone, and "invalid JSON record" sent a reader looking through a
98
+ // whole workspace for a file the error already knew.
99
+ throw new AccError(EXIT.DATA, `invalid JSON record: ${filePath}`,
100
+ { filePath, cause: error.message });
101
+ }
102
+ }
103
+
104
+ export async function listDirectoryEntries(dirPath, { root, readDirectory = readdir } = {}) {
105
+ let before;
106
+ try {
107
+ before = await assertManagedDirectory(root, dirPath);
108
+ } catch (error) {
109
+ if (error.code === "ENOENT") return [];
110
+ throw error;
111
+ }
112
+ let entries;
113
+ try {
114
+ entries = await readDirectory(dirPath, { withFileTypes: true });
115
+ } catch (error) {
116
+ if (error.code === "ENOENT") return [];
117
+ throw error;
118
+ }
119
+ const after = await assertManagedDirectory(root, dirPath);
120
+ if (before.stat.dev !== after.stat.dev || before.stat.ino !== after.stat.ino) {
121
+ throw new AccError(EXIT.DATA, "managed directory changed while listing", { dirPath, root });
122
+ }
123
+ return entries;
124
+ }
125
+
126
+ export async function listJsonFiles(dirPath, options) {
127
+ return (await listDirectoryEntries(dirPath, options))
128
+ .filter(entry => entry.isFile() && entry.name.endsWith(".json"))
129
+ .map(entry => path.join(dirPath, entry.name))
130
+ .sort((left, right) => left.localeCompare(right));
131
+ }
132
+
133
+ export async function removeIfPresent(filePath) {
134
+ await unlinkIfPresent(filePath);
135
+ }
@@ -0,0 +1,67 @@
1
+ import path from "node:path";
2
+
3
+ import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protocol";
4
+
5
+ import { encode, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
6
+
7
+ export const STORE_VERSION = 1;
8
+
9
+ export function identityPath(paths) {
10
+ return path.join(paths.root, "protocol.json");
11
+ }
12
+
13
+ function assertIdentity(record, workspaceId, filePath) {
14
+ if (record?.storeVersion !== STORE_VERSION) {
15
+ throw new AccError(EXIT.DATA, "unknown store version", { filePath,
16
+ storeVersion: record?.storeVersion });
17
+ }
18
+ if (record.workspaceId !== workspaceId) {
19
+ throw new AccError(EXIT.DATA, "store belongs to a different workspace",
20
+ { filePath, expected: workspaceId, actual: record.workspaceId });
21
+ }
22
+ return record;
23
+ }
24
+
25
+ /**
26
+ * Establish or verify the store's identity before anything else happens.
27
+ * Opening a directory that already belongs to another workspace must fail
28
+ * rather than quietly adopt it: an initialisation that silently rewrites a
29
+ * foreign store is the failure the reconciled prototype fails closed on, and
30
+ * the same rule applies here.
31
+ */
32
+ export async function requireStoreIdentity(paths, { workspaceId, clock, create = true }) {
33
+ assertPortableId(workspaceId, "workspace id");
34
+ const filePath = identityPath(paths);
35
+ const found = await readJsonIfPresent(filePath, paths.root);
36
+ if (found !== null) return assertIdentity(found.value, workspaceId, filePath);
37
+ if (!create) {
38
+ throw new AccError(EXIT.DATA, "store is not initialised", { filePath });
39
+ }
40
+ const record = { storeVersion: STORE_VERSION, workspaceId, initialisedAt: clock.now() };
41
+ try {
42
+ await publishAtomic(filePath, encode(record), { root: paths.root, tmpDir: paths.tmp });
43
+ } catch (error) {
44
+ // Losing this race is not a failure. Two agents starting together in a
45
+ // workspace neither has opened before is the ordinary case, and the two
46
+ // identity documents they write differ in one field - the moment each was
47
+ // written - so the second was refused for "different bytes" and that agent
48
+ // could not attach at all.
49
+ //
50
+ // What the store has to refuse is a directory belonging to a *different*
51
+ // workspace, and that is decided below by reading what is actually there
52
+ // rather than by whose bytes arrived first.
53
+ if (error.code !== EXIT.CONFLICT) throw error;
54
+ }
55
+ const published = await readJsonIfPresent(filePath, paths.root);
56
+ return assertIdentity(published.value, workspaceId, filePath);
57
+ }
58
+
59
+ export async function readStoreIdentity(paths) {
60
+ const found = await readJsonIfPresent(identityPath(paths), paths.root);
61
+ if (found === null) return null;
62
+ if (found.value?.storeVersion !== STORE_VERSION) {
63
+ throw new AccError(EXIT.DATA, "unknown store version",
64
+ { filePath: identityPath(paths), storeVersion: found.value?.storeVersion });
65
+ }
66
+ return found.value;
67
+ }
@@ -0,0 +1,4 @@
1
+ // CoordinationStore backed by the reconciled filesystem protocol.
2
+ export { openFilesystemStore, storePaths, ZERO_CURSOR } from "./store.mjs";
3
+ export { diagnoseFilesystemStore, repairFilesystemStore } from "./recovery.mjs";
4
+ export { readStoreIdentity, requireStoreIdentity, STORE_VERSION } from "./identity.mjs";
@@ -0,0 +1,87 @@
1
+ import path from "node:path";
2
+
3
+ import { AccError, EXIT } from "@agents-can-communicate/protocol";
4
+
5
+ import { encode, listJsonFiles, publishAtomic, readJsonIfPresent, removeIfPresent }
6
+ from "./atomic-json.mjs";
7
+
8
+ export const JOURNAL_VERSION = 1;
9
+
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.
15
+ export function journalPath(paths, transactionId) {
16
+ return path.join(paths.journal, `${transactionId}.json`);
17
+ }
18
+
19
+ export function journalEntry(transactionId, firstSequence, publications, startedAt) {
20
+ return {
21
+ journalVersion: JOURNAL_VERSION,
22
+ transactionId,
23
+ firstSequence,
24
+ startedAt,
25
+ publications: publications.map(item => ({
26
+ 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"),
30
+ replace: item.replace === true,
31
+ remove: item.remove === true,
32
+ })),
33
+ };
34
+ }
35
+
36
+ export async function writeJournalEntry(paths, options, entry) {
37
+ await publishAtomic(journalPath(paths, entry.transactionId), encode(entry), options);
38
+ return entry;
39
+ }
40
+
41
+ export async function retireJournalEntry(paths, transactionId) {
42
+ await removeIfPresent(journalPath(paths, transactionId));
43
+ }
44
+
45
+ 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 });
58
+ }
59
+ entries.push(entry);
60
+ }
61
+ return entries.sort((left, right) => left.firstSequence.localeCompare(right.firstSequence));
62
+ }
63
+
64
+ // Publishing every listed file and then retiring the entry. Already-published
65
+ // identical bytes are accepted, differing bytes fail closed, so running this
66
+ // twice changes nothing and a genuine conflict is never papered over.
67
+ export async function rollForward(paths, options, entry) {
68
+ const published = [];
69
+ 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;
76
+ }
77
+ const bytes = Buffer.from(publication.bytes, "base64");
78
+ const outcome = await publishAtomic(destination, bytes,
79
+ { ...options, replace: publication.replace === true });
80
+ if (outcome === "published") published.push(publication.path);
81
+ // Seam for the crash-window tests: abort between two publications of the
82
+ // same decided transaction.
83
+ await options.failAt?.(`after:${publication.path}`);
84
+ }
85
+ await retireJournalEntry(paths, entry.transactionId);
86
+ return published;
87
+ }
@@ -0,0 +1,44 @@
1
+ import path from "node:path";
2
+
3
+ import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protocol";
4
+
5
+ // State files carry an envelope rather than the bare record. The envelope names
6
+ // the kind, the id, and the generation, so a file that is moved or renamed can
7
+ // be detected instead of being trusted because of where it happens to sit.
8
+ // This is the generalisation of the reconciled prototype's filename binding.
9
+ export function stateEnvelope(kind, id, generation, record) {
10
+ return { kind, id, generation, record };
11
+ }
12
+
13
+ export function statePath(paths, kind, id) {
14
+ assertPortableId(kind, "record kind");
15
+ assertPortableId(id, "record id");
16
+ return path.join(paths.state, kind, `${id}.json`);
17
+ }
18
+
19
+ export function assertStateBinding(envelope, kind, id, filePath) {
20
+ const shaped = envelope !== null && typeof envelope === "object"
21
+ && typeof envelope.kind === "string" && typeof envelope.id === "string"
22
+ && typeof envelope.generation === "string";
23
+ if (!shaped) {
24
+ throw new AccError(EXIT.DATA, "state record is not a valid envelope", { filePath });
25
+ }
26
+ if (envelope.kind !== kind || envelope.id !== id) {
27
+ throw new AccError(EXIT.DATA, "state record does not match its path",
28
+ { filePath, expected: { kind, id }, actual: { kind: envelope.kind, id: envelope.id } });
29
+ }
30
+ return envelope;
31
+ }
32
+
33
+ export function eventPath(paths, sequence) {
34
+ return path.join(paths.events, `${sequence}.json`);
35
+ }
36
+
37
+ export function assertEventBinding(event, filePath) {
38
+ const sequence = path.basename(filePath, ".json");
39
+ if (event?.sequence !== sequence) {
40
+ throw new AccError(EXIT.DATA, "event record does not match its filename",
41
+ { filePath, expected: sequence, actual: event?.sequence });
42
+ }
43
+ return event;
44
+ }