agents-can-communicate 0.1.18 → 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 +78 -70
- package/SECURITY.md +31 -0
- package/bin/acc-hook.mjs +94 -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 +102 -214
- 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 +20 -22
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +12 -4
- 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 +20 -22
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +18 -11
- 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 +20 -22
- 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 +7 -3
- 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 +2 -1
- package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +20 -22
- package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +9 -9
- 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 +20 -22
- package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +7 -3
- 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 +36 -17
- 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 +1 -0
- 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 +11 -30
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +3 -0
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -5
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +9 -2
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +100 -121
- 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 +91 -107
- package/node_modules/@agents-can-communicate/core/src/index.mjs +3 -3
- package/node_modules/@agents-can-communicate/core/src/intents.mjs +0 -1
- package/node_modules/@agents-can-communicate/core/src/ports.mjs +2 -1
- package/node_modules/@agents-can-communicate/core/src/receipts.mjs +109 -0
- package/node_modules/@agents-can-communicate/core/src/service.mjs +11 -10
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +22 -20
- package/node_modules/@agents-can-communicate/core/src/status.mjs +11 -9
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +3 -294
- 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 +115 -63
- 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 +102 -72
- package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +54 -97
- 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 +68 -26
- package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +113 -35
- package/package.json +19 -1
- package/node_modules/@agents-can-communicate/core/src/communication.mjs +0 -334
- package/node_modules/@agents-can-communicate/core/src/message-signals.mjs +0 -41
- 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
|
@@ -1,14 +1,19 @@
|
|
|
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
|
|
|
@@ -23,10 +28,17 @@ export const ZERO_CURSOR = "0".repeat(SEQUENCE_WIDTH);
|
|
|
23
28
|
// corrupt record, so nothing ever had a reason to put one aside. An empty
|
|
24
29
|
// directory that reads as a feature is the same mistake as an attention kind
|
|
25
30
|
// with no rule behind it. If quarantining is ever built, it comes back with it.
|
|
26
|
-
const DIRECTORIES = ["state", "events", "journal", "locks", "ephemeral", "tmp"];
|
|
31
|
+
const DIRECTORIES = ["state", "events", "journal", "locks", "ephemeral", "retained", "tmp"];
|
|
27
32
|
|
|
28
33
|
const pad = value => String(value).padStart(SEQUENCE_WIDTH, "0");
|
|
29
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
|
+
|
|
30
42
|
export function storePaths(root) {
|
|
31
43
|
return Object.freeze(Object.fromEntries([["root", root],
|
|
32
44
|
...DIRECTORIES.map(name => [name, path.join(root, name)])]));
|
|
@@ -37,8 +49,12 @@ async function listState(paths, root, kind) {
|
|
|
37
49
|
for (const filePath of await listJsonFiles(path.join(paths.state, kind), { root })) {
|
|
38
50
|
const found = await readJsonIfPresent(filePath, root);
|
|
39
51
|
if (found === null) continue;
|
|
40
|
-
|
|
41
|
-
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);
|
|
42
58
|
}
|
|
43
59
|
return envelopes;
|
|
44
60
|
}
|
|
@@ -67,11 +83,12 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
67
83
|
// root, containment rules apply and each level is created individually so a
|
|
68
84
|
// symlinked ancestor cannot be created past.
|
|
69
85
|
await mkdir(root, { recursive: true });
|
|
70
|
-
for (const name of DIRECTORIES) await ensureManagedDirectory(root, paths[name]);
|
|
71
86
|
// Identity is settled before any read or write. Adopting a directory that
|
|
72
87
|
// already belongs to another workspace is the failure this fails closed on.
|
|
73
88
|
await requireStoreIdentity(paths, { workspaceId, clock });
|
|
89
|
+
for (const name of DIRECTORIES) await ensureManagedDirectory(root, paths[name]);
|
|
74
90
|
const publishOptions = { root, tmpDir: paths.tmp, clock, failAt };
|
|
91
|
+
await initialiseActiveJournal(paths, publishOptions);
|
|
75
92
|
|
|
76
93
|
// Any journal left behind by a crashed writer is completed before the store
|
|
77
94
|
// serves a single read, so callers never observe a half-published
|
|
@@ -107,7 +124,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
107
124
|
* these transactions make reads as "nothing conflicts" when it finds nothing.
|
|
108
125
|
* Declaring nothing reads everything, which is what this always did.
|
|
109
126
|
*/
|
|
110
|
-
async function transaction(callback, { kinds } = {}) {
|
|
127
|
+
async function transaction(callback, { kinds, deadlineAt } = {}) {
|
|
111
128
|
const wanted = kinds === undefined ? null : new Set(kinds);
|
|
112
129
|
const declared = kind => {
|
|
113
130
|
if (wanted !== null && !wanted.has(kind)) {
|
|
@@ -117,7 +134,8 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
117
134
|
}
|
|
118
135
|
return kind;
|
|
119
136
|
};
|
|
120
|
-
return withWriterMutex(paths, publishOptions, async () => {
|
|
137
|
+
return withWriterMutex(paths, { ...publishOptions, deadlineAt }, async () => {
|
|
138
|
+
assertBeforePublication(deadlineAt);
|
|
121
139
|
// Reads are loaded once per transaction so get, list, and the generation
|
|
122
140
|
// that put() compares against all describe the same instant.
|
|
123
141
|
const loaded = await loadAllState(paths, root, wanted);
|
|
@@ -171,7 +189,9 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
171
189
|
throw new AccError(EXIT.CONFLICT, `${kind} ${id} changed under this transaction`,
|
|
172
190
|
{ kind, id, expectedGeneration, actualGeneration: actual });
|
|
173
191
|
}
|
|
174
|
-
|
|
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 });
|
|
175
195
|
},
|
|
176
196
|
append(event) {
|
|
177
197
|
const stamped = { ...event, sequence: pad(sequence) };
|
|
@@ -194,7 +214,8 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
194
214
|
replace: false,
|
|
195
215
|
})),
|
|
196
216
|
...[...staged.values()].map(entry => (entry.removed === true
|
|
197
|
-
?
|
|
217
|
+
? stateDeletionPublication(paths, root, entry.kind, entry.id, entry.generation,
|
|
218
|
+
statePath(paths, entry.kind, entry.id))
|
|
198
219
|
: {
|
|
199
220
|
path: path.relative(root, statePath(paths, entry.kind, entry.id)),
|
|
200
221
|
bytes: encode(stateEnvelope(entry.kind, entry.id, entry.generation, entry.record)),
|
|
@@ -203,6 +224,11 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
203
224
|
];
|
|
204
225
|
if (publications.length === 0) return result;
|
|
205
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);
|
|
206
232
|
const entry = journalEntry(ids.next("transaction"), firstSequence, publications,
|
|
207
233
|
clock.now());
|
|
208
234
|
await writeJournalEntry(paths, publishOptions, entry);
|
|
@@ -217,7 +243,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
217
243
|
// An open journal marks a transaction that is decided but not fully
|
|
218
244
|
// published. Bounding the page below its first sequence is what keeps a
|
|
219
245
|
// partially published transaction invisible to every reader.
|
|
220
|
-
const ceiling =
|
|
246
|
+
const ceiling = await readJournalCeiling(paths, root);
|
|
221
247
|
const events = [];
|
|
222
248
|
for (const filePath of await listJsonFiles(paths.events, { root })) {
|
|
223
249
|
const sequence = path.basename(filePath, ".json");
|
|
@@ -225,7 +251,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
225
251
|
if (ceiling !== null && sequence >= ceiling) break;
|
|
226
252
|
const found = await readJsonIfPresent(filePath, root);
|
|
227
253
|
if (found === null) continue;
|
|
228
|
-
const event = assertEventBinding(found.value, filePath);
|
|
254
|
+
const event = validateRecord("event", assertEventBinding(found.value, filePath));
|
|
229
255
|
if (event.workspaceId !== workspace) continue;
|
|
230
256
|
events.push(event);
|
|
231
257
|
if (events.length === limit) break;
|
|
@@ -255,49 +281,65 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
255
281
|
participants: await of("participant"),
|
|
256
282
|
sessions: await of("session"),
|
|
257
283
|
intents: await of("intent"),
|
|
258
|
-
workstreams: await of("workstream"),
|
|
259
|
-
tasks: await of("task"),
|
|
260
284
|
claims: await of("claim"),
|
|
261
285
|
messages: await of("message"),
|
|
262
286
|
receipts: await of("receipt"),
|
|
263
|
-
decisions: await of("decision"),
|
|
264
|
-
handoffs: await of("handoff"),
|
|
265
287
|
};
|
|
266
288
|
}
|
|
267
289
|
// Ephemeral records are published by replace and never journalled: they carry
|
|
268
|
-
// no history
|
|
269
|
-
|
|
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
|
+
};
|
|
270
305
|
const ephemeral = Object.freeze({
|
|
271
306
|
async get(kind, id) {
|
|
272
|
-
return (
|
|
307
|
+
return readEphemeral(kind, id);
|
|
273
308
|
},
|
|
274
309
|
async put(kind, id, record) {
|
|
310
|
+
validateRecord(kind, record);
|
|
275
311
|
return withWriterMutex(paths, publishOptions, async () => {
|
|
276
312
|
await publishAtomic(ephemeralPath(kind, id), encode(record),
|
|
277
313
|
{ root, tmpDir: paths.tmp, replace: true });
|
|
314
|
+
await markEphemeral(paths, publishOptions, kind, id, "present");
|
|
278
315
|
return record;
|
|
279
316
|
});
|
|
280
317
|
},
|
|
281
318
|
async update(kind, id, updater) {
|
|
282
319
|
return withWriterMutex(paths, publishOptions, async () => {
|
|
283
|
-
const
|
|
284
|
-
const next = await updater(found?.value ?? null);
|
|
320
|
+
const next = await updater(await readEphemeral(kind, id));
|
|
285
321
|
if (next === null) return null;
|
|
286
322
|
validateRecord(kind, next);
|
|
287
323
|
await publishAtomic(ephemeralPath(kind, id), encode(next),
|
|
288
324
|
{ root, tmpDir: paths.tmp, replace: true });
|
|
325
|
+
await markEphemeral(paths, publishOptions, kind, id, "present");
|
|
289
326
|
return next;
|
|
290
327
|
});
|
|
291
328
|
},
|
|
292
329
|
async delete(kind, id) {
|
|
293
|
-
return withWriterMutex(paths, publishOptions,
|
|
294
|
-
|
|
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
|
+
});
|
|
295
336
|
},
|
|
296
337
|
async list(kind) {
|
|
297
338
|
const records = [];
|
|
298
|
-
for (const filePath of await listJsonFiles(
|
|
339
|
+
for (const filePath of await listJsonFiles(ephemeralDirectory(kind), { root })) {
|
|
299
340
|
const found = await readJsonIfPresent(filePath, root);
|
|
300
|
-
if (found !== null
|
|
341
|
+
if (found !== null && !await ephemeralIsDeleted(paths, root, kind,
|
|
342
|
+
path.basename(filePath, ".json"))) records.push(validateRecord(kind, found.value));
|
|
301
343
|
}
|
|
302
344
|
return records;
|
|
303
345
|
},
|
|
@@ -1,21 +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";
|
|
4
5
|
import { performance } from "node:perf_hooks";
|
|
5
6
|
|
|
6
7
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
7
8
|
|
|
8
|
-
import { encode,
|
|
9
|
+
import { encode, readJsonIfPresent } from "./atomic-json.mjs";
|
|
9
10
|
import { ensureManagedDirectory } from "./safe-directory.mjs";
|
|
11
|
+
import { withRegularNoFollow } from "./safe-file.mjs";
|
|
10
12
|
|
|
11
13
|
const STALE_MS = 60_000;
|
|
12
14
|
const ACQUIRE_TIMEOUT_MS = 2_500;
|
|
13
15
|
const OWNER = "owner.json";
|
|
14
16
|
const sleepFor = duration => new Promise(resolve => { setTimeout(resolve, duration); });
|
|
15
17
|
|
|
16
|
-
//
|
|
17
|
-
// with
|
|
18
|
-
//
|
|
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.
|
|
19
21
|
function defaultPidIsAlive(pid) {
|
|
20
22
|
try {
|
|
21
23
|
process.kill(pid, 0);
|
|
@@ -25,13 +27,59 @@ function defaultPidIsAlive(pid) {
|
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
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
|
+
|
|
28
76
|
/**
|
|
29
77
|
* Who holds the lock, or nothing if it moved while we looked.
|
|
30
78
|
*
|
|
31
79
|
* Reads inside the store refuse a parent directory whose identity changed
|
|
32
80
|
* between the check and the open - the defence against a directory being
|
|
33
81
|
* swapped under a read. This lock is the one directory whose entire life is
|
|
34
|
-
* being
|
|
82
|
+
* being published and retired: `rename` grants and releases it, so its inode
|
|
35
83
|
* changes every time it passes from one process to the next. Reading its owner
|
|
36
84
|
* through the strict path meant a contended lock raised
|
|
37
85
|
* "record parent directory changed while opening" and the whole command failed -
|
|
@@ -82,49 +130,79 @@ async function takeStaleOwnership(directory, root, owner, now, pidIsAlive) {
|
|
|
82
130
|
if (owner === null) return false;
|
|
83
131
|
const age = Date.parse(now) - Date.parse(owner.acquiredAt);
|
|
84
132
|
if (pidIsAlive(owner.pid) && !(age > STALE_MS)) return false;
|
|
85
|
-
|
|
86
|
-
|
|
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
|
+
}
|
|
87
149
|
}
|
|
88
150
|
|
|
89
151
|
export async function withWriterMutex(paths, options, operation) {
|
|
90
152
|
const { root, clock, pidIsAlive = defaultPidIsAlive, uuid = randomUUID,
|
|
91
153
|
attempts = Number.POSITIVE_INFINITY, waitMs = 20, openFile,
|
|
92
154
|
acquireTimeoutMs = ACQUIRE_TIMEOUT_MS, monotonicNow = () => performance.now(),
|
|
93
|
-
sleep = sleepFor } = options;
|
|
155
|
+
wallNow = Date.now, deadlineAt, sleep = sleepFor } = options;
|
|
94
156
|
const directory = path.join(paths.locks, "writer.lock");
|
|
95
157
|
// Wall time can jump while a process waits. A monotonic absolute deadline
|
|
96
158
|
// bounds all owner reads and retries, leaving half the hook's five-second
|
|
97
159
|
// budget for publishing the owner, doing the write, and rendering a result.
|
|
98
|
-
const
|
|
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);
|
|
99
164
|
await ensureManagedDirectory(root, paths.locks);
|
|
100
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;
|
|
101
169
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
await
|
|
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;
|
|
113
190
|
}
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
if (monotonicNow() >= deadline) {
|
|
117
|
-
await rm(directory, { recursive: true, force: true });
|
|
118
|
-
break;
|
|
119
|
-
}
|
|
120
|
-
const owner = { pid: process.pid, token, acquiredAt: clock.now() };
|
|
121
|
-
await publishAtomic(path.join(directory, OWNER), encode(owner), { root, tmpDir: paths.tmp });
|
|
122
|
-
try {
|
|
191
|
+
if (monotonicNow() >= deadline) break;
|
|
123
192
|
return await operation();
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
}
|
|
127
202
|
}
|
|
128
203
|
}
|
|
129
|
-
|
|
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 });
|
|
130
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": [
|
|
@@ -45,7 +45,23 @@
|
|
|
45
45
|
},
|
|
46
46
|
"files": [
|
|
47
47
|
"bin/",
|
|
48
|
+
"docs/ADAPTER_AUTHORING.md",
|
|
49
|
+
"docs/ARCHITECTURE.md",
|
|
48
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",
|
|
49
65
|
"README.md",
|
|
50
66
|
"LICENSE"
|
|
51
67
|
],
|
|
@@ -58,6 +74,7 @@
|
|
|
58
74
|
"@agents-can-communicate/adapter-sdk": "*",
|
|
59
75
|
"@agents-can-communicate/cli": "*",
|
|
60
76
|
"@agents-can-communicate/core": "*",
|
|
77
|
+
"@agents-can-communicate/delivery-router": "*",
|
|
61
78
|
"@agents-can-communicate/hook-runner": "*",
|
|
62
79
|
"@agents-can-communicate/installer": "*",
|
|
63
80
|
"@agents-can-communicate/mcp-server": "*",
|
|
@@ -73,6 +90,7 @@
|
|
|
73
90
|
"@agents-can-communicate/adapter-sdk",
|
|
74
91
|
"@agents-can-communicate/cli",
|
|
75
92
|
"@agents-can-communicate/core",
|
|
93
|
+
"@agents-can-communicate/delivery-router",
|
|
76
94
|
"@agents-can-communicate/hook-runner",
|
|
77
95
|
"@agents-can-communicate/installer",
|
|
78
96
|
"@agents-can-communicate/mcp-server",
|