agents-can-communicate 0.9.1 → 0.10.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 (70) hide show
  1. package/README.md +46 -44
  2. package/bin/acc-update-worker.mjs +2 -1
  3. package/bin/entrypoints/hook-output.mjs +2 -0
  4. package/docs/ARCHITECTURE.md +19 -0
  5. package/docs/CLI.md +11 -1
  6. package/docs/GETTING_STARTED.md +4 -3
  7. package/docs/UPGRADING.md +50 -0
  8. package/docs/WHY_ACC.md +32 -9
  9. package/docs/index.md +7 -5
  10. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
  11. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  12. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  13. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +3 -1
  14. package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +12 -0
  15. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  16. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +2 -1
  17. package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +11 -0
  18. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  19. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  20. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  21. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  22. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  23. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +6 -0
  24. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  25. package/node_modules/@agents-can-communicate/cli/src/args.mjs +4 -1
  26. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +14 -0
  27. package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -0
  28. package/node_modules/@agents-can-communicate/cli/src/main.mjs +12 -3
  29. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +19 -6
  30. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +1 -1
  31. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/portable-fs.mjs +41 -20
  32. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +17 -1
  33. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/store-upgrade.mjs +105 -0
  34. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +5 -0
  35. package/node_modules/@agents-can-communicate/cli/src/store-migration-command.mjs +39 -0
  36. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  37. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +21 -52
  38. package/node_modules/@agents-can-communicate/core/src/decision-changes.mjs +16 -11
  39. package/node_modules/@agents-can-communicate/core/src/inbox.mjs +12 -4
  40. package/node_modules/@agents-can-communicate/core/src/message-retry.mjs +43 -0
  41. package/node_modules/@agents-can-communicate/core/src/ports.mjs +12 -1
  42. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +20 -6
  43. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  44. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  45. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +23 -3
  46. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  47. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  48. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  49. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +1 -0
  50. package/node_modules/@agents-can-communicate/protocol/src/transaction-indexes.mjs +22 -0
  51. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  52. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +22 -10
  53. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-cache.mjs +144 -0
  54. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-io.mjs +65 -0
  55. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-pages.mjs +85 -0
  56. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-reclaim-progress.mjs +35 -0
  57. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-reclaim.mjs +142 -0
  58. package/node_modules/@agents-can-communicate/storage-filesystem/src/index-tree.mjs +142 -0
  59. package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +1 -0
  60. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +2 -0
  61. package/node_modules/@agents-can-communicate/storage-filesystem/src/portable-fs.mjs +41 -20
  62. package/node_modules/@agents-can-communicate/storage-filesystem/src/prune-durability.mjs +29 -0
  63. package/node_modules/@agents-can-communicate/storage-filesystem/src/reclaim.mjs +13 -3
  64. package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +9 -3
  65. package/node_modules/@agents-can-communicate/storage-filesystem/src/session-read.mjs +6 -4
  66. package/node_modules/@agents-can-communicate/storage-filesystem/src/state-reads.mjs +43 -0
  67. package/node_modules/@agents-can-communicate/storage-filesystem/src/store-migration.mjs +56 -0
  68. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +61 -110
  69. package/node_modules/@agents-can-communicate/storage-filesystem/src/transaction-view.mjs +131 -0
  70. package/package.json +5 -4
@@ -0,0 +1,43 @@
1
+ import path from "node:path";
2
+ import { AccError, EXIT, validateRecord } from "@agents-can-communicate/protocol";
3
+ import { listDirectoryEntries, listJsonFiles, readJsonIfPresent } from "./atomic-json.mjs";
4
+ import { assertStateBinding, statePath } from "./record-id.mjs";
5
+ import { stateGenerationIsDeleted } from "./retention.mjs";
6
+ import { assertPublicationDeadline } from "./deadline.mjs";
7
+
8
+ export async function readStateEnvelope(paths, { root, workspaceId }, kind, id) {
9
+ const filePath = statePath(paths, kind, id);
10
+ const found = await readJsonIfPresent(filePath, root);
11
+ if (found === null) return null;
12
+ const envelope = assertStateBinding(found.value, kind, id, filePath);
13
+ if (await stateGenerationIsDeleted(paths, root, kind, id, envelope.generation)) return null;
14
+ validateRecord(kind, envelope.record);
15
+ if (workspaceId !== undefined && envelope.record.workspaceId !== workspaceId) {
16
+ throw new AccError(EXIT.DATA, "state record belongs to a different workspace",
17
+ { filePath, expected: workspaceId, actual: envelope.record.workspaceId });
18
+ }
19
+ return envelope;
20
+ }
21
+
22
+ export async function listState(paths, root, kind, deadlineAt) {
23
+ const envelopes = [];
24
+ for (const filePath of await listJsonFiles(path.join(paths.state, kind), { root })) {
25
+ assertPublicationDeadline(deadlineAt);
26
+ const envelope = await readStateEnvelope(paths, { root }, kind, path.basename(filePath, ".json"));
27
+ if (envelope !== null) envelopes.push(envelope);
28
+ }
29
+ return envelopes;
30
+ }
31
+
32
+ export async function loadStateEnvelopes(paths, { root, deadlineAt }, wanted = null) {
33
+ const loaded = new Map();
34
+ const kinds = (await listDirectoryEntries(paths.state, { root }))
35
+ .filter(entry => entry.isDirectory()).map(entry => entry.name)
36
+ .filter(kind => wanted === null || wanted.has(kind));
37
+ for (const kind of kinds) {
38
+ for (const envelope of await listState(paths, root, kind, deadlineAt)) {
39
+ loaded.set(`${envelope.kind}:${envelope.id}`, envelope);
40
+ }
41
+ }
42
+ return loaded;
43
+ }
@@ -0,0 +1,56 @@
1
+ import { AccError, EXIT, validateRecord } from "@agents-can-communicate/protocol";
2
+ import { encode, listJsonFiles, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
3
+ import { initialiseActiveJournal, readActiveJournal } from "./active-journal.mjs";
4
+ import { assertPublicationDeadline } from "./deadline.mjs";
5
+ import { identityPath, readMigrationIdentity, STORE_VERSION } from "./identity.mjs";
6
+ import { createIndexCache } from "./index-cache.mjs";
7
+ import { IndexCacheUnavailable } from "./index-pages.mjs";
8
+ import { readOpenJournals, rollForward } from "./journal.mjs";
9
+ import { assertEventBinding } from "./record-id.mjs";
10
+ import { ensureManagedDirectory } from "./safe-directory.mjs";
11
+ import { loadStateEnvelopes } from "./state-reads.mjs";
12
+ import { storePaths } from "./store.mjs";
13
+ import { withWriterMutex } from "./writer-mutex.mjs";
14
+
15
+ /** Explicit library migration; the operator must quiesce unmanaged users first. */
16
+ export async function migrateFilesystemStore({ root, workspaceId, clock, ids,
17
+ allowMigration = false, platform = process.platform, deadlineAt, failAt }) {
18
+ if (allowMigration !== true) {
19
+ throw new AccError(EXIT.USAGE, "store migration requires explicit opt-in after quiescing other users");
20
+ }
21
+ assertPublicationDeadline(deadlineAt);
22
+ const paths = storePaths(root), publishOptions = { root, tmpDir: paths.tmp, clock, platform, failAt };
23
+ await readMigrationIdentity(paths, workspaceId);
24
+ return withWriterMutex(paths, { ...publishOptions, deadlineAt }, async () => {
25
+ const identity = await readMigrationIdentity(paths, workspaceId), fromVersion = identity.storeVersion;
26
+ for (const directory of Object.values(paths)) await ensureManagedDirectory(root, directory);
27
+ await initialiseActiveJournal(paths, { ...publishOptions, deadlineAt });
28
+ for (const entry of await readOpenJournals(paths, root)) {
29
+ // A decided journal finishes even if the migration's pre-switch budget lapses.
30
+ await rollForward(paths, publishOptions, entry);
31
+ }
32
+ assertPublicationDeadline(deadlineAt);
33
+ const primaries = await loadStateEnvelopes(paths, { root, deadlineAt });
34
+ for (const file of await listJsonFiles(paths.events, { root })) {
35
+ assertPublicationDeadline(deadlineAt);
36
+ const found = await readJsonIfPresent(file, root);
37
+ if (found === null) continue;
38
+ const event = validateRecord("event", assertEventBinding(found.value, file));
39
+ if (event.workspaceId !== workspaceId) throw new AccError(EXIT.DATA, "event belongs to a different workspace");
40
+ }
41
+ const cache = createIndexCache({ paths, root, workspaceId, publishOptions, loadPrimary: async () => primaries });
42
+ try { await cache.rebuild(await readActiveJournal(paths, root), deadlineAt, { required: true }); }
43
+ catch (error) {
44
+ if (!(error instanceof IndexCacheUnavailable)) throw error;
45
+ throw new AccError(EXIT.DATA, "store migration could not publish its complete index", { reason: error.reason });
46
+ }
47
+ if (fromVersion !== STORE_VERSION) {
48
+ await failAt?.("before-store-version-switch");
49
+ assertPublicationDeadline(deadlineAt);
50
+ await publishAtomic(identityPath(paths), encode({ ...identity, storeVersion: STORE_VERSION }),
51
+ { ...publishOptions, deadlineAt, replace: true, durability: "full" });
52
+ await failAt?.("after-store-version-switch");
53
+ }
54
+ return { fromVersion, toVersion: STORE_VERSION, migrated: fromVersion !== STORE_VERSION };
55
+ });
56
+ }
@@ -1,10 +1,10 @@
1
1
  import { mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- import { AccError, EXIT, assertPortableId, validateRecord }
4
+ import { AccError, EXIT, TRANSACTION_INDEXES, assertIndexTuple, assertPortableId, indexKeysFor, validateRecord }
5
5
  from "@agents-can-communicate/protocol";
6
6
 
7
- import { encode, listDirectoryEntries, listJsonFiles, publishAtomic, readJsonIfPresent,
7
+ import { encode, listJsonFiles, publishAtomic, readJsonIfPresent,
8
8
  retainFile } from "./atomic-json.mjs";
9
9
  import { initialiseActiveJournal, readActiveJournal } from "./active-journal.mjs";
10
10
  import { publicationPath } from "./publication-path.mjs";
@@ -12,15 +12,19 @@ import { assertPublicationDeadline } from "./deadline.mjs";
12
12
  import { requireStoreIdentity } from "./identity.mjs";
13
13
  import { journalEntry, readJournalCeiling, readOpenJournals, rollForward, writeJournalEntry }
14
14
  from "./journal.mjs";
15
- import { assertEventBinding, assertStateBinding, eventPath, stateEnvelope, statePath }
15
+ import { assertEventBinding, eventPath, stateEnvelope, statePath }
16
16
  from "./record-id.mjs";
17
- import { ephemeralIsDeleted, markEphemeral, stateDeletionPublication,
18
- stateGenerationIsDeleted } from "./retention.mjs";
17
+ import { ephemeralIsDeleted, markEphemeral, stateDeletionPublication } from "./retention.mjs";
19
18
  import { ensureManagedDirectory } from "./safe-directory.mjs";
20
19
  import { readEventFloor } from "./event-floor.mjs";
21
20
  import { reclaimStateRecords, trimEventLog } from "./reclaim.mjs";
22
21
  import { sweepIfDue } from "./stage-sweep.mjs";
23
22
  import { withWriterMutex } from "./writer-mutex.mjs";
23
+ import { listState, loadStateEnvelopes, readStateEnvelope } from "./state-reads.mjs";
24
+ import { createTransactionView, transactionKinds } from "./transaction-view.mjs";
25
+ import { createIndexCache } from "./index-cache.mjs";
26
+ import { IndexCacheUnavailable } from "./index-pages.mjs";
27
+ import { withIndexedPrune } from "./index-reclaim.mjs";
24
28
 
25
29
  // Kept cohesive above 300 lines because durable transactions and ephemeral
26
30
  // mutations must share this exact writer mutex. Splitting the two stores would
@@ -55,34 +59,6 @@ export function storePaths(root) {
55
59
  ...DIRECTORIES.map(name => [name, path.join(root, name)])]));
56
60
  }
57
61
 
58
- async function listState(paths, root, kind) {
59
- const envelopes = [];
60
- for (const filePath of await listJsonFiles(path.join(paths.state, kind), { root })) {
61
- const found = await readJsonIfPresent(filePath, root);
62
- if (found === null) continue;
63
- const envelope = assertStateBinding(found.value, kind,
64
- path.basename(filePath, ".json"), filePath);
65
- if (await stateGenerationIsDeleted(paths, root, kind, envelope.id,
66
- envelope.generation)) continue;
67
- validateRecord(kind, envelope.record);
68
- envelopes.push(envelope);
69
- }
70
- return envelopes;
71
- }
72
-
73
- async function loadAllState(paths, root, wanted = null) {
74
- const loaded = new Map();
75
- const kinds = (await listDirectoryEntries(paths.state, { root }))
76
- .filter(entry => entry.isDirectory()).map(entry => entry.name)
77
- .filter(kind => wanted === null || wanted.has(kind));
78
- for (const kind of kinds) {
79
- for (const envelope of await listState(paths, root, kind)) {
80
- loaded.set(`${envelope.kind}:${envelope.id}`, envelope);
81
- }
82
- }
83
- return loaded;
84
- }
85
-
86
62
  async function nextSequence(paths, root) {
87
63
  const last = (await listJsonFiles(paths.events, { root })).at(-1);
88
64
  // Only an empty directory consults the floor, so the path every transaction
@@ -118,6 +94,8 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
118
94
  await requireStoreIdentity(paths, { workspaceId, clock, platform });
119
95
  for (const name of DIRECTORIES) await ensureManagedDirectory(root, paths[name]);
120
96
  const publishOptions = { root, tmpDir: paths.tmp, clock, failAt, platform };
97
+ const indexCache = createIndexCache({ paths, root, workspaceId, publishOptions,
98
+ loadPrimary: options => loadStateEnvelopes(paths, { root, ...options }, new Set(["message", "receipt"])) });
121
99
  await initialiseActiveJournal(paths, publishOptions);
122
100
 
123
101
  // Any journal left behind by a crashed writer is completed before the store
@@ -149,7 +127,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
149
127
  return withWriterMutex(paths, { root, tmpDir: paths.tmp, clock, deadlineAt: storeDeadline }, async () => {
150
128
  const completed = [];
151
129
  for (const entry of await readOpenJournals(paths, root)) {
152
- completed.push(...await rollForward(paths, { root, tmpDir: paths.tmp, clock }, entry));
130
+ completed.push(...await rollForward(paths, { root, tmpDir: paths.tmp, clock, platform }, entry));
153
131
  }
154
132
  return completed;
155
133
  });
@@ -170,87 +148,58 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
170
148
  * these transactions make reads as "nothing conflicts" when it finds nothing.
171
149
  * Declaring nothing reads everything, which is what this always did.
172
150
  */
173
- async function transaction(callback, { kinds, deadlineAt } = {}) {
151
+ async function transaction(callback, { kinds, exactKinds = [], deadlineAt } = {}) {
174
152
  // A caller may tighten this invocation's budget, never extend it.
175
153
  deadlineAt = Math.min(deadlineAt ?? Infinity, storeDeadline ?? Infinity);
176
- const wanted = kinds === undefined ? null : new Set(kinds);
177
- const declared = kind => {
178
- if (wanted !== null && !wanted.has(kind)) {
179
- throw new AccError(EXIT.DATA,
180
- `this transaction did not declare ${kind}, so it was never read`,
181
- { kind, declared: [...wanted] });
182
- }
183
- return kind;
184
- };
154
+ const { eager } = transactionKinds(kinds, exactKinds);
185
155
  return withWriterMutex(paths, { ...publishOptions, deadlineAt }, async () => {
186
156
  assertPublicationDeadline(deadlineAt);
157
+ // An already-open handle may follow a writer that died after deciding.
158
+ // Recovery here holds this mutex directly, never trying to acquire it twice.
159
+ for (const entry of await readOpenJournals(paths, root)) await rollForward(paths, publishOptions, entry);
160
+ const before = await readActiveJournal(paths, root);
187
161
  // Reads are loaded once per transaction so get, list, and the generation
188
162
  // that put() compares against all describe the same instant.
189
- const loaded = await loadAllState(paths, root, wanted);
190
- const staged = new Map();
163
+ const loaded = await loadStateEnvelopes(paths, { root }, eager);
191
164
  const events = [];
192
165
  let sequence = await nextSequence(paths, root);
193
166
  const firstSequence = pad(sequence);
194
- const entryFor = key => {
195
- const entry = staged.get(key) ?? loaded.get(key) ?? null;
196
- return entry?.removed === true ? null : entry;
197
- };
198
-
199
- const tx = Object.freeze({
200
- get(kind, id) {
201
- declared(kind);
202
- return entryFor(`${kind}:${id}`)?.record ?? null;
203
- },
204
- generationOf(kind, id) {
205
- declared(kind);
206
- return entryFor(`${kind}:${id}`)?.generation ?? null;
207
- },
208
- list(kind, predicate = () => true) {
209
- declared(kind);
210
- const merged = new Map();
211
- for (const source of [loaded, staged]) {
212
- for (const [key, entry] of source) {
213
- if (entry.kind === kind) merged.set(key, entry);
214
- }
215
- }
216
- return [...merged.values()].filter(entry => entry.removed !== true)
217
- .map(entry => entry.record).filter(predicate);
218
- },
219
- put(kind, id, record, expectedGeneration = null) {
220
- declared(kind);
221
- const key = `${kind}:${id}`;
222
- const actual = entryFor(key)?.generation ?? null;
223
- if (actual !== expectedGeneration) {
224
- throw new AccError(EXIT.CONFLICT, `${kind} ${id} changed under this transaction`,
225
- { kind, id, expectedGeneration, actualGeneration: actual });
226
- }
227
- validateRecord(kind, record);
228
- const generation = ids.next("generation");
229
- staged.set(key, { kind, id, record, generation });
230
- return generation;
167
+ const view = createTransactionView({ kinds, exactKinds, loaded, ids,
168
+ loadEnvelope: (kind, id) => {
169
+ assertPublicationDeadline(deadlineAt);
170
+ return readStateEnvelope(paths, { root, workspaceId }, kind, id);
231
171
  },
232
- remove(kind, id, expectedGeneration = null) {
233
- declared(kind);
234
- const key = `${kind}:${id}`;
235
- const actual = entryFor(key)?.generation ?? null;
236
- if (actual !== expectedGeneration) {
237
- throw new AccError(EXIT.CONFLICT, `${kind} ${id} changed under this transaction`,
238
- { kind, id, expectedGeneration, actualGeneration: actual });
239
- }
240
- const persisted = loaded.get(key);
241
- if (persisted === undefined) staged.delete(key);
242
- else staged.set(key, { kind, id, generation: persisted.generation, removed: true });
172
+ lookupIndex(index, tuple, context) {
173
+ assertIndexTuple(index, tuple);
174
+ const kind = TRANSACTION_INDEXES[index].kind;
175
+ context.declared(kind);
176
+ return indexCache.lookup(index, tuple, { active: before, staged: context.staged, deadlineAt,
177
+ verify: async id => {
178
+ const envelope = await context.read(kind, id);
179
+ if (envelope === null) return false;
180
+ if (!indexKeysFor(kind, envelope.record).some(key => key.index === index
181
+ && JSON.stringify(key.tuple) === JSON.stringify(tuple))) {
182
+ throw new IndexCacheUnavailable("invalid_source_reference");
183
+ }
184
+ return true;
185
+ } });
243
186
  },
244
- append(event) {
187
+ appendEvent(event) {
245
188
  const stamped = { ...event, sequence: pad(sequence) };
246
189
  sequence += 1;
247
190
  validateRecord("event", stamped);
248
191
  events.push(stamped);
249
192
  return stamped;
250
- },
251
- });
252
-
253
- const result = await callback(tx);
193
+ } });
194
+ const { tx, staged } = view;
195
+ let result;
196
+ try { result = await callback(tx); }
197
+ catch (error) {
198
+ await view.finish().catch(() => {});
199
+ throw error;
200
+ }
201
+ await view.finish();
202
+ const deltas = indexCache.prepareDeltas(loaded, staged);
254
203
 
255
204
  // Events are published before state records on purpose: the event log is
256
205
  // the authority, and a crash between the two is the window the journal
@@ -281,7 +230,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
281
230
  // journalled write too, which the path below keeps.
282
231
  const [only] = staged.values();
283
232
  if (events.length === 0 && staged.size === 1 && only.removed !== true
284
- && (await readActiveJournal(paths, root)).state === "idle") {
233
+ && !["message", "receipt"].includes(only.kind) && before.state === "idle") {
285
234
  assertPublicationDeadline(deadlineAt);
286
235
  await publishAtomic(statePath(paths, only.kind, only.id), publications[0].bytes,
287
236
  { ...publishOptions, replace: true, deadlineAt });
@@ -297,6 +246,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
297
246
  await writeJournalEntry(paths, { ...publishOptions, deadlineAt }, entry);
298
247
  await failAt?.("after-journal");
299
248
  await rollForward(paths, publishOptions, entry);
249
+ await indexCache.commit({ before, after: await readActiveJournal(paths, root), deltas, deadlineAt });
300
250
  return result;
301
251
  });
302
252
  }
@@ -343,13 +293,8 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
343
293
  * pass over every session the workspace ever had, several times a hook.
344
294
  */
345
295
  async function stateRecord(workspace, kind, id) {
346
- const filePath = statePath(paths, kind, id);
347
- const found = await readJsonIfPresent(filePath, root);
348
- if (found === null) return null;
349
- const envelope = assertStateBinding(found.value, kind, id, filePath);
350
- if (await stateGenerationIsDeleted(paths, root, kind, envelope.id, envelope.generation)) return null;
351
- validateRecord(kind, envelope.record);
352
- return envelope.record.workspaceId === workspace ? envelope.record : null;
296
+ const envelope = await readStateEnvelope(paths, { root }, kind, id);
297
+ return envelope?.record.workspaceId === workspace ? envelope.record : null;
353
298
  }
354
299
 
355
300
  async function snapshot(workspace, { kinds } = {}) {
@@ -446,7 +391,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
446
391
  */
447
392
  async function stateEnvelopes(workspace, { kinds } = {}) {
448
393
  const wanted = kinds === undefined ? null : new Set(kinds);
449
- return [...(await loadAllState(paths, root, wanted)).values()]
394
+ return [...(await loadStateEnvelopes(paths, { root }, wanted)).values()]
450
395
  .filter(envelope => envelope.record.workspaceId === workspace);
451
396
  }
452
397
 
@@ -460,13 +405,18 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
460
405
  async function reclaimRecords(plan, { limit, deadlineAt } = {}) {
461
406
  const bounded = Math.min(deadlineAt ?? Infinity, storeDeadline ?? Infinity);
462
407
  return withWriterMutex(paths, { ...publishOptions, deadlineAt: bounded }, async () => {
408
+ for (const entry of await readOpenJournals(paths, root)) await rollForward(paths, publishOptions, entry);
463
409
  // A function is decided here, holding the mutex, because eligibility is a
464
410
  // statement about relationships between records and not only about each
465
411
  // record. The generation on an entry proves that record did not change;
466
412
  // it says nothing about a session opening for a participant this was
467
413
  // about to remove. Deciding inside the lock is what closes that.
468
414
  const entries = typeof plan === "function" ? await plan() : plan;
469
- return reclaimStateRecords(paths, entries, { root, limit, deadlineAt: bounded });
415
+ const reclaim = onCondemned => reclaimStateRecords(paths, entries,
416
+ { root, limit, deadlineAt: bounded, onCondemned });
417
+ if (!entries.some(entry => ["message", "receipt"].includes(entry.kind))) return reclaim();
418
+ return withIndexedPrune(paths, { ...publishOptions, ids,
419
+ firstSequence: pad(await nextSequence(paths, root)), deadlineAt: bounded }, reclaim);
470
420
  });
471
421
  }
472
422
 
@@ -484,5 +434,6 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
484
434
  }
485
435
 
486
436
  return Object.freeze({ transaction, eventsSince, snapshot, stateRecord, stateEnvelopes,
487
- reclaimRecords, trimHistory, ephemeral, paths, root, workspaceId });
437
+ reclaimRecords, trimHistory, ephemeral, paths, root, workspaceId,
438
+ indexDiagnostics: indexCache.diagnostics });
488
439
  }
@@ -0,0 +1,131 @@
1
+ import { AccError, EXIT, assertPortableId, validateRecord }
2
+ from "@agents-can-communicate/protocol";
3
+
4
+ export function transactionKinds(kinds, exactKinds = []) {
5
+ const wanted = kinds === undefined ? null : new Set(kinds);
6
+ if (!Array.isArray(exactKinds) || exactKinds.some(kind => wanted === null || !wanted.has(kind))) {
7
+ throw new AccError(EXIT.DATA, "exactKinds must be a subset of explicit transaction kinds",
8
+ { kinds, exactKinds });
9
+ }
10
+ return { wanted, exact: new Set(exactKinds),
11
+ eager: wanted === null ? null : new Set([...wanted].filter(kind => !exactKinds.includes(kind))) };
12
+ }
13
+
14
+ export function createTransactionView({ kinds, exactKinds = [], loaded, loadEnvelope,
15
+ ids, appendEvent, lookupIndex }) {
16
+ const { wanted, exact } = transactionKinds(kinds, exactKinds);
17
+ const staged = new Map(), pending = new Map(), operations = new Set();
18
+ let accepting = true;
19
+ const declared = kind => {
20
+ if (wanted !== null && !wanted.has(kind)) {
21
+ throw new AccError(EXIT.DATA, `this transaction did not declare ${kind}, so it was never read`,
22
+ { kind, declared: [...wanted] });
23
+ }
24
+ };
25
+ const loadedId = (kind, id) => {
26
+ declared(kind);
27
+ if (exact.has(kind) && !loaded.has(`${kind}:${id}`)) {
28
+ throw new AccError(EXIT.DATA, "an exact transaction record must be loaded before access", { kind, id });
29
+ }
30
+ };
31
+ const active = () => {
32
+ if (!accepting) throw new AccError(EXIT.DATA, "transaction async reads are closed");
33
+ };
34
+ const entryFor = key => {
35
+ const entry = staged.get(key) ?? loaded.get(key) ?? null;
36
+ return entry?.removed === true ? null : entry;
37
+ };
38
+ const track = operation => {
39
+ operations.add(operation);
40
+ operation.catch(() => {}); // finish() propagates a rejected unawaited read under the lock.
41
+ return operation;
42
+ };
43
+ const read = async (kind, id) => {
44
+ declared(kind);
45
+ assertPortableId(kind, "record kind");
46
+ assertPortableId(id, "record id");
47
+ const key = `${kind}:${id}`;
48
+ if (staged.has(key) || loaded.has(key)) return entryFor(key);
49
+ if (!exact.has(kind)) return null;
50
+ if (!pending.has(key)) pending.set(key, track(Promise.resolve().then(() => loadEnvelope(kind, id))));
51
+ return pending.get(key);
52
+ };
53
+ const tx = Object.freeze({
54
+ async load(kind, id) {
55
+ active();
56
+ const key = `${kind}:${id}`;
57
+ if (!loaded.has(key)) {
58
+ const envelope = await read(kind, id);
59
+ loaded.set(key, envelope);
60
+ }
61
+ else declared(kind);
62
+ return entryFor(key)?.record ?? null;
63
+ },
64
+ async lookup(index, tuple) {
65
+ active();
66
+ if (typeof lookupIndex !== "function") {
67
+ throw new AccError(EXIT.USAGE, "the transaction store does not support indexed lookup");
68
+ }
69
+ return track(Promise.resolve().then(() => lookupIndex(index, tuple, { declared, loaded, staged, read })));
70
+ },
71
+ get(kind, id) {
72
+ loadedId(kind, id);
73
+ return entryFor(`${kind}:${id}`)?.record ?? null;
74
+ },
75
+ generationOf(kind, id) {
76
+ loadedId(kind, id);
77
+ return entryFor(`${kind}:${id}`)?.generation ?? null;
78
+ },
79
+ list(kind, predicate = () => true) {
80
+ declared(kind);
81
+ if (exact.has(kind)) throw new AccError(EXIT.DATA, "an exact transaction kind cannot be listed", { kind });
82
+ const merged = new Map();
83
+ for (const source of [loaded, staged]) {
84
+ for (const [key, entry] of source) if (entry?.kind === kind) merged.set(key, entry);
85
+ }
86
+ return [...merged.values()].filter(entry => entry.removed !== true)
87
+ .map(entry => entry.record).filter(predicate);
88
+ },
89
+ put(kind, id, record, expectedGeneration = null) {
90
+ loadedId(kind, id);
91
+ const key = `${kind}:${id}`, actual = entryFor(key)?.generation ?? null;
92
+ if (actual !== expectedGeneration) {
93
+ throw new AccError(EXIT.CONFLICT, `${kind} ${id} changed under this transaction`,
94
+ { kind, id, expectedGeneration, actualGeneration: actual });
95
+ }
96
+ validateRecord(kind, record);
97
+ const generation = ids.next("generation");
98
+ staged.set(key, { kind, id, record, generation });
99
+ return generation;
100
+ },
101
+ remove(kind, id, expectedGeneration = null) {
102
+ loadedId(kind, id);
103
+ const key = `${kind}:${id}`, actual = entryFor(key)?.generation ?? null;
104
+ if (actual !== expectedGeneration) {
105
+ throw new AccError(EXIT.CONFLICT, `${kind} ${id} changed under this transaction`,
106
+ { kind, id, expectedGeneration, actualGeneration: actual });
107
+ }
108
+ const persisted = loaded.get(key);
109
+ if (persisted == null) staged.delete(key);
110
+ else staged.set(key, { kind, id, generation: persisted.generation, removed: true });
111
+ },
112
+ append: appendEvent,
113
+ });
114
+ async function finish() {
115
+ accepting = false;
116
+ const drained = new Set();
117
+ let failed = false, reason;
118
+ // A rejection cannot release the writer while sibling reads still run.
119
+ // In-flight lookups may register further primary reads while we drain.
120
+ for (;;) {
121
+ const batch = [...operations].filter(operation => !drained.has(operation));
122
+ if (batch.length === 0) break;
123
+ for (const operation of batch) drained.add(operation);
124
+ for (const result of await Promise.allSettled(batch)) {
125
+ if (result.status === "rejected" && !failed) { failed = true; reason = result.reason; }
126
+ }
127
+ }
128
+ if (failed) throw reason;
129
+ }
130
+ return { tx, staged, finish };
131
+ }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "accManagedUpdateProtocol": 2,
5
- "accStoreVersion": 6,
5
+ "accStoreVersion": 7,
6
6
  "type": "module",
7
- "description": "Local-first coordination for independently opened AI agent sessions.",
7
+ "description": "Local-first messaging between AI coding sessions in different vendors' clients, such as Claude Code and Codex.",
8
8
  "keywords": [
9
9
  "agents",
10
10
  "coordination",
@@ -14,7 +14,8 @@
14
14
  "hooks",
15
15
  "codex",
16
16
  "claude-code",
17
- "gemini-cli",
17
+ "antigravity",
18
+ "cross-vendor",
18
19
  "kimi",
19
20
  "grok",
20
21
  "multi-agent",