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
@@ -11,6 +11,19 @@ const marker = (source, prefix) => source.startsWith(prefix) ? source.slice(pref
11
11
  // Permission selection, proxy and network grants form one ownership unit. If
12
12
  // any component changes, none can be removed independently: enabled networking
13
13
  // without its proxy has a different security meaning to Codex.
14
+ //
15
+ // A block records the table it was written inside, but that table only carries
16
+ // meaning for a block whose first declaration is a bare assignment: such an
17
+ // assignment belongs to whatever table precedes it, so a moved block would
18
+ // silently mean something else. A block that opens with its own header states
19
+ // its full path and is unaffected by what sits above it.
20
+ //
21
+ // The distinction is not theoretical. ACC appends the profile block at end of
22
+ // file and then tells the user to trust ACC's hooks in Codex - and Codex writes
23
+ // those trust entries as new `hooks.state` tables above the block. Comparing
24
+ // the enclosing table unconditionally turned ACC's own documented setup step
25
+ // into `customized`: doctor then reported working permissions as unverified,
26
+ // install refused to repair them, and uninstall would not remove them.
14
27
  export function inspectPermissions(source) {
15
28
  const blocks = [];
16
29
  let open = null, metadata = null;
@@ -21,7 +34,8 @@ export function inspectPermissions(source) {
21
34
  if (open || !/^[a-z]+$/.test(start) || blocks.some(block => block.id === start)) {
22
35
  unsafe("duplicate or nested native permission marker");
23
36
  }
24
- open = { id: start, start: entry.start, table: entry.table, contentStart: entry.end };
37
+ open = { id: start, start: entry.start, table: entry.table, contentStart: entry.end,
38
+ opens: null };
25
39
  } else if (comment.startsWith(META)) {
26
40
  if (!open || open.id !== "selection" || metadata !== null || entry.start !== open.contentStart) {
27
41
  unsafe("misplaced permission metadata");
@@ -29,6 +43,8 @@ export function inspectPermissions(source) {
29
43
  try { metadata = JSON.parse(comment.slice(META.length)); }
30
44
  catch { unsafe("invalid permission metadata"); }
31
45
  open.contentStart = entry.end;
46
+ } else if (open && entry.code && open.opens === null) {
47
+ open.opens = entry.header ? "table" : "assignment";
32
48
  } else if (end !== null) {
33
49
  if (!open || open.id !== end) unsafe("unmatched native permission marker");
34
50
  blocks.push({ ...open, end: entry.end, body: source.slice(open.contentStart, entry.start) });
@@ -45,7 +61,8 @@ export function inspectPermissions(source) {
45
61
  if (!record || Object.keys(record).sort().join() !== "before,hash,id,table"
46
62
  || typeof record.before !== "string" || record.hash !== hash(block.body, record)
47
63
  || (block.id === "selection" && block.table.length !== 0)
48
- || JSON.stringify(block.table) !== JSON.stringify(record.table)) {
64
+ || (block.opens === "assignment"
65
+ && JSON.stringify(block.table) !== JSON.stringify(record.table))) {
49
66
  return { source, state: "customized" };
50
67
  }
51
68
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.4.4",
3
+ "version": "0.5.1",
4
4
  "description": "Coordinate this Gemini CLI session with other AI agent sessions working in the same workspace.",
5
5
  "contextFileName": "skills/acc/SKILL.md"
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-gemini-cli",
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/adapter-grok",
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/adapter-kimi",
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/adapter-sdk",
3
- "version": "0.4.4",
3
+ "version": "0.5.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -3,7 +3,7 @@
3
3
  export { CAPABILITY_SHAPE, assertCapabilities, defineAdapter } from "./capabilities.mjs";
4
4
  export { effectiveCapabilities, validateCertification } from "./certification.mjs";
5
5
  export { NATIVE_ACTIVATION_KINDS, NATIVE_BINDING_MODES, NATIVE_PLATFORMS, NATIVE_REASON_CODES,
6
- compareStableVersions, evaluateNativeEligibility, parseStableVersion,
6
+ compareStableVersions, evaluateNativeEligibility, evaluateVersionContract, parseStableVersion,
7
7
  validateNativeActivationPlan, validateNativeDeliveryContract, validateNativeHandshake }
8
8
  from "./native-delivery.mjs";
9
9
  export { EVENT_KINDS, NORMALIZED_EVENT_KEYS, normalizedEvent } from "./events.mjs";
@@ -102,19 +102,45 @@ function knownBadHit(contract, version) {
102
102
  : compareStableVersions(version, entry.from) >= 0 && compareStableVersions(version, entry.to) <= 0));
103
103
  }
104
104
 
105
- // The static half of the rule: platform, minimum, prerelease, and denylist.
106
- function evaluateStatic(adapter, { clientVersion, platform }) {
105
+ // Judges one reported client version against an adapter's captured
106
+ // native-delivery contract: is the platform captured, is the version at or
107
+ // above the captured minimum, and is it not on the denylist. Returns
108
+ // { reasonCode, minimumVersion, protocolContract }; reasonCode is null when
109
+ // the version satisfies the contract, otherwise one of
110
+ // "native_delivery_unsupported", "platform_not_captured",
111
+ // "version_unavailable", "prerelease_not_captured", "below_minimum_version",
112
+ // or "known_bad_version".
113
+ //
114
+ // This is the static half of the rule only: it never contacts the client, so
115
+ // passing here proves nothing about whether a live probe or handshake
116
+ // actually confirms the protocol or delivery modes. A caller that needs that
117
+ // layers a probe or handshake check on top (see evaluateNativeEligibility,
118
+ // validateNativeHandshake below, and the delivery router's own offer check,
119
+ // which uses this function alone because a response already carries no probe
120
+ // or handshake shape to check further).
121
+ export function evaluateVersionContract(adapter, { clientVersion, platform }) {
107
122
  const contract = adapter?.nativeDelivery;
108
- if (contract === undefined) {
123
+ // `== null` rather than `=== undefined`: a null declaration is as much "no
124
+ // contract" as a missing one, and reading `contract.minimumByPlatform` off it
125
+ // threw the very TypeError the guard below exists to prevent. defineAdapter
126
+ // cannot produce that shape; a hand-built registry entry can.
127
+ if (contract == null) {
109
128
  return { reasonCode: "native_delivery_unsupported", minimumVersion: null, protocolContract: null };
110
129
  }
111
- const minimumVersion = typeof platform === "string"
112
- ? (contract.minimumByPlatform[platform] ?? null) : null;
113
- if (minimumVersion === null) {
114
- return { reasonCode: "platform_not_captured", minimumVersion: null, protocolContract: null };
115
- }
116
- const anchor = contract.anchors.find(item => item.platform === platform
117
- && item.version === minimumVersion);
130
+ const uncaptured = { reasonCode: "platform_not_captured", minimumVersion: null,
131
+ protocolContract: null };
132
+ // validateNativeDeliveryContract guarantees a minimum map and a matching
133
+ // anchor, but this function is also handed adapter objects that never went
134
+ // through it. A declaration missing either half has captured nothing for
135
+ // this platform, which is a closed answer - not a TypeError raised deep
136
+ // inside a delivery offer, far from the declaration that caused it. Every
137
+ // other entry point below already answers malformed input this way.
138
+ if (typeof platform !== "string" || !isPlainObject(contract.minimumByPlatform)) return uncaptured;
139
+ const minimumVersion = contract.minimumByPlatform[platform] ?? null;
140
+ if (minimumVersion === null) return uncaptured;
141
+ const anchor = (Array.isArray(contract.anchors) ? contract.anchors : [])
142
+ .find(item => item.platform === platform && item.version === minimumVersion);
143
+ if (anchor === undefined) return uncaptured;
118
144
  const facts = { minimumVersion, protocolContract: anchor.protocolContract };
119
145
  if (!isText(clientVersion)) return { ...facts, reasonCode: "version_unavailable" };
120
146
  if (parseStableVersion(clientVersion) === null) {
@@ -147,7 +173,12 @@ function validateNativeProbe(probe) {
147
173
  }
148
174
 
149
175
  export function evaluateNativeEligibility(adapter, { clientVersion, platform, probe }) {
150
- const rule = evaluateStatic(adapter, { clientVersion, platform });
176
+ // The probe names the process that will serve the delivery. Judging the
177
+ // detected binary instead refuses a service that satisfies the contract.
178
+ // Only the version is read here; the shape is validated where it always was,
179
+ // so a malformed probe still returns a closed result rather than throwing.
180
+ const serving = isText(probe?.clientVersion) ? probe.clientVersion : clientVersion;
181
+ const rule = evaluateVersionContract(adapter, { clientVersion: serving, platform });
151
182
  const base = { eligible: false, reasonCode: null, minimumVersion: rule.minimumVersion,
152
183
  protocolContract: rule.protocolContract, modes: [] };
153
184
  const closedResult = reasonCode => deepFreeze({ ...base, reasonCode });
@@ -155,7 +186,6 @@ export function evaluateNativeEligibility(adapter, { clientVersion, platform, pr
155
186
  if (probe === null || probe === undefined) return closedResult("feature_probe_failed");
156
187
  const facts = validateNativeProbe(probe);
157
188
  if (facts.supported !== true) return closedResult(facts.reasonCode ?? "feature_probe_failed");
158
- if (facts.clientVersion !== clientVersion) return closedResult("probe_version_mismatch");
159
189
  if (facts.protocolContract !== rule.protocolContract) return closedResult("protocol_mismatch");
160
190
  const modes = orderedModes(facts.modes);
161
191
  if (!modes.includes("livePush")) return closedResult("feature_probe_failed");
@@ -191,19 +221,36 @@ function validateNativeHandshakeShape(handshake) {
191
221
 
192
222
  // The per-session half: the same static rule again, then the adapter's live
193
223
  // handshake facts. The launch-time executable fingerprint stays probe-only.
224
+ //
225
+ // An admitted verdict reports the version it admitted as `clientVersion`. That
226
+ // is the whole point of returning it: the caller publishes a binding, and the
227
+ // binding must record the version the admission actually rests on - the one
228
+ // that will serve - rather than the caller's own detected-CLI claim. 0.5.0
229
+ // judged the serving version here and left the caller publishing its claim, so
230
+ // the two records disagreed on every machine where a daemon had updated under
231
+ // its CLI, which is exactly the case the release exists to support. A refused
232
+ // verdict admits nothing and carries null.
194
233
  export function validateNativeHandshake(adapter, { clientVersion, platform, handshake }) {
195
- const rule = evaluateStatic(adapter, { clientVersion, platform });
196
- const base = { ok: false, reasonCode: null, protocolContract: rule.protocolContract, modes: [],
234
+ // Same rationale as the probe: the handshake names the session that will
235
+ // actually serve, so the static rule is judged against that version. Only
236
+ // the version is read here; the shape is validated where it always was, so
237
+ // a malformed handshake still returns a closed result rather than throwing.
238
+ const serving = isText(handshake?.clientVersion) ? handshake.clientVersion : clientVersion;
239
+ const rule = evaluateVersionContract(adapter, { clientVersion: serving, platform });
240
+ const base = { ok: false, reasonCode: null, clientVersion: null,
241
+ protocolContract: rule.protocolContract, modes: [],
197
242
  opaqueEndpointRef: null, leaseUntil: null };
198
243
  const closedResult = reasonCode => deepFreeze({ ...base, reasonCode });
199
244
  if (rule.reasonCode !== null) return closedResult(rule.reasonCode);
200
245
  if (handshake === null || handshake === undefined) return closedResult("handshake_failed");
201
246
  const facts = validateNativeHandshakeShape(handshake);
202
247
  if (facts.supported !== true) return closedResult(facts.reasonCode ?? "handshake_failed");
203
- if (facts.clientVersion !== clientVersion) return closedResult("handshake_version_mismatch");
204
248
  if (facts.protocolContract !== rule.protocolContract) return closedResult("protocol_mismatch");
205
249
  const modes = orderedModes(facts.modes);
206
250
  if (!modes.includes("livePush")) return closedResult("handshake_failed");
207
- return deepFreeze({ ok: true, reasonCode: null, protocolContract: rule.protocolContract, modes,
251
+ // `serving` is a text version whenever the rule admitted it: every other
252
+ // shape leaves evaluateVersionContract at "version_unavailable" above.
253
+ return deepFreeze({ ok: true, reasonCode: null, clientVersion: serving,
254
+ protocolContract: rule.protocolContract, modes,
208
255
  opaqueEndpointRef: facts.opaqueEndpointRef, leaseUntil: facts.leaseUntil });
209
256
  }
@@ -43,7 +43,7 @@ function assertBindingDeadline(deadlineAt) {
43
43
  }
44
44
 
45
45
  export async function storeSessionBinding({ runtimeDir, harnessSessionId, accSessionId,
46
- generation, clientVersion, platform, clientPid, deadlineAt }) {
46
+ generation, clientVersion, platform, clientPid, deadlineAt, storeVersion, runtimeRoot }) {
47
47
  assertBindingDeadline(deadlineAt);
48
48
  const file = fileFor(runtimeDir, harnessSessionId);
49
49
  const record = { schemaVersion: SCHEMA_VERSION, harnessSessionId, accSessionId, generation };
@@ -62,6 +62,21 @@ export async function storeSessionBinding({ runtimeDir, harnessSessionId, accSes
62
62
  }
63
63
  record.clientPid = clientPid;
64
64
  }
65
+ // Runtime facts, kept separate from `generation`, which names the ACC session
66
+ // generation and must not be overloaded with a runtime identity.
67
+ if (storeVersion !== undefined && storeVersion !== null) {
68
+ if (!Number.isSafeInteger(storeVersion) || storeVersion <= 0) {
69
+ throw new AccError(EXIT.USAGE, "session binding storeVersion must be a positive integer",
70
+ { storeVersion });
71
+ }
72
+ record.storeVersion = storeVersion;
73
+ }
74
+ if (runtimeRoot !== undefined && runtimeRoot !== null) {
75
+ if (typeof runtimeRoot !== "string" || runtimeRoot === "") {
76
+ throw new AccError(EXIT.USAGE, "session binding runtimeRoot must be a non-empty string", {});
77
+ }
78
+ record.runtimeRoot = runtimeRoot;
79
+ }
65
80
  const temporary = `${file}.${process.pid}.tmp`;
66
81
  await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, "utf8");
67
82
  // Replace rather than append: re-attaching supersedes the old generation, and
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/cli",
3
- "version": "0.4.4",
3
+ "version": "0.5.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -232,7 +232,13 @@ export async function runDoctor({ options, context, runtime }) {
232
232
  const service = context.service ?? await context.openService();
233
233
  const status = await service.collectStatus({});
234
234
  updateNativeRuntime(adapters, status.deliveryBindings);
235
- await updateNativeSessions(adapters, { service, status, root, now: clock.now() });
235
+ // The runtime state above is read from the binding records alone. The
236
+ // session pass then asks each adapter whether the receiver those records
237
+ // name can still take a push, and downgrades anything that only looked
238
+ // active. It needs the adapter objects to ask, which the diagnostic entries
239
+ // do not carry.
240
+ await updateNativeSessions(adapters, { service, status, root, now: clock.now(),
241
+ registry: ALL_ADAPTERS() });
236
242
  for (const adapter of adapters) {
237
243
  adapter.remediation.push(...nativeRemediation(adapter));
238
244
  }
@@ -9,3 +9,8 @@ export { discoverWorkspace } from "./workspace-discovery.mjs";
9
9
  export { createGitProbe, hermeticEnv } from "./git-probe.mjs";
10
10
  export { platformDataHome, runtimePaths } from "./runtime-paths.mjs";
11
11
  export { platformPaths } from "./platform-paths.mjs";
12
+ // A pin names the generation a session started with, kept beside its binding
13
+ // so a hook can be made to keep loading that generation once a newer one
14
+ // activates.
15
+ export { clearPin, hookEntrypointFor, readPin, reapPins, resolvePinnedGeneration, writePin }
16
+ from "./managed-runtime/pins.mjs";
@@ -1,12 +1,22 @@
1
1
  import { createHash } from "node:crypto";
2
- import { readdir } from "node:fs/promises";
2
+ import { readdir, rm } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { assertPortableId } from "@agents-can-communicate/protocol";
6
6
  import { readSessionRecord } from "@agents-can-communicate/storage-filesystem";
7
7
  import { listRuntimeHolds } from "./leases.mjs";
8
8
  import { confirmedDead, defaultPidIsAlive, withManagerLock } from "./mutex.mjs";
9
- import { canonicalManagerRoot, managedDirectory, readControl, readManagedJson, writeControl } from "./state.mjs";
9
+ import { reapPins } from "./pins.mjs";
10
+ import { reapStagingHolds } from "./staging.mjs";
11
+ import { canonicalManagerRoot, managedDirectory, readControl, readManagedJson, syncDirectory, writeControl } from "./state.mjs";
12
+
13
+ /** The staged generation states its own contract in its manifest, so the
14
+ * manager never imports code from a generation it has not activated. */
15
+ export async function declaredStoreVersion(generationRoot) {
16
+ const manifest = await readManagedJson(path.join(generationRoot, "package.json")).catch(() => null);
17
+ return Number.isSafeInteger(manifest?.accStoreVersion) && manifest.accStoreVersion > 0
18
+ ? manifest.accStoreVersion : null;
19
+ }
10
20
 
11
21
  // Both 0.3.1 and current MCP write this exact continuity record, without
12
22
  // native client facts. A foreign harness id may look like an MCP key: require
@@ -47,36 +57,60 @@ export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive } =
47
57
  const pid = record?.schemaVersion === 1 && Number.isSafeInteger(record.clientPid)
48
58
  && record.clientPid > 0 ? record.clientPid : null;
49
59
  if (pid !== null && await confirmedDead(pid, pidIsAlive)) continue;
50
- holds.push({ kind: "native", pid, reason: pid === null ? "unknown_client_pid" : "client_running" });
60
+ holds.push({ kind: "native", pid, reason: pid === null ? "unknown_client_pid" : "client_running",
61
+ storeVersion: Number.isSafeInteger(record?.storeVersion) ? record.storeVersion : null });
51
62
  }
52
63
  }
53
64
  return holds;
54
65
  }
55
66
 
67
+ /** Unknown on either side cannot be compared, and an uncomparable contract
68
+ * stays a hold. Equal contracts coexist by the store's own rule. */
69
+ export const blocksActivation = (hold, incomingStoreVersion) =>
70
+ !Number.isSafeInteger(incomingStoreVersion) || !Number.isSafeInteger(hold.storeVersion)
71
+ || hold.storeVersion !== incomingStoreVersion;
72
+
56
73
  /** Activation calls this under the admission mutex; diagnostics are a snapshot.
57
74
  * The updater may ignore its own leases, never a native client's lifetime.
58
75
  * Project only process facts: leases and bindings carry private owner tokens.
76
+ * A hold blocks only when its declared contract differs from the incoming
77
+ * generation's, or when either side declares none.
59
78
  */
60
- export async function listActivationBlockers(root, { pidIsAlive = defaultPidIsAlive, ignorePid = null } = {}) {
79
+ export async function listActivationBlockers(root, { pidIsAlive = defaultPidIsAlive,
80
+ ignorePid = null, incomingStoreVersion = null } = {}) {
61
81
  const leases = (await listRuntimeHolds(root, { pidIsAlive })).filter(lease => lease.pid !== ignorePid);
62
82
  const native = await listNativeHolds(root, { pidIsAlive });
63
- const holds = [...leases.map(({ pid, kind }) => ({ pid, kind, nativeBindings: 0 })),
64
- ...native.map(hold => ({ ...hold, nativeBindings: hold.reason === "unknown_workspace" ? 0 : 1 }))];
83
+ const holds = [
84
+ ...leases.map(({ pid, kind, runtime }) => ({ pid, kind, nativeBindings: 0,
85
+ storeVersion: runtime?.storeVersion ?? null })),
86
+ ...native.map(hold => ({ ...hold, nativeBindings: hold.reason === "unknown_workspace" ? 0 : 1,
87
+ storeVersion: hold.storeVersion ?? null })),
88
+ ];
65
89
  const byPid = new Map(), unidentified = [];
66
90
  for (const hold of holds) {
67
91
  const pid = hold.pid ?? null;
68
92
  const nativeBindings = hold.nativeBindings;
69
93
  if (pid === null) {
70
- unidentified.push({ pid, kinds: [hold.kind], reason: hold.reason, nativeBindings });
94
+ unidentified.push({ pid, kinds: [hold.kind], reason: hold.reason, nativeBindings,
95
+ storeVersion: hold.storeVersion });
71
96
  continue;
72
97
  }
73
- const blocker = byPid.get(pid) ?? { pid, kinds: [], reason: "process_running", nativeBindings: 0 };
98
+ const blocker = byPid.get(pid) ?? { pid, kinds: [], reason: "process_running",
99
+ nativeBindings: 0, storeVersion: hold.storeVersion };
74
100
  if (!blocker.kinds.includes(hold.kind)) blocker.kinds.push(hold.kind);
75
101
  blocker.nativeBindings += nativeBindings;
102
+ // One process can publish several holds. Only an unbroken run of holds
103
+ // declaring the same contract may keep it for the whole process; an
104
+ // unknown contract, or one that differs from what has already
105
+ // accumulated, collapses the process to uncomparable for good.
106
+ if (!Number.isSafeInteger(hold.storeVersion) || hold.storeVersion !== blocker.storeVersion) {
107
+ blocker.storeVersion = null;
108
+ }
76
109
  byPid.set(pid, blocker);
77
110
  }
78
111
  return [...byPid.values()].sort((a, b) => a.pid - b.pid)
79
- .map(blocker => ({ ...blocker, kinds: blocker.kinds.sort() })).concat(unidentified);
112
+ .map(blocker => ({ ...blocker, kinds: blocker.kinds.sort() })).concat(unidentified)
113
+ .filter(blocker => blocksActivation(blocker, incomingStoreVersion));
80
114
  }
81
115
 
82
116
  export function activationBlockerNotice(version, blockers) {
@@ -84,6 +118,8 @@ export function activationBlockerNotice(version, blockers) {
84
118
  const details = blockers.slice(0, 10).map(blocker => {
85
119
  const facts = [blocker.kinds.map(safeText).join(", ")];
86
120
  if (blocker.nativeBindings) facts.push(`${blocker.nativeBindings} native binding${blocker.nativeBindings === 1 ? "" : "s"}`);
121
+ facts.push(Number.isSafeInteger(blocker.storeVersion)
122
+ ? `store contract ${blocker.storeVersion}` : "contract unknown");
87
123
  if (blocker.pid === null) facts.push(blocker.reason.replaceAll("_", " "));
88
124
  return `${blocker.pid === null ? "unidentified process" : `PID ${blocker.pid}`} (${facts.join("; ")})`;
89
125
  });
@@ -91,6 +127,133 @@ export function activationBlockerNotice(version, blockers) {
91
127
  + details.join("; ") + (blockers.length > 10 ? `; and ${blockers.length - 10} more` : "") + ".";
92
128
  }
93
129
 
130
+ /** A candidate reference that is simply absent (no pending update, no
131
+ * explicit active override) is normal. One that is present but cannot be
132
+ * resolved to a real filesystem path is unknown - possibly corrupt data -
133
+ * and must postpone the whole pass rather than be silently ignored. Every
134
+ * candidate is resolved the same way a `generations/<name>` entry is, so a
135
+ * symlinked data home can never make a live reference look unrelated to the
136
+ * directory it actually names.
137
+ */
138
+ async function resolveCandidate(value) {
139
+ if (value === null || value === undefined) return { ok: true, root: null };
140
+ if (typeof value !== "string" || value === "") return { ok: false };
141
+ try { return { ok: true, root: await canonicalManagerRoot(value) }; }
142
+ catch { return { ok: false }; }
143
+ }
144
+
145
+ /** A generation is reclaimable only when nothing can still import from it:
146
+ * not the active or pending control pointer, not a live runtime lease, not a
147
+ * live session pin, and not a live staging hold for a generation still being
148
+ * verified before publication. An unidentified holder - an unreadable lease,
149
+ * a pin or staging hold that is missing, corrupt, or declares an
150
+ * unresolvable root, or the absence of control.json itself - postpones the
151
+ * whole pass rather than being assumed dead, matching the rule admission
152
+ * already applies to its own bookkeeping.
153
+ *
154
+ * Takes its own manager lock, so a caller already holding one (activation)
155
+ * must call this only after releasing it.
156
+ */
157
+ export async function reclaimGenerations({ root, active = null, pidIsAlive = defaultPidIsAlive } = {}) {
158
+ root = await canonicalManagerRoot(root);
159
+ return withManagerLock(root, async () => {
160
+ const generations = path.join(root, "generations");
161
+ if (!await managedDirectory(generations)) return { removed: [] };
162
+ // Candidates are captured before any holder is read, not after. A hold
163
+ // (or a control pointer) that lands later either names a directory this
164
+ // snapshot never saw - so it was never a deletion candidate regardless -
165
+ // or was already durable before this snapshot was taken, which, since
166
+ // every writer here writes its hold before the rename that *creates*
167
+ // the directory, guarantees it is already visible to every holder read
168
+ // below. Reading candidates last is exactly the window that left a
169
+ // staged-but-unpublished generation deletable; reading them first closes
170
+ // it for good, not just for the writer order this codebase happens to use.
171
+ //
172
+ // That guarantee is specifically about a directory the rename path
173
+ // creates. stageOwnGeneration's fast path (existingMatches) instead
174
+ // *adopts* a directory that already existed - from an earlier run,
175
+ // possibly one whose own hold is long gone - and only then writes this
176
+ // attempt's own hold for it. Such a directory can already be in this
177
+ // snapshot with no hold covering it yet, so a reclaim pass whose staging
178
+ // read happens to run before that adopting hold lands can still remove
179
+ // it. This is bounded, not open-ended: it can only affect a generation
180
+ // that is at that moment unreferenced by control, lease, and pin alike -
181
+ // the same shape as any other orphan - and it predates this reordering
182
+ // fix rather than being introduced by it.
183
+ const candidates = await readdir(generations, { withFileTypes: true });
184
+ const control = await readControl(root);
185
+ // No control at all is exactly as unknown as a corrupt one - which
186
+ // readControl already throws on, and every real caller of this function
187
+ // treats that throw as reason to postpone. Match that here explicitly:
188
+ // an absent control.json is not proof there is nothing to protect.
189
+ if (control === null) return { removed: [] };
190
+ const referenced = new Set();
191
+ for (const candidate of [active, control.active.root, control.pending?.root]) {
192
+ const resolved = await resolveCandidate(candidate);
193
+ if (!resolved.ok) return { removed: [] };
194
+ if (resolved.root) referenced.add(resolved.root);
195
+ }
196
+ let holds;
197
+ try { holds = await listRuntimeHolds(root, { pidIsAlive }); }
198
+ catch { return { removed: [] }; } // An unreadable lease is an unknown holder.
199
+ for (const lease of holds) {
200
+ const resolved = await resolveCandidate(lease.runtime?.root);
201
+ if (!resolved.ok) return { removed: [] };
202
+ if (resolved.root) referenced.add(resolved.root);
203
+ }
204
+ // A client that exits without a clean session end leaves its pin behind;
205
+ // reap confirmed-dead ones before treating every remaining pin as live,
206
+ // the same way admission already reaps confirmed-dead leases.
207
+ await reapPins({ root, pidIsAlive });
208
+ const pins = path.join(root, "pins");
209
+ if (await managedDirectory(pins)) {
210
+ for (const name of await readdir(pins)) {
211
+ if (!name.endsWith(".json")) continue;
212
+ const record = await readManagedJson(path.join(pins, name)).catch(() => null);
213
+ if (record === undefined) continue; // Concurrent pin removal, not a holder.
214
+ if (!record || record.schemaVersion !== 1 || typeof record.runtimeRoot !== "string"
215
+ || record.runtimeRoot === "") return { removed: [] }; // Malformed pin: unknown holder.
216
+ const resolved = await resolveCandidate(record.runtimeRoot);
217
+ if (!resolved.ok) return { removed: [] };
218
+ referenced.add(resolved.root);
219
+ }
220
+ }
221
+ // Staging renames a fully-formed generation into place, then runs it as a
222
+ // spawned process to verify it, before any control pointer, lease, or pin
223
+ // can reference it. A live hold names exactly the generation that step
224
+ // is building; a staging process that exited without publishing leaves
225
+ // its hold behind, reaped the same way an abandoned pin is.
226
+ await reapStagingHolds({ root, pidIsAlive });
227
+ const staging = path.join(root, "staging");
228
+ if (await managedDirectory(staging)) {
229
+ for (const name of await readdir(staging)) {
230
+ if (!name.endsWith(".json")) continue;
231
+ const record = await readManagedJson(path.join(staging, name)).catch(() => null);
232
+ if (record === undefined) continue; // Concurrent hold release, not a holder.
233
+ if (!record || record.schemaVersion !== 1 || typeof record.generationRoot !== "string"
234
+ || record.generationRoot === "") return { removed: [] }; // Malformed hold: unknown holder.
235
+ const resolved = await resolveCandidate(record.generationRoot);
236
+ if (!resolved.ok) return { removed: [] };
237
+ referenced.add(resolved.root);
238
+ }
239
+ }
240
+ const removed = [];
241
+ for (const entry of candidates) {
242
+ // A dot-prefixed entry is always an in-flight staging temp (mkdtemp's
243
+ // own naming), never a published generation. Its own creator cleans it
244
+ // up; guessing whether it is still being written into is not this
245
+ // function's job, and deleting one mid-write would corrupt that write.
246
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
247
+ const directory = path.join(generations, entry.name);
248
+ if (referenced.has(directory)) continue;
249
+ await rm(directory, { recursive: true, force: true });
250
+ removed.push(entry.name);
251
+ }
252
+ if (removed.length) await syncDirectory(generations);
253
+ return { removed };
254
+ }, { pidIsAlive });
255
+ }
256
+
94
257
  async function prepareCandidate(control, root, env) {
95
258
  const file = path.join(control.pending.root, "node_modules", "@agents-can-communicate", "cli",
96
259
  "src", "managed-runtime", "refresh.mjs");
@@ -114,11 +277,13 @@ export async function activatePending(root, { prepare = prepareCandidate, env =
114
277
  const before = await readControl(root);
115
278
  if (!before?.pending) return { activated: false, reason: "no_pending_update" };
116
279
  const apply = await prepare(before, root, env);
117
- return withManagerLock(root, async () => {
280
+ let activatedRoot = null;
281
+ const outcome = await withManagerLock(root, async () => {
118
282
  const current = await readControl(root);
119
283
  if (!current || recipe(current) !== recipe(before)) return { activated: false, reason: "state_changed" };
120
284
  if (beforeActivation) await beforeActivation(current);
121
- const blockers = await listActivationBlockers(root, { pidIsAlive, ignorePid });
285
+ const blockers = await listActivationBlockers(root, { pidIsAlive, ignorePid,
286
+ incomingStoreVersion: current.pending.storeVersion });
122
287
  if (blockers.length) {
123
288
  const notice = activationBlockerNotice(current.pending.version, blockers);
124
289
  await writeControl(root, { ...current, notice });
@@ -143,6 +308,17 @@ export async function activatePending(root, { prepare = prepareCandidate, env =
143
308
  }
144
309
  await writeControl(root, { ...activating, active: current.pending, pending: null,
145
310
  phase: "ready", notice: `ACC ${current.pending.version} is active.` });
311
+ activatedRoot = current.pending.root;
146
312
  return { activated: true, version: current.pending.version, installation: result };
147
313
  });
314
+ // Reclaim only after the fence has released and the new pointer is durable:
315
+ // reclaimGenerations takes its own manager lock, so calling it while still
316
+ // inside this one would deadlock, and a reclaim failure here must never
317
+ // turn an already-completed activation into a reported failure - it can
318
+ // always run again on the next activation.
319
+ if (outcome.activated) {
320
+ try { await reclaimGenerations({ root, active: activatedRoot, pidIsAlive }); }
321
+ catch { /* Best effort; never fails an activation that already succeeded. */ }
322
+ }
323
+ return outcome;
148
324
  }
@@ -17,7 +17,8 @@ export async function runManagementDoctor({ runtime }) {
17
17
  }
18
18
 
19
19
  export async function managedUpdateDiagnostic(root, manager, running) {
20
- const blockers = manager.pending ? await listActivationBlockers(root, { ignorePid: process.pid }) : null;
20
+ const blockers = manager.pending ? await listActivationBlockers(root, { ignorePid: process.pid,
21
+ incomingStoreVersion: manager.pending.storeVersion }) : null;
21
22
  const update = { checked: false, running, latest: manager.pending?.version ?? null,
22
23
  newer: manager.pending !== null, auto: manager.auto, pin: manager.pin,
23
24
  pending: manager.pending?.version ?? null, phase: manager.phase,
@@ -2,9 +2,11 @@ import { execFile } from "node:child_process";
2
2
  import { access, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { promisify } from "node:util";
5
+ import { declaredStoreVersion } from "./activation.mjs";
5
6
  import { stageOwnGeneration } from "./generation.mjs";
6
7
  import { verifyGeneration } from "./install.mjs";
7
8
  import { managedDirectory } from "./state.mjs";
9
+ import { releaseStagingHold } from "./staging.mjs";
8
10
  import { ENTRY_KINDS } from "./entry.mjs";
9
11
 
10
12
  const NAME = "agents-can-communicate";
@@ -65,7 +67,20 @@ export async function downloadRelease(root, release, { env = process.env } = {})
65
67
  for (const name of ENTRY_KINDS) await access(path.join(packageRoot, "bin", "entrypoints", `${name}.mjs`));
66
68
  await access(path.join(packageRoot, "bin", "acc-update-worker.mjs"));
67
69
  const generation = await stageOwnGeneration({ packageRoot, managerRoot: root });
68
- await verifyGeneration(generation, { env });
69
- return { version: generation.version, root: generation.root };
70
+ // A failed verify, or any other failure before this function actually
71
+ // returns, means this candidate is abandoned here, so release its hold
72
+ // now - declaredStoreVersion included, so a future change to it (or any
73
+ // other exception in this region) cannot silently leak the hold instead
74
+ // of releasing it. Only a clean return hands the hold on to the caller
75
+ // (worker.mjs), since publication happens later there, and it is
76
+ // responsible for releasing it once that publish attempt resolves.
77
+ try {
78
+ await verifyGeneration(generation, { env });
79
+ return { version: generation.version, root: generation.root,
80
+ storeVersion: await declaredStoreVersion(generation.root), hold: generation.hold };
81
+ } catch (error) {
82
+ await releaseStagingHold(generation.hold);
83
+ throw error;
84
+ }
70
85
  } finally { await rm(temporary, { recursive: true, force: true }); }
71
86
  }