agents-can-communicate 0.8.1 → 0.8.3

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 (54) hide show
  1. package/docs/ADAPTER_AUTHORING.md +14 -0
  2. package/docs/CAPABILITIES.md +1 -1
  3. package/docs/CLI.md +16 -0
  4. package/docs/CONFIGURATION.md +73 -8
  5. package/docs/GETTING_STARTED.md +6 -3
  6. package/docs/HOW_IT_WORKS.md +3 -1
  7. package/docs/SECURITY_MODEL.md +12 -1
  8. package/docs/TROUBLESHOOTING.md +32 -5
  9. package/docs/UPGRADING.md +40 -1
  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-antigravity/plugin/skills/acc/SKILL.md +7 -0
  13. package/node_modules/@agents-can-communicate/adapter-antigravity/src/adapter.mjs +3 -1
  14. package/node_modules/@agents-can-communicate/adapter-antigravity/src/allow-rule.mjs +204 -0
  15. package/node_modules/@agents-can-communicate/adapter-antigravity/src/install.mjs +45 -10
  16. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  17. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +9 -0
  18. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +3 -1
  19. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-observed-directories.mjs +62 -0
  20. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-socket-directories.mjs +41 -0
  21. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  22. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +3 -0
  23. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +5 -4
  24. package/node_modules/@agents-can-communicate/adapter-codex/src/live-permissions.mjs +64 -12
  25. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  26. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  27. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  28. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  29. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  30. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +7 -0
  31. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +16 -2
  32. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -1
  33. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  34. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +9 -4
  35. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +11 -4
  36. package/node_modules/@agents-can-communicate/cli/src/main.mjs +44 -6
  37. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +3 -1
  38. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +20 -10
  39. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  40. package/node_modules/@agents-can-communicate/core/src/status.mjs +12 -0
  41. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  42. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  43. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +9 -0
  44. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  45. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +10 -3
  46. package/node_modules/@agents-can-communicate/installer/src/index.mjs +1 -0
  47. package/node_modules/@agents-can-communicate/installer/src/receiver-sockets.mjs +19 -0
  48. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  49. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  50. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  51. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +30 -4
  52. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +56 -25
  53. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +6 -2
  54. package/package.json +1 -1
@@ -7,7 +7,7 @@ import { createCodexAdapter } from "@agents-can-communicate/adapter-codex";
7
7
  import { createGeminiCliAdapter } from "@agents-can-communicate/adapter-gemini-cli";
8
8
  import { createGrokAdapter } from "@agents-can-communicate/adapter-grok";
9
9
  import { createKimiAdapter } from "@agents-can-communicate/adapter-kimi";
10
- import { applyPlan, detectInstallation, loadOwnership, planInstallation }
10
+ import { applyPlan, detectInstallation, loadOwnership, planInstallation, receiverSockets }
11
11
  from "@agents-can-communicate/installer";
12
12
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
13
13
 
@@ -28,7 +28,7 @@ export { decideDelivery } from "./install-delivery-consent.mjs";
28
28
  // pointed at the home itself writes beside them rather than inside them. That
29
29
  // install reports success and the client never reads a byte of it.
30
30
  export const clientContext = (home, stateRoot,
31
- { env = {}, dataHome, cwd = process.cwd() } = {}) => ({
31
+ { env = {}, dataHome, cwd = process.cwd(), platform } = {}) => withReceivers({
32
32
  home,
33
33
  ...(dataHome === undefined ? {} : { dataHome }),
34
34
  configDir: path.join(home, ".claude"),
@@ -59,7 +59,14 @@ export const clientContext = (home, stateRoot,
59
59
  // roster and record nothing, every write failing with EPERM on the writer
60
60
  // lock. Measured with `codex exec`, which is how an agent actually runs.
61
61
  stateRoot,
62
- });
62
+ }, platform);
63
+
64
+ // Every socket ACC can deliver to, as each receiving adapter declares it, for
65
+ // the one sender that sandboxes its shell: the Codex permission profile is
66
+ // written and diagnosed from this list, so it covers Claude Code's inbox even
67
+ // when an install names Codex alone (issue #213).
68
+ const withReceivers = (context, platform) => ({ ...context,
69
+ receiverSockets: receiverSockets(ALL_ADAPTERS(), { ...context, platform }) });
63
70
 
64
71
  export const ALL_ADAPTERS = () => [createAntigravityAdapter(), createClaudeCodeAdapter(),
65
72
  createCodexAdapter(), createGeminiCliAdapter(), createGrokAdapter(), createKimiAdapter()];
@@ -222,7 +229,7 @@ export async function runInstallCommand({ options, runtime, action = "install" }
222
229
  const { data: dataHome } = platformPaths({ platform: runtime.platform,
223
230
  env: runtime.env ?? {} });
224
231
  const context = clientContext(home, path.join(dataHome, "acc"),
225
- { env: runtime.env ?? {}, dataHome });
232
+ { env: runtime.env ?? {}, dataHome, platform: runtime.platform });
226
233
 
227
234
  const detected = await detectInstallation({ adapters, context,
228
235
  probeTimeoutMs: probeTimeout(runtime.env) });
@@ -16,7 +16,7 @@ const usage = message => new AccError(EXIT.USAGE, message);
16
16
  import { commandHelpText, describeCommand, describeCommands, helpText } from "./help.mjs";
17
17
  import { runUpdateCommand } from "./update-command.mjs";
18
18
  import { runConfigCommand } from "./config-command.mjs";
19
- import { runInstallCommand } from "./install-command.mjs";
19
+ import { ALL_ADAPTERS, runInstallCommand } from "./install-command.mjs";
20
20
  import { runDoctor } from "./doctor-command.mjs";
21
21
  import { runManagementDoctor } from "./managed-runtime/diagnostics.mjs";
22
22
  import { createGitProbe } from "./git-probe.mjs";
@@ -179,12 +179,49 @@ export async function recordAndOffer({ record, router, selectMessage = value =>
179
179
  }
180
180
  }
181
181
 
182
- /** How many are here, and how many only look it. */
183
- export function describePresence({ live = 0, stale = 0 } = {}) {
182
+ /**
183
+ * What a message sent to each stale session would meet.
184
+ *
185
+ * Presence reads the client process, so a dead one is offline and a stale
186
+ * session with a live process is idle. Idle is then told apart by delivery: a
187
+ * live binding wakes it, a client with next-turn delivery shows the message on
188
+ * its next turn, and anything else reads it through `acc inbox`. A session with
189
+ * no known process cannot be told from one that left, so it is not answering.
190
+ *
191
+ * A live binding wakes on the router's own rule: a current lease, or a lapsed
192
+ * one the adapter can re-verify. Without that the next send is durable.
193
+ */
194
+ export function presenceBreakdown(participants = [], adapters = ALL_ADAPTERS()) {
195
+ const nextTurn = new Set(adapters.filter(adapter => adapter.capabilities?.delivery?.nextTurn === true)
196
+ .map(adapter => adapter.id));
197
+ const refreshes = new Set(adapters.filter(adapter => typeof adapter.refreshNativeSession === "function")
198
+ .map(adapter => adapter.id));
199
+ const wakes = participant => participant.liveDelivery === true
200
+ && (participant.liveLeaseCurrent === true || refreshes.has(participant.harness));
201
+ const idle = { wake: 0, nextTurn: 0, inbox: 0, unknown: 0 };
202
+ for (const participant of participants.filter(item => item.presence === "stale")) {
203
+ const kind = participant.processTracked !== true ? "unknown"
204
+ : wakes(participant) ? "wake"
205
+ : nextTurn.has(participant.harness) ? "nextTurn" : "inbox";
206
+ idle[kind] += 1;
207
+ }
208
+ return idle;
209
+ }
210
+
211
+ /**
212
+ * How many are here, and what a message would meet with those not active.
213
+ * Without a breakdown every stale session counts as not answering, the answer
214
+ * before presence could tell an idle session from one whose client had left.
215
+ */
216
+ export function describePresence({ live = 0, stale = 0 } = {},
217
+ idle = { wake: 0, nextTurn: 0, inbox: 0, unknown: stale }) {
184
218
  if (live === 0) return "0 live";
185
219
  if (stale === 0) return `${live} live`;
186
- if (stale === live) return `${live} present, none answering`;
187
- return `${live} live (${stale} not answering)`;
220
+ if (idle.unknown === live) return `${live} present, none answering`;
221
+ const parts = [[idle.wake, "idle, wake on send"], [idle.nextTurn, "idle until next turn"],
222
+ [idle.inbox, "idle, inbox only"], [idle.unknown, "not answering"]]
223
+ .filter(([count]) => count > 0).map(([count, text]) => `${count} ${text}`);
224
+ return `${live} live (${parts.join("; ")})`;
188
225
  }
189
226
 
190
227
  export function describeStatus(status) {
@@ -193,7 +230,8 @@ export function describeStatus(status) {
193
230
  // stops being answered but does not disappear. Printing the number alone
194
231
  // said "1 live" about a workspace where the last agent had left minutes
195
232
  // before, which is the one thing a person reads this line to find out.
196
- const line = `${describePresence(status.counts)}; ${status.counts.claims} claim(s); `
233
+ const line = `${describePresence(status.counts, presenceBreakdown(status.participants))}; `
234
+ + `${status.counts.claims} claim(s); `
197
235
  + `protection ${status.protection}`;
198
236
  // A transport accepting a message is not a model reading it. Said only when
199
237
  // it is true of something, so the everyday line stays the everyday line.
@@ -52,7 +52,9 @@ export async function downloadRelease(root, release, { env = process.env } = {})
52
52
  const temporary = await mkdtemp(path.join(downloads, ".download-"));
53
53
  try {
54
54
  await writeFile(path.join(temporary, "package.json"), JSON.stringify({ private: true, name: "acc-runtime-download", version: "1.0.0" }));
55
- await exec(process.execPath, [await npmCli(env), "install", "--ignore-scripts", "--omit=dev",
55
+ // Discovery asked the registry itself; --prefer-online makes npm ask too,
56
+ // instead of trusting a package document it cached before the release (#221).
57
+ await exec(process.execPath, [await npmCli(env), "install", "--prefer-online", "--ignore-scripts", "--omit=dev",
56
58
  "--global=false", "--prefix", temporary, "--package-lock=true",
57
59
  "--package-lock-only=false", "--dry-run=false", "--workspaces=false",
58
60
  "--include-workspace-root=false", "--no-audit", "--no-fund", "--save=true", "--save-exact", "--registry", release.registry,
@@ -60,14 +60,19 @@ export async function updateNativeSessions(adapters, { service, status, root, no
60
60
  && b.availableModes.includes("livePush"));
61
61
  const off = native.policySource === "installation-record" && !native.configured;
62
62
  const leased = binding !== undefined && Date.parse(binding.leaseUntil) > Date.parse(now);
63
- // Only where the old rule would have said "active": everywhere else the
64
- // answer is already degraded or unbound, and a live check would buy
65
- // nothing but a socket round trip.
66
- const delivery = off || !leased ? null
63
+ // A lapsed lease is asked about too. Nothing renews it while a session
64
+ // sits idle, and the router does not refuse it: it re-verifies the
65
+ // receiver and refreshes the lease before it offers. Stopping at the
66
+ // lease reported sessions that answered a live question in seconds as
67
+ // degraded, with advice to start a new one.
68
+ const delivery = off || binding === undefined ? null
67
69
  : await verifyDelivery(byAdapterId.get(entry.adapterId), { binding, runtimeDir: root });
70
+ // Without a re-verification the router cannot refresh a lapsed lease
71
+ // either, so only a live lease keeps the old answer.
68
72
  const runtime = off ? "inactive"
69
73
  : binding === undefined ? "unbound"
70
- : leased && (delivery === null || delivery.deliverable) ? "active" : "degraded";
74
+ : delivery === null ? (leased ? "active" : "degraded")
75
+ : !delivery.deliverable ? "degraded" : leased ? "active" : "idle";
71
76
  native.sessions.push({ sessionId: peer.sessionId, participantId: peer.participantId,
72
77
  presence: peer.presence, runtime, clientVersion: binding?.clientVersion ?? null, delivery,
73
78
  lastAttempt: owner === null ? null : await loadNativeAttempt({ runtimeDir: root, ...owner }) });
@@ -76,10 +81,13 @@ export async function updateNativeSessions(adapters, { service, status, root, no
76
81
  // one claiming health while nothing could be delivered. If this adapter
77
82
  // has bound sessions in this workspace and not one of them can take a
78
83
  // push, this is not an active transport.
79
- if (native.runtime === "active" && native.sessions.length > 0
80
- && !native.sessions.some(session => session.runtime === "active")) {
84
+ const reachable = native.sessions.some(session => ["active", "idle"].includes(session.runtime));
85
+ if (native.runtime === "active" && native.sessions.length > 0 && !reachable) {
81
86
  native.runtime = "degraded";
82
87
  }
88
+ // The other direction, which the binding pass cannot see: it reads a
89
+ // lapsed lease as unreachable, and a receiver that answered says otherwise.
90
+ if (native.runtime === "degraded" && reachable) native.runtime = "active";
83
91
  }
84
92
  }
85
93
 
@@ -131,10 +139,12 @@ function describeDelivery(session) {
131
139
  : "receiver verified";
132
140
  }
133
141
 
142
+ const RUNTIME_TEXT = { active: "local transport active", idle: "idle, lease lapsed" };
143
+
134
144
  export function nativeSessionLines(adapters) {
135
145
  return adapters.flatMap(entry => (entry.nativeDelivery.sessions ?? []).map(session =>
136
146
  ` ${entry.displayName} session ${session.participantId} (${session.sessionId}): `
137
- + `${session.runtime === "active" ? "local transport active" : session.runtime}; `
138
- + [describeDelivery(session), describeAttempt(session.lastAttempt)]
139
- .filter(part => part !== null).join("; ")));
147
+ + `${RUNTIME_TEXT[session.runtime] ?? session.runtime}; `
148
+ + [describeDelivery(session), session.runtime === "idle" ? "the next send refreshes it" : null,
149
+ describeAttempt(session.lastAttempt)].filter(part => part !== null).join("; ")));
140
150
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -89,6 +89,12 @@ export function createStatusService(ports, sessions, deliveryBindings) {
89
89
  const claims = snapshot.claims
90
90
  .filter(claim => Date.parse(claim.expiresAt) > Date.parse(now));
91
91
  const currentBindings = await deliveryBindings.currentBindings(now);
92
+ // A live binding and its lease are reported apart. The router refreshes a
93
+ // lapsed lease only through the adapter's own re-verification, and which
94
+ // adapters have one is not a fact core may know, so the reader decides.
95
+ const liveBound = new Map(currentBindings
96
+ .filter(({ binding }) => binding.availableModes.includes("livePush"))
97
+ .map(({ binding, reachable }) => [binding.sessionId, reachable]));
92
98
  // What each recipient has not fetched yet, and how far it got. Offered is
93
99
  // not read: a transport took the bytes. Queued sits beside it so "0
94
100
  // offered" cannot be mistaken for "nothing waits". Replaced decisions
@@ -150,6 +156,12 @@ export function createStatusService(ports, sessions, deliveryBindings) {
150
156
  enforcement: session.enforcement ?? "advisory",
151
157
  lifecycle: session.lifecycle ?? "manual",
152
158
  presence,
159
+ // What a stale presence means depends on these. With the process known,
160
+ // stale is idle, since a dead process is offline; with a live binding,
161
+ // a push can wake it. Only a session nobody can observe is not answering.
162
+ processTracked: (session.pid ?? null) !== null,
163
+ liveDelivery: liveBound.has(session.sessionId),
164
+ liveLeaseCurrent: liveBound.get(session.sessionId) === true,
153
165
  // A finished intent reads as no intent at all. `--clear` exists so a
154
166
  // peer stops seeing work that has stopped, and reporting the summary of
155
167
  // a done record made the command and this line contradict each other.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/delivery-router",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -602,6 +602,15 @@ const HANDLERS = {
602
602
  async beforeTool({ binding, context, event, adapter }) {
603
603
  if (binding === null) return { decision: "allow" };
604
604
  if (event.targets.length === 0) {
605
+ // A shell command is work too. The refresh below sat behind the write
606
+ // guard, so a turn spent in the shell went stale three minutes in, measured
607
+ // on 0.8.1. With no guard read to borrow from, the session's own record is
608
+ // the narrow read that says whether a heartbeat is due.
609
+ const own = await context.service.locateSession(binding.accSessionId).catch(() => null);
610
+ if (own !== null && needsRefresh(own.record.heartbeatAt, Date.now())) {
611
+ await context.service.heartbeatSession({ sessionId: binding.accSessionId,
612
+ generation: binding.generation }).catch(() => null);
613
+ }
605
614
  // The runner cannot tell what a shell command touches. Saying so is the
606
615
  // honest answer; guessing would block work at random and miss real writes.
607
616
  return { decision: "allow", unguarded: true };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -31,8 +31,10 @@ const VERSION = /(?:^|[^0-9A-Za-z])v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)(?:\b
31
31
  * has: nothing is written, and a client that is not installed simply fails to
32
32
  * start.
33
33
  */
34
- export const spawnProbe = async (command, args) => {
35
- const { stdout, stderr } = await run(command, args);
34
+ // `env` decides where the command is looked up, so detection given an
35
+ // environment finds the client on that environment's PATH.
36
+ export const spawnProbe = async (command, args, { env } = {}) => {
37
+ const { stdout, stderr } = await run(command, args, env === undefined ? {} : { env });
36
38
  return `${stdout}${stderr}`.trim();
37
39
  };
38
40
 
@@ -122,8 +124,11 @@ export async function detectInstallation({ adapters, context, probe = spawnProbe
122
124
  const output = await withTimeout(
123
125
  // The declared binary, never the adapter id. Guessing the id made
124
126
  // every client whose command differs from it look uninstalled.
127
+ // On the PATH the native probes below use: a caller that passes its
128
+ // own environment must not find a different client, or none.
125
129
  Promise.resolve(probe(adapter.client.command,
126
- adapter.client.versionArgs ?? ["--version"])),
130
+ adapter.client.versionArgs ?? ["--version"],
131
+ { env: context?.env === undefined ? undefined : { ...process.env, ...context.env } })),
127
132
  probeTimeoutMs, `${adapter.id} version probe`);
128
133
  if (typeof output === "string" && output !== "") {
129
134
  entry.present = true;
@@ -160,6 +165,8 @@ export async function detectInstallation({ adapters, context, probe = spawnProbe
160
165
  if (detected.blocked) entry.blocked = detected.blocked;
161
166
  if (detected.outgoingDelivery) entry.outgoingDelivery = detected.outgoingDelivery;
162
167
  if (detected.inboundDelivery) entry.inboundDelivery = detected.inboundDelivery;
168
+ // Whether the client asks before each ACC command, and what would stop it.
169
+ if (detected.commandApproval) entry.commandApproval = detected.commandApproval;
163
170
  if (detected.nativeSetup) entry.nativeSetup = detected.nativeSetup;
164
171
  entry.diagnostics = [...(detected.diagnostics ?? [])];
165
172
  // What a person has to do, as opposed to what is true. Adapters that
@@ -1,6 +1,7 @@
1
1
  // Detection, planning, application, and the record of what was installed.
2
2
  export { detectInstallation, spawnProbe } from "./detect.mjs";
3
3
  export { planInstallation } from "./plan.mjs";
4
+ export { receiverSockets } from "./receiver-sockets.mjs";
4
5
  export { applyPlan } from "./apply.mjs";
5
6
  export { finalizeRemoval, fingerprint, loadOwnership, missingArtifactParents, recordInstall,
6
7
  removeEmptyOwnedDirectories, removeOwned, removeOwnedArtifacts, treeFingerprint, verifyOwned }
@@ -0,0 +1,19 @@
1
+ import path from "node:path";
2
+
3
+ /**
4
+ * Every Unix socket path ACC can deliver to on this machine.
5
+ *
6
+ * A sender whose client sandboxes its shell - Codex - has to be allowed to
7
+ * connect to the recipient's socket, and the recipient's adapter is the only
8
+ * one that knows where that socket lives. Each adapter declares its own through
9
+ * `inboundSockets(context)`; the sender's adapter receives the composed list as
10
+ * `context.receiverSockets` and never names another vendor's paths itself.
11
+ *
12
+ * Composed from every known adapter, not the ones an install names: installing
13
+ * Codex alone must still allow a Claude Code session installed before or after.
14
+ */
15
+ export function receiverSockets(adapters, context) {
16
+ const paths = [...adapters].flatMap(adapter =>
17
+ typeof adapter.inboundSockets === "function" ? adapter.inboundSockets(context) ?? [] : []);
18
+ return [...new Set(paths.filter(item => typeof item === "string" && path.isAbsolute(item)))].sort();
19
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/mcp-server",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/protocol",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/storage-filesystem",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -64,7 +64,8 @@ async function replaceHandleBytes(handle, bytes) {
64
64
  * @returns {Promise<"published" | "already_published">}
65
65
  */
66
66
  export async function publishAtomic(destination, bytes,
67
- { root, tmpDir, stageDir, replace = false, deadlineAt, afterAccepted }) {
67
+ { root, tmpDir, stageDir, replace = false, deadlineAt, afterAccepted, afterStageEnsured,
68
+ afterStageRenamed }) {
68
69
  assertPublicationDeadline(deadlineAt);
69
70
  // The accepted stage lives apart from the partial a failed publication
70
71
  // leaves, so what a file is follows from the directory it was created in
@@ -145,9 +146,8 @@ export async function publishAtomic(destination, bytes,
145
146
  // The seam the race test uses, in the manner retainFile already
146
147
  // establishes: the window this closes is invisible without one.
147
148
  await afterAccepted?.();
148
- await ensureManagedDirectory(root, stageDir);
149
- await rename(temporary, stage);
150
- await syncDirectory(stageDir);
149
+ await retainAcceptedStage({ root, stageDir, temporary, stage, afterStageEnsured,
150
+ afterStageRenamed });
151
151
  } else {
152
152
  // A crash/error before immutable acceptance keeps its unique partial.
153
153
  // Retention avoids the unsafe parent-check/unlink pathname window, while
@@ -157,6 +157,32 @@ export async function publishAtomic(destination, bytes,
157
157
  }
158
158
  }
159
159
 
160
+ // The sweep can take `stage` between any two of these steps: it renames the
161
+ // directory aside, and a store still opening publishes here without the writer
162
+ // mutex the sweep holds (CI on #217). The bytes are already published, so the
163
+ // retained copy goes to whichever directory carries the name when it moves, and
164
+ // a copy the sweep took along with the directory needs no sync here - the sweep
165
+ // discards that directory. The two callbacks are seams for the race tests.
166
+ const STAGE_RETRIES = 4;
167
+
168
+ async function retainAcceptedStage({ root, stageDir, temporary, stage, afterStageEnsured,
169
+ afterStageRenamed }) {
170
+ for (let attempt = 0; ; attempt += 1) {
171
+ await ensureManagedDirectory(root, stageDir);
172
+ await afterStageEnsured?.();
173
+ try {
174
+ await rename(temporary, stage);
175
+ break;
176
+ } catch (error) {
177
+ if (error.code !== "ENOENT" || attempt >= STAGE_RETRIES) throw error;
178
+ }
179
+ }
180
+ await afterStageRenamed?.();
181
+ await syncDirectory(stageDir).catch(error => {
182
+ if (error.code !== "ENOENT") throw error;
183
+ });
184
+ }
185
+
160
186
  // The opener is the seam the race tests use, and stays last so callers that do
161
187
  // not care never see it.
162
188
  export async function readJsonIfPresent(filePath, root, openFile) {
@@ -29,32 +29,42 @@ function absolutePath(value, name, root = value) {
29
29
  return path.normalize(value);
30
30
  }
31
31
 
32
+ // How many more times a create-mode check takes a directory that left its name
33
+ // while being checked. The stage sweep renames `stage` aside and recreates it,
34
+ // without regard to a store that is still opening beside it, so one more try is
35
+ // the real case; the bound only stops a directory that never stays put from
36
+ // holding its caller (CI on #217).
37
+ const VANISHED_RETRIES = 4;
38
+
32
39
  async function inspectRealDirectory(directory, root, create) {
33
- let details;
34
- try {
35
- details = await lstat(directory);
36
- } catch (error) {
37
- if (error.code === "ENOENT" && !create) throw error;
38
- if (error.code !== "ENOENT") {
39
- throw invalidDirectory("cannot inspect managed directory", directory, root, error.message);
40
- }
40
+ for (let attempt = 0; ; attempt += 1) {
41
+ let details;
41
42
  try {
42
- await mkdir(directory);
43
- } catch (mkdirError) {
44
- if (mkdirError.code !== "EEXIST") {
45
- throw invalidDirectory("cannot create managed directory", directory, root,
46
- mkdirError.message);
43
+ details = await lstat(directory);
44
+ } catch (error) {
45
+ if (error.code !== "ENOENT") {
46
+ throw invalidDirectory("cannot inspect managed directory", directory, root, error.message);
47
47
  }
48
+ // Missing again right after it was created means a rename took it.
49
+ if (!create || attempt > VANISHED_RETRIES) throw error;
50
+ try {
51
+ await mkdir(directory);
52
+ } catch (mkdirError) {
53
+ if (mkdirError.code !== "EEXIST") {
54
+ throw invalidDirectory("cannot create managed directory", directory, root,
55
+ mkdirError.message);
56
+ }
57
+ }
58
+ continue;
48
59
  }
49
- details = await lstat(directory);
50
- }
51
- if (!details.isDirectory() || details.isSymbolicLink()) {
52
- throw invalidDirectory("managed directory is not a real directory", directory, root);
60
+ if (!details.isDirectory() || details.isSymbolicLink()) {
61
+ throw invalidDirectory("managed directory is not a real directory", directory, root);
62
+ }
63
+ return details;
53
64
  }
54
- return details;
55
65
  }
56
66
 
57
- async function inspectManagedDirectory(rootPath, directoryPath, create) {
67
+ async function inspectManagedDirectory(rootPath, directoryPath, create, { afterInspect } = {}) {
58
68
  const root = absolutePath(rootPath, "managed root");
59
69
  const directory = absolutePath(directoryPath, "managed directory", root);
60
70
  const relative = relativeWithin(root, directory);
@@ -67,7 +77,17 @@ async function inspectManagedDirectory(rootPath, directoryPath, create) {
67
77
  let canonicalParent = canonicalRoot;
68
78
  for (const segment of relative.split(path.sep).filter(Boolean)) {
69
79
  current = path.join(current, segment);
70
- details = await inspectRealDirectory(current, root, create);
80
+ let resolved;
81
+ for (let attempt = 0; ; attempt += 1) {
82
+ details = await inspectRealDirectory(current, root, create);
83
+ await afterInspect?.(current);
84
+ // A create-mode caller uses this name next. A directory renamed away
85
+ // between the check and the resolution leaves realpath nothing to resolve
86
+ // (ENOENT), so it is checked and created again, then resolved again; every
87
+ // check below applies to whichever attempt settles.
88
+ resolved = await resolveSegment(current, create && attempt < VANISHED_RETRIES);
89
+ if (resolved !== null) break;
90
+ }
71
91
  // realpath answers with the name the directory carries *now*, which is not
72
92
  // always the name that was asked for: on Darwin it resolves by opening the
73
93
  // path and asking the kernel for that vnode's current path, so a directory
@@ -86,7 +106,6 @@ async function inspectManagedDirectory(rootPath, directoryPath, create) {
86
106
  // swapped in under this name is not. ENOENT on the re-stat means it moved
87
107
  // again between the two calls, which cannot redirect anything: whatever
88
108
  // the caller opens through this path next fails the same way.
89
- const resolved = await realpath(current);
90
109
  if (path.dirname(resolved) !== canonicalParent
91
110
  || (path.basename(resolved) !== segment
92
111
  && !await stillTheSameDirectory(details, resolved, current, root))) {
@@ -97,6 +116,16 @@ async function inspectManagedDirectory(rootPath, directoryPath, create) {
97
116
  return { directory, stat: details };
98
117
  }
99
118
 
119
+ // null when the name went away and the caller may take it again.
120
+ async function resolveSegment(current, retry) {
121
+ try {
122
+ return await realpath(current);
123
+ } catch (error) {
124
+ if (error.code === "ENOENT" && retry) return null;
125
+ throw error;
126
+ }
127
+ }
128
+
100
129
  async function stillTheSameDirectory(details, resolved, current, root) {
101
130
  const served = await stat(resolved).then(found => found, error => {
102
131
  if (error.code === "ENOENT") return null;
@@ -105,10 +134,12 @@ async function stillTheSameDirectory(details, resolved, current, root) {
105
134
  return served === null || (served.dev === details.dev && served.ino === details.ino);
106
135
  }
107
136
 
108
- export async function assertManagedDirectory(rootPath, directoryPath) {
109
- return inspectManagedDirectory(rootPath, directoryPath, false);
137
+ // `afterInspect` is the seam the race tests use: it runs between a segment's
138
+ // check and its resolution, the window a concurrent rename lands in.
139
+ export async function assertManagedDirectory(rootPath, directoryPath, options) {
140
+ return inspectManagedDirectory(rootPath, directoryPath, false, options);
110
141
  }
111
142
 
112
- export async function ensureManagedDirectory(rootPath, directoryPath) {
113
- return (await inspectManagedDirectory(rootPath, directoryPath, true)).directory;
143
+ export async function ensureManagedDirectory(rootPath, directoryPath, options) {
144
+ return (await inspectManagedDirectory(rootPath, directoryPath, true, options)).directory;
114
145
  }
@@ -121,8 +121,12 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
121
121
  // must not roll the same journal forward concurrently.
122
122
  await recoverOpenJournals();
123
123
 
124
- // Sweeping is a write, so it holds the same mutex recovery does and no
125
- // publisher is in flight while the stage directory is detached. It is bounded
124
+ // Sweeping is a write, so it holds the same mutex recovery does, and no
125
+ // transaction publishes while the stage directory is detached. A store that is
126
+ // still opening beside it does: the identity record, the directory checks and
127
+ // the journal's first slot above run without the mutex, so the managed
128
+ // directory checks and publishAtomic take a `stage` that left its name once
129
+ // more rather than failing (CI on #217). It is bounded
126
130
  // per pass: a store carrying a large accumulation drains over several opens
127
131
  // rather than spending one hook's whole budget on it. The mutex is taken only
128
132
  // on the opens that actually sweep - see sweepIfDue, which decides before it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "accManagedUpdateProtocol": 2,
5
5
  "accStoreVersion": 6,
6
6
  "type": "module",