agents-can-communicate 0.4.4 → 0.5.1

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 (55) hide show
  1. package/README.md +5 -3
  2. package/bin/entrypoints/acc-hook.mjs +58 -5
  3. package/docs/ARCHITECTURE.md +9 -4
  4. package/docs/CLI.md +12 -8
  5. package/docs/GETTING_STARTED.md +1 -1
  6. package/docs/PROTOCOL.md +7 -0
  7. package/docs/RELEASING.md +1 -1
  8. package/docs/SECURITY_MODEL.md +4 -2
  9. package/docs/TROUBLESHOOTING.md +9 -5
  10. package/docs/UPGRADING.md +86 -28
  11. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  12. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  13. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +4 -2
  14. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +4 -0
  15. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +67 -22
  16. package/node_modules/@agents-can-communicate/adapter-codex/src/permission-ownership.mjs +19 -2
  17. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  18. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  19. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  20. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  21. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  22. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -1
  23. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +63 -16
  24. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +16 -1
  25. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  26. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +7 -1
  27. package/node_modules/@agents-can-communicate/cli/src/index.mjs +5 -0
  28. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +187 -11
  29. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/diagnostics.mjs +2 -1
  30. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +17 -2
  31. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +14 -6
  32. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +44 -25
  33. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/install.mjs +46 -29
  34. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-state.mjs +1 -1
  35. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-worker.mjs +9 -1
  36. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +52 -4
  37. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/management-recovery.mjs +18 -9
  38. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/pins.mjs +125 -0
  39. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/retire.mjs +56 -0
  40. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/staging.mjs +95 -0
  41. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +8 -1
  42. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +18 -8
  43. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +83 -7
  44. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  45. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  46. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +44 -2
  47. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  48. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +10 -1
  49. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +55 -6
  50. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  51. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  52. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  53. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  54. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +38 -4
  55. package/package.json +2 -1
@@ -0,0 +1,95 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readdir, rm } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { confirmedDead, defaultPidIsAlive } from "./mutex.mjs";
6
+ import { canonicalManagerRoot, managedDirectory, readManagedJson, syncDirectory, writeManagedJson } from "./state.mjs";
7
+
8
+ const SCHEMA_VERSION = 1;
9
+
10
+ /** Staging renames a fully-formed generation into place, then runs it as a
11
+ * spawned process to verify it, before any control pointer, lease, or pin
12
+ * can reference it - out of the manager lock, since verification can run for
13
+ * several seconds and holding the lock that long would stall every hook on
14
+ * the machine. This hold closes that window: the caller writes it for the
15
+ * generation's final path *before* either the check that may return that
16
+ * path unchanged, or the rename that makes a freshly staged one visible
17
+ * there - so the directory can never be observed without it.
18
+ *
19
+ * Returns the hold's own file path: an opaque handle for attachStagingTemp
20
+ * and releaseStagingHold. The caller owns its lifecycle end to end - this
21
+ * function never releases what it creates. */
22
+ export async function holdStagedGeneration({ root, generationRoot, stagingRoot = null, pid = process.pid }) {
23
+ const directory = path.join(root, "staging");
24
+ await managedDirectory(directory, { create: true });
25
+ const file = path.join(directory, `${randomUUID()}.json`);
26
+ await writeManagedJson(file, { schemaVersion: SCHEMA_VERSION, generationRoot, stagingRoot, pid,
27
+ createdAt: new Date().toISOString() });
28
+ return file;
29
+ }
30
+
31
+ /** Once mkdtemp names the actual in-flight temp, attach it to the same
32
+ * record a live hold already protects: a dead owner's abandoned temp is
33
+ * only ever found by way of this link, since reclaim never otherwise looks
34
+ * at a dot-prefixed generations entry.
35
+ *
36
+ * If the hold has already vanished (readManagedJson returns undefined on
37
+ * ENOENT), do nothing rather than write `{ stagingRoot }` alone: spreading
38
+ * an `undefined` record silently produces exactly the schemaVersion-less,
39
+ * generationRoot-less, pid-less fragment reclaimGenerations reads as a
40
+ * malformed hold - and reapStagingHolds can never reap it either, since it
41
+ * has no valid pid to confirm dead. Manufacturing that record would be a
42
+ * self-inflicted, permanent block on reclamation, not a corruption that
43
+ * needed an external cause. */
44
+ export async function attachStagingTemp(file, stagingRoot) {
45
+ const record = await readManagedJson(file);
46
+ if (record === undefined) return;
47
+ await writeManagedJson(file, { ...record, stagingRoot });
48
+ }
49
+
50
+ /** The caller must call this on every exit from its own staging attempt -
51
+ * success, a failed verify, a timeout, or a thrown error - once the
52
+ * generation is either published (and so independently protected by the
53
+ * control pointer) or abandoned. Confirmed-dead reaping is the backstop for
54
+ * a crash that skips this, never the release mechanism itself. */
55
+ export async function releaseStagingHold(file) {
56
+ if (typeof file === "string" && file) await rm(file, { force: true });
57
+ }
58
+
59
+ const nonempty = value => typeof value === "string" && value.length > 0;
60
+
61
+ /** A staging process that exits without releasing its hold (crash, kill, a
62
+ * SIGKILL mid-verify) leaves both the hold and, if it had gotten that far,
63
+ * its in-flight temp behind - the only sweeper either one ever gets. Same
64
+ * confirmed-death rule as reapPins. The temp is only ever removed alongside
65
+ * its owning record, and only after it is confirmed to sit directly inside
66
+ * this root's own `generations`, named the way mkdtemp names one: a corrupt
67
+ * or foreign path in the record must never turn a reap into an arbitrary
68
+ * delete. */
69
+ export async function reapStagingHolds({ root, pidIsAlive = defaultPidIsAlive } = {}) {
70
+ root = await canonicalManagerRoot(root);
71
+ const directory = path.join(root, "staging");
72
+ if (!await managedDirectory(directory)) return;
73
+ const generations = path.join(root, "generations");
74
+ let removed = false;
75
+ for (const name of await readdir(directory)) {
76
+ if (!name.endsWith(".json")) continue;
77
+ const file = path.join(directory, name);
78
+ const record = await readManagedJson(file).catch(() => null);
79
+ const pid = Number.isSafeInteger(record?.pid) ? record.pid : null;
80
+ if (pid !== null && await confirmedDead(pid, pidIsAlive)) {
81
+ if (nonempty(record.stagingRoot)) {
82
+ // Resolved the same way every generations entry is (see
83
+ // resolveCandidate in activation.mjs): a symlinked data home must
84
+ // never make a real temp look foreign, or a foreign path look real.
85
+ const resolved = await canonicalManagerRoot(record.stagingRoot).catch(() => null);
86
+ if (resolved && path.dirname(resolved) === generations && path.basename(resolved).startsWith(".staging-")) {
87
+ await rm(resolved, { recursive: true, force: true });
88
+ }
89
+ }
90
+ await rm(file, { force: true });
91
+ removed = true;
92
+ }
93
+ }
94
+ if (removed) await syncDirectory(directory);
95
+ }
@@ -77,6 +77,13 @@ const nullableString = value => value === null || nonempty(value);
77
77
  export async function validateRuntime(root, runtime) {
78
78
  if (!runtime || !nonempty(runtime.version) || typeof runtime.root !== "string"
79
79
  || !path.isAbsolute(runtime.root)) throw new Error("invalid control generation");
80
+ // Absent means a pointer written before the contract field existed. Present
81
+ // but malformed is a corrupt record, and reading it as unknown would let an
82
+ // incompatible generation past the gate.
83
+ if (runtime.storeVersion !== undefined && runtime.storeVersion !== null
84
+ && (!Number.isSafeInteger(runtime.storeVersion) || runtime.storeVersion <= 0)) {
85
+ throw new Error("invalid control generation");
86
+ }
80
87
  const generations = path.join(root, "generations");
81
88
  const resolved = await canonicalManagerRoot(runtime.root);
82
89
  const relative = path.relative(generations, resolved);
@@ -88,7 +95,7 @@ export async function validateRuntime(root, runtime) {
88
95
  if (await managedDirectory(generations)) {
89
96
  if (await realpath(generations) !== generations) throw new Error("invalid control generations directory");
90
97
  }
91
- return { version: runtime.version, root: resolved };
98
+ return { version: runtime.version, root: resolved, storeVersion: runtime.storeVersion ?? null };
92
99
  }
93
100
 
94
101
  async function validateControl(root, value) {
@@ -2,6 +2,7 @@ import { activatePending } from "./activation.mjs";
2
2
  import { downloadRelease, fetchRelease, newerVersion } from "./download.mjs";
3
3
  import { withManagerLock } from "./mutex.mjs";
4
4
  import { readControl, writeControl } from "./state.mjs";
5
+ import { releaseStagingHold } from "./staging.mjs";
5
6
  import { MAINTENANCE_ACTIVE, maintenanceNotice, maintenanceReport, readMaintenance } from "./maintenance-state.mjs";
6
7
  import { requestMaintenance } from "./maintenance.mjs";
7
8
 
@@ -40,14 +41,23 @@ export async function performUpdate(root, { force = false, check = false, env =
40
41
  const checked = { ...data, checked: true, latest: release.version, newer };
41
42
  if (check || !newer) return checked;
42
43
  const generation = await download(root, release, { env });
43
- const published = await withManagerLock(root, async () => {
44
- const current = await readControl(root);
45
- if (current.phase !== "ready" || current.active.root !== initial.active.root
46
- || current.pin !== initial.pin || !force && !current.auto) return false;
47
- await writeControl(root, { ...current, pending: generation,
48
- notice: `ACC ${generation.version} is downloaded and verified.` });
49
- return true;
50
- });
44
+ // generation.hold has protected this candidate since it was staged;
45
+ // release it once this publish attempt resolves either way - published,
46
+ // its own control pointer takes over protecting it, and abandoned
47
+ // (state_changed) it is no longer wanted at all.
48
+ let published;
49
+ try {
50
+ published = await withManagerLock(root, async () => {
51
+ const current = await readControl(root);
52
+ if (current.phase !== "ready" || current.active.root !== initial.active.root
53
+ || current.pin !== initial.pin || !force && !current.auto) return false;
54
+ await writeControl(root, { ...current, pending: generation,
55
+ notice: `ACC ${generation.version} is downloaded and verified.` });
56
+ return true;
57
+ });
58
+ } finally {
59
+ await releaseStagingHold(generation.hold);
60
+ }
51
61
  if (!published) return { ...checked, reason: "state_changed" };
52
62
  return { ...checked, pending: generation.version, ...await activate(root, { env, ignorePid }) };
53
63
  }
@@ -1,10 +1,45 @@
1
1
  import { listSessionBindings, loadNativeAttempt } from "@agents-can-communicate/adapter-sdk";
2
2
  import { describeNativeReason } from "@agents-can-communicate/installer";
3
3
 
4
+ // One re-verification per bound session, well inside a doctor run's budget.
5
+ const VERIFY_TIMEOUT_MS = 750;
6
+
7
+ // A lease is not deliverability.
8
+ //
9
+ // Doctor used to answer "local transport active" from the binding record
10
+ // alone: present, policy on, lease not yet expired. All three can hold while
11
+ // every single send falls back to durable, because none of them says the
12
+ // receiver that binding names can still be reached. Measured exactly that way
13
+ // - `Codex CLI live delivery: available; enabled (actionable); local transport
14
+ // active` on a machine where live delivery had never once worked.
15
+ //
16
+ // So ask the adapter the question the router asks: its own bounded
17
+ // re-verification of this exact binding, which resolves the opaque reference
18
+ // to the receiver record, checks the socket, and re-reads the version,
19
+ // protocol, thread and workspace from the process that would actually serve
20
+ // the push. Read-only - it writes no record and renews no lease - and it
21
+ // answers in the closed reason-code vocabulary doctor already speaks. An
22
+ // adapter with no such method gets no verdict and keeps the lease answer.
23
+ async function verifyDelivery(adapter, { binding, runtimeDir }) {
24
+ if (typeof adapter?.refreshNativeSession !== "function") return null;
25
+ try {
26
+ const handshake = await adapter.refreshNativeSession({ binding, runtimeDir,
27
+ timeoutMs: VERIFY_TIMEOUT_MS });
28
+ return handshake?.supported === true
29
+ ? { deliverable: true, reasonCode: null, clientVersion: handshake.clientVersion ?? null }
30
+ : { deliverable: false, reasonCode: handshake?.reasonCode ?? "handshake_failed",
31
+ clientVersion: null };
32
+ } catch {
33
+ return { deliverable: false, reasonCode: "handshake_failed", clientVersion: null };
34
+ }
35
+ }
36
+
4
37
  // Session diagnostics are read only here, never projected into agent context.
5
38
  // Verify the owner generation against core before attributing any last attempt.
6
- export async function updateNativeSessions(adapters, { service, status, root, now }) {
39
+ export async function updateNativeSessions(adapters, { service, status, root, now,
40
+ registry = [] }) {
7
41
  const owners = await listSessionBindings({ runtimeDir: root }).catch(() => []);
42
+ const byAdapterId = new Map((registry ?? []).map(adapter => [adapter.id, adapter]));
8
43
  const transports = new Map();
9
44
  for (const entry of adapters) {
10
45
  const native = entry.nativeDelivery;
@@ -23,13 +58,28 @@ export async function updateNativeSessions(adapters, { service, status, root, no
23
58
  const binding = transports.get(peer.participantId).find(b => b.sessionId === peer.sessionId
24
59
  && b.generation === current.generation && b.adapterId === entry.adapterId
25
60
  && b.availableModes.includes("livePush"));
26
- const runtime = native.policySource === "installation-record" && !native.configured
27
- ? "inactive" : !binding ? "unbound"
28
- : Date.parse(binding.leaseUntil) > Date.parse(now) ? "active" : "degraded";
61
+ const off = native.policySource === "installation-record" && !native.configured;
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
67
+ : await verifyDelivery(byAdapterId.get(entry.adapterId), { binding, runtimeDir: root });
68
+ const runtime = off ? "inactive"
69
+ : binding === undefined ? "unbound"
70
+ : leased && (delivery === null || delivery.deliverable) ? "active" : "degraded";
29
71
  native.sessions.push({ sessionId: peer.sessionId, participantId: peer.participantId,
30
- presence: peer.presence, runtime,
72
+ presence: peer.presence, runtime, clientVersion: binding?.clientVersion ?? null, delivery,
31
73
  lastAttempt: owner === null ? null : await loadNativeAttempt({ runtimeDir: root, ...owner }) });
32
74
  }
75
+ // The adapter's own line is the one a reader sees first, and it was the
76
+ // one claiming health while nothing could be delivered. If this adapter
77
+ // has bound sessions in this workspace and not one of them can take a
78
+ // push, this is not an active transport.
79
+ if (native.runtime === "active" && native.sessions.length > 0
80
+ && !native.sessions.some(session => session.runtime === "active")) {
81
+ native.runtime = "degraded";
82
+ }
33
83
  }
34
84
  }
35
85
 
@@ -37,8 +87,15 @@ const failures = {
37
87
  client_process_unknown: "the client process could not be identified; start a new client session",
38
88
  handshake_failed: "the session handshake failed; check the client's integration/channel setup",
39
89
  handshake_timeout: "the session handshake timed out; check the local channel and retry on the next turn",
40
- handshake_version_mismatch: "the session and CLI versions differ; start a new client session",
90
+ // Since the contract gate shipped, this reason means the version now serving
91
+ // is below the captured minimum - not that two reported versions differ.
92
+ // A daemon serving an older build than its CLI is ordinary and admitted; the
93
+ // fix for this one is to restart the service onto the build the CLI has.
94
+ handshake_version_mismatch: "the version now serving is below the captured native delivery "
95
+ + "minimum; restart the client's local delivery service",
41
96
  session_generation_stale: "the session was replaced during the handshake",
97
+ workspace_identity_unavailable: "the bound client session is no longer in this workspace; "
98
+ + "start a new client session here",
42
99
  };
43
100
 
44
101
  function describeAttempt(attempt) {
@@ -56,9 +113,28 @@ function describeAttempt(attempt) {
56
113
  return `last attempt ${attempt.at}: ${detail}`;
57
114
  }
58
115
 
116
+ // The two records this joins are the delivery binding core published and the
117
+ // receiver the adapter privately holds. Naming a difference between them is
118
+ // the point: one that still delivers is information about a service that
119
+ // updated under its binding, and one that does not is the whole reason a send
120
+ // falls back, said where a reader can act on it.
121
+ function describeDelivery(session) {
122
+ const delivery = session.delivery;
123
+ if (delivery === null || delivery === undefined) return null;
124
+ if (!delivery.deliverable) {
125
+ return "no live delivery: "
126
+ + (failures[delivery.reasonCode] ?? describeNativeReason(delivery.reasonCode));
127
+ }
128
+ return delivery.clientVersion !== null && delivery.clientVersion !== session.clientVersion
129
+ ? `receiver verified, now serving ${delivery.clientVersion} `
130
+ + `where this binding recorded ${session.clientVersion}`
131
+ : "receiver verified";
132
+ }
133
+
59
134
  export function nativeSessionLines(adapters) {
60
135
  return adapters.flatMap(entry => (entry.nativeDelivery.sessions ?? []).map(session =>
61
136
  ` ${entry.displayName} session ${session.participantId} (${session.sessionId}): `
62
137
  + `${session.runtime === "active" ? "local transport active" : session.runtime}; `
63
- + describeAttempt(session.lastAttempt)));
138
+ + [describeDelivery(session), describeAttempt(session.lastAttempt)]
139
+ .filter(part => part !== null).join("; ")));
64
140
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.4.4",
3
+ "version": "0.5.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/delivery-router",
3
- "version": "0.4.4",
3
+ "version": "0.5.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,3 +1,5 @@
1
+ import { evaluateVersionContract } from "@agents-can-communicate/adapter-sdk";
2
+
1
3
  import { refreshExpiredBinding } from "./refresh-binding.mjs";
2
4
 
3
5
  const SAFE_ERRORS = new Set(["ambiguous_recipient_sessions", "delivery_disabled",
@@ -19,6 +21,30 @@ const LIVE_POLICIES = new Set(["off", "actionable", "all"]);
19
21
  const permits = (policy, kind) => policy === "all"
20
22
  || (policy === "actionable" && ACTIONABLE.has(kind));
21
23
 
24
+ // A native-delivery contract is captured per platform, and the only platform a
25
+ // router can judge an offer against is the one it is running on - which is why
26
+ // both entrypoints pass exactly this value. Leaving the parameter optional made
27
+ // its absence silently disable every live offer: `undefined` reached the
28
+ // contract check as "platform_not_captured" and the offer was refused as an
29
+ // unsupported client version. Default it here instead, so a router that is
30
+ // handed nothing behaves as the host it runs on. Injection stays available for
31
+ // callers that need to judge against another platform.
32
+ const HOST_PLATFORM = `${process.platform}-${process.arch}`;
33
+
34
+ // evaluateVersionContract answers two different questions with one field.
35
+ // "below_minimum_version", "known_bad_version", "version_unavailable" and
36
+ // "prerelease_not_captured" are a capture reading the reported version and
37
+ // refusing it. "platform_not_captured" is the opposite: a complete, valid
38
+ // declaration that records nothing for the platform this router runs on. That
39
+ // is not a malformed adapter, it is the ordinary shape of every shipped
40
+ // adapter away from darwin-arm64 - the only platform any of them has captured
41
+ // - so reading it as a refusal disables live delivery on Linux entirely.
42
+ //
43
+ // "native_delivery_unsupported" is absent deliberately: liveCapable already
44
+ // required a nativeDelivery declaration before an offer was attempted, so the
45
+ // contract check is never handed an adapter without one.
46
+ const UNCAPTURED_PLATFORM = "platform_not_captured";
47
+
22
48
  // Compatibility was decided twice already - at bootstrap by the probe and at
23
49
  // SessionStart by the generation-bound handshake that published this binding.
24
50
  // The router validates binding identity and the adapter's answer; it does not
@@ -42,7 +68,8 @@ function safeTransport(value, opaqueEndpointRef) {
42
68
  return opaqueEndpointRef === "live-adapter" ? "native-live" : "live-adapter";
43
69
  }
44
70
 
45
- export function createDeliveryRouter({ service, adapters, clock, platform, readLivePolicy }) {
71
+ export function createDeliveryRouter({ service, adapters, clock, platform = HOST_PLATFORM,
72
+ readLivePolicy }) {
46
73
  const registry = adaptersById(adapters);
47
74
 
48
75
  async function policyFor(adapter, binding) {
@@ -167,7 +194,22 @@ export function createDeliveryRouter({ service, adapters, clock, platform, readL
167
194
  await recordFailure(binding, message, participantId, transport, code);
168
195
  return durable(participantId, code);
169
196
  }
170
- if (response.clientVersion !== binding.clientVersion) {
197
+ // Compatibility was already decided at bind time; a serving version that
198
+ // still satisfies the adapter's captured contract keeps offering, even
199
+ // when it differs from the value recorded when the binding was created.
200
+ // Only a version below the captured minimum or on the denylist refuses.
201
+ // When the contract captured nothing for this platform there is no
202
+ // minimum to judge against, so the offer keeps the rule this path had
203
+ // before the contract gate existed: the version that answered must be the
204
+ // one the binding recorded. defineAdapter rejecting a partial declaration
205
+ // does not make this unreachable - a complete, valid contract that names
206
+ // only darwin-arm64 says nothing about linux-x64, and that is every
207
+ // shipped adapter on Linux.
208
+ const versionRule = evaluateVersionContract(adapter, { clientVersion: response.clientVersion, platform });
209
+ const admitted = versionRule.reasonCode === null
210
+ || (versionRule.reasonCode === UNCAPTURED_PLATFORM
211
+ && response.clientVersion === binding.clientVersion);
212
+ if (!admitted) {
171
213
  await recordFailure(binding, message, participantId, transport,
172
214
  "unsupported_client_version");
173
215
  return durable(participantId, "unsupported_client_version");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.4.4",
3
+ "version": "0.5.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -75,8 +75,17 @@ export async function establishNativeBinding({ adapter, event, hookBinding, clie
75
75
  if (!(lease > now)) return outcome("degraded", "handshake_failed");
76
76
  const ceiling = now + 2 * heartbeatCadenceMs;
77
77
  const leaseUntil = lease > ceiling ? new Date(ceiling).toISOString() : verdict.leaseUntil;
78
+ // The binding's clientVersion is the version this binding's live pushes
79
+ // will be served by, as the handshake reported it and the contract
80
+ // admitted it - not the version `--version` printed. The two differ
81
+ // whenever a client's background service has updated under its CLI, and
82
+ // that is the case native delivery exists to survive. Publishing the
83
+ // detected CLI version instead made the binding disagree with the
84
+ // adapter's own endpoint record, which since 0.5.0 carries the serving
85
+ // version, and every send fell back to durable. The detected CLI version
86
+ // is not lost: the session-owner record beside this one still carries it.
78
87
  await service.publishDeliveryBinding({
79
- sessionId, generation, adapterId: adapter.id, clientVersion,
88
+ sessionId, generation, adapterId: adapter.id, clientVersion: verdict.clientVersion,
80
89
  availableModes: [...verdict.modes], livePolicy: policy,
81
90
  opaqueEndpointRef: verdict.opaqueEndpointRef, leaseUntil,
82
91
  });
@@ -1,14 +1,15 @@
1
1
  import { retireNativeBinding } from "./native-retirement.mjs";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { realpath } from "node:fs/promises";
3
+ import { readFile, realpath } from "node:fs/promises";
4
4
  import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
 
6
7
  import { clearNativeAttempt, clearSessionBinding, effectiveCapabilities, loadSessionBinding, storeSessionBinding }
7
8
  from "@agents-can-communicate/adapter-sdk";
8
9
  import { createCoordinationService } from "@agents-can-communicate/core";
9
10
  import { AccError, createId } from "@agents-can-communicate/protocol";
10
11
  import { openFilesystemStore } from "@agents-can-communicate/storage-filesystem";
11
- import { createGitProbe, discoverWorkspace, platformDataHome, runtimePaths }
12
+ import { clearPin, createGitProbe, discoverWorkspace, platformDataHome, runtimePaths, writePin }
12
13
  from "@agents-can-communicate/cli";
13
14
 
14
15
  import { resolveClientPid } from "./client-pid.mjs";
@@ -178,6 +179,38 @@ export function participantFor(adapterId, harnessSessionId, env = {}) {
178
179
  return `${adapterId}-${suffix}`;
179
180
  }
180
181
 
182
+ // The runtime generation this module ships as part of, found by walking up
183
+ // from its own file for the nearest ancestor manifest that declares a store
184
+ // contract. A workspace package's own manifest (this one's included) carries a
185
+ // `version` but never `accStoreVersion` - only the generation's root manifest
186
+ // does - so this cannot stop at the first `package.json` the way a version
187
+ // lookup could. Answers null facts rather than throwing: a binding published
188
+ // without a readable contract is exactly today's unknown-hold behaviour.
189
+ //
190
+ // `version` rides along from the same manifest read, for a pin (below) to name
191
+ // the generation it names by, distinct from `storeVersion`'s contract number.
192
+ async function runtimeFacts(fromUrl) {
193
+ let directory = path.dirname(fileURLToPath(fromUrl));
194
+ for (;;) {
195
+ const manifest = await readFile(path.join(directory, "package.json"), "utf8")
196
+ .then(JSON.parse).catch(() => null);
197
+ if (Number.isSafeInteger(manifest?.accStoreVersion)) {
198
+ return { storeVersion: manifest.accStoreVersion, runtimeRoot: directory,
199
+ version: typeof manifest.version === "string" ? manifest.version : null };
200
+ }
201
+ const parent = path.dirname(directory);
202
+ if (parent === directory) return { storeVersion: null, runtimeRoot: null, version: null };
203
+ directory = parent;
204
+ }
205
+ }
206
+
207
+ // Where a pin lives: the managed runtime's own root, sibling of `generations`
208
+ // and `leases`, never this workspace's store. The hook entry point that will
209
+ // read a pin (a later task) has not resolved a workspace yet when it needs to
210
+ // - it only has the data home - so a pin keyed under `paths.root` would be
211
+ // unreachable from there.
212
+ const managerRootFor = dataHome => path.join(dataHome, "acc", "runtime");
213
+
181
214
  async function openContext({ cwd, dataHome, runtime, env, deadline }) {
182
215
  assertHookBudget(deadline);
183
216
  const descriptor = await discoverWorkspace({ cwd, env: env ?? {},
@@ -307,12 +340,18 @@ async function projectTurn({ binding, context, adapter, adapterId }) {
307
340
  const HANDLERS = {
308
341
  async sessionStart({ event, context, adapter, adapterId, binding, paths,
309
342
  readProcessTable, probeClientVersion, platform, deadline }) {
343
+ // Where this session's pin lives, resolved once and reused at every write
344
+ // below. Beside `paths.root`, never inside it: see `managerRootFor`.
345
+ const pinRoot = managerRootFor(context.dataHome);
310
346
  // A repeated start refreshes the client's version/platform. Remove the old
311
347
  // certified facts before any probe, PID lookup, resume, or open can fail;
312
348
  // keep only the generation identity needed for a successful resume.
313
349
  if (binding !== null) {
350
+ const facts = await runtimeFacts(import.meta.url);
314
351
  await storeSessionBinding({ runtimeDir: paths.root, deadlineAt: deadline, harnessSessionId: event.sessionId,
315
- accSessionId: binding.accSessionId, generation: binding.generation });
352
+ accSessionId: binding.accSessionId, generation: binding.generation, ...facts });
353
+ await writePin({ root: pinRoot, harnessSessionId: event.sessionId, runtimeRoot: facts.runtimeRoot,
354
+ version: facts.version, storeVersion: facts.storeVersion });
316
355
  }
317
356
  const clientVersion = await probeClientVersion(adapter,
318
357
  { timeoutMs: Math.max(1, Math.min(1_000, deadline - Date.now())) });
@@ -357,8 +396,11 @@ const HANDLERS = {
357
396
  if (resumed !== null) {
358
397
  const hookBinding = { accSessionId: resumed.sessionId, generation: resumed.generation,
359
398
  ...clientFacts, clientPid };
399
+ const facts = await runtimeFacts(import.meta.url);
360
400
  await storeSessionBinding({ runtimeDir: paths.root, deadlineAt: deadline, harnessSessionId: event.sessionId,
361
- ...hookBinding });
401
+ ...hookBinding, ...facts });
402
+ await writePin({ root: pinRoot, harnessSessionId: event.sessionId, runtimeRoot: facts.runtimeRoot,
403
+ version: facts.version, storeVersion: facts.storeVersion, clientPid });
362
404
  return { accSessionId: resumed.sessionId, generation: resumed.generation,
363
405
  ...clientFacts, capabilities, nativeBinding: await native(hookBinding) };
364
406
  }
@@ -368,8 +410,11 @@ const HANDLERS = {
368
410
  // happened (or it was closed), a retry allocates a NEW pair, never revives it.
369
411
  const opening = { sessionId: context.service.ids.next("session"),
370
412
  generation: context.service.ids.next("generation") };
413
+ const openingFacts = await runtimeFacts(import.meta.url);
371
414
  await storeSessionBinding({ runtimeDir: paths.root, deadlineAt: deadline, harnessSessionId: event.sessionId,
372
- accSessionId: opening.sessionId, generation: opening.generation });
415
+ accSessionId: opening.sessionId, generation: opening.generation, ...openingFacts });
416
+ await writePin({ root: pinRoot, harnessSessionId: event.sessionId, runtimeRoot: openingFacts.runtimeRoot,
417
+ version: openingFacts.version, storeVersion: openingFacts.storeVersion, clientPid });
373
418
  const session = await context.service.openSession({
374
419
  ...opening,
375
420
  workspaceId: context.descriptor.id,
@@ -387,8 +432,11 @@ const HANDLERS = {
387
432
  });
388
433
  const hookBinding = { accSessionId: session.sessionId, generation: session.generation,
389
434
  ...clientFacts, clientPid };
435
+ const openedFacts = await runtimeFacts(import.meta.url);
390
436
  await storeSessionBinding({ runtimeDir: paths.root, deadlineAt: deadline, harnessSessionId: event.sessionId,
391
- ...hookBinding });
437
+ ...hookBinding, ...openedFacts });
438
+ await writePin({ root: pinRoot, harnessSessionId: event.sessionId, runtimeRoot: openedFacts.runtimeRoot,
439
+ version: openedFacts.version, storeVersion: openedFacts.storeVersion, clientPid });
392
440
  return { accSessionId: session.sessionId, generation: session.generation,
393
441
  ...clientFacts, capabilities, nativeBinding: await native(hookBinding) };
394
442
  },
@@ -415,6 +463,7 @@ const HANDLERS = {
415
463
  await context.service.clearDeliveryBinding(owner);
416
464
  }
417
465
  await clearSessionBinding({ runtimeDir: paths.root, deadlineAt: deadline, harnessSessionId: event.sessionId });
466
+ await clearPin({ root: managerRootFor(context.dataHome), harnessSessionId: event.sessionId });
418
467
  const diagnosticDeadline = nativeDiagnosticDeadline(deadline);
419
468
  if (diagnosticDeadline !== null) {
420
469
  await clearNativeAttempt({ runtimeDir: paths.root, harnessSessionId: event.sessionId,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.4.4",
3
+ "version": "0.5.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/mcp-server",
3
- "version": "0.4.4",
3
+ "version": "0.5.1",
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.4.4",
3
+ "version": "0.5.1",
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.4.4",
3
+ "version": "0.5.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,11 +1,12 @@
1
- import { lstat, mkdir, realpath } from "node:fs/promises";
1
+ import { lstat, mkdir, realpath, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
4
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
5
5
 
6
6
  // Ported from the reconciled prototype without semantic change. Every managed
7
7
  // path is validated segment by segment against the canonical root, so a
8
- // symlinked ancestor cannot redirect a read or a publication outside the store.
8
+ // symlinked ancestor cannot redirect a read or a publication - neither outside
9
+ // the store, nor to another directory inside it.
9
10
  function invalidDirectory(message, directory, root, cause) {
10
11
  return new AccError(EXIT.DATA, message, {
11
12
  directory,
@@ -60,17 +61,50 @@ async function inspectManagedDirectory(rootPath, directoryPath, create) {
60
61
  let details = await inspectRealDirectory(root, root, create);
61
62
  const canonicalRoot = await realpath(root);
62
63
  let current = root;
64
+ // The canonical location of the directory validated so far. Every segment
65
+ // has to be a child of it, which carries containment in the canonical root
66
+ // down the whole walk by induction.
67
+ let canonicalParent = canonicalRoot;
63
68
  for (const segment of relative.split(path.sep).filter(Boolean)) {
64
69
  current = path.join(current, segment);
65
70
  details = await inspectRealDirectory(current, root, create);
66
- const expected = path.join(canonicalRoot, path.relative(root, current));
67
- if (await realpath(current) !== expected) {
71
+ // realpath answers with the name the directory carries *now*, which is not
72
+ // always the name that was asked for: on Darwin it resolves by opening the
73
+ // path and asking the kernel for that vnode's current path, so a directory
74
+ // renamed between those two steps comes back under its new name. Renaming
75
+ // managed directories is ordinary here - the writer lock is granted,
76
+ // reclaimed and released entirely by rename - so demanding the exact name
77
+ // back reported an escape for an in-store move, intermittently and only
78
+ // under load, and took real writes down with it.
79
+ //
80
+ // What a move cannot change is where the directory lives. An ancestor
81
+ // replaced by a symlink after it was checked resolves somewhere else
82
+ // entirely, so the parent is the thing to hold fixed: this segment must be
83
+ // a child of the directory the previous one validated. Moving the leaf is
84
+ // then the only disagreement left, and that one is settled by identity -
85
+ // a moved directory is the same vnode under another name, while a sibling
86
+ // swapped in under this name is not. ENOENT on the re-stat means it moved
87
+ // again between the two calls, which cannot redirect anything: whatever
88
+ // the caller opens through this path next fails the same way.
89
+ const resolved = await realpath(current);
90
+ if (path.dirname(resolved) !== canonicalParent
91
+ || (path.basename(resolved) !== segment
92
+ && !await stillTheSameDirectory(details, resolved, current, root))) {
68
93
  throw invalidDirectory("managed directory escapes the canonical store root", current, root);
69
94
  }
95
+ canonicalParent = resolved;
70
96
  }
71
97
  return { directory, stat: details };
72
98
  }
73
99
 
100
+ async function stillTheSameDirectory(details, resolved, current, root) {
101
+ const served = await stat(resolved).then(found => found, error => {
102
+ if (error.code === "ENOENT") return null;
103
+ throw invalidDirectory("cannot inspect managed directory", current, root, error.message);
104
+ });
105
+ return served === null || (served.dev === details.dev && served.ino === details.ino);
106
+ }
107
+
74
108
  export async function assertManagedDirectory(rootPath, directoryPath) {
75
109
  return inspectManagedDirectory(rootPath, directoryPath, false);
76
110
  }
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.4.4",
3
+ "version": "0.5.1",
4
4
  "accManagedUpdateProtocol": 2,
5
+ "accStoreVersion": 6,
5
6
  "type": "module",
6
7
  "description": "Local-first coordination for independently opened AI agent sessions.",
7
8
  "keywords": [