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