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,114 @@
1
+ import path from "node:path";
2
+
3
+ import { AccError, EXIT, validateRecord } from "@agents-can-communicate/protocol";
4
+
5
+ import { listDirectoryEntries, listJsonFiles, readJsonIfPresent } from "./atomic-json.mjs";
6
+ import { readStoreIdentity } from "./identity.mjs";
7
+ import { readOpenJournals, rollForward } from "./journal.mjs";
8
+ import { assertEventBinding, assertStateBinding } from "./record-id.mjs";
9
+ import { storePaths } from "./store.mjs";
10
+ import { withWriterMutex } from "./writer-mutex.mjs";
11
+
12
+ /** @typedef {{ healthy: boolean, repaired: string[], blocked: string[],
13
+ * corrupt: string[] }} RepairReport */
14
+
15
+ const report = ({ repaired = [], blocked = [], corrupt = [] }) => ({
16
+ healthy: blocked.length === 0 && corrupt.length === 0,
17
+ repaired: [...repaired].sort(),
18
+ blocked: [...blocked].sort(),
19
+ corrupt: [...corrupt].sort(),
20
+ });
21
+
22
+ async function inspect(root) {
23
+ const paths = storePaths(root);
24
+ const blocked = [];
25
+ const corrupt = [];
26
+ let pending = [];
27
+
28
+ // Identity first. A store whose version or identity does not validate is
29
+ // never mutated, so an operator repairing the wrong directory fails closed
30
+ // instead of rewriting someone else's state.
31
+ let identity = null;
32
+ try {
33
+ identity = await readStoreIdentity(paths);
34
+ } catch (error) {
35
+ blocked.push(path.join(root, "protocol.json"));
36
+ return { paths, identity: null, pending, blocked, corrupt, reason: error.message };
37
+ }
38
+ if (identity === null) blocked.push(path.join(root, "protocol.json"));
39
+
40
+ try {
41
+ pending = await readOpenJournals(paths, root);
42
+ } catch (error) {
43
+ corrupt.push(paths.journal);
44
+ return { paths, identity, pending: [], blocked, corrupt, reason: error.message };
45
+ }
46
+
47
+ for (const kind of (await listDirectoryEntries(paths.state, { root }))
48
+ .filter(entry => entry.isDirectory()).map(entry => entry.name)) {
49
+ for (const filePath of await listJsonFiles(path.join(paths.state, kind), { root })) {
50
+ try {
51
+ const found = await readJsonIfPresent(filePath, root);
52
+ const envelope = assertStateBinding(found.value, kind,
53
+ path.basename(filePath, ".json"), filePath);
54
+ validateRecord(envelope.kind, envelope.record);
55
+ } catch {
56
+ corrupt.push(filePath);
57
+ }
58
+ }
59
+ }
60
+
61
+ let expected = 1;
62
+ for (const filePath of await listJsonFiles(paths.events, { root })) {
63
+ try {
64
+ const found = await readJsonIfPresent(filePath, root);
65
+ const event = assertEventBinding(found.value, filePath);
66
+ validateRecord("event", event);
67
+ // A gap can only survive a completed roll-forward if a file was removed
68
+ // by hand, so it is corruption rather than a pending publication.
69
+ if (Number(event.sequence) !== expected && pending.length === 0) corrupt.push(filePath);
70
+ expected = Number(event.sequence) + 1;
71
+ } catch {
72
+ corrupt.push(filePath);
73
+ }
74
+ }
75
+
76
+ return { paths, identity, pending, blocked, corrupt, reason: null };
77
+ }
78
+
79
+ export async function diagnoseFilesystemStore({ root }) {
80
+ const state = await inspect(root);
81
+ return report({
82
+ blocked: state.blocked,
83
+ corrupt: state.corrupt,
84
+ repaired: state.pending.map(entry => entry.transactionId),
85
+ });
86
+ }
87
+
88
+ export async function repairFilesystemStore({ root, clock }) {
89
+ const state = await inspect(root);
90
+ if (state.blocked.length > 0 || state.corrupt.length > 0) {
91
+ // Fail closed. Completing a journal on top of state we cannot even read
92
+ // would turn an ambiguous store into a confidently wrong one.
93
+ return report({ blocked: state.blocked, corrupt: state.corrupt });
94
+ }
95
+ if (state.pending.length === 0) return report({});
96
+
97
+ const repaired = await withWriterMutex(state.paths, { root, tmpDir: state.paths.tmp, clock },
98
+ async () => {
99
+ const completed = [];
100
+ for (const entry of await readOpenJournals(state.paths, root)) {
101
+ await rollForward(state.paths, { root, tmpDir: state.paths.tmp, clock }, entry);
102
+ completed.push(entry.transactionId);
103
+ }
104
+ return completed;
105
+ });
106
+ return report({ repaired });
107
+ }
108
+
109
+ export function assertRepairable(reportValue) {
110
+ if (!reportValue.healthy) {
111
+ throw new AccError(EXIT.DATA, "store state is ambiguous; repair is blocked", reportValue);
112
+ }
113
+ return reportValue;
114
+ }
@@ -0,0 +1,80 @@
1
+ import { lstat, mkdir, realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { AccError, EXIT } from "@agents-can-communicate/protocol";
5
+
6
+ // Ported from the reconciled prototype without semantic change. Every managed
7
+ // path is validated segment by segment against the canonical root, so a
8
+ // symlinked ancestor cannot redirect a read or a publication outside the store.
9
+ function invalidDirectory(message, directory, root, cause) {
10
+ return new AccError(EXIT.DATA, message, {
11
+ directory,
12
+ root,
13
+ ...(cause === undefined ? {} : { cause }),
14
+ });
15
+ }
16
+
17
+ function relativeWithin(root, directory) {
18
+ const relative = path.relative(root, directory);
19
+ if (relative === "" || (!path.isAbsolute(relative)
20
+ && relative !== ".." && !relative.startsWith(`..${path.sep}`))) return relative;
21
+ throw invalidDirectory("managed directory escapes the store root", directory, root);
22
+ }
23
+
24
+ function absolutePath(value, name, root = value) {
25
+ if (typeof value !== "string" || !path.isAbsolute(value)) {
26
+ throw invalidDirectory(`${name} must be absolute`, value, root);
27
+ }
28
+ return path.normalize(value);
29
+ }
30
+
31
+ async function inspectRealDirectory(directory, root, create) {
32
+ let details;
33
+ try {
34
+ details = await lstat(directory);
35
+ } catch (error) {
36
+ if (error.code === "ENOENT" && !create) throw error;
37
+ if (error.code !== "ENOENT") {
38
+ throw invalidDirectory("cannot inspect managed directory", directory, root, error.message);
39
+ }
40
+ try {
41
+ await mkdir(directory);
42
+ } catch (mkdirError) {
43
+ if (mkdirError.code !== "EEXIST") {
44
+ throw invalidDirectory("cannot create managed directory", directory, root,
45
+ mkdirError.message);
46
+ }
47
+ }
48
+ details = await lstat(directory);
49
+ }
50
+ if (!details.isDirectory() || details.isSymbolicLink()) {
51
+ throw invalidDirectory("managed directory is not a real directory", directory, root);
52
+ }
53
+ return details;
54
+ }
55
+
56
+ async function inspectManagedDirectory(rootPath, directoryPath, create) {
57
+ const root = absolutePath(rootPath, "managed root");
58
+ const directory = absolutePath(directoryPath, "managed directory", root);
59
+ const relative = relativeWithin(root, directory);
60
+ let details = await inspectRealDirectory(root, root, create);
61
+ const canonicalRoot = await realpath(root);
62
+ let current = root;
63
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
64
+ current = path.join(current, segment);
65
+ details = await inspectRealDirectory(current, root, create);
66
+ const expected = path.join(canonicalRoot, path.relative(root, current));
67
+ if (await realpath(current) !== expected) {
68
+ throw invalidDirectory("managed directory escapes the canonical store root", current, root);
69
+ }
70
+ }
71
+ return { directory, stat: details };
72
+ }
73
+
74
+ export async function assertManagedDirectory(rootPath, directoryPath) {
75
+ return inspectManagedDirectory(rootPath, directoryPath, false);
76
+ }
77
+
78
+ export async function ensureManagedDirectory(rootPath, directoryPath) {
79
+ return (await inspectManagedDirectory(rootPath, directoryPath, true)).directory;
80
+ }
@@ -0,0 +1,55 @@
1
+ import { constants } from "node:fs";
2
+ import { open } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { AccError, EXIT } from "@agents-can-communicate/protocol";
6
+
7
+ import { assertManagedDirectory } from "./safe-directory.mjs";
8
+
9
+ function sameDirectory(left, right) {
10
+ return left.dev === right.dev && left.ino === right.ino;
11
+ }
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) {
18
+ const parent = path.dirname(filePath);
19
+ const before = await assertManagedDirectory(root, parent);
20
+ let handle;
21
+ try {
22
+ handle = await openFile(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
23
+ } catch (error) {
24
+ if (error.code === "ENOENT") throw error;
25
+ throw new AccError(EXIT.DATA, "cannot safely open regular file",
26
+ { filePath, cause: error.message });
27
+ }
28
+ try {
29
+ const stat = await handle.stat();
30
+ if (!stat.isFile()) {
31
+ throw new AccError(EXIT.DATA, "record is not a regular file", { filePath });
32
+ }
33
+ const after = await assertManagedDirectory(root, parent);
34
+ if (!sameDirectory(before.stat, after.stat)) {
35
+ throw new AccError(EXIT.DATA, "record parent directory changed while opening",
36
+ { filePath, root });
37
+ }
38
+ return await handle.readFile();
39
+ } catch (error) {
40
+ if (error instanceof AccError || error.code === "ENOENT") throw error;
41
+ throw new AccError(EXIT.DATA, "cannot safely read regular file",
42
+ { filePath, cause: error.message });
43
+ } finally {
44
+ await handle.close();
45
+ }
46
+ }
47
+
48
+ export async function readJsonNoFollow(filePath, root, openFile = open) {
49
+ const bytes = await readRegularNoFollow(filePath, root, openFile);
50
+ try {
51
+ return { value: JSON.parse(bytes.toString("utf8")), bytes };
52
+ } catch (error) {
53
+ throw new AccError(EXIT.DATA, "invalid JSON record", { filePath, cause: error.message });
54
+ }
55
+ }
@@ -0,0 +1,292 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { AccError, EXIT, validateRecord } from "@agents-can-communicate/protocol";
5
+
6
+ import { encode, listDirectoryEntries, listJsonFiles, publishAtomic, readJsonIfPresent,
7
+ removeIfPresent } from "./atomic-json.mjs";
8
+ import { requireStoreIdentity } from "./identity.mjs";
9
+ import { journalEntry, readOpenJournals, rollForward, writeJournalEntry } from "./journal.mjs";
10
+ import { assertEventBinding, assertStateBinding, eventPath, stateEnvelope, statePath }
11
+ from "./record-id.mjs";
12
+ import { ensureManagedDirectory } from "./safe-directory.mjs";
13
+ import { withWriterMutex } from "./writer-mutex.mjs";
14
+
15
+ const SEQUENCE_WIDTH = 16;
16
+ export const ZERO_CURSOR = "0".repeat(SEQUENCE_WIDTH);
17
+ // No quarantine area. One was created in every workspace, named in the path
18
+ // typedef, and written to by nothing: repair deliberately refuses to move a
19
+ // corrupt record, so nothing ever had a reason to put one aside. An empty
20
+ // directory that reads as a feature is the same mistake as an attention kind
21
+ // with no rule behind it. If quarantining is ever built, it comes back with it.
22
+ const DIRECTORIES = ["state", "events", "journal", "locks", "ephemeral", "tmp"];
23
+
24
+ const pad = value => String(value).padStart(SEQUENCE_WIDTH, "0");
25
+
26
+ export function storePaths(root) {
27
+ return Object.freeze(Object.fromEntries([["root", root],
28
+ ...DIRECTORIES.map(name => [name, path.join(root, name)])]));
29
+ }
30
+
31
+ async function listState(paths, root, kind) {
32
+ const envelopes = [];
33
+ for (const filePath of await listJsonFiles(path.join(paths.state, kind), { root })) {
34
+ const found = await readJsonIfPresent(filePath, root);
35
+ if (found === null) continue;
36
+ envelopes.push(assertStateBinding(found.value, kind,
37
+ path.basename(filePath, ".json"), filePath));
38
+ }
39
+ return envelopes;
40
+ }
41
+
42
+ async function loadAllState(paths, root, wanted = null) {
43
+ const loaded = new Map();
44
+ const kinds = (await listDirectoryEntries(paths.state, { root }))
45
+ .filter(entry => entry.isDirectory()).map(entry => entry.name)
46
+ .filter(kind => wanted === null || wanted.has(kind));
47
+ for (const kind of kinds) {
48
+ for (const envelope of await listState(paths, root, kind)) {
49
+ loaded.set(`${envelope.kind}:${envelope.id}`, envelope);
50
+ }
51
+ }
52
+ return loaded;
53
+ }
54
+
55
+ async function nextSequence(paths, root) {
56
+ const last = (await listJsonFiles(paths.events, { root })).at(-1);
57
+ return last === undefined ? 1 : Number(path.basename(last, ".json")) + 1;
58
+ }
59
+
60
+ export async function openFilesystemStore({ root, clock, ids, workspaceId, failAt }) {
61
+ const paths = storePaths(root);
62
+ // The caller owns the root path, so its ancestors are created here. Inside the
63
+ // root, containment rules apply and each level is created individually so a
64
+ // symlinked ancestor cannot be created past.
65
+ await mkdir(root, { recursive: true });
66
+ for (const name of DIRECTORIES) await ensureManagedDirectory(root, paths[name]);
67
+ // Identity is settled before any read or write. Adopting a directory that
68
+ // already belongs to another workspace is the failure this fails closed on.
69
+ await requireStoreIdentity(paths, { workspaceId, clock });
70
+ const publishOptions = { root, tmpDir: paths.tmp, clock, failAt };
71
+
72
+ // Any journal left behind by a crashed writer is completed before the store
73
+ // serves a single read, so callers never observe a half-published
74
+ // transaction even on the first open after a crash. Recovery is a write, so
75
+ // it holds the writer mutex: two processes opening the same store at once
76
+ // must not roll the same journal forward concurrently.
77
+ await recoverOpenJournals();
78
+
79
+ async function recoverOpenJournals() {
80
+ const open = await readOpenJournals(paths, root);
81
+ if (open.length === 0) return [];
82
+ return withWriterMutex(paths, { root, tmpDir: paths.tmp, clock }, async () => {
83
+ const completed = [];
84
+ for (const entry of await readOpenJournals(paths, root)) {
85
+ completed.push(...await rollForward(paths, { root, tmpDir: paths.tmp, clock }, entry));
86
+ }
87
+ return completed;
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Run a write, having read the kinds it declared and no others.
93
+ *
94
+ * A transaction used to read every record the workspace held, for the
95
+ * generation checks `put` makes. That put the cost of `acc message` in
96
+ * proportion to everything the workspace already contained - 400 messages
97
+ * written one after another took 163 seconds, the last of them half a second
98
+ * each - and some of these transactions run inside hooks, where the budget is
99
+ * five seconds and running out means failing open.
100
+ *
101
+ * `kinds` is enforced, not merely honoured: reaching for an undeclared kind
102
+ * throws. A silent empty list would be the worst of both, since every check
103
+ * these transactions make reads as "nothing conflicts" when it finds nothing.
104
+ * Declaring nothing reads everything, which is what this always did.
105
+ */
106
+ async function transaction(callback, { kinds } = {}) {
107
+ const wanted = kinds === undefined ? null : new Set(kinds);
108
+ const declared = kind => {
109
+ if (wanted !== null && !wanted.has(kind)) {
110
+ throw new AccError(EXIT.DATA,
111
+ `this transaction did not declare ${kind}, so it was never read`,
112
+ { kind, declared: [...wanted] });
113
+ }
114
+ return kind;
115
+ };
116
+ return withWriterMutex(paths, publishOptions, async () => {
117
+ // Reads are loaded once per transaction so get, list, and the generation
118
+ // that put() compares against all describe the same instant.
119
+ const loaded = await loadAllState(paths, root, wanted);
120
+ const staged = new Map();
121
+ const events = [];
122
+ let sequence = await nextSequence(paths, root);
123
+ const firstSequence = pad(sequence);
124
+ const entryFor = key => {
125
+ const entry = staged.get(key) ?? loaded.get(key) ?? null;
126
+ return entry?.removed === true ? null : entry;
127
+ };
128
+
129
+ const tx = Object.freeze({
130
+ get(kind, id) {
131
+ declared(kind);
132
+ return entryFor(`${kind}:${id}`)?.record ?? null;
133
+ },
134
+ generationOf(kind, id) {
135
+ declared(kind);
136
+ return entryFor(`${kind}:${id}`)?.generation ?? null;
137
+ },
138
+ list(kind, predicate = () => true) {
139
+ declared(kind);
140
+ const merged = new Map();
141
+ for (const source of [loaded, staged]) {
142
+ for (const [key, entry] of source) {
143
+ if (entry.kind === kind) merged.set(key, entry);
144
+ }
145
+ }
146
+ return [...merged.values()].filter(entry => entry.removed !== true)
147
+ .map(entry => entry.record).filter(predicate);
148
+ },
149
+ put(kind, id, record, expectedGeneration = null) {
150
+ declared(kind);
151
+ const key = `${kind}:${id}`;
152
+ const actual = entryFor(key)?.generation ?? null;
153
+ if (actual !== expectedGeneration) {
154
+ throw new AccError(EXIT.CONFLICT, `${kind} ${id} changed under this transaction`,
155
+ { kind, id, expectedGeneration, actualGeneration: actual });
156
+ }
157
+ validateRecord(kind, record);
158
+ const generation = ids.next("generation");
159
+ staged.set(key, { kind, id, record, generation });
160
+ return generation;
161
+ },
162
+ remove(kind, id, expectedGeneration = null) {
163
+ declared(kind);
164
+ const key = `${kind}:${id}`;
165
+ const actual = entryFor(key)?.generation ?? null;
166
+ if (actual !== expectedGeneration) {
167
+ throw new AccError(EXIT.CONFLICT, `${kind} ${id} changed under this transaction`,
168
+ { kind, id, expectedGeneration, actualGeneration: actual });
169
+ }
170
+ staged.set(key, { kind, id, removed: true });
171
+ },
172
+ append(event) {
173
+ const stamped = { ...event, sequence: pad(sequence) };
174
+ sequence += 1;
175
+ validateRecord("event", stamped);
176
+ events.push(stamped);
177
+ return stamped;
178
+ },
179
+ });
180
+
181
+ const result = await callback(tx);
182
+
183
+ // Events are published before state records on purpose: the event log is
184
+ // the authority, and a crash between the two is the window the journal
185
+ // ceiling in eventsSince has to hide.
186
+ const publications = [
187
+ ...events.map(event => ({
188
+ path: path.relative(root, eventPath(paths, event.sequence)),
189
+ bytes: encode(event),
190
+ replace: false,
191
+ })),
192
+ ...[...staged.values()].map(entry => (entry.removed === true
193
+ ? { path: path.relative(root, statePath(paths, entry.kind, entry.id)), remove: true }
194
+ : {
195
+ path: path.relative(root, statePath(paths, entry.kind, entry.id)),
196
+ bytes: encode(stateEnvelope(entry.kind, entry.id, entry.generation, entry.record)),
197
+ replace: true,
198
+ })),
199
+ ];
200
+ if (publications.length === 0) return result;
201
+
202
+ const entry = journalEntry(ids.next("transaction"), firstSequence, publications,
203
+ clock.now());
204
+ await writeJournalEntry(paths, publishOptions, entry);
205
+ await failAt?.("after-journal");
206
+ await rollForward(paths, publishOptions, entry);
207
+ return result;
208
+ });
209
+ }
210
+
211
+ async function eventsSince(workspace, cursor, limit) {
212
+ const after = cursor ?? ZERO_CURSOR;
213
+ // An open journal marks a transaction that is decided but not fully
214
+ // published. Bounding the page below its first sequence is what keeps a
215
+ // partially published transaction invisible to every reader.
216
+ const ceiling = (await readOpenJournals(paths, root)).at(0)?.firstSequence ?? null;
217
+ const events = [];
218
+ for (const filePath of await listJsonFiles(paths.events, { root })) {
219
+ const sequence = path.basename(filePath, ".json");
220
+ if (sequence <= after) continue;
221
+ if (ceiling !== null && sequence >= ceiling) break;
222
+ const found = await readJsonIfPresent(filePath, root);
223
+ if (found === null) continue;
224
+ const event = assertEventBinding(found.value, filePath);
225
+ if (event.workspaceId !== workspace) continue;
226
+ events.push(event);
227
+ if (events.length === limit) break;
228
+ }
229
+ return { cursor: events.at(-1)?.sequence ?? after, events };
230
+ }
231
+
232
+ /**
233
+ * Read the durable state, or the part of it a caller actually needs.
234
+ *
235
+ * Every kind is read by default, which is what most callers want and what
236
+ * this always did. `kinds` exists because one caller runs in front of every
237
+ * file an agent writes: reading the whole store there made the write guard
238
+ * cost grow with the number of messages the workspace had ever carried, and
239
+ * the hook budget is five seconds after which it allows the write.
240
+ */
241
+ async function snapshot(workspace, { kinds } = {}) {
242
+ const wanted = kinds === undefined ? null : new Set(kinds);
243
+ const of = async kind => {
244
+ if (wanted !== null && !wanted.has(kind)) return [];
245
+ return (await listState(paths, root, kind))
246
+ .map(envelope => envelope.record)
247
+ .filter(record => record.workspaceId === workspace);
248
+ };
249
+ return {
250
+ workspace: (await of("workspace"))[0] ?? null,
251
+ participants: await of("participant"),
252
+ sessions: await of("session"),
253
+ intents: await of("intent"),
254
+ workstreams: await of("workstream"),
255
+ tasks: await of("task"),
256
+ claims: await of("claim"),
257
+ messages: await of("message"),
258
+ receipts: await of("receipt"),
259
+ decisions: await of("decision"),
260
+ handoffs: await of("handoff"),
261
+ };
262
+ }
263
+
264
+ // 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`);
267
+ const ephemeral = Object.freeze({
268
+ async get(kind, id) {
269
+ const found = await readJsonIfPresent(ephemeralPath(kind, id), root);
270
+ return found?.value ?? null;
271
+ },
272
+ async put(kind, id, record) {
273
+ await publishAtomic(ephemeralPath(kind, id), encode(record),
274
+ { root, tmpDir: paths.tmp, replace: true });
275
+ return record;
276
+ },
277
+ async delete(kind, id) {
278
+ await removeIfPresent(ephemeralPath(kind, id));
279
+ },
280
+ async list(kind) {
281
+ const records = [];
282
+ for (const filePath of await listJsonFiles(path.join(paths.ephemeral, kind), { root })) {
283
+ const found = await readJsonIfPresent(filePath, root);
284
+ if (found !== null) records.push(found.value);
285
+ }
286
+ return records;
287
+ },
288
+ });
289
+
290
+ return Object.freeze({ transaction, eventsSince, snapshot, ephemeral, paths, root,
291
+ workspaceId });
292
+ }
@@ -0,0 +1,87 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, rm } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { AccError, EXIT } from "@agents-can-communicate/protocol";
6
+
7
+ import { encode, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
8
+ import { ensureManagedDirectory } from "./safe-directory.mjs";
9
+
10
+ const STALE_MS = 60_000;
11
+ const OWNER = "owner.json";
12
+
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.
16
+ function defaultPidIsAlive(pid) {
17
+ try {
18
+ process.kill(pid, 0);
19
+ return true;
20
+ } catch (error) {
21
+ return error.code !== "ESRCH";
22
+ }
23
+ }
24
+
25
+ async function readOwner(directory, root) {
26
+ const found = await readJsonIfPresent(path.join(directory, OWNER), root);
27
+ return found?.value ?? null;
28
+ }
29
+
30
+ /**
31
+ * Reclaim a lock nobody is holding.
32
+ *
33
+ * A hook is a process a client is free to kill, and clients do: they all put a
34
+ * timeout on it. Killed mid-write, it leaves this directory behind with its own
35
+ * pid in it. Requiring the lock to be *both* dead and a minute old meant that
36
+ * for the next sixty seconds no write in the workspace could proceed - no
37
+ * intent, no claim, no message, and no session could attach. Hooks fail open,
38
+ * so none of it was visible: the session simply never appeared, and `acc
39
+ * status` went on reporting the ones that had.
40
+ *
41
+ * A dead owner is reclaimed at once. There is nothing to wait for: the lock
42
+ * holds no state beyond its own owner file, and the process that would have
43
+ * finished the write is gone.
44
+ *
45
+ * The age still matters for the one case liveness cannot answer. A pid is
46
+ * recycled, so a dead owner's number can come back attached to something
47
+ * unrelated, and then `pidIsAlive` says yes forever. Sixty seconds is far longer
48
+ * than any write here takes - the hook budget is five - so a holder that old is
49
+ * not a writer that is still going.
50
+ */
51
+ async function takeStaleOwnership(directory, root, owner, now, pidIsAlive) {
52
+ if (owner === null) return false;
53
+ const age = Date.parse(now) - Date.parse(owner.acquiredAt);
54
+ if (pidIsAlive(owner.pid) && !(age > STALE_MS)) return false;
55
+ await rm(directory, { recursive: true, force: true });
56
+ return true;
57
+ }
58
+
59
+ export async function withWriterMutex(paths, options, operation) {
60
+ const { root, clock, pidIsAlive = defaultPidIsAlive, uuid = randomUUID,
61
+ attempts = 50, waitMs = 20 } = options;
62
+ const directory = path.join(paths.locks, "writer.lock");
63
+ await ensureManagedDirectory(root, paths.locks);
64
+ const token = uuid();
65
+
66
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
67
+ try {
68
+ await mkdir(directory);
69
+ } catch (error) {
70
+ if (error.code !== "EEXIST") throw error;
71
+ const owner = await readOwner(directory, root);
72
+ if (!await takeStaleOwnership(directory, root, owner, clock.now(), pidIsAlive)) {
73
+ await new Promise(resolve => { setTimeout(resolve, waitMs); });
74
+ }
75
+ continue;
76
+ }
77
+ const owner = { pid: process.pid, token, acquiredAt: clock.now() };
78
+ await publishAtomic(path.join(directory, OWNER), encode(owner), { root, tmpDir: paths.tmp });
79
+ try {
80
+ return await operation();
81
+ } finally {
82
+ const current = await readOwner(directory, root);
83
+ if (current?.token === token) await rm(directory, { recursive: true, force: true });
84
+ }
85
+ }
86
+ throw new AccError(EXIT.CONFLICT, "another writer holds the store lock", { directory });
87
+ }